Add webhook support for card transactions and notifications.
Java CI / build (push) Successful in 50s
.NET+Docker CI/CD / Unit and Integration tests (push) Successful in 33s
.NET+Docker CI/CD / Push Docker image to ghcr.io (push) Failing after 30s

Some optimizations and fixes

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

Took 2 hours 4 minutes
This commit is contained in:
Dmitrii
2026-07-12 06:27:28 +03:00
parent 0e53d7dd8c
commit f19a5d3586
37 changed files with 1155 additions and 950 deletions
@@ -71,13 +71,35 @@ public class AuthController(AppDbContext dbContext, TokenService tokenService, I
var dto = await resp.Content.ReadFromJsonAsync<MojangDto>();
if (dto == null || dto.Name != session.UserName || Guid.Parse(dto.Id) != session.UserId) throw new Exception("Session expired, or dto is not acceptable.");
var token = tokenService.GenerateAccessToken(session.UserName, body.userUUID);
var user = await dbContext.Users.FirstOrDefaultAsync(k => k.Id == session.UserId);
var shortId = string.Empty;
if (user?.ShortId == null)
{
shortId = Program.GenerateRandomString(2);
while (true)
{
var a = await dbContext.Users.FirstOrDefaultAsync(k => k.ShortId == shortId);
if (a != null)
{
shortId = Program.GenerateRandomString(2);
}
else
{
break;
}
}
}
if (user != null)
{
user.Token = token;
user.Username = session.UserName;
user.UpdatedAt = DateTime.UtcNow;
user.ShortId ??= shortId;
dbContext.Update(user);
}
@@ -88,7 +110,8 @@ public class AuthController(AppDbContext dbContext, TokenService tokenService, I
Id = body.userUUID,
Username = session.UserName,
Token = token,
UpdatedAt = DateTime.UtcNow
UpdatedAt = DateTime.UtcNow,
ShortId = shortId,
};
await dbContext.AddAsync(user);
}
@@ -14,7 +14,7 @@ public class TelemetryController : ControllerBase
private static readonly ActivitySource Source = new("SpMega.ModTelemetry");
[HttpPost]
[Authorize]
[AllowAnonymous]
public IActionResult Post([FromBody] ModTelemetryBatchDto batch)
{
if (batch?.Events == null || batch.Events.Count == 0)
@@ -28,7 +28,7 @@ public class TelemetryController : ControllerBase
foreach (var e in batch.Events)
{
using var activity = Source.StartActivity("mod.telemetry.event", ActivityKind.Internal);
using var activity = Source.StartActivity("mod.telemetry.event", ActivityKind.Client);
if (activity is null) continue;
activity.SetTag("user.id", userId);
@@ -38,6 +38,7 @@ public class TelemetryController : ControllerBase
activity.SetTag("event.timestamp", e.Timestamp.ToString("O"));
activity.SetTag("event.payload", JsonSerializer.Serialize(e.Payload));
activity.SetTag("batch.sent_at", batch.SentAt.ToString("O"));
}
return Ok(new { received = batch.Events.Count });
@@ -37,12 +37,7 @@ public class TransactionsController(AppDbContext context, ILogger<TransactionsCo
ShortId = "00000000",
ReceiverName = "yawaflua",
ReceiverCardNumber = "00000",
Sender = new User
{
Id = default,
Username = "yawaflua",
Token = "123",
},
SenderMinecraftName = "yawaflua",
SenderCardNumber = "010101",
Amount = 42,
Comment = "API FIRST",
@@ -76,13 +71,13 @@ public class TransactionsController(AppDbContext context, ILogger<TransactionsCo
}
var shortId = Program.GenerateRandomString(5);
var shortId = Program.GenerateRandomString(2);
while (true)
{
var a = await context.Transactions.FirstOrDefaultAsync(k => k.ShortId == shortId);
var a = await context.Transactions.FirstOrDefaultAsync(k => k.ShortId == shortId && EF.Property<Guid>(k, "SenderId") == user.Id);
if (a != null)
{
shortId = Program.GenerateRandomString(5);
shortId = Program.GenerateRandomString(2);
}
else
{
@@ -92,24 +87,23 @@ public class TransactionsController(AppDbContext context, ILogger<TransactionsCo
var transaction = new Transaction
{
ReceiverName = body.receiverName,
ShortId = shortId,
ShortId = $"{user.ShortId}{shortId}",
ReceiverCardNumber = body.receiverCard,
Sender = user,
SenderCardNumber = body.cardId,
SenderCardNumber = cardToUse.SpworldsID,
SenderMinecraftName = user.Username,
Amount = body.amount,
Comment = body.comment,
};
try
{
var uri = "s.ywfl.dev" + "/" + shortId;
var uri = "ywfl.dev" + "/s" + shortId;
var transitionInfo = new Dictionary<string, object>
{
{ "receiver", body.receiverCard },
{ "amount", body.amount },
{ "comment", (body.comment)[8..] + "..;Чек:"+ uri }
{ "comment", (body.comment)[10..] + "..;Чек:"+ uri }
};
Console.WriteLine(((body.comment)[8..] + "..;Чек:"+ uri).Length);
Console.WriteLine(((body.comment)[8..] + "..;Чек:"+ uri));
var resp = await SendRequest(endpoint: "transactions", body: transitionInfo, AuthHeader: new("Bearer", cardToUse.Token));
var balance = (int?)JsonNode.Parse(resp)?["balance"];
if (balance == null)
@@ -131,6 +125,12 @@ public class TransactionsController(AppDbContext context, ILogger<TransactionsCo
{
Id = transaction.Id,
ReceiverName = transaction.ReceiverName,
ReceiverCardNumber = transaction.ReceiverCardNumber,
SenderMinecraftName = transaction.SenderMinecraftName,
SenderCardNumber = transaction.SenderCardNumber,
Amount = transaction.Amount,
Comment = transaction.Comment,
TransactionDate = transaction.TransactionDate
});
}
@@ -0,0 +1,195 @@
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Driver.Linq;
using SpMega.Backend.Persistent.Database;
using SpMega.Backend.Persistent.Models.Transactions;
using SpMega.Backend.Persistent.Models.Users;
namespace SpMega.Backend.Controllers.v1;
[ApiController]
[Route("/api/v1/webhook")]
public class WebhookController( AppDbContext dbContext, ILogger<WebhookController> logger, IConfiguration config) : ControllerBase
{
private const string BASE_URL = "https://spworlds.ru/api/public/";
[HttpPut("{cardId}")]
[Authorize]
public async Task<IActionResult> RegisterWebhookForCard(Guid cardId)
{
var user = HttpContext.Items["@me"] as User;
if (user == null) return Unauthorized();
var cardToUse = user.Cards.FirstOrDefault(c => c.Id == cardId);
if (cardToUse == null)
return BadRequest("Card not found");
var shortId = Program.GenerateRandomString(6);
while (true)
{
var a = user.Cards.FirstOrDefault(k => k.ShortId == shortId);
if (a != null)
{
shortId = Program.GenerateRandomString(6);
}
else
{
break;
}
}
cardToUse.ShortId = shortId;
cardToUse.WebhookConnected = true;
await dbContext.SaveChangesAsync();
var webhookUrl = new UriBuilder();
webhookUrl.Scheme = "https";
webhookUrl.Host = config["Url"];
webhookUrl.Path = $"api/v1/webhook/{user.ShortId}/{cardToUse.ShortId}/{cardId}";
var resp = await SendRequest($"card/webhook", new AuthenticationHeaderValue("Bearer", cardToUse.Token), HttpMethod.Put, new { url = webhookUrl.ToString() });
var jsonResponse = JsonSerializer.Deserialize<JsonObject>(resp);
if (jsonResponse != null && jsonResponse.TryGetPropertyValue("id", out var respId)
&& respId?.GetValue<Guid>() == cardId) return Ok();
logger.LogError("Failed to register webhook for card {CardId}. Response: {Response}", cardId, resp);
cardToUse.WebhookConnected = false;
await dbContext.SaveChangesAsync();
return BadRequest("Failed to register webhook for card");
}
[HttpPost("{userShortId}/{cardShortId}/{cardId}")]
public async Task<IActionResult> ReceiveWebhook(string userShortId, string cardShortId, Guid cardId)
{
var user = await dbContext.Users.FirstOrDefaultAsync(k => k.ShortId == userShortId);
if (user == null)
return BadRequest("User not found");
var cardToUse = user.Cards.FirstOrDefault(c => c.ShortId == cardShortId && c.Id == cardId && c.WebhookConnected);
if (cardToUse == null)
return BadRequest("Card not found");
var bodyHash = Request.Headers["X-Body-Hash"].ToString();
var rawBody = await new StreamReader(Request.Body).ReadToEndAsync();
var keyBytes = Encoding.UTF8.GetBytes(cardToUse.Token);
var messageBytes = Encoding.UTF8.GetBytes(rawBody);
using var hmac = new HMACSHA256(keyBytes);
var hashBytes = hmac.ComputeHash(messageBytes);
byte[] receivedHash;
try
{
receivedHash = Convert.FromBase64String(bodyHash);
}
catch (FormatException)
{
return BadRequest("Invalid body hash");
}
if (!CryptographicOperations.FixedTimeEquals(hashBytes, receivedHash))
{
logger.LogError("Invalid body hash for webhook. Expected: {ExpectedHash}, Received: {ReceivedHash}", Convert.ToBase64String(hashBytes), bodyHash);
return BadRequest("Invalid body hash");
}
var body = JsonSerializer.Deserialize<Webhook>(rawBody, new JsonSerializerOptions(JsonSerializerDefaults.Web));
if (body == null || !Guid.TryParse(body.Id, out var notificationId))
return BadRequest("Invalid webhook body");
if (await dbContext.Notifications.FirstOrDefaultAsync(notification => notification.Id == notificationId) != null)
return Ok();
var notify = new Notification
{
Id = notificationId,
ReceiverId = user.Id,
ReceiverName = body.Receiver.Username,
ReceiverNumber = body.Receiver.Number,
SenderName = body.Sender.Username,
SenderNumber = body.Sender.Number,
Comment = body.Comment,
Amount = body.Amount,
Type = body.Type,
IsRead = false,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
await dbContext.Notifications.AddAsync(notify);
await dbContext.SaveChangesAsync();
return Ok();
}
[HttpGet("read")]
[Authorize]
public async Task<ActionResult<List<Notification>>> ReadAllNotifications()
{
var user = HttpContext.Items["@me"] as User;
if (user == null) return Unauthorized();
var notifications = await dbContext.Notifications
.Where(notification => notification.ReceiverId == user.Id && !notification.IsRead)
.ToListAsync();
foreach (var notification in notifications)
{
notification.IsRead = true;
notification.UpdatedAt = DateTime.UtcNow;
}
if (notifications.Count > 0) await dbContext.SaveChangesAsync();
return Ok(notifications);
}
[HttpGet("all")]
[Authorize]
public async Task<ActionResult<List<Notification>>> GetAllNotifications([FromQuery] int after, [FromQuery] int limit = 20)
{
var user = HttpContext.Items["@me"] as User;
if (user == null) return Unauthorized();
var notification = await dbContext.Notifications.Where(k => k.ReceiverId == user.Id).Skip(after).Take(limit).ToListAsync();
return Ok(notification);
}
[NonAction]
internal async Task<string> SendRequest(string endpoint, AuthenticationHeaderValue AuthHeader, HttpMethod method = null, object body = null)
{
method ??= body == null ? HttpMethod.Get : HttpMethod.Post;
HttpResponseMessage message;
var client = new System.Net.Http.HttpClient();
using (var requestMessage = new HttpRequestMessage(method, BASE_URL + endpoint))
{
requestMessage.Content = new StringContent(
JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json"
);
requestMessage.Headers.Authorization = AuthHeader;
message = await client.SendAsync(requestMessage);
}
client.Dispose();
return await message.Content.ReadAsStringAsync();
}
}
public record Webhook(
string Id,
int Amount,
string Type,
WebhookUser Sender,
WebhookUser Receiver,
string Comment,
string CreatedAt
);
public record WebhookUser(string Username, string Number);