25 Commits

Author SHA1 Message Date
a94102e136 👔 Change lottery rewards 2025-10-25 00:29:56 +08:00
fc693793fe 🐛 Fixes of lotteries and enrich features 2025-10-25 00:17:56 +08:00
8cfdabbae4 ♻️ Check in algorithm v2 2025-10-24 21:51:14 +08:00
985ff41c72 📝 Document the lottery 2025-10-24 21:40:50 +08:00
a79ea4ac49 🐛 Fix lottery 2025-10-24 21:40:40 +08:00
7385caff9a Lotteries 2025-10-24 01:34:18 +08:00
15954dbfe2 Providing the post featured record in the response 2025-10-24 00:51:30 +08:00
4ba6206c9d 🛂 Stricter post visibility check 2025-10-24 00:02:27 +08:00
266b9e36e2 🗃️ Update schema to clean up unused code 2025-10-23 01:01:19 +08:00
e6aa61b03b 🐛 Bug fixes in the Sphere still referencing the old realm db 2025-10-22 23:31:42 +08:00
0c09ef25ec ⬆️ Upgrade dependencies in order to prevent CVE-2025-55315 2025-10-22 22:58:52 +08:00
dd5929c691 💥 Moved the /id to /pass and bug fixes of moved realms 2025-10-22 22:52:09 +08:00
cf87fdfb49 🗑️ Remove per service rate-limiting due to gateway covered it 2025-10-22 22:10:37 +08:00
ff03584518 🐛 Fix some issues in moving realm service 2025-10-22 21:56:50 +08:00
d6c37784e1 ♻️ Move the realm service from sphere to the pass 2025-10-21 23:45:36 +08:00
46ebd92dc1 ♻️ Refactored the chat mention logic 2025-10-17 00:46:55 +08:00
7f8521bb40 👔 Optimize subscriptions logic 2025-10-16 13:13:08 +08:00
f01226d91a 🐛 Fix post controller return incomplete structure 2025-10-13 23:11:35 +08:00
6cb6dee6be 🐛 Remove project Sphere dict key snake case convert to fix reaction counts 2025-10-13 01:19:51 +08:00
0e9caf67ff 🐛 username color hotfix 2025-10-13 01:16:35 +08:00
ca70bb5487 🐛 Fix missing username color in proto profile 2025-10-13 01:08:48 +08:00
59ed135f20 Load account info in reaction list API 2025-10-12 21:57:37 +08:00
6077f91529 Sticker search 2025-10-12 21:46:45 +08:00
5c485bb1c3 🐛 Fix autocomplete again 2025-10-12 19:30:46 +08:00
27d979d77b 🐛 Fix sticker auto complete 2025-10-12 19:21:00 +08:00
81 changed files with 17727 additions and 1099 deletions

View File

@@ -9,7 +9,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7"/> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -18,7 +18,7 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/> <PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
<PackageReference Include="NodaTime" Version="3.2.2"/> <PackageReference Include="NodaTime" Version="3.2.2"/>
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/>
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0"/> <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0"/>

View File

