diff --git a/src/backend/.dockerignore b/backend/.dockerignore similarity index 100% rename from src/backend/.dockerignore rename to backend/.dockerignore diff --git a/src/backend/.gitignore b/backend/.gitignore similarity index 100% rename from src/backend/.gitignore rename to backend/.gitignore diff --git a/backend/SpMega.Backend/Authetication/JwtAuthHandler.cs b/backend/SpMega.Backend/Authetication/JwtAuthHandler.cs new file mode 100644 index 0000000..8cb1ac4 --- /dev/null +++ b/backend/SpMega.Backend/Authetication/JwtAuthHandler.cs @@ -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 options, + ILoggerFactory logger, + UrlEncoder encoder, + AppDbContext dbContext) : AuthenticationHandler(options, logger, encoder) +{ + + protected override async Task 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 + { + 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"); + } + } +} diff --git a/backend/SpMega.Backend/Authetication/JwtAuthenticationSchemeOptions.cs b/backend/SpMega.Backend/Authetication/JwtAuthenticationSchemeOptions.cs new file mode 100644 index 0000000..56816b9 --- /dev/null +++ b/backend/SpMega.Backend/Authetication/JwtAuthenticationSchemeOptions.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Authentication; + +namespace SpMega.Backend.Authetication; + +public class JwtAuthenticationSchemeOptions : AuthenticationSchemeOptions +{ + public const string DefaultScheme = "JwtScheme"; +} \ No newline at end of file diff --git a/backend/SpMega.Backend/Controllers/v1/AuthController.cs b/backend/SpMega.Backend/Controllers/v1/AuthController.cs new file mode 100644 index 0000000..9e986ec --- /dev/null +++ b/backend/SpMega.Backend/Controllers/v1/AuthController.cs @@ -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 logger) : ControllerBase +{ + private const string BASE_URL = "https://spworlds.ru/api/public"; + public AuthenticationHeaderValue? AuthHeader { get; set; } + + [HttpPost("start")] + public async Task 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> 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(); + 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 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(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>> GetAllCardsAsync() + { + return Ok(((User)HttpContext.Items["@me"]).Cards.Where(k => !k.IsDeleted).ToList()); + } + + [HttpDelete("cards/{id}")] + [Authorize] + public async Task 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 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); + diff --git a/backend/SpMega.Backend/Controllers/v1/TransactionsController.cs b/backend/SpMega.Backend/Controllers/v1/TransactionsController.cs new file mode 100644 index 0000000..c341989 --- /dev/null +++ b/backend/SpMega.Backend/Controllers/v1/TransactionsController.cs @@ -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 logger, IConfiguration config) : ControllerBase +{ + private const string BASE_URL = "https://spworlds.ru/api/public"; + + [HttpGet("{billId}")] + public async Task 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> 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 + { + { "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>> GetAllTransactions() + { + return Ok(await context.Transactions.Where(k => k.Sender.Id == ((User)HttpContext.Items["@me"]).Id).ToListAsync()); + } + + internal async Task 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); \ No newline at end of file diff --git a/src/backend/SpMega.Backend/Dockerfile b/backend/SpMega.Backend/Dockerfile similarity index 98% rename from src/backend/SpMega.Backend/Dockerfile rename to backend/SpMega.Backend/Dockerfile index b1a0681..824d9ae 100644 --- a/src/backend/SpMega.Backend/Dockerfile +++ b/backend/SpMega.Backend/Dockerfile @@ -2,7 +2,6 @@ USER $APP_UID WORKDIR /app EXPOSE 8080 -EXPOSE 8081 FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG BUILD_CONFIGURATION=Release diff --git a/backend/SpMega.Backend/Pages/v1/AgreementPage.cshtml b/backend/SpMega.Backend/Pages/v1/AgreementPage.cshtml new file mode 100644 index 0000000..151b4be --- /dev/null +++ b/backend/SpMega.Backend/Pages/v1/AgreementPage.cshtml @@ -0,0 +1,573 @@ +@page "/privacy" +@model SpMega.Backend.AgreementPage + +@{ + Layout = null; +} + + + + + + + Пользовательское соглашение — SPMega + + + + + + + + + + +
+
+ +
+
+ +
S
+
SPMega
+
+
+
Версия: 1.0.0-2026
+
Обновлено: 28 июня 2026
+
+
+ +
+ + + + +
+

Пользовательское соглашение

+
+ Свод правил для пользователей модификации Minecraft и сервиса SPMega +
+ +
+ Важная информация + Установка, запуск модификации SPMega для Minecraft или посещение данного сайта означает ваше полное согласие со всеми пунктами данного соглашения. Если вы не согласны с какими-либо условиями, вы обязаны прекратить использование всех связанных сервисов и удалить модификацию. +
+ + +
+

Общие положения

+

Настоящее Соглашение является публичной офертой и регулирует порядок использования официального программного обеспечения (модификации для Minecraft), а также веб-сервисов и API, предоставляемых под эгидой проекта SPMega.

+

Соглашение заключается между конечным пользователем (вами) и разработчиками проекта (автором модификации и владельцами сайта). Нажимая кнопку запуска в лаунчере, заходя в игру с активным модом или посещая данный ресурс, вы подтверждаете свою дееспособность и согласие с правилами.

+
+ + +
+

Использование мода для Minecraft

+

Модификация SPMega разработана для интеграции игрового процесса Minecraft с нашей платежной и статистической системой. Пользователю предоставляется бесплатное неисключительное право на использование модификации в личных некоммерческих целях.

+

При использовании модификации запрещено:

+
    +
  • Декомпилировать, модифицировать код или создавать клоны ПО без прямого письменного согласия разработчиков.
  • +
  • Использовать баги мода или веб-интерфейсов для накрутки баланса, валюты или совершения несанкционированных транзакций.
  • +
  • Использовать модификацию в связке с запрещенными сторонними программами (чит-клиенты, боты и т.д.).
  • +
+
+ + +
+

Отказ от претензий (Дисклеймер)

+

ПРОГРАММНОЕ ОБЕСПЕЧЕНИЕ ПРЕДОСТАВЛЯЕТСЯ «КАК ЕСТЬ» (AS IS). Автор модификации и владельцы сайта снимают с себя всю ответственность за любой ущерб, возникший в результате использования мода или веб-ресурсов.

+

Пользователь осознает и соглашается, что:

+
    +
  • Автор не несет ответственности за сбои в игровом клиенте Minecraft, потерю игрового прогресса, повреждение файлов сохранений или несовместимость с другими модификациями.
  • +
  • Владельцы сайта не гарантируют 100% бесперебойную доступность серверов API и ведения транзакций.
  • +
  • Пользователь добровольно отказывается от любых финансовых, юридических, моральных или иных материальных претензий к авторам и администрации в случае технических сбоев.
  • +
+
+ + +
+

Сбор данных и статистики

+

Для корректного функционирования игровых транзакций, выписок, защиты от мошенничества и ведения честной игровой статистики, сайт и модификация SPMega осуществляют автоматический сбор технических параметров.

+

Мы собираем и обрабатываем:

+
    +
  • Ваш уникальный идентификатор Minecraft (UUID) и текущий никнейм.
  • +
  • Сведения о версии игры, версии Java и используемой версии модификации.
  • +
  • Ваш текущий IP-адрес для предотвращения мультиаккаунтинга и выявления попыток взлома API.
  • +
  • Техническую информацию о транзакциях (суммы, комментарии, номера виртуальных счетов/карт и идентификаторы).
  • +
+
+ + +
+

Конфиденциальность и безопасность данных

+

Мы высоко ценим доверие наших пользователей и гарантируем сохранность собираемой информации. Все личные данные передаются и обрабатываются по защищенным зашифрованным каналам связи (HTTPS/SSL).

+

Администрация сайта строго обязуется:

+
    +
  • Не передавать, не продавать и не распространять ваши личные данные, IP-адреса и статистику третьим лицам.
  • +
  • Использовать собранную анонимизированную статистику исключительно во внутренних аналитических целях для улучшения производительности мода.
  • +
  • Раскрывать информацию только в случаях, прямо предусмотренных действующим законодательством при наличии официальных судебных или правоохранительных запросов.
  • +
+
+ + +
+

Изменение условий соглашения

+

Администрация оставляет за собой право в одностороннем порядке вносить изменения в настоящее Пользовательское соглашение без предварительного индивидуального уведомления пользователей.

+

Новая редакция Соглашения вступает в силу с момента ее публикации на данной веб-странице. Мы рекомендуем периодически проверять условия соглашения, чтобы оставаться в курсе актуальных правил использования.

+
+
+
+
+ + + + + + + + + diff --git a/backend/SpMega.Backend/Pages/v1/BillPage.cshtml b/backend/SpMega.Backend/Pages/v1/BillPage.cshtml new file mode 100644 index 0000000..c8bf4b5 --- /dev/null +++ b/backend/SpMega.Backend/Pages/v1/BillPage.cshtml @@ -0,0 +1,688 @@ +@page "/{id?}" +@model SpMega.Backend.Pages.v1.BillPage + +@{ + Layout = null; +} + + + + + + + Чек транзакции — SPMega + + + + + + + + + + +
+ +
+ +
+ +
+
+
Получение данных чека...
+
+
+ + +
+ + +
+ + + + Ссылка скопирована в буфер обмена +
+ + + + \ No newline at end of file diff --git a/backend/SpMega.Backend/Pages/v1/BillPage.cshtml.cs b/backend/SpMega.Backend/Pages/v1/BillPage.cshtml.cs new file mode 100644 index 0000000..fbe3420 --- /dev/null +++ b/backend/SpMega.Backend/Pages/v1/BillPage.cshtml.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace SpMega.Backend.Pages.v1; + +public class BillPage : PageModel +{ + public void OnGet() + { + + } +} \ No newline at end of file diff --git a/backend/SpMega.Backend/Persistent/Database/AppDbContext.cs b/backend/SpMega.Backend/Persistent/Database/AppDbContext.cs new file mode 100644 index 0000000..0456502 --- /dev/null +++ b/backend/SpMega.Backend/Persistent/Database/AppDbContext.cs @@ -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 options) : DbContext(options) +{ + internal DbSet Transactions { get; set; } + internal DbSet Users { get; set; } + internal DbSet UserSessions { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + + } +} \ No newline at end of file diff --git a/backend/SpMega.Backend/Persistent/Models/DTO/MojangDTO.cs b/backend/SpMega.Backend/Persistent/Models/DTO/MojangDTO.cs new file mode 100644 index 0000000..fbad1cf --- /dev/null +++ b/backend/SpMega.Backend/Persistent/Models/DTO/MojangDTO.cs @@ -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 Properties { get; set; } +} \ No newline at end of file diff --git a/backend/SpMega.Backend/Persistent/Models/DTO/UserAccountDTO.cs b/backend/SpMega.Backend/Persistent/Models/DTO/UserAccountDTO.cs new file mode 100644 index 0000000..f2f3350 --- /dev/null +++ b/backend/SpMega.Backend/Persistent/Models/DTO/UserAccountDTO.cs @@ -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 roles { get; set; } + public ICity city { get; set; } + public List cards { get; set; } + public DateTime createdAt { get; set; } +} diff --git a/backend/SpMega.Backend/Persistent/Models/Transactions/Transaction.cs b/backend/SpMega.Backend/Persistent/Models/Transactions/Transaction.cs new file mode 100644 index 0000000..b58581d --- /dev/null +++ b/backend/SpMega.Backend/Persistent/Models/Transactions/Transaction.cs @@ -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; +} \ No newline at end of file diff --git a/backend/SpMega.Backend/Persistent/Models/Users/Card.cs b/backend/SpMega.Backend/Persistent/Models/Users/Card.cs new file mode 100644 index 0000000..444599c --- /dev/null +++ b/backend/SpMega.Backend/Persistent/Models/Users/Card.cs @@ -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; +} \ No newline at end of file diff --git a/backend/SpMega.Backend/Persistent/Models/Users/User.cs b/backend/SpMega.Backend/Persistent/Models/Users/User.cs new file mode 100644 index 0000000..633f5a5 --- /dev/null +++ b/backend/SpMega.Backend/Persistent/Models/Users/User.cs @@ -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 Cards { get; set; } = []; + public List 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; +} \ No newline at end of file diff --git a/backend/SpMega.Backend/Persistent/Models/Users/UserSession.cs b/backend/SpMega.Backend/Persistent/Models/Users/UserSession.cs new file mode 100644 index 0000000..e881008 --- /dev/null +++ b/backend/SpMega.Backend/Persistent/Models/Users/UserSession.cs @@ -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; +} \ No newline at end of file diff --git a/backend/SpMega.Backend/Program.cs b/backend/SpMega.Backend/Program.cs new file mode 100644 index 0000000..e7cf30b --- /dev/null +++ b/backend/SpMega.Backend/Program.cs @@ -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() + .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.DefaultScheme, + options => { }); + + builder.Services.AddAuthorizationBuilder() + .SetDefaultPolicy(new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build()); + + var serviceCollection = new ServiceCollection(); + var mongoClient = + new MongoClient(builder.Configuration.GetValue("Mongo") ?? "mongodb://curiosity:27018"); + serviceCollection.AddSingleton(mongoClient); + + builder.Services.AddDbContext((_, 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(); + } +} \ No newline at end of file diff --git a/src/backend/SpMega.Backend/Properties/launchSettings.json b/backend/SpMega.Backend/Properties/launchSettings.json similarity index 100% rename from src/backend/SpMega.Backend/Properties/launchSettings.json rename to backend/SpMega.Backend/Properties/launchSettings.json diff --git a/backend/SpMega.Backend/Services/TokenService.cs b/backend/SpMega.Backend/Services/TokenService.cs new file mode 100644 index 0000000..2788c00 --- /dev/null +++ b/backend/SpMega.Backend/Services/TokenService.cs @@ -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 + { + 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); + } +} \ No newline at end of file diff --git a/backend/SpMega.Backend/SpMega.Backend.csproj b/backend/SpMega.Backend/SpMega.Backend.csproj new file mode 100644 index 0000000..ec99eea --- /dev/null +++ b/backend/SpMega.Backend/SpMega.Backend.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + Linux + + + + + .dockerignore + + + + + + + + + + + + + + + + + diff --git a/src/backend/backend.sln b/backend/backend.sln similarity index 100% rename from src/backend/backend.sln rename to backend/backend.sln diff --git a/src/backend/compose.yaml b/backend/compose.yaml similarity index 100% rename from src/backend/compose.yaml rename to backend/compose.yaml diff --git a/build.gradle b/build.gradle index 7b3637a..1d6a84c 100644 --- a/build.gradle +++ b/build.gradle @@ -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}")) diff --git a/gradle.properties b/gradle.properties index f3db217..d9ded40 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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.3-pre-alpha +mod_version=0.4-pre-alpha maven_group=git.yawaflua.tech archives_base_name=SPMega # Dependencies diff --git a/src/backend/SpMega.Backend/Program.cs b/src/backend/SpMega.Backend/Program.cs deleted file mode 100644 index 4f8b706..0000000 --- a/src/backend/SpMega.Backend/Program.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace SpMega.Backend; - -public class Program -{ - public static void Main(string[] args) - { - var builder = WebApplication.CreateBuilder(args); - - // Add services to the container. - builder.Services.AddAuthorization(); - - - var app = builder.Build(); - - // Configure the HTTP request pipeline. - - app.UseAuthorization(); - - var summaries = new[] - { - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - }; - - app.MapGet("/weatherforecast", (HttpContext httpContext) => - { - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - { - Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - TemperatureC = Random.Shared.Next(-20, 55), - Summary = summaries[Random.Shared.Next(summaries.Length)] - }) - .ToArray(); - return forecast; - }); - - app.Run(); - } -} \ No newline at end of file diff --git a/src/backend/SpMega.Backend/SpMega.Backend.csproj b/src/backend/SpMega.Backend/SpMega.Backend.csproj deleted file mode 100644 index 42e686b..0000000 --- a/src/backend/SpMega.Backend/SpMega.Backend.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - - net10.0 - enable - enable - Linux - - - - - .dockerignore - - - - diff --git a/src/backend/SpMega.Backend/SpMega.Backend.http b/src/backend/SpMega.Backend/SpMega.Backend.http deleted file mode 100644 index fc6226b..0000000 --- a/src/backend/SpMega.Backend/SpMega.Backend.http +++ /dev/null @@ -1,6 +0,0 @@ -@SpMega.Backend_HostAddress = http://localhost:5129 - -GET {{SpMega.Backend_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/src/backend/SpMega.Backend/WeatherForecast.cs b/src/backend/SpMega.Backend/WeatherForecast.cs deleted file mode 100644 index e9d246d..0000000 --- a/src/backend/SpMega.Backend/WeatherForecast.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace SpMega.Backend; - -public class WeatherForecast -{ - public DateOnly Date { get; set; } - - public int TemperatureC { get; set; } - - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - - public string? Summary { get; set; } -} \ No newline at end of file diff --git a/src/backend/SpMega.Backend/appsettings.Development.json b/src/backend/SpMega.Backend/appsettings.Development.json deleted file mode 100644 index 0c208ae..0000000 --- a/src/backend/SpMega.Backend/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/src/backend/SpMega.Backend/appsettings.json b/src/backend/SpMega.Backend/appsettings.json deleted file mode 100644 index 10f68b8..0000000 --- a/src/backend/SpMega.Backend/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ModMenuIntegration.java b/src/client/java/git/yawaflua/tech/spmega/client/ModMenuIntegration.java new file mode 100644 index 0000000..b20a371 --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/client/ModMenuIntegration.java @@ -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(); + }; + } +} diff --git a/src/client/java/git/yawaflua/tech/spmega/client/SPMegaClient.java b/src/client/java/git/yawaflua/tech/spmega/client/SPMegaClient.java index a023d27..4a5e3ea 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/SPMegaClient.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/SPMegaClient.java @@ -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("(? { 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.afterRender(screen).register((screenInstance, drawContext, mouseX, mouseY, tickDelta) -> { - if (client.textRenderer == null) { - return; - } - UiNotifications.instance().render(drawContext, client.textRenderer, 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) -> { @@ -169,5 +238,8 @@ 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!"); } } diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/CardScreen.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/CardScreen.java index a0cd6b4..ad2083d 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/ui/CardScreen.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/CardScreen.java @@ -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 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 -> { - bankUiService.removeSelectedCard(); + 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() : ""; - bankUiService.refreshSelectedCard(playerUuid); + 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(); + } } } diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/GpsHudRenderer.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/GpsHudRenderer.java new file mode 100644 index 0000000..fdc9348 --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/GpsHudRenderer.java @@ -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 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 response = client.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() == 200) { + TerritoryWrapper[] parsed = new Gson().fromJson(response.body(), TerritoryWrapper[].class); + if (parsed != null) { + List 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; + } + } + } + } +} diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/TransactionHistoryScreen.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/TransactionHistoryScreen.java new file mode 100644 index 0000000..bddf26f --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/TransactionHistoryScreen.java @@ -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 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 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 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; + } + } + } +} diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BackendAuthenticator.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BackendAuthenticator.java new file mode 100644 index 0000000..16df42b --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BackendAuthenticator.java @@ -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 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 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 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 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 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 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 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 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 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; + } +} diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankDatabase.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankDatabase.java index c26cd34..c325474 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankDatabase.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankDatabase.java @@ -166,6 +166,31 @@ public final class BankDatabase { } } + public synchronized List loadTransferHistory(String cardId) { + String sql = "SELECT receiver, amount, comment, status, created_at FROM transfer_history WHERE sender_card_id = ? ORDER BY id DESC"; + List 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); } diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankUiService.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankUiService.java index 1b060e5..835d7d5 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankUiService.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankUiService.java @@ -1,5 +1,7 @@ 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; @@ -92,7 +94,28 @@ public final class BankUiService { return lastMessage; } + public synchronized void syncCardsWithBackend(String playerUuid) { + try { + List 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 storedCards = database.loadCards(); for (StoredCard card : storedCards) { refreshCard(card.cardId(), card.cardToken(), playerUuid, false, false); @@ -158,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); @@ -190,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 { - SPWorldsApiClient.TransactionResult result = apiClient.createTransaction( - toApiAuth(credentials), - draft.recipient(), - draft.amount(), - draft.comment() - ); + 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" ); @@ -264,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; @@ -300,4 +361,8 @@ public final class BankUiService { } return database.getCredentials(selected.id()); } + + public BankDatabase getDatabase() { + return database; + } } diff --git a/src/client/resources/assets/spmega/lang/en_us.json b/src/client/resources/assets/spmega/lang/en_us.json index ab60fd2..4e4e442 100644 --- a/src/client/resources/assets/spmega/lang/en_us.json +++ b/src/client/resources/assets/spmega/lang/en_us.json @@ -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" } + + diff --git a/src/client/resources/assets/spmega/lang/ru_ru.json b/src/client/resources/assets/spmega/lang/ru_ru.json new file mode 100644 index 0000000..df8caa1 --- /dev/null +++ b/src/client/resources/assets/spmega/lang/ru_ru.json @@ -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": "Снизу справа" +} + diff --git a/src/main/java/git/yawaflua/tech/spmega/ConfigManager.java b/src/main/java/git/yawaflua/tech/spmega/ConfigManager.java index 2cc5605..c5b7f74 100644 --- a/src/main/java/git/yawaflua/tech/spmega/ConfigManager.java +++ b/src/main/java/git/yawaflua/tech/spmega/ConfigManager.java @@ -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 readEnum(Properties properties, String key, Class 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; + } + } } diff --git a/src/main/java/git/yawaflua/tech/spmega/GpsHudPosition.java b/src/main/java/git/yawaflua/tech/spmega/GpsHudPosition.java new file mode 100644 index 0000000..e19b6d2 --- /dev/null +++ b/src/main/java/git/yawaflua/tech/spmega/GpsHudPosition.java @@ -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; + } +} diff --git a/src/main/java/git/yawaflua/tech/spmega/ModConfig.java b/src/main/java/git/yawaflua/tech/spmega/ModConfig.java index 338e7d0..05595f8 100644 --- a/src/main/java/git/yawaflua/tech/spmega/ModConfig.java +++ b/src/main/java/git/yawaflua/tech/spmega/ModConfig.java @@ -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 ); } } + diff --git a/src/main/java/git/yawaflua/tech/spmega/SPMega.java b/src/main/java/git/yawaflua/tech/spmega/SPMega.java index 3415d42..b3b87bc 100644 --- a/src/main/java/git/yawaflua/tech/spmega/SPMega.java +++ b/src/main/java/git/yawaflua/tech/spmega/SPMega.java @@ -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(); diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 2beddca..e475690 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -8,6 +8,7 @@ "Dmitri 'yawaflua' Shimanski aka DOLBAYEB" ], "contact": { + }, "license": "CC BY-NC-ND 4.0", "icon": "icon.png", @@ -19,6 +20,9 @@ "client": [ "git.yawaflua.tech.spmega.client.SPMegaClient" ], + "modmenu": [ + "git.yawaflua.tech.spmega.client.ModMenuIntegration" + ], "main": [ "git.yawaflua.tech.spmega.SPMega" ]