Author SHA1 Message Date
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
44 changed files with 3434 additions and 61 deletions
+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
+4
View File
@@ -0,0 +1,4 @@
SpMega.Backend/src/*
SpMega.Backend/obj/*
SpMega.Backend/appsettings.json
SpMega.Backend/SpMega.Backend.http
@@ -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,205 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
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");
Console.WriteLine(await resp.Content.ReadAsStringAsync());
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);
if (user != null)
{
user.Token = token;
dbContext.Update(user);
}
else
{
user = new User
{
Id = body.userUUID,
Username = session.UserName,
Token = token,
};
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);
Console.WriteLine(resp);
var user = ((User)HttpContext.Items["@me"]);
Console.WriteLine(me.id);
Console.WriteLine(user.Id.ToString());
if (user == null || user.Id.ToString() != me.minecraftUUID)
{
throw new Exception("Its not ur card");
}
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,
SpworldsID = card.number,
Token = Base64BearerToken,
CreatedAt = DateTime.UtcNow,
});
else
{
existingCard.Name = card.name;
existingCard.SpworldsID = card.number;
existingCard.Token = Base64BearerToken;
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,132 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
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 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",
Sender = new User
{
Id = default,
Username = "yawaflua",
Token = "123",
},
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 BearerToken = $"{body.cardToUse.Id}:{body.cardToUse.Token}";
string Base64BearerToken = Convert.ToBase64String(Encoding.UTF8.GetBytes(BearerToken));
var shortId = Program.GenerateRandomString(8);
var transaction = new Transaction
{
ReceiverName = body.receiverName,
ShortId = shortId,
ReceiverCardNumber = body.receiverCard,
Sender = HttpContext.Items["@me"] as User,
SenderCardNumber = body.cardToUse.SpworldsID,
Amount = body.amount,
Comment = body.comment,
};
try
{
var uri = new Uri(new Uri(config["Url"] ?? "https://spmega.yawaflua.tech"), "/" + shortId);
var transitionInfo = new Dictionary<string, object>
{
{ "receiver", body.receiverCard },
{ "amount", body.amount },
{ "comment", "Чек: "+ uri }
};
await SendRequest(endpoint: "transactions", body: transitionInfo, AuthHeader:new("Bearer", Base64BearerToken));
} 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,
});
}
[HttpGet()]
[Authorize]
public async Task<ActionResult<List<Transaction>>> GetAllTransactions()
{
return Ok(await context.Transactions.Where(k => k.Sender.Id == ((User)HttpContext.Items["@me"]).Id).ToListAsync());
}
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(Card cardToUse, string receiverCard, string receiverName, string comment, int amount);
+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,573 @@
@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.0-2026</span></div>
<div style="margin-top: 4px;">Обновлено: 28 июня 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>Сведения о версии игры, версии Java и используемой версии модификации.</li>
<li>Ваш текущий IP-адрес для предотвращения мультиаккаунтинга и выявления попыток взлома API.</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>
</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="/v1/BillPage/00000000-0000-0000-0000-000000000000" class="footer-link">Проверить чек</a>
<span>•</span>
<a href="#" class="footer-link">Поддержка</a>
</div>
<div>&copy; 2026 SPMega. Все права защищены. Minecraft является торговой маркой Mojang Synergies AB.</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,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.sender?.username || 'Клиент 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,25 @@
using Microsoft.EntityFrameworkCore;
using MongoDB.EntityFrameworkCore.Extensions;
using SpMega.Backend.Persistent.Models.Transactions;
using SpMega.Backend.Persistent.Models.Users;
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; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
}
}
@@ -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,15 @@
using SPWorldsApi.Types.Interfaces;
namespace SpMega.Backend.Persistent.Models.DTO;
public class UserAccountDTO : IUserAccount
{
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 ICity city { get; set; }
public List<IUserCard> cards { get; set; }
public DateTime createdAt { get; set; }
}
@@ -0,0 +1,19 @@
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);
public string ReceiverName { get; set; }
public string ReceiverCardNumber { get; set; }
[JsonIgnore] public User Sender { get; set; }
public string SenderCardNumber { get; set; }
public int Amount { get; set; } = 0;
public string Comment { get; set; } = "";
public DateTime TransactionDate { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,17 @@
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 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,18 @@
using SpMega.Backend.Persistent.Models.Transactions;
namespace SpMega.Backend.Persistent.Models.Users;
public class User
{
public Guid Id { get; set; }
public string Username { get; set; }
public string Token { 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,15 @@
namespace SpMega.Backend.Persistent.Models.Users;
public class UserSession
{
public Guid Id { get; set; }
public string Token { get; set; }
public User? User { get; set; } = null;
public Guid UserId { get; set; }
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;
}
+93
View File
@@ -0,0 +1,93 @@
using System.Text;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using MongoDB.Driver;
using SpMega.Backend.Authetication;
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", false)
.AddJsonFile("appsettings.Development.json", false)
.AddEnvironmentVariables();
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 serviceCollection = new ServiceCollection();
var mongoClient =
new MongoClient(builder.Configuration.GetValue<string>("Mongo") ?? "mongodb://curiosity:27018");
serviceCollection.AddSingleton<IMongoClient>(mongoClient);
builder.Services.AddDbContext<AppDbContext>((_, options) =>
{
options.UseMongoDB(mongoClient, "spmega");
});
var app = builder.Build();
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 random = new Random();
var result = new StringBuilder(length);
for (var i = 0; i < length; i++)
{
var index = random.Next(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,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"] ?? "spmega.il.yawaflua.tech";
private readonly string _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,29 @@
<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="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
+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 {
// Add repositories to retrieve artifacts from in here.
// You should only use this when depending on other mods because
// Loom adds the essential maven repositories to download Minecraft and libraries from automatically.
// See https://docs.gradle.org/current/userguide/declaring_repositories.html
// for more information about repositories.
maven { url "https://maven.shedaniel.me/" }
maven { url "https://maven.terraformersmc.com/" }
}
dependencies {
// To change the versions see the gradle.properties file
minecraft "com.mojang:minecraft:${project.minecraft_version}"
mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2"
modImplementation "net.fabricmc:fabric-loader:${project.loader_version}"
@@ -49,6 +43,8 @@ dependencies {
exclude(group: "net.fabricmc.fabric-api")
}
modCompileOnly "com.terraformersmc:modmenu:11.0.3"
include(implementation("org.xerial:sqlite-jdbc:3.46.1.3"))
include(clientImplementation("com.google.zxing:core:${project.zxing_version}"))
include(clientImplementation("com.google.zxing:javase:${project.zxing_version}"))
+1 -1
View File
@@ -6,7 +6,7 @@ minecraft_version=1.21.11
yarn_mappings=1.21.11+build.4
loader_version=0.18.1
# Mod Properties
mod_version=0.2-pre-alpha
mod_version=0.4-pre-alpha
maven_group=git.yawaflua.tech
archives_base_name=SPMega
# Dependencies
@@ -0,0 +1,124 @@
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.concurrent.CompletableFuture;
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();
CompletableFuture
.supplyAsync(() -> BankUiService.instance().addCard(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,102 @@
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()};
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.authenticate(MinecraftClient.getInstance());
if (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());
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]
);
SPMega.setConfig(updated);
});
return builder.build();
};
}
}
@@ -1,9 +1,12 @@
package git.yawaflua.tech.spmega.client;
import git.yawaflua.tech.spmega.ModConfig;
import git.yawaflua.tech.spmega.SPMega;
import git.yawaflua.tech.spmega.client.qr.QRCodeScanner;
import git.yawaflua.tech.spmega.client.ui.GpsHudRenderer;
import git.yawaflua.tech.spmega.client.ui.UiNotifications;
import git.yawaflua.tech.spmega.client.ui.UiOpeners;
import git.yawaflua.tech.spmega.client.ui.service.BackendAuthenticator;
import git.yawaflua.tech.spmega.client.ui.service.BankUiService;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
@@ -18,6 +21,7 @@ import net.minecraft.client.MinecraftClient;
import net.minecraft.client.option.KeyBinding;
import net.minecraft.client.util.InputUtil;
import net.minecraft.entity.decoration.ItemFrameEntity;
import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.text.Text;
import net.minecraft.util.ActionResult;
import org.lwjgl.glfw.GLFW;
@@ -29,6 +33,9 @@ public class SPMegaClient implements ClientModInitializer {
private static final Pattern ISOLATED_FIVE_DIGITS = Pattern.compile("(?<!\\d)(\\d{5})(?!\\d)");
private static KeyBinding openBankMenuKeyBinding;
private static KeyBinding scanQrKeyBinding;
private static KeyBinding toggleGpsKeyBinding;
private static boolean wasPaymentShortcutPressed = false;
private static String extractCardNumber(SignBlockEntity signBlockEntity) {
String candidate = findFiveDigits(signBlockEntity.getFrontText().getMessages(false));
@@ -65,6 +72,7 @@ public class SPMegaClient implements ClientModInitializer {
@Override
public void onInitializeClient() {
GpsHudRenderer.instance();
openBankMenuKeyBinding = KeyBindingHelper.registerKeyBinding(new KeyBinding(
"key.spmega.open_menu",
InputUtil.Type.KEYSYM,
@@ -79,6 +87,17 @@ public class SPMegaClient implements ClientModInitializer {
KeyBinding.Category.GAMEPLAY
));
toggleGpsKeyBinding = KeyBindingHelper.registerKeyBinding(new KeyBinding(
"key.spmega.toggle_gps",
InputUtil.Type.KEYSYM,
GLFW.GLFW_KEY_J,
KeyBinding.Category.GAMEPLAY
));
if (SPMega.getConfig() != null) {
GpsHudRenderer.instance().setEnabled(SPMega.getConfig().gpsEnabled());
}
ClientTickEvents.END_CLIENT_TICK.register(client -> {
UiNotifications.instance().tick();
while (openBankMenuKeyBinding.wasPressed()) {
@@ -87,9 +106,40 @@ public class SPMegaClient implements ClientModInitializer {
while (scanQrKeyBinding.wasPressed()) {
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()
);
SPMega.setConfig(updated);
}
String status = GpsHudRenderer.instance().isEnabled() ? "включен" : "выключен";
UiNotifications.instance().show(Text.literal("GPS Ада " + status));
}
if (client.player != null && client.options != null) {
boolean isCombinationPressed = client.options.sprintKey.isPressed() && client.options.sneakKey.isPressed() && client.options.leftKey.isPressed();
if (isCombinationPressed) {
if (!wasPaymentShortcutPressed && client.currentScreen == null) {
if (client.targetedEntity instanceof PlayerEntity targetedPlayer && targetedPlayer != client.player) {
String recipient = targetedPlayer.getStringifiedName();
UiOpeners.openPaymentMenu(client, recipient);
}
}
wasPaymentShortcutPressed = true;
} else {
wasPaymentShortcutPressed = false;
}
}
});
// World HUD path (when no screen is open).
HudRenderCallback.EVENT.register((drawContext, tickDeltaManager) -> {
MinecraftClient client = MinecraftClient.getInstance();
if (client.currentScreen != null || client.textRenderer == null) {
@@ -101,16 +151,35 @@ public class SPMegaClient implements ClientModInitializer {
client.getWindow().getScaledWidth(),
client.getWindow().getScaledHeight()
);
GpsHudRenderer.instance().render(
drawContext,
client.textRenderer,
client.getWindow().getScaledWidth(),
client.getWindow().getScaledHeight()
);
});
// Screen path (for all GUI screens).
ScreenEvents.AFTER_INIT.register((client, screen, scaledWidth, scaledHeight) ->
ScreenEvents.AFTER_INIT.register((client, screen, scaledWidth, scaledHeight) -> {
ScreenEvents.afterRender(screen).register((screenInstance, drawContext, mouseX, mouseY, tickDelta) -> {
if (client.textRenderer == null) {
return;
}
UiNotifications.instance().render(drawContext, client.textRenderer, scaledWidth, scaledHeight);
})
});
// CompletableFuture.supplyAsync(() -> BackendAuthenticator.authenticate(client))
// .thenAccept(authResult -> {
// if (authResult) {
// System.out.println("SPMega: Authenticated on backend successfully.");
// } else {
// System.err.println("SPMega: Authentication on backend failed");
// }
// })
// .exceptionally(ex -> {
// System.err.println("SPMega: Authentication error: " + ex.getMessage());
// return null;
// });
}
);
ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> {
@@ -167,5 +236,10 @@ public class SPMegaClient implements ClientModInitializer {
}
});
new ChatListener().register();
BackendAuthenticator.authenticate(MinecraftClient.getInstance());
System.out.println("Author of SPMega make it with 4 cans of monster");
System.out.println("Initialized beshalom! Tieie tovim!");
}
}
@@ -9,12 +9,14 @@ import net.minecraft.text.Text;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
public class CardScreen extends Screen {
private final Screen parent;
private final BankUiService bankUiService = BankUiService.instance();
private final UiNotifications notifications = UiNotifications.instance();
private final List<ButtonWidget> cardButtons = new ArrayList<>();
private ButtonWidget historyButton;
public CardScreen(Screen parent) {
super(Text.literal("Управление картами"));
@@ -44,16 +46,22 @@ public class CardScreen extends Screen {
}
this.addDrawableChild(ButtonWidget.builder(Text.literal("Удалить"), button -> {
CompletableFuture.supplyAsync(() -> {
bankUiService.removeSelectedCard();
return true;
});
notifications.showMessage(bankUiService.getLastMessage());
this.clearAndInit();
}).dimensions(rightX, startY, columnWidth, 20).build());
this.addDrawableChild(ButtonWidget.builder(Text.literal("Изменить"), button -> {
this.addDrawableChild(ButtonWidget.builder(Text.literal("Обновить"), button -> {
String playerUuid = this.client != null && this.client.player != null
? this.client.player.getUuidAsString()
: "";
CompletableFuture.supplyAsync(() -> {
bankUiService.refreshSelectedCard(playerUuid);
return true;
});
String message = bankUiService.getLastMessage().isBlank() ? "Карта обновлена" : bankUiService.getLastMessage();
notifications.showMessage(message);
this.clearAndInit();
@@ -69,6 +77,18 @@ public class CardScreen extends Screen {
}));
}).dimensions(rightX, startY + 48, columnWidth, 20).build());
historyButton = this.addDrawableChild(ButtonWidget.builder(Text.literal("История"), button -> {
if (this.client == null) {
return;
}
CardViewModel selected = bankUiService.getSelectedCard();
if (selected == null) {
notifications.show(Text.literal("Сначала выбери или добавь карту"));
return;
}
this.client.setScreen(new TransactionHistoryScreen(this, selected.id(), selected.title()));
}).dimensions(rightX, startY + 72, columnWidth, 20).build());
this.addDrawableChild(ButtonWidget.builder(Text.literal("Назад"), button -> this.close())
.dimensions(rightX, startY + 120, columnWidth, 20)
.build());
@@ -119,5 +139,9 @@ public class CardScreen extends Screen {
button.setMessage(Text.literal(prefix + cards.get(i).title() + suffix));
button.active = !selected;
}
if (historyButton != null) {
historyButton.active = !cards.isEmpty();
}
}
}
@@ -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 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.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
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 final Object citiesLock = new Object();
private boolean enabled = true;
private List<City> cities = new ArrayList<>();
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() {
try {
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://spm-map.tonyaleksandr.ru/api/map/territories"))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
TerritoryWrapper[] parsed = new Gson().fromJson(response.body(), TerritoryWrapper[].class);
if (parsed != null) {
List<City> list = new ArrayList<>();
for (TerritoryWrapper w : parsed) {
if (w != null && w.territory != null && w.territory.nether_portal != null && w.territory.nether_portal.length >= 2) {
City c = new City();
c.name = w.territory.name;
c.netherX = w.territory.nether_portal[0];
c.netherZ = w.territory.nether_portal[1];
list.add(c);
}
}
synchronized (citiesLock) {
cities = list;
}
}
}
} catch (Exception ignored) {
}
}
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 minDistanceSq = Double.MAX_VALUE;
for (City city : cities) {
double dx = playerX - (city.netherX / 8);
double dz = playerZ - (city.netherZ / 8);
double distSq = Math.sqrt(dx * dx + dz * dz);
if (distSq < minDistanceSq) {
minDistanceSq = distSq;
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;
}
}
}
}
}
@@ -41,6 +41,7 @@ public class PaymentScreen extends Screen {
private Text senderCardText = Text.literal("Карта отправителя: не выбрана");
private ButtonWidget transferButton;
private ButtonWidget backButton;
private boolean transferInProgress;
public PaymentScreen(Screen parent) {
this(parent, "");
@@ -192,6 +193,11 @@ public class PaymentScreen extends Screen {
}
private void submit() {
if (transferInProgress) {
notifications.show(Text.literal("Перевод уже выполняется"));
return;
}
CardViewModel selectedCard = bankUiService.getSelectedCard();
if (selectedCard == null) {
notifications.show(Text.literal("Нет выбранной карты отправителя"));
@@ -205,7 +211,8 @@ public class PaymentScreen extends Screen {
notifications.show(Text.literal("Сумма должна быть больше 0"));
return;
}
} catch (NumberFormatException exception) {
} catch (Exception exception) {
System.out.println(exception.getMessage());
notifications.show(Text.literal("Некорректная сумма"));
return;
}
@@ -232,18 +239,46 @@ public class PaymentScreen extends Screen {
commentField.getText().trim()
);
boolean accepted = bankUiService.submitPayment(draft);
setTransferInProgress(true);
notifications.show(Text.literal("Отправка перевода..."));
bankUiService.submitPaymentAsync(draft)
.thenAccept(accepted -> {
if (this.client == null) {
return;
}
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;
}
}
private boolean isValidRecipient(String recipient) {
@@ -277,7 +312,7 @@ public class PaymentScreen extends Screen {
private void updateSenderCardSelector() {
CardViewModel selectedCard = bankUiService.getSelectedCard();
if (selectedCard == null) {
senderCardLabelButton.setMessage(Text.literal("\"00000\": \"0\" АР"));
senderCardLabelButton.setMessage(Text.literal("TEST 00000: 0 АР"));
senderLeftButton.active = false;
senderRightButton.active = false;
return;
@@ -285,9 +320,8 @@ public class PaymentScreen extends Screen {
senderLeftButton.active = true;
senderRightButton.active = true;
String cardId = extractCardId(selectedCard.title());
String balance = Long.toString(selectedCard.balance());
senderCardLabelButton.setMessage(Text.literal("\"" + cardId + "\": \"" + balance + "\" АР"));
senderCardLabelButton.setMessage(Text.literal(selectedCard.title() + ": " + balance + " АР"));
senderCardLabelButton.active = false;
}
@@ -6,6 +6,7 @@ import net.minecraft.client.gui.widget.ButtonWidget;
import net.minecraft.text.Text;
import net.minecraft.util.Util;
import java.awt.*;
import java.net.URI;
public class QRcodeAcceptScreen extends Screen {
@@ -20,6 +21,8 @@ public class QRcodeAcceptScreen extends Screen {
@Override
protected void init() {
this.addDrawableChild(ButtonWidget.builder(Text.translatable("button.spmega.qr.cancel"), button -> {
if (this.client != null) {
this.client.setScreen(parent);
@@ -53,17 +56,10 @@ public class QRcodeAcceptScreen extends Screen {
context.drawCenteredTextWithShadow(
this.textRenderer,
Text.translatable("screen.spmega.qr.accept_link"),
Text.literal("Найдена ссылка: " + url),
this.width / 2,
this.height / 2 - 30,
0xFFFFFF
);
context.drawCenteredTextWithShadow(
this.textRenderer,
Text.literal(url),
this.width / 2,
this.height / 2 - 10,
0xFFD27F
this.height / 2,
Color.WHITE.getRGB()
);
}
}
@@ -0,0 +1,124 @@
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.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 = "";
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());
loadTransactions();
}
private void loadTransactions() {
loading = true;
errorMessage = "";
transactions.clear();
CompletableFuture.runAsync(() -> {
try {
List<LocalTransaction> list = null;
git.yawaflua.tech.spmega.ModConfig config = git.yawaflua.tech.spmega.SPMega.getConfig();
if (config != null && config.allowBackend()) {
try {
list = BackendAuthenticator.fetchTransactionsFromBackend(cardId);
} catch (Exception e) {
System.err.println("[SPMEGA] Failed to fetch transactions from server, falling back to DB: " + e.getMessage());
}
}
if (list == null) {
list = BankUiService.instance().getDatabase().loadTransferHistory(cardId);
}
List<LocalTransaction> finalList = list;
if (this.client != null) {
this.client.execute(() -> {
this.transactions.addAll(finalList);
this.loading = false;
});
}
} catch (Exception e) {
if (this.client != null) {
this.client.execute(() -> {
this.errorMessage = e.getMessage();
this.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;
context.drawCenteredTextWithShadow(this.textRenderer, this.title, centerX, 20, 0xFFFFFF);
context.drawCenteredTextWithShadow(this.textRenderer, Text.literal("Карта: " + cardTitle), centerX, 35, 0xBFBFBF);
if (loading) {
context.drawCenteredTextWithShadow(this.textRenderer, Text.literal("Загрузка транзакций..."), centerX, this.height / 2 - 10, 0xCCCCCC);
} else if (!errorMessage.isEmpty()) {
context.drawCenteredTextWithShadow(this.textRenderer, Text.literal("Ошибка: " + errorMessage), centerX, this.height / 2 - 10, 0xFF5555);
} else if (transactions.isEmpty()) {
context.drawCenteredTextWithShadow(this.textRenderer, Text.literal("Транзакций не найдено"), centerX, this.height / 2 - 10, 0xCCCCCC);
} else {
int y = startY + 15;
for (int i = 0; i < Math.min(6, transactions.size()); i++) {
LocalTransaction tx = transactions.get(i);
String amountText = tx.amount() + " АР";
String dateText = tx.createdAt();
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);
int color = tx.status().equalsIgnoreCase("SUCCESS") ? 0x55FF55 : 0xFF5555;
context.drawCenteredTextWithShadow(this.textRenderer, Text.literal(line), centerX, y, color);
y += 18;
}
}
}
}
@@ -0,0 +1,368 @@
package git.yawaflua.tech.spmega.client.ui.service;
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 net.minecraft.client.MinecraftClient;
import net.minecraft.client.session.Session;
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.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class BackendAuthenticator {
public static boolean authenticate(MinecraftClient client) {
Session session = client.getSession();
if (session == null || session.getUuidOrNull() == null) {
System.err.println("Cannot authenticate: Client has no valid session.");
return false;
}
MinecraftSessionService sessionService = client.getApiServices().sessionService();
try {
var serverId = sendStartSessionRequestToBackend(session.getUsername(), session.getUuidOrNull());
System.out.println("[SPMEGA] Trying to auth in mojang with serverId: " + serverId);
sessionService.joinServer(
session.getUuidOrNull(),
session.getAccessToken(),
serverId
);
System.out.println("[SPMEGA] Sending session submitter to backend");
return sendAuthRequestToBackend(session.getUuidOrNull(), serverId);
} catch (AuthenticationException e) {
System.err.println("I cant auth by Mojang: " + e.getMessage());
System.err.println("Please check your credentials and try again.");
return false;
} catch (Exception e) {
System.err.println("Failed to authenticate with backend: " + e.getMessage());
return false;
}
}
private static String sendStartSessionRequestToBackend(String username, UUID uuid) throws IOException, InterruptedException {
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";
HttpClient httpClient = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
System.err.println("[SPMEGA] Server returned status code " + response.statusCode() + " on start: " + response.body());
throw new IOException("Server returned status code " + response.statusCode() + " on start: " + response.body());
}
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
if (json.has("sessionId")) {
return json.get("sessionId").getAsString();
} else {
System.err.println("[SPMEGA] Invalid response from start endpoint: " + response.body());
throw new IOException("Invalid response from start endpoint: " + response.body());
}
}
private static boolean sendAuthRequestToBackend(UUID uuid, String serverId) throws IOException, InterruptedException {
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";
HttpClient httpClient = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
System.err.println("[SPMEGA] Server returned status code " + response.statusCode() + " on validate: " + response.body());
throw new IOException("Server returned status code " + response.statusCode() + " on validate: " + response.body());
}
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
if (json.has("token")) {
String token = json.get("token").getAsString();
if (config != null) {
ModConfig updated = new ModConfig(
config.apiDomain(),
token,
config.allowBackend(),
config.signQuickPayEnabled(),
config.gpsEnabled(),
config.gpsPosition()
);
SPMega.setConfig(updated);
System.out.println("[SPMEGA] Backend auth successful, saved token.");
return true;
} else {
System.err.println("[SPMEGA] Config is null, cannot save token.");
throw new IOException("Config is null, cannot save token.");
}
} else {
System.err.println("[SPMEGA] Invalid response from validate endpoint: " + response.body());
throw new IOException("Invalid response from validate endpoint: " + response.body());
}
}
public static void sendCardToBackend(String cardId, String cardToken) {
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();
HttpClient httpClient = HttpClient.newHttpClient();
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, HttpResponse.BodyHandlers.ofString())
.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 List<CardCredentials> fetchCardsFromBackend() throws IOException, InterruptedException {
ModConfig config = SPMega.getConfig();
if (config == null || !config.allowBackend()) {
return 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";
HttpClient httpClient = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.header("Accept", "application/json")
.header("Authorization", "Bearer " + config.apiToken())
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw 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 el : cardsArray) {
JsonObject cardJson = el.getAsJsonObject();
String cardId = "";
if (cardJson.has("cardId") && !cardJson.get("cardId").isJsonNull()) {
cardId = cardJson.get("cardId").getAsString();
} else if (cardJson.has("id") && !cardJson.get("id").isJsonNull()) {
cardId = cardJson.get("id").getAsString();
}
String cardToken = "";
if (cardJson.has("cardToken") && !cardJson.get("cardToken").isJsonNull()) {
cardToken = cardJson.get("cardToken").getAsString();
} else if (cardJson.has("token") && !cardJson.get("token").isJsonNull()) {
cardToken = cardJson.get("token").getAsString();
}
if (!cardId.isEmpty() && !cardToken.isEmpty()) {
cards.add(new CardCredentials(cardId, cardToken));
}
}
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;
HttpClient httpClient = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.header("Accept", "application/json")
.header("Authorization", "Bearer " + config.apiToken())
.DELETE()
.build();
httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.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 boolean createTransactionOnBackend(String senderCardId, String receiver, long amount, String comment) throws IOException, InterruptedException {
ModConfig config = SPMega.getConfig();
if (config == null) {
throw 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("senderCardId", senderCardId);
jsonPayload.addProperty("cardId", senderCardId);
jsonPayload.addProperty("receiver", receiver);
jsonPayload.addProperty("recipient", receiver);
jsonPayload.addProperty("amount", amount);
jsonPayload.addProperty("comment", comment);
String requestBody = jsonPayload.toString();
HttpClient httpClient = HttpClient.newHttpClient();
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();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200 || response.statusCode() == 201 || response.statusCode() == 204) {
return true;
} else {
throw new IOException("Server returned status code " + response.statusCode() + ": " + response.body());
}
}
public static List<BankDatabase.LocalTransaction> fetchTransactionsFromBackend(String cardId) throws IOException, InterruptedException {
ModConfig config = SPMega.getConfig();
if (config == null || !config.allowBackend()) {
return 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?cardId=" + cardId;
HttpClient httpClient = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.header("Accept", "application/json")
.header("Authorization", "Bearer " + config.apiToken())
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("Server returned status code " + response.statusCode() + " on fetch transactions.");
}
com.google.gson.JsonArray transactionsArray = JsonParser.parseString(response.body()).getAsJsonArray();
List<BankDatabase.LocalTransaction> list = new ArrayList<>();
for (com.google.gson.JsonElement el : transactionsArray) {
JsonObject json = el.getAsJsonObject();
String receiver = json.has("receiver") ? json.get("receiver").getAsString() : (json.has("recipient") ? json.get("recipient").getAsString() : "unknown");
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").getAsString() : "SUCCESS";
String createdAt = json.has("createdAt") ? json.get("createdAt").getAsString() : (json.has("created_at") ? json.get("created_at").getAsString() : "");
list.add(new BankDatabase.LocalTransaction(receiver, amount, comment, status, createdAt));
}
return list;
}
}
@@ -166,6 +166,31 @@ public final class BankDatabase {
}
}
public synchronized List<LocalTransaction> loadTransferHistory(String cardId) {
String sql = "SELECT receiver, amount, comment, status, created_at FROM transfer_history WHERE sender_card_id = ? ORDER BY id DESC";
List<LocalTransaction> result = new ArrayList<>();
try (Connection connection = open(); PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, cardId);
try (ResultSet rs = statement.executeQuery()) {
while (rs.next()) {
result.add(new LocalTransaction(
rs.getString("receiver"),
rs.getLong("amount"),
rs.getString("comment"),
rs.getString("status"),
rs.getString("created_at")
));
}
}
return result;
} catch (SQLException exception) {
throw new RuntimeException("Failed to load transfer history", exception);
}
}
public record LocalTransaction(String receiver, long amount, String comment, String status, String createdAt) {
}
private Connection open() throws SQLException {
return DriverManager.getConnection(jdbcUrl);
}
@@ -1,9 +1,12 @@
package git.yawaflua.tech.spmega.client.ui.service;
import git.yawaflua.tech.spmega.ModConfig;
import git.yawaflua.tech.spmega.SPMega;
import git.yawaflua.tech.spmega.api.SPWorldsApiClient;
import net.fabricmc.loader.api.FabricLoader;
import java.util.*;
import java.util.concurrent.CompletableFuture;
public final class BankUiService {
private static final BankUiService INSTANCE = new BankUiService();
@@ -91,7 +94,28 @@ public final class BankUiService {
return lastMessage;
}
public synchronized void syncCardsWithBackend(String playerUuid) {
try {
List<CardCredentials> backendCards = BackendAuthenticator.fetchCardsFromBackend();
boolean changed = false;
for (CardCredentials creds : backendCards) {
if (database.getCredentials(creds.cardId()) == null) {
database.upsertCardCredentials(creds.cardId(), creds.cardToken());
refreshCard(creds.cardId(), creds.cardToken(), playerUuid, false, false);
changed = true;
}
}
if (changed) {
reloadCardsFromDb();
}
} catch (Exception e) {
System.err.println("[SPMEGA] Failed to sync cards with backend: " + e.getMessage());
}
}
public synchronized void refreshOnServerJoin(String playerUuid) {
syncCardsWithBackend(playerUuid);
List<StoredCard> storedCards = database.loadCards();
for (StoredCard card : storedCards) {
refreshCard(card.cardId(), card.cardToken(), playerUuid, false, false);
@@ -110,7 +134,7 @@ public final class BankUiService {
List<String> numbers = new ArrayList<>();
for (SPWorldsApiClient.PlayerCard apiCard : apiCards) {
if (apiCard.number() != null && !apiCard.number().isBlank()) {
numbers.add(apiCard.number());
numbers.add(apiCard.name() + " : " + apiCard.number());
}
}
lastMessage = "";
@@ -157,7 +181,12 @@ public final class BankUiService {
return;
}
database.deleteCard(selected.id());
String cardId = selected.id();
database.deleteCard(cardId);
ModConfig config = SPMega.getConfig();
if (config.allowBackend())
BackendAuthenticator.deleteCardOnBackend(cardId);
reloadCardsFromDb();
if (selectedCardIndex >= cards.size()) {
selectedCardIndex = Math.max(0, cards.size() - 1);
@@ -189,21 +218,47 @@ public final class BankUiService {
return false;
}
git.yawaflua.tech.spmega.ModConfig config = git.yawaflua.tech.spmega.SPMega.getConfig();
boolean useBackend = config != null && config.allowBackend();
try {
long newBalance = 0;
if (useBackend) {
BackendAuthenticator.createTransactionOnBackend(
draft.senderCardId(),
draft.recipient(),
draft.amount(),
draft.comment()
);
try {
refreshCard(credentials.cardId(), credentials.cardToken(), "", false, false);
StoredCard updatedCard = database.loadCards().stream()
.filter(c -> c.cardId().equals(credentials.cardId()))
.findFirst()
.orElse(null);
if (updatedCard != null) {
newBalance = updatedCard.balance();
}
} catch (Exception e) {
System.err.println("[SPMEGA] Failed to refresh card balance after transaction: " + e.getMessage());
}
} else {
SPWorldsApiClient.TransactionResult result = apiClient.createTransaction(
toApiAuth(credentials),
draft.recipient(),
draft.amount(),
draft.comment()
);
newBalance = result.balance();
database.updateCardBalance(credentials.cardId(), newBalance);
}
database.updateCardBalance(credentials.cardId(), result.balance());
database.insertTransferHistory(
credentials.cardId(),
draft.recipient(),
draft.amount(),
draft.comment(),
result.balance(),
newBalance,
"SUCCESS"
);
@@ -224,6 +279,10 @@ public final class BankUiService {
}
}
public CompletableFuture<Boolean> submitPaymentAsync(PaymentDraft draft) {
return CompletableFuture.supplyAsync(() -> submitPayment(draft));
}
private boolean refreshCard(
String cardId,
String cardToken,
@@ -259,6 +318,13 @@ public final class BankUiService {
return true;
}
}
git.yawaflua.tech.spmega.ModConfig config = git.yawaflua.tech.spmega.SPMega.getConfig();
if (config.allowBackend())
CompletableFuture.supplyAsync(() -> {
BackendAuthenticator.sendCardToBackend(cardId, cardToken);
return true;
}
);
lastMessage = "";
return true;
@@ -295,4 +361,8 @@ public final class BankUiService {
}
return database.getCredentials(selected.id());
}
public BankDatabase getDatabase() {
return database;
}
}
@@ -14,6 +14,22 @@
"message.spmega.qr.error": "Failed to scan QR from screen",
"message.spmega.qr.hover_tip": "Click to open link",
"message.spmega.qr.found_link": "Found link: %s",
"message.spmega.qr.failed_open": "Failed to open link"
"message.spmega.qr.failed_open": "Failed to open link",
"key.spmega.toggle_gps": "Toggle Nether GPS",
"title.spmega.config": "SPMega Settings",
"category.spmega.general": "General Settings",
"option.spmega.api_domain": "API Domain",
"option.spmega.api_token": "API Token",
"option.spmega.allow_backend": "Allow saving data, transactions and other sensitive information to the backend",
"option.spmega.sign_quick_pay": "Quick Pay by Signs",
"option.spmega.gps_position": "Nether GPS position",
"option.spmega.gps_position.top_left": "Top-Left",
"option.spmega.gps_position.top_right": "Top-Right",
"option.spmega.gps_position.bottom_left": "Bottom-Left",
"option.spmega.gps_position.bottom_right": "Bottom-Right",
"option.spmega.gps_position.bottom_center": "Bottom-center",
"option.spmega.gps_position.top_center": "Top-center"
}
@@ -0,0 +1,33 @@
{
"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": "Снизу справа"
}
@@ -42,19 +42,39 @@ public final class ConfigManager {
shouldSave = true;
}
boolean allowAccess = readBoolean(properties, "allow.backend", defaults.allowBackend());
String rawAllowAccess = properties.getProperty("sign.quickPay.enabled");
if (rawAllowAccess == null || !Boolean.toString(allowAccess).equalsIgnoreCase(rawAllowAccess.trim())) {
shouldSave = true;
}
boolean signQuickPayEnabled = readBoolean(properties, "sign.quickPay.enabled", defaults.signQuickPayEnabled());
String rawQuickPay = properties.getProperty("sign.quickPay.enabled");
if (rawQuickPay == null || !Boolean.toString(signQuickPayEnabled).equalsIgnoreCase(rawQuickPay.trim())) {
shouldSave = true;
}
ModConfig config = new ModConfig(apiDomain, apiToken, signQuickPayEnabled);
boolean gpsEnabled = readBoolean(properties, "gps.enabled", defaults.gpsEnabled());
String rawGps = properties.getProperty("gps.enabled");
if (rawGps == null || !Boolean.toString(gpsEnabled).equalsIgnoreCase(rawGps.trim())) {
shouldSave = true;
}
GpsHudPosition gpsPosition = readEnum(properties, "gps.position", GpsHudPosition.class, defaults.gpsPosition());
String rawPosition = properties.getProperty("gps.position");
if (rawPosition == null || !gpsPosition.name().equalsIgnoreCase(rawPosition.trim())) {
shouldSave = true;
}
ModConfig config = new ModConfig(apiDomain, apiToken, allowAccess, signQuickPayEnabled, gpsEnabled, gpsPosition);
if (shouldSave) {
save(configPath, config);
}
return config;
}
private static String readString(Properties properties, String key, String fallback) {
@@ -97,11 +117,18 @@ public final class ConfigManager {
return fallback;
}
public static void save(ModConfig config) {
Path configPath = FabricLoader.getInstance().getConfigDir().resolve(FILE_NAME);
save(configPath, config);
}
private static void save(Path configPath, ModConfig config) {
Properties properties = new Properties();
properties.setProperty("api.domain", config.apiDomain());
properties.setProperty("api.token", config.apiToken());
properties.setProperty("sign.quickPay.enabled", Boolean.toString(config.signQuickPayEnabled()));
properties.setProperty("gps.enabled", Boolean.toString(config.gpsEnabled()));
properties.setProperty("gps.position", config.gpsPosition().name());
try {
Files.createDirectories(configPath.getParent());
@@ -112,5 +139,17 @@ public final class ConfigManager {
throw new RuntimeException("Failed to save config: " + configPath, exception);
}
}
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,24 @@
package git.yawaflua.tech.spmega;
public record ModConfig(String apiDomain, String apiToken, boolean signQuickPayEnabled) {
public static final String DEFAULT_API_DOMAIN = "https://spmega-api.yawaflua.tech";
public record ModConfig(String apiDomain, String apiToken, boolean allowBackend, boolean signQuickPayEnabled,
boolean gpsEnabled, GpsHudPosition gpsPosition) {
public static final String DEFAULT_API_DOMAIN = "http://localhost:5129";
public static final boolean ALLOW_BACKEND = false;
public static final String DEFAULT_API_TOKEN = "ulBKE9MWEtIGiPAhXV69I28W9BRiSrV3";
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_LEFT;
public static ModConfig createDefault() {
return new ModConfig(
DEFAULT_API_DOMAIN,
DEFAULT_API_TOKEN,
DEFAULT_SIGN_QUICK_PAY_ENABLED
ALLOW_BACKEND,
DEFAULT_SIGN_QUICK_PAY_ENABLED,
DEFAULT_GPS_ENABLED,
DEFAULT_GPS_POSITION
);
}
}
@@ -9,6 +9,11 @@ public class SPMega implements ModInitializer {
return config;
}
public static void setConfig(ModConfig newConfig) {
config = newConfig;
ConfigManager.save(newConfig);
}
@Override
public void onInitialize() {
config = ConfigManager.loadOrCreate();
@@ -53,6 +53,15 @@ public final class SPWorldsApiClient {
String body = send(request);
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
if (json.has("statusCode")) {
switch (json.get("statusCode").getAsInt()) {
case 403:
throw new IOException("Апи вернула ошибку: " + json.get("message").getAsString());
default:
System.out.println("Unhandled status code in card info response: " + json.get("statusCode").getAsInt());
break;
}
}
long balance = json.has("balance") ? json.get("balance").getAsLong() : 0L;
String webhook = json.has("webhook") && !json.get("webhook").isJsonNull()
? json.get("webhook").getAsString()
+9 -2
View File
@@ -4,8 +4,12 @@
"version": "${version}",
"name": "SPMega",
"description": "yawaflua`s SPRadar+SPMHelper mod, thats make a lot!",
"authors": [],
"contact": {},
"authors": [
"Dmitri 'yawaflua' Shimanski aka DOLBAYEB"
],
"contact": {
},
"license": "CC BY-NC-ND 4.0",
"icon": "icon.png",
"environment": "client",
@@ -16,6 +20,9 @@
"client": [
"git.yawaflua.tech.spmega.client.SPMegaClient"
],
"modmenu": [
"git.yawaflua.tech.spmega.client.ModMenuIntegration"
],
"main": [
"git.yawaflua.tech.spmega.SPMega"
]