12 Commits
Author SHA1 Message Date
Dmitrii 9238069d0e Refactor JWT configuration handling in Program.cs and TokenService.cs
Java CI / build (push) Successful in 48s
.NET+Docker CI/CD / Unit and Integration tests (push) Successful in 34s
Release CI / build-and-upload-mod (push) Successful in 7s
Release CI / build-and-upload-mod (release) Successful in 8s
.NET+Docker CI/CD / Push Docker image to ghcr.io (push) Failing after 27s
Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 23 minutes
2026-06-29 05:00:55 +03:00
Dmitrii 8516dce2ee Change from dev-domain to prod-domain
Java CI / build (push) Successful in 40s
.NET+Docker CI/CD / Unit and Integration tests (push) Successful in 32s
.NET+Docker CI/CD / Push Docker image to ghcr.io (push) Failing after 18s
Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 3 minutes
2026-06-29 04:04:20 +03:00
Dmitrii 7475a4ff32 Remove appsettings file from not-optionals
Java CI / build (push) Successful in 49s
.NET+Docker CI/CD / Unit and Integration tests (push) Successful in 37s
.NET+Docker CI/CD / Push Docker image to ghcr.io (push) Failing after 26s
Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 4 minutes
2026-06-29 04:00:36 +03:00
Dmitrii 254499b49a Update Docker action version in workflow file
Java CI / build (push) Successful in 47s
.NET+Docker CI/CD / Unit and Integration tests (push) Successful in 32s
.NET+Docker CI/CD / Push Docker image to ghcr.io (push) Failing after 1m23s
Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 4 minutes
2026-06-29 03:52:16 +03:00
Dmitrii 98bf51eb4c Fix old and misstyped command to restore .net project
Java CI / build (push) Successful in 45s
.NET+Docker CI/CD / Unit and Integration tests (push) Successful in 31s
.NET+Docker CI/CD / Push Docker image to ghcr.io (push) Failing after 1m19s
Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 1 minute
2026-06-29 03:46:44 +03:00
Dmitrii 5858cc5db0 Fix dependency list
Java CI / build (push) Successful in 43s
.NET+Docker CI/CD / Unit and Integration tests (push) Failing after 28s
.NET+Docker CI/CD / Push Docker image to ghcr.io (push) Has been skipped
Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 3 minutes
2026-06-29 03:45:30 +03:00
Dmitrii 23291a1932 Update AgreementPage and EncryptionHelper for improved data handling and clarity
Java CI / build (push) Successful in 40s
.NET+Docker CI/CD / Unit and Integration tests (push) Failing after 23s
.NET+Docker CI/CD / Push Docker image to ghcr.io (push) Has been skipped
Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 2 minutes
2026-06-29 03:42:50 +03:00
Dmitrii 9f0209af6c fix workflow file
Java CI / build (push) Successful in 45s
.NET+Docker CI/CD / Unit and Integration tests (push) Failing after 25s
.NET+Docker CI/CD / Push Docker image to ghcr.io (push) Has been skipped
Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 2 minutes
2026-06-29 03:36:45 +03:00
Dmitrii 0841f51a10 Enhance transaction fetching with pagination support and improved error handling
Java CI / build (push) Successful in 45s
.NET+Docker CI/CD / Unit and Integration tests (push) Failing after 27s
.NET+Docker CI/CD / Push Docker image to ghcr.io (push) Has been skipped
Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 22 minutes
2026-06-29 03:34:36 +03:00
Dmitrii 77e525b890 Refactor card management to support asynchronous operations and improve error handling
Java CI / build (push) Successful in 47s
.NET+Docker CI/CD / Unit and Integration tests (push) Failing after 23s
.NET+Docker CI/CD / Push Docker image to ghcr.io (push) Has been skipped
Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 3 minutes
2026-06-29 03:03:59 +03:00
Dmitrii 25929286c6 Add AgreementPage and EncryptionHelper for transaction handling
Java CI / build (push) Successful in 48s
.NET+Docker CI/CD / Unit and Integration tests (push) Failing after 24s
.NET+Docker CI/CD / Push Docker image to ghcr.io (push) Has been skipped
Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 24 seconds
2026-06-29 03:01:17 +03:00
Dmitrii eae9f04b96 Refactor .gitignore and enhance transaction handling with encryption support
Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 48 minutes
2026-06-29 03:00:53 +03:00
22 changed files with 759 additions and 320 deletions
+69
View File
@@ -0,0 +1,69 @@
name: .NET+Docker CI/CD
on:
push:
branches: [ "master", "develop", "main" ]
paths-ignore:
- 'README.md'
- '*.md'
jobs:
build:
name: Unit and Integration tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Restore dependencies
run: dotnet restore backend/SpMega.Backend/SpMega.Backend.csproj
- name: Build
run: dotnet build --no-restore backend/SpMega.Backend/SpMega.Backend.csproj
- name: Test
run: dotnet test --no-build --verbosity normal backend/SpMega.Backend/SpMega.Backend.csproj
compose:
name: Push Docker image to ghcr.io
runs-on: ubuntu-latest
needs: build
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: yawaflua
password: ${{ env.GITHUB_TOKEN }}
- name: Log in to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.yawaflua.tech
username: yawaflua
password: ${{ env.GITEA_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: |
ghcr.io/yawaflua/spmega
git.yawaflua.tech/yawaflua/spmega
- name: Build and push API
uses: docker/build-push-action@v6
with:
context: backend/
file: backend/SpMega.Backend/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+5
View File
@@ -19,3 +19,8 @@ hs_err_pid*
# Common working directory # Common working directory
run run
backend/SpMega.Backend/src/*
backend/SpMega.Backend/obj/*
backend/SpMega.Backend/appsettings.json
backend/SpMega.Backend/SpMega.Backend.http
-4
View File
@@ -1,4 +0,0 @@
SpMega.Backend/src/*
SpMega.Backend/obj/*
SpMega.Backend/appsettings.json
SpMega.Backend/SpMega.Backend.http
@@ -112,8 +112,9 @@ public class AuthController(AppDbContext dbContext, TokenService tokenService, I
Console.WriteLine(resp); Console.WriteLine(resp);
var user = ((User)HttpContext.Items["@me"]); var user = ((User)HttpContext.Items["@me"]);
Console.WriteLine(me.id); Console.WriteLine(me.id);
Console.WriteLine(Guid.Parse(me.minecraftUUID));
Console.WriteLine(user.Id.ToString()); Console.WriteLine(user.Id.ToString());
if (user == null || user.Id.ToString() != me.minecraftUUID) if (user == null || user.Id != Guid.Parse(me.minecraftUUID))
{ {
throw new Exception("Its not ur card"); throw new Exception("Its not ur card");
} }
@@ -1,9 +1,12 @@
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MongoDB.Bson;
using MongoDB.Driver;
using SpMega.Backend.Persistent.Database; using SpMega.Backend.Persistent.Database;
using SpMega.Backend.Persistent.Models.Transactions; using SpMega.Backend.Persistent.Models.Transactions;
using SpMega.Backend.Persistent.Models.Users; using SpMega.Backend.Persistent.Models.Users;
@@ -15,7 +18,8 @@ namespace SpMega.Backend.Controllers.v1;
[ApiController] [ApiController]
public class TransactionsController(AppDbContext context, ILogger<TransactionsController> logger, IConfiguration config) : ControllerBase public class TransactionsController(AppDbContext context, ILogger<TransactionsController> logger, IConfiguration config) : ControllerBase
{ {
private const string BASE_URL = "https://spworlds.ru/api/public"; private const int LIMIT = 10;
private const string BASE_URL = "https://spworlds.ru/api/public/";
[HttpGet("{billId}")] [HttpGet("{billId}")]
public async Task<IActionResult> GetBill(string billId) public async Task<IActionResult> GetBill(string billId)
@@ -55,32 +59,59 @@ public class TransactionsController(AppDbContext context, ILogger<TransactionsCo
[Authorize] [Authorize]
public async Task<ActionResult<Transaction>> CreateTransaction([FromBody] CreateTransactionBody body) public async Task<ActionResult<Transaction>> CreateTransaction([FromBody] CreateTransactionBody body)
{ {
var BearerToken = $"{body.cardToUse.Id}:{body.cardToUse.Token}"; var user = HttpContext.Items["@me"] as User;
string Base64BearerToken = Convert.ToBase64String(Encoding.UTF8.GetBytes(BearerToken)); if (user == null)
{
return Unauthorized(new { error = "User not authenticated" });
}
var cardToUse = user.Cards.FirstOrDefault(l => l.Id == Guid.Parse(body.cardId));
if (cardToUse == null)
{
return BadRequest(new { error = "Card not found" });
}
var shortId = Program.GenerateRandomString(8); var shortId = Program.GenerateRandomString(5);
while (true)
{
var a = await context.Transactions.FirstOrDefaultAsync(k => k.ShortId == shortId);
if (a != null)
{
shortId = Program.GenerateRandomString(5);
}
else
{
break;
}
}
var transaction = new Transaction var transaction = new Transaction
{ {
ReceiverName = body.receiverName, ReceiverName = body.receiverName,
ShortId = shortId, ShortId = shortId,
ReceiverCardNumber = body.receiverCard, ReceiverCardNumber = body.receiverCard,
Sender = HttpContext.Items["@me"] as User, Sender = user,
SenderCardNumber = body.cardToUse.SpworldsID, SenderCardNumber = body.cardId,
Amount = body.amount, Amount = body.amount,
Comment = body.comment, Comment = body.comment,
}; };
try try
{ {
var uri = new Uri(new Uri(config["Url"] ?? "https://spmega.yawaflua.tech"), "/" + shortId); var uri = "s.ywfl.dev" + "/" + shortId;
var transitionInfo = new Dictionary<string, object> var transitionInfo = new Dictionary<string, object>
{ {
{ "receiver", body.receiverCard }, { "receiver", body.receiverCard },
{ "amount", body.amount }, { "amount", body.amount },
{ "comment", "Чек: "+ uri } { "comment", body.comment + ";Чек:"+ uri }
}; };
Console.WriteLine((body.comment + ";Чек: "+ uri).Length);
await SendRequest(endpoint: "transactions", body: transitionInfo, AuthHeader:new("Bearer", Base64BearerToken)); Console.WriteLine((body.comment + ";Чек: "+ uri));
var resp = await SendRequest(endpoint: "transactions", body: transitionInfo,
AuthHeader: new("Bearer", cardToUse.Token));
var balance = (int?)JsonNode.Parse(resp)?["balance"];
if (balance == null)
{
throw new Exception("Failed to create transaction: " + resp);
}
} catch (Exception exception) } catch (Exception exception)
{ {
@@ -99,11 +130,16 @@ public class TransactionsController(AppDbContext context, ILogger<TransactionsCo
[HttpGet()] [HttpGet()]
[Authorize] [Authorize]
public async Task<ActionResult<List<Transaction>>> GetAllTransactions() public async Task<ActionResult<List<Transaction>>> GetAllTransactions([FromQuery] int p = 1)
{ {
return Ok(await context.Transactions.Where(k => k.Sender.Id == ((User)HttpContext.Items["@me"]).Id).ToListAsync()); return Ok(await context.Transactions
.Where(k => EF.Property<Guid>(k, "SenderId") == ((User)HttpContext.Items["@me"]).Id)
.Skip(p - 1 * LIMIT)
.Take(p * LIMIT)
.ToListAsync());
} }
[NonAction]
internal async Task<string> SendRequest(string endpoint, AuthenticationHeaderValue AuthHeader, HttpMethod method = null, object body = null) internal async Task<string> SendRequest(string endpoint, AuthenticationHeaderValue AuthHeader, HttpMethod method = null, object body = null)
{ {
method ??= body == null ? HttpMethod.Get : HttpMethod.Post; method ??= body == null ? HttpMethod.Get : HttpMethod.Post;
@@ -129,4 +165,4 @@ public class TransactionsController(AppDbContext context, ILogger<TransactionsCo
return await message.Content.ReadAsStringAsync(); return await message.Content.ReadAsStringAsync();
} }
} }
public record CreateTransactionBody(Card cardToUse, string receiverCard, string receiverName, string comment, int amount); public record CreateTransactionBody(string cardId, string receiverCard, string receiverName, string comment, int amount);
@@ -487,10 +487,15 @@
<p>Мы собираем и обрабатываем:</p> <p>Мы собираем и обрабатываем:</p>
<ul> <ul>
<li>Ваш уникальный идентификатор Minecraft (<span class="badge">UUID</span>) и текущий никнейм.</li> <li>Ваш уникальный идентификатор Minecraft (<span class="badge">UUID</span>) и текущий никнейм.</li>
<li>Сведения о версии игры, версии Java и используемой версии модификации.</li> <li>Время первой и последующих авторизаций.</li>
<li>Ваш текущий IP-адрес для предотвращения мультиаккаунтинга и выявления попыток взлома API.</li> <li>Ваш текущий IP-адрес для предотвращения и выявления попыток взлома API.</li>
<li>Техническую информацию о транзакциях (суммы, комментарии, номера виртуальных счетов/карт и идентификаторы).</li> <li>Техническую информацию о транзакциях (суммы, комментарии, номера виртуальных счетов/карт и идентификаторы).</li>
</ul> </ul>
<p>Мы не сохраняем:</p>
<ul>
<li>Токен карты «КАК ЕСТЬ», данные шифруются по протоколу AES-256-CBC, и только после этого сохраняются в базе данных.</li>
<li>Любые личные данные, кроме указанных выше</li>
</ul>
</section> </section>
<!-- Section 5 --> <!-- Section 5 -->
@@ -501,7 +506,7 @@
<ul> <ul>
<li><strong>Не передавать, не продавать и не распространять</strong> ваши личные данные, IP-адреса и статистику третьим лицам.</li> <li><strong>Не передавать, не продавать и не распространять</strong> ваши личные данные, IP-адреса и статистику третьим лицам.</li>
<li>Использовать собранную анонимизированную статистику исключительно во внутренних аналитических целях для улучшения производительности мода.</li> <li>Использовать собранную анонимизированную статистику исключительно во внутренних аналитических целях для улучшения производительности мода.</li>
<li>Раскрывать информацию только в случаях, прямо предусмотренных действующим законодательством при наличии официальных судебных или правоохранительных запросов.</li> <li>Раскрывать информацию только в случаях, прямо предусмотренных действующим законодательством государства Израиль при наличии официальных судебных или правоохранительных запросов.</li>
</ul> </ul>
</section> </section>
@@ -517,11 +522,11 @@
<footer> <footer>
<div class="footer-links"> <div class="footer-links">
<a href="/v1/BillPage/00000000-0000-0000-0000-000000000000" class="footer-link">Проверить чек</a> <a href="/00000000" class="footer-link">Проверить чек</a>
<span>•</span> <span>•</span>
<a href="#" class="footer-link">Поддержка</a> <a href="https://t.me/meharmen" class="footer-link">Поддержка</a>
</div> </div>
<div>&copy; 2026 SPMega. Все права защищены. Minecraft является торговой маркой Mojang Synergies AB.</div> <div>&copy; 2026 Dmitri Shimanski. Все права защищены. NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT..</div>
</footer> </footer>
<!-- Scroll to Top Button --> <!-- Scroll to Top Button -->
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace SpMega.Backend;
public class AgreementPage : PageModel
{
public void OnGet()
{
}
}
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using MongoDB.EntityFrameworkCore.Extensions; using MongoDB.EntityFrameworkCore.Extensions;
using SpMega.Backend.Persistent.Models.Transactions; using SpMega.Backend.Persistent.Models.Transactions;
using SpMega.Backend.Persistent.Models.Users; using SpMega.Backend.Persistent.Models.Users;
using SpMega.Backend.Services;
namespace SpMega.Backend.Persistent.Database; namespace SpMega.Backend.Persistent.Database;
@@ -20,6 +21,14 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
modelBuilder.Entity<User>()
.OwnsMany(u => u.Cards, card =>
{
card.Property(c => c.Token)
.HasConversion(
v => EncryptionHelper.Encrypt(v),
v => EncryptionHelper.Decrypt(v)
);
});
} }
} }
@@ -2,14 +2,32 @@ using SPWorldsApi.Types.Interfaces;
namespace SpMega.Backend.Persistent.Models.DTO; namespace SpMega.Backend.Persistent.Models.DTO;
public class UserAccountDTO : IUserAccount public class UserAccountDTO
{ {
public string id { get; set; } public string id { get; set; }
public string username { get; set; } public string username { get; set; }
public string minecraftUUID { get; set; } public string minecraftUUID { get; set; }
public string status { get; set; } public string status { get; set; }
public List<string> roles { get; set; } public List<string> roles { get; set; }
public ICity city { get; set; } public CityDTO city { get; set; }
public List<IUserCard> cards { get; set; } public List<UserCardDTO> cards { get; set; }
public DateTime createdAt { get; set; } public DateTime createdAt { get; set; }
} }
public class UserCardDTO
{
public string id { get; set; }
public string name { get; set; }
public string number { get; set; }
public int color { get; set; }
}
public class CityDTO
{
public string id { get; set; }
public string name { get; set; }
public string description { get; set; }
public int x { get; set; }
public int z { get; set; }
public bool isMayor { get; set; }
}
+9 -4
View File
@@ -1,3 +1,4 @@
using System.Security.Cryptography;
using System.Text; using System.Text;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -14,10 +15,15 @@ public class Program
{ {
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Configuration builder.Configuration
.AddJsonFile("appsettings.json", false) .AddJsonFile("appsettings.json", true)
.AddJsonFile("appsettings.Development.json", false) .AddJsonFile("appsettings.Development.json", true)
.AddEnvironmentVariables(); .AddEnvironmentVariables();
var conf = builder.Configuration;
var encryptionKey = conf["Encryption:Key"] ?? "a-default-fallback-only-for-dev-key-change-this!";
EncryptionHelper.Initialize(encryptionKey);
_ = conf["JWT:Secret"] ?? conf["JWT__Secret"] ?? throw new InvalidOperationException("JWT__Secret is not configured.");
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
builder.Services.AddLogging(logging => builder.Services.AddLogging(logging =>
{ {
@@ -79,12 +85,11 @@ public class Program
{ {
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
var random = new Random();
var result = new StringBuilder(length); var result = new StringBuilder(length);
for (var i = 0; i < length; i++) for (var i = 0; i < length; i++)
{ {
var index = random.Next(chars.Length); var index = RandomNumberGenerator.GetInt32(chars.Length);
result.Append(chars[index]); result.Append(chars[index]);
} }
@@ -0,0 +1,113 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
namespace SpMega.Backend.Services;
public static class EncryptionHelper
{
private static byte[]? _key;
/// <summary>
/// Initializes the encryption key. The key will be derived using SHA-256 from the provided secret string
/// to ensure it is exactly 32 bytes (256 bits) long.
/// </summary>
public static void Initialize(string secretKey)
{
if (string.IsNullOrWhiteSpace(secretKey))
{
throw new ArgumentException("Encryption key cannot be null or empty.", nameof(secretKey));
}
_key = SHA256.HashData(Encoding.UTF8.GetBytes(secretKey));
}
/// <summary>
/// Encrypts the plain text using AES-256-CBC.
/// A unique Initialization Vector (IV) is generated for each encryption and stored alongside the ciphertext.
/// </summary>
public static string Encrypt(string plainText)
{
if (_key == null)
{
throw new InvalidOperationException("EncryptionHelper has not been initialized. Call Initialize() first.");
}
if (string.IsNullOrEmpty(plainText))
{
return plainText;
}
using var aes = Aes.Create();
aes.Key = _key;
aes.GenerateIV();
var iv = aes.IV;
using var encryptor = aes.CreateEncryptor(aes.Key, iv);
using var ms = new MemoryStream();
ms.Write(iv, 0, iv.Length);
using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write))
using (var writer = new StreamWriter(cs, Encoding.UTF8))
{
writer.Write(plainText);
}
return Convert.ToBase64String(ms.ToArray());
}
/// <summary>
/// Decrypts the cipher text using AES-256-CBC.
/// Reconstructs the Initialization Vector (IV) from the beginning of the ciphertext.
/// Falls back to returning the input string if decryption fails (e.g., for legacy plaintext tokens).
/// </summary>
public static string Decrypt(string cipherText)
{
if (_key == null)
{
throw new InvalidOperationException("EncryptionHelper has not been initialized. Call Initialize() first.");
}
if (string.IsNullOrEmpty(cipherText))
{
return cipherText;
}
try
{
var fullCipher = Convert.FromBase64String(cipherText);
using var aes = Aes.Create();
aes.Key = _key;
var ivLength = aes.BlockSize / 8;
if (fullCipher.Length < ivLength)
{
// Too short to be an encrypted payload, return original
return cipherText;
}
var iv = new byte[ivLength];
var cipher = new byte[fullCipher.Length - ivLength];
Buffer.BlockCopy(fullCipher, 0, iv, 0, ivLength);
Buffer.BlockCopy(fullCipher, ivLength, cipher, 0, cipher.Length);
aes.IV = iv;
using var decryptor = aes.CreateDecryptor(aes.Key, aes.IV);
using var ms = new MemoryStream(cipher);
using var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read);
using var reader = new StreamReader(cs, Encoding.UTF8);
return reader.ReadToEnd();
}
catch (Exception ex) when (ex is CryptographicException or FormatException)
{
// Fallback for legacy unencrypted tokens
return cipherText;
}
}
}
@@ -8,8 +8,8 @@ namespace SpMega.Backend.Services;
public class TokenService(IConfiguration conf) public class TokenService(IConfiguration conf)
{ {
private const int AccessTokenExpirationMinutes = 24; private const int AccessTokenExpirationMinutes = 24;
private readonly string _issuer = conf["JWT__Issuer"] ?? "spmega.il.yawaflua.tech"; private readonly string _issuer = conf["JWT:Issuer"] ?? conf["JWT__Issuer"] ?? "spmega.il.yawaflua.tech";
private readonly string _secret = conf["JWT__Secret"] ?? throw new InvalidOperationException("JWT__Secret is not configured."); private readonly string _secret = conf["JWT:Secret"] ?? conf["JWT__Secret"] ?? throw new InvalidOperationException("JWT__Secret is not configured.");
public string GenerateAccessToken(string userName, Guid uuid) public string GenerateAccessToken(string userName, Guid uuid)
{ {
@@ -9,7 +9,6 @@ import net.minecraft.text.Text;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Pattern; import java.util.regex.Pattern;
public class ChatListener { public class ChatListener {
@@ -57,8 +56,7 @@ public class ChatListener {
if (client.player != null) { if (client.player != null) {
String playerUuid = client.player.getUuidAsString(); String playerUuid = client.player.getUuidAsString();
CompletableFuture BankUiService.instance().addCardAsync(cardId, tokenId, playerUuid)
.supplyAsync(() -> BankUiService.instance().addCard(cardId, tokenId, playerUuid))
.thenAccept(msg -> { .thenAccept(msg -> {
if (client == null) { if (client == null) {
return; return;
@@ -26,6 +26,7 @@ import net.minecraft.text.Text;
import net.minecraft.util.ActionResult; import net.minecraft.util.ActionResult;
import org.lwjgl.glfw.GLFW; import org.lwjgl.glfw.GLFW;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@@ -166,25 +167,13 @@ public class SPMegaClient implements ClientModInitializer {
} }
UiNotifications.instance().render(drawContext, client.textRenderer, scaledWidth, scaledHeight); UiNotifications.instance().render(drawContext, client.textRenderer, scaledWidth, scaledHeight);
}); });
// CompletableFuture.supplyAsync(() -> BackendAuthenticator.authenticate(client))
// .thenAccept(authResult -> {
// if (authResult) {
// System.out.println("SPMega: Authenticated on backend successfully.");
// } else {
// System.err.println("SPMega: Authentication on backend failed");
// }
// })
// .exceptionally(ex -> {
// System.err.println("SPMega: Authentication error: " + ex.getMessage());
// return null;
// });
} }
); );
ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> { ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> {
if (client.player != null) { if (client.player != null) {
BankUiService.instance().refreshOnServerJoin(client.player.getUuidAsString()); BankUiService.instance().refreshOnServerJoinAsync(client.player.getUuidAsString());
} }
}); });
@@ -238,8 +227,13 @@ public class SPMegaClient implements ClientModInitializer {
}); });
new ChatListener().register(); new ChatListener().register();
BackendAuthenticator.authenticate(MinecraftClient.getInstance()); CompletableFuture.runAsync(() -> BackendAuthenticator.authenticate(MinecraftClient.getInstance()))
.exceptionally(throwable -> {
System.err.println("[SPMEGA] Error during async authenticate: " + throwable.getMessage());
return null;
});
System.out.println("Author of SPMega make it with 4 cans of monster"); System.out.println("Author of SPMega make it with 4 cans of monster");
System.out.println("If u want to see more updates - give me like 10 shekels for monster plzzz");
System.out.println("Initialized beshalom! Tieie tovim!"); System.out.println("Initialized beshalom! Tieie tovim!");
} }
} }
@@ -7,7 +7,6 @@ import net.minecraft.client.gui.widget.ButtonWidget;
import net.minecraft.client.gui.widget.TextFieldWidget; import net.minecraft.client.gui.widget.TextFieldWidget;
import net.minecraft.text.Text; import net.minecraft.text.Text;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer; import java.util.function.Consumer;
public class AddCardScreen extends Screen { public class AddCardScreen extends Screen {
@@ -85,8 +84,7 @@ public class AddCardScreen extends Screen {
addButton.active = false; addButton.active = false;
cancelButton.active = false; cancelButton.active = false;
CompletableFuture bankUiService.addCardAsync(cardId, cardToken, playerUuid)
.supplyAsync(() -> bankUiService.addCard(cardId, cardToken, playerUuid))
.thenAccept(message -> { .thenAccept(message -> {
if (this.client == null) { if (this.client == null) {
return; return;
@@ -9,7 +9,6 @@ import net.minecraft.text.Text;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.CompletableFuture;
public class CardScreen extends Screen { public class CardScreen extends Screen {
private final Screen parent; private final Screen parent;
@@ -46,25 +45,30 @@ public class CardScreen extends Screen {
} }
this.addDrawableChild(ButtonWidget.builder(Text.literal("Удалить"), button -> { this.addDrawableChild(ButtonWidget.builder(Text.literal("Удалить"), button -> {
CompletableFuture.supplyAsync(() -> { bankUiService.removeSelectedCardAsync()
bankUiService.removeSelectedCard(); .thenAccept(message -> {
return true; if (this.client != null) {
}); this.client.execute(() -> {
notifications.showMessage(bankUiService.getLastMessage()); notifications.showMessage(message);
this.clearAndInit(); this.clearAndInit();
});
}
});
}).dimensions(rightX, startY, columnWidth, 20).build()); }).dimensions(rightX, startY, columnWidth, 20).build());
this.addDrawableChild(ButtonWidget.builder(Text.literal("Обновить"), button -> { this.addDrawableChild(ButtonWidget.builder(Text.literal("Обновить"), button -> {
String playerUuid = this.client != null && this.client.player != null String playerUuid = this.client != null && this.client.player != null
? this.client.player.getUuidAsString() ? this.client.player.getUuidAsString()
: ""; : "";
CompletableFuture.supplyAsync(() -> { bankUiService.refreshSelectedCardAsync(playerUuid)
bankUiService.refreshSelectedCard(playerUuid); .thenAccept(message -> {
return true; if (this.client != null) {
}); this.client.execute(() -> {
String message = bankUiService.getLastMessage().isBlank() ? "Карта обновлена" : bankUiService.getLastMessage();
notifications.showMessage(message); notifications.showMessage(message);
this.clearAndInit(); this.clearAndInit();
});
}
});
}).dimensions(rightX, startY + 24, columnWidth, 20).build()); }).dimensions(rightX, startY + 24, columnWidth, 20).build());
this.addDrawableChild(ButtonWidget.builder(Text.literal("Добавить новую"), button -> { this.addDrawableChild(ButtonWidget.builder(Text.literal("Добавить новую"), button -> {
@@ -12,7 +12,6 @@ import net.minecraft.text.Text;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@@ -382,8 +381,7 @@ public class PaymentScreen extends Screen {
} }
lastRecipientLookup = username; lastRecipientLookup = username;
CompletableFuture bankUiService.loadRecipientCardsAsync(username)
.supplyAsync(() -> bankUiService.loadRecipientCards(username))
.thenAccept(cards -> { .thenAccept(cards -> {
if (this.client == null) { if (this.client == null) {
return; return;
@@ -3,6 +3,7 @@ package git.yawaflua.tech.spmega.client.ui;
import git.yawaflua.tech.spmega.client.ui.service.BackendAuthenticator; import git.yawaflua.tech.spmega.client.ui.service.BackendAuthenticator;
import git.yawaflua.tech.spmega.client.ui.service.BankDatabase.LocalTransaction; import git.yawaflua.tech.spmega.client.ui.service.BankDatabase.LocalTransaction;
import git.yawaflua.tech.spmega.client.ui.service.BankUiService; import git.yawaflua.tech.spmega.client.ui.service.BankUiService;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.gui.DrawContext; import net.minecraft.client.gui.DrawContext;
import net.minecraft.client.gui.screen.Screen; import net.minecraft.client.gui.screen.Screen;
import net.minecraft.client.gui.widget.ButtonWidget; import net.minecraft.client.gui.widget.ButtonWidget;
@@ -19,6 +20,8 @@ public class TransactionHistoryScreen extends Screen {
private final List<LocalTransaction> transactions = new ArrayList<>(); private final List<LocalTransaction> transactions = new ArrayList<>();
private boolean loading = true; private boolean loading = true;
private String errorMessage = ""; private String errorMessage = "";
private int scrollOffset = 0;
private int currentPage = 1;
public TransactionHistoryScreen(Screen parent, String cardId, String cardTitle) { public TransactionHistoryScreen(Screen parent, String cardId, String cardTitle) {
super(Text.literal("История транзакций")); super(Text.literal("История транзакций"));
@@ -36,40 +39,112 @@ public class TransactionHistoryScreen extends Screen {
.dimensions(centerX - 60, startY + 10, 120, 20) .dimensions(centerX - 60, startY + 10, 120, 20)
.build()); .build());
// Scroll Up/Down buttons
this.addDrawableChild(ButtonWidget.builder(Text.literal(""), button -> scrollUp())
.dimensions(centerX + 140, 65, 20, 20)
.build());
this.addDrawableChild(ButtonWidget.builder(Text.literal(""), button -> scrollDown())
.dimensions(centerX + 140, 155, 20, 20)
.build());
// Prev Page button
this.addDrawableChild(ButtonWidget.builder(Text.literal("<"), button -> {
if (currentPage > 1) {
currentPage--;
scrollOffset = 0;
loadTransactions(); loadTransactions();
} }
}).dimensions(centerX - 100, startY - 15, 30, 20).build());
// Next Page button
this.addDrawableChild(ButtonWidget.builder(Text.literal(">"), button -> {
currentPage++;
scrollOffset = 0;
loadTransactions();
}).dimensions(centerX + 70, startY - 15, 30, 20).build());
loadTransactions();
}
private void scrollUp() {
if (scrollOffset > 0) {
scrollOffset--;
}
}
private void scrollDown() {
if (scrollOffset < Math.max(0, transactions.size() - 6)) {
scrollOffset++;
}
}
// Support pre-1.20.2 mouse scroll
public boolean mouseScrolled(double mouseX, double mouseY, double amount) {
if (amount > 0) {
scrollUp();
} else if (amount < 0) {
scrollDown();
}
return true;
}
// Support 1.20.2+ mouse scroll
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmount, double verticalAmount) {
if (verticalAmount > 0) {
scrollUp();
} else if (verticalAmount < 0) {
scrollDown();
}
return true;
}
private void loadTransactions() { private void loadTransactions() {
loading = true; loading = true;
errorMessage = ""; errorMessage = "";
transactions.clear(); transactions.clear();
UiNotifications.instance().show(Text.literal("Загрузка..."));
CompletableFuture.runAsync(() -> { CompletableFuture.runAsync(() -> {
try { try {
System.out.println("[SPMEGA] Transaction history loading started for card: " + cardId + ", page: " + currentPage);
List<LocalTransaction> list = null; List<LocalTransaction> list = null;
git.yawaflua.tech.spmega.ModConfig config = git.yawaflua.tech.spmega.SPMega.getConfig(); git.yawaflua.tech.spmega.ModConfig config = git.yawaflua.tech.spmega.SPMega.getConfig();
if (config != null && config.allowBackend()) { if (config != null && config.allowBackend()) {
try { try {
list = BackendAuthenticator.fetchTransactionsFromBackend(cardId); list = BackendAuthenticator.fetchTransactionsFromBackend(cardId, currentPage);
System.out.println("[SPMEGA] Fetched " + (list != null ? list.size() : "null") + " transactions from backend.");
} catch (Exception e) { } catch (Exception e) {
System.err.println("[SPMEGA] Failed to fetch transactions from server, falling back to DB: " + e.getMessage()); System.err.println("[SPMEGA] Failed to fetch transactions from server, falling back to DB: " + e.getMessage());
e.printStackTrace();
} }
} }
if (list == null) { if (list == null) {
list = BankUiService.instance().getDatabase().loadTransferHistory(cardId); list = BankUiService.instance().getDatabase().loadTransferHistory(cardId);
System.out.println("[SPMEGA] Loaded " + (list != null ? list.size() : "null") + " transactions from database.");
} }
List<LocalTransaction> finalList = list; List<LocalTransaction> finalList = list != null ? list : new ArrayList<>();
if (this.client != null) { MinecraftClient minecraftClient = MinecraftClient.getInstance();
this.client.execute(() -> { if (minecraftClient != null) {
minecraftClient.execute(() -> {
System.out.println("[SPMEGA] Setting transactions list on main thread. Count: " + finalList.size());
this.transactions.addAll(finalList); this.transactions.addAll(finalList);
this.scrollOffset = 0;
this.loading = false; this.loading = false;
}); });
} else {
System.out.println("[SPMEGA] MinecraftClient.getInstance() is null in loadTransactions callback!");
} }
} catch (Exception e) { } catch (Exception e) {
if (this.client != null) { System.err.println("[SPMEGA] Outer exception in loadTransactions: " + e.getMessage());
this.client.execute(() -> { e.printStackTrace();
MinecraftClient minecraftClient = MinecraftClient.getInstance();
if (minecraftClient != null) {
minecraftClient.execute(() -> {
this.errorMessage = e.getMessage(); this.errorMessage = e.getMessage();
this.loading = false; this.loading = false;
}); });
@@ -91,30 +166,38 @@ public class TransactionHistoryScreen extends Screen {
int centerX = this.width / 2; int centerX = this.width / 2;
int startY = 50; int startY = 50;
int bottomStartY = this.height / 2 + 50;
context.drawCenteredTextWithShadow(this.textRenderer, this.title, centerX, 20, 0xFFFFFF); context.drawCenteredTextWithShadow(this.textRenderer, this.title, centerX, 20, 0xFFFFFFFF);
context.drawCenteredTextWithShadow(this.textRenderer, Text.literal("Карта: " + cardTitle), centerX, 35, 0xBFBFBF); context.drawCenteredTextWithShadow(this.textRenderer, Text.literal("Карта: " + cardTitle), centerX, 35, 0xFFBFBFBF);
context.drawCenteredTextWithShadow(this.textRenderer, Text.literal("Стр. " + currentPage), centerX, bottomStartY - 9, 0xFFFFFFFF);
if (loading) { if (loading) {
context.drawCenteredTextWithShadow(this.textRenderer, Text.literal("Загрузка транзакций..."), centerX, this.height / 2 - 10, 0xCCCCCC); context.drawCenteredTextWithShadow(this.textRenderer, Text.literal("Загрузка транзакций..."), centerX, this.height / 2 - 10, 0xFFCCCCCC);
} else if (!errorMessage.isEmpty()) { } else if (!errorMessage.isEmpty()) {
context.drawCenteredTextWithShadow(this.textRenderer, Text.literal("Ошибка: " + errorMessage), centerX, this.height / 2 - 10, 0xFF5555); context.drawCenteredTextWithShadow(this.textRenderer, Text.literal("Ошибка: " + errorMessage), centerX, this.height / 2 - 10, 0xFFFF5555);
} else if (transactions.isEmpty()) { } else if (transactions.isEmpty()) {
context.drawCenteredTextWithShadow(this.textRenderer, Text.literal("Транзакций не найдено"), centerX, this.height / 2 - 10, 0xCCCCCC); context.drawCenteredTextWithShadow(this.textRenderer, Text.literal("Транзакций не найдено"), centerX, this.height / 2 - 10, 0xFFCCCCCC);
} else { } else {
int y = startY + 15; int y = startY + 15;
for (int i = 0; i < Math.min(6, transactions.size()); i++) { int limit = Math.min(6, transactions.size() - scrollOffset);
LocalTransaction tx = transactions.get(i); for (int i = 0; i < limit; i++) {
LocalTransaction tx = transactions.get(scrollOffset + i);
String amountText = tx.amount() + " АР"; String amountText = tx.amount() + " АР";
String dateText = tx.createdAt(); String dateText = tx.createdAt();
if (dateText == null) {
dateText = "";
}
if (dateText.length() > 19) { if (dateText.length() > 19) {
dateText = dateText.substring(0, 19).replace("T", " "); dateText = dateText.substring(0, 19).replace("T", " ");
} }
String commentText = tx.comment().isEmpty() ? "" : " (" + tx.comment() + ")"; String commentText = tx.comment().isEmpty() ? "" : " (" + tx.comment() + ")";
String line = String.format("%s -> %s | %s%s", dateText, tx.receiver(), amountText, commentText); String line = String.format("%s -> %s | %s%s", dateText, tx.receiver(), amountText, commentText);
int color = tx.status().equalsIgnoreCase("SUCCESS") ? 0x55FF55 : 0xFF5555;
String status = tx.status();
int color = (status != null && status.equalsIgnoreCase("SUCCESS")) ? 0xFF55FF55 : 0xFFFF5555;
context.drawCenteredTextWithShadow(this.textRenderer, Text.literal(line), centerX, y, color); context.drawCenteredTextWithShadow(this.textRenderer, Text.literal(line), centerX, y, color);
y += 18; y += 18;
@@ -1,5 +1,6 @@
package git.yawaflua.tech.spmega.client.ui.service; package git.yawaflua.tech.spmega.client.ui.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import com.google.gson.JsonParser; import com.google.gson.JsonParser;
import com.mojang.authlib.exceptions.AuthenticationException; import com.mojang.authlib.exceptions.AuthenticationException;
@@ -84,12 +85,11 @@ public class BackendAuthenticator {
} }
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject(); JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
if (json.has("sessionId")) { if (!json.has("sessionId")) {
return json.get("sessionId").getAsString();
} else {
System.err.println("[SPMEGA] Invalid response from start endpoint: " + response.body()); System.err.println("[SPMEGA] Invalid response from start endpoint: " + response.body());
throw new IOException("Invalid response from start endpoint: " + response.body()); throw new IOException("Invalid response from start endpoint: " + response.body());
} }
return json.get("sessionId").getAsString();
} }
private static boolean sendAuthRequestToBackend(UUID uuid, String serverId) throws IOException, InterruptedException { private static boolean sendAuthRequestToBackend(UUID uuid, String serverId) throws IOException, InterruptedException {
@@ -123,9 +123,16 @@ public class BackendAuthenticator {
} }
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject(); JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
if (json.has("token")) { if (!json.has("token")) {
System.err.println("[SPMEGA] Invalid response from validate endpoint: " + response.body());
throw new IOException("Invalid response from validate endpoint: " + response.body());
}
if (config == null) {
System.err.println("[SPMEGA] Config is null, cannot save token.");
throw new IOException("Config is null, cannot save token.");
}
String token = json.get("token").getAsString(); String token = json.get("token").getAsString();
if (config != null) {
ModConfig updated = new ModConfig( ModConfig updated = new ModConfig(
config.apiDomain(), config.apiDomain(),
token, token,
@@ -137,14 +144,6 @@ public class BackendAuthenticator {
SPMega.setConfig(updated); SPMega.setConfig(updated);
System.out.println("[SPMEGA] Backend auth successful, saved token."); System.out.println("[SPMEGA] Backend auth successful, saved token.");
return true; return true;
} else {
System.err.println("[SPMEGA] Config is null, cannot save token.");
throw new IOException("Config is null, cannot save token.");
}
} else {
System.err.println("[SPMEGA] Invalid response from validate endpoint: " + response.body());
throw new IOException("Invalid response from validate endpoint: " + response.body());
}
} }
public static void sendCardToBackend(String cardId, String cardToken) { public static void sendCardToBackend(String cardId, String cardToken) {
@@ -223,19 +222,13 @@ public class BackendAuthenticator {
List<CardCredentials> cards = new ArrayList<>(); List<CardCredentials> cards = new ArrayList<>();
for (com.google.gson.JsonElement el : cardsArray) { for (com.google.gson.JsonElement el : cardsArray) {
JsonObject cardJson = el.getAsJsonObject(); JsonObject cardJson = el.getAsJsonObject();
String cardId = ""; String cardId = cardJson.has("cardId") && !cardJson.get("cardId").isJsonNull()
if (cardJson.has("cardId") && !cardJson.get("cardId").isJsonNull()) { ? cardJson.get("cardId").getAsString()
cardId = cardJson.get("cardId").getAsString(); : (cardJson.has("id") && !cardJson.get("id").isJsonNull() ? cardJson.get("id").getAsString() : "");
} else if (cardJson.has("id") && !cardJson.get("id").isJsonNull()) {
cardId = cardJson.get("id").getAsString();
}
String cardToken = ""; String cardToken = cardJson.has("cardToken") && !cardJson.get("cardToken").isJsonNull()
if (cardJson.has("cardToken") && !cardJson.get("cardToken").isJsonNull()) { ? cardJson.get("cardToken").getAsString()
cardToken = cardJson.get("cardToken").getAsString(); : (cardJson.has("token") && !cardJson.get("token").isJsonNull() ? cardJson.get("token").getAsString() : "");
} else if (cardJson.has("token") && !cardJson.get("token").isJsonNull()) {
cardToken = cardJson.get("token").getAsString();
}
if (!cardId.isEmpty() && !cardToken.isEmpty()) { if (!cardId.isEmpty() && !cardToken.isEmpty()) {
cards.add(new CardCredentials(cardId, cardToken)); cards.add(new CardCredentials(cardId, cardToken));
@@ -299,10 +292,10 @@ public class BackendAuthenticator {
String url = apiDomain + "/api/v1/transactions"; String url = apiDomain + "/api/v1/transactions";
JsonObject jsonPayload = new JsonObject(); JsonObject jsonPayload = new JsonObject();
jsonPayload.addProperty("senderCardId", senderCardId); jsonPayload.addProperty("cardToUse", senderCardId);
jsonPayload.addProperty("cardId", senderCardId); jsonPayload.addProperty("cardId", senderCardId);
jsonPayload.addProperty("receiver", receiver); jsonPayload.addProperty("receiverCard", receiver);
jsonPayload.addProperty("recipient", receiver); jsonPayload.addProperty("receiverName", receiver);
jsonPayload.addProperty("amount", amount); jsonPayload.addProperty("amount", amount);
jsonPayload.addProperty("comment", comment); jsonPayload.addProperty("comment", comment);
String requestBody = jsonPayload.toString(); String requestBody = jsonPayload.toString();
@@ -316,16 +309,17 @@ public class BackendAuthenticator {
.build(); .build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200 || response.statusCode() == 201 || response.statusCode() == 204) { if (response.statusCode() != 200 && response.statusCode() != 201 && response.statusCode() != 204) {
return true;
} else {
throw new IOException("Server returned status code " + response.statusCode() + ": " + response.body()); throw new IOException("Server returned status code " + response.statusCode() + ": " + response.body());
} }
return true;
} }
public static List<BankDatabase.LocalTransaction> fetchTransactionsFromBackend(String cardId) throws IOException, InterruptedException { public static List<BankDatabase.LocalTransaction> fetchTransactionsFromBackend(String cardId, int page) throws IOException, InterruptedException {
System.out.println("[SPMEGA] fetchTransactionsFromBackend called for cardId: " + cardId + ", page: " + page);
ModConfig config = SPMega.getConfig(); ModConfig config = SPMega.getConfig();
if (config == null || !config.allowBackend()) { if (config == null || !config.allowBackend()) {
System.out.println("[SPMEGA] fetchTransactionsFromBackend aborted: config is null or backend not allowed.");
return List.of(); return List.of();
} }
@@ -337,7 +331,8 @@ public class BackendAuthenticator {
apiDomain = apiDomain.substring(0, apiDomain.length() - 1); apiDomain = apiDomain.substring(0, apiDomain.length() - 1);
} }
String url = apiDomain + "/api/v1/transactions?cardId=" + cardId; String url = apiDomain + "/api/v1/transactions?p=" + page;
System.out.println("[SPMEGA] Requesting transactions from URL: " + url);
HttpClient httpClient = HttpClient.newHttpClient(); HttpClient httpClient = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create(url)) HttpRequest request = HttpRequest.newBuilder(URI.create(url))
@@ -347,22 +342,85 @@ public class BackendAuthenticator {
.build(); .build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println("[SPMEGA] Response status code: " + response.statusCode());
if (response.statusCode() != 200) { if (response.statusCode() != 200) {
System.err.println("[SPMEGA] Failed response body: " + response.body());
throw new IOException("Server returned status code " + response.statusCode() + " on fetch transactions."); throw new IOException("Server returned status code " + response.statusCode() + " on fetch transactions.");
} }
com.google.gson.JsonArray transactionsArray = JsonParser.parseString(response.body()).getAsJsonArray(); String body = response.body();
System.out.println("[SPMEGA] Response body: " + (body.length() > 500 ? body.substring(0, 500) + "..." : body));
JsonElement parsed = JsonParser.parseString(body);
com.google.gson.JsonArray transactionsArray = null;
if (parsed.isJsonArray()) {
transactionsArray = parsed.getAsJsonArray();
} else if (parsed.isJsonObject()) {
JsonObject obj = parsed.getAsJsonObject();
if (obj.has("$values") && obj.get("$values").isJsonArray()) {
transactionsArray = obj.getAsJsonArray("$values");
} else if (obj.has("transactions") && obj.get("transactions").isJsonArray()) {
transactionsArray = obj.getAsJsonArray("transactions");
} else if (obj.has("values") && obj.get("values").isJsonArray()) {
transactionsArray = obj.getAsJsonArray("values");
}
}
if (transactionsArray == null) {
System.err.println("[SPMEGA] Response body is not a JSON array or wrapped array: " + body);
return List.of();
}
System.out.println("[SPMEGA] Total transactions returned from server: " + transactionsArray.size());
List<BankDatabase.LocalTransaction> list = new ArrayList<>(); List<BankDatabase.LocalTransaction> list = new ArrayList<>();
for (com.google.gson.JsonElement el : transactionsArray) { for (com.google.gson.JsonElement el : transactionsArray) {
try {
JsonObject json = el.getAsJsonObject(); JsonObject json = el.getAsJsonObject();
String receiver = json.has("receiver") ? json.get("receiver").getAsString() : (json.has("recipient") ? json.get("recipient").getAsString() : "unknown");
// Check cardId filter
String senderCardNumber = "";
if (json.has("senderCardNumber") && !json.get("senderCardNumber").isJsonNull()) {
senderCardNumber = json.get("senderCardNumber").getAsString();
}
if (!senderCardNumber.equalsIgnoreCase(cardId)) {
continue;
}
String receiver = "unknown";
if (json.has("receiver") && !json.get("receiver").isJsonNull()) {
receiver = json.get("receiver").getAsString();
} else if (json.has("receiverName") && !json.get("receiverName").isJsonNull()) {
receiver = json.get("receiverName").getAsString();
} else if (json.has("receiverCardNumber") && !json.get("receiverCardNumber").isJsonNull()) {
receiver = json.get("receiverCardNumber").getAsString();
} else if (json.has("recipient") && !json.get("recipient").isJsonNull()) {
receiver = json.get("recipient").getAsString();
}
long amount = json.has("amount") ? json.get("amount").getAsLong() : 0L; long amount = json.has("amount") ? json.get("amount").getAsLong() : 0L;
String comment = json.has("comment") && !json.get("comment").isJsonNull() ? json.get("comment").getAsString() : ""; String comment = json.has("comment") && !json.get("comment").isJsonNull() ? json.get("comment").getAsString() : "";
String status = json.has("status") ? json.get("status").getAsString() : "SUCCESS"; String status = json.has("status") && !json.get("status").isJsonNull() ? json.get("status").getAsString() : "SUCCESS";
String createdAt = json.has("createdAt") ? json.get("createdAt").getAsString() : (json.has("created_at") ? json.get("created_at").getAsString() : "");
String createdAt = "";
if (json.has("transactionDate") && !json.get("transactionDate").isJsonNull()) {
createdAt = json.get("transactionDate").getAsString();
} else if (json.has("createdAt") && !json.get("createdAt").isJsonNull()) {
createdAt = json.get("createdAt").getAsString();
} else if (json.has("created_at") && !json.get("created_at").isJsonNull()) {
createdAt = json.get("created_at").getAsString();
}
System.out.printf("[SPMEGA] Parsed transaction: %s -> %s, amount=%d, comment=%s, status=%s%n",
createdAt, receiver, amount, comment, status);
list.add(new BankDatabase.LocalTransaction(receiver, amount, comment, status, createdAt)); list.add(new BankDatabase.LocalTransaction(receiver, amount, comment, status, createdAt));
} catch (Exception parseEx) {
System.err.println("[SPMEGA] Error parsing transaction element: " + parseEx.getMessage());
} }
}
System.out.println("[SPMEGA] Returning " + list.size() + " filtered transactions for card " + cardId);
return list; return list;
} }
} }
@@ -5,6 +5,8 @@ import git.yawaflua.tech.spmega.SPMega;
import git.yawaflua.tech.spmega.api.SPWorldsApiClient; import git.yawaflua.tech.spmega.api.SPWorldsApiClient;
import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.MinecraftClient;
import java.util.*; import java.util.*;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
@@ -17,7 +19,7 @@ public final class BankUiService {
private final String apiDomain = "https://spworlds.ru"; private final String apiDomain = "https://spworlds.ru";
private int selectedCardIndex; private int selectedCardIndex;
private String lastMessage = ""; private volatile String lastMessage = "";
private BankUiService() { private BankUiService() {
this.database = new BankDatabase(FabricLoader.getInstance().getConfigDir().resolve("spmega.db")); this.database = new BankDatabase(FabricLoader.getInstance().getConfigDir().resolve("spmega.db"));
@@ -54,11 +56,20 @@ public final class BankUiService {
return normalized.substring(normalized.length() - 5); return normalized.substring(normalized.length() - 5);
} }
public synchronized List<CardViewModel> getCards() { private void runOnMainThread(Runnable runnable) {
MinecraftClient client = MinecraftClient.getInstance();
if (client == null || client.isOnThread()) {
runnable.run();
} else {
client.execute(runnable);
}
}
public List<CardViewModel> getCards() {
return Collections.unmodifiableList(cards); return Collections.unmodifiableList(cards);
} }
public synchronized CardViewModel getSelectedCard() { public CardViewModel getSelectedCard() {
if (cards.isEmpty()) { if (cards.isEmpty()) {
return null; return null;
} }
@@ -66,7 +77,7 @@ public final class BankUiService {
return cards.get(selectedCardIndex); return cards.get(selectedCardIndex);
} }
public synchronized int getSelectedCardIndex() { public int getSelectedCardIndex() {
if (cards.isEmpty()) { if (cards.isEmpty()) {
return 0; return 0;
} }
@@ -74,7 +85,7 @@ public final class BankUiService {
return selectedCardIndex; return selectedCardIndex;
} }
public synchronized void setSelectedCardIndex(int index) { public void setSelectedCardIndex(int index) {
if (cards.isEmpty()) { if (cards.isEmpty()) {
selectedCardIndex = 0; selectedCardIndex = 0;
return; return;
@@ -82,7 +93,7 @@ public final class BankUiService {
selectedCardIndex = Math.floorMod(index, cards.size()); selectedCardIndex = Math.floorMod(index, cards.size());
} }
public synchronized CardViewModel cycleSelectedCard(int direction) { public CardViewModel cycleSelectedCard(int direction) {
if (cards.isEmpty()) { if (cards.isEmpty()) {
return null; return null;
} }
@@ -90,18 +101,19 @@ public final class BankUiService {
return cards.get(selectedCardIndex); return cards.get(selectedCardIndex);
} }
public synchronized String getLastMessage() { public String getLastMessage() {
return lastMessage; return lastMessage;
} }
public synchronized void syncCardsWithBackend(String playerUuid) { public CompletableFuture<Void> syncCardsWithBackendAsync(String playerUuid) {
return CompletableFuture.runAsync(() -> {
try { try {
List<CardCredentials> backendCards = BackendAuthenticator.fetchCardsFromBackend(); List<CardCredentials> backendCards = BackendAuthenticator.fetchCardsFromBackend();
boolean changed = false; boolean changed = false;
for (CardCredentials creds : backendCards) { for (CardCredentials creds : backendCards) {
if (database.getCredentials(creds.cardId()) == null) { if (database.getCredentials(creds.cardId()) == null) {
database.upsertCardCredentials(creds.cardId(), creds.cardToken()); database.upsertCardCredentials(creds.cardId(), creds.cardToken());
refreshCard(creds.cardId(), creds.cardToken(), playerUuid, false, false); refreshCardSync(creds.cardId(), creds.cardToken(), playerUuid, false, false);
changed = true; changed = true;
} }
} }
@@ -111,24 +123,38 @@ public final class BankUiService {
} catch (Exception e) { } catch (Exception e) {
System.err.println("[SPMEGA] Failed to sync cards with backend: " + e.getMessage()); System.err.println("[SPMEGA] Failed to sync cards with backend: " + e.getMessage());
} }
});
} }
public synchronized void refreshOnServerJoin(String playerUuid) { public CompletableFuture<Void> refreshOnServerJoinAsync(String playerUuid) {
syncCardsWithBackend(playerUuid); return CompletableFuture.runAsync(() -> {
try {
List<CardCredentials> backendCards = BackendAuthenticator.fetchCardsFromBackend();
for (CardCredentials creds : backendCards) {
if (database.getCredentials(creds.cardId()) == null) {
database.upsertCardCredentials(creds.cardId(), creds.cardToken());
refreshCardSync(creds.cardId(), creds.cardToken(), playerUuid, false, false);
}
}
} catch (Exception e) {
System.err.println("[SPMEGA] Failed to sync cards with backend: " + e.getMessage());
}
List<StoredCard> storedCards = database.loadCards(); List<StoredCard> storedCards = database.loadCards();
for (StoredCard card : storedCards) { for (StoredCard card : storedCards) {
refreshCard(card.cardId(), card.cardToken(), playerUuid, false, false); refreshCardSync(card.cardId(), card.cardToken(), playerUuid, false, false);
} }
reloadCardsFromDb(); reloadCardsFromDb();
});
} }
public synchronized List<String> loadRecipientCards(String username) { public CompletableFuture<List<String>> loadRecipientCardsAsync(String username) {
CardCredentials credentials = getSelectedCredentials(); CardCredentials credentials = getSelectedCredentials();
if (credentials == null || username == null || username.isBlank()) { if (credentials == null || username == null || username.isBlank()) {
return List.of(); return CompletableFuture.completedFuture(List.of());
} }
return CompletableFuture.supplyAsync(() -> {
try { try {
List<SPWorldsApiClient.PlayerCard> apiCards = apiClient.getPlayerCards(username, toApiAuth(credentials)); List<SPWorldsApiClient.PlayerCard> apiCards = apiClient.getPlayerCards(username, toApiAuth(credentials));
List<String> numbers = new ArrayList<>(); List<String> numbers = new ArrayList<>();
@@ -143,75 +169,82 @@ public final class BankUiService {
lastMessage = "Не удалось получить карты игрока: " + exception.getMessage(); lastMessage = "Не удалось получить карты игрока: " + exception.getMessage();
return List.of(); return List.of();
} }
});
} }
public synchronized String addCard(String cardIdRaw, String cardTokenRaw, String playerUuid) { public CompletableFuture<String> addCardAsync(String cardIdRaw, String cardTokenRaw, String playerUuid) {
String cardId = cardIdRaw == null ? "" : cardIdRaw.trim(); String cardId = cardIdRaw == null ? "" : cardIdRaw.trim();
String cardToken = cardTokenRaw == null ? "" : cardTokenRaw.trim(); String cardToken = cardTokenRaw == null ? "" : cardTokenRaw.trim();
if (cardId.isEmpty() || cardToken.isEmpty()) { if (cardId.isEmpty() || cardToken.isEmpty()) {
lastMessage = "Укажи cardId и cardToken"; return CompletableFuture.completedFuture("Укажи cardId и cardToken");
return lastMessage;
} }
try { try {
UUID.fromString(cardId); UUID.fromString(cardId);
} catch (IllegalArgumentException exception) { } catch (IllegalArgumentException exception) {
lastMessage = "cardId должен быть UUID"; return CompletableFuture.completedFuture("cardId должен быть UUID");
return lastMessage;
} }
return CompletableFuture.supplyAsync(() -> {
database.upsertCardCredentials(cardId, cardToken); database.upsertCardCredentials(cardId, cardToken);
boolean refreshed = refreshCard(cardId, cardToken, playerUuid, true, true); boolean refreshed = refreshCardSync(cardId, cardToken, playerUuid, true, true);
if (!refreshed) { if (!refreshed) {
database.deleteCard(cardId); database.deleteCard(cardId);
} }
reloadCardsFromDb(); reloadCardsFromDb();
if (refreshed && lastMessage.isBlank()) { if (refreshed && lastMessage.isBlank()) {
lastMessage = "Карта добавлена"; return "Карта добавлена";
} }
return lastMessage; return lastMessage;
});
} }
public synchronized void removeSelectedCard() { public CompletableFuture<String> removeSelectedCardAsync() {
CardViewModel selected = getSelectedCard(); CardViewModel selected = getSelectedCard();
if (selected == null) { if (selected == null) {
lastMessage = "Нет карты для удаления"; return CompletableFuture.completedFuture("Нет карты для удаления");
return;
} }
String cardId = selected.id(); String cardId = selected.id();
return CompletableFuture.supplyAsync(() -> {
database.deleteCard(cardId); database.deleteCard(cardId);
ModConfig config = SPMega.getConfig(); ModConfig config = SPMega.getConfig();
if (config.allowBackend()) if (config != null && config.allowBackend()) {
BackendAuthenticator.deleteCardOnBackend(cardId); BackendAuthenticator.deleteCardOnBackend(cardId);
}
reloadCardsFromDb(); reloadCardsFromDb();
runOnMainThread(() -> {
if (selectedCardIndex >= cards.size()) { if (selectedCardIndex >= cards.size()) {
selectedCardIndex = Math.max(0, cards.size() - 1); selectedCardIndex = Math.max(0, cards.size() - 1);
} }
lastMessage = "Карта удалена"; });
return "Карта удалена";
});
} }
public synchronized void refreshSelectedCard(String playerUuid) { public CompletableFuture<String> refreshSelectedCardAsync(String playerUuid) {
CardViewModel selected = getSelectedCard(); CardViewModel selected = getSelectedCard();
if (selected == null) { if (selected == null) {
lastMessage = "Нет карты для обновления"; return CompletableFuture.completedFuture("Нет карты для обновления");
return;
} }
return CompletableFuture.supplyAsync(() -> {
CardCredentials credentials = database.getCredentials(selected.id()); CardCredentials credentials = database.getCredentials(selected.id());
if (credentials == null) { if (credentials == null) {
lastMessage = "Не найдены креды карты"; return "Не найдены креды карты";
return;
} }
refreshCard(credentials.cardId(), credentials.cardToken(), playerUuid, false, false); refreshCardSync(credentials.cardId(), credentials.cardToken(), playerUuid, false, false);
reloadCardsFromDb(); reloadCardsFromDb();
return lastMessage.isBlank() ? "Карта обновлена" : lastMessage;
});
} }
public synchronized boolean submitPayment(PaymentDraft draft) { public CompletableFuture<Boolean> submitPaymentAsync(PaymentDraft draft) {
return CompletableFuture.supplyAsync(() -> {
CardCredentials credentials = database.getCredentials(draft.senderCardId()); CardCredentials credentials = database.getCredentials(draft.senderCardId());
if (credentials == null) { if (credentials == null) {
lastMessage = "Не найдены креды карты отправителя"; lastMessage = "Не найдены креды карты отправителя";
@@ -231,7 +264,7 @@ public final class BankUiService {
draft.comment() draft.comment()
); );
try { try {
refreshCard(credentials.cardId(), credentials.cardToken(), "", false, false); refreshCardSync(credentials.cardId(), credentials.cardToken(), "", false, false);
StoredCard updatedCard = database.loadCards().stream() StoredCard updatedCard = database.loadCards().stream()
.filter(c -> c.cardId().equals(credentials.cardId())) .filter(c -> c.cardId().equals(credentials.cardId()))
.findFirst() .findFirst()
@@ -277,13 +310,10 @@ public final class BankUiService {
lastMessage = "Ошибка перевода: " + exception.getMessage(); lastMessage = "Ошибка перевода: " + exception.getMessage();
return false; return false;
} }
});
} }
public CompletableFuture<Boolean> submitPaymentAsync(PaymentDraft draft) { private boolean refreshCardSync(
return CompletableFuture.supplyAsync(() -> submitPayment(draft));
}
private boolean refreshCard(
String cardId, String cardId,
String cardToken, String cardToken,
String playerUuid, String playerUuid,
@@ -319,12 +349,9 @@ public final class BankUiService {
} }
} }
git.yawaflua.tech.spmega.ModConfig config = git.yawaflua.tech.spmega.SPMega.getConfig(); git.yawaflua.tech.spmega.ModConfig config = git.yawaflua.tech.spmega.SPMega.getConfig();
if (config.allowBackend()) if (config != null && config.allowBackend()) {
CompletableFuture.supplyAsync(() -> {
BackendAuthenticator.sendCardToBackend(cardId, cardToken); BackendAuthenticator.sendCardToBackend(cardId, cardToken);
return true;
} }
);
lastMessage = ""; lastMessage = "";
return true; return true;
@@ -336,8 +363,8 @@ public final class BankUiService {
private void reloadCardsFromDb() { private void reloadCardsFromDb() {
List<StoredCard> storedCards = database.loadCards(); List<StoredCard> storedCards = database.loadCards();
runOnMainThread(() -> {
cards.clear(); cards.clear();
for (StoredCard stored : storedCards) { for (StoredCard stored : storedCards) {
String cardNumber = stored.cardNumber() == null || stored.cardNumber().isBlank() String cardNumber = stored.cardNumber() == null || stored.cardNumber().isBlank()
? extractLastDigits(stored.cardId()) ? extractLastDigits(stored.cardId())
@@ -352,6 +379,7 @@ public final class BankUiService {
} else { } else {
selectedCardIndex = Math.floorMod(selectedCardIndex, cards.size()); selectedCardIndex = Math.floorMod(selectedCardIndex, cards.size());
} }
});
} }
private CardCredentials getSelectedCredentials() { private CardCredentials getSelectedCredentials() {
@@ -2,12 +2,12 @@ package git.yawaflua.tech.spmega;
public record ModConfig(String apiDomain, String apiToken, boolean allowBackend, boolean signQuickPayEnabled, public record ModConfig(String apiDomain, String apiToken, boolean allowBackend, boolean signQuickPayEnabled,
boolean gpsEnabled, GpsHudPosition gpsPosition) { boolean gpsEnabled, GpsHudPosition gpsPosition) {
public static final String DEFAULT_API_DOMAIN = "http://localhost:5129"; public static final String DEFAULT_API_DOMAIN = "https://spmega.yawaflua.tech";
public static final boolean ALLOW_BACKEND = false; public static final boolean ALLOW_BACKEND = true;
public static final String DEFAULT_API_TOKEN = "ulBKE9MWEtIGiPAhXV69I28W9BRiSrV3"; public static final String DEFAULT_API_TOKEN = "-";
public static final boolean DEFAULT_SIGN_QUICK_PAY_ENABLED = true; public static final boolean DEFAULT_SIGN_QUICK_PAY_ENABLED = true;
public static final boolean DEFAULT_GPS_ENABLED = true; public static final boolean DEFAULT_GPS_ENABLED = true;
public static final GpsHudPosition DEFAULT_GPS_POSITION = GpsHudPosition.TOP_LEFT; public static final GpsHudPosition DEFAULT_GPS_POSITION = GpsHudPosition.TOP_CENTER;
public static ModConfig createDefault() { public static ModConfig createDefault() {
return new ModConfig( return new ModConfig(
+14 -4
View File
@@ -3,12 +3,20 @@
"id": "spmega", "id": "spmega",
"version": "${version}", "version": "${version}",
"name": "SPMega", "name": "SPMega",
"description": "yawaflua`s SPRadar+SPMHelper mod, thats make a lot!", "description": "yawaflua's SPRadar+SPMHelper mod, that's make a lot!",
"authors": [ "authors": [
"Dmitri 'yawaflua' Shimanski aka DOLBAYEB" {
"name": "Dmitri 'yawaflua' Shimanski aka DOLBAYEB",
"contact": {
"homepage": "https://yawaflua.tech",
"email": "spmega@yawaflua.tech"
}
}
], ],
"contact": { "contact": {
"homepage": "https://yawaflua.tech",
"issues": "https://git.yawaflua.tech/spmega",
"sources": "https://git.yawaflua.tech/spmega"
}, },
"license": "CC BY-NC-ND 4.0", "license": "CC BY-NC-ND 4.0",
"icon": "icon.png", "icon": "icon.png",
@@ -37,7 +45,9 @@
"depends": { "depends": {
"fabricloader": ">=${loader_version}", "fabricloader": ">=${loader_version}",
"fabric-api": "*", "fabric-api": "*",
"minecraft": "${minecraft_version}", "minecraft": "${minecraft_version}"
},
"suggests": {
"cloth-config2": ">=${cloth_config_version}" "cloth-config2": ">=${cloth_config_version}"
} }
} }