Author SHA1 Message Date
Dmitrii b1bc688d83 Fix trace header data
Java CI / build (push) Successful in 51s
.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 27s
Release CI / build-and-upload-mod (push) Successful in 7s
Release CI / build-and-upload-mod (release) Successful in 7s
Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 5 minutes
2026-07-12 08:03:14 +03:00
Dmitrii efe19edaf4 Hotfix mongo`s bugs
Java CI / build (push) Successful in 52s
.NET+Docker CI/CD / Unit and Integration tests (push) Successful in 33s
.NET+Docker CI/CD / Push Docker image to ghcr.io (push) Failing after 31s
Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 17 minutes
2026-07-12 07:55:45 +03:00
Dmitrii d12890f8eb Hotfix mongo`s bugs
Java CI / build (push) Successful in 47s
.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 29s
Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 9 minutes
2026-07-12 07:32:28 +03:00
Dmitrii 69244ef832 Hotfix otlp headers
Java CI / build (push) Successful in 49s
.NET+Docker CI/CD / Push Docker image to ghcr.io (push) Failing after 29s
.NET+Docker CI/CD / Unit and Integration tests (push) Successful in 33s
Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 19 minutes
2026-07-12 07:22:19 +03:00
Dmitrii d04afe2968 Hotfix otlp headers
Java CI / build (push) Successful in 46s
.NET+Docker CI/CD / Unit and Integration tests (push) Successful in 33s
.NET+Docker CI/CD / Push Docker image to ghcr.io (push) Failing after 29s
Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 14 minutes
2026-07-12 06:57:15 +03:00
Dmitrii f19a5d3586 Add webhook support for card transactions and notifications.
Java CI / build (push) Successful in 50s
.NET+Docker CI/CD / Unit and Integration tests (push) Successful in 33s
.NET+Docker CI/CD / Push Docker image to ghcr.io (push) Failing after 30s
Some optimizations and fixes

Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 2 hours 4 minutes
2026-07-12 06:27:28 +03:00
Dmitrii 0e53d7dd8c Implement telemetry system with data collection and reporting
Java CI / build (push) Successful in 56s
.NET+Docker CI/CD / Unit and Integration tests (push) Successful in 38s
.NET+Docker CI/CD / Push Docker image to ghcr.io (push) Failing after 44s
Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 20 minutes
2026-07-11 00:38:28 +03:00
yawaflua f3a2a5d463 Обновить README.md
Java CI / build (push) Successful in 49s
2026-07-09 04:32:20 +03:00
Dmitrii d065c6897e Add balance handling to card transactions and user accounts
Java CI / build (push) Successful in 1m11s
.NET+Docker CI/CD / Unit and Integration tests (push) Successful in 36s
.NET+Docker CI/CD / Push Docker image to ghcr.io (push) Failing after 33s
Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 6 minutes
2026-07-06 03:53:31 +03:00
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
Dmitrii eee0d44c92 Add backend structure with Docker support and initial configuration files
Java CI / build (push) Successful in 1m56s
Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 4 hours 25 minutes
2026-06-28 08:20:33 +03:00
Dmitrii 7862597f45 Bump mod version to 0.3-pre-alpha for upcoming features and improvements
Java CI / build (push) Successful in 1m52s
Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 12 minutes
2026-06-26 03:16:04 +03:00
79 changed files with 5391 additions and 516 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 }}
+6
View File
@@ -19,3 +19,9 @@ hs_err_pid*
# Common working directory
run
backend/SpMega.Backend/bin/*
backend/SpMega.Backend/obj/*
backend/SpMega.Backend/appsettings.json
backend/SpMega.Backend/appsettings.Development.json
backend/SpMega.Backend/SpMega.Backend.http
+1
View File
@@ -1,3 +1,4 @@
![westandwith](https://share.nikgob.com/westand-withisrael)
# SPMega
SPMega - клиентский Fabric-мод с банковым UI для работы с картами и переводами через API `spworlds.ru`.
View File
@@ -0,0 +1,64 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SpMega.Backend.Persistent.Database;
namespace SpMega.Backend.Authetication;
public class JwtAuthHandler(
IOptionsMonitor<JwtAuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
AppDbContext dbContext) : AuthenticationHandler<JwtAuthenticationSchemeOptions>(options, logger, encoder)
{
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.ContainsKey("Authorization") || !Request.Headers.Authorization.ToString().StartsWith("Bearer "))
return AuthenticateResult.NoResult();
var token = Request.Headers.Authorization.ToString().Replace("Bearer ", "");
if (string.IsNullOrEmpty(token))
{
return AuthenticateResult.Fail("Invalid authorization header format");
}
try
{
var user = await dbContext.Users.FirstOrDefaultAsync(k => k.Token == token);
if (user == null)
{
return AuthenticateResult.Fail("Invalid or expired token");
}
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(ClaimTypes.Name, user.Username),
new("UserId", user.Id.ToString()),
new("Token", token),
new(ClaimTypes.Role, "default")
};
var claimsIdentity = new ClaimsIdentity(claims, Scheme.Name);
var claimsPrincipal = new ClaimsPrincipal(claimsIdentity);
Context.Items["User"] = user;
Context.Items["@me"] = user;
Context.Items["user"] = user;
var ticket = new AuthenticationTicket(claimsPrincipal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
catch (Exception ex)
{
Logger.LogError(ex, "Error during JWT authentication");
return AuthenticateResult.Fail("Authentication error occurred");
}
}
}
@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Authentication;
namespace SpMega.Backend.Authetication;
public class JwtAuthenticationSchemeOptions : AuthenticationSchemeOptions
{
public const string DefaultScheme = "JwtScheme";
}
@@ -0,0 +1,239 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MongoDB.Driver;
using SpMega.Backend.Persistent.Database;
using SpMega.Backend.Persistent.Models.DTO;
using SpMega.Backend.Persistent.Models.Users;
using SpMega.Backend.Services;
using SPWorldsApi;
using SPWorldsApi.Types.Interfaces;
namespace SpMega.Backend.Controllers.v1;
public record GetSessionIdBody(string userName, Guid userUUID);
public record ValidateSessionBody(string sessionId, Guid userUUID);
[Route("api/v1/auth")]
[ApiController]
public class AuthController(AppDbContext dbContext, TokenService tokenService, ILogger<AuthController> logger) : ControllerBase
{
private const string BASE_URL = "https://spworlds.ru/api/public";
public AuthenticationHeaderValue? AuthHeader { get; set; }
[HttpPost("start")]
public async Task<IActionResult> GetSessionIdAsync([FromBody] GetSessionIdBody body)
{
var userSession = new UserSession
{
Id = Guid.NewGuid(),
Token = Program.GenerateRandomString(15),
UserId = body.userUUID,
UserName = body.userName,
CreatedAt = DateTime.UtcNow
};
await dbContext.UserSessions.AddAsync(userSession);
await dbContext.SaveChangesAsync();
return Ok(new { sessionId = userSession.Token});
}
[HttpPost("validate")]
public async Task<ActionResult<User>> ValidateSessionAsync([FromBody]ValidateSessionBody body)
{
try
{
var session =
await dbContext.UserSessions.FirstOrDefaultAsync(k => k.UserId == body.userUUID && k.Token == body.sessionId);
if (session == null || session.CreatedAt.AddMinutes(2) < DateTime.UtcNow || session.IsDeleted)
throw new Exception("Session expired or not fount");
using var httpClient = new HttpClient();
using var request = new HttpRequestMessage(
HttpMethod.Get,
$"https://sessionserver.mojang.com/session/minecraft/hasJoined?username={session.UserName}&serverId={session.Token}"
);
session.IsDeleted = true;
session.DeletedAt = DateTime.UtcNow;
session.UpdatedAt = DateTime.UtcNow;
await dbContext.SaveChangesAsync();
var resp = await httpClient.SendAsync(request);
if (resp.StatusCode != HttpStatusCode.OK) throw new Exception("Mojang response is not OK");
var dto = await resp.Content.ReadFromJsonAsync<MojangDto>();
if (dto == null || dto.Name != session.UserName || Guid.Parse(dto.Id) != session.UserId) throw new Exception("Session expired, or dto is not acceptable.");
var token = tokenService.GenerateAccessToken(session.UserName, body.userUUID);
var user = await dbContext.Users.FirstOrDefaultAsync(k => k.Id == session.UserId);
var shortId = string.Empty;
if (user?.ShortId == null)
{
shortId = Program.GenerateRandomString(2);
while (true)
{
var a = await dbContext.Users.FirstOrDefaultAsync(k => k.ShortId == shortId);
if (a != null)
{
shortId = Program.GenerateRandomString(2);
}
else
{
break;
}
}
}
if (user != null)
{
user.Token = token;
user.Username = session.UserName;
user.UpdatedAt = DateTime.UtcNow;
user.ShortId ??= shortId;
dbContext.Update(user);
}
else
{
user = new User
{
Id = body.userUUID,
Username = session.UserName,
Token = token,
UpdatedAt = DateTime.UtcNow,
ShortId = shortId,
};
await dbContext.AddAsync(user);
}
await dbContext.SaveChangesAsync();
return Ok(user);
}
catch (Exception e)
{
logger.LogError(e, "Error when validation session.");
return BadRequest("I think ure try to fool us. Try again later");
}
}
[HttpPut("cards")]
[Authorize]
public async Task<IActionResult> AddCardAsync([FromBody] AddCardBody body)
{
try
{
var BearerToken = $"{body.id}:{body.token}";
string Base64BearerToken = Convert.ToBase64String(Encoding.UTF8.GetBytes(BearerToken));
var resp = await SendRequest("/accounts/me", new("Bearer", Base64BearerToken));
var me = JsonSerializer.Deserialize<UserAccountDTO>(resp);
var user = ((User)HttpContext.Items["@me"]);
if (user == null || user.Id != Guid.Parse(me.minecraftUUID))
{
throw new Exception("Its not ur card");
}
var balanceResp = await SendRequest("/public/card", new("Bearer", Base64BearerToken));
var balance = (int?)JsonNode.Parse(balanceResp)?["balance"];
var card = me.cards.First(k => k.id == body.id);
var existingCard = user.Cards.FirstOrDefault(k => k.Id.ToString() == card.id);
if (existingCard == null)
user.Cards.Add(new Card
{
Id = Guid.Parse(card.id),
Name = card.name,
Balance = balance ?? -1,
SpworldsID = card.number,
Token = Base64BearerToken,
CreatedAt = DateTime.UtcNow,
});
else
{
existingCard.Name = card.name;
existingCard.SpworldsID = card.number;
existingCard.Token = Base64BearerToken;
existingCard.Balance = balance ?? -1;
existingCard.UpdatedAt = DateTime.UtcNow;
}
await dbContext.SaveChangesAsync();
return Ok();
}
catch (Exception e)
{
logger.LogError(e, "Error when adding card.");
return BadRequest("Error when adding card.");
}
}
[HttpGet("cards")]
[Authorize]
public async Task<ActionResult<List<Card>>> GetAllCardsAsync()
{
return Ok(((User)HttpContext.Items["@me"]).Cards.Where(k => !k.IsDeleted).ToList());
}
[HttpDelete("cards/{id}")]
[Authorize]
public async Task<IActionResult> DeleteCardAsync(Guid id)
{
try
{
var user = (HttpContext.Items["@me"] as User);
user.Cards.RemoveAll(c => c.Id == id);
await dbContext.SaveChangesAsync();
return Ok();
}
catch (Exception e)
{
logger.LogError(e, "Error when deleting card.");
return BadRequest("Error when deleting card.");
}
}
internal async Task<string> SendRequest(string endpoint, AuthenticationHeaderValue AuthHeader, HttpMethod method = null, object body = null)
{
method ??= body == null ? HttpMethod.Get : HttpMethod.Post;
HttpResponseMessage message;
var client = new System.Net.Http.HttpClient();
using (var requestMessage = new HttpRequestMessage(method, BASE_URL + endpoint))
{
requestMessage.Content = new StringContent(
JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json"
);
requestMessage.Headers.Authorization = AuthHeader;
message = await client.SendAsync(requestMessage);
}
client.Dispose();
return await message.Content.ReadAsStringAsync();
}
}
public record AddCardBody(string id, string token);
@@ -0,0 +1,47 @@
using System.Diagnostics;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SpMega.Backend.Persistent.Models.DTO;
using SpMega.Backend.Persistent.Models.Users;
namespace SpMega.Backend.Controllers.v1;
[ApiController]
[Route("api/v1/telemetry")]
[Authorize]
public class TelemetryController : ControllerBase
{
private static readonly ActivitySource Source = new("SpMega.ModTelemetry");
[HttpPost]
[AllowAnonymous]
public IActionResult Post([FromBody] ModTelemetryBatchDto batch)
{
if (batch?.Events == null || batch.Events.Count == 0)
{
return Ok(new { received = 0 });
}
var user = HttpContext.Items["@me"] as User;
var userId = user?.Id.ToString() ?? User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "anonymous";
var sessionId = !string.IsNullOrEmpty(batch.SessionId) ? batch.SessionId : Guid.NewGuid().ToString("N");
foreach (var e in batch.Events)
{
using var activity = Source.StartActivity("mod.telemetry.event", ActivityKind.Client);
if (activity is null) continue;
activity.SetTag("user.id", userId);
activity.SetTag("session.id", sessionId);
activity.SetTag("client.version", batch.ClientVersion ?? string.Empty);
activity.SetTag("event.type", e.EventType);
activity.SetTag("event.timestamp", e.Timestamp.ToString("O"));
activity.SetTag("event.payload", JsonSerializer.Serialize(e.Payload));
activity.SetTag("batch.sent_at", batch.SentAt.ToString("O"));
}
return Ok(new { received = batch.Events.Count });
}
}
@@ -0,0 +1,174 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MongoDB.Bson;
using MongoDB.Driver;
using SpMega.Backend.Persistent.Database;
using SpMega.Backend.Persistent.Models.Transactions;
using SpMega.Backend.Persistent.Models.Users;
using SPWorldsApi;
namespace SpMega.Backend.Controllers.v1;
[Route("api/v1/transactions")]
[ApiController]
public class TransactionsController(AppDbContext context, ILogger<TransactionsController> logger, IConfiguration config) : ControllerBase
{
private const int LIMIT = 10;
private const string BASE_URL = "https://spworlds.ru/api/public/";
[HttpGet("{billId}")]
public async Task<IActionResult> GetBill(string billId)
{
var transaction = await context.Transactions
.FirstOrDefaultAsync(t => t.ShortId == billId);
if (transaction == null)
{
if (billId == "00000000")
{
return Ok(new Transaction
{
Id = Guid.Empty,
ShortId = "00000000",
ReceiverName = "yawaflua",
ReceiverCardNumber = "00000",
SenderMinecraftName = "yawaflua",
SenderCardNumber = "010101",
Amount = 42,
Comment = "API FIRST",
TransactionDate = DateTime.UtcNow
});
}
return NotFound(new { error = "Transaction not found" });
}
return Ok(transaction);
}
[HttpPut()]
[Authorize]
public async Task<ActionResult<Transaction>> CreateTransaction([FromBody] CreateTransactionBody body)
{
var user = HttpContext.Items["@me"] as User;
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" });
}
if (cardToUse.Balance != -1 && cardToUse.Balance < body.amount)
{
return BadRequest(new { error = "Insufficient balance" });
}
var shortId = Program.GenerateRandomString(2);
while (true)
{
var a = await context.Transactions.FirstOrDefaultAsync(k => k.ShortId == shortId && EF.Property<Guid>(k, "SenderId") == user.Id);
if (a != null)
{
shortId = Program.GenerateRandomString(2);
}
else
{
break;
}
}
var transaction = new Transaction
{
ReceiverName = body.receiverName,
ShortId = $"{user.ShortId}{shortId}",
ReceiverCardNumber = body.receiverCard,
Sender = user,
SenderCardNumber = cardToUse.SpworldsID,
SenderMinecraftName = user.Username,
Amount = body.amount,
Comment = body.comment
};
try
{
var uri = "ywfl.dev" + "/s" + shortId;
var transitionInfo = new Dictionary<string, object>
{
{ "receiver", body.receiverCard },
{ "amount", body.amount },
{ "comment", (body.comment)[10..] + "..;Чек:"+ 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);
}
cardToUse.Balance = balance.Value;
} catch (Exception exception)
{
logger.LogError(exception, "Error creating transaction");
return BadRequest(new { error = "Failed to create transaction" });
}
await context.AddAsync(transaction);
await context.SaveChangesAsync();
return Ok(new Transaction
{
Id = transaction.Id,
ReceiverName = transaction.ReceiverName,
ReceiverCardNumber = transaction.ReceiverCardNumber,
SenderMinecraftName = transaction.SenderMinecraftName,
SenderCardNumber = transaction.SenderCardNumber,
Amount = transaction.Amount,
Comment = transaction.Comment,
TransactionDate = transaction.TransactionDate
});
}
[HttpGet()]
[Authorize]
public async Task<ActionResult<List<Transaction>>> GetAllTransactions([FromQuery] int p = 1)
{
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)
{
method ??= body == null ? HttpMethod.Get : HttpMethod.Post;
HttpResponseMessage message;
var client = new System.Net.Http.HttpClient();
using (var requestMessage = new HttpRequestMessage(method, BASE_URL + endpoint))
{
requestMessage.Content = new StringContent(
JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json"
);
requestMessage.Headers.Authorization = AuthHeader;
message = await client.SendAsync(requestMessage);
}
client.Dispose();
return await message.Content.ReadAsStringAsync();
}
}
public record CreateTransactionBody(string cardId, string receiverCard, string receiverName, string comment, int amount);
@@ -0,0 +1,195 @@
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Driver.Linq;
using SpMega.Backend.Persistent.Database;
using SpMega.Backend.Persistent.Models.Transactions;
using SpMega.Backend.Persistent.Models.Users;
namespace SpMega.Backend.Controllers.v1;
[ApiController]
[Route("/api/v1/webhook")]
public class WebhookController( AppDbContext dbContext, ILogger<WebhookController> logger, IConfiguration config) : ControllerBase
{
private const string BASE_URL = "https://spworlds.ru/api/public/";
[HttpPut("{cardId}")]
[Authorize]
public async Task<IActionResult> RegisterWebhookForCard(Guid cardId)
{
var user = HttpContext.Items["@me"] as User;
if (user == null) return Unauthorized();
var cardToUse = user.Cards.FirstOrDefault(c => c.Id == cardId);
if (cardToUse == null)
return BadRequest("Card not found");
var shortId = Program.GenerateRandomString(6);
while (true)
{
var a = user.Cards.FirstOrDefault(k => k.ShortId == shortId);
if (a != null)
{
shortId = Program.GenerateRandomString(6);
}
else
{
break;
}
}
cardToUse.ShortId = shortId;
cardToUse.WebhookConnected = true;
await dbContext.SaveChangesAsync();
var webhookUrl = new UriBuilder();
webhookUrl.Scheme = "https";
webhookUrl.Host = config["Url"];
webhookUrl.Path = $"api/v1/webhook/{user.ShortId}/{cardToUse.ShortId}/{cardId}";
var resp = await SendRequest($"card/webhook", new AuthenticationHeaderValue("Bearer", cardToUse.Token), HttpMethod.Put, new { url = webhookUrl.ToString() });
var jsonResponse = JsonSerializer.Deserialize<JsonObject>(resp);
if (jsonResponse != null && jsonResponse.TryGetPropertyValue("id", out var respId)
&& respId?.GetValue<Guid>() == cardId) return Ok();
logger.LogError("Failed to register webhook for card {CardId}. Response: {Response}", cardId, resp);
cardToUse.WebhookConnected = false;
await dbContext.SaveChangesAsync();
return BadRequest("Failed to register webhook for card");
}
[HttpPost("{userShortId}/{cardShortId}/{cardId}")]
public async Task<IActionResult> ReceiveWebhook(string userShortId, string cardShortId, Guid cardId)
{
var user = await dbContext.Users.FirstOrDefaultAsync(k => k.ShortId == userShortId);
if (user == null)
return BadRequest("User not found");
var cardToUse = user.Cards.FirstOrDefault(c => c.ShortId == cardShortId && c.Id == cardId && c.WebhookConnected == true);
if (cardToUse == null)
return BadRequest("Card not found");
var bodyHash = Request.Headers["X-Body-Hash"].ToString();
var rawBody = await new StreamReader(Request.Body).ReadToEndAsync();
var keyBytes = Encoding.UTF8.GetBytes(cardToUse.Token);
var messageBytes = Encoding.UTF8.GetBytes(rawBody);
using var hmac = new HMACSHA256(keyBytes);
var hashBytes = hmac.ComputeHash(messageBytes);
byte[] receivedHash;
try
{
receivedHash = Convert.FromBase64String(bodyHash);
}
catch (FormatException)
{
return BadRequest("Invalid body hash");
}
if (!CryptographicOperations.FixedTimeEquals(hashBytes, receivedHash))
{
logger.LogError("Invalid body hash for webhook. Expected: {ExpectedHash}, Received: {ReceivedHash}", Convert.ToBase64String(hashBytes), bodyHash);
return BadRequest("Invalid body hash");
}
var body = JsonSerializer.Deserialize<Webhook>(rawBody, new JsonSerializerOptions(JsonSerializerDefaults.Web));
if (body == null || !Guid.TryParse(body.Id, out var notificationId))
return BadRequest("Invalid webhook body");
if (await dbContext.Notifications.FirstOrDefaultAsync(notification => notification.Id == notificationId) != null)
return Ok();
var notify = new Notification
{
Id = notificationId,
ReceiverId = user.Id,
ReceiverName = body.Receiver.Username,
ReceiverNumber = body.Receiver.Number,
SenderName = body.Sender.Username,
SenderNumber = body.Sender.Number,
Comment = body.Comment,
Amount = body.Amount,
Type = body.Type,
IsRead = false,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
await dbContext.Notifications.AddAsync(notify);
await dbContext.SaveChangesAsync();
return Ok();
}
[HttpGet("read")]
[Authorize]
public async Task<ActionResult<List<Notification>>> ReadAllNotifications()
{
var user = HttpContext.Items["@me"] as User;
if (user == null) return Unauthorized();
var notifications = await dbContext.Notifications
.Where(notification => notification.ReceiverId == user.Id && !notification.IsRead)
.ToListAsync();
foreach (var notification in notifications)
{
notification.IsRead = true;
notification.UpdatedAt = DateTime.UtcNow;
}
if (notifications.Count > 0) await dbContext.SaveChangesAsync();
return Ok(notifications);
}
[HttpGet("all")]
[Authorize]
public async Task<ActionResult<List<Notification>>> GetAllNotifications([FromQuery] int after, [FromQuery] int limit = 20)
{
var user = HttpContext.Items["@me"] as User;
if (user == null) return Unauthorized();
var notification = await dbContext.Notifications.Where(k => k.ReceiverId == user.Id).Skip(after).Take(limit).ToListAsync();
return Ok(notification);
}
[NonAction]
internal async Task<string> SendRequest(string endpoint, AuthenticationHeaderValue AuthHeader, HttpMethod method = null, object body = null)
{
method ??= body == null ? HttpMethod.Get : HttpMethod.Post;
HttpResponseMessage message;
var client = new System.Net.Http.HttpClient();
using (var requestMessage = new HttpRequestMessage(method, BASE_URL + endpoint))
{
requestMessage.Content = new StringContent(
JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json"
);
requestMessage.Headers.Authorization = AuthHeader;
message = await client.SendAsync(requestMessage);
}
client.Dispose();
return await message.Content.ReadAsStringAsync();
}
}
public record Webhook(
string Id,
int Amount,
string Type,
WebhookUser Sender,
WebhookUser Receiver,
string Comment,
string CreatedAt
);
public record WebhookUser(string Username, string Number);
@@ -2,7 +2,6 @@
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
@@ -0,0 +1,51 @@
using System.Diagnostics;
namespace SpMega.Backend.Middleware;
public class RequestTelemetryMiddleware(RequestDelegate next, ILogger<RequestTelemetryMiddleware> logger)
{
private static readonly ActivitySource Source = new("SpMega.RequestTelemetry");
private const string RequestIdHeader = "X-Request-Id";
public async Task InvokeAsync(HttpContext context)
{
var path = context.Request.Path.Value ?? string.Empty;
if (path.Equals("/api/v1/telemetry", StringComparison.OrdinalIgnoreCase)
&& context.Request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase))
{
await next(context);
return;
}
var requestId = context.Request.Headers[RequestIdHeader].FirstOrDefault() ?? Guid.NewGuid().ToString("N");
context.Response.Headers[RequestIdHeader] = requestId;
using var activity = Source.StartActivity("http.request", ActivityKind.Server);
activity?.SetTag("request.id", requestId);
activity?.SetTag("http.method", context.Request.Method);
activity?.SetTag("http.path", path);
activity?.SetTag("http.scheme", context.Request.Scheme);
activity?.SetTag("http.host", context.Request.Host.Value ?? string.Empty);
activity?.SetTag("client.ip", context.Connection.RemoteIpAddress?.ToString());
activity?.SetTag("http.user_agent", context.Request.Headers.UserAgent.ToString());
activity?.SetTag("http.headers", string.Join(", ", context.Request.Headers.Select(h => $"{h.Key}: {h.Value}")));
var stopwatch = Stopwatch.StartNew();
try
{
await next(context);
}
finally
{
stopwatch.Stop();
activity?.SetTag("http.status_code", context.Response.StatusCode);
activity?.SetTag("http.duration_ms", stopwatch.ElapsedMilliseconds);
var userId = context.User?.Identity?.IsAuthenticated == true
? context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
: null;
if (userId is not null)
activity?.SetTag("user.id", userId);
}
}
}
@@ -0,0 +1,583 @@
@page "/privacy"
@model SpMega.Backend.AgreementPage
@{
Layout = null;
}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Пользовательское соглашение — SPMega</title>
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg-color: #0b0f17;
--surface-color: #121824;
--surface-border: rgba(255, 255, 255, 0.08);
--primary: #3b82f6;
--primary-glow: rgba(59, 130, 246, 0.15);
--success: #10b981;
--success-glow: rgba(16, 185, 129, 0.15);
--warning: #f59e0b;
--warning-glow: rgba(245, 158, 11, 0.1);
--text-main: #f3f4f6;
--text-muted: #9ca3af;
--text-link: #60a5fa;
--font-sans: 'Inter', sans-serif;
--font-display: 'Outfit', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
scroll-behavior: smooth;
}
body {
font-family: var(--font-sans);
background-color: var(--bg-color);
color: var(--text-main);
min-height: 100vh;
line-height: 1.6;
overflow-x: hidden;
display: flex;
flex-direction: column;
position: relative;
}
/* Subtle glowing background blobs */
.glow-blob {
position: absolute;
border-radius: 50%;
filter: blur(120px);
z-index: 0;
pointer-events: none;
opacity: 0.5;
}
.blob-1 {
width: 400px;
height: 400px;
background: rgba(59, 130, 246, 0.12);
top: -100px;
left: -100px;
}
.blob-2 {
width: 500px;
height: 500px;
background: rgba(16, 185, 129, 0.08);
bottom: -150px;
right: -100px;
}
.wrapper {
position: relative;
z-index: 1;
max-width: 1200px;
width: 100%;
margin: 0 auto;
padding: 40px 24px 80px;
flex-grow: 1;
}
/* Navigation Header */
header {
margin-bottom: 40px;
border-bottom: 1px solid var(--surface-border);
padding-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo-group {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
}
.logo-icon {
width: 36px;
height: 36px;
background: linear-gradient(135deg, var(--primary), var(--success));
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-family: var(--font-display);
color: #ffffff;
font-size: 18px;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.logo-text {
font-family: var(--font-display);
font-size: 24px;
font-weight: 800;
letter-spacing: 0.5px;
background: linear-gradient(to right, #ffffff, #9ca3af);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.meta-info {
text-align: right;
font-size: 12px;
color: var(--text-muted);
}
/* Layout Grid */
.layout-grid {
display: grid;
grid-template-columns: 280px 1fr;
gap: 40px;
align-items: start;
}
/* Sticky Sidebar Table of Contents */
.sidebar {
position: sticky;
top: 40px;
background: var(--surface-color);
border: 1px solid var(--surface-border);
border-radius: 12px;
padding: 24px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
}
.sidebar-title {
font-family: var(--font-display);
font-weight: 700;
font-size: 16px;
text-transform: uppercase;
letter-spacing: 1px;
color: #ffffff;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.toc-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 12px;
}
.toc-link {
color: var(--text-muted);
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
display: inline-block;
border-left: 2px solid transparent;
padding-left: 10px;
}
.toc-link:hover, .toc-link.active {
color: var(--primary);
border-left-color: var(--primary);
padding-left: 14px;
}
/* Legal Content */
.document-container {
background: var(--surface-color);
border: 1px solid var(--surface-border);
border-radius: 16px;
padding: 40px 48px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
animation: slideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
.doc-title {
font-family: var(--font-display);
font-size: 32px;
font-weight: 800;
line-height: 1.2;
margin-bottom: 16px;
background: linear-gradient(135deg, #ffffff, #e5e7eb);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.doc-subtitle {
font-size: 14px;
color: var(--text-muted);
margin-bottom: 32px;
display: flex;
gap: 16px;
}
.alert-box {
background: var(--warning-glow);
border-left: 4px solid var(--warning);
border-radius: 4px;
padding: 16px 20px;
margin-bottom: 36px;
font-size: 14px;
color: #fce7f3;
}
.alert-box strong {
color: var(--warning);
font-family: var(--font-display);
text-transform: uppercase;
letter-spacing: 0.5px;
display: block;
margin-bottom: 4px;
}
.doc-section {
margin-bottom: 40px;
scroll-margin-top: 40px;
}
.doc-section h2 {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
color: #ffffff;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 10px;
}
.doc-section h2::before {
content: '';
display: inline-block;
width: 6px;
height: 20px;
background: var(--primary);
border-radius: 2px;
}
.doc-section p {
color: rgba(243, 244, 246, 0.85);
margin-bottom: 16px;
font-size: 15px;
text-align: justify;
}
.doc-section ul {
list-style: none;
margin-left: 8px;
margin-bottom: 16px;
}
.doc-section li {
position: relative;
padding-left: 20px;
margin-bottom: 10px;
font-size: 14.5px;
color: rgba(243, 244, 246, 0.8);
}
.doc-section li::before {
content: "✓";
position: absolute;
left: 0;
color: var(--success);
font-weight: bold;
}
.badge {
background: rgba(255, 255, 255, 0.05);
padding: 4px 8px;
border-radius: 4px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-link);
}
/* Footer elements */
footer {
margin-top: auto;
border-top: 1px solid var(--surface-border);
padding: 24px 0;
text-align: center;
font-size: 12px;
color: var(--text-muted);
z-index: 1;
position: relative;
}
.footer-links {
display: flex;
justify-content: center;
gap: 20px;
margin-bottom: 12px;
}
.footer-link {
color: var(--text-muted);
text-decoration: none;
transition: color 0.2s ease;
}
.footer-link:hover {
color: #ffffff;
}
/* Scroll to top button */
.scroll-top {
position: fixed;
bottom: 30px;
right: 30px;
background: var(--primary);
color: #ffffff;
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
cursor: pointer;
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.4);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
opacity: 0;
pointer-events: none;
z-index: 99;
}
.scroll-top.visible {
opacity: 1;
pointer-events: auto;
}
.scroll-top:hover {
transform: translateY(-3px);
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.6);
}
/* Animations */
@@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
/* Responsive Design */
@@media (max-width: 960px) {
.layout-grid {
grid-template-columns: 1fr;
}
.sidebar {
display: none; /* Hide sidebar table of contents on smaller screens */
}
.document-container {
padding: 30px 24px;
}
}
@@media (max-width: 600px) {
header {
flex-direction: column;
gap: 16px;
text-align: center;
align-items: center;
}
.meta-info {
text-align: center;
}
.doc-title {
font-size: 24px;
}
.doc-subtitle {
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
}
}
</style>
</head>
<body>
<div class="glow-blob blob-1"></div>
<div class="glow-blob blob-2"></div>
<div class="wrapper">
<header>
<a href="#" class="logo-group">
<div class="logo-icon">S</div>
<div class="logo-text">SPMega</div>
</a>
<div class="meta-info">
<div>Версия: <span class="badge">1.0.1-2026</span></div>
<div style="margin-top: 4px;">Обновлено: 12 июля 2026</div>
</div>
</header>
<main class="layout-grid">
<!-- Sidebar Navigation -->
<aside class="sidebar">
<h3 class="sidebar-title">Содержание</h3>
<ul class="toc-list">
<li><a href="#intro" class="toc-link active" id="link-intro">1. Общие положения</a></li>
<li><a href="#minecraft-mod" class="toc-link" id="link-minecraft-mod">2. Использование мода</a></li>
<li><a href="#disclaimer" class="toc-link" id="link-disclaimer">3. Отказ от претензий</a></li>
<li><a href="#data-collection" class="toc-link" id="link-data-collection">4. Сбор данных</a></li>
<li><a href="#privacy" class="toc-link" id="link-privacy">5. Конфиденциальность</a></li>
<li><a href="#changes" class="toc-link" id="link-changes">6. Изменение соглашения</a></li>
</ul>
</aside>
<!-- Document Content -->
<div class="document-container">
<h1 class="doc-title">Пользовательское соглашение</h1>
<div class="doc-subtitle">
<span>Свод правил для пользователей модификации Minecraft и сервиса SPMega</span>
</div>
<div class="alert-box">
<strong>Важная информация</strong>
Установка, запуск модификации SPMega для Minecraft или посещение данного сайта означает ваше полное согласие со всеми пунктами данного соглашения. Если вы не согласны с какими-либо условиями, вы обязаны прекратить использование всех связанных сервисов и удалить модификацию.
</div>
<!-- Section 1 -->
<section id="intro" class="doc-section">
<h2>Общие положения</h2>
<p>Настоящее Соглашение является публичной офертой и регулирует порядок использования официального программного обеспечения (модификации для Minecraft), а также веб-сервисов и API, предоставляемых под эгидой проекта SPMega.</p>
<p>Соглашение заключается между конечным пользователем (вами) и разработчиками проекта (автором модификации и владельцами сайта). Нажимая кнопку запуска в лаунчере, заходя в игру с активным модом или посещая данный ресурс, вы подтверждаете свою дееспособность и согласие с правилами.</p>
</section>
<!-- Section 2 -->
<section id="minecraft-mod" class="doc-section">
<h2>Использование мода для Minecraft</h2>
<p>Модификация SPMega разработана для интеграции игрового процесса Minecraft с нашей платежной и статистической системой. Пользователю предоставляется бесплатное неисключительное право на использование модификации в личных некоммерческих целях.</p>
<p>При использовании модификации запрещено:</p>
<ul>
<li>Декомпилировать, модифицировать код или создавать клоны ПО без прямого письменного согласия разработчиков.</li>
<li>Использовать баги мода или веб-интерфейсов для накрутки баланса, валюты или совершения несанкционированных транзакций.</li>
<li>Использовать модификацию в связке с запрещенными сторонними программами (чит-клиенты, боты и т.д.).</li>
</ul>
</section>
<!-- Section 3 -->
<section id="disclaimer" class="doc-section">
<h2>Отказ от претензий (Дисклеймер)</h2>
<p><strong>ПРОГРАММНОЕ ОБЕСПЕЧЕНИЕ ПРЕДОСТАВЛЯЕТСЯ «КАК ЕСТЬ» (AS IS).</strong> Автор модификации и владельцы сайта снимают с себя всю ответственность за любой ущерб, возникший в результате использования мода или веб-ресурсов.</p>
<p>Пользователь осознает и соглашается, что:</p>
<ul>
<li>Автор не несет ответственности за сбои в игровом клиенте Minecraft, потерю игрового прогресса, повреждение файлов сохранений или несовместимость с другими модификациями.</li>
<li>Владельцы сайта не гарантируют 100% бесперебойную доступность серверов API и ведения транзакций.</li>
<li>Пользователь добровольно отказывается от любых финансовых, юридических, моральных или иных материальных претензий к авторам и администрации в случае технических сбоев.</li>
</ul>
</section>
<!-- Section 4 -->
<section id="data-collection" class="doc-section">
<h2>Сбор данных и статистики</h2>
<p>Для корректного функционирования игровых транзакций, выписок, защиты от мошенничества и ведения честной игровой статистики, сайт и модификация SPMega осуществляют автоматический сбор технических параметров.</p>
<p>Мы собираем и обрабатываем:</p>
<ul>
<li>Ваш уникальный идентификатор Minecraft (<span class="badge">UUID</span>) и текущий никнейм.</li>
<li>Время первой и последующих авторизаций.</li>
<li>Ваш текущий IP-адрес для предотвращения и выявления попыток взлома API.</li>
<li>Техническую информацию о транзакциях (суммы, комментарии, номера виртуальных счетов/карт и идентификаторы).</li>
<li>Технические характеристики устройства: процессор, видеокарту, объем оперативной памяти, операционную систему и название компьютера.</li>
<li>Показатели производительности мода, включая FPS и тайминги запросов.</li>
<li>Трассировочную информацию о запросах к серверной части SPMega для диагностики ошибок и улучшения качества мода.</li>
</ul>
<p>Количество передаваемой телеметрии можно уменьшить в настройках модификации.</p>
<p>Мы не сохраняем:</p>
<ul>
<li>Токен карты «КАК ЕСТЬ», данные шифруются по протоколу AES-256-CBC, и только после этого сохраняются в базе данных.</li>
<li>Любые личные данные, кроме указанных выше</li>
</ul>
</section>
<!-- Section 5 -->
<section id="privacy" class="doc-section">
<h2>Конфиденциальность и безопасность данных</h2>
<p>Мы высоко ценим доверие наших пользователей и гарантируем сохранность собираемой информации. Все личные данные передаются и обрабатываются по защищенным зашифрованным каналам связи (HTTPS/SSL).</p>
<p>Администрация сайта строго обязуется:</p>
<ul>
<li><strong>Не передавать, не продавать и не распространять</strong> ваши личные данные, IP-адреса, телеметрию и статистику третьим лицам.</li>
<li>Использовать собранную анонимизированную статистику исключительно во внутренних аналитических целях для улучшения производительности мода.</li>
<li>Раскрывать информацию только в случаях, прямо предусмотренных действующим законодательством государства Израиль при наличии официальных судебных или правоохранительных запросов.</li>
</ul>
<p>По юридическим вопросам свяжитесь с нами по электронной почте <a href="mailto:yawaflua@outlook.co.il">yawaflua&#64;outlook.co.il</a> или в Telegram: <a href="https://t.me/meharmen">&#64;meharmen</a>.</p>
</section>
<!-- Section 6 -->
<section id="changes" class="doc-section">
<h2>Изменение условий соглашения</h2>
<p>Администрация оставляет за собой право в одностороннем порядке вносить изменения в настоящее Пользовательское соглашение без предварительного индивидуального уведомления пользователей.</p>
<p>Новая редакция Соглашения вступает в силу с момента ее публикации на данной веб-странице. Мы рекомендуем периодически проверять условия соглашения, чтобы оставаться в курсе актуальных правил использования.</p>
</section>
</div>
</main>
</div>
<footer>
<div class="footer-links">
<a href="/00000000" class="footer-link">Проверить чек</a>
<span>•</span>
<a href="https://t.me/meharmen" class="footer-link">Поддержка</a>
</div>
<div>&copy; 2026 Dmitri Shimanski. Все права защищены. NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT..</div>
</footer>
<!-- Scroll to Top Button -->
<button class="scroll-top" id="scrollTopBtn" onclick="window.scrollTo({top: 0, behavior: 'smooth'})">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
</svg>
</button>
<script>
// Smooth scroll spy to highlight active sidebar links
window.addEventListener('DOMContentLoaded', () => {
const sections = document.querySelectorAll('.doc-section');
const navLinks = document.querySelectorAll('.toc-link');
const scrollTopBtn = document.getElementById('scrollTopBtn');
function highlightNav() {
let current = '';
// Show/hide scroll top button
if (window.scrollY > 300) {
scrollTopBtn.classList.add('visible');
} else {
scrollTopBtn.classList.remove('visible');
}
// Scroll spy logic
sections.forEach(section => {
const sectionTop = section.offsetTop;
const sectionHeight = section.clientHeight;
if (window.scrollY >= sectionTop - 120) {
current = section.getAttribute('id');
}
});
navLinks.forEach(link => {
link.classList.remove('active');
if (link.getAttribute('href') === `#${current}`) {
link.classList.add('active');
}
});
}
window.addEventListener('scroll', highlightNav);
highlightNav(); // Trigger once on load
});
</script>
</body>
</html>
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace SpMega.Backend;
public class AgreementPage : PageModel
{
public void OnGet()
{
}
}
@@ -0,0 +1,688 @@
@page "/{id?}"
@model SpMega.Backend.Pages.v1.BillPage
@{
Layout = null;
}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Чек транзакции — SPMega</title>
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-gradient: radial-gradient(circle at 50% 50%, #1a1e29 0%, #0d0f14 100%);
--paper-color: #faf9f6;
--text-dark: #1f2937;
--text-muted: #6b7280;
--primary: #10b981;
--primary-hover: #059669;
--error: #ef4444;
--border-color: #e5e7eb;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', sans-serif;
background: var(--bg-gradient);
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 20px;
color: #ffffff;
overflow-x: hidden;
}
/* Ambient glowing background */
.ambient-glow {
position: absolute;
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(16, 185, 129, 0.08) 0%, rgba(0,0,0,0) 70%);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 0;
pointer-events: none;
}
.receipt-wrapper {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 400px;
animation: fadeIn 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Receipt paper style */
.receipt-paper {
background-color: var(--paper-color);
color: var(--text-dark);
width: 100%;
padding: 40px 28px 30px;
position: relative;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
}
/* Torn paper effect using CSS linear gradients */
.receipt-paper::before, .receipt-paper::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: 12px;
background-size: 16px 16px;
background-repeat: repeat-x;
z-index: 2;
}
.receipt-paper::before {
top: -12px;
background-image: linear-gradient(135deg, transparent 25%, var(--paper-color) 25%, var(--paper-color) 75%, transparent 75%),
linear-gradient(225deg, transparent 25%, var(--paper-color) 25%, var(--paper-color) 75%, transparent 75%);
}
.receipt-paper::after {
bottom: -12px;
background-image: linear-gradient(45deg, transparent 25%, var(--paper-color) 25%, var(--paper-color) 75%, transparent 75%),
linear-gradient(-45deg, transparent 25%, var(--paper-color) 25%, var(--paper-color) 75%, transparent 75%);
}
/* Stamp style */
.stamp {
position: absolute;
top: 22%;
right: 10%;
border: 3px double var(--primary);
color: var(--primary);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
padding: 4px 10px;
transform: rotate(-15deg);
opacity: 0.85;
border-radius: 4px;
user-select: none;
letter-spacing: 2px;
pointer-events: none;
background: rgba(250, 249, 246, 0.8);
box-shadow: 0 0 0 2px var(--primary);
animation: stampIn 0.8s cubic-bezier(0.175, 0.885, 0.32, 1.275) 0.5s both;
}
.stamp.error {
border-color: var(--error);
color: var(--error);
box-shadow: 0 0 0 2px var(--error);
}
/* Header elements */
.receipt-header {
text-align: center;
margin-bottom: 24px;
}
.receipt-logo {
font-family: 'Inter', sans-serif;
font-weight: 800;
font-size: 20px;
letter-spacing: 1px;
color: #111827;
margin-bottom: 4px;
}
.receipt-subtitle {
font-size: 10px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
}
.divider {
border-top: 2px dashed #d1d5db;
margin: 20px 0;
position: relative;
}
.divider::before, .divider::after {
content: '';
position: absolute;
width: 10px;
height: 10px;
background: #0d0f14; /* cutouts on sides */
border-radius: 50%;
top: -6px;
z-index: 3;
}
.divider::before { left: -34px; }
.divider::after { right: -34px; }
/* Meta block */
.meta-row {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
gap: 12px;
}
.meta-label {
color: var(--text-muted);
flex-shrink: 0;
}
.meta-value {
text-align: right;
word-break: break-all;
color: #111827;
}
.meta-value.bold {
font-weight: 700;
}
/* Amount styling */
.amount-section {
text-align: center;
padding: 15px 0;
}
.amount-title {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
margin-bottom: 6px;
letter-spacing: 0.5px;
}
.amount-val {
font-size: 32px;
font-weight: 700;
color: #111827;
}
/* Comments box */
.comment-box {
background: rgba(0, 0, 0, 0.03);
border-left: 3px solid #9ca3af;
padding: 10px 12px;
margin-top: 15px;
font-style: italic;
color: #374151;
border-radius: 2px;
}
/* Footer tools */
.receipt-footer {
text-align: center;
margin-top: 30px;
}
.qr-code-wrapper {
margin: 15px auto;
width: 110px;
height: 110px;
background: #ffffff;
padding: 5px;
border: 1px solid #e5e7eb;
display: flex;
justify-content: center;
align-items: center;
border-radius: 4px;
}
.qr-code-wrapper img {
width: 100%;
height: 100%;
}
.barcode {
width: 100%;
height: 40px;
margin: 15px auto 4px;
background: repeating-linear-gradient(
90deg,
#111827,
#111827 2px,
transparent 2px,
transparent 5px,
#111827 5px,
#111827 6px,
transparent 6px,
transparent 9px,
#111827 9px,
#111827 11px,
transparent 11px,
transparent 13px
);
opacity: 0.85;
}
.barcode-text {
font-size: 9px;
color: var(--text-muted);
letter-spacing: 4px;
text-transform: uppercase;
}
/* Action buttons */
.action-buttons {
display: flex;
gap: 12px;
width: 100%;
margin-top: 30px;
z-index: 1;
}
.btn {
flex: 1;
padding: 12px 16px;
border-radius: 8px;
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 600;
border: none;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s ease;
text-decoration: none;
}
.btn-primary {
background-color: var(--primary);
color: #ffffff;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
.btn-primary:hover {
background-color: var(--primary-hover);
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.4);
}
.btn-secondary {
background-color: rgba(255, 255, 255, 0.08);
color: #e5e7eb;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.btn-secondary:hover {
background-color: rgba(255, 255, 255, 0.15);
color: #ffffff;
transform: translateY(-1px);
}
.btn:active {
transform: translateY(0);
}
/* Loading / Error skeletons */
.loader-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 50px 0;
width: 100%;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(16, 185, 129, 0.1);
border-radius: 50%;
border-top-color: var(--primary);
animation: spin 1s ease-in-out infinite;
margin-bottom: 16px;
}
.error-state {
text-align: center;
padding: 30px 10px;
}
.error-icon {
font-size: 40px;
color: var(--error);
margin-bottom: 16px;
}
/* Toast notification */
.toast {
position: fixed;
bottom: 24px;
background: rgba(17, 24, 39, 0.9);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #ffffff;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
z-index: 100;
display: flex;
align-items: center;
gap: 8px;
transform: translateY(100px);
opacity: 0;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.toast.show {
transform: translateY(0);
opacity: 1;
}
/* Animations */
@@keyframes fadeIn {
from { opacity: 0; transform: translateY(15px); }
to { opacity: 1; transform: translateY(0); }
}
@@keyframes stampIn {
from { opacity: 0; transform: rotate(-25deg) scale(1.6); filter: blur(2px); }
to { opacity: 0.85; transform: rotate(-15deg) scale(1); filter: blur(0); }
}
@@keyframes spin {
to { transform: rotate(360deg); }
}
/* Printable stylesheet */
@@media print {
body {
background: #ffffff !important;
color: #000000 !important;
padding: 0;
margin: 0;
display: block;
}
.ambient-glow, .action-buttons, .toast {
display: none !important;
}
.receipt-wrapper {
max-width: 100%;
box-shadow: none !important;
transform: none !important;
animation: none !important;
}
.receipt-paper {
box-shadow: none !important;
padding: 10px;
border: none;
margin: 0 auto;
background: #ffffff !important;
}
.receipt-paper::before, .receipt-paper::after {
display: none !important;
}
.divider::before, .divider::after {
display: none !important;
}
.stamp {
background: none !important;
border-style: solid !important;
}
}
</style>
</head>
<body>
<div class="ambient-glow"></div>
<div class="receipt-wrapper">
<div class="receipt-paper" id="receiptContent">
<!-- Dynamic Content Injected Here -->
<div class="loader-container" id="loadingState">
<div class="spinner"></div>
<div style="color: var(--text-muted)">Получение данных чека...</div>
</div>
</div>
<div class="action-buttons" id="actionButtons" style="display: none;">
<button class="btn btn-secondary" onclick="copyReceiptLink()">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
Ссылка
</button>
<button class="btn btn-primary" onclick="window.print()">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-3a2 2 0 00-2-2H9a2 2 0 00-2 2v3a2 2 0 002 2zm5-17v2m-6 4h12" />
</svg>
Печать чека
</button>
</div>
</div>
<!-- Copy Toast -->
<div class="toast" id="copyToast">
<svg width="18" height="18" fill="none" stroke="var(--primary)" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Ссылка скопирована в буфер обмена</span>
</div>
<script>
function getTransactionId() {
const urlParams = new URLSearchParams(window.location.search);
const idParam = urlParams.get('billId') || urlParams.get('id') || urlParams.get('transactionId');
if (idParam) return idParam;
const pathSegments = window.location.pathname.split('/').filter(p => p);
if (pathSegments.length > 0) {
return pathSegments[pathSegments.length - 1];
}
return null;
}
function formatDate(isoString) {
try {
const date = new Date(isoString);
if (isNaN(date.getTime())) return isoString;
const pad = (n) => String(n).padStart(2, '0');
const d = pad(date.getDate());
const m = pad(date.getMonth() + 1);
const y = date.getFullYear();
const h = pad(date.getHours());
const min = pad(date.getMinutes());
const s = pad(date.getSeconds());
return `${d}.${m}.${y} ${h}:${min}:${s}`;
} catch (e) {
return isoString;
}
}
async function loadBill() {
const billId = getTransactionId();
const receiptContentEl = document.getElementById('receiptContent');
const actionButtonsEl = document.getElementById('actionButtons');
if (!billId) {
renderError('Идентификатор транзакции не указан. Пожалуйста, укажите ShortId в URL-адресе.');
return;
}
try {
// Fetch transaction details from API
const response = await fetch(`/api/v1/transactions/${billId}`);
if (!response.ok) {
throw new Error(`Ошибка сервера: ${response.status}`);
}
const transaction = await response.json();
// Success stamp and details rendering
const formattedDate = formatDate(transaction.transactionDate || new Date().toISOString());
const senderCard = (transaction.senderCardNumber);
const receiverCard = (transaction.receiverCardNumber);
let commentHtml = '';
if (transaction.comment && transaction.comment.trim() !== '') {
commentHtml = `
<div class="comment-box">
<strong>Назначение:</strong><br/>
${escapeHtml(transaction.comment)}
</div>
`;
}
// Make dynamic QR code
const qrUrl = encodeURIComponent(window.location.href);
const qrCodeSrc = `https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${qrUrl}&color=111827&bgcolor=faf9f6`;
receiptContentEl.innerHTML = `
<!-- Inked stamp of completion -->
<div class="stamp">Оплачено</div>
<div class="receipt-header">
<div class="receipt-logo">SPMEGA PAY</div>
<div class="receipt-subtitle">Электронная квитанция</div>
</div>
<div class="divider"></div>
<div class="amount-section">
<div class="amount-title">Сумма платежа</div>
<div class="amount-val">${Number(transaction.amount)} АР</div>
</div>
<div class="divider"></div>
<div class="meta-row">
<span class="meta-label">ID транзакции:</span>
<span class="meta-value" style="font-size: 11px;">${transaction.id}</span>
</div>
<div class="meta-row">
<span class="meta-label">Дата & Время:</span>
<span class="meta-value">${formattedDate}</span>
</div>
<div class="divider"></div>
<div class="meta-row">
<span class="meta-label">Отправитель:</span>
<span class="meta-value bold">${escapeHtml(transaction.senderMinecraftName || 'Клиент SPMega')}</span>
</div>
<div class="meta-row">
<span class="meta-label">Карта списания:</span>
<span class="meta-value">${senderCard}</span>
</div>
<div class="divider" style="margin: 10px 0; border-top: 1px dotted #9ca3af;"></div>
<div class="meta-row">
<span class="meta-label">Получатель:</span>
<span class="meta-value bold">${escapeHtml(transaction.receiverName || 'Не указан')}</span>
</div>
<div class="meta-row">
<span class="meta-label">Карта зачисления:</span>
<span class="meta-value">${receiverCard}</span>
</div>
${commentHtml}
<div class="divider"></div>
<div class="receipt-footer">
<div style="font-size: 10px; color: var(--text-muted); margin-bottom: 12px; text-transform: uppercase;">
Сканируйте для проверки
</div>
<div class="qr-code-wrapper">
<img src="${qrCodeSrc}" alt="QR-код чека" onerror="this.parentElement.style.display='none'" />
</div>
<div class="barcode"></div>
<div class="barcode-text">spmega*transaction*chk</div>
<div style="font-size: 10px; color: var(--text-muted); margin-top: 15px;">
Спасибо за то, что вы с нами!
</div>
</div>
`;
// Display operations buttons
actionButtonsEl.style.display = 'flex';
} catch (err) {
console.error(err);
renderError('Не удалось загрузить чек. Убедитесь, что GUID транзакции верен и сервер доступен.');
}
}
function renderError(message) {
const receiptContentEl = document.getElementById('receiptContent');
receiptContentEl.innerHTML = `
<div class="stamp error">Ошибка</div>
<div class="receipt-header">
<div class="receipt-logo">SPMEGA PAY</div>
<div class="receipt-subtitle">Проблема с транзакцией</div>
</div>
<div class="divider"></div>
<div class="error-state">
<div class="error-icon">⚠</div>
<div style="font-weight: 600; color: #111827; margin-bottom: 8px;">Чек не найден</div>
<div style="color: var(--text-muted); font-size: 12px; line-height: 1.5;">${escapeHtml(message)}</div>
</div>
<div class="divider"></div>
<div class="receipt-footer">
<div class="barcode"></div>
<div class="barcode-text">error*code*404</div>
</div>
`;
}
function escapeHtml(text) {
if (!text) return '';
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return String(text).replace(/[&<>"']/g, function(m) { return map[m]; });
}
function copyReceiptLink() {
navigator.clipboard.writeText(window.location.href).then(() => {
const toast = document.getElementById('copyToast');
toast.classList.add('show');
setTimeout(() => {
toast.classList.remove('show');
}, 3000);
}).catch(err => {
console.error('Could not copy text: ', err);
});
}
// Initialize loading on DOM ready
window.addEventListener('DOMContentLoaded', loadBill);
</script>
</body>
</html>
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace SpMega.Backend.Pages.v1;
public class BillPage : PageModel
{
public void OnGet()
{
}
}
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
using MongoDB.EntityFrameworkCore.Extensions;
using SpMega.Backend.Persistent.Models.Telemetry;
using SpMega.Backend.Persistent.Models.Transactions;
using SpMega.Backend.Persistent.Models.Users;
using SpMega.Backend.Services;
namespace SpMega.Backend.Persistent.Database;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
internal DbSet<Transaction> Transactions { get; set; }
internal DbSet<User> Users { get; set; }
internal DbSet<UserSession> UserSessions { get; set; }
internal DbSet<Notification> Notifications { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
}
protected override void OnModelCreating(ModelBuilder 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)
);
});
}
}
@@ -0,0 +1,16 @@
using System.Text.Json;
namespace SpMega.Backend.Persistent.Models.DTO;
public record ModTelemetryEventDto(
string EventType,
DateTimeOffset Timestamp,
JsonElement Payload
);
public record ModTelemetryBatchDto(
string ClientVersion,
string SessionId,
DateTimeOffset SentAt,
List<ModTelemetryEventDto> Events
);
@@ -0,0 +1,15 @@
namespace SpMega.Backend.Persistent.Models.DTO;
public class PropertyDto
{
public string Name { get; set; }
public string Value { get; set; }
public string Signature { get; set; }
}
public class MojangDto
{
public string Id { get; set; }
public string Name { get; set; }
public List<PropertyDto> Properties { get; set; }
}
@@ -0,0 +1,33 @@
using SPWorldsApi.Types.Interfaces;
namespace SpMega.Backend.Persistent.Models.DTO;
public class UserAccountDTO
{
public string id { get; set; }
public string username { get; set; }
public string minecraftUUID { get; set; }
public string status { get; set; }
public List<string> roles { get; set; }
public CityDTO city { get; set; }
public List<UserCardDTO> cards { 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; }
}
@@ -0,0 +1,19 @@
using MongoDB.Bson;
namespace SpMega.Backend.Persistent.Models.Telemetry;
public class BackendRequestTelemetryDocument
{
public ObjectId Id { get; set; }
public string RequestId { get; set; } = string.Empty;
public string Method { get; set; } = string.Empty;
public string Path { get; set; } = string.Empty;
public string Scheme { get; set; } = string.Empty;
public string Host { get; set; } = string.Empty;
public string? ClientIp { get; set; }
public string? UserAgent { get; set; }
public int StatusCode { get; set; }
public long DurationMs { get; set; }
public string? UserId { get; set; }
public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow;
}
@@ -0,0 +1,21 @@
using MongoDB.Bson;
namespace SpMega.Backend.Persistent.Models.Telemetry;
public class ModTelemetryDocument
{
public ObjectId Id { get; set; }
public string UserId { get; set; } = string.Empty;
public string SessionId { get; set; } = string.Empty;
public string ClientVersion { get; set; } = string.Empty;
public DateTimeOffset SentAt { get; set; }
public DateTimeOffset ReceivedAt { get; set; } = DateTimeOffset.UtcNow;
public List<ModTelemetryEventDocument> Events { get; set; } = [];
}
public class ModTelemetryEventDocument
{
public string EventType { get; set; } = string.Empty;
public DateTimeOffset Timestamp { get; set; }
public string PayloadJson { get; set; } = "{}";
}
@@ -0,0 +1,21 @@
using SpMega.Backend.Persistent.Models.Users;
namespace SpMega.Backend.Persistent.Models.Transactions;
public class Notification
{
public Guid Id { get; set; }
public Guid ReceiverId { get; set; }
public string ReceiverName { get; set; }
public string ReceiverNumber { get; set; }
public string SenderName { get; set; }
public string SenderNumber { get; set; }
public string Comment { get; set; }
public int Amount { get; set; }
public string Type { get; set; }
public bool IsRead { get; set; } = false;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using SpMega.Backend.Persistent.Models.Users;
namespace SpMega.Backend.Persistent.Models.Transactions;
public class Transaction
{
public Guid Id { get; set; }
public string ShortId { get; set; } = Program.GenerateRandomString(8);
[MaxLength(2048)] public string ReceiverName { get; set; }
[MaxLength(2048)] public string ReceiverCardNumber { get; set; }
[JsonIgnore] public User Sender { get; set; }
[MaxLength(2048)] public string SenderMinecraftName { get; set; }
[MaxLength(2048)] public string SenderCardNumber { get; set; }
public int Amount { get; set; } = 0;
[MaxLength(2048)] public string Comment { get; set; } = "";
public DateTime TransactionDate { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
namespace SpMega.Backend.Persistent.Models.Users;
[Owned]
public class Card
{
public Guid Id { get; set; }
public string Name { get; set; }
public string SpworldsID { get; set; }
public string Token { get; set; }
public string? ShortId { get; set; } = "";
public bool? WebhookConnected { get; set; } = false;
public int? Balance { get; set; } = -1;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; }
public DateTime? DeletedAt { get; set; }
public bool IsDeleted { get; set; } = false;
}
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
using SpMega.Backend.Persistent.Models.Transactions;
namespace SpMega.Backend.Persistent.Models.Users;
public class User
{
public Guid Id { get; set; }
[MaxLength(2048)] public string Username { get; set; }
[MaxLength(2048)] public string Token { get; set; }
[MaxLength(2048)] public string? ShortId { get; set; }
public List<Card> Cards { get; set; } = [];
public List<Transaction> Transactions { get; set; } = [];
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; }
public DateTime? DeletedAt { get; set; }
public bool IsDeleted { get; set; } = false;
}
@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
namespace SpMega.Backend.Persistent.Models.Users;
public class UserSession
{
public Guid Id { get; set; }
[MaxLength(2048)] public string Token { get; set; }
public User? User { get; set; } = null;
public Guid UserId { get; set; }
[MaxLength(2048)] public string UserName { get; set; }
public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; }
public DateTime? DeletedAt { get; set; }
public bool IsDeleted { get; set; } = false;
}
+136
View File
@@ -0,0 +1,136 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using MongoDB.Driver;
using OpenTelemetry.Exporter;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using SpMega.Backend.Authetication;
using SpMega.Backend.Middleware;
using SpMega.Backend.Persistent.Database;
using SpMega.Backend.Services;
namespace SpMega.Backend;
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Configuration
.AddJsonFile("appsettings.json", true)
.AddJsonFile("appsettings.Development.json", true)
.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.AddLogging(logging =>
{
logging.AddConsole();
logging.AddDebug();
});
builder.Services
.AddScoped<TokenService>()
.AddRazorPages();
builder.Services
.AddHttpLogging(logging =>
{
logging.LoggingFields = Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.All;
})
.AddRouting()
.AddControllers();
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtAuthenticationSchemeOptions.DefaultScheme;
options.DefaultChallengeScheme = JwtAuthenticationSchemeOptions.DefaultScheme;
})
.AddScheme<JwtAuthenticationSchemeOptions, JwtAuthHandler>(
JwtAuthenticationSchemeOptions.DefaultScheme,
options => { });
builder.Services.AddAuthorizationBuilder()
.SetDefaultPolicy(new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build());
var mongoClient =
new MongoClient(builder.Configuration.GetValue<string>("Mongo") ?? "mongodb://curiosity:27018");
builder.Services.AddSingleton<IMongoClient>(mongoClient);
builder.Services.AddDbContext<AppDbContext>((_, options) =>
{
options.UseMongoDB(mongoClient, "spmega");
});
var otlpEndpoint = (conf["Otlp:Endpoint"] ?? conf["Otlp__Endpoint"] ?? "http://curiosity:5080/api/default").TrimEnd('/');
var otlpHeaders = conf["Otlp:Headers"] ?? conf["Otlp__Headers"] ?? throw new InvalidOperationException("Otlp__Headers is not configured.");
var tracesEndpoint = new Uri($"{otlpEndpoint}/v1/traces");
var metricsEndpoint = new Uri($"{otlpEndpoint}/v1/metrics");
builder.Services.AddOpenTelemetry()
.ConfigureResource(res => res.AddService("SpMega.Backend"))
.WithTracing(tracing =>
{
tracing
.AddSource("SpMega.ModTelemetry")
.AddSource("SpMega.RequestTelemetry")
.AddAspNetCoreInstrumentation()
.AddOtlpExporter(opts =>
{
opts.Protocol = OtlpExportProtocol.HttpProtobuf;
opts.Endpoint = tracesEndpoint;
opts.Headers = otlpHeaders;
});
})
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddMeter(
"Microsoft.AspNetCore.Hosting",
"Microsoft.AspNetCore.Server.Kestrel",
"System.Net.Http",
"System.Runtime")
.AddOtlpExporter(opts =>
{
opts.Protocol = OtlpExportProtocol.HttpProtobuf;
opts.Endpoint = metricsEndpoint;
opts.Headers = otlpHeaders;
}));
var app = builder.Build();
app.UseMiddleware<RequestTelemetryMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
app.UseWhen(ctx => ctx.Request.Path.StartsWithSegments("/api"), appBuilder =>
{
appBuilder.UseHttpLogging();
});
app.MapControllers();
app.MapRazorPages();
app.Run();
}
public static string GenerateRandomString(int length)
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
var result = new StringBuilder(length);
for (var i = 0; i < length; i++)
{
var index = RandomNumberGenerator.GetInt32(chars.Length);
result.Append(chars[index]);
}
return result.ToString();
}
}
@@ -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;
}
}
}
@@ -0,0 +1,40 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
namespace SpMega.Backend.Services;
public class TokenService(IConfiguration conf)
{
private const int AccessTokenExpirationMinutes = 24;
private readonly string _issuer = conf["JWT:Issuer"] ?? conf["JWT__Issuer"] ?? "spmega.il.yawaflua.tech";
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)
{
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Name, userName),
new (JwtRegisteredClaimNames.UniqueName, uuid.ToString()),
new(JwtRegisteredClaimNames.AuthTime, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.ASCII.GetBytes(_secret);
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.AddHours(AccessTokenExpirationMinutes),
Issuer = _issuer,
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature),
IssuedAt = DateTime.UtcNow
};
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
}
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
<ItemGroup>
<Folder Include="Controllers\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.19.1" />
<PackageReference Include="MongoDB.EntityFrameworkCore" Version="10.0.2" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageReference Include="spworlds-api" Version="1.0.3" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
</ItemGroup>
</Project>
+3
View File
@@ -0,0 +1,3 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APaymentWrapper_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F28d7b99649ed43d899c3d8518f41c1976400_003F94_003Fbc0e580e_003FPaymentWrapper_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASPWorlds_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F28d7b99649ed43d899c3d8518f41c1976400_003F64_003Fd3041423_003FSPWorlds_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>
+3 -7
View File
@@ -28,17 +28,11 @@ fabricApi {
}
repositories {
// Add repositories to retrieve artifacts from in here.
// You should only use this when depending on other mods because
// Loom adds the essential maven repositories to download Minecraft and libraries from automatically.
// See https://docs.gradle.org/current/userguide/declaring_repositories.html
// for more information about repositories.
maven { url "https://maven.shedaniel.me/" }
maven { url "https://maven.terraformersmc.com/" }
}
dependencies {
// To change the versions see the gradle.properties file
minecraft "com.mojang:minecraft:${project.minecraft_version}"
mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2"
modImplementation "net.fabricmc:fabric-loader:${project.loader_version}"
@@ -49,6 +43,8 @@ dependencies {
exclude(group: "net.fabricmc.fabric-api")
}
modCompileOnly "com.terraformersmc:modmenu:11.0.3"
include(implementation("org.xerial:sqlite-jdbc:3.46.1.3"))
include(clientImplementation("com.google.zxing:core:${project.zxing_version}"))
include(clientImplementation("com.google.zxing:javase:${project.zxing_version}"))
+1 -1
View File
@@ -6,7 +6,7 @@ minecraft_version=1.21.11
yarn_mappings=1.21.11+build.4
loader_version=0.18.1
# Mod Properties
mod_version=0.2-pre-alpha
mod_version=0.5-beta
maven_group=git.yawaflua.tech
archives_base_name=SPMega
# Dependencies
-4
View File
@@ -1,4 +0,0 @@
SpMega.Backend/src/*
SpMega.Backend/obj/*
SpMega.Backend/appsettings.json
SpMega.Backend/SpMega.Backend.http
-39
View File
@@ -1,39 +0,0 @@
namespace SpMega.Backend;
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddAuthorization();
var app = builder.Build();
// Configure the HTTP request pipeline.
app.UseAuthorization();
var summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
app.MapGet("/weatherforecast", (HttpContext httpContext) =>
{
var forecast = Enumerable.Range(1, 5).Select(index =>
new WeatherForecast
{
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = summaries[Random.Shared.Next(summaries.Length)]
})
.ToArray();
return forecast;
});
app.Run();
}
}
@@ -1,16 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
</Project>
@@ -1,6 +0,0 @@
@SpMega.Backend_HostAddress = http://localhost:5129
GET {{SpMega.Backend_HostAddress}}/weatherforecast/
Accept: application/json
###
@@ -1,12 +0,0 @@
namespace SpMega.Backend;
public class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string? Summary { get; set; }
}
@@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -1,9 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
@@ -9,7 +9,6 @@ import net.minecraft.text.Text;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Pattern;
public class ChatListener {
@@ -57,8 +56,7 @@ public class ChatListener {
if (client.player != null) {
String playerUuid = client.player.getUuidAsString();
CompletableFuture
.supplyAsync(() -> BankUiService.instance().addCard(cardId, tokenId, playerUuid))
BankUiService.instance().addCardAsync(cardId, tokenId, playerUuid)
.thenAccept(msg -> {
if (client == null) {
return;
@@ -0,0 +1,139 @@
package git.yawaflua.tech.spmega.client;
import com.terraformersmc.modmenu.api.ConfigScreenFactory;
import com.terraformersmc.modmenu.api.ModMenuApi;
import git.yawaflua.tech.spmega.GpsHudPosition;
import git.yawaflua.tech.spmega.ModConfig;
import git.yawaflua.tech.spmega.SPMega;
import git.yawaflua.tech.spmega.client.ui.service.BackendAuthenticator;
import me.shedaniel.clothconfig2.api.ConfigBuilder;
import me.shedaniel.clothconfig2.api.ConfigCategory;
import me.shedaniel.clothconfig2.api.ConfigEntryBuilder;
import net.minecraft.client.MinecraftClient;
import net.minecraft.text.Text;
import net.minecraft.util.Formatting;
public class ModMenuIntegration implements ModMenuApi {
@Override
public ConfigScreenFactory<?> getModConfigScreenFactory() {
return parent -> {
ConfigBuilder builder = ConfigBuilder.create()
.setParentScreen(parent)
.setTitle(Text.translatable("title.spmega.config"));
ConfigEntryBuilder entryBuilder = builder.entryBuilder();
ConfigCategory general = builder.getOrCreateCategory(Text.translatable("category.spmega.general"));
ModConfig current = SPMega.getConfig();
if (current == null) {
current = ModConfig.createDefault();
}
final String[] apiDomain = {current.apiDomain()};
final String[] apiToken = {current.apiToken()};
final boolean[] allowBackend = {current.allowBackend()};
final boolean[] signQuickPayEnabled = {current.signQuickPayEnabled()};
final GpsHudPosition[] gpsPosition = {current.gpsPosition()};
final GpsHudPosition[] notificationPosition = {current.notificationPosition()};
final int[] telemetryIntervalSeconds = {current.telemetryIntervalSeconds()};
final boolean[] telemetryCollectSystemInfo = {current.telemetryCollectSystemInfo()};
general.addEntry(entryBuilder.startStrField(Text.translatable("option.spmega.api_domain"), current.apiDomain())
.setDefaultValue(ModConfig.DEFAULT_API_DOMAIN)
.setSaveConsumer(newValue -> apiDomain[0] = newValue)
.build());
general.addEntry(entryBuilder.startStrField(Text.translatable("option.spmega.api_token"), current.apiToken())
.setDefaultValue(ModConfig.DEFAULT_API_TOKEN)
.setSaveConsumer(newValue -> apiToken[0] = newValue)
.build());
general.addEntry(entryBuilder.startBooleanToggle(Text.translatable("option.spmega.allow_backend"), current.allowBackend())
.setDefaultValue(ModConfig.ALLOW_BACKEND)
.setSaveConsumer(newValue -> allowBackend[0] = newValue)
.build());
boolean isLoggedIn = !current.apiToken().isEmpty() && !current.apiToken().equals(ModConfig.DEFAULT_API_TOKEN);
general.addEntry(entryBuilder.startBooleanToggle(Text.literal("Авторизоваться").formatted(Formatting.RED), isLoggedIn)
.setTooltip(Text.literal("Запустить процесс авторизации через Mojang и ваш бэкенд"))
.setSaveConsumer(newValue -> {
if (newValue) {
BackendAuthenticator.authenticateAsync(MinecraftClient.getInstance())
.thenAccept(authenticated -> {
if (authenticated && SPMega.getConfig() != null) {
apiToken[0] = SPMega.getConfig().apiToken();
}
});
} else {
apiToken[0] = ModConfig.DEFAULT_API_TOKEN;
}
})
.build());
general.addEntry(entryBuilder.startBooleanToggle(Text.translatable("option.spmega.sign_quick_pay"), current.signQuickPayEnabled())
.setDefaultValue(ModConfig.DEFAULT_SIGN_QUICK_PAY_ENABLED)
.setSaveConsumer(newValue -> signQuickPayEnabled[0] = newValue)
.build());
general.addEntry(entryBuilder.startEnumSelector(
Text.translatable("option.spmega.gps_position"),
GpsHudPosition.class,
current.gpsPosition()
)
.setDefaultValue(ModConfig.DEFAULT_GPS_POSITION)
.setEnumNameProvider(position -> Text.translatable("option.spmega.gps_position." + position.name().toLowerCase()))
.setSaveConsumer(newValue -> gpsPosition[0] = newValue)
.build());
general.addEntry(entryBuilder.startEnumSelector(
Text.translatable("option.spmega.notification_position"),
GpsHudPosition.class,
current.notificationPosition()
)
.setDefaultValue(ModConfig.DEFAULT_NOTIFICATION_POSITION)
.setEnumNameProvider(position -> Text.translatable(
"option.spmega.notification_position." + position.name().toLowerCase()))
.setSaveConsumer(newValue -> notificationPosition[0] = newValue)
.build());
// Telemetry settings
ConfigCategory telemetry = builder.getOrCreateCategory(Text.translatable("category.spmega.telemetry"));
telemetry.addEntry(entryBuilder.startIntField(Text.translatable("option.spmega.telemetry_interval"), current.telemetryIntervalSeconds())
.setDefaultValue(ModConfig.DEFAULT_TELEMETRY_INTERVAL_SECONDS)
.setMin(10)
.setMax(600)
.setTooltip(Text.translatable("tooltip.spmega.telemetry_interval"))
.setSaveConsumer(newValue -> telemetryIntervalSeconds[0] = newValue)
.build());
telemetry.addEntry(entryBuilder.startBooleanToggle(Text.translatable("option.spmega.telemetry_system_info"), current.telemetryCollectSystemInfo())
.setDefaultValue(ModConfig.DEFAULT_TELEMETRY_COLLECT_SYSTEM_INFO)
.setTooltip(Text.translatable("tooltip.spmega.telemetry_system_info"))
.setSaveConsumer(newValue -> telemetryCollectSystemInfo[0] = newValue)
.build());
builder.setSavingRunnable(() -> {
boolean gpsEnabledVal = true;
if (SPMega.getConfig() != null) {
gpsEnabledVal = SPMega.getConfig().gpsEnabled();
}
ModConfig updated = new ModConfig(
apiDomain[0],
apiToken[0],
allowBackend[0],
signQuickPayEnabled[0],
gpsEnabledVal,
gpsPosition[0],
notificationPosition[0],
telemetryIntervalSeconds[0],
telemetryCollectSystemInfo[0]
);
SPMega.setConfig(updated);
});
return builder.build();
};
}
}
@@ -1,9 +1,13 @@
package git.yawaflua.tech.spmega.client;
import git.yawaflua.tech.spmega.ModConfig;
import git.yawaflua.tech.spmega.SPMega;
import git.yawaflua.tech.spmega.client.qr.QRCodeScanner;
import git.yawaflua.tech.spmega.client.telemetry.*;
import git.yawaflua.tech.spmega.client.ui.GpsHudRenderer;
import git.yawaflua.tech.spmega.client.ui.UiNotifications;
import git.yawaflua.tech.spmega.client.ui.UiOpeners;
import git.yawaflua.tech.spmega.client.ui.service.BackendAuthenticator;
import git.yawaflua.tech.spmega.client.ui.service.BankUiService;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
@@ -18,6 +22,7 @@ import net.minecraft.client.MinecraftClient;
import net.minecraft.client.option.KeyBinding;
import net.minecraft.client.util.InputUtil;
import net.minecraft.entity.decoration.ItemFrameEntity;
import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.text.Text;
import net.minecraft.util.ActionResult;
import org.lwjgl.glfw.GLFW;
@@ -29,6 +34,9 @@ public class SPMegaClient implements ClientModInitializer {
private static final Pattern ISOLATED_FIVE_DIGITS = Pattern.compile("(?<!\\d)(\\d{5})(?!\\d)");
private static KeyBinding openBankMenuKeyBinding;
private static KeyBinding scanQrKeyBinding;
private static KeyBinding toggleGpsKeyBinding;
private static boolean wasPaymentShortcutPressed = false;
private static String extractCardNumber(SignBlockEntity signBlockEntity) {
String candidate = findFiveDigits(signBlockEntity.getFrontText().getMessages(false));
@@ -65,6 +73,7 @@ public class SPMegaClient implements ClientModInitializer {
@Override
public void onInitializeClient() {
GpsHudRenderer.instance();
openBankMenuKeyBinding = KeyBindingHelper.registerKeyBinding(new KeyBinding(
"key.spmega.open_menu",
InputUtil.Type.KEYSYM,
@@ -79,17 +88,63 @@ public class SPMegaClient implements ClientModInitializer {
KeyBinding.Category.GAMEPLAY
));
toggleGpsKeyBinding = KeyBindingHelper.registerKeyBinding(new KeyBinding(
"key.spmega.toggle_gps",
InputUtil.Type.KEYSYM,
GLFW.GLFW_KEY_J,
KeyBinding.Category.GAMEPLAY
));
if (SPMega.getConfig() != null) {
GpsHudRenderer.instance().setEnabled(SPMega.getConfig().gpsEnabled());
}
ClientTickEvents.END_CLIENT_TICK.register(client -> {
PerformanceSampler.instance().tick();
UiNotifications.instance().tick();
while (openBankMenuKeyBinding.wasPressed()) {
UiOpeners.openMainMenu(client);
}
while (scanQrKeyBinding.wasPressed()) {
QRCodeScanner.ScanQrCode(client);
QRCodeScanner.scanQrCode(client);
}
while (toggleGpsKeyBinding.wasPressed()) {
GpsHudRenderer.instance().toggle();
if (SPMega.getConfig() != null) {
ModConfig current = SPMega.getConfig();
ModConfig updated = new ModConfig(
current.apiDomain(),
current.apiToken(),
current.allowBackend(),
current.signQuickPayEnabled(),
GpsHudRenderer.instance().isEnabled(),
current.gpsPosition(),
current.notificationPosition(),
current.telemetryIntervalSeconds(),
current.telemetryCollectSystemInfo()
);
SPMega.setConfig(updated);
}
String status = GpsHudRenderer.instance().isEnabled() ? "включен" : "выключен";
UiNotifications.instance().show(Text.literal("GPS Ада " + status));
}
if (client.player != null && client.options != null) {
boolean isCombinationPressed = client.options.sprintKey.isPressed() && client.options.sneakKey.isPressed() && client.options.leftKey.isPressed();
if (isCombinationPressed) {
if (!wasPaymentShortcutPressed && client.currentScreen == null) {
if (client.targetedEntity instanceof PlayerEntity targetedPlayer && targetedPlayer != client.player) {
String recipient = targetedPlayer.getStringifiedName();
UiOpeners.openPaymentMenu(client, recipient);
}
}
wasPaymentShortcutPressed = true;
} else {
wasPaymentShortcutPressed = false;
}
}
});
// World HUD path (when no screen is open).
HudRenderCallback.EVENT.register((drawContext, tickDeltaManager) -> {
MinecraftClient client = MinecraftClient.getInstance();
if (client.currentScreen != null || client.textRenderer == null) {
@@ -101,21 +156,28 @@ public class SPMegaClient implements ClientModInitializer {
client.getWindow().getScaledWidth(),
client.getWindow().getScaledHeight()
);
GpsHudRenderer.instance().render(
drawContext,
client.textRenderer,
client.getWindow().getScaledWidth(),
client.getWindow().getScaledHeight()
);
});
// Screen path (for all GUI screens).
ScreenEvents.AFTER_INIT.register((client, screen, scaledWidth, scaledHeight) ->
ScreenEvents.AFTER_INIT.register((client, screen, scaledWidth, scaledHeight) -> {
ScreenEvents.afterRender(screen).register((screenInstance, drawContext, mouseX, mouseY, tickDelta) -> {
if (client.textRenderer == null) {
return;
}
UiNotifications.instance().render(drawContext, client.textRenderer, scaledWidth, scaledHeight);
})
});
}
);
ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> {
if (client.player != null) {
BankUiService.instance().refreshOnServerJoin(client.player.getUuidAsString());
BankUiService.instance().refreshOnServerJoinAsync(client.player.getUuidAsString());
}
});
@@ -169,5 +231,35 @@ public class SPMegaClient implements ClientModInitializer {
});
new ChatListener().register();
WebhookNotificationPoller.instance().start();
BackendAuthenticator.authenticateAsync(MinecraftClient.getInstance())
.exceptionally(throwable -> {
System.err.println("[SPMEGA] Error during async authenticate: " + throwable.getMessage());
return null;
});
// Telemetry init
long clientInitStart = System.nanoTime();
ModConfig cfg = SPMega.getConfig();
int interval = (cfg != null) ? cfg.telemetryIntervalSeconds() : ModConfig.DEFAULT_TELEMETRY_INTERVAL_SECONDS;
SystemInfoCollector.instance().init();
TelemetrySender.instance().start(interval);
long clientInitMs = (System.nanoTime() - clientInitStart) / 1_000_000L;
com.google.gson.JsonObject initPayload = new com.google.gson.JsonObject();
initPayload.addProperty("phase", "client_init");
initPayload.addProperty("durationMs", clientInitMs);
TelemetryCollector.instance().record(TelemetryEvent.now("lifecycle", initPayload));
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
com.google.gson.JsonObject deinitPayload = new com.google.gson.JsonObject();
deinitPayload.addProperty("phase", "client_deinit");
TelemetryCollector.instance().record(TelemetryEvent.now("lifecycle", deinitPayload));
TelemetrySender.instance().flush();
TelemetrySender.instance().stop();
}, "SPMega-Telemetry-Shutdown"));
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!");
}
}
@@ -0,0 +1,72 @@
package git.yawaflua.tech.spmega.client;
import git.yawaflua.tech.spmega.client.ui.UiNotifications;
import git.yawaflua.tech.spmega.client.ui.service.BackendAuthenticator;
import git.yawaflua.tech.spmega.client.ui.service.BankUiService;
import net.minecraft.client.MinecraftClient;
import net.minecraft.text.Text;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
public final class WebhookNotificationPoller {
private static final WebhookNotificationPoller INSTANCE = new WebhookNotificationPoller();
private static final long POLL_INTERVAL_SECONDS = 15;
private final AtomicBoolean started = new AtomicBoolean();
private final AtomicBoolean requestInFlight = new AtomicBoolean();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "SPMega-Webhook-Poller");
thread.setDaemon(true);
return thread;
});
private WebhookNotificationPoller() {
}
public static WebhookNotificationPoller instance() {
return INSTANCE;
}
private static String format(BackendAuthenticator.PaymentNotification notification) {
String sender = notification.senderName().isBlank()
? notification.senderNumber()
: notification.senderName();
String comment = notification.comment().isBlank() ? "" : "" + notification.comment();
return "Получено " + notification.amount() + " АР от " + sender + comment;
}
public void start() {
if (started.compareAndSet(false, true)) {
scheduler.scheduleAtFixedRate(this::poll, 0, POLL_INTERVAL_SECONDS, TimeUnit.SECONDS);
}
}
private void poll() {
try {
if (!BankUiService.instance().hasWebhookEnabledCards()
|| !requestInFlight.compareAndSet(false, true)) {
return;
}
} catch (Exception exception) {
System.err.println("[SPMEGA] Failed to read local webhook state: " + exception.getMessage());
return;
}
BackendAuthenticator.readNotificationsAsync().whenComplete((notifications, exception) -> {
requestInFlight.set(false);
if (exception != null) {
System.err.println("[SPMEGA] Failed to poll webhook notifications: " + exception.getMessage());
return;
}
MinecraftClient client = MinecraftClient.getInstance();
if (client == null || notifications.isEmpty()) {
return;
}
client.execute(() -> notifications.forEach(notification ->
UiNotifications.instance().showQueued(Text.literal(format(notification)))));
});
}
}
@@ -0,0 +1,202 @@
package git.yawaflua.tech.spmega.client.api;
import com.google.gson.*;
import git.yawaflua.tech.spmega.client.telemetry.InstrumentedHttpClient;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpRequest;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
public final class SPWorldsApiClient {
private final InstrumentedHttpClient httpClient;
private final Gson gson;
private final String baseUrl;
public SPWorldsApiClient(String baseUrl) {
this.httpClient = new InstrumentedHttpClient();
this.gson = new Gson();
this.baseUrl = normalizeBaseUrl(baseUrl);
}
private static String encodeAuth(CardAuth auth) {
String raw = auth.cardId() + ":" + auth.cardToken();
return Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8));
}
private static String normalizeBaseUrl(String rawBaseUrl) {
String fallback = "https://spworlds.ru";
if (rawBaseUrl == null || rawBaseUrl.trim().isEmpty()) {
return fallback;
}
String value = rawBaseUrl.trim();
if (value.endsWith("/")) {
return value.substring(0, value.length() - 1);
}
return value;
}
private static String getString(JsonObject json, String key) {
if (!json.has(key) || json.get(key).isJsonNull()) {
return "";
}
return json.get(key).getAsString();
}
public CompletableFuture<CardInfo> getCardInfoAsync(CardAuth auth) {
HttpRequest request = requestBuilder("/api/public/card", auth).GET().build();
return sendAsync(request).thenApply(body -> {
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
if (json.has("statusCode")) {
switch (json.get("statusCode").getAsInt()) {
case 403:
throw new IOException("Апи вернула ошибку: " + json.get("message").getAsString());
default:
System.out.println("Unhandled status code in card info response: " + json.get("statusCode").getAsInt());
break;
}
}
long balance = json.has("balance") ? json.get("balance").getAsLong() : 0L;
String webhook = json.has("webhook") && !json.get("webhook").isJsonNull()
? json.get("webhook").getAsString()
: "";
return new CardInfo(balance, webhook);
} catch (Exception exception) {
System.out.println("Failed to parse card info response: " + exception.getMessage());
System.out.println(body);
throw new CompletionException(new IOException("Failed to parse card info response", exception));
}
});
}
public CompletableFuture<List<PlayerCard>> getPlayerCardsAsync(String username, CardAuth auth) {
HttpRequest request = requestBuilder("/api/public/accounts/" + username + "/cards", auth).GET().build();
return sendAsync(request).thenApply(body -> {
try {
JsonArray json = JsonParser.parseString(body).getAsJsonArray();
List<PlayerCard> cards = new ArrayList<>();
for (JsonElement element : json) {
JsonObject card = element.getAsJsonObject();
String name = card.has("name") ? card.get("name").getAsString() : "";
String number = card.has("number") ? card.get("number").getAsString() : "";
cards.add(new PlayerCard(name, number));
}
return cards;
} catch (Exception exception) {
System.out.println("Failed to parse player cards response: " + exception.getMessage());
System.out.println(body);
throw new CompletionException(new IOException("Failed to parse player cards response", exception));
}
});
}
public CompletableFuture<AccountMe> getAccountMeAsync(CardAuth auth) {
HttpRequest request = requestBuilder("/api/public/accounts/me", auth).GET().build();
return sendAsync(request).thenApply(body -> {
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
String id = json.has("id") ? json.get("id").getAsString() : "";
String username = json.has("username") ? json.get("username").getAsString() : "";
String minecraftUuid = json.has("minecraftUUID") ? json.get("minecraftUUID").getAsString() : "";
List<AccountCard> cards = new ArrayList<>();
if (json.has("cards") && json.get("cards").isJsonArray()) {
for (JsonElement element : json.getAsJsonArray("cards")) {
JsonObject card = element.getAsJsonObject();
cards.add(new AccountCard(
getString(card, "id"),
getString(card, "name"),
getString(card, "number"),
card.has("color") && !card.get("color").isJsonNull() ? card.get("color").getAsInt() : 0
));
}
}
return new AccountMe(id, username, minecraftUuid, cards);
} catch (Exception exception) {
System.out.println("Failed to parse account info response: " + exception.getMessage());
System.out.println(body);
throw new CompletionException(new IOException("Failed to parse account info response", exception));
}
});
}
public CompletableFuture<TransactionResult> createTransactionAsync(
CardAuth auth, String receiver, long amount, String comment) {
JsonObject payload = new JsonObject();
payload.addProperty("receiver", receiver);
payload.addProperty("amount", amount);
payload.addProperty("comment", comment.isEmpty() ? "Перевод через SPMega" : comment);
HttpRequest request = requestBuilder("/api/public/transactions", auth)
.POST(HttpRequest.BodyPublishers.ofString(gson.toJson(payload)))
.build();
return sendAsync(request).thenApply(body -> {
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
long balance = json.has("balance") ? json.get("balance").getAsLong() : 0L;
return new TransactionResult(balance);
} catch (Exception exception) {
System.out.println("Failed to parse transaction response: " + exception.getMessage());
System.out.println(body);
throw new CompletionException(new IOException("Failed to parse transaction response", exception));
}
});
}
private HttpRequest.Builder requestBuilder(String path, CardAuth auth) {
return HttpRequest.newBuilder(URI.create(baseUrl + path))
.header("Authorization", "Bearer " + encodeAuth(auth))
.header("Content-Type", "application/json")
.header("Accept", "application/json");
}
private CompletableFuture<String> sendAsync(HttpRequest request) {
return httpClient.sendAsync(request).thenApply(response -> {
int statusCode = response.statusCode();
if (statusCode < 200 || statusCode >= 300) {
System.out.println(response.body());
JsonElement parsed = JsonParser.parseString(response.body());
JsonObject object = parsed.isJsonArray()
? parsed.getAsJsonArray().get(0).getAsJsonObject()
: parsed.getAsJsonObject();
String message = "";
if (object.has("error") && !object.get("error").isJsonNull()) {
message = "Ошибка в запросе: " + object.get("error").getAsString();
} else if (object.has("message") && !object.get("message").isJsonNull()) {
message = object.get("message").getAsString();
}
throw new CompletionException(new IOException(statusCode + ": " + message));
}
return response.body();
});
}
public record CardAuth(String cardId, String cardToken) {
}
public record CardInfo(long balance, String webhook) {
}
public record PlayerCard(String name, String number) {
}
public record AccountMe(String id, String username, String minecraftUuid, List<AccountCard> cards) {
}
public record AccountCard(String id, String name, String number, int color) {
}
public record TransactionResult(long balance) {
}
}
@@ -4,21 +4,32 @@ import com.google.zxing.*;
import com.google.zxing.common.GlobalHistogramBinarizer;
import com.google.zxing.common.HybridBinarizer;
import com.google.zxing.multi.GenericMultipleBarcodeReader;
import git.yawaflua.tech.spmega.client.telemetry.TelemetryCollector;
import git.yawaflua.tech.spmega.client.telemetry.TelemetryEvent;
import git.yawaflua.tech.spmega.client.ui.QRcodeAcceptScreen;
import git.yawaflua.tech.spmega.client.ui.UiNotifications;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.texture.NativeImage;
import net.minecraft.client.util.ScreenshotRecorder;
import net.minecraft.text.Text;
import java.awt.image.BufferedImage;
import java.util.Collections;
import java.util.EnumMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class QRCodeScanner {
public final class QRCodeScanner {
private static final ExecutorService DECODER = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "SPMega-QR-Decoder");
thread.setDaemon(true);
return thread;
});
public static void ScanQrCode(MinecraftClient client) {
private QRCodeScanner() {
}
public static void scanQrCode(MinecraftClient client) {
if (client == null || client.player == null || client.getFramebuffer() == null) {
return;
}
@@ -30,6 +41,7 @@ public class QRCodeScanner {
return;
}
long startNs = System.nanoTime();
try {
ScreenshotRecorder.takeScreenshot(client.getFramebuffer(), nativeImage -> {
try {
@@ -39,24 +51,28 @@ public class QRCodeScanner {
notifications.show(Text.translatable("message.spmega.qr.capture_failed"));
}
});
recordQrEvent(false, false, null, "capture_failed", startNs);
return;
}
String result = decodeQRCode(nativeImageToBufferedImage(nativeImage));
client.execute(() -> {
if (client.player == null) {
return;
}
if (result == null) {
notifications.show(Text.translatable("message.spmega.qr.not_found"));
return;
}
Text clickableLink = Text.literal(result)
.styled(style -> style.withInsertion(result));
client.player.sendMessage(Text.translatable("message.spmega.qr.found_link", clickableLink), false);
client.setScreen(new QRcodeAcceptScreen(result, client.currentScreen));
int width = nativeImage.getWidth();
int height = nativeImage.getHeight();
int[] pixels = nativeImage.copyPixelsArgb();
CompletableFuture.supplyAsync(() -> decodeQrCode(width, height, pixels), DECODER)
.whenComplete((result, throwable) -> {
client.execute(() -> showResult(client, notifications, result, throwable));
recordQrEvent(
throwable == null && result != null,
System.nanoTime() - startNs > 50_000_000L,
result,
throwable == null ? null : throwable.getClass().getSimpleName() + ": " + throwable.getMessage(),
startNs
);
});
} catch (Exception exception) {
client.execute(() -> notifications.show(Text.translatable("message.spmega.qr.error")));
recordQrEvent(false, false, null,
exception.getClass().getSimpleName() + ": " + exception.getMessage(), startNs);
} finally {
if (nativeImage != null) {
nativeImage.close();
@@ -65,34 +81,55 @@ public class QRCodeScanner {
});
} catch (Exception ex) {
notifications.show(Text.translatable("message.spmega.qr.error"));
recordQrEvent(false, false, null, ex.getClass().getSimpleName() + ": " + ex.getMessage(), startNs);
}
}
private static BufferedImage nativeImageToBufferedImage(NativeImage screenshot) {
BufferedImage bufferedImage = new BufferedImage(
screenshot.getWidth(),
screenshot.getHeight(),
BufferedImage.TYPE_INT_RGB
);
for (int y = 0; y < screenshot.getHeight(); y++) {
for (int x = 0; x < screenshot.getWidth(); x++) {
int color = screenshot.getColorArgb(x, y);
bufferedImage.setRGB(x, y, color);
private static void showResult(
MinecraftClient client,
UiNotifications notifications,
String result,
Throwable throwable
) {
if (client.player == null) {
return;
}
if (throwable != null) {
notifications.show(Text.translatable("message.spmega.qr.error"));
return;
}
if (result == null) {
notifications.show(Text.translatable("message.spmega.qr.not_found"));
return;
}
return bufferedImage;
Text clickableLink = Text.literal(result).styled(style -> style.withInsertion(result));
client.player.sendMessage(Text.translatable("message.spmega.qr.found_link", clickableLink), false);
client.setScreen(new QRcodeAcceptScreen(result, client.currentScreen));
}
private static String decodeQRCode(BufferedImage image) {
private static void recordQrEvent(boolean success, boolean didLag, String decodedUrl, String error, long startNs) {
com.google.gson.JsonObject payload = new com.google.gson.JsonObject();
payload.addProperty("success", success);
payload.addProperty("didLag", didLag);
payload.addProperty("durationMs", (System.nanoTime() - startNs) / 1_000_000L);
if (decodedUrl != null) {
payload.addProperty("decodedUrl", decodedUrl);
}
if (error != null) {
payload.addProperty("error", error);
}
TelemetryCollector.instance().record(TelemetryEvent.now("qr_scan", payload));
}
private static String decodeQrCode(int width, int height, int[] pixels) {
Map<DecodeHintType, Object> hints = new EnumMap<>(DecodeHintType.class);
hints.put(DecodeHintType.TRY_HARDER, Boolean.TRUE);
hints.put(DecodeHintType.POSSIBLE_FORMATS, Collections.singletonList(BarcodeFormat.QR_CODE));
hints.put(DecodeHintType.CHARACTER_SET, "UTF-8");
hints.put(DecodeHintType.ALSO_INVERTED, Boolean.TRUE);
LuminanceSource source = new RGBLuminanceSource(image.getWidth(), image.getHeight(), image.getRGB(0, 0, image.getWidth(), image.getHeight(), null, 0, image.getWidth()));
LuminanceSource source = new RGBLuminanceSource(width, height, pixels);
String result = tryDecodeWithStrategies(source, hints);
@@ -0,0 +1,59 @@
package git.yawaflua.tech.spmega.client.telemetry;
import com.google.gson.JsonObject;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
public record InstrumentedHttpClient(HttpClient delegate) {
public InstrumentedHttpClient() {
this(HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.build());
}
public static void recordHttpEvent(URI uri, String method, int statusCode,
long durationMs, boolean success, String errorType,
int fpsBefore, int fpsAfter) {
JsonObject payload = new JsonObject();
payload.addProperty("target", TelemetryUriSanitizer.classifyTarget(uri));
payload.addProperty("method", method);
payload.addProperty("path", TelemetryUriSanitizer.sanitize(uri));
payload.addProperty("statusCode", statusCode);
payload.addProperty("durationMs", durationMs);
payload.addProperty("success", success);
if (errorType != null) {
payload.addProperty("errorType", errorType);
}
payload.addProperty("fpsBefore", fpsBefore);
payload.addProperty("fpsAfter", fpsAfter);
TelemetryCollector.instance().record(TelemetryEvent.now("http_request", payload));
}
public CompletableFuture<HttpResponse<String>> sendAsync(HttpRequest request) {
URI uri = request.uri();
String method = request.method();
int fpsBefore = PerformanceSampler.instance().currentFps();
long startNs = System.nanoTime();
return delegate.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.whenComplete((response, throwable) -> {
long durationMs = (System.nanoTime() - startNs) / 1_000_000L;
int fpsAfter = PerformanceSampler.instance().currentFps();
if (throwable != null) {
recordHttpEvent(uri, method, -1, durationMs,
false, throwable.getClass().getSimpleName(), fpsBefore, fpsAfter);
} else {
recordHttpEvent(uri, method, response.statusCode(), durationMs,
true, null, fpsBefore, fpsAfter);
}
});
}
}
@@ -0,0 +1,61 @@
package git.yawaflua.tech.spmega.client.telemetry;
import com.google.gson.JsonObject;
import net.minecraft.client.MinecraftClient;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
public final class PerformanceSampler {
private static final PerformanceSampler INSTANCE = new PerformanceSampler();
private final AtomicInteger fpsSum = new AtomicInteger();
private final AtomicInteger fpsCount = new AtomicInteger();
private final AtomicInteger fpsMin = new AtomicInteger(Integer.MAX_VALUE);
private final AtomicInteger fpsMax = new AtomicInteger(0);
private final AtomicLong lastSnapshotMs = new AtomicLong(System.currentTimeMillis());
private PerformanceSampler() {
}
public static PerformanceSampler instance() {
return INSTANCE;
}
public void tick() {
int fps = MinecraftClient.getInstance().getCurrentFps();
fpsSum.addAndGet(fps);
fpsCount.incrementAndGet();
fpsMin.updateAndGet(prev -> Math.min(prev, fps));
fpsMax.updateAndGet(prev -> Math.max(prev, fps));
}
public int currentFps() {
return MinecraftClient.getInstance().getCurrentFps();
}
public void emitSnapshot() {
int count = fpsCount.getAndSet(0);
int sum = fpsSum.getAndSet(0);
int min = fpsMin.getAndSet(Integer.MAX_VALUE);
int max = fpsMax.getAndSet(0);
long now = System.currentTimeMillis();
long periodMs = now - lastSnapshotMs.getAndSet(now);
if (count == 0) return;
JsonObject payload = new JsonObject();
payload.addProperty("fpsAvg", sum / count);
payload.addProperty("fpsMin", min == Integer.MAX_VALUE ? 0 : min);
payload.addProperty("fpsMax", max);
payload.addProperty("sampleCount", count);
payload.addProperty("periodMs", periodMs);
Runtime rt = Runtime.getRuntime();
payload.addProperty("usedMemoryMb", (rt.totalMemory() - rt.freeMemory()) / (1024 * 1024));
payload.addProperty("totalMemoryMb", rt.totalMemory() / (1024 * 1024));
payload.addProperty("maxMemoryMb", rt.maxMemory() / (1024 * 1024));
TelemetryCollector.instance().record(TelemetryEvent.now("performance_snapshot", payload));
}
}
@@ -0,0 +1,119 @@
package git.yawaflua.tech.spmega.client.telemetry;
import com.google.gson.JsonObject;
import net.minecraft.client.MinecraftClient;
import org.lwjgl.opengl.GL11;
import oshi.SystemInfo;
import oshi.hardware.CentralProcessor;
import java.net.InetAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.nio.file.FileStore;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Arrays;
import java.util.concurrent.atomic.AtomicReference;
public final class SystemInfoCollector {
private static final SystemInfoCollector INSTANCE = new SystemInfoCollector();
private static final long MB = 1024L * 1024L;
private final CentralProcessor cpu = new SystemInfo().getHardware().getProcessor();
private final AtomicReference<String> cachedIp = new AtomicReference<>("<unknown>");
private final AtomicReference<String> gpuRenderer = new AtomicReference<>("<pending>");
private final AtomicReference<String> gpuVendor = new AtomicReference<>("<pending>");
private volatile boolean gpuCollected = false;
private SystemInfoCollector() {
}
public static SystemInfoCollector instance() {
return INSTANCE;
}
public void init() {
fetchIpAsync();
collectGpuOnRenderThread();
}
private void fetchIpAsync() {
HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build()
.sendAsync(HttpRequest.newBuilder()
.uri(URI.create("https://api.ipify.org?format=text"))
.timeout(Duration.ofSeconds(5))
.GET()
.build(),
java.net.http.HttpResponse.BodyHandlers.ofString())
.thenAccept(response -> {
if (response.statusCode() == 200) {
cachedIp.set(response.body().trim());
}
})
.exceptionally(ignored -> null);
}
private void collectGpuOnRenderThread() {
MinecraftClient.getInstance().execute(() -> {
try {
gpuRenderer.set(GL11.glGetString(GL11.GL_RENDERER));
gpuVendor.set(GL11.glGetString(GL11.GL_VENDOR));
} catch (Exception ignored) {
}
gpuCollected = true;
});
}
public JsonObject collect() {
JsonObject info = new JsonObject();
Runtime rt = Runtime.getRuntime();
info.addProperty("cpuCores", rt.availableProcessors());
info.addProperty("cpuName", cpu.getProcessorIdentifier().getName());
info.addProperty("cpuCurrentFrequencyHz", Arrays.stream(cpu.getCurrentFreq()).max().orElse(0));
info.addProperty("cpuMaxFrequencyHz", cpu.getMaxFreq());
try {
var osBean = java.lang.management.ManagementFactory.getOperatingSystemMXBean();
if (osBean instanceof com.sun.management.OperatingSystemMXBean ext) {
double cpuLoad = ext.getProcessCpuLoad();
if (cpuLoad >= 0) {
info.addProperty("cpuLoad", Math.round(cpuLoad * 100.0));
}
}
} catch (Exception ignored) {
}
info.addProperty("gpuRenderer", gpuRenderer.get());
info.addProperty("gpuVendor", gpuVendor.get());
info.addProperty("ramTotalMb", rt.maxMemory() / MB);
info.addProperty("ramFreeMb", rt.freeMemory() / MB);
info.addProperty("ramUsedMb", (rt.totalMemory() - rt.freeMemory()) / MB);
try {
FileStore store = Files.getFileStore(Path.of("."));
info.addProperty("storageTotalMb", store.getTotalSpace() / MB);
info.addProperty("storageFreeMb", store.getUsableSpace() / MB);
} catch (Exception ignored) {
}
info.addProperty("publicIp", cachedIp.get());
try {
info.addProperty("hostName", InetAddress.getLocalHost().getHostName());
} catch (Exception ignored) {
info.addProperty("hostName", System.getenv("COMPUTERNAME"));
}
info.addProperty("javaVersion", System.getProperty("java.version"));
info.addProperty("osName", System.getProperty("os.name"));
info.addProperty("osArch", System.getProperty("os.arch"));
return info;
}
}
@@ -0,0 +1,40 @@
package git.yawaflua.tech.spmega.client.telemetry;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentLinkedQueue;
public final class TelemetryCollector {
private static final TelemetryCollector INSTANCE = new TelemetryCollector();
private static final int MAX_QUEUE_SIZE = 1000;
private final ConcurrentLinkedQueue<TelemetryEvent> queue = new ConcurrentLinkedQueue<>();
private TelemetryCollector() {
}
public static TelemetryCollector instance() {
return INSTANCE;
}
public void record(TelemetryEvent event) {
if (event == null) return;
if (queue.size() >= MAX_QUEUE_SIZE) {
queue.poll();
}
queue.add(event);
}
public List<TelemetryEvent> drain() {
List<TelemetryEvent> batch = new ArrayList<>();
TelemetryEvent event;
while ((event = queue.poll()) != null) {
batch.add(event);
}
return batch;
}
public int size() {
return queue.size();
}
}
@@ -0,0 +1,16 @@
package git.yawaflua.tech.spmega.client.telemetry;
import com.google.gson.JsonObject;
import java.time.Instant;
public record TelemetryEvent(String eventType, Instant timestamp, JsonObject payload) {
public static TelemetryEvent now(String eventType, JsonObject payload) {
return new TelemetryEvent(eventType, Instant.now(), payload);
}
public static TelemetryEvent now(String eventType) {
return now(eventType, new JsonObject());
}
}
@@ -0,0 +1,124 @@
package git.yawaflua.tech.spmega.client.telemetry;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import git.yawaflua.tech.spmega.ModConfig;
import git.yawaflua.tech.spmega.SPMega;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public final class TelemetrySender {
private static final TelemetrySender INSTANCE = new TelemetrySender();
private static final Gson GSON = new Gson();
private final String sessionId = UUID.randomUUID().toString();
private final HttpClient httpClient;
private ScheduledExecutorService scheduler;
private TelemetrySender() {
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
}
public static TelemetrySender instance() {
return INSTANCE;
}
public String sessionId() {
return sessionId;
}
public void start(int intervalSeconds) {
if (scheduler != null && !scheduler.isShutdown()) {
scheduler.shutdownNow();
}
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "SPMega-Telemetry-Sender");
t.setDaemon(true);
return t;
});
scheduler.scheduleAtFixedRate(this::flush, intervalSeconds, intervalSeconds, TimeUnit.SECONDS);
}
public void stop() {
if (scheduler != null) {
scheduler.shutdownNow();
}
}
public void flush() {
try {
ModConfig config = SPMega.getConfig();
if (config == null) return;
// Performance snapshot при каждом flush
PerformanceSampler.instance().emitSnapshot();
// System info (only if telemetryCollectSystemInfo enabled)
if (config.telemetryCollectSystemInfo()) {
JsonObject sysInfo = SystemInfoCollector.instance().collect();
TelemetryCollector.instance().record(TelemetryEvent.now("system_info", sysInfo));
}
List<TelemetryEvent> events = TelemetryCollector.instance().drain();
if (events.isEmpty()) return;
JsonObject batch = new JsonObject();
batch.addProperty("clientVersion", getModVersion());
batch.addProperty("sessionId", sessionId);
batch.addProperty("sentAt", Instant.now().toString());
JsonArray eventsArray = new JsonArray();
for (TelemetryEvent event : events) {
JsonObject ev = new JsonObject();
ev.addProperty("eventType", event.eventType());
ev.addProperty("timestamp", event.timestamp().toString());
ev.add("payload", event.payload());
eventsArray.add(ev);
}
batch.add("events", eventsArray);
String body = GSON.toJson(batch);
String apiDomain = config.apiDomain();
if (apiDomain == null || apiDomain.isEmpty()) apiDomain = ModConfig.DEFAULT_API_DOMAIN;
String token = config.apiToken();
HttpRequest.Builder reqBuilder = HttpRequest.newBuilder()
.uri(URI.create(apiDomain + "/api/v1/telemetry"))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(10))
.POST(HttpRequest.BodyPublishers.ofString(body));
if (token != null && !token.equals(ModConfig.DEFAULT_API_TOKEN)) {
reqBuilder.header("Authorization", "Bearer " + token);
}
httpClient.sendAsync(reqBuilder.build(), HttpResponse.BodyHandlers.ofString());
} catch (Exception e) {
System.err.println("[SPMEGA] Telemetry flush error: " + e.getMessage());
}
}
private String getModVersion() {
try {
return net.fabricmc.loader.api.FabricLoader.getInstance()
.getModContainer("spmega")
.map(c -> c.getMetadata().getVersion().getFriendlyString())
.orElse("unknown");
} catch (Exception e) {
return "unknown";
}
}
}
@@ -0,0 +1,47 @@
package git.yawaflua.tech.spmega.client.telemetry;
import java.net.URI;
import java.util.regex.Pattern;
public final class TelemetryUriSanitizer {
private static final Pattern UUID_PATTERN = Pattern.compile(
"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}");
private static final Pattern TOKEN_PATTERN = Pattern.compile(
"(token|key|secret|password|auth|bearer)=[^&]+", Pattern.CASE_INSENSITIVE);
private TelemetryUriSanitizer() {
}
public static String sanitize(URI uri) {
if (uri == null) return "<null>";
String path = uri.getPath() != null ? uri.getPath() : "";
path = UUID_PATTERN.matcher(path).replaceAll("<uuid>");
String query = uri.getQuery();
if (query != null) {
query = TOKEN_PATTERN.matcher(query).replaceAll("$1=<redacted>");
return uri.getHost() + path + "?" + query;
}
return uri.getHost() + path;
}
public static String sanitize(String url) {
try {
return sanitize(URI.create(url));
} catch (Exception e) {
return "<invalid-url>";
}
}
public static String classifyTarget(URI uri) {
if (uri == null) return "unknown";
String host = uri.getHost();
if (host == null) return "unknown";
if (host.contains("spworlds.ru")) return "spworlds";
if (host.contains("spmega") || host.contains("ywfl.dev") || host.contains("yawaflua")) return "spmega-backend";
if (host.contains("mojang.com") || host.contains("minecraft.net")) return "mojang";
if (host.contains("sp-mini.ru") || host.contains("spmap")) return "gps-map";
if (host.contains("ipify.org")) return "ipify";
return "other";
}
}
@@ -7,7 +7,6 @@ import net.minecraft.client.gui.widget.ButtonWidget;
import net.minecraft.client.gui.widget.TextFieldWidget;
import net.minecraft.text.Text;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
public class AddCardScreen extends Screen {
@@ -85,8 +84,7 @@ public class AddCardScreen extends Screen {
addButton.active = false;
cancelButton.active = false;
CompletableFuture
.supplyAsync(() -> bankUiService.addCard(cardId, cardToken, playerUuid))
bankUiService.addCardAsync(cardId, cardToken, playerUuid)
.thenAccept(message -> {
if (this.client == null) {
return;
@@ -3,6 +3,7 @@ package git.yawaflua.tech.spmega.client.ui;
import git.yawaflua.tech.spmega.client.ui.service.BankUiService;
import git.yawaflua.tech.spmega.client.ui.service.CardViewModel;
import net.minecraft.client.gui.DrawContext;
import net.minecraft.client.gui.screen.ConfirmScreen;
import net.minecraft.client.gui.screen.Screen;
import net.minecraft.client.gui.widget.ButtonWidget;
import net.minecraft.text.Text;
@@ -15,6 +16,8 @@ public class CardScreen extends Screen {
private final BankUiService bankUiService = BankUiService.instance();
private final UiNotifications notifications = UiNotifications.instance();
private final List<ButtonWidget> cardButtons = new ArrayList<>();
private ButtonWidget historyButton;
private ButtonWidget webhookButton;
public CardScreen(Screen parent) {
super(Text.literal("Управление картами"));
@@ -44,19 +47,30 @@ public class CardScreen extends Screen {
}
this.addDrawableChild(ButtonWidget.builder(Text.literal("Удалить"), button -> {
bankUiService.removeSelectedCard();
notifications.showMessage(bankUiService.getLastMessage());
bankUiService.removeSelectedCardAsync()
.thenAccept(message -> {
if (this.client != null) {
this.client.execute(() -> {
notifications.showMessage(message);
this.clearAndInit();
});
}
});
}).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
? this.client.player.getUuidAsString()
: "";
bankUiService.refreshSelectedCard(playerUuid);
String message = bankUiService.getLastMessage().isBlank() ? "Карта обновлена" : bankUiService.getLastMessage();
bankUiService.refreshSelectedCardAsync(playerUuid)
.thenAccept(message -> {
if (this.client != null) {
this.client.execute(() -> {
notifications.showMessage(message);
this.clearAndInit();
});
}
});
}).dimensions(rightX, startY + 24, columnWidth, 20).build());
this.addDrawableChild(ButtonWidget.builder(Text.literal("Добавить новую"), button -> {
@@ -69,8 +83,42 @@ public class CardScreen extends Screen {
}));
}).dimensions(rightX, startY + 48, columnWidth, 20).build());
historyButton = this.addDrawableChild(ButtonWidget.builder(Text.literal("История"), button -> {
if (this.client == null) {
return;
}
CardViewModel selected = bankUiService.getSelectedCard();
if (selected == null) {
notifications.show(Text.literal("Сначала выбери или добавь карту"));
return;
}
this.client.setScreen(new TransactionHistoryScreen(this, selected.id(), selected.title()));
}).dimensions(rightX, startY + 72, columnWidth, 20).build());
webhookButton = this.addDrawableChild(ButtonWidget.builder(Text.literal("Включить вебхуки"), button -> {
if (this.client == null || bankUiService.getSelectedCard() == null) {
notifications.show(Text.literal("Сначала выбери или добавь карту"));
return;
}
this.client.setScreen(new ConfirmScreen(confirmed -> {
if (this.client == null) {
return;
}
this.client.setScreen(this);
if (!confirmed) {
return;
}
bankUiService.registerSelectedWebhookAsync().thenAccept(message -> this.client.execute(() -> {
notifications.showMessage(message);
this.clearAndInit();
}));
}, Text.literal("Включить обработку вебхуков?"),
Text.literal("Если другой вебхук уже подключен к карте — он может затереться."),
Text.literal("Включить"), Text.literal("Отмена")));
}).dimensions(rightX, startY + 96, columnWidth, 20).build());
this.addDrawableChild(ButtonWidget.builder(Text.literal("Назад"), button -> this.close())
.dimensions(rightX, startY + 120, columnWidth, 20)
.dimensions(rightX, startY + 144, columnWidth, 20)
.build());
if (cards.isEmpty()) {
@@ -119,5 +167,15 @@ public class CardScreen extends Screen {
button.setMessage(Text.literal(prefix + cards.get(i).title() + suffix));
button.active = !selected;
}
if (historyButton != null) {
historyButton.active = !cards.isEmpty();
}
if (webhookButton != null) {
CardViewModel selected = bankUiService.getSelectedCard();
boolean enabled = selected != null && selected.webhookEnabled();
webhookButton.active = selected != null && !enabled;
webhookButton.setMessage(Text.literal(enabled ? "Вебхуки включены" : "Включить вебхуки"));
}
}
}
@@ -0,0 +1,238 @@
package git.yawaflua.tech.spmega.client.ui;
import com.google.gson.Gson;
import git.yawaflua.tech.spmega.GpsHudPosition;
import git.yawaflua.tech.spmega.SPMega;
import git.yawaflua.tech.spmega.client.telemetry.InstrumentedHttpClient;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.font.TextRenderer;
import net.minecraft.client.gui.DrawContext;
import net.minecraft.text.Text;
import net.minecraft.world.World;
import java.net.URI;
import java.net.http.HttpRequest;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public final class GpsHudRenderer {
private static final GpsHudRenderer INSTANCE = new GpsHudRenderer();
private boolean enabled = true;
private volatile List<City> cities = List.of();
private GpsHudRenderer() {
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread thread = new Thread(r, "SPMega-City-Poller");
thread.setDaemon(true);
return thread;
});
scheduler.scheduleAtFixedRate(this::fetchCities, 0, 5, TimeUnit.HOURS);
}
public static GpsHudRenderer instance() {
return INSTANCE;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public void toggle() {
this.enabled = !this.enabled;
}
private void fetchCities() {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://map.sp-mini.ru/api/map/territories"))
.GET()
.build();
new InstrumentedHttpClient().sendAsync(request)
.thenAccept(response -> {
if (response.statusCode() != 200) {
return;
}
TerritoryWrapper[] parsed = new Gson().fromJson(response.body(), TerritoryWrapper[].class);
if (parsed == null) {
return;
}
List<City> updatedCities = new ArrayList<>();
for (TerritoryWrapper wrapper : parsed) {
if (wrapper != null && wrapper.territory != null
&& wrapper.territory.nether_portal != null
&& wrapper.territory.nether_portal.length >= 2) {
City city = new City();
city.name = wrapper.territory.name;
city.netherX = wrapper.territory.nether_portal[0];
city.netherZ = wrapper.territory.nether_portal[1];
updatedCities.add(city);
}
}
cities = List.copyOf(updatedCities);
})
.exceptionally(ignored -> null);
}
public void render(DrawContext context, TextRenderer textRenderer, int width, int height) {
if (!enabled) {
return;
}
MinecraftClient client = MinecraftClient.getInstance();
if (client.world == null || client.player == null) {
return;
}
if (client.world.getRegistryKey() != World.NETHER) {
return;
}
double playerX = client.player.getX();
double playerZ = client.player.getZ();
LaneInfo playerLane = new LaneInfo(Math.round(playerX), Math.round(playerZ));
String titleText = "§7Ветка: " + playerLane.name + " §7(§f" + playerLane.coord + "§7)";
String offsetText = "§7Смещение: §f" + (playerLane.offset == 0 ? "0" : (playerLane.offset > 0 ? "+" + playerLane.offset : playerLane.offset));
City nearestCity = null;
double minDistanceSquared = Double.MAX_VALUE;
for (City city : cities) {
double dx = playerX - (city.netherX / 8);
double dz = playerZ - (city.netherZ / 8);
double distanceSquared = dx * dx + dz * dz;
if (distanceSquared < minDistanceSquared) {
minDistanceSquared = distanceSquared;
nearestCity = city;
}
}
String cityText = null;
if (nearestCity != null) {
long cx = Math.round(nearestCity.netherX / 8.0);
long cz = Math.round(nearestCity.netherZ / 8.0);
LaneInfo cityLane = new LaneInfo(cx, cz);
cityText = "§7Ближайший: §f" + nearestCity.name + " §7~(§f" + cityLane.name + " §f" + cityLane.coord + "§7, §7см: §f" + (cityLane.offset == 0 ? "0" : (cityLane.offset > 0 ? "+" + cityLane.offset : cityLane.offset)) + "§7)";
}
int padding = 6;
int lineSpacing = 2;
int textWidth = Math.max(textRenderer.getWidth(Text.literal(titleText)), textRenderer.getWidth(Text.literal(offsetText)));
if (cityText != null) {
textWidth = Math.max(textWidth, textRenderer.getWidth(Text.literal(cityText)));
}
int boxWidth = textWidth + padding * 2 + 6;
int linesCount = cityText != null ? 3 : 2;
int boxHeight = textRenderer.fontHeight * linesCount + lineSpacing * (linesCount - 1) + padding * 2;
GpsHudPosition position = GpsHudPosition.TOP_CENTER;
if (SPMega.getConfig() != null) {
position = SPMega.getConfig().gpsPosition();
}
int boxX;
int boxY;
switch (position) {
case TOP_RIGHT:
boxX = width - boxWidth - 10;
boxY = 10;
break;
case BOTTOM_LEFT:
boxX = 10;
boxY = height - boxHeight - 10;
break;
case BOTTOM_RIGHT:
boxX = width - boxWidth - 10;
boxY = height - boxHeight - 10;
break;
case TOP_CENTER:
boxX = (width - boxWidth) / 2;
boxY = 10;
break;
case BOTTOM_CENTER:
boxX = (width - boxWidth) / 2;
boxY = height - boxHeight - 10;
break;
case TOP_LEFT:
default:
boxX = 10;
boxY = 10;
break;
}
int bgCol = 0x90101010;
context.fill(boxX, boxY, boxX + boxWidth, boxY + boxHeight, bgCol);
int barWidth = 3;
context.fill(boxX + padding, boxY + padding, boxX + padding + barWidth, boxY + boxHeight - padding, playerLane.color);
int textX = boxX + padding + barWidth + 4;
int textY = boxY + padding;
context.drawTextWithShadow(textRenderer, Text.literal(titleText), textX, textY, 0xFFFFFFFF);
context.drawTextWithShadow(textRenderer, Text.literal(offsetText), textX, textY + textRenderer.fontHeight + lineSpacing, 0xFFFFFFFF);
if (cityText != null) {
context.drawTextWithShadow(textRenderer, Text.literal(cityText), textX, textY + (textRenderer.fontHeight + lineSpacing) * 2, 0xFFFFFFFF);
}
}
private static class City {
String name;
int netherX;
int netherZ;
}
private static class TerritoryWrapper {
Territory territory;
}
private static class Territory {
String name;
int[] nether_portal;
}
private static class LaneInfo {
String name;
int color;
long coord;
long offset;
LaneInfo(long x, long z) {
long absX = Math.abs(x);
long absZ = Math.abs(z);
if (absX > absZ) {
if (x > 0) {
name = "§aЗеленая";
color = 0xFF55FF55;
coord = x;
offset = z;
} else {
name = "§9Синяя";
color = 0xFF5555FF;
coord = absX;
offset = z;
}
} else {
if (z < 0) {
name = "§cКрасная";
color = 0xFFFF5555;
coord = absZ;
offset = x;
} else {
name = "§eЖелтая";
color = 0xFFFFFF55;
coord = absZ;
offset = x;
}
}
}
}
}
@@ -40,7 +40,7 @@ public class MainBankScreen extends Screen {
this.addDrawableChild(ButtonWidget.builder(Text.translatable("button.spmega.scan_qr"), button -> {
this.client.setScreen(null);
QRCodeScanner.ScanQrCode(this.client);
QRCodeScanner.scanQrCode(this.client);
}).dimensions(centerX - 60, y + 28, 120, 20).build());
}
@@ -12,7 +12,6 @@ import net.minecraft.text.Text;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -382,8 +381,7 @@ public class PaymentScreen extends Screen {
}
lastRecipientLookup = username;
CompletableFuture
.supplyAsync(() -> bankUiService.loadRecipientCards(username))
bankUiService.loadRecipientCardsAsync(username)
.thenAccept(cards -> {
if (this.client == null) {
return;
@@ -0,0 +1,204 @@
package git.yawaflua.tech.spmega.client.ui;
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.BankUiService;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.gui.DrawContext;
import net.minecraft.client.gui.screen.Screen;
import net.minecraft.client.gui.widget.ButtonWidget;
import net.minecraft.text.Text;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
public class TransactionHistoryScreen extends Screen {
private final Screen parent;
private final String cardId;
private final String cardTitle;
private final List<LocalTransaction> transactions = new ArrayList<>();
private boolean loading = true;
private String errorMessage = "";
private int scrollOffset = 0;
private int currentPage = 1;
public TransactionHistoryScreen(Screen parent, String cardId, String cardTitle) {
super(Text.literal("История транзакций"));
this.parent = parent;
this.cardId = cardId;
this.cardTitle = cardTitle;
}
@Override
protected void init() {
int centerX = this.width / 2;
int startY = this.height / 2 + 50;
this.addDrawableChild(ButtonWidget.builder(Text.literal("Назад"), button -> this.close())
.dimensions(centerX - 60, startY + 10, 120, 20)
.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();
}
}).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() {
loading = true;
errorMessage = "";
transactions.clear();
UiNotifications.instance().show(Text.literal("Загрузка..."));
int page = currentPage;
System.out.println("[SPMEGA] Transaction history loading started for card: " + cardId + ", page: " + page);
git.yawaflua.tech.spmega.ModConfig config = git.yawaflua.tech.spmega.SPMega.getConfig();
CompletableFuture<List<LocalTransaction>> remote = config != null && config.allowBackend()
? BackendAuthenticator.fetchTransactionsFromBackendAsync(cardId, page)
.whenComplete((list, exception) -> {
if (exception == null) {
System.out.println("[SPMEGA] Fetched " + list.size() + " transactions from backend.");
} else {
System.err.println("[SPMEGA] Failed to fetch transactions from server, falling back to DB: "
+ exception.getMessage());
}
})
.exceptionally(exception -> null)
: CompletableFuture.completedFuture(null);
remote.thenApply(list -> {
if (list != null) {
return list;
}
List<LocalTransaction> local = BankUiService.instance().getDatabase().loadTransferHistory(cardId);
System.out.println("[SPMEGA] Loaded " + local.size() + " transactions from database.");
return local;
})
.whenComplete((list, exception) -> {
MinecraftClient minecraftClient = MinecraftClient.getInstance();
if (minecraftClient == null) {
System.out.println("[SPMEGA] MinecraftClient.getInstance() is null in loadTransactions callback!");
return;
}
minecraftClient.execute(() -> {
if (exception != null) {
errorMessage = exception.getMessage();
} else {
System.out.println("[SPMEGA] Setting transactions list on main thread. Count: " + list.size());
transactions.addAll(list);
scrollOffset = 0;
}
loading = false;
});
});
}
@Override
public void close() {
if (this.client != null) {
this.client.setScreen(parent);
}
}
@Override
public void render(DrawContext context, int mouseX, int mouseY, float delta) {
super.render(context, mouseX, mouseY, delta);
int centerX = this.width / 2;
int startY = 50;
int bottomStartY = this.height / 2 + 50;
context.drawCenteredTextWithShadow(this.textRenderer, this.title, centerX, 20, 0xFFFFFFFF);
context.drawCenteredTextWithShadow(this.textRenderer, Text.literal("Карта: " + cardTitle), centerX, 35, 0xFFBFBFBF);
context.drawCenteredTextWithShadow(this.textRenderer, Text.literal("Стр. " + currentPage), centerX, bottomStartY - 9, 0xFFFFFFFF);
if (loading) {
context.drawCenteredTextWithShadow(this.textRenderer, Text.literal("Загрузка транзакций..."), centerX, this.height / 2 - 10, 0xFFCCCCCC);
} else if (!errorMessage.isEmpty()) {
context.drawCenteredTextWithShadow(this.textRenderer, Text.literal("Ошибка: " + errorMessage), centerX, this.height / 2 - 10, 0xFFFF5555);
} else if (transactions.isEmpty()) {
context.drawCenteredTextWithShadow(this.textRenderer, Text.literal("Транзакций не найдено"), centerX, this.height / 2 - 10, 0xFFCCCCCC);
} else {
int y = startY + 15;
int limit = Math.min(6, transactions.size() - scrollOffset);
for (int i = 0; i < limit; i++) {
LocalTransaction tx = transactions.get(scrollOffset + i);
String amountText = tx.amount() + " АР";
String dateText = tx.createdAt();
if (dateText == null) {
dateText = "";
}
if (dateText.length() > 19) {
dateText = dateText.substring(0, 19).replace("T", " ");
}
String commentText = tx.comment().isEmpty() ? "" : " (" + tx.comment() + ")";
String line = String.format("%s -> %s | %s%s", dateText, tx.receiver(), amountText, commentText);
String status = tx.status();
int color = (status != null && status.equalsIgnoreCase("SUCCESS")) ? 0xFF55FF55 : 0xFFFF5555;
context.drawCenteredTextWithShadow(this.textRenderer, Text.literal(line), centerX, y, color);
y += 18;
}
}
}
}
@@ -5,17 +5,22 @@ import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.Strictness;
import com.google.gson.stream.JsonReader;
import git.yawaflua.tech.spmega.GpsHudPosition;
import git.yawaflua.tech.spmega.SPMega;
import net.minecraft.client.font.TextRenderer;
import net.minecraft.client.gui.DrawContext;
import net.minecraft.text.Text;
import java.awt.*;
import java.util.ArrayDeque;
import java.util.Queue;
public final class UiNotifications {
private static final int DEFAULT_DURATION_TICKS = 70;
private static final UiNotifications INSTANCE = new UiNotifications();
private Text currentText = Text.empty();
private final Queue<Text> queuedTexts = new ArrayDeque<>();
private int remainingTicks;
private UiNotifications() {
@@ -62,6 +67,17 @@ public final class UiNotifications {
show(Text.literal(extractMessage(message)));
}
public synchronized void showQueued(Text text) {
if (text == null || text.getString().isBlank()) {
return;
}
if (!isVisible()) {
show(text);
} else {
queuedTexts.add(text);
}
}
public synchronized void render(DrawContext context, TextRenderer textRenderer, int width, int height) {
if (!isVisible()) {
return;
@@ -72,8 +88,18 @@ public final class UiNotifications {
int textWidth = textRenderer.getWidth(message);
int boxWidth = textWidth + padding * 2;
int boxHeight = textRenderer.fontHeight + padding * 2;
int x = width - boxWidth - 10;
int y = height - boxHeight - 10;
GpsHudPosition position = SPMega.getConfig() == null
? GpsHudPosition.BOTTOM_RIGHT
: SPMega.getConfig().notificationPosition();
int x = switch (position) {
case TOP_LEFT, BOTTOM_LEFT -> 10;
case TOP_CENTER, BOTTOM_CENTER -> (width - boxWidth) / 2;
case TOP_RIGHT, BOTTOM_RIGHT -> width - boxWidth - 10;
};
int y = switch (position) {
case TOP_LEFT, TOP_CENTER, TOP_RIGHT -> 10;
case BOTTOM_LEFT, BOTTOM_CENTER, BOTTOM_RIGHT -> height - boxHeight - 10;
};
context.fill(x, y, x + boxWidth, y + boxHeight, Color.DARK_GRAY.getRGB());
context.drawTextWithShadow(textRenderer, currentText, x + padding, y + padding, Color.WHITE.getRGB());
@@ -85,8 +111,8 @@ public final class UiNotifications {
}
remainingTicks--;
if (remainingTicks <= 0) {
currentText = Text.empty();
remainingTicks = 0;
currentText = queuedTexts.isEmpty() ? Text.empty() : queuedTexts.remove();
remainingTicks = currentText.getString().isBlank() ? 0 : DEFAULT_DURATION_TICKS;
}
}
@@ -0,0 +1,519 @@
package git.yawaflua.tech.spmega.client.ui.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mojang.authlib.exceptions.AuthenticationException;
import com.mojang.authlib.minecraft.MinecraftSessionService;
import git.yawaflua.tech.spmega.ModConfig;
import git.yawaflua.tech.spmega.SPMega;
import git.yawaflua.tech.spmega.client.telemetry.InstrumentedHttpClient;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.session.Session;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
public final class BackendAuthenticator {
private BackendAuthenticator() {
}
public static CompletableFuture<Boolean> authenticateAsync(MinecraftClient client) {
Session session = client.getSession();
if (session == null || session.getUuidOrNull() == null) {
System.err.println("Cannot authenticate: Client has no valid session.");
return CompletableFuture.completedFuture(false);
}
MinecraftSessionService sessionService = client.getApiServices().sessionService();
return sendStartSessionRequestToBackendAsync(session.getUsername(), session.getUuidOrNull())
.thenCompose(serverId -> CompletableFuture.runAsync(() -> {
System.out.println("[SPMEGA] Trying to auth in mojang with serverId: " + serverId);
try {
sessionService.joinServer(session.getUuidOrNull(), session.getAccessToken(), serverId);
} catch (AuthenticationException exception) {
throw new CompletionException(exception);
}
})
.thenCompose(ignored -> {
System.out.println("[SPMEGA] Sending session submitter to backend");
return sendAuthRequestToBackendAsync(session.getUuidOrNull(), serverId);
}))
.exceptionally(throwable -> {
Throwable cause = throwable.getCause() == null ? throwable : throwable.getCause();
if (cause instanceof AuthenticationException) {
System.err.println("I cant auth by Mojang: " + cause.getMessage());
System.err.println("Please check your credentials and try again.");
} else {
System.err.println("Failed to authenticate with backend: " + cause.getMessage());
}
return false;
});
}
private static CompletableFuture<String> sendStartSessionRequestToBackendAsync(String username, UUID uuid) {
ModConfig config = SPMega.getConfig();
String apiDomain = (config != null) ? config.apiDomain() : ModConfig.DEFAULT_API_DOMAIN;
if (apiDomain == null || apiDomain.isBlank()) {
apiDomain = ModConfig.DEFAULT_API_DOMAIN;
}
if (apiDomain.endsWith("/")) {
apiDomain = apiDomain.substring(0, apiDomain.length() - 1);
}
JsonObject jsonPayload = new JsonObject();
jsonPayload.addProperty("userName", username);
jsonPayload.addProperty("userUUID", uuid.toString());
String requestBody = jsonPayload.toString();
String url = apiDomain + "/api/v1/auth/start";
InstrumentedHttpClient httpClient = new InstrumentedHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString(requestBody))
.build();
return httpClient.sendAsync(request).thenApply(response -> {
if (response.statusCode() != 200) {
System.err.println("[SPMEGA] Server returned status code " + response.statusCode() + " on start: " + response.body());
throw new CompletionException(new IOException(
"Server returned status code " + response.statusCode() + " on start: " + response.body()));
}
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
if (!json.has("sessionId")) {
System.err.println("[SPMEGA] Invalid response from start endpoint: " + response.body());
throw new CompletionException(new IOException("Invalid response from start endpoint: " + response.body()));
}
return json.get("sessionId").getAsString();
});
}
private static CompletableFuture<Boolean> sendAuthRequestToBackendAsync(UUID uuid, String serverId) {
ModConfig config = SPMega.getConfig();
String apiDomain = (config != null) ? config.apiDomain() : ModConfig.DEFAULT_API_DOMAIN;
if (apiDomain == null || apiDomain.isBlank()) {
apiDomain = ModConfig.DEFAULT_API_DOMAIN;
}
if (apiDomain.endsWith("/")) {
apiDomain = apiDomain.substring(0, apiDomain.length() - 1);
}
JsonObject jsonPayload = new JsonObject();
jsonPayload.addProperty("userUUID", uuid.toString());
jsonPayload.addProperty("sessionId", serverId);
String requestBody = jsonPayload.toString();
String url = apiDomain + "/api/v1/auth/validate";
InstrumentedHttpClient httpClient = new InstrumentedHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
return httpClient.sendAsync(request).thenApply(response -> {
if (response.statusCode() != 200) {
System.err.println("[SPMEGA] Server returned status code " + response.statusCode() + " on validate: " + response.body());
throw new CompletionException(new IOException(
"Server returned status code " + response.statusCode() + " on validate: " + response.body()));
}
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
if (!json.has("token")) {
System.err.println("[SPMEGA] Invalid response from validate endpoint: " + response.body());
throw new CompletionException(new IOException("Invalid response from validate endpoint: " + response.body()));
}
if (config == null) {
System.err.println("[SPMEGA] Config is null, cannot save token.");
throw new CompletionException(new IOException("Config is null, cannot save token."));
}
String token = json.get("token").getAsString();
SPMega.setConfig(new ModConfig(
config.apiDomain(), token, config.allowBackend(), config.signQuickPayEnabled(),
config.gpsEnabled(), config.gpsPosition(), config.notificationPosition(),
config.telemetryIntervalSeconds(), config.telemetryCollectSystemInfo()));
System.out.println("[SPMEGA] Backend auth successful, saved token.");
return true;
});
}
public static void sendCardToBackend(String cardId, String cardToken) {
ModConfig config = SPMega.getConfig();
if (config == null || !config.allowBackend()) {
return;
}
String apiDomain = config.apiDomain();
if (apiDomain == null || apiDomain.isBlank()) {
apiDomain = ModConfig.DEFAULT_API_DOMAIN;
}
if (apiDomain.endsWith("/")) {
apiDomain = apiDomain.substring(0, apiDomain.length() - 1);
}
String url = apiDomain + "/api/v1/auth/cards";
JsonObject jsonPayload = new JsonObject();
jsonPayload.addProperty("id", cardId);
jsonPayload.addProperty("token", cardToken);
String requestBody = jsonPayload.toString();
InstrumentedHttpClient httpClient = new InstrumentedHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("Authorization", "Bearer " + config.apiToken())
.PUT(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
httpClient.sendAsync(request)
.thenAccept(response -> {
if (response.statusCode() != 200 && response.statusCode() != 204) {
System.err.println("[SPMEGA] Failed to send card to backend: status code "
+ response.statusCode() + ", body: " + response.body());
} else {
System.out.println("[SPMEGA] Card successfully synchronized with backend.");
}
})
.exceptionally(throwable -> {
System.err.println("[SPMEGA] Error sending card to backend: " + throwable.getMessage());
return null;
});
}
public static CompletableFuture<List<CardCredentials>> fetchCardsFromBackendAsync() {
ModConfig config = SPMega.getConfig();
if (config == null || !config.allowBackend()) {
return CompletableFuture.completedFuture(List.of());
}
String apiDomain = config.apiDomain();
if (apiDomain == null || apiDomain.isBlank()) {
apiDomain = ModConfig.DEFAULT_API_DOMAIN;
}
if (apiDomain.endsWith("/")) {
apiDomain = apiDomain.substring(0, apiDomain.length() - 1);
}
String url = apiDomain + "/api/v1/auth/cards";
InstrumentedHttpClient httpClient = new InstrumentedHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.header("Accept", "application/json")
.header("Authorization", "Bearer " + config.apiToken())
.GET()
.build();
return httpClient.sendAsync(request).thenApply(response -> {
if (response.statusCode() != 200) {
throw new CompletionException(new IOException(
"Server returned status code " + response.statusCode() + " on fetch cards."));
}
com.google.gson.JsonArray cardsArray = JsonParser.parseString(response.body()).getAsJsonArray();
List<CardCredentials> cards = new ArrayList<>();
for (com.google.gson.JsonElement element : cardsArray) {
JsonObject cardJson = element.getAsJsonObject();
String cardId = cardJson.has("cardId") && !cardJson.get("cardId").isJsonNull()
? cardJson.get("cardId").getAsString()
: (cardJson.has("id") && !cardJson.get("id").isJsonNull() ? cardJson.get("id").getAsString() : "");
String cardToken = cardJson.has("cardToken") && !cardJson.get("cardToken").isJsonNull()
? cardJson.get("cardToken").getAsString()
: (cardJson.has("token") && !cardJson.get("token").isJsonNull() ? cardJson.get("token").getAsString() : "");
boolean webhookEnabled = cardJson.has("webhookConnected")
&& cardJson.get("webhookConnected").getAsBoolean();
if (!cardId.isEmpty() && !cardToken.isEmpty()) {
cards.add(new CardCredentials(cardId, cardToken, webhookEnabled));
}
}
return cards;
});
}
public static void deleteCardOnBackend(String cardId) {
ModConfig config = SPMega.getConfig();
if (config == null || !config.allowBackend()) {
return;
}
String apiDomain = config.apiDomain();
if (apiDomain == null || apiDomain.isBlank()) {
apiDomain = ModConfig.DEFAULT_API_DOMAIN;
}
if (apiDomain.endsWith("/")) {
apiDomain = apiDomain.substring(0, apiDomain.length() - 1);
}
String url = apiDomain + "/api/v1/auth/cards/" + cardId;
InstrumentedHttpClient httpClient = new InstrumentedHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.header("Accept", "application/json")
.header("Authorization", "Bearer " + config.apiToken())
.DELETE()
.build();
httpClient.sendAsync(request)
.thenAccept(response -> {
if (response.statusCode() != 200 && response.statusCode() != 204) {
System.err.println("[SPMEGA] Failed to delete card from backend: status code "
+ response.statusCode() + ", body: " + response.body());
} else {
System.out.println("[SPMEGA] Card successfully deleted from backend.");
}
})
.exceptionally(throwable -> {
System.err.println("[SPMEGA] Error deleting card from backend: " + throwable.getMessage());
return null;
});
}
public static CompletableFuture<Void> registerWebhookForCardAsync(String cardId) {
ModConfig config = SPMega.getConfig();
if (config == null || !config.allowBackend()) {
return CompletableFuture.failedFuture(new IOException("Backend is disabled"));
}
HttpRequest request = HttpRequest.newBuilder(URI.create(backendUrl(config, "/api/v1/webhook/" + cardId)))
.header("Accept", "application/json")
.header("Authorization", "Bearer " + config.apiToken())
.PUT(HttpRequest.BodyPublishers.noBody())
.build();
return new InstrumentedHttpClient().sendAsync(request).thenApply(response -> {
if (response.statusCode() != 200 && response.statusCode() != 204) {
throw new CompletionException(new IOException(
"Server returned status code " + response.statusCode() + ": " + response.body()));
}
return null;
});
}
public static CompletableFuture<List<PaymentNotification>> readNotificationsAsync() {
ModConfig config = SPMega.getConfig();
if (config == null || !config.allowBackend()
|| config.apiToken() == null || config.apiToken().isBlank()
|| ModConfig.DEFAULT_API_TOKEN.equals(config.apiToken())) {
return CompletableFuture.completedFuture(List.of());
}
HttpRequest request = HttpRequest.newBuilder(URI.create(backendUrl(config, "/api/v1/webhook/read")))
.header("Accept", "application/json")
.header("Authorization", "Bearer " + config.apiToken())
.GET()
.build();
return new InstrumentedHttpClient().sendAsync(request).thenApply(response -> {
if (response.statusCode() != 200) {
throw new CompletionException(new IOException(
"Server returned status code " + response.statusCode() + " on notification read."));
}
JsonElement parsed = JsonParser.parseString(response.body());
com.google.gson.JsonArray array = parsed.isJsonArray()
? parsed.getAsJsonArray()
: parsed.getAsJsonObject().getAsJsonArray("$values");
if (array == null) {
return List.of();
}
List<PaymentNotification> notifications = new ArrayList<>();
for (JsonElement element : array) {
JsonObject json = element.getAsJsonObject();
notifications.add(new PaymentNotification(
stringValue(json, "id"),
stringValue(json, "senderName"),
stringValue(json, "senderNumber"),
stringValue(json, "comment"),
json.has("amount") ? json.get("amount").getAsInt() : 0,
stringValue(json, "type")
));
}
return notifications;
});
}
private static String backendUrl(ModConfig config, String path) {
String domain = config.apiDomain();
if (domain == null || domain.isBlank()) {
domain = ModConfig.DEFAULT_API_DOMAIN;
}
return (domain.endsWith("/") ? domain.substring(0, domain.length() - 1) : domain) + path;
}
private static String stringValue(JsonObject json, String name) {
return json.has(name) && !json.get(name).isJsonNull() ? json.get(name).getAsString() : "";
}
public static CompletableFuture<Boolean> createTransactionOnBackendAsync(
String senderCardId, String receiver, long amount, String comment) {
ModConfig config = SPMega.getConfig();
if (config == null) {
return CompletableFuture.failedFuture(new IOException("ModConfig is null"));
}
String apiDomain = config.apiDomain();
if (apiDomain == null || apiDomain.isBlank()) {
apiDomain = ModConfig.DEFAULT_API_DOMAIN;
}
if (apiDomain.endsWith("/")) {
apiDomain = apiDomain.substring(0, apiDomain.length() - 1);
}
String url = apiDomain + "/api/v1/transactions";
JsonObject jsonPayload = new JsonObject();
jsonPayload.addProperty("cardToUse", senderCardId);
jsonPayload.addProperty("cardId", senderCardId);
jsonPayload.addProperty("receiverCard", receiver);
jsonPayload.addProperty("receiverName", receiver);
jsonPayload.addProperty("amount", amount);
jsonPayload.addProperty("comment", comment);
String requestBody = jsonPayload.toString();
InstrumentedHttpClient httpClient = new InstrumentedHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("Authorization", "Bearer " + config.apiToken())
.PUT(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
return httpClient.sendAsync(request).thenApply(response -> {
if (response.statusCode() != 200 && response.statusCode() != 201 && response.statusCode() != 204) {
throw new CompletionException(new IOException(
"Server returned status code " + response.statusCode() + ": " + response.body()));
}
return true;
});
}
public static CompletableFuture<List<BankDatabase.LocalTransaction>> fetchTransactionsFromBackendAsync(
String cardId, int page) {
System.out.println("[SPMEGA] fetchTransactionsFromBackend called for cardId: " + cardId + ", page: " + page);
ModConfig config = SPMega.getConfig();
if (config == null || !config.allowBackend()) {
System.out.println("[SPMEGA] fetchTransactionsFromBackend aborted: config is null or backend not allowed.");
return CompletableFuture.completedFuture(List.of());
}
String apiDomain = config.apiDomain();
if (apiDomain == null || apiDomain.isBlank()) {
apiDomain = ModConfig.DEFAULT_API_DOMAIN;
}
if (apiDomain.endsWith("/")) {
apiDomain = apiDomain.substring(0, apiDomain.length() - 1);
}
String url = apiDomain + "/api/v1/transactions?p=" + page;
System.out.println("[SPMEGA] Requesting transactions from URL: " + url);
InstrumentedHttpClient httpClient = new InstrumentedHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.header("Accept", "application/json")
.header("Authorization", "Bearer " + config.apiToken())
.GET()
.build();
return httpClient.sendAsync(request)
.thenApply(response -> parseTransactionsResponse(response, cardId));
}
private static List<BankDatabase.LocalTransaction> parseTransactionsResponse(
HttpResponse<String> response, String cardId) {
System.out.println("[SPMEGA] Response status code: " + response.statusCode());
if (response.statusCode() != 200) {
System.err.println("[SPMEGA] Failed response body: " + response.body());
throw new CompletionException(new IOException(
"Server returned status code " + response.statusCode() + " on fetch transactions."));
}
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<>();
for (com.google.gson.JsonElement el : transactionsArray) {
try {
JsonObject json = el.getAsJsonObject();
// 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;
String comment = json.has("comment") && !json.get("comment").isJsonNull() ? json.get("comment").getAsString() : "";
String status = json.has("status") && !json.get("status").isJsonNull() ? json.get("status").getAsString() : "SUCCESS";
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));
} 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;
}
public record PaymentNotification(
String id, String senderName, String senderNumber, String comment, int amount, String type) {
}
}
@@ -23,9 +23,23 @@ public final class BankDatabase {
card_number TEXT,
balance INTEGER NOT NULL DEFAULT 0,
owner_uuid TEXT,
webhook_enabled INTEGER NOT NULL DEFAULT 0,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
""");
boolean hasWebhookColumn = false;
try (ResultSet columns = statement.executeQuery("PRAGMA table_info(cards)")) {
while (columns.next()) {
if ("webhook_enabled".equals(columns.getString("name"))) {
hasWebhookColumn = true;
break;
}
}
}
if (!hasWebhookColumn) {
statement.executeUpdate(
"ALTER TABLE cards ADD COLUMN webhook_enabled INTEGER NOT NULL DEFAULT 0");
}
statement.executeUpdate("""
CREATE TABLE IF NOT EXISTS transfer_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -100,8 +114,31 @@ public final class BankDatabase {
}
}
public synchronized void setWebhookEnabled(String cardId, boolean enabled) {
String sql = "UPDATE cards SET webhook_enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE card_id = ?";
try (Connection connection = open(); PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setBoolean(1, enabled);
statement.setString(2, cardId);
statement.executeUpdate();
} catch (SQLException exception) {
throw new RuntimeException("Failed to update webhook state", exception);
}
}
public synchronized boolean hasWebhookEnabledCards() {
try (Connection connection = open();
PreparedStatement statement = connection.prepareStatement(
"SELECT 1 FROM cards WHERE webhook_enabled = 1 LIMIT 1");
ResultSet result = statement.executeQuery()) {
return result.next();
} catch (SQLException exception) {
throw new RuntimeException("Failed to read webhook state", exception);
}
}
public synchronized List<StoredCard> loadCards() {
String sql = "SELECT card_id, card_token, card_name, card_number, balance, owner_uuid FROM cards ORDER BY updated_at DESC";
String sql = "SELECT card_id, card_token, card_name, card_number, balance, owner_uuid, webhook_enabled "
+ "FROM cards ORDER BY updated_at DESC";
List<StoredCard> result = new ArrayList<>();
try (Connection connection = open();
PreparedStatement statement = connection.prepareStatement(sql);
@@ -113,7 +150,8 @@ public final class BankDatabase {
rs.getString("card_name"),
rs.getString("card_number"),
rs.getLong("balance"),
rs.getString("owner_uuid")
rs.getString("owner_uuid"),
rs.getBoolean("webhook_enabled")
));
}
return result;
@@ -166,6 +204,31 @@ public final class BankDatabase {
}
}
public synchronized List<LocalTransaction> loadTransferHistory(String cardId) {
String sql = "SELECT receiver, amount, comment, status, created_at FROM transfer_history WHERE sender_card_id = ? ORDER BY id DESC";
List<LocalTransaction> result = new ArrayList<>();
try (Connection connection = open(); PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, cardId);
try (ResultSet rs = statement.executeQuery()) {
while (rs.next()) {
result.add(new LocalTransaction(
rs.getString("receiver"),
rs.getLong("amount"),
rs.getString("comment"),
rs.getString("status"),
rs.getString("created_at")
));
}
}
return result;
} catch (SQLException exception) {
throw new RuntimeException("Failed to load transfer history", exception);
}
}
public record LocalTransaction(String receiver, long amount, String comment, String status, String createdAt) {
}
private Connection open() throws SQLException {
return DriverManager.getConnection(jdbcUrl);
}
@@ -1,8 +1,14 @@
package git.yawaflua.tech.spmega.client.ui.service;
import git.yawaflua.tech.spmega.api.SPWorldsApiClient;
import git.yawaflua.tech.spmega.ModConfig;
import git.yawaflua.tech.spmega.SPMega;
import git.yawaflua.tech.spmega.client.api.SPWorldsApiClient;
import git.yawaflua.tech.spmega.client.telemetry.TelemetryCollector;
import git.yawaflua.tech.spmega.client.telemetry.TelemetryEvent;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.MinecraftClient;
import java.util.*;
import java.util.concurrent.CompletableFuture;
@@ -15,7 +21,7 @@ public final class BankUiService {
private final String apiDomain = "https://spworlds.ru";
private int selectedCardIndex;
private String lastMessage = "";
private volatile String lastMessage = "";
private BankUiService() {
this.database = new BankDatabase(FabricLoader.getInstance().getConfigDir().resolve("spmega.db"));
@@ -52,11 +58,20 @@ public final class BankUiService {
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);
}
public synchronized CardViewModel getSelectedCard() {
public CardViewModel getSelectedCard() {
if (cards.isEmpty()) {
return null;
}
@@ -64,7 +79,7 @@ public final class BankUiService {
return cards.get(selectedCardIndex);
}
public synchronized int getSelectedCardIndex() {
public int getSelectedCardIndex() {
if (cards.isEmpty()) {
return 0;
}
@@ -72,7 +87,7 @@ public final class BankUiService {
return selectedCardIndex;
}
public synchronized void setSelectedCardIndex(int index) {
public void setSelectedCardIndex(int index) {
if (cards.isEmpty()) {
selectedCardIndex = 0;
return;
@@ -80,7 +95,7 @@ public final class BankUiService {
selectedCardIndex = Math.floorMod(index, cards.size());
}
public synchronized CardViewModel cycleSelectedCard(int direction) {
public CardViewModel cycleSelectedCard(int direction) {
if (cards.isEmpty()) {
return null;
}
@@ -88,26 +103,51 @@ public final class BankUiService {
return cards.get(selectedCardIndex);
}
public synchronized String getLastMessage() {
public String getLastMessage() {
return lastMessage;
}
public synchronized void refreshOnServerJoin(String playerUuid) {
List<StoredCard> storedCards = database.loadCards();
for (StoredCard card : storedCards) {
refreshCard(card.cardId(), card.cardToken(), playerUuid, false, false);
private static String rootMessage(Throwable throwable) {
Throwable cause = throwable;
while (cause.getCause() != null) {
cause = cause.getCause();
}
return cause.getMessage();
}
public CompletableFuture<Void> syncCardsWithBackendAsync(String playerUuid) {
return BackendAuthenticator.fetchCardsFromBackendAsync()
.thenCompose(cards -> syncMissingCardsAsync(cards, playerUuid))
.thenAccept(changed -> {
if (changed) {
reloadCardsFromDb();
}
public synchronized List<String> loadRecipientCards(String username) {
CardCredentials credentials = getSelectedCredentials();
if (credentials == null || username == null || username.isBlank()) {
return List.of();
})
.exceptionally(exception -> {
System.err.println("[SPMEGA] Failed to sync cards with backend: " + exception.getMessage());
return null;
});
}
try {
List<SPWorldsApiClient.PlayerCard> apiCards = apiClient.getPlayerCards(username, toApiAuth(credentials));
public CompletableFuture<Void> refreshOnServerJoinAsync(String playerUuid) {
return BackendAuthenticator.fetchCardsFromBackendAsync()
.exceptionally(exception -> {
System.err.println("[SPMEGA] Failed to sync cards with backend: " + exception.getMessage());
return List.of();
})
.thenCompose(cards -> syncMissingCardsAsync(cards, playerUuid))
.thenCompose(ignored -> refreshStoredCardsAsync(playerUuid))
.thenRun(this::reloadCardsFromDb);
}
public CompletableFuture<List<String>> loadRecipientCardsAsync(String username) {
CardCredentials credentials = getSelectedCredentials();
if (credentials == null || username == null || username.isBlank()) {
return CompletableFuture.completedFuture(List.of());
}
return apiClient.getPlayerCardsAsync(username, toApiAuth(credentials))
.thenApply(apiCards -> {
List<String> numbers = new ArrayList<>();
for (SPWorldsApiClient.PlayerCard apiCard : apiCards) {
if (apiCard.number() != null && !apiCard.number().isBlank()) {
@@ -116,130 +156,167 @@ public final class BankUiService {
}
lastMessage = "";
return numbers;
} catch (Exception exception) {
})
.exceptionally(exception -> {
lastMessage = "Не удалось получить карты игрока: " + exception.getMessage();
return List.of();
}
});
}
public synchronized String addCard(String cardIdRaw, String cardTokenRaw, String playerUuid) {
public CompletableFuture<String> removeSelectedCardAsync() {
CardViewModel selected = getSelectedCard();
if (selected == null) {
return CompletableFuture.completedFuture("Нет карты для удаления");
}
String cardId = selected.id();
return CompletableFuture.supplyAsync(() -> {
database.deleteCard(cardId);
ModConfig config = SPMega.getConfig();
if (config != null && config.allowBackend()) {
BackendAuthenticator.deleteCardOnBackend(cardId);
}
reloadCardsFromDb();
runOnMainThread(() -> {
if (selectedCardIndex >= cards.size()) {
selectedCardIndex = Math.max(0, cards.size() - 1);
}
});
return "Карта удалена";
});
}
public CompletableFuture<String> addCardAsync(String cardIdRaw, String cardTokenRaw, String playerUuid) {
String cardId = cardIdRaw == null ? "" : cardIdRaw.trim();
String cardToken = cardTokenRaw == null ? "" : cardTokenRaw.trim();
if (cardId.isEmpty() || cardToken.isEmpty()) {
lastMessage = "Укажи cardId и cardToken";
return lastMessage;
return CompletableFuture.completedFuture("Укажи cardId и cardToken");
}
try {
UUID.fromString(cardId);
} catch (IllegalArgumentException exception) {
lastMessage = "cardId должен быть UUID";
return lastMessage;
return CompletableFuture.completedFuture("cardId должен быть UUID");
}
database.upsertCardCredentials(cardId, cardToken);
boolean refreshed = refreshCard(cardId, cardToken, playerUuid, true, true);
return CompletableFuture.runAsync(() -> database.upsertCardCredentials(cardId, cardToken))
.thenCompose(ignored -> refreshCardAsync(cardId, cardToken, playerUuid, true, true))
.thenApply(refreshed -> {
if (!refreshed) {
database.deleteCard(cardId);
}
reloadCardsFromDb();
if (refreshed && lastMessage.isBlank()) {
lastMessage = "Карта добавлена";
}
return lastMessage;
return refreshed && lastMessage.isBlank() ? "Карта добавлена" : lastMessage;
});
}
public synchronized void removeSelectedCard() {
public CompletableFuture<String> refreshSelectedCardAsync(String playerUuid) {
CardViewModel selected = getSelectedCard();
if (selected == null) {
lastMessage = "Нет карты для удаления";
return;
return CompletableFuture.completedFuture("Нет карты для обновления");
}
database.deleteCard(selected.id());
reloadCardsFromDb();
if (selectedCardIndex >= cards.size()) {
selectedCardIndex = Math.max(0, cards.size() - 1);
}
lastMessage = "Карта удалена";
}
public synchronized void refreshSelectedCard(String playerUuid) {
CardViewModel selected = getSelectedCard();
if (selected == null) {
lastMessage = "Нет карты для обновления";
return;
}
CardCredentials credentials = database.getCredentials(selected.id());
return CompletableFuture.supplyAsync(() -> database.getCredentials(selected.id()))
.thenCompose(credentials -> {
if (credentials == null) {
lastMessage = "Не найдены креды карты";
return;
return CompletableFuture.completedFuture("Не найдены креды карты");
}
refreshCard(credentials.cardId(), credentials.cardToken(), playerUuid, false, false);
return refreshCardAsync(credentials.cardId(), credentials.cardToken(), playerUuid, false, false)
.thenApply(ignored -> {
reloadCardsFromDb();
return lastMessage.isBlank() ? "Карта обновлена" : lastMessage;
});
});
}
public synchronized boolean submitPayment(PaymentDraft draft) {
CardCredentials credentials = database.getCredentials(draft.senderCardId());
if (credentials == null) {
lastMessage = "Не найдены креды карты отправителя";
return false;
private void recordPaymentEvent(PaymentDraft draft, boolean success, String mode, String destination, long durationMs, String senderLast4) {
recordPaymentEvent(draft, success, mode, destination, durationMs, senderLast4, null);
}
try {
SPWorldsApiClient.TransactionResult result = apiClient.createTransaction(
toApiAuth(credentials),
draft.recipient(),
draft.amount(),
draft.comment()
);
database.updateCardBalance(credentials.cardId(), result.balance());
database.insertTransferHistory(
credentials.cardId(),
draft.recipient(),
draft.amount(),
draft.comment(),
result.balance(),
"SUCCESS"
);
reloadCardsFromDb();
lastMessage = "Перевод выполнен";
return true;
} catch (Exception exception) {
database.insertTransferHistory(
credentials.cardId(),
draft.recipient(),
draft.amount(),
draft.comment(),
null,
"FAILED: " + trimMessage(exception.getMessage())
);
lastMessage = "Ошибка перевода: " + exception.getMessage();
return false;
private void recordPaymentEvent(PaymentDraft draft, boolean success, String mode, String destination, long durationMs, String senderLast4, String error) {
com.google.gson.JsonObject payload = new com.google.gson.JsonObject();
payload.addProperty("success", success);
payload.addProperty("mode", mode);
payload.addProperty("destination", destination);
payload.addProperty("durationMs", durationMs);
payload.addProperty("senderCardLast4", senderLast4 != null ? senderLast4 : "unknown");
payload.addProperty("amount", draft.amount());
payload.addProperty("receiver", draft.recipient());
if (error != null) {
payload.addProperty("error", trimMessage(error));
}
TelemetryCollector.instance().record(TelemetryEvent.now("payment", payload));
}
public CompletableFuture<Boolean> submitPaymentAsync(PaymentDraft draft) {
return CompletableFuture.supplyAsync(() -> submitPayment(draft));
return CompletableFuture.supplyAsync(() -> database.getCredentials(draft.senderCardId()))
.thenCompose(credentials -> {
if (credentials == null) {
lastMessage = "Не найдены креды карты отправителя";
recordPaymentEvent(draft, false, "local", "no_credentials", 0, null);
return CompletableFuture.completedFuture(false);
}
private boolean refreshCard(
ModConfig config = SPMega.getConfig();
boolean useBackend = config != null && config.allowBackend();
String mode = useBackend ? "backend" : "spworlds-direct";
String destination = useBackend ? "backend" : "local-db";
String senderLast4 = extractLastDigits(credentials.cardId());
long startNs = System.nanoTime();
CompletableFuture<Long> transaction = useBackend
? BackendAuthenticator.createTransactionOnBackendAsync(
draft.senderCardId(), draft.recipient(), draft.amount(), draft.comment())
.thenCompose(ignored -> refreshCardAsync(
credentials.cardId(), credentials.cardToken(), "", false, false))
.thenApply(ignored -> database.loadCards().stream()
.filter(card -> card.cardId().equals(credentials.cardId()))
.findFirst()
.map(StoredCard::balance)
.orElse(0L))
: apiClient.createTransactionAsync(
toApiAuth(credentials), draft.recipient(), draft.amount(), draft.comment())
.thenApply(result -> {
database.updateCardBalance(credentials.cardId(), result.balance());
return result.balance();
});
return transaction
.thenApply(newBalance -> {
database.insertTransferHistory(
credentials.cardId(), draft.recipient(), draft.amount(), draft.comment(),
newBalance, "SUCCESS");
reloadCardsFromDb();
lastMessage = "Перевод выполнен";
recordPaymentEvent(draft, true, mode, destination,
(System.nanoTime() - startNs) / 1_000_000L, senderLast4);
return true;
})
.exceptionally(exception -> {
String error = rootMessage(exception);
database.insertTransferHistory(
credentials.cardId(), draft.recipient(), draft.amount(), draft.comment(),
null, "FAILED: " + trimMessage(error));
lastMessage = "Ошибка перевода: " + error;
recordPaymentEvent(draft, false, mode, destination,
(System.nanoTime() - startNs) / 1_000_000L, senderLast4, error);
return false;
});
});
}
private CompletableFuture<Boolean> refreshCardAsync(
String cardId,
String cardToken,
String playerUuid,
boolean reportOwnerWarning,
boolean requireCardInAccount
) {
try {
SPWorldsApiClient.CardAuth auth = new SPWorldsApiClient.CardAuth(cardId, cardToken);
SPWorldsApiClient.AccountMe me = apiClient.getAccountMe(auth);
SPWorldsApiClient.CardInfo cardInfo = apiClient.getCardInfo(auth);
return apiClient.getAccountMeAsync(auth)
.thenCompose(me -> apiClient.getCardInfoAsync(auth).thenApply(cardInfo -> {
SPWorldsApiClient.AccountCard currentCard = me.cards().stream()
.filter(card -> Objects.equals(card.id(), cardId))
@@ -264,26 +341,81 @@ public final class BankUiService {
return true;
}
}
ModConfig config = SPMega.getConfig();
if (config != null && config.allowBackend()) {
BackendAuthenticator.sendCardToBackend(cardId, cardToken);
}
lastMessage = "";
return true;
} catch (Exception exception) {
lastMessage = "Не удалось обновить карту: " + exception.getMessage();
}))
.exceptionally(exception -> {
lastMessage = "Не удалось обновить карту: " + rootMessage(exception);
return false;
});
}
public CompletableFuture<String> registerSelectedWebhookAsync() {
CardViewModel selected = getSelectedCard();
if (selected == null) {
return CompletableFuture.completedFuture("Сначала выбери или добавь карту");
}
if (selected.webhookEnabled()) {
return CompletableFuture.completedFuture("Вебхук для этой карты уже включён");
}
return BackendAuthenticator.registerWebhookForCardAsync(selected.id())
.thenApply(ignored -> {
database.setWebhookEnabled(selected.id(), true);
reloadCardsFromDb();
return "Обработка вебхуков включена";
})
.exceptionally(exception -> "Не удалось включить вебхук: " + rootMessage(exception));
}
public boolean hasWebhookEnabledCards() {
return database.hasWebhookEnabledCards();
}
private CompletableFuture<Boolean> syncMissingCardsAsync(List<CardCredentials> backendCards, String playerUuid) {
CompletableFuture<Boolean> result = CompletableFuture.completedFuture(false);
for (CardCredentials credentials : backendCards) {
result = result.thenCompose(changed -> {
if (database.getCredentials(credentials.cardId()) != null) {
database.setWebhookEnabled(credentials.cardId(), credentials.webhookEnabled());
return CompletableFuture.completedFuture(changed);
}
database.upsertCardCredentials(credentials.cardId(), credentials.cardToken());
database.setWebhookEnabled(credentials.cardId(), credentials.webhookEnabled());
return refreshCardAsync(
credentials.cardId(), credentials.cardToken(), playerUuid, false, false)
.thenApply(ignored -> true);
});
}
return result;
}
private CompletableFuture<Void> refreshStoredCardsAsync(String playerUuid) {
CompletableFuture<Void> result = CompletableFuture.completedFuture(null);
for (StoredCard card : database.loadCards()) {
result = result.thenCompose(ignored -> refreshCardAsync(
card.cardId(), card.cardToken(), playerUuid, false, false).thenApply(refreshed -> null));
}
return result;
}
private void reloadCardsFromDb() {
List<StoredCard> storedCards = database.loadCards();
runOnMainThread(() -> {
cards.clear();
for (StoredCard stored : storedCards) {
String cardNumber = stored.cardNumber() == null || stored.cardNumber().isBlank()
? extractLastDigits(stored.cardId())
: stored.cardNumber();
String cardName = stored.cardName() == null || stored.cardName().isBlank() ? "Карта" : stored.cardName();
String title = cardNumber + ": " + cardName;
cards.add(new CardViewModel(stored.cardId(), title, stored.balance()));
cards.add(new CardViewModel(
stored.cardId(), title, stored.balance(), stored.webhookEnabled()));
}
if (cards.isEmpty()) {
@@ -291,6 +423,7 @@ public final class BankUiService {
} else {
selectedCardIndex = Math.floorMod(selectedCardIndex, cards.size());
}
});
}
private CardCredentials getSelectedCredentials() {
@@ -300,4 +433,8 @@ public final class BankUiService {
}
return database.getCredentials(selected.id());
}
public BankDatabase getDatabase() {
return database;
}
}
@@ -1,5 +1,8 @@
package git.yawaflua.tech.spmega.client.ui.service;
public record CardCredentials(String cardId, String cardToken) {
public record CardCredentials(String cardId, String cardToken, boolean webhookEnabled) {
public CardCredentials(String cardId, String cardToken) {
this(cardId, cardToken, false);
}
}
@@ -1,5 +1,5 @@
package git.yawaflua.tech.spmega.client.ui.service;
public record CardViewModel(String id, String title, long balance) {
public record CardViewModel(String id, String title, long balance, boolean webhookEnabled) {
}
@@ -1,6 +1,6 @@
package git.yawaflua.tech.spmega.client.ui.service;
public record StoredCard(String cardId, String cardToken, String cardName, String cardNumber, long balance,
String ownerUuid) {
String ownerUuid, boolean webhookEnabled) {
}
@@ -14,6 +14,36 @@
"message.spmega.qr.error": "Failed to scan QR from screen",
"message.spmega.qr.hover_tip": "Click to open link",
"message.spmega.qr.found_link": "Found link: %s",
"message.spmega.qr.failed_open": "Failed to open link"
"message.spmega.qr.failed_open": "Failed to open link",
"key.spmega.toggle_gps": "Toggle Nether GPS",
"title.spmega.config": "SPMega Settings",
"category.spmega.general": "General Settings",
"option.spmega.api_domain": "API Domain",
"option.spmega.api_token": "API Token",
"option.spmega.allow_backend": "Allow saving data, transactions and other sensitive information to the backend",
"option.spmega.sign_quick_pay": "Quick Pay by Signs",
"option.spmega.gps_position": "Nether GPS position",
"option.spmega.gps_position.top_left": "Top-Left",
"option.spmega.gps_position.top_right": "Top-Right",
"option.spmega.gps_position.bottom_left": "Bottom-Left",
"option.spmega.gps_position.bottom_right": "Bottom-Right",
"option.spmega.gps_position.bottom_center": "Bottom-center",
"option.spmega.gps_position.top_center": "Top-center",
"option.spmega.notification_position": "Notification position",
"option.spmega.notification_position.top_left": "Top-left",
"option.spmega.notification_position.top_right": "Top-right",
"option.spmega.notification_position.bottom_left": "Bottom-left",
"option.spmega.notification_position.bottom_right": "Bottom-right",
"option.spmega.notification_position.bottom_center": "Bottom-center",
"option.spmega.notification_position.top_center": "Top-center",
"category.spmega.telemetry": "Telemetry",
"option.spmega.telemetry_enabled": "Collect system information",
"tooltip.spmega.telemetry_enabled": "Disabling hides hardware data (CPU, GPU, RAM, IP). Performance and request stats keep sending.",
"option.spmega.telemetry_interval": "Send interval (sec)",
"tooltip.spmega.telemetry_interval": "How often telemetry batches are sent to the server (10-600 sec)",
"option.spmega.telemetry_system_info": "Send system information",
"tooltip.spmega.telemetry_system_info": "CPU, GPU, RAM, storage, IP, device name, Java version"
}
@@ -0,0 +1,46 @@
{
"category.spmega": "SPMega",
"key.spmega.open_menu": "Открыть меню SPMega",
"key.spmega.scan_qr": "Сканировать QR-код с экрана",
"key.spmega.toggle_gps": "Вкл/Выкл GPS Ада",
"button.spmega.scan_qr": "Сканировать QR",
"button.spmega.qr.cancel": "Отмена",
"button.spmega.qr.open_link": "Открыть ссылку",
"screen.spmega.qr.confirm_title": "Подтверждение ссылки",
"screen.spmega.qr.accept_link": "Открыть эту ссылку?",
"message.spmega.qr.capture_failed": "Не удалось сделать снимок экрана Minecraft",
"message.spmega.qr.not_found": "QR-код на экране не найден",
"message.spmega.qr.invalid_url": "QR-код не содержит валидный http/https URL",
"message.spmega.qr.opened": "Открыто: %s",
"message.spmega.qr.error": "Не удалось отсканировать QR-код с экрана",
"message.spmega.qr.hover_tip": "Нажмите, чтобы открыть ссылку",
"message.spmega.qr.found_link": "Найдена ссылка: %s",
"message.spmega.qr.failed_open": "Не удалось открыть ссылку",
"title.spmega.config": "Настройки SPMega",
"category.spmega.general": "Основные настройки",
"option.spmega.api_domain": "Домен API",
"option.spmega.api_token": "Токен API",
"option.spmega.allow_backend": "Разрешить сохранение данных, транзакций и другой получаемой информации на сервере",
"option.spmega.sign_quick_pay": "Быстрая оплата по табличкам",
"option.spmega.gps_position": "Положение GPS Ада",
"option.spmega.gps_position.top_left": "Слева вверху",
"option.spmega.gps_position.top_right": "Справа вверху",
"option.spmega.gps_position.bottom_left": "Слева внизу",
"option.spmega.gps_position.bottom_center": "Снизу по-центру",
"option.spmega.gps_position.top_center": "Сверху по-центру",
"option.spmega.gps_position.bottom_right": "Снизу справа",
"option.spmega.notification_position": "Положение уведомлений",
"option.spmega.notification_position.top_left": "Слева вверху",
"option.spmega.notification_position.top_right": "Справа вверху",
"option.spmega.notification_position.bottom_left": "Слева внизу",
"option.spmega.notification_position.bottom_center": "Снизу по центру",
"option.spmega.notification_position.top_center": "Сверху по центру",
"option.spmega.notification_position.bottom_right": "Снизу справа",
"category.spmega.telemetry": "Телеметрия",
"option.spmega.telemetry_enabled": "Сбор системной информации",
"tooltip.spmega.telemetry_enabled": "Отключение скрывает данные компьютера (CPU, GPU, RAM, IP). Статистика производительности и запросов продолжает отправляться.",
"option.spmega.telemetry_interval": "Интервал отправки (сек)",
"tooltip.spmega.telemetry_interval": "Как часто батч телеметрии отправляется на сервер (от 10 до 600 сек)",
"option.spmega.telemetry_system_info": "Отправлять информацию о системе",
"tooltip.spmega.telemetry_system_info": "CPU, GPU, RAM, диск, IP, имя устройства, версия Java"
}
@@ -42,19 +42,60 @@ public final class ConfigManager {
shouldSave = true;
}
boolean allowAccess = readBoolean(properties, "allow.backend", defaults.allowBackend());
String rawAllowAccess = properties.getProperty("allow.backend");
if (rawAllowAccess == null || !Boolean.toString(allowAccess).equalsIgnoreCase(rawAllowAccess.trim())) {
shouldSave = true;
}
boolean signQuickPayEnabled = readBoolean(properties, "sign.quickPay.enabled", defaults.signQuickPayEnabled());
String rawQuickPay = properties.getProperty("sign.quickPay.enabled");
if (rawQuickPay == null || !Boolean.toString(signQuickPayEnabled).equalsIgnoreCase(rawQuickPay.trim())) {
shouldSave = true;
}
ModConfig config = new ModConfig(apiDomain, apiToken, signQuickPayEnabled);
boolean gpsEnabled = readBoolean(properties, "gps.enabled", defaults.gpsEnabled());
String rawGps = properties.getProperty("gps.enabled");
if (rawGps == null || !Boolean.toString(gpsEnabled).equalsIgnoreCase(rawGps.trim())) {
shouldSave = true;
}
GpsHudPosition gpsPosition = readEnum(properties, "gps.position", GpsHudPosition.class, defaults.gpsPosition());
String rawPosition = properties.getProperty("gps.position");
if (rawPosition == null || !gpsPosition.name().equalsIgnoreCase(rawPosition.trim())) {
shouldSave = true;
}
GpsHudPosition notificationPosition = readEnum(
properties, "notifications.position", GpsHudPosition.class, defaults.notificationPosition());
String rawNotificationPosition = properties.getProperty("notifications.position");
if (rawNotificationPosition == null
|| !notificationPosition.name().equalsIgnoreCase(rawNotificationPosition.trim())) {
shouldSave = true;
}
int telemetryIntervalSeconds = readInt(properties, "telemetry.intervalSeconds", defaults.telemetryIntervalSeconds());
String rawInterval = properties.getProperty("telemetry.intervalSeconds");
if (rawInterval == null || !Integer.toString(telemetryIntervalSeconds).equals(rawInterval.trim())) {
shouldSave = true;
}
boolean telemetryCollectSystemInfo = readBoolean(properties, "telemetry.collectSystemInfo", defaults.telemetryCollectSystemInfo());
String rawSysInfo = properties.getProperty("telemetry.collectSystemInfo");
if (rawSysInfo == null || !Boolean.toString(telemetryCollectSystemInfo).equalsIgnoreCase(rawSysInfo.trim())) {
shouldSave = true;
}
ModConfig config = new ModConfig(apiDomain, apiToken, allowAccess, signQuickPayEnabled, gpsEnabled, gpsPosition,
notificationPosition, telemetryIntervalSeconds, telemetryCollectSystemInfo);
if (shouldSave) {
save(configPath, config);
}
return config;
}
private static String readString(Properties properties, String key, String fallback) {
@@ -97,11 +138,22 @@ public final class ConfigManager {
return fallback;
}
public static void save(ModConfig config) {
Path configPath = FabricLoader.getInstance().getConfigDir().resolve(FILE_NAME);
save(configPath, config);
}
private static void save(Path configPath, ModConfig config) {
Properties properties = new Properties();
properties.setProperty("api.domain", config.apiDomain());
properties.setProperty("api.token", config.apiToken());
properties.setProperty("allow.backend", Boolean.toString(config.allowBackend()));
properties.setProperty("sign.quickPay.enabled", Boolean.toString(config.signQuickPayEnabled()));
properties.setProperty("gps.enabled", Boolean.toString(config.gpsEnabled()));
properties.setProperty("gps.position", config.gpsPosition().name());
properties.setProperty("notifications.position", config.notificationPosition().name());
properties.setProperty("telemetry.intervalSeconds", Integer.toString(config.telemetryIntervalSeconds()));
properties.setProperty("telemetry.collectSystemInfo", Boolean.toString(config.telemetryCollectSystemInfo()));
try {
Files.createDirectories(configPath.getParent());
@@ -112,5 +164,29 @@ public final class ConfigManager {
throw new RuntimeException("Failed to save config: " + configPath, exception);
}
}
private static int readInt(Properties properties, String key, int fallback) {
String value = properties.getProperty(key);
if (value == null || value.trim().isEmpty()) {
return fallback;
}
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException exception) {
return fallback;
}
}
private static <E extends Enum<E>> E readEnum(Properties properties, String key, Class<E> enumClass, E fallback) {
String value = properties.getProperty(key);
if (value == null || value.trim().isEmpty()) {
return fallback;
}
try {
return Enum.valueOf(enumClass, value.trim().toUpperCase());
} catch (IllegalArgumentException exception) {
return fallback;
}
}
}
@@ -0,0 +1,21 @@
package git.yawaflua.tech.spmega;
public enum GpsHudPosition {
TOP_LEFT("Слева вверху"),
TOP_RIGHT("Справа вверху"),
BOTTOM_LEFT("Слева внизу"),
BOTTOM_RIGHT("Справа внизу"),
TOP_CENTER("Сверху по-центру"),
BOTTOM_CENTER("Снизу по-центру");
private final String displayName;
GpsHudPosition(String displayName) {
this.displayName = displayName;
}
public String getDisplayName() {
return displayName;
}
}
@@ -1,16 +1,31 @@
package git.yawaflua.tech.spmega;
public record ModConfig(String apiDomain, String apiToken, boolean signQuickPayEnabled) {
public static final String DEFAULT_API_DOMAIN = "https://spmega-api.yawaflua.tech";
public static final String DEFAULT_API_TOKEN = "ulBKE9MWEtIGiPAhXV69I28W9BRiSrV3";
public record ModConfig(String apiDomain, String apiToken, boolean allowBackend, boolean signQuickPayEnabled,
boolean gpsEnabled, GpsHudPosition gpsPosition,
GpsHudPosition notificationPosition, int telemetryIntervalSeconds,
boolean telemetryCollectSystemInfo) {
public static final String DEFAULT_API_DOMAIN = "https://spmega.yawaflua.tech";
public static final boolean ALLOW_BACKEND = true;
public static final String DEFAULT_API_TOKEN = "-";
public static final boolean DEFAULT_SIGN_QUICK_PAY_ENABLED = true;
public static final boolean DEFAULT_GPS_ENABLED = true;
public static final GpsHudPosition DEFAULT_GPS_POSITION = GpsHudPosition.TOP_CENTER;
public static final GpsHudPosition DEFAULT_NOTIFICATION_POSITION = GpsHudPosition.BOTTOM_RIGHT;
public static final boolean DEFAULT_TELEMETRY_ENABLED = true;
public static final int DEFAULT_TELEMETRY_INTERVAL_SECONDS = 60;
public static final boolean DEFAULT_TELEMETRY_COLLECT_SYSTEM_INFO = true;
public static ModConfig createDefault() {
return new ModConfig(
DEFAULT_API_DOMAIN,
DEFAULT_API_TOKEN,
DEFAULT_SIGN_QUICK_PAY_ENABLED
ALLOW_BACKEND,
DEFAULT_SIGN_QUICK_PAY_ENABLED,
DEFAULT_GPS_ENABLED,
DEFAULT_GPS_POSITION,
DEFAULT_NOTIFICATION_POSITION,
DEFAULT_TELEMETRY_INTERVAL_SECONDS,
DEFAULT_TELEMETRY_COLLECT_SYSTEM_INFO
);
}
}
@@ -9,8 +9,16 @@ public class SPMega implements ModInitializer {
return config;
}
public static void setConfig(ModConfig newConfig) {
config = newConfig;
ConfigManager.save(newConfig);
}
@Override
public void onInitialize() {
long startNs = System.nanoTime();
config = ConfigManager.loadOrCreate();
long durationMs = (System.nanoTime() - startNs) / 1_000_000L;
System.out.println("[SPMEGA] Main init took " + durationMs + "ms");
}
}
@@ -1,199 +0,0 @@
package git.yawaflua.tech.spmega.api;
import com.google.gson.*;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
public final class SPWorldsApiClient {
private final HttpClient httpClient;
private final Gson gson;
private final String baseUrl;
public SPWorldsApiClient(String baseUrl) {
this.httpClient = HttpClient.newHttpClient();
this.gson = new Gson();
this.baseUrl = normalizeBaseUrl(baseUrl);
}
private static String encodeAuth(CardAuth auth) {
String raw = auth.cardId() + ":" + auth.cardToken();
return Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8));
}
private static String normalizeBaseUrl(String rawBaseUrl) {
String fallback = "https://spworlds.ru";
if (rawBaseUrl == null || rawBaseUrl.trim().isEmpty()) {
return fallback;
}
String value = rawBaseUrl.trim();
if (value.endsWith("/")) {
return value.substring(0, value.length() - 1);
}
return value;
}
private static String getString(JsonObject json, String key) {
if (!json.has(key) || json.get(key).isJsonNull()) {
return "";
}
return json.get(key).getAsString();
}
public CardInfo getCardInfo(CardAuth auth) throws IOException, InterruptedException {
HttpRequest request = requestBuilder("/api/public/card", auth).GET().build();
String body = send(request);
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
if (json.has("statusCode")) {
switch (json.get("statusCode").getAsInt()) {
case 403:
throw new IOException("Апи вернула ошибку: " + json.get("message").getAsString());
default:
System.out.println("Unhandled status code in card info response: " + json.get("statusCode").getAsInt());
break;
}
}
long balance = json.has("balance") ? json.get("balance").getAsLong() : 0L;
String webhook = json.has("webhook") && !json.get("webhook").isJsonNull()
? json.get("webhook").getAsString()
: "";
return new CardInfo(balance, webhook);
} catch (Exception e) {
System.out.println("Failed to parse card info response: " + e.getMessage());
System.out.println(body);
throw new IOException("Failed to parse card info response", e);
}
}
public List<PlayerCard> getPlayerCards(String username, CardAuth auth) throws IOException, InterruptedException {
HttpRequest request = requestBuilder("/api/public/accounts/" + username + "/cards", auth).GET().build();
String body = send(request);
try {
JsonArray json = JsonParser.parseString(body).getAsJsonArray();
List<PlayerCard> cards = new ArrayList<>();
for (JsonElement element : json) {
JsonObject card = element.getAsJsonObject();
String name = card.has("name") ? card.get("name").getAsString() : "";
String number = card.has("number") ? card.get("number").getAsString() : "";
cards.add(new PlayerCard(name, number));
}
return cards;
} catch (Exception e) {
System.out.println("Failed to parse player cards response: " + e.getMessage());
System.out.println(body);
throw new IOException("Failed to parse player cards response", e);
}
}
public AccountMe getAccountMe(CardAuth auth) throws IOException, InterruptedException {
HttpRequest request = requestBuilder("/api/public/accounts/me", auth).GET().build();
String body = send(request);
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
String id = json.has("id") ? json.get("id").getAsString() : "";
String username = json.has("username") ? json.get("username").getAsString() : "";
String minecraftUuid = json.has("minecraftUUID") ? json.get("minecraftUUID").getAsString() : "";
List<AccountCard> cards = new ArrayList<>();
if (json.has("cards") && json.get("cards").isJsonArray()) {
for (JsonElement element : json.getAsJsonArray("cards")) {
JsonObject card = element.getAsJsonObject();
cards.add(new AccountCard(
getString(card, "id"),
getString(card, "name"),
getString(card, "number"),
card.has("color") && !card.get("color").isJsonNull() ? card.get("color").getAsInt() : 0
));
}
}
return new AccountMe(id, username, minecraftUuid, cards);
} catch (Exception e) {
System.out.println("Failed to parse account info response: " + e.getMessage());
System.out.println(body);
throw new IOException("Failed to parse account info response", e);
}
}
public TransactionResult createTransaction(CardAuth auth, String receiver, long amount, String comment)
throws IOException, InterruptedException {
JsonObject payload = new JsonObject();
payload.addProperty("receiver", receiver);
payload.addProperty("amount", amount);
payload.addProperty("comment", comment.isEmpty() ? "Перевод через SPMega" : comment);
HttpRequest request = requestBuilder("/api/public/transactions", auth)
.POST(HttpRequest.BodyPublishers.ofString(gson.toJson(payload)))
.build();
String body = send(request);
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
long balance = json.has("balance") ? json.get("balance").getAsLong() : 0L;
return new TransactionResult(balance);
} catch (Exception exception) {
System.out.println("Failed to parse transaction response: " + exception.getMessage());
System.out.println(body);
throw new IOException("Failed to parse transaction response", exception);
}
}
private HttpRequest.Builder requestBuilder(String path, CardAuth auth) {
return HttpRequest.newBuilder(URI.create(baseUrl + path))
.header("Authorization", "Bearer " + encodeAuth(auth))
.header("Content-Type", "application/json")
.header("Accept", "application/json");
}
private String send(HttpRequest request) throws IOException, InterruptedException {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
int statusCode = response.statusCode();
if (statusCode < 200 || statusCode >= 300) {
System.out.println(response.body());
JsonElement parsed = JsonParser.parseString(response.body());
JsonObject object = parsed.isJsonArray() ? parsed.getAsJsonArray().get(0).getAsJsonObject() : parsed.getAsJsonObject();
var message = "";
if (object.has("error") && !object.get("error").isJsonNull()) {
message = "Ошибка в запросе: " + object.get("error").getAsString();
} else if (object.has("message") && !object.get("message").isJsonNull()) {
message = object.get("message").getAsString();
}
throw new IOException(statusCode + ": " + message);
}
return response.body();
}
public record CardAuth(String cardId, String cardToken) {
}
public record CardInfo(long balance, String webhook) {
}
public record PlayerCard(String name, String number) {
}
public record AccountMe(String id, String username, String minecraftUuid, List<AccountCard> cards) {
}
public record AccountCard(String id, String name, String number, int color) {
}
public record TransactionResult(long balance) {
}
}
+17 -3
View File
@@ -3,11 +3,20 @@
"id": "spmega",
"version": "${version}",
"name": "SPMega",
"description": "yawaflua`s SPRadar+SPMHelper mod, thats make a lot!",
"description": "yawaflua's SPRadar+SPMHelper mod, that's make a lot!",
"authors": [
"Dmitri 'yawaflua' Shimanski aka DOLBAYEB"
{
"name": "Dmitri 'yawaflua' Shimanski aka DOLBAYEB",
"contact": {
"homepage": "https://yawaflua.tech",
"email": "spmega@yawaflua.tech"
}
}
],
"contact": {
"homepage": "https://yawaflua.tech",
"issues": "https://git.yawaflua.tech/spmega",
"sources": "https://git.yawaflua.tech/spmega"
},
"license": "CC BY-NC-ND 4.0",
"icon": "icon.png",
@@ -19,6 +28,9 @@
"client": [
"git.yawaflua.tech.spmega.client.SPMegaClient"
],
"modmenu": [
"git.yawaflua.tech.spmega.client.ModMenuIntegration"
],
"main": [
"git.yawaflua.tech.spmega.SPMega"
]
@@ -33,7 +45,9 @@
"depends": {
"fabricloader": ">=${loader_version}",
"fabric-api": "*",
"minecraft": "${minecraft_version}",
"minecraft": "${minecraft_version}"
},
"suggests": {
"cloth-config2": ">=${cloth_config_version}"
}
}