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
Dmitrii eea62f90e2 Add initial project backend structure with Docker support and configuration files. Added some useful features such as async requests, automatically adding card by message from chat etc
Release CI / build-and-upload-mod (push) Successful in 10s
Release CI / build-and-upload-mod (release) Successful in 7s
Java CI / build (push) Failing after 11m8s
Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 9 minutes
2026-06-26 03:03:26 +03:00
73 changed files with 5665 additions and 433 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 # Common working directory
run 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
SPMega - клиентский Fabric-мод с банковым UI для работы с картами и переводами через API `spworlds.ru`. SPMega - клиентский Fabric-мод с банковым UI для работы с картами и переводами через API `spworlds.ru`.
+25
View File
@@ -0,0 +1,25 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
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);
+22
View File
@@ -0,0 +1,22 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["SpMega.Backend/SpMega.Backend.csproj", "SpMega.Backend/"]
RUN dotnet restore "SpMega.Backend/SpMega.Backend.csproj"
COPY . .
WORKDIR "/src/SpMega.Backend"
RUN dotnet build "./SpMega.Backend.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./SpMega.Backend.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "SpMega.Backend.dll"]
@@ -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,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5129",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -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>
+21
View File
@@ -0,0 +1,21 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SpMega.Backend", "SpMega.Backend\SpMega.Backend.csproj", "{FF126D84-9381-4F0C-9925-FE08AC1FD07E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{64C061F2-C216-4C79-9AC2-CC43F1C0A34D}"
ProjectSection(SolutionItems) = preProject
compose.yaml = compose.yaml
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{FF126D84-9381-4F0C-9925-FE08AC1FD07E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FF126D84-9381-4F0C-9925-FE08AC1FD07E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FF126D84-9381-4F0C-9925-FE08AC1FD07E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FF126D84-9381-4F0C-9925-FE08AC1FD07E}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
+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>
+7
View File
@@ -0,0 +1,7 @@
services:
spmega.backend:
image: spmega.backend
build:
context: .
dockerfile: SpMega.Backend/Dockerfile
+3 -7
View File
@@ -28,17 +28,11 @@ fabricApi {
} }
repositories { 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.shedaniel.me/" }
maven { url "https://maven.terraformersmc.com/" }
} }
dependencies { dependencies {
// To change the versions see the gradle.properties file
minecraft "com.mojang:minecraft:${project.minecraft_version}" minecraft "com.mojang:minecraft:${project.minecraft_version}"
mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2" mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2"
modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" modImplementation "net.fabricmc:fabric-loader:${project.loader_version}"
@@ -49,6 +43,8 @@ dependencies {
exclude(group: "net.fabricmc.fabric-api") exclude(group: "net.fabricmc.fabric-api")
} }
modCompileOnly "com.terraformersmc:modmenu:11.0.3"
include(implementation("org.xerial:sqlite-jdbc:3.46.1.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:core:${project.zxing_version}"))
include(clientImplementation("com.google.zxing:javase:${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 yarn_mappings=1.21.11+build.4
loader_version=0.18.1 loader_version=0.18.1
# Mod Properties # Mod Properties
mod_version=0.2-pre-alpha mod_version=0.5-beta
maven_group=git.yawaflua.tech maven_group=git.yawaflua.tech
archives_base_name=SPMega archives_base_name=SPMega
# Dependencies # Dependencies
@@ -0,0 +1,122 @@
package git.yawaflua.tech.spmega.client;
import git.yawaflua.tech.spmega.client.ui.UiNotifications;
import git.yawaflua.tech.spmega.client.ui.service.BankUiService;
import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents;
import net.minecraft.client.MinecraftClient;
import net.minecraft.text.ClickEvent;
import net.minecraft.text.Text;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
public class ChatListener {
private final UiNotifications notifications = UiNotifications.instance();
private final Pattern TRANSACTION_PATTERN = Pattern.compile(
".*(Управление картой).*",
Pattern.CASE_INSENSITIVE
);
public void register() {
ClientReceiveMessageEvents.CHAT.register((message, signedMessage, sender, params, timestamp) -> {
try {
handleMessage(message);
} catch (Throwable t) {
System.err.println("[SPMega] Ошибка обработки сообщения в CHAT листенере:");
t.printStackTrace();
}
});
ClientReceiveMessageEvents.GAME.register((message, overlay) -> {
try {
handleMessage(message);
} catch (Throwable t) {
System.err.println("[SPMega] Ошибка обработки сообщения в GAME листенере:");
t.printStackTrace();
}
});
}
private void handleMessage(Text message) {
if (message == null) {
return;
}
String text = message.getString();
if (TRANSACTION_PATTERN.matcher(text).matches()) {
List<String> clickValues = new ArrayList<>();
collectClickEventValues(message, clickValues);
if (clickValues.size() >= 2) {
String tokenId = clickValues.get(0);
String cardId = clickValues.get(1);
MinecraftClient client = MinecraftClient.getInstance();
if (client.player != null) {
String playerUuid = client.player.getUuidAsString();
BankUiService.instance().addCardAsync(cardId, tokenId, playerUuid)
.thenAccept(msg -> {
if (client == null) {
return;
}
client.execute(() -> {
notifications.showMessage(msg);
});
})
.exceptionally(exception -> {
if (client != null) {
client.execute(() -> {
notifications.show(Text.literal("Ошибка добавления карты: " + exception.getMessage()));
});
}
return null;
});
}
} else {
System.out.println("[SPMega] Недостаточно кликабельных данных в сообщении (найдено: " + clickValues.size() + ")");
}
}
}
private void collectClickEventValues(Text text, List<String> values) {
if (text == null) {
return;
}
ClickEvent clickEvent = text.getStyle().getClickEvent();
if (clickEvent != null) {
try {
String value = getEventValue(clickEvent);
if (value != null && !value.isEmpty()) {
values.add(value);
}
} catch (Throwable t) {
System.err.println("[SPMega] Ошибка извлечения значения клик-события:");
t.printStackTrace();
}
}
for (Text sibling : text.getSiblings()) {
collectClickEventValues(sibling, values);
}
}
private String getEventValue(ClickEvent clickEvent) {
if (clickEvent instanceof ClickEvent.CopyToClipboard(String value)) {
return value;
} else if (clickEvent instanceof ClickEvent.RunCommand(String command1)) {
return command1;
} else if (clickEvent instanceof ClickEvent.SuggestCommand(String command)) {
return command;
} else if (clickEvent instanceof ClickEvent.OpenUrl(java.net.URI uri)) {
return uri.toString();
} else if (clickEvent instanceof ClickEvent.OpenFile(String path)) {
return path;
} else if (clickEvent instanceof ClickEvent.ChangePage(int page)) {
return String.valueOf(page);
}
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; package git.yawaflua.tech.spmega.client;
import git.yawaflua.tech.spmega.ModConfig;
import git.yawaflua.tech.spmega.SPMega; import git.yawaflua.tech.spmega.SPMega;
import git.yawaflua.tech.spmega.client.qr.QRCodeScanner; 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.UiNotifications;
import git.yawaflua.tech.spmega.client.ui.UiOpeners; 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 git.yawaflua.tech.spmega.client.ui.service.BankUiService;
import net.fabricmc.api.ClientModInitializer; import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; 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.option.KeyBinding;
import net.minecraft.client.util.InputUtil; import net.minecraft.client.util.InputUtil;
import net.minecraft.entity.decoration.ItemFrameEntity; import net.minecraft.entity.decoration.ItemFrameEntity;
import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.text.Text; import net.minecraft.text.Text;
import net.minecraft.util.ActionResult; import net.minecraft.util.ActionResult;
import org.lwjgl.glfw.GLFW; import org.lwjgl.glfw.GLFW;
@@ -29,6 +34,9 @@ public class SPMegaClient implements ClientModInitializer {
private static final Pattern ISOLATED_FIVE_DIGITS = Pattern.compile("(?<!\\d)(\\d{5})(?!\\d)"); private static final Pattern ISOLATED_FIVE_DIGITS = Pattern.compile("(?<!\\d)(\\d{5})(?!\\d)");
private static KeyBinding openBankMenuKeyBinding; private static KeyBinding openBankMenuKeyBinding;
private static KeyBinding scanQrKeyBinding; private static KeyBinding scanQrKeyBinding;
private static KeyBinding toggleGpsKeyBinding;
private static boolean wasPaymentShortcutPressed = false;
private static String extractCardNumber(SignBlockEntity signBlockEntity) { private static String extractCardNumber(SignBlockEntity signBlockEntity) {
String candidate = findFiveDigits(signBlockEntity.getFrontText().getMessages(false)); String candidate = findFiveDigits(signBlockEntity.getFrontText().getMessages(false));
@@ -65,6 +73,7 @@ public class SPMegaClient implements ClientModInitializer {
@Override @Override
public void onInitializeClient() { public void onInitializeClient() {
GpsHudRenderer.instance();
openBankMenuKeyBinding = KeyBindingHelper.registerKeyBinding(new KeyBinding( openBankMenuKeyBinding = KeyBindingHelper.registerKeyBinding(new KeyBinding(
"key.spmega.open_menu", "key.spmega.open_menu",
InputUtil.Type.KEYSYM, InputUtil.Type.KEYSYM,
@@ -79,17 +88,63 @@ public class SPMegaClient implements ClientModInitializer {
KeyBinding.Category.GAMEPLAY 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 -> { ClientTickEvents.END_CLIENT_TICK.register(client -> {
PerformanceSampler.instance().tick();
UiNotifications.instance().tick(); UiNotifications.instance().tick();
while (openBankMenuKeyBinding.wasPressed()) { while (openBankMenuKeyBinding.wasPressed()) {
UiOpeners.openMainMenu(client); UiOpeners.openMainMenu(client);
} }
while (scanQrKeyBinding.wasPressed()) { 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) -> { HudRenderCallback.EVENT.register((drawContext, tickDeltaManager) -> {
MinecraftClient client = MinecraftClient.getInstance(); MinecraftClient client = MinecraftClient.getInstance();
if (client.currentScreen != null || client.textRenderer == null) { if (client.currentScreen != null || client.textRenderer == null) {
@@ -101,21 +156,28 @@ public class SPMegaClient implements ClientModInitializer {
client.getWindow().getScaledWidth(), client.getWindow().getScaledWidth(),
client.getWindow().getScaledHeight() 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) -> {
ScreenEvents.afterRender(screen).register((screenInstance, drawContext, mouseX, mouseY, tickDelta) -> { if (client.textRenderer == null) {
if (client.textRenderer == null) { return;
return; }
} UiNotifications.instance().render(drawContext, client.textRenderer, scaledWidth, scaledHeight);
UiNotifications.instance().render(drawContext, client.textRenderer, scaledWidth, scaledHeight); });
})
}
); );
ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> { ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> {
if (client.player != null) { if (client.player != null) {
BankUiService.instance().refreshOnServerJoin(client.player.getUuidAsString()); BankUiService.instance().refreshOnServerJoinAsync(client.player.getUuidAsString());
} }
}); });
@@ -167,5 +229,37 @@ 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.GlobalHistogramBinarizer;
import com.google.zxing.common.HybridBinarizer; import com.google.zxing.common.HybridBinarizer;
import com.google.zxing.multi.GenericMultipleBarcodeReader; 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.QRcodeAcceptScreen;
import git.yawaflua.tech.spmega.client.ui.UiNotifications; import git.yawaflua.tech.spmega.client.ui.UiNotifications;
import net.minecraft.client.MinecraftClient; import net.minecraft.client.MinecraftClient;
import net.minecraft.client.texture.NativeImage;
import net.minecraft.client.util.ScreenshotRecorder; import net.minecraft.client.util.ScreenshotRecorder;
import net.minecraft.text.Text; import net.minecraft.text.Text;
import java.awt.image.BufferedImage;
import java.util.Collections; import java.util.Collections;
import java.util.EnumMap; import java.util.EnumMap;
import java.util.Map; 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) { if (client == null || client.player == null || client.getFramebuffer() == null) {
return; return;
} }
@@ -30,6 +41,7 @@ public class QRCodeScanner {
return; return;
} }
long startNs = System.nanoTime();
try { try {
ScreenshotRecorder.takeScreenshot(client.getFramebuffer(), nativeImage -> { ScreenshotRecorder.takeScreenshot(client.getFramebuffer(), nativeImage -> {
try { try {
@@ -39,24 +51,28 @@ public class QRCodeScanner {
notifications.show(Text.translatable("message.spmega.qr.capture_failed")); notifications.show(Text.translatable("message.spmega.qr.capture_failed"));
} }
}); });
recordQrEvent(false, false, null, "capture_failed", startNs);
return; return;
} }
String result = decodeQRCode(nativeImageToBufferedImage(nativeImage)); int width = nativeImage.getWidth();
client.execute(() -> { int height = nativeImage.getHeight();
if (client.player == null) { int[] pixels = nativeImage.copyPixelsArgb();
return; CompletableFuture.supplyAsync(() -> decodeQrCode(width, height, pixels), DECODER)
} .whenComplete((result, throwable) -> {
if (result == null) { client.execute(() -> showResult(client, notifications, result, throwable));
notifications.show(Text.translatable("message.spmega.qr.not_found")); recordQrEvent(
return; throwable == null && result != null,
} System.nanoTime() - startNs > 50_000_000L,
result,
Text clickableLink = Text.literal(result) throwable == null ? null : throwable.getClass().getSimpleName() + ": " + throwable.getMessage(),
.styled(style -> style.withInsertion(result)); startNs
client.player.sendMessage(Text.translatable("message.spmega.qr.found_link", clickableLink), false); );
client.setScreen(new QRcodeAcceptScreen(result, client.currentScreen)); });
}); } catch (Exception exception) {
client.execute(() -> notifications.show(Text.translatable("message.spmega.qr.error")));
recordQrEvent(false, false, null,
exception.getClass().getSimpleName() + ": " + exception.getMessage(), startNs);
} finally { } finally {
if (nativeImage != null) { if (nativeImage != null) {
nativeImage.close(); nativeImage.close();
@@ -65,34 +81,55 @@ public class QRCodeScanner {
}); });
} catch (Exception ex) { } catch (Exception ex) {
notifications.show(Text.translatable("message.spmega.qr.error")); notifications.show(Text.translatable("message.spmega.qr.error"));
recordQrEvent(false, false, null, ex.getClass().getSimpleName() + ": " + ex.getMessage(), startNs);
} }
} }
private static BufferedImage nativeImageToBufferedImage(NativeImage screenshot) { private static void showResult(
BufferedImage bufferedImage = new BufferedImage( MinecraftClient client,
screenshot.getWidth(), UiNotifications notifications,
screenshot.getHeight(), String result,
BufferedImage.TYPE_INT_RGB Throwable throwable
); ) {
if (client.player == null) {
for (int y = 0; y < screenshot.getHeight(); y++) { return;
for (int x = 0; x < screenshot.getWidth(); x++) { }
int color = screenshot.getColorArgb(x, y); if (throwable != null) {
bufferedImage.setRGB(x, y, color); 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); Map<DecodeHintType, Object> hints = new EnumMap<>(DecodeHintType.class);
hints.put(DecodeHintType.TRY_HARDER, Boolean.TRUE); hints.put(DecodeHintType.TRY_HARDER, Boolean.TRUE);
hints.put(DecodeHintType.POSSIBLE_FORMATS, Collections.singletonList(BarcodeFormat.QR_CODE)); hints.put(DecodeHintType.POSSIBLE_FORMATS, Collections.singletonList(BarcodeFormat.QR_CODE));
hints.put(DecodeHintType.CHARACTER_SET, "UTF-8"); hints.put(DecodeHintType.CHARACTER_SET, "UTF-8");
hints.put(DecodeHintType.ALSO_INVERTED, Boolean.TRUE); 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); 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.client.gui.widget.TextFieldWidget;
import net.minecraft.text.Text; import net.minecraft.text.Text;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer; import java.util.function.Consumer;
public class AddCardScreen extends Screen { public class AddCardScreen extends Screen {
@@ -85,8 +84,7 @@ public class AddCardScreen extends Screen {
addButton.active = false; addButton.active = false;
cancelButton.active = false; cancelButton.active = false;
CompletableFuture bankUiService.addCardAsync(cardId, cardToken, playerUuid)
.supplyAsync(() -> bankUiService.addCard(cardId, cardToken, playerUuid))
.thenAccept(message -> { .thenAccept(message -> {
if (this.client == null) { if (this.client == null) {
return; return;
@@ -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.BankUiService;
import git.yawaflua.tech.spmega.client.ui.service.CardViewModel; import git.yawaflua.tech.spmega.client.ui.service.CardViewModel;
import net.minecraft.client.gui.DrawContext; 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.screen.Screen;
import net.minecraft.client.gui.widget.ButtonWidget; import net.minecraft.client.gui.widget.ButtonWidget;
import net.minecraft.text.Text; import net.minecraft.text.Text;
@@ -15,6 +16,8 @@ public class CardScreen extends Screen {
private final BankUiService bankUiService = BankUiService.instance(); private final BankUiService bankUiService = BankUiService.instance();
private final UiNotifications notifications = UiNotifications.instance(); private final UiNotifications notifications = UiNotifications.instance();
private final List<ButtonWidget> cardButtons = new ArrayList<>(); private final List<ButtonWidget> cardButtons = new ArrayList<>();
private ButtonWidget historyButton;
private ButtonWidget webhookButton;
public CardScreen(Screen parent) { public CardScreen(Screen parent) {
super(Text.literal("Управление картами")); super(Text.literal("Управление картами"));
@@ -44,19 +47,30 @@ public class CardScreen extends Screen {
} }
this.addDrawableChild(ButtonWidget.builder(Text.literal("Удалить"), button -> { this.addDrawableChild(ButtonWidget.builder(Text.literal("Удалить"), button -> {
bankUiService.removeSelectedCard(); bankUiService.removeSelectedCardAsync()
notifications.showMessage(bankUiService.getLastMessage()); .thenAccept(message -> {
this.clearAndInit(); if (this.client != null) {
this.client.execute(() -> {
notifications.showMessage(message);
this.clearAndInit();
});
}
});
}).dimensions(rightX, startY, columnWidth, 20).build()); }).dimensions(rightX, startY, columnWidth, 20).build());
this.addDrawableChild(ButtonWidget.builder(Text.literal("Изменить"), button -> { this.addDrawableChild(ButtonWidget.builder(Text.literal("Обновить"), button -> {
String playerUuid = this.client != null && this.client.player != null String playerUuid = this.client != null && this.client.player != null
? this.client.player.getUuidAsString() ? this.client.player.getUuidAsString()
: ""; : "";
bankUiService.refreshSelectedCard(playerUuid); bankUiService.refreshSelectedCardAsync(playerUuid)
String message = bankUiService.getLastMessage().isBlank() ? "Карта обновлена" : bankUiService.getLastMessage(); .thenAccept(message -> {
notifications.showMessage(message); if (this.client != null) {
this.clearAndInit(); this.client.execute(() -> {
notifications.showMessage(message);
this.clearAndInit();
});
}
});
}).dimensions(rightX, startY + 24, columnWidth, 20).build()); }).dimensions(rightX, startY + 24, columnWidth, 20).build());
this.addDrawableChild(ButtonWidget.builder(Text.literal("Добавить новую"), button -> { this.addDrawableChild(ButtonWidget.builder(Text.literal("Добавить новую"), button -> {
@@ -69,8 +83,42 @@ public class CardScreen extends Screen {
})); }));
}).dimensions(rightX, startY + 48, columnWidth, 20).build()); }).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()) this.addDrawableChild(ButtonWidget.builder(Text.literal("Назад"), button -> this.close())
.dimensions(rightX, startY + 120, columnWidth, 20) .dimensions(rightX, startY + 144, columnWidth, 20)
.build()); .build());
if (cards.isEmpty()) { if (cards.isEmpty()) {
@@ -119,5 +167,15 @@ public class CardScreen extends Screen {
button.setMessage(Text.literal(prefix + cards.get(i).title() + suffix)); button.setMessage(Text.literal(prefix + cards.get(i).title() + suffix));
button.active = !selected; 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.addDrawableChild(ButtonWidget.builder(Text.translatable("button.spmega.scan_qr"), button -> {
this.client.setScreen(null); this.client.setScreen(null);
QRCodeScanner.ScanQrCode(this.client); QRCodeScanner.scanQrCode(this.client);
}).dimensions(centerX - 60, y + 28, 120, 20).build()); }).dimensions(centerX - 60, y + 28, 120, 20).build());
} }
@@ -12,7 +12,6 @@ import net.minecraft.text.Text;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@@ -41,6 +40,7 @@ public class PaymentScreen extends Screen {
private Text senderCardText = Text.literal("Карта отправителя: не выбрана"); private Text senderCardText = Text.literal("Карта отправителя: не выбрана");
private ButtonWidget transferButton; private ButtonWidget transferButton;
private ButtonWidget backButton; private ButtonWidget backButton;
private boolean transferInProgress;
public PaymentScreen(Screen parent) { public PaymentScreen(Screen parent) {
this(parent, ""); this(parent, "");
@@ -192,6 +192,11 @@ public class PaymentScreen extends Screen {
} }
private void submit() { private void submit() {
if (transferInProgress) {
notifications.show(Text.literal("Перевод уже выполняется"));
return;
}
CardViewModel selectedCard = bankUiService.getSelectedCard(); CardViewModel selectedCard = bankUiService.getSelectedCard();
if (selectedCard == null) { if (selectedCard == null) {
notifications.show(Text.literal("Нет выбранной карты отправителя")); notifications.show(Text.literal("Нет выбранной карты отправителя"));
@@ -205,7 +210,8 @@ public class PaymentScreen extends Screen {
notifications.show(Text.literal("Сумма должна быть больше 0")); notifications.show(Text.literal("Сумма должна быть больше 0"));
return; return;
} }
} catch (NumberFormatException exception) { } catch (Exception exception) {
System.out.println(exception.getMessage());
notifications.show(Text.literal("Некорректная сумма")); notifications.show(Text.literal("Некорректная сумма"));
return; return;
} }
@@ -232,18 +238,46 @@ public class PaymentScreen extends Screen {
commentField.getText().trim() commentField.getText().trim()
); );
boolean accepted = bankUiService.submitPayment(draft); setTransferInProgress(true);
String serviceMessage = bankUiService.getLastMessage(); notifications.show(Text.literal("Отправка перевода..."));
if (!serviceMessage.isBlank()) {
notifications.showMessage(serviceMessage); bankUiService.submitPaymentAsync(draft)
} else { .thenAccept(accepted -> {
notifications.show(accepted if (this.client == null) {
? Text.literal("Перевод выполнен") return;
: Text.literal("Перевод отклонен")); }
this.client.execute(() -> {
setTransferInProgress(false);
String serviceMessage = bankUiService.getLastMessage();
if (!serviceMessage.isBlank()) {
notifications.showMessage(serviceMessage);
} else {
notifications.show(accepted
? Text.literal("Перевод выполнен")
: Text.literal("Перевод отклонен"));
}
updateSenderCardSelector();
updateSenderCardText();
});
})
.exceptionally(exception -> {
if (this.client != null) {
this.client.execute(() -> {
setTransferInProgress(false);
notifications.show(Text.literal("Ошибка перевода: " + exception.getMessage()));
});
}
return null;
});
}
private void setTransferInProgress(boolean inProgress) {
transferInProgress = inProgress;
if (transferButton != null) {
transferButton.active = !inProgress;
} }
updateSenderCardSelector();
updateSenderCardText();
} }
private boolean isValidRecipient(String recipient) { private boolean isValidRecipient(String recipient) {
@@ -277,7 +311,7 @@ public class PaymentScreen extends Screen {
private void updateSenderCardSelector() { private void updateSenderCardSelector() {
CardViewModel selectedCard = bankUiService.getSelectedCard(); CardViewModel selectedCard = bankUiService.getSelectedCard();
if (selectedCard == null) { if (selectedCard == null) {
senderCardLabelButton.setMessage(Text.literal("\"00000\": \"0\" АР")); senderCardLabelButton.setMessage(Text.literal("TEST 00000: 0 АР"));
senderLeftButton.active = false; senderLeftButton.active = false;
senderRightButton.active = false; senderRightButton.active = false;
return; return;
@@ -285,9 +319,8 @@ public class PaymentScreen extends Screen {
senderLeftButton.active = true; senderLeftButton.active = true;
senderRightButton.active = true; senderRightButton.active = true;
String cardId = extractCardId(selectedCard.title());
String balance = Long.toString(selectedCard.balance()); String balance = Long.toString(selectedCard.balance());
senderCardLabelButton.setMessage(Text.literal("\"" + cardId + "\": \"" + balance + "\" АР")); senderCardLabelButton.setMessage(Text.literal(selectedCard.title() + ": " + balance + " АР"));
senderCardLabelButton.active = false; senderCardLabelButton.active = false;
} }
@@ -348,8 +381,7 @@ public class PaymentScreen extends Screen {
} }
lastRecipientLookup = username; lastRecipientLookup = username;
CompletableFuture bankUiService.loadRecipientCardsAsync(username)
.supplyAsync(() -> bankUiService.loadRecipientCards(username))
.thenAccept(cards -> { .thenAccept(cards -> {
if (this.client == null) { if (this.client == null) {
return; return;
@@ -6,6 +6,7 @@ import net.minecraft.client.gui.widget.ButtonWidget;
import net.minecraft.text.Text; import net.minecraft.text.Text;
import net.minecraft.util.Util; import net.minecraft.util.Util;
import java.awt.*;
import java.net.URI; import java.net.URI;
public class QRcodeAcceptScreen extends Screen { public class QRcodeAcceptScreen extends Screen {
@@ -20,6 +21,8 @@ public class QRcodeAcceptScreen extends Screen {
@Override @Override
protected void init() { protected void init() {
this.addDrawableChild(ButtonWidget.builder(Text.translatable("button.spmega.qr.cancel"), button -> { this.addDrawableChild(ButtonWidget.builder(Text.translatable("button.spmega.qr.cancel"), button -> {
if (this.client != null) { if (this.client != null) {
this.client.setScreen(parent); this.client.setScreen(parent);
@@ -53,17 +56,10 @@ public class QRcodeAcceptScreen extends Screen {
context.drawCenteredTextWithShadow( context.drawCenteredTextWithShadow(
this.textRenderer, this.textRenderer,
Text.translatable("screen.spmega.qr.accept_link"), Text.literal("Найдена ссылка: " + url),
this.width / 2, this.width / 2,
this.height / 2 - 30, this.height / 2,
0xFFFFFF Color.WHITE.getRGB()
);
context.drawCenteredTextWithShadow(
this.textRenderer,
Text.literal(url),
this.width / 2,
this.height / 2 - 10,
0xFFD27F
); );
} }
} }
@@ -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.JsonParser;
import com.google.gson.Strictness; import com.google.gson.Strictness;
import com.google.gson.stream.JsonReader; 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.font.TextRenderer;
import net.minecraft.client.gui.DrawContext; import net.minecraft.client.gui.DrawContext;
import net.minecraft.text.Text; import net.minecraft.text.Text;
import java.awt.*; import java.awt.*;
import java.util.ArrayDeque;
import java.util.Queue;
public final class UiNotifications { public final class UiNotifications {
private static final int DEFAULT_DURATION_TICKS = 70; private static final int DEFAULT_DURATION_TICKS = 70;
private static final UiNotifications INSTANCE = new UiNotifications(); private static final UiNotifications INSTANCE = new UiNotifications();
private Text currentText = Text.empty(); private Text currentText = Text.empty();
private final Queue<Text> queuedTexts = new ArrayDeque<>();
private int remainingTicks; private int remainingTicks;
private UiNotifications() { private UiNotifications() {
@@ -62,6 +67,17 @@ public final class UiNotifications {
show(Text.literal(extractMessage(message))); 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) { public synchronized void render(DrawContext context, TextRenderer textRenderer, int width, int height) {
if (!isVisible()) { if (!isVisible()) {
return; return;
@@ -72,8 +88,18 @@ public final class UiNotifications {
int textWidth = textRenderer.getWidth(message); int textWidth = textRenderer.getWidth(message);
int boxWidth = textWidth + padding * 2; int boxWidth = textWidth + padding * 2;
int boxHeight = textRenderer.fontHeight + padding * 2; int boxHeight = textRenderer.fontHeight + padding * 2;
int x = width - boxWidth - 10; GpsHudPosition position = SPMega.getConfig() == null
int y = height - boxHeight - 10; ? 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.fill(x, y, x + boxWidth, y + boxHeight, Color.DARK_GRAY.getRGB());
context.drawTextWithShadow(textRenderer, currentText, x + padding, y + padding, Color.WHITE.getRGB()); context.drawTextWithShadow(textRenderer, currentText, x + padding, y + padding, Color.WHITE.getRGB());
@@ -85,8 +111,8 @@ public final class UiNotifications {
} }
remainingTicks--; remainingTicks--;
if (remainingTicks <= 0) { if (remainingTicks <= 0) {
currentText = Text.empty(); currentText = queuedTexts.isEmpty() ? Text.empty() : queuedTexts.remove();
remainingTicks = 0; 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, card_number TEXT,
balance INTEGER NOT NULL DEFAULT 0, balance INTEGER NOT NULL DEFAULT 0,
owner_uuid TEXT, owner_uuid TEXT,
webhook_enabled INTEGER NOT NULL DEFAULT 0,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP 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(""" statement.executeUpdate("""
CREATE TABLE IF NOT EXISTS transfer_history ( CREATE TABLE IF NOT EXISTS transfer_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, 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() { 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<>(); List<StoredCard> result = new ArrayList<>();
try (Connection connection = open(); try (Connection connection = open();
PreparedStatement statement = connection.prepareStatement(sql); PreparedStatement statement = connection.prepareStatement(sql);
@@ -113,7 +150,8 @@ public final class BankDatabase {
rs.getString("card_name"), rs.getString("card_name"),
rs.getString("card_number"), rs.getString("card_number"),
rs.getLong("balance"), rs.getLong("balance"),
rs.getString("owner_uuid") rs.getString("owner_uuid"),
rs.getBoolean("webhook_enabled")
)); ));
} }
return result; 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 { private Connection open() throws SQLException {
return DriverManager.getConnection(jdbcUrl); return DriverManager.getConnection(jdbcUrl);
} }
@@ -1,9 +1,16 @@
package git.yawaflua.tech.spmega.client.ui.service; 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.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.MinecraftClient;
import java.util.*; import java.util.*;
import java.util.concurrent.CompletableFuture;
public final class BankUiService { public final class BankUiService {
private static final BankUiService INSTANCE = new BankUiService(); private static final BankUiService INSTANCE = new BankUiService();
@@ -14,7 +21,7 @@ public final class BankUiService {
private final String apiDomain = "https://spworlds.ru"; private final String apiDomain = "https://spworlds.ru";
private int selectedCardIndex; private int selectedCardIndex;
private String lastMessage = ""; private volatile String lastMessage = "";
private BankUiService() { private BankUiService() {
this.database = new BankDatabase(FabricLoader.getInstance().getConfigDir().resolve("spmega.db")); this.database = new BankDatabase(FabricLoader.getInstance().getConfigDir().resolve("spmega.db"));
@@ -51,11 +58,20 @@ public final class BankUiService {
return normalized.substring(normalized.length() - 5); return normalized.substring(normalized.length() - 5);
} }
public synchronized List<CardViewModel> getCards() { private void runOnMainThread(Runnable runnable) {
MinecraftClient client = MinecraftClient.getInstance();
if (client == null || client.isOnThread()) {
runnable.run();
} else {
client.execute(runnable);
}
}
public List<CardViewModel> getCards() {
return Collections.unmodifiableList(cards); return Collections.unmodifiableList(cards);
} }
public synchronized CardViewModel getSelectedCard() { public CardViewModel getSelectedCard() {
if (cards.isEmpty()) { if (cards.isEmpty()) {
return null; return null;
} }
@@ -63,7 +79,7 @@ public final class BankUiService {
return cards.get(selectedCardIndex); return cards.get(selectedCardIndex);
} }
public synchronized int getSelectedCardIndex() { public int getSelectedCardIndex() {
if (cards.isEmpty()) { if (cards.isEmpty()) {
return 0; return 0;
} }
@@ -71,7 +87,7 @@ public final class BankUiService {
return selectedCardIndex; return selectedCardIndex;
} }
public synchronized void setSelectedCardIndex(int index) { public void setSelectedCardIndex(int index) {
if (cards.isEmpty()) { if (cards.isEmpty()) {
selectedCardIndex = 0; selectedCardIndex = 0;
return; return;
@@ -79,7 +95,7 @@ public final class BankUiService {
selectedCardIndex = Math.floorMod(index, cards.size()); selectedCardIndex = Math.floorMod(index, cards.size());
} }
public synchronized CardViewModel cycleSelectedCard(int direction) { public CardViewModel cycleSelectedCard(int direction) {
if (cards.isEmpty()) { if (cards.isEmpty()) {
return null; return null;
} }
@@ -87,154 +103,220 @@ public final class BankUiService {
return cards.get(selectedCardIndex); return cards.get(selectedCardIndex);
} }
public synchronized String getLastMessage() { public String getLastMessage() {
return lastMessage; return lastMessage;
} }
public synchronized void refreshOnServerJoin(String playerUuid) { private static String rootMessage(Throwable throwable) {
List<StoredCard> storedCards = database.loadCards(); Throwable cause = throwable;
for (StoredCard card : storedCards) { while (cause.getCause() != null) {
refreshCard(card.cardId(), card.cardToken(), playerUuid, false, false); cause = cause.getCause();
} }
reloadCardsFromDb(); return cause.getMessage();
} }
public synchronized List<String> loadRecipientCards(String username) { public CompletableFuture<Void> syncCardsWithBackendAsync(String playerUuid) {
return BackendAuthenticator.fetchCardsFromBackendAsync()
.thenCompose(cards -> syncMissingCardsAsync(cards, playerUuid))
.thenAccept(changed -> {
if (changed) {
reloadCardsFromDb();
}
})
.exceptionally(exception -> {
System.err.println("[SPMEGA] Failed to sync cards with backend: " + exception.getMessage());
return null;
});
}
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(); CardCredentials credentials = getSelectedCredentials();
if (credentials == null || username == null || username.isBlank()) { if (credentials == null || username == null || username.isBlank()) {
return List.of(); return CompletableFuture.completedFuture(List.of());
} }
try { return apiClient.getPlayerCardsAsync(username, toApiAuth(credentials))
List<SPWorldsApiClient.PlayerCard> apiCards = apiClient.getPlayerCards(username, toApiAuth(credentials)); .thenApply(apiCards -> {
List<String> numbers = new ArrayList<>(); List<String> numbers = new ArrayList<>();
for (SPWorldsApiClient.PlayerCard apiCard : apiCards) { for (SPWorldsApiClient.PlayerCard apiCard : apiCards) {
if (apiCard.number() != null && !apiCard.number().isBlank()) { if (apiCard.number() != null && !apiCard.number().isBlank()) {
numbers.add(apiCard.number()); numbers.add(apiCard.name() + " : " + apiCard.number());
}
} }
} lastMessage = "";
lastMessage = ""; return numbers;
return numbers; })
} catch (Exception exception) { .exceptionally(exception -> {
lastMessage = "Не удалось получить карты игрока: " + exception.getMessage(); lastMessage = "Не удалось получить карты игрока: " + exception.getMessage();
return List.of(); return List.of();
} });
} }
public synchronized String addCard(String cardIdRaw, String cardTokenRaw, String playerUuid) { public CompletableFuture<String> 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 cardId = cardIdRaw == null ? "" : cardIdRaw.trim();
String cardToken = cardTokenRaw == null ? "" : cardTokenRaw.trim(); String cardToken = cardTokenRaw == null ? "" : cardTokenRaw.trim();
if (cardId.isEmpty() || cardToken.isEmpty()) { if (cardId.isEmpty() || cardToken.isEmpty()) {
lastMessage = "Укажи cardId и cardToken"; return CompletableFuture.completedFuture("Укажи cardId и cardToken");
return lastMessage;
} }
try { try {
UUID.fromString(cardId); UUID.fromString(cardId);
} catch (IllegalArgumentException exception) { } catch (IllegalArgumentException exception) {
lastMessage = "cardId должен быть UUID"; return CompletableFuture.completedFuture("cardId должен быть UUID");
return lastMessage;
} }
database.upsertCardCredentials(cardId, cardToken); return CompletableFuture.runAsync(() -> database.upsertCardCredentials(cardId, cardToken))
boolean refreshed = refreshCard(cardId, cardToken, playerUuid, true, true); .thenCompose(ignored -> refreshCardAsync(cardId, cardToken, playerUuid, true, true))
if (!refreshed) { .thenApply(refreshed -> {
database.deleteCard(cardId); if (!refreshed) {
} database.deleteCard(cardId);
reloadCardsFromDb(); }
reloadCardsFromDb();
if (refreshed && lastMessage.isBlank()) { return refreshed && lastMessage.isBlank() ? "Карта добавлена" : lastMessage;
lastMessage = "Карта добавлена"; });
}
return lastMessage;
} }
public synchronized void removeSelectedCard() { public CompletableFuture<String> refreshSelectedCardAsync(String playerUuid) {
CardViewModel selected = getSelectedCard(); CardViewModel selected = getSelectedCard();
if (selected == null) { if (selected == null) {
lastMessage = "Нет карты для удаления"; return CompletableFuture.completedFuture("Нет карты для обновления");
return;
} }
database.deleteCard(selected.id()); return CompletableFuture.supplyAsync(() -> database.getCredentials(selected.id()))
reloadCardsFromDb(); .thenCompose(credentials -> {
if (selectedCardIndex >= cards.size()) { if (credentials == null) {
selectedCardIndex = Math.max(0, cards.size() - 1); return CompletableFuture.completedFuture("Не найдены креды карты");
} }
lastMessage = "Карта удалена"; return refreshCardAsync(credentials.cardId(), credentials.cardToken(), playerUuid, false, false)
.thenApply(ignored -> {
reloadCardsFromDb();
return lastMessage.isBlank() ? "Карта обновлена" : lastMessage;
});
});
} }
public synchronized void refreshSelectedCard(String playerUuid) { private void recordPaymentEvent(PaymentDraft draft, boolean success, String mode, String destination, long durationMs, String senderLast4) {
CardViewModel selected = getSelectedCard(); recordPaymentEvent(draft, success, mode, destination, durationMs, senderLast4, null);
if (selected == null) {
lastMessage = "Нет карты для обновления";
return;
}
CardCredentials credentials = database.getCredentials(selected.id());
if (credentials == null) {
lastMessage = "Не найдены креды карты";
return;
}
refreshCard(credentials.cardId(), credentials.cardToken(), playerUuid, false, false);
reloadCardsFromDb();
} }
public synchronized boolean submitPayment(PaymentDraft draft) { private void recordPaymentEvent(PaymentDraft draft, boolean success, String mode, String destination, long durationMs, String senderLast4, String error) {
CardCredentials credentials = database.getCredentials(draft.senderCardId()); com.google.gson.JsonObject payload = new com.google.gson.JsonObject();
if (credentials == null) { payload.addProperty("success", success);
lastMessage = "Не найдены креды карты отправителя"; payload.addProperty("mode", mode);
return false; payload.addProperty("destination", destination);
} payload.addProperty("durationMs", durationMs);
payload.addProperty("senderCardLast4", senderLast4 != null ? senderLast4 : "unknown");
try { payload.addProperty("amount", draft.amount());
SPWorldsApiClient.TransactionResult result = apiClient.createTransaction( payload.addProperty("receiver", draft.recipient());
toApiAuth(credentials), if (error != null) {
draft.recipient(), payload.addProperty("error", trimMessage(error));
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;
} }
TelemetryCollector.instance().record(TelemetryEvent.now("payment", payload));
} }
private boolean refreshCard( public CompletableFuture<Boolean> submitPaymentAsync(PaymentDraft 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);
}
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 cardId,
String cardToken, String cardToken,
String playerUuid, String playerUuid,
boolean reportOwnerWarning, boolean reportOwnerWarning,
boolean requireCardInAccount boolean requireCardInAccount
) { ) {
try { SPWorldsApiClient.CardAuth auth = new SPWorldsApiClient.CardAuth(cardId, cardToken);
SPWorldsApiClient.CardAuth auth = new SPWorldsApiClient.CardAuth(cardId, cardToken); return apiClient.getAccountMeAsync(auth)
SPWorldsApiClient.AccountMe me = apiClient.getAccountMe(auth); .thenCompose(me -> apiClient.getCardInfoAsync(auth).thenApply(cardInfo -> {
SPWorldsApiClient.CardInfo cardInfo = apiClient.getCardInfo(auth);
SPWorldsApiClient.AccountCard currentCard = me.cards().stream() SPWorldsApiClient.AccountCard currentCard = me.cards().stream()
.filter(card -> Objects.equals(card.id(), cardId)) .filter(card -> Objects.equals(card.id(), cardId))
@@ -259,33 +341,89 @@ public final class BankUiService {
return true; return true;
} }
} }
ModConfig config = SPMega.getConfig();
if (config != null && config.allowBackend()) {
BackendAuthenticator.sendCardToBackend(cardId, cardToken);
}
lastMessage = ""; lastMessage = "";
return true; return true;
} catch (Exception exception) { }))
lastMessage = "Не удалось обновить карту: " + exception.getMessage(); .exceptionally(exception -> {
return false; 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() { private void reloadCardsFromDb() {
List<StoredCard> storedCards = database.loadCards(); List<StoredCard> storedCards = database.loadCards();
cards.clear(); 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(), stored.webhookEnabled()));
}
for (StoredCard stored : storedCards) { if (cards.isEmpty()) {
String cardNumber = stored.cardNumber() == null || stored.cardNumber().isBlank() selectedCardIndex = 0;
? extractLastDigits(stored.cardId()) } else {
: stored.cardNumber(); selectedCardIndex = Math.floorMod(selectedCardIndex, cards.size());
String cardName = stored.cardName() == null || stored.cardName().isBlank() ? "Карта" : stored.cardName(); }
String title = cardNumber + ": " + cardName; });
cards.add(new CardViewModel(stored.cardId(), title, stored.balance()));
}
if (cards.isEmpty()) {
selectedCardIndex = 0;
} else {
selectedCardIndex = Math.floorMod(selectedCardIndex, cards.size());
}
} }
private CardCredentials getSelectedCredentials() { private CardCredentials getSelectedCredentials() {
@@ -295,4 +433,8 @@ public final class BankUiService {
} }
return database.getCredentials(selected.id()); return database.getCredentials(selected.id());
} }
public BankDatabase getDatabase() {
return database;
}
} }
@@ -1,5 +1,8 @@
package git.yawaflua.tech.spmega.client.ui.service; 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; 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; package git.yawaflua.tech.spmega.client.ui.service;
public record StoredCard(String cardId, String cardToken, String cardName, String cardNumber, long balance, 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.error": "Failed to scan QR from screen",
"message.spmega.qr.hover_tip": "Click to open link", "message.spmega.qr.hover_tip": "Click to open link",
"message.spmega.qr.found_link": "Found link: %s", "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; 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()); boolean signQuickPayEnabled = readBoolean(properties, "sign.quickPay.enabled", defaults.signQuickPayEnabled());
String rawQuickPay = properties.getProperty("sign.quickPay.enabled"); String rawQuickPay = properties.getProperty("sign.quickPay.enabled");
if (rawQuickPay == null || !Boolean.toString(signQuickPayEnabled).equalsIgnoreCase(rawQuickPay.trim())) { if (rawQuickPay == null || !Boolean.toString(signQuickPayEnabled).equalsIgnoreCase(rawQuickPay.trim())) {
shouldSave = true; 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) { if (shouldSave) {
save(configPath, config); save(configPath, config);
} }
return config; return config;
} }
private static String readString(Properties properties, String key, String fallback) { private static String readString(Properties properties, String key, String fallback) {
@@ -97,11 +138,22 @@ public final class ConfigManager {
return fallback; 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) { private static void save(Path configPath, ModConfig config) {
Properties properties = new Properties(); Properties properties = new Properties();
properties.setProperty("api.domain", config.apiDomain()); properties.setProperty("api.domain", config.apiDomain());
properties.setProperty("api.token", config.apiToken()); 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("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 { try {
Files.createDirectories(configPath.getParent()); Files.createDirectories(configPath.getParent());
@@ -112,5 +164,29 @@ public final class ConfigManager {
throw new RuntimeException("Failed to save config: " + configPath, exception); 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; package git.yawaflua.tech.spmega;
public record ModConfig(String apiDomain, String apiToken, boolean signQuickPayEnabled) { public record ModConfig(String apiDomain, String apiToken, boolean allowBackend, boolean signQuickPayEnabled,
public static final String DEFAULT_API_DOMAIN = "https://spmega-api.yawaflua.tech"; boolean gpsEnabled, GpsHudPosition gpsPosition,
public static final String DEFAULT_API_TOKEN = "ulBKE9MWEtIGiPAhXV69I28W9BRiSrV3"; 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_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() { public static ModConfig createDefault() {
return new ModConfig( return new ModConfig(
DEFAULT_API_DOMAIN, DEFAULT_API_DOMAIN,
DEFAULT_API_TOKEN, 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; return config;
} }
public static void setConfig(ModConfig newConfig) {
config = newConfig;
ConfigManager.save(newConfig);
}
@Override @Override
public void onInitialize() { public void onInitialize() {
long startNs = System.nanoTime();
config = ConfigManager.loadOrCreate(); config = ConfigManager.loadOrCreate();
long durationMs = (System.nanoTime() - startNs) / 1_000_000L;
System.out.println("[SPMEGA] Main init took " + durationMs + "ms");
} }
} }
@@ -1,190 +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();
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) {
}
}
+21 -4
View File
@@ -3,9 +3,21 @@
"id": "spmega", "id": "spmega",
"version": "${version}", "version": "${version}",
"name": "SPMega", "name": "SPMega",
"description": "yawaflua`s SPRadar+SPMHelper mod, thats make a lot!", "description": "yawaflua's SPRadar+SPMHelper mod, that's make a lot!",
"authors": [], "authors": [
"contact": {}, {
"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", "license": "CC BY-NC-ND 4.0",
"icon": "icon.png", "icon": "icon.png",
"environment": "client", "environment": "client",
@@ -16,6 +28,9 @@
"client": [ "client": [
"git.yawaflua.tech.spmega.client.SPMegaClient" "git.yawaflua.tech.spmega.client.SPMegaClient"
], ],
"modmenu": [
"git.yawaflua.tech.spmega.client.ModMenuIntegration"
],
"main": [ "main": [
"git.yawaflua.tech.spmega.SPMega" "git.yawaflua.tech.spmega.SPMega"
] ]
@@ -30,7 +45,9 @@
"depends": { "depends": {
"fabricloader": ">=${loader_version}", "fabricloader": ">=${loader_version}",
"fabric-api": "*", "fabric-api": "*",
"minecraft": "${minecraft_version}", "minecraft": "${minecraft_version}"
},
"suggests": {
"cloth-config2": ">=${cloth_config_version}" "cloth-config2": ">=${cloth_config_version}"
} }
} }