@@ -19,7 +19,7 @@ public class BotAccountController(
DeveloperService ds, DeveloperService ds,
DevProjectService projectService, DevProjectService projectService,
ILogger<BotAccountController> logger, ILogger<BotAccountController> logger,
AccountClientHelper accounts, RemoteAccountService remoteAccounts,
BotAccountReceiverService.BotAccountReceiverServiceClient accountsReceiver BotAccountReceiverService.BotAccountReceiverServiceClient accountsReceiver
) )
: ControllerBase : ControllerBase
@@ -222,7 +222,7 @@ public class BotAccountController(
if (bot is null || bot.ProjectId != projectId) if (bot is null || bot.ProjectId != projectId)
return NotFound("Bot not found"); return NotFound("Bot not found");
var botAccount = await accounts.GetBotAccount(bot.Id); var botAccount = await remoteAccounts.GetBotAccount(bot.Id);
if (request.Name is not null) botAccount.Name = request.Name; if (request.Name is not null) botAccount.Name = request.Name;
if (request.Nick is not null) botAccount.Nick = request.Nick; if (request.Nick is not null) botAccount.Nick = request.Nick;

View File

@@ -10,7 +10,7 @@ namespace DysonNetwork.Develop.Identity;
public class BotAccountService( public class BotAccountService(
AppDatabase db, AppDatabase db,
BotAccountReceiverService.BotAccountReceiverServiceClient accountReceiver, BotAccountReceiverService.BotAccountReceiverServiceClient accountReceiver,
AccountClientHelper accounts RemoteAccountService remoteAccounts
) )
{ {
public async Task<SnBotAccount?> GetBotByIdAsync(Guid id) public async Task<SnBotAccount?> GetBotByIdAsync(Guid id)
@@ -158,7 +158,7 @@ public class BotAccountService(
public async Task<List<SnBotAccount>> LoadBotsAccountAsync(List<SnBotAccount> bots) public async Task<List<SnBotAccount>> LoadBotsAccountAsync(List<SnBotAccount> bots)
{ {
var automatedIds = bots.Select(b => b.Id).ToList(); var automatedIds = bots.Select(b => b.Id).ToList();
var data = await accounts.GetBotAccountBatch(automatedIds); var data = await remoteAccounts.GetBotAccountBatch(automatedIds);
foreach (var bot in bots) foreach (var bot in bots)
{ {

View File

@@ -35,6 +35,6 @@ using (var scope = app.Services.CreateScope())
app.ConfigureAppMiddleware(builder.Configuration); app.ConfigureAppMiddleware(builder.Configuration);
app.UseSwaggerManifest(); app.UseSwaggerManifest("DysonNetwork.Develop");
app.Run(); app.Run();

View File

@@ -12,7 +12,7 @@
<PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" /> <PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" />
<PackageReference Include="FFMpegCore" Version="5.2.0" /> <PackageReference Include="FFMpegCore" Version="5.2.0" />
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" /> <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
@@ -40,7 +40,7 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" /> <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" /> <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" /> <PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
@@ -56,8 +56,8 @@
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.9" /> <PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.9" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.9" /> <PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.9" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" /> <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.6" />
<PackageReference Include="tusdotnet" Version="2.10.0" /> <PackageReference Include="tusdotnet" Version="2.10.0" />
</ItemGroup> </ItemGroup>

View File

@@ -16,7 +16,6 @@ builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxV
// Add application services // Add application services
builder.Services.AddAppServices(builder.Configuration); builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppRateLimiting();
builder.Services.AddAppAuthentication(); builder.Services.AddAppAuthentication();
builder.Services.AddDysonAuth(); builder.Services.AddDysonAuth();
builder.Services.AddAccountService(); builder.Services.AddAccountService();
@@ -49,6 +48,6 @@ app.ConfigureAppMiddleware(tusDiskStore);
// Configure gRPC // Configure gRPC
app.ConfigureGrpcServices(); app.ConfigureGrpcServices();
app.UseSwaggerManifest(); app.UseSwaggerManifest("DysonNetwork.Drive");
app.Run(); app.Run();

View File

@@ -43,19 +43,6 @@ public static class ServiceCollectionExtensions
return services; return services;
} }
public static IServiceCollection AddAppRateLimiting(this IServiceCollection services)
{
services.AddRateLimiter(o => o.AddFixedWindowLimiter(policyName: "fixed", opts =>
{
opts.Window = TimeSpan.FromMinutes(1);
opts.PermitLimit = 120;
opts.QueueLimit = 2;
opts.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
}));
return services;
}
public static IServiceCollection AddAppAuthentication(this IServiceCollection services) public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
{ {
services.AddAuthorization(); services.AddAuthorization();

View File

@@ -90,7 +90,6 @@ var apiRoutes = serviceNames.Select(serviceName =>
{ {
var apiPath = serviceName switch var apiPath = serviceName switch
{ {
"pass" => "/id",
_ => $"/{serviceName}" _ => $"/{serviceName}"
}; };
return new RouteConfig return new RouteConfig
@@ -162,8 +161,6 @@ app.UseForwardedHeaders(forwardedHeadersOptions);
app.UseCors(); app.UseCors();
app.UseRateLimiter();
app.MapReverseProxy().RequireRateLimiting("fixed"); app.MapReverseProxy().RequireRateLimiting("fixed");
app.MapControllers(); app.MapControllers();

View File

@@ -80,7 +80,7 @@ public class AccountCurrentController(
[MaxLength(1024)] public string? TimeZone { get; set; } [MaxLength(1024)] public string? TimeZone { get; set; }
[MaxLength(1024)] public string? Location { get; set; } [MaxLength(1024)] public string? Location { get; set; }
[MaxLength(4096)] public string? Bio { get; set; } [MaxLength(4096)] public string? Bio { get; set; }
public UsernameColor? UsernameColor { get; set; } public Shared.Models.UsernameColor? UsernameColor { get; set; }
public Instant? Birthday { get; set; } public Instant? Birthday { get; set; }
public List<ProfileLink>? Links { get; set; } public List<ProfileLink>? Links { get; set; }

View File

@@ -271,7 +271,7 @@ public class AccountEventService(
return backdatedCheckInMonths < 4; return backdatedCheckInMonths < 4;
} }
public const string CheckInLockKey = "checkin:lock:"; private const string CheckInLockKey = "checkin:lock:";
public async Task<SnCheckInResult> CheckInDaily(SnAccount user, Instant? backdated = null) public async Task<SnCheckInResult> CheckInDaily(SnAccount user, Instant? backdated = null)
{ {
@@ -322,7 +322,11 @@ public class AccountEventService(
})); }));
// The 5 is specialized, keep it alone. // The 5 is specialized, keep it alone.
var checkInLevel = (CheckInResultLevel)Random.Next(Enum.GetValues<CheckInResultLevel>().Length - 1); var sum = 0;
var maxLevel = Enum.GetValues<CheckInResultLevel>().Length - 1;
for (var i = 0; i < 5; i++)
sum += Random.Next(maxLevel);
var checkInLevel = (CheckInResultLevel)(sum / 5);
var accountBirthday = await db.AccountProfiles var accountBirthday = await db.AccountProfiles
.Where(x => x.AccountId == user.Id) .Where(x => x.AccountId == user.Id)

View File

@@ -12,13 +12,11 @@ public class AccountServiceGrpc(
AccountEventService accountEvents, AccountEventService accountEvents,
RelationshipService relationships, RelationshipService relationships,
SubscriptionService subscriptions, SubscriptionService subscriptions,
IClock clock,
ILogger<AccountServiceGrpc> logger ILogger<AccountServiceGrpc> logger
) )
: Shared.Proto.AccountService.AccountServiceBase : Shared.Proto.AccountService.AccountServiceBase
{ {
private readonly AppDatabase _db = db ?? throw new ArgumentNullException(nameof(db)); private readonly AppDatabase _db = db ?? throw new ArgumentNullException(nameof(db));
private readonly IClock _clock = clock ?? throw new ArgumentNullException(nameof(clock));
private readonly ILogger<AccountServiceGrpc> private readonly ILogger<AccountServiceGrpc>
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
@@ -266,7 +264,7 @@ public class AccountServiceGrpc(
public override async Task<BoolValue> HasRelationship(GetRelationshipRequest request, ServerCallContext context) public override async Task<BoolValue> HasRelationship(GetRelationshipRequest request, ServerCallContext context)
{ {
var hasRelationship = false; bool hasRelationship;
if (!request.HasStatus) if (!request.HasStatus)
hasRelationship = await relationships.HasExistingRelationship( hasRelationship = await relationships.HasExistingRelationship(
Guid.Parse(request.AccountId), Guid.Parse(request.AccountId),

View File

@@ -39,6 +39,9 @@ public class AppDatabase(
public DbSet<SnAuthClient> AuthClients { get; set; } = null!; public DbSet<SnAuthClient> AuthClients { get; set; } = null!;
public DbSet<SnApiKey> ApiKeys { get; set; } = null!; public DbSet<SnApiKey> ApiKeys { get; set; } = null!;
public DbSet<SnRealm> Realms { get; set; } = null!;
public DbSet<SnRealmMember> RealmMembers { get; set; } = null!;
public DbSet<SnWallet> Wallets { get; set; } = null!; public DbSet<SnWallet> Wallets { get; set; } = null!;
public DbSet<SnWalletPocket> WalletPockets { get; set; } = null!; public DbSet<SnWalletPocket> WalletPockets { get; set; } = null!;
public DbSet<SnWalletOrder> PaymentOrders { get; set; } = null!; public DbSet<SnWalletOrder> PaymentOrders { get; set; } = null!;
@@ -54,6 +57,9 @@ public class AppDatabase(
public DbSet<SnSocialCreditRecord> SocialCreditRecords { get; set; } = null!; public DbSet<SnSocialCreditRecord> SocialCreditRecords { get; set; } = null!;
public DbSet<SnExperienceRecord> ExperienceRecords { get; set; } = null!; public DbSet<SnExperienceRecord> ExperienceRecords { get; set; } = null!;
public DbSet<SnLottery> Lotteries { get; set; } = null!;
public DbSet<SnLotteryRecord> LotteryRecords { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
optionsBuilder.UseNpgsql( optionsBuilder.UseNpgsql(
@@ -128,6 +134,14 @@ public class AppDatabase(
.WithMany(a => a.IncomingRelationships) .WithMany(a => a.IncomingRelationships)
.HasForeignKey(r => r.RelatedId); .HasForeignKey(r => r.RelatedId);
modelBuilder.Entity<SnRealmMember>()
.HasKey(pm => new { pm.RealmId, pm.AccountId });
modelBuilder.Entity<SnRealmMember>()
.HasOne(pm => pm.Realm)
.WithMany(p => p.Members)
.HasForeignKey(pm => pm.RealmId)
.OnDelete(DeleteBehavior.Cascade);
// Automatically apply soft-delete filter to all entities inheriting BaseModel // Automatically apply soft-delete filter to all entities inheriting BaseModel
foreach (var entityType in modelBuilder.Model.GetEntityTypes()) foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{ {

View File

@@ -343,8 +343,8 @@ public class OidcProviderController(
{ {
issuer, issuer,
authorization_endpoint = $"{siteUrl}/auth/authorize", authorization_endpoint = $"{siteUrl}/auth/authorize",
token_endpoint = $"{baseUrl}/id/auth/open/token", token_endpoint = $"{baseUrl}/pass/auth/open/token",
userinfo_endpoint = $"{baseUrl}/id/auth/open/userinfo", userinfo_endpoint = $"{baseUrl}/pass/auth/open/userinfo",
jwks_uri = $"{baseUrl}/.well-known/jwks", jwks_uri = $"{baseUrl}/.well-known/jwks",
scopes_supported = new[] { "openid", "profile", "email" }, scopes_supported = new[] { "openid", "profile", "email" },
response_types_supported = new[] response_types_supported = new[]

View File

@@ -8,7 +8,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0"/> <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0"/>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7"/> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
@@ -28,7 +28,7 @@
<PackageReference Include="OpenGraph-Net" Version="4.0.1" /> <PackageReference Include="OpenGraph-Net" Version="4.0.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/> <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0"/> <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0"/> <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/> <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/> <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/>
<PackageReference Include="Otp.NET" Version="1.4.0"/> <PackageReference Include="Otp.NET" Version="1.4.0"/>
@@ -44,8 +44,8 @@
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.1"/> <PackageReference Include="EFCore.BulkExtensions" Version="9.0.1"/>
<PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1"/> <PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1"/>
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0"/> <PackageReference Include="EFCore.NamingConventions" Version="9.0.0"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" /> <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.6" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,117 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Pass.Permission;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Lotteries;
[ApiController]
[Route("/api/lotteries")]
public class LotteryController(AppDatabase db, LotteryService lotteryService) : ControllerBase
{
public class CreateLotteryRequest
{
[Required]
public List<int> RegionOneNumbers { get; set; } = null!;
[Required]
[Range(0, 99)]
public int RegionTwoNumber { get; set; }
[Range(1, int.MaxValue)]
public int Multiplier { get; set; } = 1;
}
[HttpPost]
[Authorize]
public async Task<ActionResult<SnWalletOrder>> CreateLottery([FromBody] CreateLotteryRequest request)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
var order = await lotteryService.CreateLotteryOrderAsync(
accountId: currentUser.Id,
region1: request.RegionOneNumbers,
region2: request.RegionTwoNumber,
multiplier: request.Multiplier);
return Ok(order);
}
catch (ArgumentException err)
{
return BadRequest(err.Message);
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
}
[HttpGet]
[Authorize]
public async Task<ActionResult<List<SnLottery>>> GetLotteries(
[FromQuery] int offset = 0,
[FromQuery] int limit = 20)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var lotteries = await lotteryService.GetUserTicketsAsync(currentUser.Id, offset, limit);
var total = await lotteryService.GetUserTicketCountAsync(currentUser.Id);
Response.Headers["X-Total"] = total.ToString();
return Ok(lotteries);
}
[HttpGet("{id}")]
[Authorize]
public async Task<ActionResult<SnLottery>> GetLottery(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var lottery = await lotteryService.GetTicketAsync(id);
if (lottery == null || lottery.AccountId != currentUser.Id)
return NotFound();
return Ok(lottery);
}
[HttpPost("draw")]
[Authorize]
[RequiredPermission("maintenance", "lotteries.draw.perform")]
public async Task<IActionResult> PerformLotteryDraw()
{
await lotteryService.DrawLotteries();
return Ok();
}
[HttpGet("records")]
public async Task<ActionResult<List<SnLotteryRecord>>> GetLotteryRecords(
[FromQuery] Instant? startDate = null,
[FromQuery] Instant? endDate = null,
[FromQuery] int offset = 0,
[FromQuery] int limit = 20)
{
var query = db.LotteryRecords
.OrderByDescending(r => r.CreatedAt)
.AsQueryable();
if (startDate.HasValue)
query = query.Where(r => r.DrawDate >= startDate.Value);
if (endDate.HasValue)
query = query.Where(r => r.DrawDate <= endDate.Value);
var total = await query.CountAsync();
Response.Headers["X-Total"] = total.ToString();
var records = await query
.Skip(offset)
.Take(limit)
.ToListAsync();
return Ok(records);
}
}

View File

@@ -0,0 +1,21 @@
using Quartz;
namespace DysonNetwork.Pass.Lotteries;
public class LotteryDrawJob(LotteryService lotteryService, ILogger<LotteryDrawJob> logger) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
logger.LogInformation("Starting daily lottery draw...");
try
{
await lotteryService.DrawLotteries();
logger.LogInformation("Daily lottery draw completed successfully.");
}
catch (Exception ex)
{
logger.LogError(ex, "Error occurred during daily lottery draw.");
}
}
}

View File

@@ -0,0 +1,276 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Pass.Wallet;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NodaTime;
using System.Text.Json;
namespace DysonNetwork.Pass.Lotteries;
public class LotteryOrderMetaData
{
public Guid AccountId { get; set; }
public List<int> RegionOneNumbers { get; set; } = new();
public int RegionTwoNumber { get; set; }
public int Multiplier { get; set; } = 1;
}
public class LotteryService(
AppDatabase db,
PaymentService paymentService,
WalletService walletService,
ILogger<LotteryService> logger)
{
private readonly ILogger<LotteryService> _logger = logger;
private static bool ValidateNumbers(List<int> region1, int region2)
{
if (region1.Count != 5 || region1.Distinct().Count() != 5)
return false;
if (region1.Any(n => n < 0 || n > 99))
return false;
if (region2 < 0 || region2 > 99)
return false;
return true;
}
public async Task<SnLottery> CreateTicketAsync(Guid accountId, List<int> region1, int region2, int multiplier = 1)
{
if (!ValidateNumbers(region1, region2))
throw new ArgumentException("Invalid lottery numbers");
var lottery = new SnLottery
{
AccountId = accountId,
RegionOneNumbers = region1,
RegionTwoNumber = region2,
Multiplier = multiplier
};
db.Lotteries.Add(lottery);
await db.SaveChangesAsync();
return lottery;
}
public async Task<List<SnLottery>> GetUserTicketsAsync(Guid accountId, int offset = 0, int limit = 20)
{
return await db.Lotteries
.Where(l => l.AccountId == accountId)
.OrderByDescending(l => l.CreatedAt)
.Skip(offset)
.Take(limit)
.ToListAsync();
}
public async Task<SnLottery?> GetTicketAsync(Guid id)
{
return await db.Lotteries.FirstOrDefaultAsync(l => l.Id == id);
}
public async Task<int> GetUserTicketCountAsync(Guid accountId)
{
return await db.Lotteries.CountAsync(l => l.AccountId == accountId);
}
private static decimal CalculateLotteryPrice(int multiplier)
{
return 10 + (multiplier - 1) * 10;
}
public async Task<SnWalletOrder> CreateLotteryOrderAsync(Guid accountId, List<int> region1, int region2,
int multiplier = 1)
{
if (!ValidateNumbers(region1, region2))
throw new ArgumentException("Invalid lottery numbers");
var now = SystemClock.Instance.GetCurrentInstant();
var todayStart = new LocalDateTime(now.InUtc().Year, now.InUtc().Month, now.InUtc().Day, 0, 0).InUtc()
.ToInstant();
var hasPurchasedToday = await db.Lotteries.AnyAsync(l =>
l.AccountId == accountId &&
l.CreatedAt >= todayStart &&
l.DrawStatus == LotteryDrawStatus.Pending
);
if (hasPurchasedToday)
throw new InvalidOperationException("You can only purchase one lottery per day.");
var price = CalculateLotteryPrice(multiplier);
var lotteryData = new LotteryOrderMetaData
{
AccountId = accountId,
RegionOneNumbers = region1,
RegionTwoNumber = region2,
Multiplier = multiplier
};
return await paymentService.CreateOrderAsync(
null,
WalletCurrency.SourcePoint,
price,
appIdentifier: "lottery",
productIdentifier: "lottery",
meta: new Dictionary<string, object>
{
["data"] = JsonSerializer.Serialize(lotteryData)
});
}
public async Task HandleLotteryOrder(SnWalletOrder order)
{
if (order.Status == OrderStatus.Finished)
return; // Already processed
if (order.Status != OrderStatus.Paid ||
!order.Meta.TryGetValue("data", out var dataValue) ||
dataValue is null ||
dataValue is not JsonElement { ValueKind: JsonValueKind.String } jsonElem)
throw new InvalidOperationException("Invalid order.");
var jsonString = jsonElem.GetString();
if (jsonString is null)
throw new InvalidOperationException("Invalid order.");
var data = JsonSerializer.Deserialize<LotteryOrderMetaData>(jsonString);
if (data is null)
throw new InvalidOperationException("Invalid order data.");
await CreateTicketAsync(data.AccountId, data.RegionOneNumbers, data.RegionTwoNumber, data.Multiplier);
order.Status = OrderStatus.Finished;
await db.SaveChangesAsync();
}
private static int CalculateReward(int region1Matches, bool region2Match)
{
var reward = region1Matches switch
{
0 => 0,
1 => 10,
2 => 100,
3 => 500,
4 => 1000,
5 => 10000,
_ => 0
};
if (region2Match) reward *= 10;
return reward;
}
private static List<int> GenerateUniqueRandomNumbers(int count, int min, int max)
{
var numbers = new List<int>();
var random = new Random();
while (numbers.Count < count)
{
var num = random.Next(min, max + 1);
if (!numbers.Contains(num)) numbers.Add(num);
}
return numbers.OrderBy(n => n).ToList();
}
private int CountMatches(List<int> playerNumbers, List<int> winningNumbers)
{
return playerNumbers.Intersect(winningNumbers).Count();
}
public async Task DrawLotteries()
{
try
{
_logger.LogInformation("Starting drawing lotteries...");
var now = SystemClock.Instance.GetCurrentInstant();
// All pending lottery tickets
var tickets = await db.Lotteries
.Where(l => l.DrawStatus == LotteryDrawStatus.Pending)
.ToListAsync();
if (tickets.Count == 0)
{
_logger.LogInformation("No pending lottery tickets");
return;
}
_logger.LogInformation("Found {Count} pending lottery tickets for draw", tickets.Count);
// Generate winning numbers
var winningRegion1 = GenerateUniqueRandomNumbers(5, 0, 99);
var winningRegion2 = GenerateUniqueRandomNumbers(1, 0, 99)[0];
_logger.LogInformation("Winning numbers generated: Region1 [{Region1}], Region2 [{Region2}]",
string.Join(",", winningRegion1), winningRegion2);
var drawDate = Instant.FromDateTimeUtc(new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month,
DateTime.UtcNow.Day, 0, 0, 0, DateTimeKind.Utc).AddDays(-1)); // Yesterday's date
var totalPrizesAwarded = 0;
long totalPrizeAmount = 0;
// Process each ticket
foreach (var ticket in tickets)
{
var region1Matches = CountMatches(ticket.RegionOneNumbers, winningRegion1);
var region2Match = ticket.RegionTwoNumber == winningRegion2;
var reward = CalculateReward(region1Matches, region2Match);
// Record match results
ticket.MatchedRegionOneNumbers = ticket.RegionOneNumbers.Intersect(winningRegion1).ToList();
ticket.MatchedRegionTwoNumber = region2Match ? (int?)winningRegion2 : null;
if (reward > 0)
{
var wallet = await walletService.GetWalletAsync(ticket.AccountId);
if (wallet != null)
{
await paymentService.CreateTransactionAsync(
payerWalletId: null,
payeeWalletId: wallet.Id,
currency: WalletCurrency.SourcePoint,
amount: reward,
remarks: $"Lottery prize: {region1Matches} matches{(region2Match ? " + special" : "")}"
);
_logger.LogInformation(
"Awarded {Amount} to account {AccountId} for {Matches} matches{(Special ? \" + special\" : \"\")}",
reward, ticket.AccountId, region1Matches, region2Match ? " + special" : "");
totalPrizesAwarded++;
totalPrizeAmount += reward;
}
else
{
_logger.LogWarning("Wallet not found for account {AccountId}, skipping prize award",
ticket.AccountId);
}
}
ticket.DrawStatus = LotteryDrawStatus.Drawn;
ticket.DrawDate = drawDate;
}
// Save the draw record
var lotteryRecord = new SnLotteryRecord
{
DrawDate = drawDate,
WinningRegionOneNumbers = winningRegion1,
WinningRegionTwoNumber = winningRegion2,
TotalTickets = tickets.Count,
TotalPrizesAwarded = totalPrizesAwarded,
TotalPrizeAmount = totalPrizeAmount
};
db.LotteryRecords.Add(lotteryRecord);
await db.SaveChangesAsync();
_logger.LogInformation("Daily lottery draw completed: {Prizes} prizes awarded, total amount {Amount}",
totalPrizesAwarded, totalPrizeAmount);
}
catch (Exception ex)
{
_logger.LogError(ex, "An error occurred during the daily lottery draw");
throw;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,160 @@
using System;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddRealmFromSphere : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "realms",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
slug = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
is_community = table.Column<bool>(type: "boolean", nullable: false),
is_public = table.Column<bool>(type: "boolean", nullable: false),
picture_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
background_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
picture = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
background = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
verification = table.Column<SnVerificationMark>(type: "jsonb", nullable: true),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_realms", x => x.id);
});
migrationBuilder.CreateTable(
name: "realm_members",
columns: table => new
{
realm_id = table.Column<Guid>(type: "uuid", nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
role = table.Column<int>(type: "integer", nullable: false),
joined_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
leave_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_realm_members", x => new { x.realm_id, x.account_id });
table.ForeignKey(
name: "fk_realm_members_realms_realm_id",
column: x => x.realm_id,
principalTable: "realms",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "sn_chat_room",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
type = table.Column<int>(type: "integer", nullable: false),
is_community = table.Column<bool>(type: "boolean", nullable: false),
is_public = table.Column<bool>(type: "boolean", nullable: false),
picture_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
background_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
picture = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
background = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
realm_id = table.Column<Guid>(type: "uuid", nullable: true),
sn_realm_id = table.Column<Guid>(type: "uuid", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_sn_chat_room", x => x.id);
table.ForeignKey(
name: "fk_sn_chat_room_realms_sn_realm_id",
column: x => x.sn_realm_id,
principalTable: "realms",
principalColumn: "id");
});
migrationBuilder.CreateTable(
name: "sn_chat_member",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
chat_room_id = table.Column<Guid>(type: "uuid", nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
nick = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
role = table.Column<int>(type: "integer", nullable: false),
notify = table.Column<int>(type: "integer", nullable: false),
last_read_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
joined_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
leave_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
is_bot = table.Column<bool>(type: "boolean", nullable: false),
break_until = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
timeout_until = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
timeout_cause = table.Column<ChatTimeoutCause>(type: "jsonb", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_sn_chat_member", x => x.id);
table.ForeignKey(
name: "fk_sn_chat_member_sn_chat_room_chat_room_id",
column: x => x.chat_room_id,
principalTable: "sn_chat_room",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_realms_slug",
table: "realms",
column: "slug",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_sn_chat_member_chat_room_id",
table: "sn_chat_member",
column: "chat_room_id");
migrationBuilder.CreateIndex(
name: "ix_sn_chat_room_sn_realm_id",
table: "sn_chat_room",
column: "sn_realm_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "realm_members");
migrationBuilder.DropTable(
name: "sn_chat_member");
migrationBuilder.DropTable(
name: "sn_chat_room");
migrationBuilder.DropTable(
name: "realms");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,99 @@
using System;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class RemoveChatRoom : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "sn_chat_member");
migrationBuilder.DropTable(
name: "sn_chat_room");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "sn_chat_room",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
background = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
background_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
is_community = table.Column<bool>(type: "boolean", nullable: false),
is_public = table.Column<bool>(type: "boolean", nullable: false),
name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
picture = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
picture_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
realm_id = table.Column<Guid>(type: "uuid", nullable: true),
sn_realm_id = table.Column<Guid>(type: "uuid", nullable: true),
type = table.Column<int>(type: "integer", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_sn_chat_room", x => x.id);
table.ForeignKey(
name: "fk_sn_chat_room_realms_sn_realm_id",
column: x => x.sn_realm_id,
principalTable: "realms",
principalColumn: "id");
});
migrationBuilder.CreateTable(
name: "sn_chat_member",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
chat_room_id = table.Column<Guid>(type: "uuid", nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
break_until = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
is_bot = table.Column<bool>(type: "boolean", nullable: false),
joined_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
last_read_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
leave_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
nick = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
notify = table.Column<int>(type: "integer", nullable: false),
role = table.Column<int>(type: "integer", nullable: false),
timeout_cause = table.Column<ChatTimeoutCause>(type: "jsonb", nullable: true),
timeout_until = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_sn_chat_member", x => x.id);
table.ForeignKey(
name: "fk_sn_chat_member_sn_chat_room_chat_room_id",
column: x => x.chat_room_id,
principalTable: "sn_chat_room",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_sn_chat_member_chat_room_id",
table: "sn_chat_member",
column: "chat_room_id");
migrationBuilder.CreateIndex(
name: "ix_sn_chat_room_sn_realm_id",
table: "sn_chat_room",
column: "sn_realm_id");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddLotteries : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "lotteries",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
region_one_numbers = table.Column<List<int>>(type: "jsonb", nullable: false),
region_two_number = table.Column<int>(type: "integer", nullable: false),
multiplier = table.Column<int>(type: "integer", nullable: false),
draw_status = table.Column<int>(type: "integer", nullable: false),
draw_date = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_lotteries", x => x.id);
table.ForeignKey(
name: "fk_lotteries_accounts_account_id",
column: x => x.account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "lottery_records",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
draw_date = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
winning_region_one_numbers = table.Column<List<int>>(type: "jsonb", nullable: false),
winning_region_two_number = table.Column<int>(type: "integer", nullable: false),
total_tickets = table.Column<int>(type: "integer", nullable: false),
total_prizes_awarded = table.Column<int>(type: "integer", nullable: false),
total_prize_amount = table.Column<long>(type: "bigint", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_lottery_records", x => x.id);
});
migrationBuilder.CreateIndex(
name: "ix_lotteries_account_id",
table: "lotteries",
column: "account_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "lotteries");
migrationBuilder.DropTable(
name: "lottery_records");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddDetailLotteriesStatus : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<List<int>>(
name: "matched_region_one_numbers",
table: "lotteries",
type: "jsonb",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "matched_region_two_number",
table: "lotteries",
type: "integer",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "matched_region_one_numbers",
table: "lotteries");
migrationBuilder.DropColumn(
name: "matched_region_two_number",
table: "lotteries");
}
}
}

View File

@@ -22,7 +22,7 @@ namespace DysonNetwork.Pass.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "9.0.7") .HasAnnotation("ProductVersion", "9.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -1059,6 +1059,117 @@ namespace DysonNetwork.Pass.Migrations
b.ToTable("experience_records", (string)null); b.ToTable("experience_records", (string)null);
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnLottery", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("DrawDate")
.HasColumnType("timestamp with time zone")
.HasColumnName("draw_date");
b.Property<int>("DrawStatus")
.HasColumnType("integer")
.HasColumnName("draw_status");
b.Property<List<int>>("MatchedRegionOneNumbers")
.HasColumnType("jsonb")
.HasColumnName("matched_region_one_numbers");
b.Property<int?>("MatchedRegionTwoNumber")
.HasColumnType("integer")
.HasColumnName("matched_region_two_number");
b.Property<int>("Multiplier")
.HasColumnType("integer")
.HasColumnName("multiplier");
b.Property<List<int>>("RegionOneNumbers")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("region_one_numbers");
b.Property<int>("RegionTwoNumber")
.HasColumnType("integer")
.HasColumnName("region_two_number");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_lotteries");
b.HasIndex("AccountId")
.HasDatabaseName("ix_lotteries_account_id");
b.ToTable("lotteries", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnLotteryRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant>("DrawDate")
.HasColumnType("timestamp with time zone")
.HasColumnName("draw_date");
b.Property<long>("TotalPrizeAmount")
.HasColumnType("bigint")
.HasColumnName("total_prize_amount");
b.Property<int>("TotalPrizesAwarded")
.HasColumnType("integer")
.HasColumnName("total_prizes_awarded");
b.Property<int>("TotalTickets")
.HasColumnType("integer")
.HasColumnName("total_tickets");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<List<int>>("WinningRegionOneNumbers")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("winning_region_one_numbers");
b.Property<int>("WinningRegionTwoNumber")
.HasColumnType("integer")
.HasColumnName("winning_region_two_number");
b.HasKey("Id")
.HasName("pk_lottery_records");
b.ToTable("lottery_records", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnMagicSpell", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnMagicSpell", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -1252,6 +1363,127 @@ namespace DysonNetwork.Pass.Migrations
b.ToTable("permission_nodes", (string)null); b.ToTable("permission_nodes", (string)null);
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealm", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<SnCloudFileReferenceObject>("Background")
.HasColumnType("jsonb")
.HasColumnName("background");
b.Property<string>("BackgroundId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("background_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("description");
b.Property<bool>("IsCommunity")
.HasColumnType("boolean")
.HasColumnName("is_community");
b.Property<bool>("IsPublic")
.HasColumnType("boolean")
.HasColumnName("is_public");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<SnCloudFileReferenceObject>("Picture")
.HasColumnType("jsonb")
.HasColumnName("picture");
b.Property<string>("PictureId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("picture_id");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("slug");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<SnVerificationMark>("Verification")
.HasColumnType("jsonb")
.HasColumnName("verification");
b.HasKey("Id")
.HasName("pk_realms");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_realms_slug");
b.ToTable("realms", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealmMember", b =>
{
b.Property<Guid>("RealmId")
.HasColumnType("uuid")
.HasColumnName("realm_id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("JoinedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("joined_at");
b.Property<Instant?>("LeaveAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("leave_at");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("RealmId", "AccountId")
.HasName("pk_realm_members");
b.ToTable("realm_members", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnSocialCreditRecord", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnSocialCreditRecord", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -2113,6 +2345,18 @@ namespace DysonNetwork.Pass.Migrations
b.Navigation("Account"); b.Navigation("Account");
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnLottery", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_lotteries_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnMagicSpell", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnMagicSpell", b =>
{ {
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account") b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
@@ -2145,6 +2389,18 @@ namespace DysonNetwork.Pass.Migrations
b.Navigation("Group"); b.Navigation("Group");
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealmMember", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnRealm", "Realm")
.WithMany("Members")
.HasForeignKey("RealmId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_realm_members_realms_realm_id");
b.Navigation("Realm");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnSocialCreditRecord", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnSocialCreditRecord", b =>
{ {
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account") b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
@@ -2336,6 +2592,11 @@ namespace DysonNetwork.Pass.Migrations
b.Navigation("Nodes"); b.Navigation("Nodes");
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealm", b =>
{
b.Navigation("Members");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWallet", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnWallet", b =>
{ {
b.Navigation("Pockets"); b.Navigation("Pockets");

View File

@@ -13,7 +13,6 @@ builder.ConfigureAppKestrel(builder.Configuration);
// Add application services // Add application services
builder.Services.AddAppServices(builder.Configuration); builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppRateLimiting();
builder.Services.AddAppAuthentication(); builder.Services.AddAppAuthentication();
builder.Services.AddRingService(); builder.Services.AddRingService();
builder.Services.AddDriveService(); builder.Services.AddDriveService();
@@ -52,6 +51,6 @@ app.ConfigureAppMiddleware(builder.Configuration);
// Configure gRPC // Configure gRPC
app.ConfigureGrpcServices(); app.ConfigureGrpcServices();
app.UseSwaggerManifest(); app.UseSwaggerManifest("DysonNetwork.Pass");
app.Run(); app.Run();

View File

@@ -1,14 +1,17 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Account;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry; using DysonNetwork.Shared.Registry;
using Google.Protobuf.WellKnownTypes;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using Google.Protobuf.WellKnownTypes; using AccountService = DysonNetwork.Pass.Account.AccountService;
using DysonNetwork.Shared.Models; using ActionLogService = DysonNetwork.Pass.Account.ActionLogService;
namespace DysonNetwork.Sphere.Realm; namespace DysonNetwork.Pass.Realm;
[ApiController] [ApiController]
[Route("/api/realms")] [Route("/api/realms")]
@@ -17,9 +20,9 @@ public class RealmController(
RealmService rs, RealmService rs,
FileService.FileServiceClient files, FileService.FileServiceClient files,
FileReferenceService.FileReferenceServiceClient fileRefs, FileReferenceService.FileReferenceServiceClient fileRefs,
ActionLogService.ActionLogServiceClient als, ActionLogService als,
AccountService.AccountServiceClient accounts, RelationshipService rels,
AccountClientHelper accountsHelper AccountEventService accountEvents
) : Controller ) : Controller
{ {
[HttpGet("{slug}")] [HttpGet("{slug}")]
@@ -37,8 +40,8 @@ public class RealmController(
[Authorize] [Authorize]
public async Task<ActionResult<List<SnRealm>>> ListJoinedRealms() public async Task<ActionResult<List<SnRealm>>> ListJoinedRealms()
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id); var accountId = currentUser.Id;
var members = await db.RealmMembers var members = await db.RealmMembers
.Where(m => m.AccountId == accountId) .Where(m => m.AccountId == accountId)
@@ -54,8 +57,8 @@ public class RealmController(
[Authorize] [Authorize]
public async Task<ActionResult<List<SnRealmMember>>> ListInvites() public async Task<ActionResult<List<SnRealmMember>>> ListInvites()
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id); var accountId = currentUser.Id;
var members = await db.RealmMembers var members = await db.RealmMembers
.Where(m => m.AccountId == accountId) .Where(m => m.AccountId == accountId)
@@ -77,20 +80,18 @@ public class RealmController(
public async Task<ActionResult<SnRealmMember>> InviteMember(string slug, public async Task<ActionResult<SnRealmMember>> InviteMember(string slug,
[FromBody] RealmMemberRequest request) [FromBody] RealmMemberRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id); var accountId = currentUser.Id;
var relatedUser = var relatedUser = await db.Accounts.Where(a => a.Id == request.RelatedUserId).FirstOrDefaultAsync();
await accounts.GetAccountAsync(new GetAccountRequest { Id = request.RelatedUserId.ToString() });
if (relatedUser == null) return BadRequest("Related user was not found"); if (relatedUser == null) return BadRequest("Related user was not found");
var hasBlocked = await accounts.HasRelationshipAsync(new GetRelationshipRequest() var hasBlocked = await rels.HasRelationshipWithStatus(
{ currentUser.Id,
AccountId = currentUser.Id, request.RelatedUserId,
RelatedId = request.RelatedUserId.ToString(), RelationshipStatus.Blocked
Status = -100 );
}); if (hasBlocked)
if (hasBlocked?.Value ?? false)
return StatusCode(403, "You cannot invite a user that blocked you."); return StatusCode(403, "You cannot invite a user that blocked you.");
var realm = await db.Realms var realm = await db.Realms
@@ -102,7 +103,7 @@ public class RealmController(
return StatusCode(403, "You cannot invite member has higher permission than yours."); return StatusCode(403, "You cannot invite member has higher permission than yours.");
var existingMember = await db.RealmMembers var existingMember = await db.RealmMembers
.Where(m => m.AccountId == Guid.Parse(relatedUser.Id)) .Where(m => m.AccountId == relatedUser.Id)
.Where(m => m.RealmId == realm.Id) .Where(m => m.RealmId == realm.Id)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (existingMember != null) if (existingMember != null)
@@ -116,26 +117,23 @@ public class RealmController(
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await rs.SendInviteNotify(existingMember); await rs.SendInviteNotify(existingMember);
_ = als.CreateActionLogAsync(new CreateActionLogRequest als.CreateActionLogFromRequest(
{ "realms.members.invite",
Action = "realms.members.invite", new Dictionary<string, object>()
Meta =
{ {
{ "realm_id", Value.ForString(realm.Id.ToString()) }, { "realm_id", Value.ForString(realm.Id.ToString()) },
{ "account_id", Value.ForString(existingMember.AccountId.ToString()) }, { "account_id", Value.ForString(existingMember.AccountId.ToString()) },
{ "role", Value.ForNumber(request.Role) } { "role", Value.ForNumber(request.Role) }
}, },
AccountId = currentUser.Id, Request
UserAgent = Request.Headers.UserAgent.ToString(), );
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return Ok(existingMember); return Ok(existingMember);
} }
var member = new SnRealmMember var member = new SnRealmMember
{ {
AccountId = Guid.Parse(relatedUser.Id), AccountId = relatedUser.Id,
RealmId = realm.Id, RealmId = realm.Id,
Role = request.Role, Role = request.Role,
}; };
@@ -143,21 +141,18 @@ public class RealmController(
db.RealmMembers.Add(member); db.RealmMembers.Add(member);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest als.CreateActionLogFromRequest(
{ "realms.members.invite",
Action = "realms.members.invite", new Dictionary<string, object>()
Meta =
{ {
{ "realm_id", Value.ForString(realm.Id.ToString()) }, { "realm_id", Value.ForString(realm.Id.ToString()) },
{ "account_id", Value.ForString(member.AccountId.ToString()) }, { "account_id", Value.ForString(member.AccountId.ToString()) },
{ "role", Value.ForNumber(request.Role) } { "role", Value.ForNumber(request.Role) }
}, },
AccountId = currentUser.Id, Request
UserAgent = Request.Headers.UserAgent.ToString(), );
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
member.AccountId = Guid.Parse(relatedUser.Id); member.AccountId = relatedUser.Id;
member.Realm = realm; member.Realm = realm;
await rs.SendInviteNotify(member); await rs.SendInviteNotify(member);
@@ -168,8 +163,8 @@ public class RealmController(
[Authorize] [Authorize]
public async Task<ActionResult<SnRealm>> AcceptMemberInvite(string slug) public async Task<ActionResult<SnRealm>> AcceptMemberInvite(string slug)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id); var accountId = currentUser.Id;
var member = await db.RealmMembers var member = await db.RealmMembers
.Where(m => m.AccountId == accountId) .Where(m => m.AccountId == accountId)
@@ -182,18 +177,15 @@ public class RealmController(
db.Update(member); db.Update(member);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest als.CreateActionLogFromRequest(
"realms.members.join",
new Dictionary<string, object>()
{ {
Action = "realms.members.join", { "realm_id", member.RealmId.ToString() },
Meta = { "account_id", member.AccountId.ToString() }
{
{ "realm_id", Value.ForString(member.RealmId.ToString()) },
{ "account_id", Value.ForString(member.AccountId.ToString()) }
}, },
AccountId = currentUser.Id, Request
UserAgent = Request.Headers.UserAgent.ToString(), );
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return Ok(member); return Ok(member);
} }
@@ -202,8 +194,8 @@ public class RealmController(
[Authorize] [Authorize]
public async Task<ActionResult> DeclineMemberInvite(string slug) public async Task<ActionResult> DeclineMemberInvite(string slug)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id); var accountId = currentUser.Id;
var member = await db.RealmMembers var member = await db.RealmMembers
.Where(m => m.AccountId == accountId) .Where(m => m.AccountId == accountId)
@@ -215,19 +207,16 @@ public class RealmController(
member.LeaveAt = SystemClock.Instance.GetCurrentInstant(); member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest als.CreateActionLogFromRequest(
{ "realms.members.decline_invite",
Action = "realms.members.decline_invite", new Dictionary<string, object>()
Meta =
{ {
{ "realm_id", Value.ForString(member.RealmId.ToString()) }, { "realm_id", Value.ForString(member.RealmId.ToString()) },
{ "account_id", Value.ForString(member.AccountId.ToString()) }, { "account_id", Value.ForString(member.AccountId.ToString()) },
{ "decliner_id", Value.ForString(currentUser.Id) } { "decliner_id", Value.ForString(currentUser.Id.ToString()) }
}, },
AccountId = currentUser.Id, Request
UserAgent = Request.Headers.UserAgent.ToString(), );
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return NoContent(); return NoContent();
} }
@@ -248,8 +237,8 @@ public class RealmController(
if (!realm.IsPublic) if (!realm.IsPublic)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
if (!await rs.IsMemberWithRole(realm.Id, Guid.Parse(currentUser.Id), RealmMemberRole.Normal)) if (!await rs.IsMemberWithRole(realm.Id, currentUser.Id, RealmMemberRole.Normal))
return StatusCode(403, "You must be a member to view this realm's members."); return StatusCode(403, "You must be a member to view this realm's members.");
} }
@@ -263,7 +252,7 @@ public class RealmController(
.OrderBy(m => m.JoinedAt) .OrderBy(m => m.JoinedAt)
.ToListAsync(); .ToListAsync();
var memberStatuses = await accountsHelper.GetAccountStatusBatch( var memberStatuses = await accountEvents.GetStatuses(
members.Select(m => m.AccountId).ToList() members.Select(m => m.AccountId).ToList()
); );
@@ -306,8 +295,8 @@ public class RealmController(
[Authorize] [Authorize]
public async Task<ActionResult<SnRealmMember>> GetCurrentIdentity(string slug) public async Task<ActionResult<SnRealmMember>> GetCurrentIdentity(string slug)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id); var accountId = currentUser.Id;
var member = await db.RealmMembers var member = await db.RealmMembers
.Where(m => m.AccountId == accountId) .Where(m => m.AccountId == accountId)
@@ -323,8 +312,8 @@ public class RealmController(
[Authorize] [Authorize]
public async Task<ActionResult> LeaveRealm(string slug) public async Task<ActionResult> LeaveRealm(string slug)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id); var accountId = currentUser.Id;
var member = await db.RealmMembers var member = await db.RealmMembers
.Where(m => m.AccountId == accountId) .Where(m => m.AccountId == accountId)
@@ -339,19 +328,16 @@ public class RealmController(
member.LeaveAt = SystemClock.Instance.GetCurrentInstant(); member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest als.CreateActionLogFromRequest(
"realms.members.leave",
new Dictionary<string, object>()
{ {
Action = "realms.members.leave", { "realm_id", member.RealmId.ToString() },
Meta = { "account_id", member.AccountId.ToString() },
{ { "leaver_id", currentUser.Id }
{ "realm_id", Value.ForString(member.RealmId.ToString()) },
{ "account_id", Value.ForString(member.AccountId.ToString()) },
{ "leaver_id", Value.ForString(currentUser.Id) }
}, },
AccountId = currentUser.Id, Request
UserAgent = Request.Headers.UserAgent.ToString(), );
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return NoContent(); return NoContent();
} }
@@ -371,7 +357,7 @@ public class RealmController(
[Authorize] [Authorize]
public async Task<ActionResult<SnRealm>> CreateRealm(RealmRequest request) public async Task<ActionResult<SnRealm>> CreateRealm(RealmRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest("You cannot create a realm without a name."); if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest("You cannot create a realm without a name.");
if (string.IsNullOrWhiteSpace(request.Slug)) return BadRequest("You cannot create a realm without a slug."); if (string.IsNullOrWhiteSpace(request.Slug)) return BadRequest("You cannot create a realm without a slug.");
@@ -383,7 +369,7 @@ public class RealmController(
Name = request.Name!, Name = request.Name!,
Slug = request.Slug!, Slug = request.Slug!,
Description = request.Description!, Description = request.Description!,
AccountId = Guid.Parse(currentUser.Id), AccountId = currentUser.Id,
IsCommunity = request.IsCommunity ?? false, IsCommunity = request.IsCommunity ?? false,
IsPublic = request.IsPublic ?? false, IsPublic = request.IsPublic ?? false,
Members = new List<SnRealmMember> Members = new List<SnRealmMember>
@@ -391,7 +377,7 @@ public class RealmController(
new() new()
{ {
Role = RealmMemberRole.Owner, Role = RealmMemberRole.Owner,
AccountId = Guid.Parse(currentUser.Id), AccountId = currentUser.Id,
JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow) JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow)
} }
} }
@@ -414,21 +400,18 @@ public class RealmController(
db.Realms.Add(realm); db.Realms.Add(realm);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest als.CreateActionLogFromRequest(
"realms.create",
new Dictionary<string, object>()
{ {
Action = "realms.create", { "realm_id", realm.Id.ToString() },
Meta = { "name", realm.Name },
{ { "slug", realm.Slug },
{ "realm_id", Value.ForString(realm.Id.ToString()) }, { "is_community", realm.IsCommunity },
{ "name", Value.ForString(realm.Name) }, { "is_public", realm.IsPublic }
{ "slug", Value.ForString(realm.Slug) },
{ "is_community", Value.ForBool(realm.IsCommunity) },
{ "is_public", Value.ForBool(realm.IsPublic) }
}, },
AccountId = currentUser.Id, Request
UserAgent = Request.Headers.UserAgent.ToString(), );
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
var realmResourceId = $"realm:{realm.Id}"; var realmResourceId = $"realm:{realm.Id}";
@@ -459,14 +442,14 @@ public class RealmController(
[Authorize] [Authorize]
public async Task<ActionResult<SnRealm>> Update(string slug, [FromBody] RealmRequest request) public async Task<ActionResult<SnRealm>> Update(string slug, [FromBody] RealmRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var realm = await db.Realms var realm = await db.Realms
.Where(r => r.Slug == slug) .Where(r => r.Slug == slug)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (realm is null) return NotFound(); if (realm is null) return NotFound();
var accountId = Guid.Parse(currentUser.Id); var accountId = currentUser.Id;
var member = await db.RealmMembers var member = await db.RealmMembers
.Where(m => m.AccountId == accountId && m.RealmId == realm.Id && m.JoinedAt != null && m.LeaveAt == null) .Where(m => m.AccountId == accountId && m.RealmId == realm.Id && m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
@@ -542,24 +525,21 @@ public class RealmController(
db.Realms.Update(realm); db.Realms.Update(realm);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest als.CreateActionLogFromRequest(
"realms.update",
new Dictionary<string, object>()
{ {
Action = "realms.update", { "realm_id", realm.Id.ToString() },
Meta = { "name_updated", request.Name != null },
{ { "slug_updated", request.Slug != null },
{ "realm_id", Value.ForString(realm.Id.ToString()) }, { "description_updated", request.Description != null },
{ "name_updated", Value.ForBool(request.Name != null) }, { "picture_updated", request.PictureId != null },
{ "slug_updated", Value.ForBool(request.Slug != null) }, { "background_updated", request.BackgroundId != null },
{ "description_updated", Value.ForBool(request.Description != null) }, { "is_community_updated", request.IsCommunity != null },
{ "picture_updated", Value.ForBool(request.PictureId != null) }, { "is_public_updated", request.IsPublic != null }
{ "background_updated", Value.ForBool(request.BackgroundId != null) },
{ "is_community_updated", Value.ForBool(request.IsCommunity != null) },
{ "is_public_updated", Value.ForBool(request.IsPublic != null) }
}, },
AccountId = currentUser.Id, Request
UserAgent = Request.Headers.UserAgent.ToString(), );
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return Ok(realm); return Ok(realm);
} }
@@ -568,7 +548,7 @@ public class RealmController(
[Authorize] [Authorize]
public async Task<ActionResult<SnRealmMember>> JoinRealm(string slug) public async Task<ActionResult<SnRealmMember>> JoinRealm(string slug)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var realm = await db.Realms var realm = await db.Realms
.Where(r => r.Slug == slug) .Where(r => r.Slug == slug)
@@ -579,7 +559,7 @@ public class RealmController(
return StatusCode(403, "Only community realms can be joined without invitation."); return StatusCode(403, "Only community realms can be joined without invitation.");
var existingMember = await db.RealmMembers var existingMember = await db.RealmMembers
.Where(m => m.AccountId == Guid.Parse(currentUser.Id) && m.RealmId == realm.Id) .Where(m => m.AccountId == currentUser.Id && m.RealmId == realm.Id)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (existingMember is not null) if (existingMember is not null)
{ {
@@ -592,26 +572,23 @@ public class RealmController(
db.Update(existingMember); db.Update(existingMember);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest als.CreateActionLogFromRequest(
"realms.members.join",
new Dictionary<string, object>()
{ {
Action = "realms.members.join", { "realm_id", existingMember.RealmId.ToString() },
Meta = { "account_id", currentUser.Id },
{ { "is_community", realm.IsCommunity }
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "account_id", Value.ForString(currentUser.Id) },
{ "is_community", Value.ForBool(realm.IsCommunity) }
}, },
AccountId = currentUser.Id, Request
UserAgent = Request.Headers.UserAgent.ToString(), );
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return Ok(existingMember); return Ok(existingMember);
} }
var member = new SnRealmMember var member = new SnRealmMember
{ {
AccountId = Guid.Parse(currentUser.Id), AccountId = currentUser.Id,
RealmId = realm.Id, RealmId = realm.Id,
Role = RealmMemberRole.Normal, Role = RealmMemberRole.Normal,
JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow) JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow)
@@ -620,19 +597,16 @@ public class RealmController(
db.RealmMembers.Add(member); db.RealmMembers.Add(member);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest als.CreateActionLogFromRequest(
"realms.members.join",
new Dictionary<string, object>()
{ {
Action = "realms.members.join", { "realm_id", realm.Id.ToString() },
Meta = { "account_id", currentUser.Id },
{ { "is_community", realm.IsCommunity }
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "account_id", Value.ForString(currentUser.Id) },
{ "is_community", Value.ForBool(realm.IsCommunity) }
}, },
AccountId = currentUser.Id, Request
UserAgent = Request.Headers.UserAgent.ToString(), );
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return Ok(member); return Ok(member);
} }
@@ -641,7 +615,7 @@ public class RealmController(
[Authorize] [Authorize]
public async Task<ActionResult> RemoveMember(string slug, Guid memberId) public async Task<ActionResult> RemoveMember(string slug, Guid memberId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var realm = await db.Realms var realm = await db.Realms
.Where(r => r.Slug == slug) .Where(r => r.Slug == slug)
@@ -653,25 +627,22 @@ public class RealmController(
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (member is null) return NotFound(); if (member is null) return NotFound();
if (!await rs.IsMemberWithRole(realm.Id, Guid.Parse(currentUser.Id), RealmMemberRole.Moderator, member.Role)) if (!await rs.IsMemberWithRole(realm.Id, currentUser.Id, RealmMemberRole.Moderator, member.Role))
return StatusCode(403, "You do not have permission to remove members from this realm."); return StatusCode(403, "You do not have permission to remove members from this realm.");
member.LeaveAt = SystemClock.Instance.GetCurrentInstant(); member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest als.CreateActionLogFromRequest(
"realms.members.kick",
new Dictionary<string, object>()
{ {
Action = "realms.members.kick", { "realm_id", realm.Id.ToString() },
Meta = { "account_id", memberId.ToString() },
{ { "kicker_id", currentUser.Id }
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "account_id", Value.ForString(memberId.ToString()) },
{ "kicker_id", Value.ForString(currentUser.Id) }
}, },
AccountId = currentUser.Id, Request
UserAgent = Request.Headers.UserAgent.ToString(), );
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return NoContent(); return NoContent();
} }
@@ -681,7 +652,7 @@ public class RealmController(
public async Task<ActionResult<SnRealmMember>> UpdateMemberRole(string slug, Guid memberId, [FromBody] int newRole) public async Task<ActionResult<SnRealmMember>> UpdateMemberRole(string slug, Guid memberId, [FromBody] int newRole)
{ {
if (newRole >= RealmMemberRole.Owner) return BadRequest("Unable to set realm member to owner or greater role."); if (newRole >= RealmMemberRole.Owner) return BadRequest("Unable to set realm member to owner or greater role.");
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var realm = await db.Realms var realm = await db.Realms
.Where(r => r.Slug == slug) .Where(r => r.Slug == slug)
@@ -693,7 +664,7 @@ public class RealmController(
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (member is null) return NotFound(); if (member is null) return NotFound();
if (!await rs.IsMemberWithRole(realm.Id, Guid.Parse(currentUser.Id), RealmMemberRole.Moderator, member.Role, if (!await rs.IsMemberWithRole(realm.Id, currentUser.Id, RealmMemberRole.Moderator, member.Role,
newRole)) newRole))
return StatusCode(403, "You do not have permission to update member roles in this realm."); return StatusCode(403, "You do not have permission to update member roles in this realm.");
@@ -701,20 +672,17 @@ public class RealmController(
db.RealmMembers.Update(member); db.RealmMembers.Update(member);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest als.CreateActionLogFromRequest(
"realms.members.role_update",
new Dictionary<string, object>()
{ {
Action = "realms.members.role_update", { "realm_id", realm.Id.ToString() },
Meta = { "account_id", memberId.ToString() },
{ { "new_role", newRole },
{ "realm_id", Value.ForString(realm.Id.ToString()) }, { "updater_id", currentUser.Id }
{ "account_id", Value.ForString(memberId.ToString()) },
{ "new_role", Value.ForNumber(newRole) },
{ "updater_id", Value.ForString(currentUser.Id) }
}, },
AccountId = currentUser.Id, Request
UserAgent = Request.Headers.UserAgent.ToString(), );
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return Ok(member); return Ok(member);
} }
@@ -723,7 +691,7 @@ public class RealmController(
[Authorize] [Authorize]
public async Task<ActionResult> Delete(string slug) public async Task<ActionResult> Delete(string slug)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var transaction = await db.Database.BeginTransactionAsync(); var transaction = await db.Database.BeginTransactionAsync();
@@ -732,16 +700,11 @@ public class RealmController(
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (realm is null) return NotFound(); if (realm is null) return NotFound();
if (!await rs.IsMemberWithRole(realm.Id, Guid.Parse(currentUser.Id), RealmMemberRole.Owner)) if (!await rs.IsMemberWithRole(realm.Id, currentUser.Id, RealmMemberRole.Owner))
return StatusCode(403, "Only the owner can delete this realm."); return StatusCode(403, "Only the owner can delete this realm.");
try try
{ {
var chats = await db.ChatRooms
.Where(c => c.RealmId == realm.Id)
.Select(c => c.Id)
.ToListAsync();
db.Realms.Remove(realm); db.Realms.Remove(realm);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -749,15 +712,6 @@ public class RealmController(
await db.RealmMembers await db.RealmMembers
.Where(m => m.RealmId == realm.Id) .Where(m => m.RealmId == realm.Id)
.ExecuteUpdateAsync(m => m.SetProperty(m => m.DeletedAt, now)); .ExecuteUpdateAsync(m => m.SetProperty(m => m.DeletedAt, now));
await db.ChatRooms
.Where(c => c.RealmId == realm.Id)
.ExecuteUpdateAsync(c => c.SetProperty(c => c.DeletedAt, now));
await db.ChatMessages
.Where(m => chats.Contains(m.ChatRoomId))
.ExecuteUpdateAsync(m => m.SetProperty(m => m.DeletedAt, now));
await db.ChatMembers
.Where(m => chats.Contains(m.ChatRoomId))
.ExecuteUpdateAsync(m => m.SetProperty(m => m.DeletedAt, now));
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await transaction.CommitAsync(); await transaction.CommitAsync();
} }
@@ -767,19 +721,16 @@ public class RealmController(
throw; throw;
} }
_ = als.CreateActionLogAsync(new CreateActionLogRequest als.CreateActionLogFromRequest(
"realms.delete",
new Dictionary<string, object>()
{ {
Action = "realms.delete", { "realm_id", realm.Id.ToString() },
Meta = { "realm_name", realm.Name },
{ { "realm_slug", realm.Slug }
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "realm_name", Value.ForString(realm.Name) },
{ "realm_slug", Value.ForString(realm.Slug) }
}, },
AccountId = currentUser.Id, Request
UserAgent = Request.Headers.UserAgent.ToString(), );
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
// Delete all file references for this realm // Delete all file references for this realm
var realmResourceId = $"realm:{realm.Id}"; var realmResourceId = $"realm:{realm.Id}";

View File

@@ -1,20 +1,18 @@
using DysonNetwork.Pass.Localization;
using DysonNetwork.Shared; using DysonNetwork.Shared;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry; using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere.Localization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
namespace DysonNetwork.Sphere.Realm; namespace DysonNetwork.Pass.Realm;
public class RealmService( public class RealmService(
AppDatabase db, AppDatabase db,
RingService.RingServiceClient pusher, RingService.RingServiceClient pusher,
AccountService.AccountServiceClient accounts,
IStringLocalizer<NotificationResource> localizer, IStringLocalizer<NotificationResource> localizer,
AccountClientHelper accountsHelper,
ICacheService cache ICacheService cache
) )
{ {
@@ -42,13 +40,18 @@ public class RealmService(
public async Task SendInviteNotify(SnRealmMember member) public async Task SendInviteNotify(SnRealmMember member)
{ {
var account = await accounts.GetAccountAsync(new GetAccountRequest { Id = member.AccountId.ToString() }); var account = await db.Accounts
CultureService.SetCultureInfo(account); .Include(a => a.Profile)
.FirstOrDefaultAsync(a => a.Id == member.AccountId);
if (account == null) throw new InvalidOperationException("Account not found");
CultureService.SetCultureInfo(account.Language);
await pusher.SendPushNotificationToUserAsync( await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest new SendPushNotificationToUserRequest
{ {
UserId = account.Id, UserId = account.Id.ToString(),
Notification = new PushNotification Notification = new PushNotification
{ {
Topic = "invites.realms", Topic = "invites.realms",
@@ -75,20 +78,26 @@ public class RealmService(
public async Task<SnRealmMember> LoadMemberAccount(SnRealmMember member) public async Task<SnRealmMember> LoadMemberAccount(SnRealmMember member)
{ {
var account = await accountsHelper.GetAccount(member.AccountId); var account = await db.Accounts
member.Account = SnAccount.FromProtoValue(account); .Include(a => a.Profile)
.FirstOrDefaultAsync(a => a.Id == member.AccountId);
if (account != null)
member.Account = account;
return member; return member;
} }
public async Task<List<SnRealmMember>> LoadMemberAccounts(ICollection<SnRealmMember> members) public async Task<List<SnRealmMember>> LoadMemberAccounts(ICollection<SnRealmMember> members)
{ {
var accountIds = members.Select(m => m.AccountId).ToList(); var accountIds = members.Select(m => m.AccountId).ToList();
var accounts = (await accountsHelper.GetAccountBatch(accountIds)).ToDictionary(a => Guid.Parse(a.Id), a => a); var accountsDict = await db.Accounts
.Include(a => a.Profile)
.Where(a => accountIds.Contains(a.Id))
.ToDictionaryAsync(a => a.Id, a => a);
return members.Select(m => return members.Select(m =>
{ {
if (accounts.TryGetValue(m.AccountId, out var account)) if (accountsDict.TryGetValue(m.AccountId, out var account))
m.Account = SnAccount.FromProtoValue(account); m.Account = account;
return m; return m;
}).ToList(); }).ToList();
} }

View File

@@ -0,0 +1,170 @@
using DysonNetwork.Shared.Proto;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.EntityFrameworkCore;
using DysonNetwork.Pass.Localization;
using DysonNetwork.Shared;
using DysonNetwork.Shared.Cache;
using Microsoft.Extensions.Localization;
namespace DysonNetwork.Pass.Realm;
public class RealmServiceGrpc(
AppDatabase db,
RingService.RingServiceClient pusher,
IStringLocalizer<NotificationResource> localizer,
ICacheService cache
)
: Shared.Proto.RealmService.RealmServiceBase
{
private const string CacheKeyPrefix = "account:realms:";
public override async Task<Shared.Proto.Realm> GetRealm(GetRealmRequest request, ServerCallContext context)
{
var realm = request.QueryCase switch
{
GetRealmRequest.QueryOneofCase.Id when !string.IsNullOrWhiteSpace(request.Id) => await db.Realms.FindAsync(
Guid.Parse(request.Id)),
GetRealmRequest.QueryOneofCase.Slug when !string.IsNullOrWhiteSpace(request.Slug) => await db.Realms
.FirstOrDefaultAsync(r => r.Slug == request.Slug),
_ => throw new RpcException(new Status(StatusCode.InvalidArgument, "Must provide either id or slug"))
};
return realm == null
? throw new RpcException(new Status(StatusCode.NotFound, "Realm not found"))
: realm.ToProtoValue();
}
public override async Task<GetRealmBatchResponse> GetRealmBatch(GetRealmBatchRequest request, ServerCallContext context)
{
var ids = request.Ids.Select(Guid.Parse).ToList();
var realms = await db.Realms.Where(r => ids.Contains(r.Id)).ToListAsync();
var response = new GetRealmBatchResponse();
response.Realms.AddRange(realms.Select(r => r.ToProtoValue()));
return response;
}
public override async Task<GetUserRealmsResponse> GetUserRealms(GetUserRealmsRequest request,
ServerCallContext context)
{
var accountId = Guid.Parse(request.AccountId);
var cacheKey = $"{CacheKeyPrefix}{accountId}";
var (found, cachedRealms) = await cache.GetAsyncWithStatus<List<Guid>>(cacheKey);
if (found && cachedRealms != null)
return new GetUserRealmsResponse { RealmIds = { cachedRealms.Select(g => g.ToString()) } };
var realms = await db.RealmMembers
.Include(m => m.Realm)
.Where(m => m.AccountId == accountId)
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
.Where(m => m.Realm != null)
.Select(m => m.Realm!.Id)
.ToListAsync();
// Cache the result for 5 minutes
await cache.SetAsync(cacheKey, realms, TimeSpan.FromMinutes(5));
return new GetUserRealmsResponse { RealmIds = { realms.Select(g => g.ToString()) } };
}
public override async Task<GetPublicRealmsResponse> GetPublicRealms(Empty request, ServerCallContext context)
{
var realms = await db.Realms.Where(r => r.IsPublic).ToListAsync();
var response = new GetPublicRealmsResponse();
response.Realms.AddRange(realms.Select(r => r.ToProtoValue()));
return response;
}
public override async Task<GetPublicRealmsResponse> SearchRealms(SearchRealmsRequest request, ServerCallContext context)
{
var realms = await db.Realms
.Where(r => r.IsPublic)
.Where(r => EF.Functions.Like(r.Slug, $"{request.Query}%") || EF.Functions.Like(r.Name, $"{request.Query}%"))
.Take(request.Limit)
.ToListAsync();
var response = new GetPublicRealmsResponse();
response.Realms.AddRange(realms.Select(r => r.ToProtoValue()));
return response;
}
public override async Task<Empty> SendInviteNotify(SendInviteNotifyRequest request, ServerCallContext context)
{
var member = request.Member;
var account = await db.Accounts
.AsNoTracking()
.Include(a => a.Profile)
.FirstOrDefaultAsync(a => a.Id == Guid.Parse(member.AccountId));
if (account == null) throw new RpcException(new Status(StatusCode.NotFound, "Account not found"));
CultureService.SetCultureInfo(account.Language);
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = account.Id.ToString(),
Notification = new PushNotification
{
Topic = "invites.realms",
Title = localizer["RealmInviteTitle"],
Body = localizer["RealmInviteBody", member.Realm?.Name ?? "Unknown Realm"],
ActionUri = "/realms",
IsSavable = true
}
}
);
return new Empty();
}
public override async Task<BoolValue> IsMemberWithRole(IsMemberWithRoleRequest request, ServerCallContext context)
{
if (request.RequiredRoles.Count == 0)
return new BoolValue { Value = false };
var maxRequiredRole = request.RequiredRoles.Max();
var member = await db.RealmMembers
.Where(m => m.RealmId == Guid.Parse(request.RealmId) && m.AccountId == Guid.Parse(request.AccountId) &&
m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
return new BoolValue { Value = member?.Role >= maxRequiredRole };
}
public override async Task<RealmMember> LoadMemberAccount(LoadMemberAccountRequest request,
ServerCallContext context)
{
var member = request.Member;
var account = await db.Accounts
.AsNoTracking()
.Include(a => a.Profile)
.FirstOrDefaultAsync(a => a.Id == Guid.Parse(member.AccountId));
var response = new RealmMember(member) { Account = account?.ToProtoValue() };
return response;
}
public override async Task<LoadMemberAccountsResponse> LoadMemberAccounts(LoadMemberAccountsRequest request,
ServerCallContext context)
{
var accountIds = request.Members.Select(m => Guid.Parse(m.AccountId)).ToList();
var accounts = await db.Accounts
.AsNoTracking()
.Include(a => a.Profile)
.Where(a => accountIds.Contains(a.Id))
.ToDictionaryAsync(a => a.Id, a => a.ToProtoValue());
var response = new LoadMemberAccountsResponse();
foreach (var member in request.Members)
{
var updatedMember = new RealmMember(member);
if (accounts.TryGetValue(Guid.Parse(member.AccountId), out var account))
{
updatedMember.Account = account;
}
response.Members.Add(updatedMember);
}
return response;
}
}

View File

@@ -3,6 +3,7 @@ using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Credit; using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Leveling; using DysonNetwork.Pass.Leveling;
using DysonNetwork.Pass.Permission; using DysonNetwork.Pass.Permission;
using DysonNetwork.Pass.Realm;
using DysonNetwork.Pass.Wallet; using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Http; using DysonNetwork.Shared.Http;
using Prometheus; using Prometheus;
@@ -21,7 +22,6 @@ public static class ApplicationConfiguration
app.ConfigureForwardedHeaders(configuration); app.ConfigureForwardedHeaders(configuration);
app.UseWebSockets(); app.UseWebSockets();
app.UseRateLimiter();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseMiddleware<PermissionMiddleware>(); app.UseMiddleware<PermissionMiddleware>();
@@ -42,6 +42,7 @@ public static class ApplicationConfiguration
app.MapGrpcService<BotAccountReceiverGrpc>(); app.MapGrpcService<BotAccountReceiverGrpc>();
app.MapGrpcService<WalletServiceGrpc>(); app.MapGrpcService<WalletServiceGrpc>();
app.MapGrpcService<PaymentServiceGrpc>(); app.MapGrpcService<PaymentServiceGrpc>();
app.MapGrpcService<RealmServiceGrpc>();
return app; return app;
} }

View File

@@ -104,9 +104,33 @@ public class BroadcastEventHandler(
logger.LogInformation("Subscription for order {OrderId} handled successfully.", evt.OrderId); logger.LogInformation("Subscription for order {OrderId} handled successfully.", evt.OrderId);
await msg.AckAsync(cancellationToken: stoppingToken); await msg.AckAsync(cancellationToken: stoppingToken);
} }
else if (evt.ProductIdentifier == "lottery")
{
logger.LogInformation("Handling lottery order: {OrderId}", evt.OrderId);
await using var scope = serviceProvider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var lotteries = scope.ServiceProvider.GetRequiredService<Lotteries.LotteryService>();
var order = await db.PaymentOrders.FindAsync(
[evt.OrderId],
cancellationToken: stoppingToken
);
if (order == null)
{
logger.LogWarning("Order with ID {OrderId} not found. Redelivering.", evt.OrderId);
await msg.NakAsync(cancellationToken: stoppingToken);
continue;
}
await lotteries.HandleLotteryOrder(order);
logger.LogInformation("Lottery ticket for order {OrderId} created successfully.", evt.OrderId);
await msg.AckAsync(cancellationToken: stoppingToken);
}
else else
{ {
// Not a subscription or gift order, skip // Not a subscription, gift, or lottery order, skip
continue; continue;
} }
} }

View File

@@ -66,6 +66,13 @@ public static class ScheduledJobsConfiguration
.WithIntervalInHours(1) .WithIntervalInHours(1)
.RepeatForever()) .RepeatForever())
); );
var lotteryDrawJob = new JobKey("LotteryDraw");
q.AddJob<Lotteries.LotteryDrawJob>(opts => opts.WithIdentity(lotteryDrawJob));
q.AddTrigger(opts => opts
.ForJob(lotteryDrawJob)
.WithIdentity("LotteryDrawTrigger")
.WithCronSchedule("0 0 0 * * ?"));
}); });
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);

View File

@@ -17,6 +17,7 @@ using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Handlers; using DysonNetwork.Pass.Handlers;
using DysonNetwork.Pass.Leveling; using DysonNetwork.Pass.Leveling;
using DysonNetwork.Pass.Mailer; using DysonNetwork.Pass.Mailer;
using DysonNetwork.Pass.Realm;
using DysonNetwork.Pass.Safety; using DysonNetwork.Pass.Safety;
using DysonNetwork.Pass.Wallet.PaymentHandlers; using DysonNetwork.Pass.Wallet.PaymentHandlers;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
@@ -91,19 +92,6 @@ public static class ServiceCollectionExtensions
return services; return services;
} }
public static IServiceCollection AddAppRateLimiting(this IServiceCollection services)
{
services.AddRateLimiter(o => o.AddFixedWindowLimiter(policyName: "fixed", opts =>
{
opts.Window = TimeSpan.FromMinutes(1);
opts.PermitLimit = 120;
opts.QueueLimit = 2;
opts.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
}));
return services;
}
public static IServiceCollection AddAppAuthentication(this IServiceCollection services) public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
{ {
services.AddAuthorization(); services.AddAuthorization();
@@ -152,6 +140,8 @@ public static class ServiceCollectionExtensions
services.AddScoped<SafetyService>(); services.AddScoped<SafetyService>();
services.AddScoped<SocialCreditService>(); services.AddScoped<SocialCreditService>();
services.AddScoped<ExperienceService>(); services.AddScoped<ExperienceService>();
services.AddScoped<RealmService>();
services.AddScoped<Lotteries.LotteryService>();
services.Configure<OidcProviderOptions>(configuration.GetSection("OidcProvider")); services.Configure<OidcProviderOptions>(configuration.GetSection("OidcProvider"));
services.AddScoped<OidcProviderService>(); services.AddScoped<OidcProviderService>();

View File

@@ -197,7 +197,8 @@ public class SubscriptionGiftController(
if (currentUser.Profile.Level < MinimumAccountLevel) if (currentUser.Profile.Level < MinimumAccountLevel)
{ {
return StatusCode(403, "Account level must be at least 60 to purchase a gift."); if (currentUser.PerkSubscription is null)
return StatusCode(403, "Account level must be at least 60 or a member of the Stellar Program to purchase a gift.");
} }
Duration? giftDuration = null; Duration? giftDuration = null;

View File

@@ -250,6 +250,14 @@ public class SubscriptionService(
: null; : null;
if (subscriptionInfo is null) throw new InvalidOperationException("No matching subscription found."); if (subscriptionInfo is null) throw new InvalidOperationException("No matching subscription found.");
if (subscriptionInfo.RequiredLevel > 0)
{
var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == subscription.AccountId);
if (profile is null) throw new InvalidOperationException("Account must have a profile");
if (profile.Level < subscriptionInfo.RequiredLevel)
throw new InvalidOperationException("Account level must be at least 60 to purchase a gift.");
}
return await payment.CreateOrderAsync( return await payment.CreateOrderAsync(
null, null,
subscriptionInfo.Currency, subscriptionInfo.Currency,
@@ -684,6 +692,9 @@ public class SubscriptionService(
if (now > gift.ExpiresAt) if (now > gift.ExpiresAt)
throw new InvalidOperationException("Gift has expired."); throw new InvalidOperationException("Gift has expired.");
if (gift.GifterId == redeemer.Id)
throw new InvalidOperationException("You cannot redeem your own gift.");
// Validate redeemer permissions // Validate redeemer permissions
if (!gift.IsOpenGift && gift.RecipientId != redeemer.Id) if (!gift.IsOpenGift && gift.RecipientId != redeemer.Id)
throw new InvalidOperationException("This gift is not intended for you."); throw new InvalidOperationException("This gift is not intended for you.");

View File

@@ -15,7 +15,7 @@
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" /> <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" />
<PackageReference Include="MailKit" Version="4.13.0" /> <PackageReference Include="MailKit" Version="4.13.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
@@ -31,8 +31,8 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="Quartz" Version="3.14.0" /> <PackageReference Include="Quartz" Version="3.14.0" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" /> <PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" /> <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.6" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -14,7 +14,6 @@ builder.ConfigureAppKestrel(builder.Configuration);
// Add application services // Add application services
builder.Services.AddAppServices(builder.Configuration); builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppRateLimiting();
builder.Services.AddAppAuthentication(); builder.Services.AddAppAuthentication();
builder.Services.AddDysonAuth(); builder.Services.AddDysonAuth();
builder.Services.AddAccountService(); builder.Services.AddAccountService();
@@ -45,6 +44,6 @@ app.ConfigureAppMiddleware(builder.Configuration);
// Configure gRPC // Configure gRPC
app.ConfigureGrpcServices(); app.ConfigureGrpcServices();
app.UseSwaggerManifest(); app.UseSwaggerManifest("DysonNetwork.Ring");
app.Run(); app.Run();

View File

@@ -12,7 +12,6 @@ public static class ApplicationConfiguration
app.ConfigureForwardedHeaders(configuration); app.ConfigureForwardedHeaders(configuration);
app.UseWebSockets(); app.UseWebSockets();
app.UseRateLimiter();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();

View File

@@ -50,19 +50,6 @@ public static class ServiceCollectionExtensions
return services; return services;
} }
public static IServiceCollection AddAppRateLimiting(this IServiceCollection services)
{
services.AddRateLimiter(o => o.AddFixedWindowLimiter(policyName: "fixed", opts =>
{
opts.Window = TimeSpan.FromMinutes(1);
opts.PermitLimit = 120;
opts.QueueLimit = 2;
opts.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
}));
return services;
}
public static IServiceCollection AddAppAuthentication(this IServiceCollection services) public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
{ {
services.AddAuthorization(); services.AddAuthorization();

View File

@@ -20,7 +20,7 @@
</PackageReference> </PackageReference>
<PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" /> <PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.3.0" /> <PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.7" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.7" />
<PackageReference Include="NATS.Net" Version="2.6.8" /> <PackageReference Include="NATS.Net" Version="2.6.8" />
<PackageReference Include="NodaTime" Version="3.2.2" /> <PackageReference Include="NodaTime" Version="3.2.2" />
@@ -29,20 +29,20 @@
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" /> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="OpenGraph-Net" Version="4.0.1" /> <PackageReference Include="OpenGraph-Net" Version="4.0.1" />
<PackageReference Include="Otp.NET" Version="1.4.0" /> <PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" /> <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.6" />
<PackageReference Include="System.Net.Http" Version="4.3.4" /> <PackageReference Include="System.Net.Http" Version="4.3.4" />
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" /> <PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
<PackageReference Include="Aspire.NATS.Net" Version="9.4.2" /> <PackageReference Include="Aspire.NATS.Net" Version="9.5.1" />
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.4.2" /> <PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.5.1" />
<PackageReference Include="Aspire.StackExchange.Redis" Version="9.4.2" /> <PackageReference Include="Aspire.StackExchange.Redis" Version="9.5.1" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.7.0"/> <PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.7.0"/>
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="9.4.2"/> <PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="9.4.2"/>
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/> <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0"/> <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0"/> <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.GrpcNetClient" Version="1.12.0-beta.1" /> <PackageReference Include="OpenTelemetry.Instrumentation.GrpcNetClient" Version="1.12.0-beta.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/> <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/> <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/>

View File

@@ -56,7 +56,7 @@ public static class SwaggerGen
return builder; return builder;
} }
public static WebApplication UseSwaggerManifest(this WebApplication app) public static WebApplication UseSwaggerManifest(this WebApplication app, string serviceName)
{ {
app.MapOpenApi(); app.MapOpenApi();
@@ -103,7 +103,7 @@ public static class SwaggerGen
var publicBasePath = configuration["Swagger:PublicBasePath"]?.TrimEnd('/') ?? ""; var publicBasePath = configuration["Swagger:PublicBasePath"]?.TrimEnd('/') ?? "";
options.SwaggerEndpoint( options.SwaggerEndpoint(
$"{publicBasePath}/swagger/v1/swagger.json", $"{publicBasePath}/swagger/v1/swagger.json",
"Develop API v1"); $"{serviceName} API v1");
}); });
return app; return app;

View File

@@ -148,6 +148,32 @@ public class UsernameColor
public string? Value { get; set; } // e.g. "red" or "#ff6600" public string? Value { get; set; } // e.g. "red" or "#ff6600"
public string? Direction { get; set; } // e.g. "to right" public string? Direction { get; set; } // e.g. "to right"
public List<string>? Colors { get; set; } // e.g. ["#ff0000", "#00ff00"] public List<string>? Colors { get; set; } // e.g. ["#ff0000", "#00ff00"]
public Proto.UsernameColor ToProtoValue()
{
var proto = new Proto.UsernameColor
{
Type = Type,
Value = Value,
Direction = Direction,
};
if (Colors is not null)
{
proto.Colors.AddRange(Colors);
}
return proto;
}
public static UsernameColor FromProtoValue(Proto.UsernameColor proto)
{
return new UsernameColor
{
Type = proto.Type,
Value = proto.Value,
Direction = proto.Direction,
Colors = proto.Colors?.ToList()
};
}
} }
public class SnAccountProfile : ModelBase, IIdentifiedResource public class SnAccountProfile : ModelBase, IIdentifiedResource
@@ -218,6 +244,7 @@ public class SnAccountProfile : ModelBase, IIdentifiedResource
AccountId = AccountId.ToString(), AccountId = AccountId.ToString(),
Verification = Verification?.ToProtoValue(), Verification = Verification?.ToProtoValue(),
ActiveBadge = ActiveBadge?.ToProtoValue(), ActiveBadge = ActiveBadge?.ToProtoValue(),
UsernameColor = UsernameColor?.ToProtoValue(),
CreatedAt = CreatedAt.ToTimestamp(), CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp() UpdatedAt = UpdatedAt.ToTimestamp()
}; };
@@ -247,6 +274,7 @@ public class SnAccountProfile : ModelBase, IIdentifiedResource
Picture = proto.Picture is null ? null : SnCloudFileReferenceObject.FromProtoValue(proto.Picture), Picture = proto.Picture is null ? null : SnCloudFileReferenceObject.FromProtoValue(proto.Picture),
Background = proto.Background is null ? null : SnCloudFileReferenceObject.FromProtoValue(proto.Background), Background = proto.Background is null ? null : SnCloudFileReferenceObject.FromProtoValue(proto.Background),
AccountId = Guid.Parse(proto.AccountId), AccountId = Guid.Parse(proto.AccountId),
UsernameColor = proto.UsernameColor is not null ? UsernameColor.FromProtoValue(proto.UsernameColor) : null,
CreatedAt = proto.CreatedAt.ToInstant(), CreatedAt = proto.CreatedAt.ToInstant(),
UpdatedAt = proto.UpdatedAt.ToInstant() UpdatedAt = proto.UpdatedAt.ToInstant()
}; };

View File

@@ -30,7 +30,7 @@ public class SnChatRoom : ModelBase, IIdentifiedResource
[JsonIgnore] public ICollection<SnChatMember> Members { get; set; } = new List<SnChatMember>(); [JsonIgnore] public ICollection<SnChatMember> Members { get; set; } = new List<SnChatMember>();
public Guid? RealmId { get; set; } public Guid? RealmId { get; set; }
public SnRealm? Realm { get; set; } [NotMapped] public SnRealm? Realm { get; set; }
[NotMapped] [NotMapped]
[JsonPropertyName("members")] [JsonPropertyName("members")]

View File

@@ -64,7 +64,7 @@ public class SnPost : ModelBase, IIdentifiedResource, IActivity
public SnPost? ForwardedPost { get; set; } public SnPost? ForwardedPost { get; set; }
public Guid? RealmId { get; set; } public Guid? RealmId { get; set; }
public SnRealm? Realm { get; set; } [NotMapped] public SnRealm? Realm { get; set; }
[Column(TypeName = "jsonb")] public List<SnCloudFileReferenceObject> Attachments { get; set; } = []; [Column(TypeName = "jsonb")] public List<SnCloudFileReferenceObject> Attachments { get; set; } = [];
@@ -73,11 +73,12 @@ public class SnPost : ModelBase, IIdentifiedResource, IActivity
public Guid PublisherId { get; set; } public Guid PublisherId { get; set; }
public SnPublisher Publisher { get; set; } = null!; public SnPublisher Publisher { get; set; } = null!;
public ICollection<SnPostAward> Awards { get; set; } = null!; public List<SnPostAward> Awards { get; set; } = null!;
[JsonIgnore] public ICollection<SnPostReaction> Reactions { get; set; } = new List<SnPostReaction>(); [JsonIgnore] public List<SnPostReaction> Reactions { get; set; } = [];
public ICollection<SnPostTag> Tags { get; set; } = new List<SnPostTag>(); public List<SnPostTag> Tags { get; set; } = [];
public ICollection<SnPostCategory> Categories { get; set; } = new List<SnPostCategory>(); public List<SnPostCategory> Categories { get; set; } = [];
[JsonIgnore] public ICollection<SnPostCollection> Collections { get; set; } = new List<SnPostCollection>(); [JsonIgnore] public List<SnPostCollection> Collections { get; set; } = [];
public List<SnPostFeaturedRecord> FeaturedRecords { get; set; } = [];
[JsonIgnore] public bool Empty => Content == null && Attachments.Count == 0 && ForwardedPostId == null; [JsonIgnore] public bool Empty => Content == null && Attachments.Count == 0 && ForwardedPostId == null;
[NotMapped] public bool IsTruncated { get; set; } = false; [NotMapped] public bool IsTruncated { get; set; } = false;
@@ -104,7 +105,7 @@ public class SnPostTag : ModelBase
public Guid Id { get; set; } public Guid Id { get; set; }
[MaxLength(128)] public string Slug { get; set; } = null!; [MaxLength(128)] public string Slug { get; set; } = null!;
[MaxLength(256)] public string? Name { get; set; } [MaxLength(256)] public string? Name { get; set; }
[JsonIgnore] public ICollection<SnPost> Posts { get; set; } = new List<SnPost>(); [JsonIgnore] public List<SnPost> Posts { get; set; } = new List<SnPost>();
[NotMapped] public int? Usage { get; set; } [NotMapped] public int? Usage { get; set; }
} }
@@ -114,7 +115,7 @@ public class SnPostCategory : ModelBase
public Guid Id { get; set; } public Guid Id { get; set; }
[MaxLength(128)] public string Slug { get; set; } = null!; [MaxLength(128)] public string Slug { get; set; } = null!;
[MaxLength(256)] public string? Name { get; set; } [MaxLength(256)] public string? Name { get; set; }
[JsonIgnore] public ICollection<SnPost> Posts { get; set; } = new List<SnPost>(); [JsonIgnore] public List<SnPost> Posts { get; set; } = new List<SnPost>();
[NotMapped] public int? Usage { get; set; } [NotMapped] public int? Usage { get; set; }
} }
@@ -139,15 +140,14 @@ public class SnPostCollection : ModelBase
public SnPublisher Publisher { get; set; } = null!; public SnPublisher Publisher { get; set; } = null!;
public ICollection<SnPost> Posts { get; set; } = new List<SnPost>(); public List<SnPost> Posts { get; set; } = new List<SnPost>();
} }
public class SnPostFeaturedRecord : ModelBase public class SnPostFeaturedRecord : ModelBase
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public Guid PostId { get; set; } public Guid PostId { get; set; }
public SnPost Post { get; set; } = null!; [JsonIgnore] public SnPost Post { get; set; } = null!;
public Instant? FeaturedAt { get; set; } public Instant? FeaturedAt { get; set; }
public int SocialCredits { get; set; } public int SocialCredits { get; set; }
} }
@@ -168,6 +168,7 @@ public class SnPostReaction : ModelBase
public Guid PostId { get; set; } public Guid PostId { get; set; }
[JsonIgnore] public SnPost Post { get; set; } = null!; [JsonIgnore] public SnPost Post { get; set; } = null!;
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
[NotMapped] public SnAccount? Account { get; set; }
} }
public class SnPostAward : ModelBase public class SnPostAward : ModelBase

View File

@@ -42,7 +42,7 @@ public class SnPublisher : ModelBase, IIdentifiedResource
public Guid? AccountId { get; set; } public Guid? AccountId { get; set; }
public Guid? RealmId { get; set; } public Guid? RealmId { get; set; }
[JsonIgnore] public SnRealm? Realm { get; set; } [NotMapped] public SnRealm? Realm { get; set; }
[NotMapped] public SnAccount? Account { get; set; } [NotMapped] public SnAccount? Account { get; set; }
public string ResourceIdentifier => $"publisher:{Id}"; public string ResourceIdentifier => $"publisher:{Id}";

View File

@@ -1,8 +1,10 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Shared.Models; namespace DysonNetwork.Shared.Models;
@@ -26,11 +28,35 @@ public class SnRealm : ModelBase, IIdentifiedResource
[Column(TypeName = "jsonb")] public SnVerificationMark? Verification { get; set; } [Column(TypeName = "jsonb")] public SnVerificationMark? Verification { get; set; }
[JsonIgnore] public ICollection<SnRealmMember> Members { get; set; } = new List<SnRealmMember>(); [JsonIgnore] public ICollection<SnRealmMember> Members { get; set; } = new List<SnRealmMember>();
[JsonIgnore] public ICollection<SnChatRoom> ChatRooms { get; set; } = new List<SnChatRoom>();
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
public string ResourceIdentifier => $"realm:{Id}"; public string ResourceIdentifier => $"realm:{Id}";
public Realm ToProtoValue()
{
return new Realm
{
Id = Id.ToString(),
Name = Name,
Slug = Slug,
IsCommunity = IsCommunity,
IsPublic = IsPublic
};
}
public static SnRealm FromProtoValue(Realm proto)
{
return new SnRealm
{
Id = Guid.Parse(proto.Id),
Name = proto.Name,
Slug = proto.Slug,
Description = "",
IsCommunity = proto.IsCommunity,
IsPublic = proto.IsPublic
};
}
} }
public abstract class RealmMemberRole public abstract class RealmMemberRole
@@ -51,4 +77,40 @@ public class SnRealmMember : ModelBase
public int Role { get; set; } = RealmMemberRole.Normal; public int Role { get; set; } = RealmMemberRole.Normal;
public Instant? JoinedAt { get; set; } public Instant? JoinedAt { get; set; }
public Instant? LeaveAt { get; set; } public Instant? LeaveAt { get; set; }
public Proto.RealmMember ToProtoValue()
{
var proto = new Proto.RealmMember
{
AccountId = AccountId.ToString(),
RealmId = RealmId.ToString(),
Role = Role,
JoinedAt = JoinedAt?.ToTimestamp(),
LeaveAt = LeaveAt?.ToTimestamp(),
Realm = Realm.ToProtoValue()
};
if (Account != null)
{
proto.Account = Account.ToProtoValue();
}
return proto;
}
public static SnRealmMember FromProtoValue(RealmMember proto)
{
var member = new SnRealmMember
{
AccountId = Guid.Parse(proto.AccountId),
RealmId = Guid.Parse(proto.RealmId),
Role = proto.Role,
JoinedAt = proto.JoinedAt?.ToInstant(),
LeaveAt = proto.LeaveAt?.ToInstant(),
Realm = proto.Realm != null ? SnRealm.FromProtoValue(proto.Realm) : new SnRealm() // Provide default or handle null
};
if (proto.Account != null)
{
member.Account = SnAccount.FromProtoValue(proto.Account);
}
return member;
}
} }

View File

@@ -0,0 +1,54 @@
using NodaTime;
namespace DysonNetwork.Shared.Models;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
public enum LotteryDrawStatus
{
Pending = 0,
Drawn = 1
}
public class SnLotteryRecord : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public Instant DrawDate { get; set; } // Date of the draw
[Column(TypeName = "jsonb")]
public List<int> WinningRegionOneNumbers { get; set; } = new(); // 5 winning numbers
[Range(0, 99)]
public int WinningRegionTwoNumber { get; set; } // 1 winning number
public int TotalTickets { get; set; } // Total tickets processed for this draw
public int TotalPrizesAwarded { get; set; } // Total prizes awarded
public long TotalPrizeAmount { get; set; } // Total ISP prize amount awarded
}
public class SnLottery : ModelBase
{
public Guid Id { get; init; } = Guid.NewGuid();
public SnAccount Account { get; init; } = null!;
public Guid AccountId { get; init; }
[Column(TypeName = "jsonb")]
public List<int> RegionOneNumbers { get; set; } = []; // 5 numbers, 0-99, unique
[Range(0, 99)]
public int RegionTwoNumber { get; init; } // 1 number, 0-99, can repeat
public int Multiplier { get; init; } = 1; // Default 1x
public LotteryDrawStatus DrawStatus { get; set; } = LotteryDrawStatus.Pending; // Status to track draw processing
public Instant? DrawDate { get; set; } // Date when this ticket was drawn
[Column(TypeName = "jsonb")]
public List<int>? MatchedRegionOneNumbers { get; set; } // The actual numbers that matched in region one
public int? MatchedRegionTwoNumber { get; set; } // The matched number if special number matched (null otherwise)
}

View File

@@ -59,6 +59,13 @@ message AccountStatus {
bytes meta = 10; bytes meta = 10;
} }
message UsernameColor {
string type = 1;
google.protobuf.StringValue value = 2;
google.protobuf.StringValue direction = 3;
repeated string colors = 4;
}
// Profile contains detailed information about a user // Profile contains detailed information about a user
message AccountProfile { message AccountProfile {
string id = 1; string id = 1;
@@ -89,6 +96,7 @@ message AccountProfile {
google.protobuf.Timestamp created_at = 22; google.protobuf.Timestamp created_at = 22;
google.protobuf.Timestamp updated_at = 23; google.protobuf.Timestamp updated_at = 23;
optional UsernameColor username_color = 24;
} }
// AccountContact represents a contact method for an account // AccountContact represents a contact method for an account

View File

@@ -0,0 +1,110 @@
syntax = "proto3";
package proto;
option csharp_namespace = "DysonNetwork.Shared.Proto";
import "google/protobuf/timestamp.proto";
import "google/protobuf/wrappers.proto";
import "google/protobuf/empty.proto";
import 'account.proto';
// Message Definitions
message Realm {
string id = 1;
string name = 2;
string slug = 3;
bool is_community = 4;
bool is_public = 5;
}
message RealmMember {
string account_id = 1;
string realm_id = 2;
int32 role = 3;
optional google.protobuf.Timestamp joined_at = 4;
optional google.protobuf.Timestamp leave_at = 5;
optional Account account = 6;
optional Realm realm = 7;
}
// Service Definitions
service RealmService {
// Get realm by id or slug
rpc GetRealm(GetRealmRequest) returns (Realm) {}
// Get realm batch by ids
rpc GetRealmBatch(GetRealmBatchRequest) returns (GetRealmBatchResponse) {}
// Get realms for a user
rpc GetUserRealms(GetUserRealmsRequest) returns (GetUserRealmsResponse) {}
// Get public realms
rpc GetPublicRealms(google.protobuf.Empty) returns (GetPublicRealmsResponse) {}
// Search public realms
rpc SearchRealms(SearchRealmsRequest) returns (GetPublicRealmsResponse) {}
// Send invitation notification
rpc SendInviteNotify(SendInviteNotifyRequest) returns (google.protobuf.Empty) {}
// Check if member has required role
rpc IsMemberWithRole(IsMemberWithRoleRequest) returns (google.protobuf.BoolValue) {}
// Load account for a member
rpc LoadMemberAccount(LoadMemberAccountRequest) returns (RealmMember) {}
// Load accounts for members
rpc LoadMemberAccounts(LoadMemberAccountsRequest) returns (LoadMemberAccountsResponse) {}
}
// Request/Response Messages
message GetRealmRequest {
oneof query {
string id = 1;
string slug = 2;
}
}
message GetUserRealmsRequest {
string account_id = 1;
}
message GetRealmBatchRequest {
repeated string ids = 1;
}
message GetRealmBatchResponse {
repeated Realm realms = 1;
}
message GetUserRealmsResponse {
repeated string realm_ids = 1;
}
message GetPublicRealmsResponse {
repeated Realm realms = 1;
}
message SearchRealmsRequest {
string query = 1;
int32 limit = 2;
}
message SendInviteNotifyRequest {
RealmMember member = 1;
}
message IsMemberWithRoleRequest {
string realm_id = 1;
string account_id = 2;
repeated int32 required_roles = 3;
}
message LoadMemberAccountRequest {
RealmMember member = 1;
}
message LoadMemberAccountsRequest {
repeated RealmMember members = 1;
}
message LoadMemberAccountsResponse {
repeated RealmMember members = 1;
}

View File

@@ -3,7 +3,7 @@ using DysonNetwork.Shared.Proto;
namespace DysonNetwork.Shared.Registry; namespace DysonNetwork.Shared.Registry;
public class AccountClientHelper(AccountService.AccountServiceClient accounts) public class RemoteAccountService(AccountService.AccountServiceClient accounts)
{ {
public async Task<Account> GetAccount(Guid id) public async Task<Account> GetAccount(Guid id)
{ {

View File

@@ -0,0 +1,82 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Google.Protobuf.WellKnownTypes;
namespace DysonNetwork.Shared.Registry;
public class RemoteRealmService(RealmService.RealmServiceClient realms)
{
public async Task<SnRealm> GetRealm(string id)
{
var request = new GetRealmRequest { Id = id };
var response = await realms.GetRealmAsync(request);
return SnRealm.FromProtoValue(response);
}
public async Task<SnRealm> GetRealmBySlug(string slug)
{
var request = new GetRealmRequest { Slug = slug };
var response = await realms.GetRealmAsync(request);
return SnRealm.FromProtoValue(response);
}
public async Task<List<Guid>> GetUserRealms(Guid accountId)
{
var request = new GetUserRealmsRequest { AccountId = accountId.ToString() };
var response = await realms.GetUserRealmsAsync(request);
return response.RealmIds.Select(Guid.Parse).ToList();
}
public async Task<List<SnRealm>> GetPublicRealms()
{
var response = await realms.GetPublicRealmsAsync(new Empty());
return response.Realms.Select(SnRealm.FromProtoValue).ToList();
}
public async Task<List<SnRealm>> SearchRealms(string query, int limit)
{
var request = new SearchRealmsRequest { Query = query, Limit = limit };
var response = await realms.SearchRealmsAsync(request);
return response.Realms.Select(SnRealm.FromProtoValue).ToList();
}
public async Task<List<SnRealm>> GetRealmBatch(List<string> ids)
{
var request = new GetRealmBatchRequest();
request.Ids.AddRange(ids);
var response = await realms.GetRealmBatchAsync(request);
return response.Realms.Select(SnRealm.FromProtoValue).ToList();
}
public async Task SendInviteNotify(SnRealmMember member)
{
var protoMember = member.ToProtoValue();
var request = new SendInviteNotifyRequest { Member = protoMember };
await realms.SendInviteNotifyAsync(request);
}
public async Task<bool> IsMemberWithRole(Guid realmId, Guid accountId, List<int> requiredRoles)
{
var request = new IsMemberWithRoleRequest { RealmId = realmId.ToString(), AccountId = accountId.ToString() };
request.RequiredRoles.AddRange(requiredRoles);
var response = await realms.IsMemberWithRoleAsync(request);
return response.Value;
}
public async Task<SnRealmMember> LoadMemberAccount(SnRealmMember member)
{
var protoMember = member.ToProtoValue();
var request = new LoadMemberAccountRequest { Member = protoMember };
var response = await realms.LoadMemberAccountAsync(request);
return SnRealmMember.FromProtoValue(response);
}
public async Task<List<SnRealmMember>> LoadMemberAccounts(List<SnRealmMember> members)
{
var protoMembers = members.Select(m => m.ToProtoValue()).ToList();
var request = new LoadMemberAccountsRequest();
request.Members.AddRange(protoMembers);
var response = await realms.LoadMemberAccountsAsync(request);
return response.Members.Select(SnRealmMember.FromProtoValue).ToList();
}
}

View File

@@ -40,7 +40,7 @@ public static class ServiceInjectionHelper
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler() .ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true } { ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
); );
services.AddSingleton<AccountClientHelper>(); services.AddSingleton<RemoteAccountService>();
services services
.AddGrpcClient<BotAccountReceiverService.BotAccountReceiverServiceClient>(o => .AddGrpcClient<BotAccountReceiverService.BotAccountReceiverServiceClient>(o =>
@@ -60,6 +60,13 @@ public static class ServiceInjectionHelper
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true } { ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
); );
services
.AddGrpcClient<RealmService.RealmServiceClient>(o => o.Address = new Uri("https://_grpc.pass"))
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
);
services.AddSingleton<RemoteRealmService>();
return services; return services;
} }
@@ -70,7 +77,8 @@ public static class ServiceInjectionHelper
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true } { ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
); );
services.AddGrpcClient<FileReferenceService.FileReferenceServiceClient>(o => o.Address = new Uri("https://_grpc.drive")) services.AddGrpcClient<FileReferenceService.FileReferenceServiceClient>(o =>
o.Address = new Uri("https://_grpc.drive"))
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler() .ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true } { ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
); );
@@ -80,7 +88,8 @@ public static class ServiceInjectionHelper
public static IServiceCollection AddPublisherService(this IServiceCollection services) public static IServiceCollection AddPublisherService(this IServiceCollection services)
{ {
services.AddGrpcClient<PublisherService.PublisherServiceClient>(o => o.Address = new Uri("https://_grpc.sphere")) services
.AddGrpcClient<PublisherService.PublisherServiceClient>(o => o.Address = new Uri("https://_grpc.sphere"))
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler() .ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true } { ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
); );
@@ -90,7 +99,8 @@ public static class ServiceInjectionHelper
public static IServiceCollection AddDevelopService(this IServiceCollection services) public static IServiceCollection AddDevelopService(this IServiceCollection services)
{ {
services.AddGrpcClient<CustomAppService.CustomAppServiceClient>(o => o.Address = new Uri("https://_grpc.develop")) services.AddGrpcClient<CustomAppService.CustomAppServiceClient>(o =>
o.Address = new Uri("https://_grpc.develop"))
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler() .ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true } { ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
); );

View File

@@ -1,8 +1,8 @@
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere.Discovery; using DysonNetwork.Sphere.Discovery;
using DysonNetwork.Sphere.Post; using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Realm;
using DysonNetwork.Sphere.WebReader; using DysonNetwork.Sphere.WebReader;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
@@ -13,7 +13,7 @@ public class ActivityService(
AppDatabase db, AppDatabase db,
Publisher.PublisherService pub, Publisher.PublisherService pub,
PostService ps, PostService ps,
RealmService rs, RemoteRealmService rs,
DiscoveryService ds, DiscoveryService ds,
AccountService.AccountServiceClient accounts AccountService.AccountServiceClient accounts
) )
@@ -40,19 +40,15 @@ public class ActivityService(
debugInclude ??= new HashSet<string>(); debugInclude ??= new HashSet<string>();
// Get and process posts // Get and process posts
var postsQuery = db.Posts var publicRealms = await rs.GetPublicRealms();
.Include(e => e.RepliedPost) var publicRealmIds = publicRealms.Select(r => r.Id).ToList();
.Include(e => e.ForwardedPost)
.Include(e => e.Categories) var postsQuery = BuildPostsQuery(cursor, null, publicRealmIds)
.Include(e => e.Tags)
.Include(e => e.Realm)
.Where(e => e.RepliedPostId == null)
.Where(p => cursor == null || p.PublishedAt < cursor)
.OrderByDescending(p => p.PublishedAt)
.FilterWithVisibility(null, [], [], isListing: true) .FilterWithVisibility(null, [], [], isListing: true)
.Take(take * 5); .Take(take * 5);
var posts = await GetAndProcessPosts(postsQuery); var posts = await GetAndProcessPosts(postsQuery);
await LoadPostsRealmsAsync(posts, rs);
posts = RankPosts(posts, take); posts = RankPosts(posts, take);
var interleaved = new List<SnActivity>(); var interleaved = new List<SnActivity>();
@@ -102,7 +98,7 @@ public class ActivityService(
var userRealms = await rs.GetUserRealms(Guid.Parse(currentUser.Id)); var userRealms = await rs.GetUserRealms(Guid.Parse(currentUser.Id));
// Build and execute the posts query // Build and execute the post query
var postsQuery = BuildPostsQuery(cursor, filteredPublishersId, userRealms); var postsQuery = BuildPostsQuery(cursor, filteredPublishersId, userRealms);
// Apply visibility filtering and execute // Apply visibility filtering and execute
@@ -118,10 +114,10 @@ public class ActivityService(
var posts = await GetAndProcessPosts( var posts = await GetAndProcessPosts(
postsQuery, postsQuery,
currentUser, currentUser,
userFriends,
userPublishers,
trackViews: true); trackViews: true);
await LoadPostsRealmsAsync(posts, rs);
posts = RankPosts(posts, take); posts = RankPosts(posts, take);
var interleaved = new List<SnActivity>(); var interleaved = new List<SnActivity>();
@@ -219,15 +215,19 @@ public class ActivityService(
private async Task<SnActivity?> GetShuffledPostsActivity(int count = 5) private async Task<SnActivity?> GetShuffledPostsActivity(int count = 5)
{ {
var publicRealms = await rs.GetPublicRealms();
var publicRealmIds = publicRealms.Select(r => r.Id).ToList();
var postsQuery = db.Posts var postsQuery = db.Posts
.Include(p => p.Categories) .Include(p => p.Categories)
.Include(p => p.Tags) .Include(p => p.Tags)
.Include(p => p.Realm)
.Where(p => p.RepliedPostId == null) .Where(p => p.RepliedPostId == null)
.Where(p => p.RealmId == null || publicRealmIds.Contains(p.RealmId.Value))
.OrderBy(_ => EF.Functions.Random()) .OrderBy(_ => EF.Functions.Random())
.Take(count); .Take(count);
var posts = await GetAndProcessPosts(postsQuery, trackViews: false); var posts = await GetAndProcessPosts(postsQuery, trackViews: false);
await LoadPostsRealmsAsync(posts, rs);
return posts.Count == 0 return posts.Count == 0
? null ? null
@@ -272,8 +272,6 @@ public class ActivityService(
private async Task<List<SnPost>> GetAndProcessPosts( private async Task<List<SnPost>> GetAndProcessPosts(
IQueryable<SnPost> baseQuery, IQueryable<SnPost> baseQuery,
Account? currentUser = null, Account? currentUser = null,
List<Guid>? userFriends = null,
List<Shared.Models.SnPublisher>? userPublishers = null,
bool trackViews = true) bool trackViews = true)
{ {
var posts = await baseQuery.ToListAsync(); var posts = await baseQuery.ToListAsync();
@@ -306,7 +304,7 @@ public class ActivityService(
.Include(e => e.ForwardedPost) .Include(e => e.ForwardedPost)
.Include(e => e.Categories) .Include(e => e.Categories)
.Include(e => e.Tags) .Include(e => e.Tags)
.Include(e => e.Realm) .Include(e => e.FeaturedRecords)
.Where(e => e.RepliedPostId == null) .Where(e => e.RepliedPostId == null)
.Where(p => cursor == null || p.PublishedAt < cursor) .Where(p => cursor == null || p.PublishedAt < cursor)
.OrderByDescending(p => p.PublishedAt) .OrderByDescending(p => p.PublishedAt)
@@ -315,10 +313,14 @@ public class ActivityService(
if (filteredPublishersId != null && filteredPublishersId.Count != 0) if (filteredPublishersId != null && filteredPublishersId.Count != 0)
query = query.Where(p => filteredPublishersId.Contains(p.PublisherId)); query = query.Where(p => filteredPublishersId.Contains(p.PublisherId));
if (userRealms == null) if (userRealms == null)
query = query.Where(p => p.Realm == null || p.Realm.IsPublic); {
// For anonymous users, only show public realm posts or posts without realm
// Get public realm ids in the caller and pass them
query = query.Where(p => p.RealmId == null); // Modify in caller
}
else else
query = query.Where(p => query = query.Where(p =>
p.Realm == null || p.Realm.IsPublic || p.RealmId == null || userRealms.Contains(p.RealmId.Value)); p.RealmId == null || userRealms.Contains(p.RealmId.Value));
return query; return query;
} }
@@ -339,6 +341,23 @@ public class ActivityService(
}; };
} }
private static async Task LoadPostsRealmsAsync(List<SnPost> posts, RemoteRealmService rs)
{
var postRealmIds = posts.Where(p => p.RealmId != null).Select(p => p.RealmId.Value).Distinct().ToList();
if (!postRealmIds.Any()) return;
var realms = await rs.GetRealmBatch(postRealmIds.Select(id => id.ToString()).ToList());
var realmDict = realms.ToDictionary(r => r.Id, r => r);
foreach (var post in posts.Where(p => p.RealmId != null))
{
if (post.RealmId != null && realmDict.TryGetValue(post.RealmId.Value, out var realm))
{
post.Realm = realm;
}
}
}
private static double CalculatePopularity(List<SnPost> posts) private static double CalculatePopularity(List<SnPost> posts)
{ {
var score = posts.Sum(p => p.Upvotes - p.Downvotes); var score = posts.Sum(p => p.Upvotes - p.Downvotes);

View File

@@ -1,6 +1,7 @@
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Reflection; using System.Reflection;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.WebReader;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Query; using Microsoft.EntityFrameworkCore.Query;
@@ -33,26 +34,23 @@ public class AppDatabase(
public DbSet<SnPostFeaturedRecord> PostFeaturedRecords { get; set; } = null!; public DbSet<SnPostFeaturedRecord> PostFeaturedRecords { get; set; } = null!;
public DbSet<SnPostCategorySubscription> PostCategorySubscriptions { get; set; } = null!; public DbSet<SnPostCategorySubscription> PostCategorySubscriptions { get; set; } = null!;
public DbSet<Shared.Models.SnPoll> Polls { get; set; } = null!; public DbSet<SnPoll> Polls { get; set; } = null!;
public DbSet<SnPollQuestion> PollQuestions { get; set; } = null!; public DbSet<SnPollQuestion> PollQuestions { get; set; } = null!;
public DbSet<SnPollAnswer> PollAnswers { get; set; } = null!; public DbSet<SnPollAnswer> PollAnswers { get; set; } = null!;
public DbSet<Shared.Models.SnRealm> Realms { get; set; } = null!;
public DbSet<SnRealmMember> RealmMembers { get; set; } = null!;
public DbSet<SnChatRoom> ChatRooms { get; set; } = null!; public DbSet<SnChatRoom> ChatRooms { get; set; } = null!;
public DbSet<SnChatMember> ChatMembers { get; set; } = null!; public DbSet<SnChatMember> ChatMembers { get; set; } = null!;
public DbSet<SnChatMessage> ChatMessages { get; set; } = null!; public DbSet<SnChatMessage> ChatMessages { get; set; } = null!;
public DbSet<SnRealtimeCall> ChatRealtimeCall { get; set; } = null!; public DbSet<SnRealtimeCall> ChatRealtimeCall { get; set; } = null!;
public DbSet<SnChatMessageReaction> ChatReactions { get; set; } = null!; public DbSet<SnChatMessageReaction> ChatReactions { get; set; } = null!;
public DbSet<Shared.Models.SnSticker> Stickers { get; set; } = null!; public DbSet<SnSticker> Stickers { get; set; } = null!;
public DbSet<StickerPack> StickerPacks { get; set; } = null!; public DbSet<StickerPack> StickerPacks { get; set; } = null!;
public DbSet<StickerPackOwnership> StickerPackOwnerships { get; set; } = null!; public DbSet<StickerPackOwnership> StickerPackOwnerships { get; set; } = null!;
public DbSet<WebReader.WebArticle> WebArticles { get; set; } = null!; public DbSet<WebArticle> WebArticles { get; set; } = null!;
public DbSet<WebReader.WebFeed> WebFeeds { get; set; } = null!; public DbSet<WebFeed> WebFeeds { get; set; } = null!;
public DbSet<WebReader.WebFeedSubscription> WebFeedSubscriptions { get; set; } = null!; public DbSet<WebFeedSubscription> WebFeedSubscriptions { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
@@ -112,14 +110,6 @@ public class AppDatabase(
.WithMany(c => c.Posts) .WithMany(c => c.Posts)
.UsingEntity(j => j.ToTable("post_collection_links")); .UsingEntity(j => j.ToTable("post_collection_links"));
modelBuilder.Entity<SnRealmMember>()
.HasKey(pm => new { pm.RealmId, pm.AccountId });
modelBuilder.Entity<SnRealmMember>()
.HasOne(pm => pm.Realm)
.WithMany(p => p.Members)
.HasForeignKey(pm => pm.RealmId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<SnChatMember>() modelBuilder.Entity<SnChatMember>()
.HasKey(pm => new { pm.Id }); .HasKey(pm => new { pm.Id });
modelBuilder.Entity<SnChatMember>() modelBuilder.Entity<SnChatMember>()
@@ -150,10 +140,10 @@ public class AppDatabase(
.HasForeignKey(m => m.SenderId) .HasForeignKey(m => m.SenderId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<WebReader.WebFeed>() modelBuilder.Entity<WebFeed>()
.HasIndex(f => f.Url) .HasIndex(f => f.Url)
.IsUnique(); .IsUnique();
modelBuilder.Entity<WebReader.WebArticle>() modelBuilder.Entity<WebArticle>()
.HasIndex(a => a.Url) .HasIndex(a => a.Url)
.IsUnique(); .IsUnique();

View File

@@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Autocompletion; namespace DysonNetwork.Sphere.Autocompletion;
public class AutocompletionService(AppDatabase db, AccountClientHelper accountsHelper) public class AutocompletionService(AppDatabase db, RemoteAccountService remoteAccountsHelper, RemoteRealmService remoteRealmService)
{ {
public async Task<List<DysonNetwork.Shared.Models.Autocompletion>> GetAutocompletion(string content, Guid? chatId = null, Guid? realmId = null, int limit = 10) public async Task<List<DysonNetwork.Shared.Models.Autocompletion>> GetAutocompletion(string content, Guid? chatId = null, Guid? realmId = null, int limit = 10)
{ {
@@ -16,7 +16,7 @@ public class AutocompletionService(AppDatabase db, AccountClientHelper accountsH
var afterAt = content[1..]; var afterAt = content[1..];
string type; string type;
string query; string query;
bool hadSlash = afterAt.Contains('/'); var hadSlash = afterAt.Contains('/');
if (hadSlash) if (hadSlash)
{ {
var parts = afterAt.Split('/', 2); var parts = afterAt.Split('/', 2);
@@ -47,7 +47,7 @@ public class AutocompletionService(AppDatabase db, AccountClientHelper accountsH
switch (type) switch (type)
{ {
case "u": case "u":
var allAccounts = await accountsHelper.SearchAccounts(query); var allAccounts = await remoteAccountsHelper.SearchAccounts(query);
var filteredAccounts = allAccounts; var filteredAccounts = allAccounts;
if (chatId.HasValue) if (chatId.HasValue)
@@ -61,12 +61,13 @@ public class AutocompletionService(AppDatabase db, AccountClientHelper accountsH
} }
else if (realmId.HasValue) else if (realmId.HasValue)
{ {
var realmMemberIds = await db.RealmMembers // TODO: Filter to realm members only - needs efficient implementation
.Where(m => m.RealmId == realmId.Value && m.LeaveAt == null) // var realmMemberIds = await db.RealmMembers
.Select(m => m.AccountId) // .Where(m => m.RealmId == realmId.Value && m.LeaveAt == null)
.ToListAsync(); // .Select(m => m.AccountId)
var realmMemberIdStrings = realmMemberIds.Select(id => id.ToString()).ToHashSet(); // .ToListAsync();
filteredAccounts = allAccounts.Where(a => realmMemberIdStrings.Contains(a.Id)).ToList(); // var realmMemberIdStrings = realmMemberIds.Select(id => id.ToString()).ToHashSet();
// filteredAccounts = allAccounts.Where(a => realmMemberIdStrings.Contains(a.Id)).ToList();
} }
var users = filteredAccounts var users = filteredAccounts
@@ -95,17 +96,14 @@ public class AutocompletionService(AppDatabase db, AccountClientHelper accountsH
break; break;
case "r": case "r":
var realms = await db.Realms var realms = await remoteRealmService.SearchRealms(query, limit);
.Where(r => EF.Functions.Like(r.Slug, $"{query}%") || EF.Functions.Like(r.Name, $"{query}%")) var autocompletions = realms.Select(r => new DysonNetwork.Shared.Models.Autocompletion
.Take(limit)
.Select(r => new DysonNetwork.Shared.Models.Autocompletion
{ {
Type = "realm", Type = "realm",
Keyword = "@r/" + r.Slug, Keyword = "@r/" + r.Slug,
Data = r Data = r
}) });
.ToListAsync(); results.AddRange(autocompletions);
results.AddRange(realms);
break; break;
case "c": case "c":
@@ -130,30 +128,17 @@ public class AutocompletionService(AppDatabase db, AccountClientHelper accountsH
{ {
var stickers = await db.Stickers var stickers = await db.Stickers
.Include(s => s.Pack) .Include(s => s.Pack)
.Where(s => EF.Functions.Like(s.Slug, $"{query}%")) .Where(s => EF.Functions.Like(s.Pack.Prefix + "+" + s.Slug, $"{query}%"))
.Take(limit) .Take(limit)
.Select(s => new DysonNetwork.Shared.Models.Autocompletion .Select(s => new DysonNetwork.Shared.Models.Autocompletion
{ {
Type = "sticker", Type = "sticker",
Keyword = s.Slug, Keyword = $":{s.Pack.Prefix}+{s.Slug}:",
Data = s Data = s
}) })
.ToListAsync(); .ToListAsync();
// Also possibly search by pack prefix? But user said slug after : var results = stickers.ToList();
// Perhaps combine or search packs
var packs = await db.StickerPacks
.Where(p => EF.Functions.Like(p.Prefix, $"{query}%"))
.Take(limit)
.Select(p => new DysonNetwork.Shared.Models.Autocompletion
{
Type = "sticker_pack",
Keyword = p.Prefix,
Data = p
})
.ToListAsync();
var results = stickers.Concat(packs).Take(limit).ToList();
return results; return results;
} }
} }

View File

@@ -87,7 +87,8 @@ public partial class ChatController(
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
var member = await db.ChatMembers var member = await db.ChatMembers
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null) .Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null &&
m.LeaveAt == null)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Member) if (member == null || member.Role < ChatMemberRole.Member)
return StatusCode(403, "You are not a member of this chat room."); return StatusCode(403, "You are not a member of this chat room.");
@@ -129,7 +130,8 @@ public partial class ChatController(
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
var member = await db.ChatMembers var member = await db.ChatMembers
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null) .Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null &&
m.LeaveAt == null)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Member) if (member == null || member.Role < ChatMemberRole.Member)
return StatusCode(403, "You are not a member of this chat room."); return StatusCode(403, "You are not a member of this chat room.");
@@ -148,9 +150,74 @@ public partial class ChatController(
} }
[GeneratedRegex("@([A-Za-z0-9_-]+)")] [GeneratedRegex(@"@(?:u/)?([A-Za-z0-9_-]+)")]
private static partial Regex MentionRegex(); private static partial Regex MentionRegex();
/// <summary>
/// Extracts mentioned users from message content, replies, and forwards
/// </summary>
private async Task<List<Guid>> ExtractMentionedUsersAsync(string? content, Guid? repliedMessageId,
Guid? forwardedMessageId, Guid roomId, Guid? excludeSenderId = null)
{
var mentionedUsers = new List<Guid>();
// Add sender of a replied message
if (repliedMessageId.HasValue)
{
var replyingTo = await db.ChatMessages
.Where(m => m.Id == repliedMessageId.Value && m.ChatRoomId == roomId)
.Include(m => m.Sender)
.Select(m => m.Sender)
.FirstOrDefaultAsync();
if (replyingTo != null)
mentionedUsers.Add(replyingTo.AccountId);
}
// Add sender of a forwarded message
if (forwardedMessageId.HasValue)
{
var forwardedMessage = await db.ChatMessages
.Where(m => m.Id == forwardedMessageId.Value)
.Select(m => new { m.SenderId })
.FirstOrDefaultAsync();
if (forwardedMessage != null)
{
mentionedUsers.Add(forwardedMessage.SenderId);
}
}
// Extract mentions from content using regex
if (!string.IsNullOrWhiteSpace(content))
{
var mentionedNames = MentionRegex()
.Matches(content)
.Select(m => m.Groups[1].Value)
.Distinct()
.ToList();
if (mentionedNames.Count > 0)
{
var queryRequest = new LookupAccountBatchRequest();
queryRequest.Names.AddRange(mentionedNames);
var queryResponse = (await accounts.LookupAccountBatchAsync(queryRequest)).Accounts;
var mentionedIds = queryResponse.Select(a => Guid.Parse(a.Id)).ToList();
if (mentionedIds.Count > 0)
{
var mentionedMembers = await db.ChatMembers
.Where(m => m.ChatRoomId == roomId && mentionedIds.Contains(m.AccountId))
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
.Where(m => excludeSenderId == null || m.AccountId != excludeSenderId.Value)
.Select(m => m.AccountId)
.ToListAsync();
mentionedUsers.AddRange(mentionedMembers);
}
}
}
return mentionedUsers.Distinct().ToList();
}
[HttpPost("{roomId:guid}/messages")] [HttpPost("{roomId:guid}/messages")]
[Authorize] [Authorize]
[RequiredPermission("global", "chat.messages.create")] [RequiredPermission("global", "chat.messages.create")]
@@ -188,6 +255,7 @@ public partial class ChatController(
.ToList(); .ToList();
} }
// Validate reply and forward message IDs exist
if (request.RepliedMessageId.HasValue) if (request.RepliedMessageId.HasValue)
{ {
var repliedMessage = await db.ChatMessages var repliedMessage = await db.ChatMessages
@@ -208,28 +276,9 @@ public partial class ChatController(
message.ForwardedMessageId = forwardedMessage.Id; message.ForwardedMessageId = forwardedMessage.Id;
} }
if (request.Content is not null) // Extract mentioned users
{ message.MembersMentioned = await ExtractMentionedUsersAsync(request.Content, request.RepliedMessageId,
var mentioned = MentionRegex() request.ForwardedMessageId, roomId);
.Matches(request.Content)
.Select(m => m.Groups[1].Value)
.ToList();
if (mentioned.Count > 0)
{
var queryRequest = new LookupAccountBatchRequest();
queryRequest.Names.AddRange(mentioned);
var queryResponse = (await accounts.LookupAccountBatchAsync(queryRequest)).Accounts;
var mentionedId = queryResponse
.Select(a => Guid.Parse(a.Id))
.ToList();
var mentionedMembers = await db.ChatMembers
.Where(m => m.ChatRoomId == roomId && mentionedId.Contains(m.AccountId))
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
.Select(m => m.Id)
.ToListAsync();
message.MembersMentioned = mentionedMembers;
}
}
var result = await cs.SendMessageAsync(message, member, member.ChatRoom); var result = await cs.SendMessageAsync(message, member, member.ChatRoom);
@@ -259,6 +308,7 @@ public partial class ChatController(
(request.AttachmentsId == null || request.AttachmentsId.Count == 0)) (request.AttachmentsId == null || request.AttachmentsId.Count == 0))
return BadRequest("You cannot send an empty message."); return BadRequest("You cannot send an empty message.");
// Validate reply and forward message IDs exist
if (request.RepliedMessageId.HasValue) if (request.RepliedMessageId.HasValue)
{ {
var repliedMessage = await db.ChatMessages var repliedMessage = await db.ChatMessages
@@ -275,6 +325,11 @@ public partial class ChatController(
return BadRequest("The message you're forwarding does not exist."); return BadRequest("The message you're forwarding does not exist.");
} }
// Update mentions based on new content and references
var updatedMentions = await ExtractMentionedUsersAsync(request.Content, request.RepliedMessageId,
request.ForwardedMessageId, roomId, accountId);
message.MembersMentioned = updatedMentions;
// Call service method to update the message // Call service method to update the message
await cs.UpdateMessageAsync( await cs.UpdateMessageAsync(
message, message,
@@ -324,7 +379,8 @@ public partial class ChatController(
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
var isMember = await db.ChatMembers var isMember = await db.ChatMembers
.AnyAsync(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null); .AnyAsync(m =>
m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null);
if (!isMember) if (!isMember)
return StatusCode(403, "You are not a member of this chat room."); return StatusCode(403, "You are not a member of this chat room.");
@@ -333,14 +389,16 @@ public partial class ChatController(
} }
[HttpPost("{roomId:guid}/autocomplete")] [HttpPost("{roomId:guid}/autocomplete")]
public async Task<ActionResult<List<DysonNetwork.Shared.Models.Autocompletion>>> ChatAutoComplete([FromBody] AutocompletionRequest request, Guid roomId) public async Task<ActionResult<List<DysonNetwork.Shared.Models.Autocompletion>>> ChatAutoComplete(
[FromBody] AutocompletionRequest request, Guid roomId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized(); return Unauthorized();
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
var isMember = await db.ChatMembers var isMember = await db.ChatMembers
.AnyAsync(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null); .AnyAsync(m =>
m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null);
if (!isMember) if (!isMember)
return StatusCode(403, "You are not a member of this chat room."); return StatusCode(403, "You are not a member of this chat room.");

View File

@@ -6,7 +6,7 @@ using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry; using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere.Localization; using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Realm;
using Grpc.Core; using Grpc.Core;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
@@ -20,14 +20,14 @@ namespace DysonNetwork.Sphere.Chat;
public class ChatRoomController( public class ChatRoomController(
AppDatabase db, AppDatabase db,
ChatRoomService crs, ChatRoomService crs,
RealmService rs, RemoteRealmService rs,
IStringLocalizer<NotificationResource> localizer, IStringLocalizer<NotificationResource> localizer,
AccountService.AccountServiceClient accounts, AccountService.AccountServiceClient accounts,
FileService.FileServiceClient files, FileService.FileServiceClient files,
FileReferenceService.FileReferenceServiceClient fileRefs, FileReferenceService.FileReferenceServiceClient fileRefs,
ActionLogService.ActionLogServiceClient als, ActionLogService.ActionLogServiceClient als,
RingService.RingServiceClient pusher, RingService.RingServiceClient pusher,
AccountClientHelper accountsHelper RemoteAccountService remoteAccountsHelper
) : ControllerBase ) : ControllerBase
{ {
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
@@ -35,9 +35,12 @@ public class ChatRoomController(
{ {
var chatRoom = await db.ChatRooms var chatRoom = await db.ChatRooms
.Where(c => c.Id == id) .Where(c => c.Id == id)
.Include(e => e.Realm)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (chatRoom is null) return NotFound(); if (chatRoom is null) return NotFound();
if (chatRoom.RealmId != null)
chatRoom.Realm = await rs.GetRealm(chatRoom.RealmId.Value.ToString());
if (chatRoom.Type != ChatRoomType.DirectMessage) return Ok(chatRoom); if (chatRoom.Type != ChatRoomType.DirectMessage) return Ok(chatRoom);
if (HttpContext.Items["CurrentUser"] is Account currentUser) if (HttpContext.Items["CurrentUser"] is Account currentUser)
@@ -203,7 +206,7 @@ public class ChatRoomController(
if (request.RealmId is not null) if (request.RealmId is not null)
{ {
if (!await rs.IsMemberWithRole(request.RealmId.Value, Guid.Parse(currentUser.Id), if (!await rs.IsMemberWithRole(request.RealmId.Value, Guid.Parse(currentUser.Id),
RealmMemberRole.Moderator)) [RealmMemberRole.Moderator]))
return StatusCode(403, "You need at least be a moderator to create chat linked to the realm."); return StatusCode(403, "You need at least be a moderator to create chat linked to the realm.");
chatRoom.RealmId = request.RealmId; chatRoom.RealmId = request.RealmId;
} }
@@ -301,7 +304,7 @@ public class ChatRoomController(
if (chatRoom.RealmId is not null) if (chatRoom.RealmId is not null)
{ {
if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id), if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id),
RealmMemberRole.Moderator)) [RealmMemberRole.Moderator]))
return StatusCode(403, "You need at least be a realm moderator to update the chat."); return StatusCode(403, "You need at least be a realm moderator to update the chat.");
} }
else if (!await crs.IsMemberWithRole(chatRoom.Id, Guid.Parse(currentUser.Id), ChatMemberRole.Moderator)) else if (!await crs.IsMemberWithRole(chatRoom.Id, Guid.Parse(currentUser.Id), ChatMemberRole.Moderator))
@@ -309,13 +312,9 @@ public class ChatRoomController(
if (request.RealmId is not null) if (request.RealmId is not null)
{ {
var member = await db.RealmMembers if (!await rs.IsMemberWithRole(request.RealmId.Value, Guid.Parse(currentUser.Id), [RealmMemberRole.Moderator]))
.Where(m => m.AccountId == Guid.Parse(currentUser.Id))
.Where(m => m.RealmId == request.RealmId)
.FirstOrDefaultAsync();
if (member is null || member.Role < RealmMemberRole.Moderator)
return StatusCode(403, "You need at least be a moderator to transfer the chat linked to the realm."); return StatusCode(403, "You need at least be a moderator to transfer the chat linked to the realm.");
chatRoom.RealmId = member.RealmId; chatRoom.RealmId = request.RealmId;
} }
if (request.PictureId is not null) if (request.PictureId is not null)
@@ -415,7 +414,7 @@ public class ChatRoomController(
if (chatRoom.RealmId is not null) if (chatRoom.RealmId is not null)
{ {
if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id), if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id),
RealmMemberRole.Moderator)) [RealmMemberRole.Moderator]))
return StatusCode(403, "You need at least be a realm moderator to delete the chat."); return StatusCode(403, "You need at least be a realm moderator to delete the chat.");
} }
else if (!await crs.IsMemberWithRole(chatRoom.Id, Guid.Parse(currentUser.Id), ChatMemberRole.Owner)) else if (!await crs.IsMemberWithRole(chatRoom.Id, Guid.Parse(currentUser.Id), ChatMemberRole.Owner))
@@ -507,7 +506,7 @@ public class ChatRoomController(
.Select(m => m.AccountId) .Select(m => m.AccountId)
.ToListAsync(); .ToListAsync();
var memberStatuses = await accountsHelper.GetAccountStatusBatch(members); var memberStatuses = await remoteAccountsHelper.GetAccountStatusBatch(members);
var onlineCount = memberStatuses.Count(s => s.Value.IsOnline); var onlineCount = memberStatuses.Count(s => s.Value.IsOnline);
@@ -546,7 +545,7 @@ public class ChatRoomController(
.OrderBy(m => m.JoinedAt) .OrderBy(m => m.JoinedAt)
.ToListAsync(); .ToListAsync();
var memberStatuses = await accountsHelper.GetAccountStatusBatch( var memberStatuses = await remoteAccountsHelper.GetAccountStatusBatch(
members.Select(m => m.AccountId).ToList() members.Select(m => m.AccountId).ToList()
); );
@@ -623,11 +622,7 @@ public class ChatRoomController(
// Handle realm-owned chat rooms // Handle realm-owned chat rooms
if (chatRoom.RealmId is not null) if (chatRoom.RealmId is not null)
{ {
var realmMember = await db.RealmMembers if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, accountId, [RealmMemberRole.Moderator]))
.Where(m => m.AccountId == accountId)
.Where(m => m.RealmId == chatRoom.RealmId)
.FirstOrDefaultAsync();
if (realmMember is null || realmMember.Role < RealmMemberRole.Moderator)
return StatusCode(403, "You need at least be a realm moderator to invite members to this chat."); return StatusCode(403, "You need at least be a realm moderator to invite members to this chat.");
} }
else else
@@ -832,11 +827,7 @@ public class ChatRoomController(
// Check if the chat room is owned by a realm // Check if the chat room is owned by a realm
if (chatRoom.RealmId is not null) if (chatRoom.RealmId is not null)
{ {
var realmMember = await db.RealmMembers if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id), [RealmMemberRole.Moderator]))
.Where(m => m.AccountId == Guid.Parse(currentUser.Id))
.Where(m => m.RealmId == chatRoom.RealmId)
.FirstOrDefaultAsync();
if (realmMember is null || realmMember.Role < RealmMemberRole.Moderator)
return StatusCode(403, "You need at least be a realm moderator to change member roles."); return StatusCode(403, "You need at least be a realm moderator to change member roles.");
} }
else else
@@ -899,12 +890,12 @@ public class ChatRoomController(
if (chatRoom.RealmId is not null) if (chatRoom.RealmId is not null)
{ {
if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id), if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id),
RealmMemberRole.Moderator)) [RealmMemberRole.Moderator]))
return StatusCode(403, "You need at least be a realm moderator to remove members."); return StatusCode(403, "You need at least be a realm moderator to remove members.");
} }
else else
{ {
if (!await crs.IsMemberWithRole(chatRoom.Id, Guid.Parse(currentUser.Id), ChatMemberRole.Moderator)) if (!await crs.IsMemberWithRole(chatRoom.Id, Guid.Parse(currentUser.Id), [ChatMemberRole.Moderator]))
return StatusCode(403, "You need at least be a moderator to remove members."); return StatusCode(403, "You need at least be a moderator to remove members.");
} }

View File

@@ -9,7 +9,7 @@ namespace DysonNetwork.Sphere.Chat;
public class ChatRoomService( public class ChatRoomService(
AppDatabase db, AppDatabase db,
ICacheService cache, ICacheService cache,
AccountClientHelper accountsHelper RemoteAccountService remoteAccountsHelper
) )
{ {
private const string ChatRoomGroupPrefix = "chatroom:"; private const string ChatRoomGroupPrefix = "chatroom:";
@@ -147,7 +147,7 @@ public class ChatRoomService(
public async Task<SnChatMember> LoadMemberAccount(SnChatMember member) public async Task<SnChatMember> LoadMemberAccount(SnChatMember member)
{ {
var account = await accountsHelper.GetAccount(member.AccountId); var account = await remoteAccountsHelper.GetAccount(member.AccountId);
member.Account = SnAccount.FromProtoValue(account); member.Account = SnAccount.FromProtoValue(account);
return member; return member;
} }
@@ -155,7 +155,7 @@ public class ChatRoomService(
public async Task<List<SnChatMember>> LoadMemberAccounts(ICollection<SnChatMember> members) public async Task<List<SnChatMember>> LoadMemberAccounts(ICollection<SnChatMember> members)
{ {
var accountIds = members.Select(m => m.AccountId).ToList(); var accountIds = members.Select(m => m.AccountId).ToList();
var accounts = (await accountsHelper.GetAccountBatch(accountIds)).ToDictionary(a => Guid.Parse(a.Id), a => a); var accounts = (await remoteAccountsHelper.GetAccountBatch(accountIds)).ToDictionary(a => Guid.Parse(a.Id), a => a);
return return
[ [

View File

@@ -198,8 +198,6 @@ public partial class ChatService(
public async Task<SnChatMessage> SendMessageAsync(SnChatMessage message, SnChatMember sender, SnChatRoom room) public async Task<SnChatMessage> SendMessageAsync(SnChatMessage message, SnChatMember sender, SnChatRoom room)
{ {
if (string.IsNullOrWhiteSpace(message.Nonce)) message.Nonce = Guid.NewGuid().ToString(); if (string.IsNullOrWhiteSpace(message.Nonce)) message.Nonce = Guid.NewGuid().ToString();
message.CreatedAt = SystemClock.Instance.GetCurrentInstant();
message.UpdatedAt = message.CreatedAt;
// First complete the save operation // First complete the save operation
db.ChatMessages.Add(message); db.ChatMessages.Add(message);
@@ -209,20 +207,25 @@ public partial class ChatService(
await CreateFileReferencesForMessageAsync(message); await CreateFileReferencesForMessageAsync(message);
// Then start the delivery process // Then start the delivery process
var localMessage = message;
var localSender = sender;
var localRoom = room;
var localLogger = logger;
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
try try
{ {
await DeliverMessageAsync(message, sender, room); await DeliverMessageAsync(localMessage, localSender, localRoom);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError($"Error when delivering message: {ex.Message} {ex.StackTrace}"); localLogger.LogError($"Error when delivering message: {ex.Message} {ex.StackTrace}");
} }
}); });
// Process link preview in the background to avoid delaying message sending // Process link preview in the background to avoid delaying message sending
_ = Task.Run(async () => await ProcessMessageLinkPreviewAsync(message)); var localMessageForPreview = message;
_ = Task.Run(async () => await ProcessMessageLinkPreviewAsync(localMessageForPreview));
message.Sender = sender; message.Sender = sender;
message.ChatRoom = room; message.ChatRoom = room;

View File

@@ -1,30 +1,27 @@
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Registry;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Realm; namespace DysonNetwork.Sphere.Chat;
[ApiController] [ApiController]
[Route("/api/realms/{slug}")] [Route("/api/realms/{slug}")]
public class RealmChatController(AppDatabase db, RealmService rs) : ControllerBase public class RealmChatController(AppDatabase db, RemoteRealmService rs) : ControllerBase
{ {
[HttpGet("chat")] [HttpGet("chat")]
[Authorize] [Authorize]
public async Task<ActionResult<List<SnChatRoom>>> ListRealmChat(string slug) public async Task<ActionResult<List<SnChatRoom>>> ListRealmChat(string slug)
{ {
var currentUser = HttpContext.Items["CurrentUser"] as Account; var currentUser = HttpContext.Items["CurrentUser"] as Shared.Proto.Account;
var accountId = currentUser is null ? Guid.Empty : Guid.Parse(currentUser.Id); var accountId = currentUser is null ? Guid.Empty : Guid.Parse(currentUser.Id);
var realm = await db.Realms var realm = await rs.GetRealmBySlug(slug);
.Where(r => r.Slug == slug)
.FirstOrDefaultAsync();
if (realm is null) return NotFound();
if (!realm.IsPublic) if (!realm.IsPublic)
{ {
if (currentUser is null) return Unauthorized(); if (currentUser is null) return Unauthorized();
if (!await rs.IsMemberWithRole(realm.Id, accountId, RealmMemberRole.Normal)) if (!await rs.IsMemberWithRole(realm.Id, accountId, [RealmMemberRole.Normal]))
return StatusCode(403, "You need at least one member to view the realm's chat."); return StatusCode(403, "You need at least one member to view the realm's chat.");
} }

View File

@@ -1,30 +1,31 @@
using Microsoft.EntityFrameworkCore; using DysonNetwork.Shared.Registry;
namespace DysonNetwork.Sphere.Discovery; namespace DysonNetwork.Sphere.Discovery;
public class DiscoveryService(AppDatabase appDatabase) public class DiscoveryService(RemoteRealmService remoteRealmService)
{ {
public Task<List<Shared.Models.SnRealm>> GetCommunityRealmAsync( public async Task<List<Shared.Models.SnRealm>> GetCommunityRealmAsync(
string? query, string? query,
int take = 10, int take = 10,
int offset = 0, int offset = 0,
bool randomizer = false bool randomizer = false
) )
{ {
var realmsQuery = appDatabase.Realms var allRealms = await remoteRealmService.GetPublicRealms();
.Where(r => r.IsCommunity) var communityRealms = allRealms.Where(r => r.IsCommunity);
.OrderByDescending(r => r.CreatedAt)
.AsQueryable();
if (!string.IsNullOrEmpty(query)) if (!string.IsNullOrEmpty(query))
realmsQuery = realmsQuery.Where(r => {
EF.Functions.ILike(r.Name, $"%{query}%") || communityRealms = communityRealms.Where(r =>
EF.Functions.ILike(r.Description, $"%{query}%") r.Name.Contains(query, StringComparison.OrdinalIgnoreCase)
); );
realmsQuery = randomizer }
? realmsQuery.OrderBy(r => EF.Functions.Random())
: realmsQuery.OrderByDescending(r => r.CreatedAt);
return realmsQuery.Skip(offset).Take(take).ToListAsync(); // Since we don't have CreatedAt in the proto model, we'll just apply randomizer if requested
var orderedRealms = randomizer
? communityRealms.OrderBy(_ => Random.Shared.Next())
: communityRealms;
return orderedRealms.Skip(offset).Take(take).ToList();
} }
} }

View File

@@ -19,8 +19,8 @@
<PackageReference Include="HtmlAgilityPack" Version="1.12.3" /> <PackageReference Include="HtmlAgilityPack" Version="1.12.3" />
<PackageReference Include="Livekit.Server.Sdk.Dotnet" Version="1.0.10" /> <PackageReference Include="Livekit.Server.Sdk.Dotnet" Version="1.0.10" />
<PackageReference Include="Markdig" Version="0.41.3"/> <PackageReference Include="Markdig" Version="0.41.3"/>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.7"/> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.10" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7"/> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -44,7 +44,7 @@
<PackageReference Include="OpenGraph-Net" Version="4.0.1" /> <PackageReference Include="OpenGraph-Net" Version="4.0.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/> <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0"/> <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0"/> <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/> <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/> <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/>
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/> <PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/>
@@ -56,9 +56,9 @@
<PackageReference Include="Quartz.AspNetCore" Version="3.14.0"/> <PackageReference Include="Quartz.AspNetCore" Version="3.14.0"/>
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0"/> <PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0"/>
<PackageReference Include="StackExchange.Redis.Extensions.AspNetCore" Version="11.0.0"/> <PackageReference Include="StackExchange.Redis.Extensions.AspNetCore" Version="11.0.0"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="9.0.4" /> <PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="9.0.6" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" /> <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.6" />
<PackageReference Include="System.ServiceModel.Syndication" Version="9.0.7" /> <PackageReference Include="System.ServiceModel.Syndication" Version="9.0.7" />
<PackageReference Include="TencentCloudSDK.Tmt" Version="3.0.1276" /> <PackageReference Include="TencentCloudSDK.Tmt" Version="3.0.1276" />
</ItemGroup> </ItemGroup>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,89 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class ChangeRealmReferenceMode : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_chat_rooms_realms_realm_id",
table: "chat_rooms");
migrationBuilder.DropForeignKey(
name: "fk_posts_realms_realm_id",
table: "posts");
migrationBuilder.DropIndex(
name: "ix_posts_realm_id",
table: "posts");
migrationBuilder.DropIndex(
name: "ix_chat_rooms_realm_id",
table: "chat_rooms");
migrationBuilder.AddColumn<Guid>(
name: "sn_realm_id",
table: "chat_rooms",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "ix_chat_rooms_sn_realm_id",
table: "chat_rooms",
column: "sn_realm_id");
migrationBuilder.AddForeignKey(
name: "fk_chat_rooms_realms_sn_realm_id",
table: "chat_rooms",
column: "sn_realm_id",
principalTable: "realms",
principalColumn: "id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_chat_rooms_realms_sn_realm_id",
table: "chat_rooms");
migrationBuilder.DropIndex(
name: "ix_chat_rooms_sn_realm_id",
table: "chat_rooms");
migrationBuilder.DropColumn(
name: "sn_realm_id",
table: "chat_rooms");
migrationBuilder.CreateIndex(
name: "ix_posts_realm_id",
table: "posts",
column: "realm_id");
migrationBuilder.CreateIndex(
name: "ix_chat_rooms_realm_id",
table: "chat_rooms",
column: "realm_id");
migrationBuilder.AddForeignKey(
name: "fk_chat_rooms_realms_realm_id",
table: "chat_rooms",
column: "realm_id",
principalTable: "realms",
principalColumn: "id");
migrationBuilder.AddForeignKey(
name: "fk_posts_realms_realm_id",
table: "posts",
column: "realm_id",
principalTable: "realms",
principalColumn: "id");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,132 @@
using System;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class RemoveRealms : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_chat_rooms_realms_sn_realm_id",
table: "chat_rooms");
migrationBuilder.DropForeignKey(
name: "fk_publishers_realms_realm_id",
table: "publishers");
migrationBuilder.DropTable(
name: "realm_members");
migrationBuilder.DropTable(
name: "realms");
migrationBuilder.DropIndex(
name: "ix_publishers_realm_id",
table: "publishers");
migrationBuilder.DropIndex(
name: "ix_chat_rooms_sn_realm_id",
table: "chat_rooms");
migrationBuilder.DropColumn(
name: "sn_realm_id",
table: "chat_rooms");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "sn_realm_id",
table: "chat_rooms",
type: "uuid",
nullable: true);
migrationBuilder.CreateTable(
name: "realms",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
background = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
background_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
is_community = table.Column<bool>(type: "boolean", nullable: false),
is_public = table.Column<bool>(type: "boolean", nullable: false),
name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
picture = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
picture_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
slug = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
verification = table.Column<SnVerificationMark>(type: "jsonb", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_realms", x => x.id);
});
migrationBuilder.CreateTable(
name: "realm_members",
columns: table => new
{
realm_id = table.Column<Guid>(type: "uuid", nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
joined_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
leave_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
role = table.Column<int>(type: "integer", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_realm_members", x => new { x.realm_id, x.account_id });
table.ForeignKey(
name: "fk_realm_members_realms_realm_id",
column: x => x.realm_id,
principalTable: "realms",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_publishers_realm_id",
table: "publishers",
column: "realm_id");
migrationBuilder.CreateIndex(
name: "ix_chat_rooms_sn_realm_id",
table: "chat_rooms",
column: "sn_realm_id");
migrationBuilder.CreateIndex(
name: "ix_realms_slug",
table: "realms",
column: "slug",
unique: true);
migrationBuilder.AddForeignKey(
name: "fk_chat_rooms_realms_sn_realm_id",
table: "chat_rooms",
column: "sn_realm_id",
principalTable: "realms",
principalColumn: "id");
migrationBuilder.AddForeignKey(
name: "fk_publishers_realms_realm_id",
table: "publishers",
column: "realm_id",
principalTable: "realms",
principalColumn: "id");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@ public class PollController(
AppDatabase db, AppDatabase db,
PollService polls, PollService polls,
Publisher.PublisherService pub, Publisher.PublisherService pub,
AccountClientHelper accountsHelper RemoteAccountService remoteAccountsHelper
) : ControllerBase ) : ControllerBase
{ {
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
@@ -110,7 +110,7 @@ public class PollController(
if (!poll.IsAnonymous) if (!poll.IsAnonymous)
{ {
var answeredAccountsId = answers.Select(x => x.AccountId).Distinct().ToList(); var answeredAccountsId = answers.Select(x => x.AccountId).Distinct().ToList();
var answeredAccounts = await accountsHelper.GetAccountBatch(answeredAccountsId); var answeredAccounts = await remoteAccountsHelper.GetAccountBatch(answeredAccountsId);
// Populate Account field for each answer // Populate Account field for each answer
foreach (var answer in answers) foreach (var answer in answers)

View File

@@ -4,8 +4,9 @@ using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere.Poll; using DysonNetwork.Sphere.Poll;
using DysonNetwork.Sphere.Realm;
using DysonNetwork.Sphere.WebReader; using DysonNetwork.Sphere.WebReader;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -23,11 +24,12 @@ public class PostController(
AppDatabase db, AppDatabase db,
PostService ps, PostService ps,
PublisherService pub, PublisherService pub,
RemoteAccountService remoteAccountsHelper,
AccountService.AccountServiceClient accounts, AccountService.AccountServiceClient accounts,
ActionLogService.ActionLogServiceClient als, ActionLogService.ActionLogServiceClient als,
PaymentService.PaymentServiceClient payments, PaymentService.PaymentServiceClient payments,
PollService polls, PollService polls,
RealmService rs RemoteRealmService rs
) )
: ControllerBase : ControllerBase
{ {
@@ -103,17 +105,20 @@ public class PostController(
var accountId = currentUser is null ? Guid.Empty : Guid.Parse(currentUser.Id); var accountId = currentUser is null ? Guid.Empty : Guid.Parse(currentUser.Id);
var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(accountId); var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(accountId);
var userRealms = currentUser is null ? [] : await rs.GetUserRealms(accountId); var userRealms = currentUser is null ? new List<Guid>() : await rs.GetUserRealms(accountId);
var publicRealms = await rs.GetPublicRealms();
var publicRealmIds = publicRealms.Select(r => r.Id).ToList();
var visibleRealmIds = userRealms.Concat(publicRealmIds).Distinct().ToList();
var publisher = pubName == null ? null : await db.Publishers.FirstOrDefaultAsync(p => p.Name == pubName); var publisher = pubName == null ? null : await db.Publishers.FirstOrDefaultAsync(p => p.Name == pubName);
var realm = realmName == null ? null : await db.Realms.FirstOrDefaultAsync(r => r.Slug == realmName); var realm = realmName == null ? null : (await rs.GetRealmBySlug(realmName));
var query = db.Posts var query = db.Posts
.Include(e => e.Categories) .Include(e => e.Categories)
.Include(e => e.Tags) .Include(e => e.Tags)
.Include(e => e.RepliedPost) .Include(e => e.RepliedPost)
.Include(e => e.ForwardedPost) .Include(e => e.ForwardedPost)
.Include(e => e.Realm) .Include(e => e.FeaturedRecords)
.AsQueryable(); .AsQueryable();
if (publisher != null) if (publisher != null)
query = query.Where(p => p.PublisherId == publisher.Id); query = query.Where(p => p.PublisherId == publisher.Id);
@@ -129,8 +134,7 @@ public class PostController(
query = query.Where(e => e.Attachments.Count > 0); query = query.Where(e => e.Attachments.Count > 0);
if (realm == null) if (realm == null)
query = query.Where(p => query = query.Where(p => p.RealmId == null || visibleRealmIds.Contains(p.RealmId.Value));
p.RealmId == null || p.Realm == null || userRealms.Contains(p.RealmId.Value) || p.Realm.IsPublic);
switch (pinned) switch (pinned)
{ {
@@ -183,11 +187,31 @@ public class PostController(
.ToListAsync(); .ToListAsync();
posts = await ps.LoadPostInfo(posts, currentUser, true); posts = await ps.LoadPostInfo(posts, currentUser, true);
// Load realm data for posts that have realm
await LoadPostsRealmsAsync(posts, rs);
Response.Headers["X-Total"] = totalCount.ToString(); Response.Headers["X-Total"] = totalCount.ToString();
return Ok(posts); return Ok(posts);
} }
private static async Task LoadPostsRealmsAsync(List<SnPost> posts, RemoteRealmService rs)
{
var postRealmIds = posts.Where(p => p.RealmId != null).Select(p => p.RealmId.Value).Distinct().ToList();
if (!postRealmIds.Any()) return;
var realms = await rs.GetRealmBatch(postRealmIds.Select(id => id.ToString()).ToList());
var realmDict = realms.GroupBy(r => r.Id).ToDictionary(g => g.Key, g => g.FirstOrDefault());
foreach (var post in posts.Where(p => p.RealmId != null))
{
if (post.RealmId != null && realmDict.TryGetValue(post.RealmId.Value, out var realm))
{
post.Realm = realm;
}
}
}
[HttpGet("{publisherName}/{slug}")] [HttpGet("{publisherName}/{slug}")]
public async Task<ActionResult<SnPost>> GetPost(string publisherName, string slug) public async Task<ActionResult<SnPost>> GetPost(string publisherName, string slug)
{ {
@@ -206,15 +230,17 @@ public class PostController(
var post = await db.Posts var post = await db.Posts
.Include(e => e.Publisher) .Include(e => e.Publisher)
.Where(e => e.Slug == slug && e.Publisher.Name == publisherName) .Where(e => e.Slug == slug && e.Publisher.Name == publisherName)
.Include(e => e.Realm)
.Include(e => e.Tags) .Include(e => e.Tags)
.Include(e => e.Categories) .Include(e => e.Categories)
.Include(e => e.RepliedPost) .Include(e => e.RepliedPost)
.Include(e => e.ForwardedPost) .Include(e => e.ForwardedPost)
.Include(e => e.FeaturedRecords)
.FilterWithVisibility(currentUser, userFriends, userPublishers) .FilterWithVisibility(currentUser, userFriends, userPublishers)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (post is null) return NotFound(); if (post is null) return NotFound();
post = await ps.LoadPostInfo(post, currentUser); post = await ps.LoadPostInfo(post, currentUser);
if (post.RealmId != null)
post.Realm = await rs.GetRealm(post.RealmId.Value.ToString());
return Ok(post); return Ok(post);
} }
@@ -237,15 +263,19 @@ public class PostController(
var post = await db.Posts var post = await db.Posts
.Where(e => e.Id == id) .Where(e => e.Id == id)
.Include(e => e.Publisher) .Include(e => e.Publisher)
.Include(e => e.Realm)
.Include(e => e.Tags) .Include(e => e.Tags)
.Include(e => e.Categories) .Include(e => e.Categories)
.Include(e => e.RepliedPost) .Include(e => e.RepliedPost)
.Include(e => e.ForwardedPost) .Include(e => e.ForwardedPost)
.Include(e => e.FeaturedRecords)
.FilterWithVisibility(currentUser, userFriends, userPublishers) .FilterWithVisibility(currentUser, userFriends, userPublishers)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (post is null) return NotFound(); if (post is null) return NotFound();
post = await ps.LoadPostInfo(post, currentUser); post = await ps.LoadPostInfo(post, currentUser);
if (post.RealmId != null)
{
post.Realm = await rs.GetRealm(post.RealmId.Value.ToString());
}
return Ok(post); return Ok(post);
} }
@@ -271,6 +301,14 @@ public class PostController(
.Take(take) .Take(take)
.Skip(offset) .Skip(offset)
.ToListAsync(); .ToListAsync();
var accountsProto = await remoteAccountsHelper.GetAccountBatch(reactions.Select(r => r.AccountId).ToList());
var accounts = accountsProto.ToDictionary(a => Guid.Parse(a.Id), a => SnAccount.FromProtoValue(a));
foreach (var reaction in reactions)
if (accounts.TryGetValue(reaction.AccountId, out var account))
reaction.Account = account;
return Ok(reactions); return Ok(reactions);
} }
@@ -362,6 +400,7 @@ public class PostController(
.Include(e => e.ForwardedPost) .Include(e => e.ForwardedPost)
.Include(e => e.Categories) .Include(e => e.Categories)
.Include(e => e.Tags) .Include(e => e.Tags)
.Include(e => e.FeaturedRecords)
.FilterWithVisibility(currentUser, userFriends, userPublishers, isListing: true) .FilterWithVisibility(currentUser, userFriends, userPublishers, isListing: true)
.OrderByDescending(e => e.PublishedAt ?? e.CreatedAt) .OrderByDescending(e => e.PublishedAt ?? e.CreatedAt)
.Skip(offset) .Skip(offset)
@@ -448,7 +487,10 @@ public class PostController(
if (request.RepliedPostId is not null) if (request.RepliedPostId is not null)
{ {
var repliedPost = await db.Posts.FindAsync(request.RepliedPostId.Value); var repliedPost = await db.Posts
.Where(p => p.Id == request.RepliedPostId.Value)
.Include(p => p.Publisher)
.FirstOrDefaultAsync();
if (repliedPost is null) return BadRequest("Post replying to was not found."); if (repliedPost is null) return BadRequest("Post replying to was not found.");
post.RepliedPost = repliedPost; post.RepliedPost = repliedPost;
post.RepliedPostId = repliedPost.Id; post.RepliedPostId = repliedPost.Id;
@@ -456,7 +498,10 @@ public class PostController(
if (request.ForwardedPostId is not null) if (request.ForwardedPostId is not null)
{ {
var forwardedPost = await db.Posts.FindAsync(request.ForwardedPostId.Value); var forwardedPost = await db.Posts
.Where(p => p.Id == request.ForwardedPostId.Value)
.Include(p => p.Publisher)
.FirstOrDefaultAsync();
if (forwardedPost is null) return BadRequest("Forwarded post was not found."); if (forwardedPost is null) return BadRequest("Forwarded post was not found.");
post.ForwardedPost = forwardedPost; post.ForwardedPost = forwardedPost;
post.ForwardedPostId = forwardedPost.Id; post.ForwardedPostId = forwardedPost.Id;
@@ -464,9 +509,8 @@ public class PostController(
if (request.RealmId is not null) if (request.RealmId is not null)
{ {
var realm = await db.Realms.FindAsync(request.RealmId.Value); var realm = await rs.GetRealm(request.RealmId.Value.ToString());
if (realm is null) return BadRequest("Realm was not found."); if (!await rs.IsMemberWithRole(realm.Id, accountId, new List<int> { RealmMemberRole.Normal }))
if (!await rs.IsMemberWithRole(realm.Id, accountId, RealmMemberRole.Normal))
return StatusCode(403, "You are not a member of this realm."); return StatusCode(403, "You are not a member of this realm.");
post.RealmId = realm.Id; post.RealmId = realm.Id;
} }
@@ -692,7 +736,7 @@ public class PostController(
if (request.Mode == PostPinMode.RealmPage && post.RealmId != null) if (request.Mode == PostPinMode.RealmPage && post.RealmId != null)
{ {
if (!await rs.IsMemberWithRole(post.RealmId.Value, accountId, RealmMemberRole.Moderator)) if (!await rs.IsMemberWithRole(post.RealmId.Value, accountId, new List<int> { RealmMemberRole.Moderator }))
return StatusCode(403, "You are not a moderator of this realm"); return StatusCode(403, "You are not a moderator of this realm");
} }
@@ -740,7 +784,7 @@ public class PostController(
if (post is { PinMode: PostPinMode.RealmPage, RealmId: not null }) if (post is { PinMode: PostPinMode.RealmPage, RealmId: not null })
{ {
if (!await rs.IsMemberWithRole(post.RealmId.Value, accountId, RealmMemberRole.Moderator)) if (!await rs.IsMemberWithRole(post.RealmId.Value, accountId, new List<int> { RealmMemberRole.Moderator }))
return StatusCode(403, "You are not a moderator of this realm"); return StatusCode(403, "You are not a moderator of this realm");
} }
@@ -785,6 +829,7 @@ public class PostController(
.Include(e => e.Publisher) .Include(e => e.Publisher)
.Include(e => e.Categories) .Include(e => e.Categories)
.Include(e => e.Tags) .Include(e => e.Tags)
.Include(e => e.FeaturedRecords)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (post is null) return NotFound(); if (post is null) return NotFound();
@@ -849,9 +894,8 @@ public class PostController(
// The realm is the same as well as the poll // The realm is the same as well as the poll
if (request.RealmId is not null) if (request.RealmId is not null)
{ {
var realm = await db.Realms.FindAsync(request.RealmId.Value); var realm = await rs.GetRealm(request.RealmId.Value.ToString());
if (realm is null) return BadRequest("Realm was not found."); if (!await rs.IsMemberWithRole(realm.Id, accountId, new List<int> { RealmMemberRole.Normal }))
if (!await rs.IsMemberWithRole(realm.Id, accountId, RealmMemberRole.Normal))
return StatusCode(403, "You are not a member of this realm."); return StatusCode(403, "You are not a member of this realm.");
post.RealmId = realm.Id; post.RealmId = realm.Id;
} }

View File

@@ -25,9 +25,9 @@ public partial class PostService(
ILogger<PostService> logger, ILogger<PostService> logger,
FileService.FileServiceClient files, FileService.FileServiceClient files,
FileReferenceService.FileReferenceServiceClient fileRefs, FileReferenceService.FileReferenceServiceClient fileRefs,
PollService polls,
Publisher.PublisherService ps, Publisher.PublisherService ps,
WebReaderService reader WebReaderService reader,
AccountService.AccountServiceClient accounts
) )
{ {
private const string PostFileUsageIdentifier = "post"; private const string PostFileUsageIdentifier = "post";
@@ -685,6 +685,8 @@ public partial class PostService(
post.ForwardedPost.Publisher = forwardedPublisher; post.ForwardedPost.Publisher = forwardedPublisher;
} }
await ps.LoadIndividualPublisherAccounts(publishers.Values);
return posts; return posts;
} }
@@ -700,6 +702,16 @@ public partial class PostService(
: new Dictionary<Guid, Dictionary<string, bool>>(); : new Dictionary<Guid, Dictionary<string, bool>>();
var repliesCountMap = await GetPostRepliesCountBatch(postsId); var repliesCountMap = await GetPostRepliesCountBatch(postsId);
// Load user friends if the current user exists
List<SnPublisher> publishers = [];
List<Guid> userFriends = [];
if (currentUser is not null)
{
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest { AccountId = currentUser.Id });
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
publishers = await ps.GetUserPublishers(Guid.Parse(currentUser.Id));
}
foreach (var post in posts) foreach (var post in posts)
{ {
// Set reaction count // Set reaction count
@@ -717,6 +729,26 @@ public partial class PostService(
? repliesCount ? repliesCount
: 0; : 0;
// Check visibility for replied post
if (post.RepliedPost != null)
{
if (!CanViewPost(post.RepliedPost, currentUser, publishers, userFriends))
{
post.RepliedPost = null;
post.RepliedGone = true;
}
}
// Check visibility for forwarded post
if (post.ForwardedPost != null)
{
if (!CanViewPost(post.ForwardedPost, currentUser, publishers, userFriends))
{
post.ForwardedPost = null;
post.ForwardedGone = true;
}
}
// Track view for each post in the list // Track view for each post in the list
if (currentUser != null) if (currentUser != null)
await IncreaseViewCount(post.Id, currentUser.Id); await IncreaseViewCount(post.Id, currentUser.Id);
@@ -727,6 +759,39 @@ public partial class PostService(
return posts; return posts;
} }
private bool CanViewPost(SnPost post, Account? currentUser, List<SnPublisher> publishers, List<Guid> userFriends)
{
var now = SystemClock.Instance.GetCurrentInstant();
var publishersId = publishers.Select(e => e.Id).ToList();
// Check if post is deleted
if (post.DeletedAt != null)
return false;
if (currentUser is null)
{
// Anonymous user can only view public posts that are published
return post.PublishedAt != null && now >= post.PublishedAt && post.Visibility == PostVisibility.Public;
}
// Check publication status - either published or user is member
var isPublished = post.PublishedAt != null && now >= post.PublishedAt;
var isMember = publishersId.Contains(post.PublisherId);
if (!isPublished && !isMember)
return false;
// Check visibility
if (post.Visibility == PostVisibility.Private && !isMember)
return false;
if (post.Visibility == PostVisibility.Friends &&
!(post.Publisher.AccountId.HasValue && userFriends.Contains(post.Publisher.AccountId.Value) || isMember))
return false;
// Public and Unlisted are allowed
return true;
}
private async Task<Dictionary<Guid, int>> GetPostRepliesCountBatch(List<Guid> postIds) private async Task<Dictionary<Guid, int>> GetPostRepliesCountBatch(List<Guid> postIds)
{ {
return await db.Posts return await db.Posts
@@ -738,46 +803,6 @@ public partial class PostService(
); );
} }
private async Task LoadPostEmbed(SnPost post, Account? currentUser)
{
if (!post.Meta!.TryGetValue("embeds", out var value))
return;
var embeds = value switch
{
JsonElement e => e.Deserialize<List<Dictionary<string, object>>>(),
_ => null
};
if (embeds is null)
return;
// Find the index of the poll embed first
var pollIndex = embeds.FindIndex(e =>
e.ContainsKey("type") && ((JsonElement)e["type"]).ToString() == "poll"
);
if (pollIndex < 0)
{
post.Meta["embeds"] = embeds;
return;
}
var pollEmbed = embeds[pollIndex];
try
{
var pollId = Guid.Parse(((JsonElement)pollEmbed["id"]).ToString());
Guid? currentUserId = currentUser is not null ? Guid.Parse(currentUser.Id) : null;
var updatedPoll = await polls.LoadPollEmbed(pollId, currentUserId);
embeds[pollIndex] = EmbeddableBase.ToDictionary(updatedPoll);
post.Meta["embeds"] = embeds;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to load poll embed for post {PostId}", post.Id);
}
}
public async Task<List<SnPost>> LoadPostInfo( public async Task<List<SnPost>> LoadPostInfo(
List<SnPost> posts, List<SnPost> posts,
Account? currentUser = null, Account? currentUser = null,
@@ -789,12 +814,6 @@ public partial class PostService(
posts = await LoadPublishers(posts); posts = await LoadPublishers(posts);
posts = await LoadInteractive(posts, currentUser); posts = await LoadInteractive(posts, currentUser);
foreach (
var post in posts
.Where(e => e.Meta is not null && e.Meta.ContainsKey("embeds"))
)
await LoadPostEmbed(post, currentUser);
if (truncate) if (truncate)
posts = TruncatePostContent(posts); posts = TruncatePostContent(posts);
@@ -896,6 +915,7 @@ public partial class PostService(
.Include(e => e.RepliedPost) .Include(e => e.RepliedPost)
.Include(e => e.Categories) .Include(e => e.Categories)
.Include(e => e.Publisher) .Include(e => e.Publisher)
.Include(e => e.FeaturedRecords)
.Take(featuredIds.Count) .Take(featuredIds.Count)
.ToListAsync(); .ToListAsync();
posts = posts.OrderBy(e => featuredIds.IndexOf(e.Id)).ToList(); posts = posts.OrderBy(e => featuredIds.IndexOf(e.Id)).ToList();
@@ -938,7 +958,7 @@ public partial class PostService(
var pub = scope.ServiceProvider.GetRequiredService<Publisher.PublisherService>(); var pub = scope.ServiceProvider.GetRequiredService<Publisher.PublisherService>();
var nty = scope.ServiceProvider.GetRequiredService<RingService.RingServiceClient>(); var nty = scope.ServiceProvider.GetRequiredService<RingService.RingServiceClient>();
var accounts = scope.ServiceProvider.GetRequiredService<AccountService.AccountServiceClient>(); var accounts = scope.ServiceProvider.GetRequiredService<AccountService.AccountServiceClient>();
var accountsHelper = scope.ServiceProvider.GetRequiredService<AccountClientHelper>(); var accountsHelper = scope.ServiceProvider.GetRequiredService<RemoteAccountService>();
try try
{ {
var sender = await accountsHelper.GetAccount(accountId); var sender = await accountsHelper.GetAccount(accountId);

View File

@@ -15,7 +15,6 @@ builder.ConfigureAppKestrel(builder.Configuration);
// Add application services // Add application services
builder.Services.AddAppServices(); builder.Services.AddAppServices();
builder.Services.AddAppRateLimiting();
builder.Services.AddAppAuthentication(); builder.Services.AddAppAuthentication();
builder.Services.AddDysonAuth(); builder.Services.AddDysonAuth();
builder.Services.AddAccountService(); builder.Services.AddAccountService();
@@ -45,6 +44,6 @@ using (var scope = app.Services.CreateScope())
// Configure application middleware pipeline // Configure application middleware pipeline
app.ConfigureAppMiddleware(builder.Configuration); app.ConfigureAppMiddleware(builder.Configuration);
app.UseSwaggerManifest(); app.UseSwaggerManifest("DysonNetwork.Sphere");
app.Run(); app.Run();

View File

@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Auth; using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -17,7 +18,8 @@ public class PublisherController(
AccountService.AccountServiceClient accounts, AccountService.AccountServiceClient accounts,
FileService.FileServiceClient files, FileService.FileServiceClient files,
FileReferenceService.FileReferenceServiceClient fileRefs, FileReferenceService.FileReferenceServiceClient fileRefs,
ActionLogService.ActionLogServiceClient als ActionLogService.ActionLogServiceClient als,
RemoteRealmService remoteRealmService
) )
: ControllerBase : ControllerBase
{ {
@@ -352,13 +354,11 @@ public class PublisherController(
return BadRequest("Name and Nick are required."); return BadRequest("Name and Nick are required.");
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var realm = await db.Realms.FirstOrDefaultAsync(r => r.Slug == realmSlug); var realm = await remoteRealmService.GetRealmBySlug(realmSlug);
if (realm == null) return NotFound("Realm not found"); if (realm == null) return NotFound("Realm not found");
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
var isAdmin = await db.RealmMembers var isAdmin = await remoteRealmService.IsMemberWithRole(realm.Id, accountId, [RealmMemberRole.Moderator]);
.AnyAsync(m =>
m.RealmId == realm.Id && m.AccountId == accountId && m.Role >= RealmMemberRole.Moderator);
if (!isAdmin) if (!isAdmin)
return StatusCode(403, "You need to be a moderator of the realm to create an organization publisher"); return StatusCode(403, "You need to be a moderator of the realm to create an organization publisher");

View File

@@ -4,6 +4,7 @@ using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry; using DysonNetwork.Shared.Registry;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using PublisherType = DysonNetwork.Shared.Models.PublisherType;
namespace DysonNetwork.Sphere.Publisher; namespace DysonNetwork.Sphere.Publisher;
@@ -11,7 +12,7 @@ public class PublisherService(
AppDatabase db, AppDatabase db,
FileReferenceService.FileReferenceServiceClient fileRefs, FileReferenceService.FileReferenceServiceClient fileRefs,
ICacheService cache, ICacheService cache,
AccountClientHelper accountsHelper RemoteAccountService remoteAccounts
) )
{ {
public async Task<SnPublisher?> GetPublisherByName(string name) public async Task<SnPublisher?> GetPublisherByName(string name)
@@ -408,7 +409,8 @@ public class PublisherService(
return isEnabled.Value; return isEnabled.Value;
} }
public async Task<bool> IsMemberWithRole(Guid publisherId, Guid accountId, Shared.Models.PublisherMemberRole requiredRole) public async Task<bool> IsMemberWithRole(Guid publisherId, Guid accountId,
Shared.Models.PublisherMemberRole requiredRole)
{ {
var member = await db.Publishers var member = await db.Publishers
.Where(p => p.Id == publisherId) .Where(p => p.Id == publisherId)
@@ -420,7 +422,7 @@ public class PublisherService(
public async Task<SnPublisherMember> LoadMemberAccount(SnPublisherMember member) public async Task<SnPublisherMember> LoadMemberAccount(SnPublisherMember member)
{ {
var account = await accountsHelper.GetAccount(member.AccountId); var account = await remoteAccounts.GetAccount(member.AccountId);
member.Account = SnAccount.FromProtoValue(account); member.Account = SnAccount.FromProtoValue(account);
return member; return member;
} }
@@ -428,13 +430,35 @@ public class PublisherService(
public async Task<List<SnPublisherMember>> LoadMemberAccounts(ICollection<SnPublisherMember> members) public async Task<List<SnPublisherMember>> LoadMemberAccounts(ICollection<SnPublisherMember> members)
{ {
var accountIds = members.Select(m => m.AccountId).ToList(); var accountIds = members.Select(m => m.AccountId).ToList();
var accounts = (await accountsHelper.GetAccountBatch(accountIds)).ToDictionary(a => Guid.Parse(a.Id), a => a); var accounts = (await remoteAccounts.GetAccountBatch(accountIds)).ToDictionary(a => Guid.Parse(a.Id), a => a);
return [.. members.Select(m => return
[
.. members.Select(m =>
{ {
if (accounts.TryGetValue(m.AccountId, out var account)) if (accounts.TryGetValue(m.AccountId, out var account))
m.Account = SnAccount.FromProtoValue(account); m.Account = SnAccount.FromProtoValue(account);
return m; return m;
})]; })
];
}
public async Task<List<SnPublisher>> LoadIndividualPublisherAccounts(ICollection<SnPublisher> publishers)
{
var accountIds = publishers
.Where(p => p.AccountId.HasValue && p.Type == PublisherType.Individual)
.Select(p => p.AccountId!.Value)
.ToList();
if (accountIds.Count == 0) return publishers.ToList();
var accounts = (await remoteAccounts.GetAccountBatch(accountIds)).ToDictionary(a => Guid.Parse(a.Id), a => a);
foreach (var p in publishers)
{
if (p.AccountId.HasValue && accounts.TryGetValue(p.AccountId.Value, out var account))
p.Account = SnAccount.FromProtoValue(account);
}
return publishers.ToList();
} }
} }

View File

@@ -93,7 +93,7 @@ public class BroadcastEventHandler(
break; break;
} }
default: default:
await msg.NakAsync(cancellationToken: stoppingToken); // ignore
break; break;
} }
} }
@@ -134,10 +134,6 @@ public class BroadcastEventHandler(
.Where(m => m.AccountId == evt.AccountId) .Where(m => m.AccountId == evt.AccountId)
.ExecuteDeleteAsync(cancellationToken: stoppingToken); .ExecuteDeleteAsync(cancellationToken: stoppingToken);
await db.RealmMembers
.Where(m => m.AccountId == evt.AccountId)
.ExecuteDeleteAsync(cancellationToken: stoppingToken);
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken: stoppingToken); await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken: stoppingToken);
try try
{ {

View File

@@ -5,14 +5,11 @@ using DysonNetwork.Sphere.Chat.Realtime;
using DysonNetwork.Sphere.Localization; using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Post; using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Publisher; using DysonNetwork.Sphere.Publisher;
using DysonNetwork.Sphere.Realm;
using DysonNetwork.Sphere.Sticker; using DysonNetwork.Sphere.Sticker;
using Microsoft.AspNetCore.RateLimiting;
using NodaTime; using NodaTime;
using NodaTime.Serialization.SystemTextJson; using NodaTime.Serialization.SystemTextJson;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.GeoIp; using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Registry; using DysonNetwork.Shared.Registry;
@@ -41,7 +38,6 @@ public static class ServiceCollectionExtensions
{ {
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals; options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
}).AddDataAnnotationsLocalization(options => }).AddDataAnnotationsLocalization(options =>
@@ -75,19 +71,6 @@ public static class ServiceCollectionExtensions
return services; return services;
} }
public static IServiceCollection AddAppRateLimiting(this IServiceCollection services)
{
services.AddRateLimiter(o => o.AddFixedWindowLimiter(policyName: "fixed", opts =>
{
opts.Window = TimeSpan.FromMinutes(1);
opts.PermitLimit = 120;
opts.QueueLimit = 2;
opts.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
}));
return services;
}
public static IServiceCollection AddAppAuthentication(this IServiceCollection services) public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
{ {
services.AddAuthorization(); services.AddAuthorization();
@@ -111,7 +94,6 @@ public static class ServiceCollectionExtensions
services.AddScoped<PublisherSubscriptionService>(); services.AddScoped<PublisherSubscriptionService>();
services.AddScoped<ActivityService>(); services.AddScoped<ActivityService>();
services.AddScoped<PostService>(); services.AddScoped<PostService>();
services.AddScoped<RealmService>();
services.AddScoped<ChatRoomService>(); services.AddScoped<ChatRoomService>();
services.AddScoped<ChatService>(); services.AddScoped<ChatService>();
services.AddScoped<StickerService>(); services.AddScoped<StickerService>();
@@ -120,7 +102,8 @@ public static class ServiceCollectionExtensions
services.AddScoped<WebFeedService>(); services.AddScoped<WebFeedService>();
services.AddScoped<DiscoveryService>(); services.AddScoped<DiscoveryService>();
services.AddScoped<PollService>(); services.AddScoped<PollService>();
services.AddScoped<AccountClientHelper>(); services.AddScoped<RemoteAccountService>();
services.AddScoped<RemoteRealmService>();
services.AddScoped<AutocompletionService>(); services.AddScoped<AutocompletionService>();
var translationProvider = configuration["Translation:Provider"]?.ToLower(); var translationProvider = configuration["Translation:Provider"]?.ToLower();

View File

@@ -237,6 +237,22 @@ public class StickerController(
return Redirect($"/drive/files/{sticker.Image.Id}?original=true"); return Redirect($"/drive/files/{sticker.Image.Id}?original=true");
} }
[HttpGet("search")]
public async Task<ActionResult<List<SnSticker>>> SearchSticker([FromQuery] string query, [FromQuery] int take = 10, [FromQuery] int offset = 0)
{
var queryable = db.Stickers
.Include(s => s.Pack)
.Where(s => EF.Functions.Like(s.Pack.Prefix + "+" + s.Slug, $"{query}%"))
.OrderByDescending(s => s.CreatedAt)
.AsQueryable();
var totalCount = await queryable.CountAsync();
Response.Headers["X-Total"] = totalCount.ToString();
var stickers = await queryable.Take(take).Skip(offset).ToListAsync();
return Ok(stickers);
}
[HttpGet("{packId:guid}/content/{id:guid}")] [HttpGet("{packId:guid}/content/{id:guid}")]
public async Task<ActionResult<SnSticker>> GetSticker(Guid packId, Guid id) public async Task<ActionResult<SnSticker>> GetSticker(Guid packId, Guid id)
{ {

View File

@@ -41,7 +41,7 @@ public class WebFeed : ModelBase
[Column(TypeName = "jsonb")] public WebFeedConfig Config { get; set; } = new(); [Column(TypeName = "jsonb")] public WebFeedConfig Config { get; set; } = new();
public Guid PublisherId { get; set; } public Guid PublisherId { get; set; }
public Shared.Models.SnPublisher Publisher { get; set; } = null!; public SnPublisher Publisher { get; set; } = null!;
[JsonIgnore] public ICollection<WebArticle> Articles { get; set; } = new List<WebArticle>(); [JsonIgnore] public ICollection<WebArticle> Articles { get; set; } = new List<WebArticle>();
} }

View File

@@ -167,6 +167,9 @@
&lt;Assembly Path="/opt/homebrew/Cellar/dotnet/9.0.6/libexec/packs/Microsoft.AspNetCore.App.Ref/9.0.6/ref/net9.0/Microsoft.AspNetCore.RateLimiting.dll" /&gt; &lt;Assembly Path="/opt/homebrew/Cellar/dotnet/9.0.6/libexec/packs/Microsoft.AspNetCore.App.Ref/9.0.6/ref/net9.0/Microsoft.AspNetCore.RateLimiting.dll" /&gt;
&lt;Assembly Path="/Users/littlesheep/.nuget/packages/nodatime/3.2.2/lib/net8.0/NodaTime.dll" /&gt; &lt;Assembly Path="/Users/littlesheep/.nuget/packages/nodatime/3.2.2/lib/net8.0/NodaTime.dll" /&gt;
&lt;/AssemblyExplorer&gt;</s:String> &lt;/AssemblyExplorer&gt;</s:String>
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=d3d47f4e_002D5d7b_002D4bb3_002D9fd2_002D0e52828cc908/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;
&lt;Solution /&gt;
&lt;/SessionState&gt;</s:String>
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002EPass_002FResources_002FLocalization_002FAccountEventResource/@EntryIndexedValue">False</s:Boolean> <s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002EPass_002FResources_002FLocalization_002FAccountEventResource/@EntryIndexedValue">False</s:Boolean>
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002EPass_002FResources_002FLocalization_002FEmailResource/@EntryIndexedValue">False</s:Boolean> <s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002EPass_002FResources_002FLocalization_002FEmailResource/@EntryIndexedValue">False</s:Boolean>

403
README_LOTTERY.md Normal file
View File

@@ -0,0 +1,403 @@
:bug# Lottery System API Documentation
## Overview
The DysonNetwork Lottery System provides a daily lottery where users can purchase tickets with custom number selections. Each day features a new draw with random winning numbers. Users purchase tickets using ISP (Dyson Network Points), with results announced each morning.
The API is handled by the DysonNetwork.Pass service. Which means if you use it with the Gateway the `/api` should be replaced with `/pass`
### Key Features
- **Daily Draws**: Automated draws at midnight UTC
- **Custom Number Selection**: Users choose 5 unique numbers (0-99) + 1 special number (0-99)
- **Flexible Pricing**: Base cost 10 ISP + extra ISP per multiplier (e.g., multiplier=2 costs 20 ISP)
- **Daily Limits**: One ticket purchase per user per day
- **Prize System**: Multiple prize tiers based on matches
- **Instant Payment**: Tickets purchased using in-app points
- **Historical Records**: Complete draw history and statistics
## Data Models
### LotteryDrawStatus Enum
```csharp
public enum LotteryDrawStatus
{
Pending = 0, // Ticket awaiting draw
Drawn = 1 // Ticket has been processed in draw
}
```
### SnLottery Model
```csharp
public class SnLottery : ModelBase
{
public Guid Id { get; set; }
public SnAccount Account { get; set; } = null!;
public Guid AccountId { get; set; }
public List<int> RegionOneNumbers { get; set; } = new(); // 5 numbers (0-99)
public int RegionTwoNumber { get; set; } // Special number (0-99)
public int Multiplier { get; set; } = 1; // Prize multiplier (≥1)
public LotteryDrawStatus DrawStatus { get; set; }
public DateTime? DrawDate { get; set; } // Date when drawn
}
```
### SnLotteryRecord Model
```csharp
public class SnLotteryRecord : ModelBase
{
public Guid Id { get; set; }
public DateTime DrawDate { get; set; }
public List<int> WinningRegionOneNumbers { get; set; } = new(); // 5 winning numbers
public int WinningRegionTwoNumber { get; set; } // Winning special number
public int TotalTickets { get; set; } // Total tickets processed
public int TotalPrizesAwarded { get; set; } // Number of winning tickets
public long TotalPrizeAmount { get; set; } // Total ISP prize amount
}
```
## Prize Structure
| Region 1 Matches | Base Prize (ISP) | Notes |
|-----------------|------------------|-------|
| 0 | 0 | No prize |
| 1 | 10 | Minimum win |
| 2 | 20 | Double minimum |
| 3 | 50 | Five times minimum |
| 4 | 100 | Ten times minimum |
| 5 | 1000 | Maximum prize |
**Special Number Bonus**: If Region 2 number matches, multiply any prize by 10x.
## API Endpoints
All endpoints require authentication via Bearer token.
### Purchase Ticket
**POST** `/api/lotteries`
Creates a lottery order and deducts ISP from user's wallet.
**Request Body:**
```json
{
"RegionOneNumbers": [5, 23, 47, 68, 89],
"RegionTwoNumber": 42,
"Multiplier": 1
}
```
**Response:**
```json
{
"id": "guid",
"accountId": "guid",
"createdAt": "2025-10-24T00:00:00Z",
"status": "Paid",
"currency": "isp",
"amount": 10,
"productIdentifier": "lottery"
}
```
**Validation Rules:**
- `RegionOneNumbers`: Exactly 5 unique integers between 0-99
- `RegionTwoNumber`: Single integer between 0-99
- `Multiplier`: Integer ≥ 1
- User can only purchase 1 ticket per day
**Pricing:**
- Base cost: 10 ISP
- Additional cost: (Multiplier - 1) × 10 ISP
- Total cost = (Multiplier × 10) ISP
### Get User Tickets
**GET** `/api/lotteries`
Retrieves user's lottery tickets with pagination.
**Query Parameters:**
- `offset` (optional, default 0): Page offset
- `limit` (optional, default 20, max 100): Items per page
**Response:**
```json
[
{
"id": "guid",
"regionOneNumbers": [5, 23, 47, 68, 89],
"regionTwoNumber": 42,
"multiplier": 1,
"drawStatus": "Pending",
"drawDate": null,
"createdAt": "2025-10-24T10:30:00Z"
}
]
```
**Response Headers:**
```
X-Total: 42 // Total number of user's tickets
```
### Get Specific Ticket
**GET** `/api/lotteries/{id}`
Retrieves a specific lottery ticket by ID.
**Response:**
Same structure as individual items from Get User Tickets.
**Error Responses:**
- `404 Not Found`: Ticket doesn't exist or user doesn't own it
### Get Lottery Records
**GET** `/api/lotteries/records`
Retrieves historical lottery draw results.
**Query Parameters:**
- `startDate` (optional): Filter by draw date (YYYY-MM-DD)
- `endDate` (optional): Filter by draw date (YYYY-MM-DD)
- `offset` (optional, default 0): Page offset
- `limit` (optional, default 20): Items per page
**Response:**
```json
[
{
"id": "guid",
"drawDate": "2025-10-24T00:00:00Z",
"winningRegionOneNumbers": [7, 15, 23, 46, 82],
"winningRegionTwoNumber": 19,
"totalTickets": 245,
"totalPrizesAwarded": 23,
"totalPrizeAmount": 4820
}
]
```
## Integration Examples
### Frontend Integration (JavaScript/React)
```javascript
// Purchase a lottery ticket
async function purchaseLottery(numbers, specialNumber, multiplier = 1) {
try {
const response = await fetch('/api/lotteries', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${userToken}`
},
body: JSON.stringify({
RegionOneNumbers: numbers, // Array of 5 unique numbers 0-99
RegionTwoNumber: specialNumber, // Number 0-99
Multiplier: multiplier // Optional, defaults to 1
})
});
const order = await response.json();
if (response.ok) {
console.log('Ticket purchased successfully!', order);
// Refresh user ISP balance
updateWalletBalance();
} else {
console.error('Purchase failed:', order);
}
} catch (error) {
console.error('Network error:', error);
}
}
// Get user's tickets
async function getUserTickets() {
try {
const response = await fetch('/api/lotteries?limit=20', {
headers: {
'Authorization': `Bearer ${userToken}`
}
});
const tickets = await response.json();
const totalTickets = response.headers.get('X-Total');
return { tickets, total: parseInt(totalTickets) };
} catch (error) {
console.error('Error fetching tickets:', error);
}
}
// Get draw history
async function getDrawHistory() {
try {
const response = await fetch('/api/lotteries/records', {
headers: {
'Authorization': `Bearer ${userToken}`
}
});
return await response.json();
} catch (error) {
console.error('Error fetching history:', error);
}
}
```
### Mobile Integration (React Native/TypeScript)
```typescript
interface LotteryTicket {
id: string;
regionOneNumbers: number[];
regionTwoNumber: number;
multiplier: number;
drawStatus: 'Pending' | 'Drawn';
drawDate?: string;
createdAt: string;
}
interface PurchaseRequest {
RegionOneNumbers: number[];
RegionTwoNumber: number;
Multiplier: number;
}
class LotteryService {
private apiUrl = 'https://your-api-domain.com/api/lotteries';
async purchaseTicket(
ticket: Omit<PurchaseRequest, 'RegionOneNumbers'> & { numbers: number[] },
token: string
): Promise<any> {
const request: PurchaseRequest = {
RegionOneNumbers: ticket.numbers,
RegionTwoNumber: ticket.RegionTwoNumber,
Multiplier: ticket.Multiplier
};
const response = await fetch(this.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(request)
});
return response.json();
}
async getTickets(token: string, offset = 0, limit = 20): Promise<LotteryTicket[]> {
const response = await fetch(`${this.apiUrl}?offset=${offset}&limit=${limit}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
return response.json();
}
async getDrawRecords(token: string): Promise<any[]> {
const response = await fetch(`${this.apiUrl}/records`, {
headers: { 'Authorization': `Bearer ${token}` }
});
return response.json();
}
}
```
### Number Validation
```javascript
function validateLotteryNumbers(numbers, specialNumber, multiplier = 1) {
// Validate region one numbers
if (!Array.isArray(numbers) || numbers.length !== 5) {
return { valid: false, error: 'Must select exactly 5 numbers' };
}
const uniqueNumbers = new Set(numbers);
if (uniqueNumbers.size !== 5) {
return { valid: false, error: 'Numbers must be unique' };
}
// Check range 0-99
for (const num of numbers) {
if (!Number.isInteger(num) || num < 0 || num > 99) {
return { valid: false, error: 'Numbers must be integers between 0-99' };
}
}
// Validate special number
if (!Number.isInteger(specialNumber) || specialNumber < 0 || specialNumber > 99) {
return { valid: false, error: 'Special number must be between 0-99' };
}
// Validate multiplier
if (!Number.isInteger(multiplier) || multiplier < 1) {
return { valid: false, error: 'Multiplier must be 1 or greater' };
}
return { valid: true };
}
// Example usage
const validation = validateLotteryNumbers([5, 12, 23, 47, 89], 42, 2);
if (!validation.valid) {
console.error(validation.error);
}
```
## Daily Draw Schedule
- **Draw Time**: Every midnight UTC (00:00 UTC)
- **Processing**: Only tickets from the previous day are included
- **Prize Distribution**: Winners automatically receive ISP credits
- **History**: Draws are preserved indefinitely
## Error Handling
### Common Error Codes
- `400 Bad Request`: Invalid request data (bad numbers, duplicate purchase, etc.)
- `401 Unauthorized`: Missing or invalid authentication token
- `404 Not Found`: Ticket doesn't exist or access denied
- `403 Forbidden`: Insufficient permissions (admin endpoints)
### Error Response Format
```json
{
"message": "You can only purchase one lottery per day.",
"type": "ArgumentException",
"statusCode": 400
}
```
## Testing Guidelines
### Test Cases
1. **Valid Purchase**: Select valid numbers, verify wallet deduction
2. **Invalid Numbers**: Try duplicate region one numbers, out-of-range values
3. **Daily Limit**: Attempt second purchase in same day
4. **Insufficient Funds**: Try purchase without enough ISP
5. **Draw Processing**: Verify winning tickets receive correct prizes
6. **Historical Data**: Check draw records match processed tickets
### Test Data Examples
```javascript
// Valid ticket
{ numbers: [1, 15, 23, 67, 89], special: 42, multiplier: 1 }
// Invalid - duplicate numbers
{ numbers: [1, 15, 23, 15, 89], special: 42, multiplier: 1 }
// Invalid - out of range
{ numbers: [1, 15, 23, 67, 150], special: 42, multiplier: 1 }
```
## Support
For API integration questions or support:
- Check network documentation for authentication details
- Contact Dyson Network development team for assistance
- Monitor API response headers for pagination metadata