36 Commits

Author SHA1 Message Date
a071bd2738 Publication site global config data structure 2025-12-10 19:33:00 +08:00
43945fc524 🐛 Fix discovery realms order incorrect 2025-12-07 14:28:41 +08:00
e477429a35 👔 Increase the chance of other type of activities show up
🗑️ Remove debug include in timeline
2025-12-06 21:12:08 +08:00
fe3a057185 👔 Discovery realms will show desc by member count 2025-12-06 21:10:08 +08:00
ad3c104c5c Proper trace for auth session 2025-12-04 00:38:44 +08:00
2020d625aa 🗃️ Add migration of add sticker pack icon 2025-12-04 00:27:09 +08:00
f471c5635d Post article thumbnail 2025-12-04 00:26:54 +08:00
eaeaa28c60 Sticker icon 2025-12-04 00:19:36 +08:00
ee5c7cb7ce 🐛 Fix get device API 2025-12-03 23:29:31 +08:00
33abf12e41 🐛 Fix pass service swagger docs duplicate schema name cause 500 2025-12-03 22:46:47 +08:00
4a71f92ef0 ♻️ Updated auth challenges and device API to fit new design 2025-12-03 22:43:35 +08:00
4faa1a4b64 🐛 Fix message pack cache serilaize issue in sticker 2025-12-03 22:09:56 +08:00
e49a1ec49a Push token clean up when invalid 2025-12-03 21:42:18 +08:00
a88f42b26a Rolling back to old logic to provide mock device id in websocket gateway 2025-12-03 21:30:29 +08:00
c45be62331 Support switching from JSON to MessagePack in cache during runtime 2025-12-03 21:27:26 +08:00
c8228e0c8e Use JSON to serialize cache 2025-12-03 01:47:57 +08:00
c642c6d646 Resend self activation email API 2025-12-03 01:17:39 +08:00
270c211cb8 ♻️ Refactored to make a simplifier auth session system 2025-12-03 00:38:28 +08:00
74c8f3490d 🐛 Fix the message pack serializer 2025-12-03 00:38:12 +08:00
b364edc74b Use Json Serializer in cache again 2025-12-02 22:59:43 +08:00
9addf38677 🐛 Enable contractless serilization in cache to fix message pack serilizer 2025-12-02 22:51:12 +08:00
a02ed10434 🐛 Fix use wrong DI type in cache service 2025-12-02 22:45:30 +08:00
aca28f9318 ♻️ Refactored the cache service 2025-12-02 22:38:47 +08:00
c2f72993b7 🐛 Fix app snapshot didn't included in release 2025-12-02 21:52:24 +08:00
158cc75c5b 💥 Simplified permission node system and data structure 2025-12-02 21:42:26 +08:00
fa2f53ff7a 🐛 Fix file reference created with wrong date 2025-12-02 21:03:57 +08:00
2cce5ebf80 Use affiliation spell for registeration 2025-12-02 00:54:57 +08:00
13b2e46ecc Affliation spell CRUD 2025-12-01 23:33:48 +08:00
cbd68c9ae6 Proper site manager send file method 2025-12-01 22:55:20 +08:00
b99b61e0f9 🐛 Fix chat backward comapbility 2025-11-30 21:33:39 +08:00
94f4e68120 Timeout prevent send message logic 2025-11-30 21:13:54 +08:00
d5510f7e4d Chat timeout APIs
🐛 Fix member listing in chat
2025-11-30 21:08:07 +08:00
c038ab9e3c ♻️ A more robust and simpler chat system 2025-11-30 20:58:48 +08:00
e97719ec84 🗃️ Add missing account id migrations 2025-11-30 20:13:15 +08:00
40b8ea8eb8 🗃️ Bring account id back to chat room 2025-11-30 19:59:30 +08:00
f9b4dd45d7 🐛 Trying to fix relationship bugs 2025-11-30 17:52:19 +08:00
119 changed files with 21126 additions and 2373 deletions

View File

@@ -69,7 +69,7 @@ public class DeveloperController(
[HttpPost("{name}/enroll")] [HttpPost("{name}/enroll")]
[Authorize] [Authorize]
[RequiredPermission("global", "developers.create")] [AskPermission("developers.create")]
public async Task<ActionResult<SnDeveloper>> EnrollDeveloperProgram(string name) public async Task<ActionResult<SnDeveloper>> EnrollDeveloperProgram(string name)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();

View File

@@ -16,7 +16,7 @@ public static class ApplicationConfiguration
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseMiddleware<PermissionMiddleware>(); app.UseMiddleware<RemotePermissionMiddleware>();
app.MapControllers(); app.MapControllers();

View File

@@ -16,9 +16,7 @@ public static class ServiceCollectionExtensions
services.AddLocalization(); services.AddLocalization();
services.AddDbContext<AppDatabase>(); services.AddDbContext<AppDatabase>();
services.AddSingleton<IClock>(SystemClock.Instance);
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddSingleton<ICacheService, CacheServiceRedis>();
services.AddHttpClient(); services.AddHttpClient();

View File

@@ -1,22 +1,28 @@
{ {
"Debug": true, "Debug": true,
"BaseUrl": "http://localhost:5071", "BaseUrl": "http://localhost:5071",
"SiteUrl": "https://solian.app", "SiteUrl": "https://solian.app",
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_develop;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
},
"KnownProxies": [
"127.0.0.1",
"::1"
],
"Swagger": {
"PublicBasePath": "/develop"
},
"Cache": {
"Serializer": "MessagePack"
},
"Etcd": {
"Insecure": true
} }
},
"AllowedHosts": "*",
"ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_develop;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
},
"KnownProxies": ["127.0.0.1", "::1"],
"Swagger": {
"PublicBasePath": "/develop"
},
"Etcd": {
"Insecure": true
}
} }

View File

@@ -6,7 +6,6 @@ using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Query;
using NodaTime; using NodaTime;
using Quartz; using Quartz;
using TaskStatus = DysonNetwork.Drive.Storage.Model.TaskStatus; using TaskStatus = DysonNetwork.Drive.Storage.Model.TaskStatus;

View File

@@ -12,9 +12,7 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
{ {
services.AddDbContext<AppDatabase>(); // Assuming you'll have an AppDatabase services.AddDbContext<AppDatabase>(); // Assuming you'll have an AppDatabase
services.AddSingleton<IClock>(SystemClock.Instance);
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddSingleton<ICacheService, CacheServiceRedis>(); // Uncomment if you have CacheServiceRedis
services.AddHttpClient(); services.AddHttpClient();

View File

@@ -381,7 +381,7 @@ public class FileController(
[Authorize] [Authorize]
[HttpDelete("recycle")] [HttpDelete("recycle")]
[RequiredPermission("maintenance", "files.delete.recycle")] [AskPermission("files.delete.recycle")]
public async Task<ActionResult> DeleteAllRecycledFiles() public async Task<ActionResult> DeleteAllRecycledFiles()
{ {
var count = await fs.DeleteAllRecycledFilesAsync(); var count = await fs.DeleteAllRecycledFilesAsync();

View File

@@ -58,12 +58,15 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
Duration? duration = null Duration? duration = null
) )
{ {
var now = SystemClock.Instance.GetCurrentInstant();
var data = fileId.Select(id => new SnCloudFileReference var data = fileId.Select(id => new SnCloudFileReference
{ {
FileId = id, FileId = id,
Usage = usage, Usage = usage,
ResourceId = resourceId, ResourceId = resourceId,
ExpiredAt = expiredAt ?? SystemClock.Instance.GetCurrentInstant() + duration ExpiredAt = expiredAt ?? now + duration,
CreatedAt = now,
UpdatedAt = now
}).ToList(); }).ToList();
await db.BulkInsertAsync(data); await db.BulkInsertAsync(data);
return data; return data;

View File

@@ -113,7 +113,7 @@ public class FileUploadController(
if (currentUser.IsSuperuser) return null; if (currentUser.IsSuperuser) return null;
var allowed = await permission.HasPermissionAsync(new HasPermissionRequest var allowed = await permission.HasPermissionAsync(new HasPermissionRequest
{ Actor = $"user:{currentUser.Id}", Area = "global", Key = "files.create" }); { Actor = currentUser.Id, Key = "files.create" });
return allowed.HasPermission return allowed.HasPermission
? null ? null

View File

@@ -1,118 +1,121 @@
{ {
"Debug": true, "Debug": true,
"BaseUrl": "http://localhost:5090", "BaseUrl": "http://localhost:5090",
"GatewayUrl": "http://localhost:5094", "GatewayUrl": "http://localhost:5094",
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": { "ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60" "App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
}, },
"Authentication": { "Authentication": {
"Schemes": { "Schemes": {
"Bearer": { "Bearer": {
"ValidAudiences": [ "ValidAudiences": [
"http://localhost:5071", "http://localhost:5071",
"https://localhost:7099" "https://localhost:7099"
], ],
"ValidIssuer": "solar-network" "ValidIssuer": "solar-network"
} }
} }
}, },
"AuthToken": { "AuthToken": {
"PublicKeyPath": "Keys/PublicKey.pem", "PublicKeyPath": "Keys/PublicKey.pem",
"PrivateKeyPath": "Keys/PrivateKey.pem" "PrivateKeyPath": "Keys/PrivateKey.pem"
}, },
"Storage": { "Storage": {
"Uploads": "Uploads", "Uploads": "Uploads",
"PreferredRemote": "c53136a6-9152-4ecb-9f88-43c41438c23e", "PreferredRemote": "c53136a6-9152-4ecb-9f88-43c41438c23e",
"Remote": [ "Remote": [
{ {
"Id": "minio", "Id": "minio",
"Label": "Minio", "Label": "Minio",
"Region": "auto", "Region": "auto",
"Bucket": "solar-network-development", "Bucket": "solar-network-development",
"Endpoint": "localhost:9000", "Endpoint": "localhost:9000",
"SecretId": "littlesheep", "SecretId": "littlesheep",
"SecretKey": "password", "SecretKey": "password",
"EnabledSigned": true, "EnabledSigned": true,
"EnableSsl": false "EnableSsl": false
}, },
{ {
"Id": "cloudflare", "Id": "cloudflare",
"Label": "Cloudflare R2", "Label": "Cloudflare R2",
"Region": "auto", "Region": "auto",
"Bucket": "solar-network", "Bucket": "solar-network",
"Endpoint": "0a70a6d1b7128888c823359d0008f4e1.r2.cloudflarestorage.com", "Endpoint": "0a70a6d1b7128888c823359d0008f4e1.r2.cloudflarestorage.com",
"SecretId": "8ff5d06c7b1639829d60bc6838a542e6", "SecretId": "8ff5d06c7b1639829d60bc6838a542e6",
"SecretKey": "fd58158c5201be16d1872c9209d9cf199421dae3c2f9972f94b2305976580d67", "SecretKey": "fd58158c5201be16d1872c9209d9cf199421dae3c2f9972f94b2305976580d67",
"EnableSigned": true, "EnableSigned": true,
"EnableSsl": true "EnableSsl": true
} }
]
},
"Captcha": {
"Provider": "cloudflare",
"ApiKey": "0x4AAAAAABCDUdOujj4feOb_",
"ApiSecret": "0x4AAAAAABCDUWABiJQweqlB7tYq-IqIm8U"
},
"Notifications": {
"Topic": "dev.solsynth.solian",
"Endpoint": "http://localhost:8088"
},
"Email": {
"Server": "smtp4dev.orb.local",
"Port": 25,
"UseSsl": false,
"Username": "no-reply@mail.solsynth.dev",
"Password": "password",
"FromAddress": "no-reply@mail.solsynth.dev",
"FromName": "Alphabot",
"SubjectPrefix": "Solar Network"
},
"RealtimeChat": {
"Endpoint": "https://solar-network-im44o8gq.livekit.cloud",
"ApiKey": "APIs6TiL8wj3A4j",
"ApiSecret": "SffxRneIwTnlHPtEf3zicmmv3LUEl7xXael4PvWZrEhE"
},
"GeoIp": {
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
},
"Oidc": {
"Google": {
"ClientId": "961776991058-963m1qin2vtp8fv693b5fdrab5hmpl89.apps.googleusercontent.com",
"ClientSecret": ""
},
"Apple": {
"ClientId": "dev.solsynth.solian",
"TeamId": "W7HPZ53V6B",
"KeyId": "B668YP4KBG",
"PrivateKeyPath": "./Keys/Solarpass.p8"
},
"Microsoft": {
"ClientId": "YOUR_MICROSOFT_CLIENT_ID",
"ClientSecret": "YOUR_MICROSOFT_CLIENT_SECRET",
"DiscoveryEndpoint": "YOUR_MICROSOFT_DISCOVERY_ENDPOINT"
}
},
"Payment": {
"Auth": {
"Afdian": "<token here>"
},
"Subscriptions": {
"Afdian": {
"7d17aae23c9611f0b5705254001e7c00": "solian.stellar.primary",
"7dfae4743c9611f0b3a55254001e7c00": "solian.stellar.nova",
"141713ee3d6211f085b352540025c377": "solian.stellar.supernova"
}
}
},
"Cache": {
"Serializer": "MessagePack"
},
"KnownProxies": [
"127.0.0.1",
"::1"
] ]
},
"Captcha": {
"Provider": "cloudflare",
"ApiKey": "0x4AAAAAABCDUdOujj4feOb_",
"ApiSecret": "0x4AAAAAABCDUWABiJQweqlB7tYq-IqIm8U"
},
"Notifications": {
"Topic": "dev.solsynth.solian",
"Endpoint": "http://localhost:8088"
},
"Email": {
"Server": "smtp4dev.orb.local",
"Port": 25,
"UseSsl": false,
"Username": "no-reply@mail.solsynth.dev",
"Password": "password",
"FromAddress": "no-reply@mail.solsynth.dev",
"FromName": "Alphabot",
"SubjectPrefix": "Solar Network"
},
"RealtimeChat": {
"Endpoint": "https://solar-network-im44o8gq.livekit.cloud",
"ApiKey": "APIs6TiL8wj3A4j",
"ApiSecret": "SffxRneIwTnlHPtEf3zicmmv3LUEl7xXael4PvWZrEhE"
},
"GeoIp": {
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
},
"Oidc": {
"Google": {
"ClientId": "961776991058-963m1qin2vtp8fv693b5fdrab5hmpl89.apps.googleusercontent.com",
"ClientSecret": ""
},
"Apple": {
"ClientId": "dev.solsynth.solian",
"TeamId": "W7HPZ53V6B",
"KeyId": "B668YP4KBG",
"PrivateKeyPath": "./Keys/Solarpass.p8"
},
"Microsoft": {
"ClientId": "YOUR_MICROSOFT_CLIENT_ID",
"ClientSecret": "YOUR_MICROSOFT_CLIENT_SECRET",
"DiscoveryEndpoint": "YOUR_MICROSOFT_DISCOVERY_ENDPOINT"
}
},
"Payment": {
"Auth": {
"Afdian": "<token here>"
},
"Subscriptions": {
"Afdian": {
"7d17aae23c9611f0b5705254001e7c00": "solian.stellar.primary",
"7dfae4743c9611f0b3a55254001e7c00": "solian.stellar.nova",
"141713ee3d6211f085b352540025c377": "solian.stellar.supernova"
}
}
},
"KnownProxies": [
"127.0.0.1",
"::1"
]
} }

View File

@@ -1,13 +1,16 @@
{ {
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
}
},
"Cache": {
"Serializer": "MessagePack"
},
"AllowedHosts": "*",
"SiteUrl": "http://localhost:3000",
"Client": {
"SomeSetting": "SomeValue"
} }
},
"AllowedHosts": "*",
"SiteUrl": "http://localhost:3000",
"Client": {
"SomeSetting": "SomeValue"
}
} }

View File

@@ -14,9 +14,7 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddAppServices(this IServiceCollection services) public static IServiceCollection AddAppServices(this IServiceCollection services)
{ {
services.AddDbContext<AppDatabase>(); services.AddDbContext<AppDatabase>();
services.AddSingleton<IClock>(SystemClock.Instance);
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddSingleton<ICacheService, CacheServiceRedis>();
services.AddHttpClient(); services.AddHttpClient();

View File

@@ -19,6 +19,9 @@
"Etcd": { "Etcd": {
"Insecure": true "Insecure": true
}, },
"Cache": {
"Serializer": "MessagePack"
},
"Thinking": { "Thinking": {
"DefaultService": "deepseek-chat", "DefaultService": "deepseek-chat",
"Services": { "Services": {

View File

@@ -1,8 +1,10 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Affiliation;
using DysonNetwork.Pass.Auth; using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Credit; using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Permission; using DysonNetwork.Pass.Permission;
using DysonNetwork.Pass.Wallet; using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.GeoIp; using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Http; using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
@@ -22,6 +24,7 @@ public class AccountController(
SubscriptionService subscriptions, SubscriptionService subscriptions,
AccountEventService events, AccountEventService events,
SocialCreditService socialCreditService, SocialCreditService socialCreditService,
AffiliationSpellService ars,
GeoIpService geo GeoIpService geo
) : ControllerBase ) : ControllerBase
{ {
@@ -103,6 +106,8 @@ public class AccountController(
[MaxLength(32)] public string Language { get; set; } = "en-us"; [MaxLength(32)] public string Language { get; set; } = "en-us";
[Required] public string CaptchaToken { get; set; } = string.Empty; [Required] public string CaptchaToken { get; set; } = string.Empty;
public string? AffiliationSpell { get; set; }
} }
public class AccountCreateValidateRequest public class AccountCreateValidateRequest
@@ -118,6 +123,8 @@ public class AccountController(
[RegularExpression(@"^[^+]+@[^@]+\.[^@]+$", ErrorMessage = "Email address cannot contain '+' symbol.")] [RegularExpression(@"^[^+]+@[^@]+\.[^@]+$", ErrorMessage = "Email address cannot contain '+' symbol.")]
[MaxLength(1024)] [MaxLength(1024)]
public string? Email { get; set; } public string? Email { get; set; }
public string? AffiliationSpell { get; set; }
} }
[HttpPost("validate")] [HttpPost("validate")]
@@ -138,6 +145,12 @@ public class AccountController(
return BadRequest("Email has already been used."); return BadRequest("Email has already been used.");
} }
if (request.AffiliationSpell is not null)
{
if (!await ars.CheckAffiliationSpellHasTaken(request.AffiliationSpell))
return BadRequest("No affiliation spell has been found.");
}
return Ok("Everything seems good."); return Ok("Everything seems good.");
} }
@@ -307,7 +320,7 @@ public class AccountController(
[HttpPost("credits/validate")] [HttpPost("credits/validate")]
[Authorize] [Authorize]
[RequiredPermission("maintenance", "credits.validate.perform")] [AskPermission("credits.validate.perform")]
public async Task<IActionResult> PerformSocialCreditValidation() public async Task<IActionResult> PerformSocialCreditValidation()
{ {
await socialCreditService.ValidateSocialCredits(); await socialCreditService.ValidateSocialCredits();
@@ -316,7 +329,7 @@ public class AccountController(
[HttpDelete("{name}")] [HttpDelete("{name}")]
[Authorize] [Authorize]
[RequiredPermission("maintenance", "accounts.deletion")] [AskPermission("accounts.deletion")]
public async Task<IActionResult> AdminDeleteAccount(string name) public async Task<IActionResult> AdminDeleteAccount(string name)
{ {
var account = await accounts.LookupAccount(name); var account = await accounts.LookupAccount(name);

View File

@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Permission; using DysonNetwork.Pass.Permission;
using DysonNetwork.Pass.Wallet; using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http; using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
@@ -194,7 +195,7 @@ public class AccountCurrentController(
} }
[HttpPatch("statuses")] [HttpPatch("statuses")]
[RequiredPermission("global", "accounts.statuses.update")] [AskPermission("accounts.statuses.update")]
public async Task<ActionResult<SnAccountStatus>> UpdateStatus([FromBody] AccountController.StatusRequest request) public async Task<ActionResult<SnAccountStatus>> UpdateStatus([FromBody] AccountController.StatusRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
@@ -228,7 +229,7 @@ public class AccountCurrentController(
} }
[HttpPost("statuses")] [HttpPost("statuses")]
[RequiredPermission("global", "accounts.statuses.create")] [AskPermission("accounts.statuses.create")]
public async Task<ActionResult<SnAccountStatus>> CreateStatus([FromBody] AccountController.StatusRequest request) public async Task<ActionResult<SnAccountStatus>> CreateStatus([FromBody] AccountController.StatusRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
@@ -559,7 +560,7 @@ public class AccountCurrentController(
[HttpGet("devices")] [HttpGet("devices")]
[Authorize] [Authorize]
public async Task<ActionResult<List<SnAuthClientWithChallenge>>> GetDevices() public async Task<ActionResult<List<SnAuthClientWithSessions>>> GetDevices()
{ {
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser || if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized(); HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
@@ -570,18 +571,41 @@ public class AccountCurrentController(
.Where(device => device.AccountId == currentUser.Id) .Where(device => device.AccountId == currentUser.Id)
.ToListAsync(); .ToListAsync();
var challengeDevices = devices.Select(SnAuthClientWithChallenge.FromClient).ToList(); var sessionDevices = devices.ConvertAll(SnAuthClientWithSessions.FromClient).ToList();
var deviceIds = challengeDevices.Select(x => x.DeviceId).ToList(); var clientIds = sessionDevices.Select(x => x.Id).ToList();
var authChallenges = await db.AuthChallenges var authSessions = await db.AuthSessions
.Where(c => deviceIds.Contains(c.DeviceId)) .Where(c => c.ClientId != null && clientIds.Contains(c.ClientId.Value))
.GroupBy(c => c.DeviceId) .GroupBy(c => c.ClientId!.Value)
.ToDictionaryAsync(c => c.Key, c => c.ToList()); .ToDictionaryAsync(c => c.Key, c => c.ToList());
foreach (var challengeDevice in challengeDevices) foreach (var dev in sessionDevices)
if (authChallenges.TryGetValue(challengeDevice.DeviceId, out var challenge)) if (authSessions.TryGetValue(dev.Id, out var challenge))
challengeDevice.Challenges = challenge; dev.Sessions = challenge;
return Ok(challengeDevices); return Ok(sessionDevices);
}
[HttpGet("challenges")]
[Authorize]
public async Task<ActionResult<List<SnAuthChallenge>>> GetChallenges(
[FromQuery] int take = 20,
[FromQuery] int offset = 0
)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var query = db.AuthChallenges
.Where(challenge => challenge.AccountId == currentUser.Id)
.OrderByDescending(c => c.CreatedAt);
var total = await query.CountAsync();
Response.Headers.Append("X-Total", total.ToString());
var challenges = await query
.Skip(offset)
.Take(take)
.ToListAsync();
return Ok(challenges);
} }
[HttpGet("sessions")] [HttpGet("sessions")]
@@ -595,8 +619,8 @@ public class AccountCurrentController(
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized(); HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
var query = db.AuthSessions var query = db.AuthSessions
.OrderByDescending(x => x.LastGrantedAt)
.Include(session => session.Account) .Include(session => session.Account)
.Include(session => session.Challenge)
.Where(session => session.Account.Id == currentUser.Id); .Where(session => session.Account.Id == currentUser.Id);
var total = await query.CountAsync(); var total = await query.CountAsync();
@@ -604,7 +628,6 @@ public class AccountCurrentController(
Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString()); Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
var sessions = await query var sessions = await query
.OrderByDescending(x => x.LastGrantedAt)
.Skip(offset) .Skip(offset)
.Take(take) .Take(take)
.ToListAsync(); .ToListAsync();

View File

@@ -1,4 +1,5 @@
using System.Globalization; using System.Globalization;
using DysonNetwork.Pass.Affiliation;
using DysonNetwork.Pass.Auth.OpenId; using DysonNetwork.Pass.Auth.OpenId;
using DysonNetwork.Pass.Localization; using DysonNetwork.Pass.Localization;
using DysonNetwork.Pass.Mailer; using DysonNetwork.Pass.Mailer;
@@ -24,6 +25,7 @@ public class AccountService(
FileService.FileServiceClient files, FileService.FileServiceClient files,
FileReferenceService.FileReferenceServiceClient fileRefs, FileReferenceService.FileReferenceServiceClient fileRefs,
AccountUsernameService uname, AccountUsernameService uname,
AffiliationSpellService ars,
EmailService mailer, EmailService mailer,
RingService.RingServiceClient pusher, RingService.RingServiceClient pusher,
IStringLocalizer<NotificationResource> localizer, IStringLocalizer<NotificationResource> localizer,
@@ -101,6 +103,7 @@ public class AccountService(
string? password, string? password,
string language = "en-US", string language = "en-US",
string region = "en", string region = "en",
string? affiliationSpell = null,
bool isEmailVerified = false, bool isEmailVerified = false,
bool isActivated = false bool isActivated = false
) )
@@ -113,7 +116,7 @@ public class AccountService(
).CountAsync(); ).CountAsync();
if (dupeEmailCount > 0) if (dupeEmailCount > 0)
throw new InvalidOperationException("Account email has already been used."); throw new InvalidOperationException("Account email has already been used.");
var account = new SnAccount var account = new SnAccount
{ {
Name = name, Name = name,
@@ -122,7 +125,7 @@ public class AccountService(
Region = region, Region = region,
Contacts = Contacts =
[ [
new() new SnAccountContact
{ {
Type = Shared.Models.AccountContactType.Email, Type = Shared.Models.AccountContactType.Email,
Content = email, Content = email,
@@ -144,6 +147,9 @@ public class AccountService(
Profile = new SnAccountProfile() Profile = new SnAccountProfile()
}; };
if (affiliationSpell is not null)
await ars.CreateAffiliationResult(affiliationSpell, $"account:{account.Id}");
if (isActivated) if (isActivated)
{ {
account.ActivatedAt = SystemClock.Instance.GetCurrentInstant(); account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
@@ -152,7 +158,7 @@ public class AccountService(
{ {
db.PermissionGroupMembers.Add(new SnPermissionGroupMember db.PermissionGroupMembers.Add(new SnPermissionGroupMember
{ {
Actor = $"user:{account.Id}", Actor = account.Id.ToString(),
Group = defaultGroup Group = defaultGroup
}); });
} }
@@ -193,10 +199,7 @@ public class AccountService(
displayName, displayName,
userInfo.Email, userInfo.Email,
null, null,
"en-US", isEmailVerified: userInfo.EmailVerified
"en",
userInfo.EmailVerified,
userInfo.EmailVerified
); );
} }

View File

@@ -1,3 +1,5 @@
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -7,17 +9,31 @@ namespace DysonNetwork.Pass.Account;
[Route("/api/spells")] [Route("/api/spells")]
public class MagicSpellController(AppDatabase db, MagicSpellService sp) : ControllerBase public class MagicSpellController(AppDatabase db, MagicSpellService sp) : ControllerBase
{ {
[HttpPost("activation/resend")]
[Authorize]
public async Task<ActionResult> ResendActivationMagicSpell()
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var spell = await db.MagicSpells.FirstOrDefaultAsync(s =>
s.Type == MagicSpellType.AccountActivation && s.AccountId == currentUser.Id);
if (spell is null) return BadRequest("Unable to find activation magic spell.");
await sp.NotifyMagicSpell(spell, true);
return Ok();
}
[HttpPost("{spellId:guid}/resend")] [HttpPost("{spellId:guid}/resend")]
public async Task<ActionResult> ResendMagicSpell(Guid spellId) public async Task<ActionResult> ResendMagicSpell(Guid spellId)
{ {
var spell = db.MagicSpells.FirstOrDefault(x => x.Id == spellId); var spell = db.MagicSpells.FirstOrDefault(x => x.Id == spellId);
if (spell == null) if (spell == null)
return NotFound(); return NotFound();
await sp.NotifyMagicSpell(spell, true); await sp.NotifyMagicSpell(spell, true);
return Ok(); return Ok();
} }
[HttpGet("{spellWord}")] [HttpGet("{spellWord}")]
public async Task<ActionResult> GetMagicSpell(string spellWord) public async Task<ActionResult> GetMagicSpell(string spellWord)
{ {
@@ -38,7 +54,8 @@ public class MagicSpellController(AppDatabase db, MagicSpellService sp) : Contro
} }
[HttpPost("{spellWord}/apply")] [HttpPost("{spellWord}/apply")]
public async Task<ActionResult> ApplyMagicSpell([FromRoute] string spellWord, [FromBody] MagicSpellApplyRequest? request) public async Task<ActionResult> ApplyMagicSpell([FromRoute] string spellWord,
[FromBody] MagicSpellApplyRequest? request)
{ {
var word = Uri.UnescapeDataString(spellWord); var word = Uri.UnescapeDataString(spellWord);
var spell = await db.MagicSpells var spell = await db.MagicSpells
@@ -59,6 +76,7 @@ public class MagicSpellController(AppDatabase db, MagicSpellService sp) : Contro
{ {
return BadRequest(ex.Message); return BadRequest(ex.Message);
} }
return Ok(); return Ok();
} }
} }

View File

@@ -26,6 +26,7 @@ public class MagicSpellService(
Dictionary<string, object> meta, Dictionary<string, object> meta,
Instant? expiredAt = null, Instant? expiredAt = null,
Instant? affectedAt = null, Instant? affectedAt = null,
string? code = null,
bool preventRepeat = false bool preventRepeat = false
) )
{ {
@@ -41,7 +42,7 @@ public class MagicSpellService(
return existingSpell; return existingSpell;
} }
var spellWord = _GenerateRandomString(128); var spellWord = code ?? _GenerateRandomString(128);
var spell = new SnMagicSpell var spell = new SnMagicSpell
{ {
Spell = spellWord, Spell = spellWord,
@@ -193,7 +194,7 @@ public class MagicSpellService(
{ {
db.PermissionGroupMembers.Add(new SnPermissionGroupMember db.PermissionGroupMembers.Add(new SnPermissionGroupMember
{ {
Actor = $"user:{account.Id}", Actor = account.Id.ToString(),
Group = defaultGroup Group = defaultGroup
}); });
} }

View File

@@ -17,12 +17,18 @@ public class RelationshipService(
{ {
private const string UserFriendsCacheKeyPrefix = "accounts:friends:"; private const string UserFriendsCacheKeyPrefix = "accounts:friends:";
private const string UserBlockedCacheKeyPrefix = "accounts:blocked:"; private const string UserBlockedCacheKeyPrefix = "accounts:blocked:";
private static readonly TimeSpan CacheExpiration = TimeSpan.FromHours(1);
public async Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId) public async Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId)
{ {
if (accountId == Guid.Empty || relatedId == Guid.Empty)
throw new ArgumentException("Account IDs cannot be empty.");
if (accountId == relatedId)
return false; // Prevent self-relationships
var count = await db.AccountRelationships var count = await db.AccountRelationships
.Where(r => (r.AccountId == accountId && r.RelatedId == relatedId) || .Where(r => (r.AccountId == accountId && r.RelatedId == relatedId) ||
(r.AccountId == relatedId && r.AccountId == accountId)) (r.AccountId == relatedId && r.RelatedId == accountId))
.CountAsync(); .CountAsync();
return count > 0; return count > 0;
} }
@@ -34,6 +40,9 @@ public class RelationshipService(
bool ignoreExpired = false bool ignoreExpired = false
) )
{ {
if (accountId == Guid.Empty || relatedId == Guid.Empty)
throw new ArgumentException("Account IDs cannot be empty.");
var now = Instant.FromDateTimeUtc(DateTime.UtcNow); var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
var queries = db.AccountRelationships.AsQueryable() var queries = db.AccountRelationships.AsQueryable()
.Where(r => r.AccountId == accountId && r.RelatedId == relatedId); .Where(r => r.AccountId == accountId && r.RelatedId == relatedId);
@@ -61,7 +70,7 @@ public class RelationshipService(
db.AccountRelationships.Add(relationship); db.AccountRelationships.Add(relationship);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await PurgeRelationshipCache(sender.Id, target.Id); await PurgeRelationshipCache(sender.Id, target.Id, status);
return relationship; return relationship;
} }
@@ -80,7 +89,7 @@ public class RelationshipService(
db.Remove(relationship); db.Remove(relationship);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await PurgeRelationshipCache(sender.Id, target.Id); await PurgeRelationshipCache(sender.Id, target.Id, RelationshipStatus.Blocked);
return relationship; return relationship;
} }
@@ -114,19 +123,24 @@ public class RelationshipService(
} }
}); });
await PurgeRelationshipCache(sender.Id, target.Id, RelationshipStatus.Pending);
return relationship; return relationship;
} }
public async Task DeleteFriendRequest(Guid accountId, Guid relatedId) public async Task DeleteFriendRequest(Guid accountId, Guid relatedId)
{ {
var relationship = await GetRelationship(accountId, relatedId, RelationshipStatus.Pending); if (accountId == Guid.Empty || relatedId == Guid.Empty)
if (relationship is null) throw new ArgumentException("Friend request was not found."); throw new ArgumentException("Account IDs cannot be empty.");
await db.AccountRelationships var affectedRows = await db.AccountRelationships
.Where(r => r.AccountId == accountId && r.RelatedId == relatedId && r.Status == RelationshipStatus.Pending) .Where(r => r.AccountId == accountId && r.RelatedId == relatedId && r.Status == RelationshipStatus.Pending)
.ExecuteDeleteAsync(); .ExecuteDeleteAsync();
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId); if (affectedRows == 0)
throw new ArgumentException("Friend request was not found.");
await PurgeRelationshipCache(accountId, relatedId, RelationshipStatus.Pending);
} }
public async Task<SnAccountRelationship> AcceptFriendRelationship( public async Task<SnAccountRelationship> AcceptFriendRelationship(
@@ -155,7 +169,7 @@ public class RelationshipService(
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId); await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId, RelationshipStatus.Friends, status);
return relationshipBackward; return relationshipBackward;
} }
@@ -165,11 +179,12 @@ public class RelationshipService(
var relationship = await GetRelationship(accountId, relatedId); var relationship = await GetRelationship(accountId, relatedId);
if (relationship is null) throw new ArgumentException("There is no relationship between you and the user."); if (relationship is null) throw new ArgumentException("There is no relationship between you and the user.");
if (relationship.Status == status) return relationship; if (relationship.Status == status) return relationship;
var oldStatus = relationship.Status;
relationship.Status = status; relationship.Status = status;
db.Update(relationship); db.Update(relationship);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await PurgeRelationshipCache(accountId, relatedId); await PurgeRelationshipCache(accountId, relatedId, oldStatus, status);
return relationship; return relationship;
} }
@@ -181,21 +196,7 @@ public class RelationshipService(
public async Task<List<Guid>> ListAccountFriends(Guid accountId) public async Task<List<Guid>> ListAccountFriends(Guid accountId)
{ {
var cacheKey = $"{UserFriendsCacheKeyPrefix}{accountId}"; return await GetCachedRelationships(accountId, RelationshipStatus.Friends, UserFriendsCacheKeyPrefix);
var friends = await cache.GetAsync<List<Guid>>(cacheKey);
if (friends == null)
{
friends = await db.AccountRelationships
.Where(r => r.RelatedId == accountId)
.Where(r => r.Status == RelationshipStatus.Friends)
.Select(r => r.AccountId)
.ToListAsync();
await cache.SetAsync(cacheKey, friends, TimeSpan.FromHours(1));
}
return friends ?? [];
} }
public async Task<List<Guid>> ListAccountBlocked(SnAccount account) public async Task<List<Guid>> ListAccountBlocked(SnAccount account)
@@ -205,21 +206,7 @@ public class RelationshipService(
public async Task<List<Guid>> ListAccountBlocked(Guid accountId) public async Task<List<Guid>> ListAccountBlocked(Guid accountId)
{ {
var cacheKey = $"{UserBlockedCacheKeyPrefix}{accountId}"; return await GetCachedRelationships(accountId, RelationshipStatus.Blocked, UserBlockedCacheKeyPrefix);
var blocked = await cache.GetAsync<List<Guid>>(cacheKey);
if (blocked == null)
{
blocked = await db.AccountRelationships
.Where(r => r.RelatedId == accountId)
.Where(r => r.Status == RelationshipStatus.Blocked)
.Select(r => r.AccountId)
.ToListAsync();
await cache.SetAsync(cacheKey, blocked, TimeSpan.FromHours(1));
}
return blocked ?? [];
} }
public async Task<bool> HasRelationshipWithStatus(Guid accountId, Guid relatedId, public async Task<bool> HasRelationshipWithStatus(Guid accountId, Guid relatedId,
@@ -229,11 +216,52 @@ public class RelationshipService(
return relationship is not null; return relationship is not null;
} }
private async Task PurgeRelationshipCache(Guid accountId, Guid relatedId) private async Task<List<Guid>> GetCachedRelationships(Guid accountId, RelationshipStatus status, string cachePrefix)
{ {
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}"); if (accountId == Guid.Empty)
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relatedId}"); throw new ArgumentException("Account ID cannot be empty.");
await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{accountId}");
await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{relatedId}"); var cacheKey = $"{cachePrefix}{accountId}";
var relationships = await cache.GetAsync<List<Guid>>(cacheKey);
if (relationships == null)
{
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
relationships = await db.AccountRelationships
.Where(r => r.RelatedId == accountId)
.Where(r => r.Status == status)
.Where(r => r.ExpiredAt == null || r.ExpiredAt > now)
.Select(r => r.AccountId)
.ToListAsync();
await cache.SetAsync(cacheKey, relationships, CacheExpiration);
}
return relationships ?? new List<Guid>();
} }
}
private async Task PurgeRelationshipCache(Guid accountId, Guid relatedId, params RelationshipStatus[] statuses)
{
if (statuses.Length == 0)
{
statuses = Enum.GetValues<RelationshipStatus>();
}
var keysToRemove = new List<string>();
if (statuses.Contains(RelationshipStatus.Friends) || statuses.Contains(RelationshipStatus.Pending))
{
keysToRemove.Add($"{UserFriendsCacheKeyPrefix}{accountId}");
keysToRemove.Add($"{UserFriendsCacheKeyPrefix}{relatedId}");
}
if (statuses.Contains(RelationshipStatus.Blocked))
{
keysToRemove.Add($"{UserBlockedCacheKeyPrefix}{accountId}");
keysToRemove.Add($"{UserBlockedCacheKeyPrefix}{relatedId}");
}
var removeTasks = keysToRemove.Select(key => cache.RemoveAsync(key));
await Task.WhenAll(removeTasks);
}
}

View File

@@ -0,0 +1,134 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Pass.Affiliation;
[ApiController]
[Route("/api/affiliations")]
public class AffiliationSpellController(AppDatabase db, AffiliationSpellService ars) : ControllerBase
{
public class CreateAffiliationSpellRequest
{
[MaxLength(1024)] public string? Spell { get; set; }
}
[HttpPost]
[Authorize]
public async Task<ActionResult<SnAffiliationSpell>> CreateSpell([FromBody] CreateAffiliationSpellRequest request)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
var spell = await ars.CreateAffiliationSpell(currentUser.Id, request.Spell);
return Ok(spell);
}
catch (InvalidOperationException e)
{
return BadRequest(e.Message);
}
}
[HttpGet]
[Authorize]
public async Task<ActionResult<List<SnAffiliationSpell>>> ListCreatedSpells(
[FromQuery(Name = "order")] string orderBy = "date",
[FromQuery(Name = "desc")] bool orderDesc = false,
[FromQuery] int take = 10,
[FromQuery] int offset = 0
)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var queryable = db.AffiliationSpells
.Where(s => s.AccountId == currentUser.Id)
.AsQueryable();
queryable = orderBy switch
{
"usage" => orderDesc
? queryable.OrderByDescending(q => q.Results.Count)
: queryable.OrderBy(q => q.Results.Count),
_ => orderDesc
? queryable.OrderByDescending(q => q.CreatedAt)
: queryable.OrderBy(q => q.CreatedAt)
};
var totalCount = queryable.Count();
Response.Headers["X-Total"] = totalCount.ToString();
var spells = await queryable
.Skip(offset)
.Take(take)
.ToListAsync();
return Ok(spells);
}
[HttpGet("{id:guid}")]
[Authorize]
public async Task<ActionResult<SnAffiliationSpell>> GetSpell([FromRoute] Guid id)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var spell = await db.AffiliationSpells
.Where(s => s.AccountId == currentUser.Id)
.Where(s => s.Id == id)
.FirstOrDefaultAsync();
if (spell is null) return NotFound();
return Ok(spell);
}
[HttpGet("{id:guid}/results")]
[Authorize]
public async Task<ActionResult<List<SnAffiliationResult>>> ListResults(
[FromRoute] Guid id,
[FromQuery(Name = "desc")] bool orderDesc = false,
[FromQuery] int take = 10,
[FromQuery] int offset = 0
)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var queryable = db.AffiliationResults
.Include(r => r.Spell)
.Where(r => r.Spell.AccountId == currentUser.Id)
.Where(r => r.SpellId == id)
.AsQueryable();
// Order by creation date
queryable = orderDesc
? queryable.OrderByDescending(r => r.CreatedAt)
: queryable.OrderBy(r => r.CreatedAt);
var totalCount = queryable.Count();
Response.Headers["X-Total"] = totalCount.ToString();
var results = await queryable
.Skip(offset)
.Take(take)
.ToListAsync();
return Ok(results);
}
[HttpDelete("{id:guid}")]
[Authorize]
public async Task<ActionResult> DeleteSpell([FromRoute] Guid id)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var spell = await db.AffiliationSpells
.Where(s => s.AccountId == currentUser.Id)
.Where(s => s.Id == id)
.FirstOrDefaultAsync();
if (spell is null) return NotFound();
db.AffiliationSpells.Remove(spell);
await db.SaveChangesAsync();
return Ok();
}
}

View File

@@ -0,0 +1,62 @@
using System.Security.Cryptography;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Pass.Affiliation;
public class AffiliationSpellService(AppDatabase db)
{
public async Task<SnAffiliationSpell> CreateAffiliationSpell(Guid accountId, string? spellWord)
{
spellWord ??= _GenerateRandomString(8);
if (await CheckAffiliationSpellHasTaken(spellWord))
throw new InvalidOperationException("The spell has been taken.");
var spell = new SnAffiliationSpell
{
AccountId = accountId,
Spell = spellWord
};
db.AffiliationSpells.Add(spell);
await db.SaveChangesAsync();
return spell;
}
public async Task<SnAffiliationResult> CreateAffiliationResult(string spellWord, string resourceId)
{
var spell =
await db.AffiliationSpells.FirstOrDefaultAsync(a => a.Spell == spellWord);
if (spell is null) throw new InvalidOperationException("The spell was not found.");
var result = new SnAffiliationResult
{
Spell = spell,
ResourceIdentifier = resourceId
};
db.AffiliationResults.Add(result);
await db.SaveChangesAsync();
return result;
}
public async Task<bool> CheckAffiliationSpellHasTaken(string spellWord)
{
return await db.AffiliationSpells.AnyAsync(s => s.Spell == spellWord);
}
private static string _GenerateRandomString(int length)
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
var result = new char[length];
using var rng = RandomNumberGenerator.Create();
for (var i = 0; i < length; i++)
{
var bytes = new byte[1];
rng.GetBytes(bytes);
result[i] = chars[bytes[0] % chars.Length];
}
return new string(result);
}
}

View File

@@ -61,6 +61,9 @@ public class AppDatabase(
public DbSet<SnLottery> Lotteries { get; set; } = null!; public DbSet<SnLottery> Lotteries { get; set; } = null!;
public DbSet<SnLotteryRecord> LotteryRecords { get; set; } = null!; public DbSet<SnLotteryRecord> LotteryRecords { get; set; } = null!;
public DbSet<SnAffiliationSpell> AffiliationSpells { get; set; } = null!;
public DbSet<SnAffiliationResult> AffiliationResults { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
optionsBuilder.UseNpgsql( optionsBuilder.UseNpgsql(
@@ -100,7 +103,7 @@ public class AppDatabase(
"stickers.packs.create", "stickers.packs.create",
"stickers.create" "stickers.create"
}.Select(permission => }.Select(permission =>
PermissionService.NewPermissionNode("group:default", "global", permission, true)) PermissionService.NewPermissionNode("group:default", permission, true))
.ToList() .ToList()
}); });
await context.SaveChangesAsync(cancellationToken); await context.SaveChangesAsync(cancellationToken);

View File

@@ -70,7 +70,7 @@ public class DysonTokenAuthHandler(
}; };
// Add scopes as claims // Add scopes as claims
session.Challenge?.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope))); session.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope)));
// Add superuser claim if applicable // Add superuser claim if applicable
if (session.Account.IsSuperuser) if (session.Account.IsSuperuser)
@@ -117,16 +117,17 @@ public class DysonTokenAuthHandler(
{ {
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{ {
var token = authHeader["Bearer ".Length..].Trim(); var tokenText = authHeader["Bearer ".Length..].Trim();
var parts = token.Split('.'); var parts = tokenText.Split('.');
return new TokenInfo return new TokenInfo
{ {
Token = token, Token = tokenText,
Type = parts.Length == 3 ? TokenType.OidcKey : TokenType.AuthKey Type = parts.Length == 3 ? TokenType.OidcKey : TokenType.AuthKey
}; };
} }
else if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
{ {
return new TokenInfo return new TokenInfo
{ {
@@ -134,7 +135,8 @@ public class DysonTokenAuthHandler(
Type = TokenType.AuthKey Type = TokenType.AuthKey
}; };
} }
else if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
{ {
return new TokenInfo return new TokenInfo
{ {

View File

@@ -34,8 +34,8 @@ public class AuthController(
[Required] [MaxLength(256)] public string Account { get; set; } = null!; [Required] [MaxLength(256)] public string Account { get; set; } = null!;
[Required] [MaxLength(512)] public string DeviceId { get; set; } = null!; [Required] [MaxLength(512)] public string DeviceId { get; set; } = null!;
[MaxLength(1024)] public string? DeviceName { get; set; } [MaxLength(1024)] public string? DeviceName { get; set; }
public List<string> Audiences { get; set; } = new(); public List<string> Audiences { get; set; } = [];
public List<string> Scopes { get; set; } = new(); public List<string> Scopes { get; set; } = [];
} }
[HttpPost("challenge")] [HttpPost("challenge")]
@@ -68,15 +68,9 @@ public class AuthController(
.Where(e => e.UserAgent == userAgent) .Where(e => e.UserAgent == userAgent)
.Where(e => e.StepRemain > 0) .Where(e => e.StepRemain > 0)
.Where(e => e.ExpiredAt != null && now < e.ExpiredAt) .Where(e => e.ExpiredAt != null && now < e.ExpiredAt)
.Where(e => e.Type == Shared.Models.ChallengeType.Login)
.Where(e => e.DeviceId == request.DeviceId) .Where(e => e.DeviceId == request.DeviceId)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (existingChallenge is not null) if (existingChallenge is not null) return existingChallenge;
{
var existingSession = await db.AuthSessions.Where(e => e.ChallengeId == existingChallenge.Id)
.FirstOrDefaultAsync();
if (existingSession is null) return existingChallenge;
}
var challenge = new SnAuthChallenge var challenge = new SnAuthChallenge
{ {
@@ -111,14 +105,11 @@ public class AuthController(
.ThenInclude(e => e.Profile) .ThenInclude(e => e.Profile)
.FirstOrDefaultAsync(e => e.Id == id); .FirstOrDefaultAsync(e => e.Id == id);
if (challenge is null) if (challenge is not null) return challenge;
{ logger.LogWarning("GetChallenge: challenge not found (challengeId={ChallengeId}, ip={IpAddress})",
logger.LogWarning("GetChallenge: challenge not found (challengeId={ChallengeId}, ip={IpAddress})", id, HttpContext.Connection.RemoteIpAddress?.ToString());
id, HttpContext.Connection.RemoteIpAddress?.ToString()); return NotFound("Auth challenge was not found.");
return NotFound("Auth challenge was not found.");
}
return challenge;
} }
[HttpGet("challenge/{id:guid}/factors")] [HttpGet("challenge/{id:guid}/factors")]
@@ -216,7 +207,7 @@ public class AuthController(
throw new ArgumentException("Invalid password."); throw new ArgumentException("Invalid password.");
} }
} }
catch (Exception ex) catch (Exception)
{ {
challenge.FailedAttempts++; challenge.FailedAttempts++;
db.Update(challenge); db.Update(challenge);
@@ -229,8 +220,11 @@ public class AuthController(
); );
await db.SaveChangesAsync(); await db.SaveChangesAsync();
logger.LogWarning("DoChallenge: authentication failure (challengeId={ChallengeId}, factorId={FactorId}, accountId={AccountId}, failedAttempts={FailedAttempts}, factorType={FactorType}, ip={IpAddress}, uaLength={UaLength})", logger.LogWarning(
challenge.Id, factor.Id, challenge.AccountId, challenge.FailedAttempts, factor.Type, HttpContext.Connection.RemoteIpAddress?.ToString(), (HttpContext.Request.Headers.UserAgent.ToString() ?? "").Length); "DoChallenge: authentication failure (challengeId={ChallengeId}, factorId={FactorId}, accountId={AccountId}, failedAttempts={FailedAttempts}, factorType={FactorType}, ip={IpAddress}, uaLength={UaLength})",
challenge.Id, factor.Id, challenge.AccountId, challenge.FailedAttempts, factor.Type,
HttpContext.Connection.RemoteIpAddress?.ToString(),
HttpContext.Request.Headers.UserAgent.ToString().Length);
return BadRequest("Invalid password."); return BadRequest("Invalid password.");
} }
@@ -240,7 +234,7 @@ public class AuthController(
AccountService.SetCultureInfo(challenge.Account); AccountService.SetCultureInfo(challenge.Account);
await pusher.SendPushNotificationToUserAsync(new SendPushNotificationToUserRequest await pusher.SendPushNotificationToUserAsync(new SendPushNotificationToUserRequest
{ {
Notification = new PushNotification() Notification = new PushNotification
{ {
Topic = "auth.login", Topic = "auth.login",
Title = localizer["NewLoginTitle"], Title = localizer["NewLoginTitle"],
@@ -279,7 +273,7 @@ public class AuthController(
{ {
[Required] [MaxLength(512)] public string DeviceId { get; set; } = null!; [Required] [MaxLength(512)] public string DeviceId { get; set; } = null!;
[MaxLength(1024)] public string? DeviceName { get; set; } [MaxLength(1024)] public string? DeviceName { get; set; }
[Required] public DysonNetwork.Shared.Models.ClientPlatform Platform { get; set; } [Required] public Shared.Models.ClientPlatform Platform { get; set; }
public Instant? ExpiredAt { get; set; } public Instant? ExpiredAt { get; set; }
} }
@@ -338,8 +332,9 @@ public class AuthController(
[Microsoft.AspNetCore.Authorization.Authorize] // Use full namespace to avoid ambiguity with DysonNetwork.Pass.Permission.Authorize [Microsoft.AspNetCore.Authorization.Authorize] // Use full namespace to avoid ambiguity with DysonNetwork.Pass.Permission.Authorize
public async Task<ActionResult<TokenExchangeResponse>> LoginFromSession([FromBody] NewSessionRequest request) public async Task<ActionResult<TokenExchangeResponse>> LoginFromSession([FromBody] NewSessionRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser || if (HttpContext.Items["CurrentUser"] is not SnAccount ||
HttpContext.Items["CurrentSession"] is not Shared.Models.SnAuthSession currentSession) return Unauthorized(); HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession)
return Unauthorized();
var newSession = await auth.CreateSessionFromParentAsync( var newSession = await auth.CreateSessionFromParentAsync(
currentSession, currentSession,
@@ -352,16 +347,15 @@ public class AuthController(
var tk = auth.CreateToken(newSession); var tk = auth.CreateToken(newSession);
// Set cookie using HttpContext, similar to CreateSessionAndIssueToken // Set cookie using HttpContext, similar to CreateSessionAndIssueToken
var cookieDomain = _cookieDomain;
HttpContext.Response.Cookies.Append(AuthConstants.CookieTokenName, tk, new CookieOptions HttpContext.Response.Cookies.Append(AuthConstants.CookieTokenName, tk, new CookieOptions
{ {
HttpOnly = true, HttpOnly = true,
Secure = true, Secure = true,
SameSite = SameSiteMode.Lax, SameSite = SameSiteMode.Lax,
Domain = cookieDomain, Domain = _cookieDomain,
Expires = request.ExpiredAt?.ToDateTimeOffset() ?? DateTime.UtcNow.AddYears(20) Expires = request.ExpiredAt?.ToDateTimeOffset() ?? DateTime.UtcNow.AddYears(20)
}); });
return Ok(new TokenExchangeResponse { Token = tk }); return Ok(new TokenExchangeResponse { Token = tk });
} }
} }

View File

@@ -3,6 +3,7 @@ using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
@@ -14,7 +15,8 @@ public class AuthService(
IConfiguration config, IConfiguration config,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
ICacheService cache ICacheService cache,
GeoIpService geo
) )
{ {
private HttpContext HttpContext => httpContextAccessor.HttpContext!; private HttpContext HttpContext => httpContextAccessor.HttpContext!;
@@ -31,7 +33,7 @@ public class AuthService(
{ {
// 1) Find out how many authentication factors the account has enabled. // 1) Find out how many authentication factors the account has enabled.
var enabledFactors = await db.AccountAuthFactors var enabledFactors = await db.AccountAuthFactors
.Where(f => f.AccountId == account.Id) .Where(f => f.AccountId == account.Id && f.Type != AccountAuthFactorType.PinCode)
.Where(f => f.EnabledAt != null) .Where(f => f.EnabledAt != null)
.ToListAsync(); .ToListAsync();
var maxSteps = enabledFactors.Count; var maxSteps = enabledFactors.Count;
@@ -42,13 +44,18 @@ public class AuthService(
// 2) Get login context from recent sessions // 2) Get login context from recent sessions
var recentSessions = await db.AuthSessions var recentSessions = await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.AccountId == account.Id) .Where(s => s.AccountId == account.Id)
.Where(s => s.LastGrantedAt != null) .Where(s => s.LastGrantedAt != null)
.OrderByDescending(s => s.LastGrantedAt) .OrderByDescending(s => s.LastGrantedAt)
.Take(10) .Take(10)
.ToListAsync(); .ToListAsync();
var recentChallengeIds =
recentSessions
.Where(s => s.ChallengeId != null)
.Select(s => s.ChallengeId!.Value).ToList();
var recentChallenges = await db.AuthChallenges.Where(c => recentChallengeIds.Contains(c.Id)).ToListAsync();
var ipAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString(); var ipAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString();
var userAgent = request.Headers.UserAgent.ToString(); var userAgent = request.Headers.UserAgent.ToString();
@@ -60,14 +67,14 @@ public class AuthService(
else else
{ {
// Check if IP has been used before // Check if IP has been used before
var ipPreviouslyUsed = recentSessions.Any(s => s.Challenge?.IpAddress == ipAddress); var ipPreviouslyUsed = recentChallenges.Any(c => c.IpAddress == ipAddress);
if (!ipPreviouslyUsed) if (!ipPreviouslyUsed)
{ {
riskScore += 8; riskScore += 8;
} }
// Check geographical distance for last known location // Check geographical distance for last known location
var lastKnownIp = recentSessions.FirstOrDefault(s => !string.IsNullOrWhiteSpace(s.Challenge?.IpAddress))?.Challenge?.IpAddress; var lastKnownIp = recentChallenges.FirstOrDefault(c => !string.IsNullOrWhiteSpace(c.IpAddress))?.IpAddress;
if (!string.IsNullOrWhiteSpace(lastKnownIp) && lastKnownIp != ipAddress) if (!string.IsNullOrWhiteSpace(lastKnownIp) && lastKnownIp != ipAddress)
{ {
riskScore += 6; riskScore += 6;
@@ -81,9 +88,9 @@ public class AuthService(
} }
else else
{ {
var uaPreviouslyUsed = recentSessions.Any(s => var uaPreviouslyUsed = recentChallenges.Any(c =>
!string.IsNullOrWhiteSpace(s.Challenge?.UserAgent) && !string.IsNullOrWhiteSpace(c.UserAgent) &&
string.Equals(s.Challenge.UserAgent, userAgent, StringComparison.OrdinalIgnoreCase)); string.Equals(c.UserAgent, userAgent, StringComparison.OrdinalIgnoreCase));
if (!uaPreviouslyUsed) if (!uaPreviouslyUsed)
{ {
@@ -181,33 +188,28 @@ public class AuthService(
return totalRequiredSteps; return totalRequiredSteps;
} }
public async Task<SnAuthSession> CreateSessionForOidcAsync(SnAccount account, Instant time, public async Task<SnAuthSession> CreateSessionForOidcAsync(
Guid? customAppId = null, SnAuthSession? parentSession = null) SnAccount account,
Instant time,
Guid? customAppId = null,
SnAuthSession? parentSession = null
)
{ {
var challenge = new SnAuthChallenge var ipAddr = HttpContext.Connection.RemoteIpAddress?.ToString();
{ var geoLocation = ipAddr is not null ? geo.GetPointFromIp(ipAddr) : null;
AccountId = account.Id,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
UserAgent = HttpContext.Request.Headers.UserAgent,
StepRemain = 1,
StepTotal = 1,
Type = customAppId is not null ? ChallengeType.OAuth : ChallengeType.Oidc,
DeviceId = Guid.NewGuid().ToString(),
DeviceName = "OIDC/OAuth",
Platform = ClientPlatform.Web,
};
var session = new SnAuthSession var session = new SnAuthSession
{ {
AccountId = account.Id, AccountId = account.Id,
CreatedAt = time, CreatedAt = time,
LastGrantedAt = time, LastGrantedAt = time,
Challenge = challenge, IpAddress = ipAddr,
UserAgent = HttpContext.Request.Headers.UserAgent,
Location = geoLocation,
AppId = customAppId, AppId = customAppId,
ParentSessionId = parentSession?.Id ParentSessionId = parentSession?.Id,
Type = customAppId is not null ? SessionType.OAuth : SessionType.Oidc,
}; };
db.AuthChallenges.Add(challenge);
db.AuthSessions.Add(session); db.AuthSessions.Add(session);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -221,7 +223,8 @@ public class AuthService(
ClientPlatform platform = ClientPlatform.Unidentified ClientPlatform platform = ClientPlatform.Unidentified
) )
{ {
var device = await db.AuthClients.FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.AccountId == accountId); var device = await db.AuthClients
.FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.AccountId == accountId);
if (device is not null) return device; if (device is not null) return device;
device = new SnAuthClient device = new SnAuthClient
{ {
@@ -342,12 +345,8 @@ public class AuthService(
/// <param name="sessionsToRevoke">A HashSet to store the IDs of all sessions to be revoked.</param> /// <param name="sessionsToRevoke">A HashSet to store the IDs of all sessions to be revoked.</param>
private async Task CollectSessionsToRevoke(Guid currentSessionId, HashSet<Guid> sessionsToRevoke) private async Task CollectSessionsToRevoke(Guid currentSessionId, HashSet<Guid> sessionsToRevoke)
{ {
if (sessionsToRevoke.Contains(currentSessionId)) if (!sessionsToRevoke.Add(currentSessionId))
{
return; // Already processed this session return; // Already processed this session
}
sessionsToRevoke.Add(currentSessionId);
// Find direct children // Find direct children
var childSessions = await db.AuthSessions var childSessions = await db.AuthSessions
@@ -419,20 +418,24 @@ public class AuthService(
if (challenge.StepRemain != 0) if (challenge.StepRemain != 0)
throw new ArgumentException("Challenge not yet completed."); throw new ArgumentException("Challenge not yet completed.");
var hasSession = await db.AuthSessions var device = await GetOrCreateDeviceAsync(
.AnyAsync(e => e.ChallengeId == challenge.Id); challenge.AccountId,
if (hasSession) challenge.DeviceId,
throw new ArgumentException("Session already exists for this challenge."); challenge.DeviceName,
challenge.Platform
);
var device = await GetOrCreateDeviceAsync(challenge.AccountId, challenge.DeviceId, challenge.DeviceName,
challenge.Platform);
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
var session = new SnAuthSession var session = new SnAuthSession
{ {
LastGrantedAt = now, LastGrantedAt = now,
ExpiredAt = now.Plus(Duration.FromDays(7)), ExpiredAt = now.Plus(Duration.FromDays(7)),
AccountId = challenge.AccountId, AccountId = challenge.AccountId,
IpAddress = challenge.IpAddress,
UserAgent = challenge.UserAgent,
Location = challenge.Location,
Scopes = challenge.Scopes,
Audiences = challenge.Audiences,
ChallengeId = challenge.Id, ChallengeId = challenge.Id,
ClientId = device.Id, ClientId = device.Id,
}; };
@@ -457,7 +460,7 @@ public class AuthService(
return tk; return tk;
} }
private string CreateCompactToken(Guid sessionId, RSA rsa) private static string CreateCompactToken(Guid sessionId, RSA rsa)
{ {
// Create the payload: just the session ID // Create the payload: just the session ID
var payloadBytes = sessionId.ToByteArray(); var payloadBytes = sessionId.ToByteArray();
@@ -548,7 +551,8 @@ public class AuthService(
return key; return key;
} }
public async Task<SnApiKey> CreateApiKey(Guid accountId, string label, Instant? expiredAt = null, SnAuthSession? parentSession = null) public async Task<SnApiKey> CreateApiKey(Guid accountId, string label, Instant? expiredAt = null,
SnAuthSession? parentSession = null)
{ {
var key = new SnApiKey var key = new SnApiKey
{ {
@@ -684,9 +688,16 @@ public class AuthService(
{ {
var device = await GetOrCreateDeviceAsync(parentSession.AccountId, deviceId, deviceName, platform); var device = await GetOrCreateDeviceAsync(parentSession.AccountId, deviceId, deviceName, platform);
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
var geoLocation = ipAddress is not null ? geo.GetPointFromIp(ipAddress) : null;
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
var session = new SnAuthSession var session = new SnAuthSession
{ {
IpAddress = ipAddress,
UserAgent = userAgent,
Location = geoLocation,
AccountId = parentSession.AccountId, AccountId = parentSession.AccountId,
CreatedAt = now, CreatedAt = now,
LastGrantedAt = now, LastGrantedAt = now,
@@ -700,4 +711,4 @@ public class AuthService(
return session; return session;
} }
} }

View File

@@ -306,7 +306,7 @@ public class OidcProviderController(
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized(); HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
// Get requested scopes from the token // Get requested scopes from the token
var scopes = currentSession.Challenge?.Scopes ?? []; var scopes = currentSession.Scopes;
var userInfo = new Dictionary<string, object> var userInfo = new Dictionary<string, object>
{ {

View File

@@ -72,7 +72,6 @@ public class OidcProviderService(
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
var queryable = db.AuthSessions var queryable = db.AuthSessions
.Include(s => s.Challenge)
.AsQueryable(); .AsQueryable();
if (withAccount) if (withAccount)
queryable = queryable queryable = queryable
@@ -85,8 +84,7 @@ public class OidcProviderService(
.Where(s => s.AccountId == accountId && .Where(s => s.AccountId == accountId &&
s.AppId == clientId && s.AppId == clientId &&
(s.ExpiredAt == null || s.ExpiredAt > now) && (s.ExpiredAt == null || s.ExpiredAt > now) &&
s.Challenge != null && s.Type == Shared.Models.SessionType.OAuth)
s.Challenge.Type == Shared.Models.ChallengeType.OAuth)
.OrderByDescending(s => s.CreatedAt) .OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
} }
@@ -511,7 +509,6 @@ public class OidcProviderService(
{ {
return await db.AuthSessions return await db.AuthSessions
.Include(s => s.Account) .Include(s => s.Account)
.Include(s => s.Challenge)
.FirstOrDefaultAsync(s => s.Id == sessionId); .FirstOrDefaultAsync(s => s.Id == sessionId);
} }

View File

@@ -23,11 +23,6 @@ public class OidcController(
private const string StateCachePrefix = "oidc-state:"; private const string StateCachePrefix = "oidc-state:";
private static readonly TimeSpan StateExpiration = TimeSpan.FromMinutes(15); private static readonly TimeSpan StateExpiration = TimeSpan.FromMinutes(15);
public class TokenExchangeResponse
{
public string Token { get; set; } = string.Empty;
}
[HttpGet("{provider}")] [HttpGet("{provider}")]
public async Task<ActionResult> OidcLogin( public async Task<ActionResult> OidcLogin(
[FromRoute] string provider, [FromRoute] string provider,
@@ -81,7 +76,7 @@ public class OidcController(
/// Handles Apple authentication directly from mobile apps /// Handles Apple authentication directly from mobile apps
/// </summary> /// </summary>
[HttpPost("apple/mobile")] [HttpPost("apple/mobile")]
public async Task<ActionResult<TokenExchangeResponse>> AppleMobileLogin( public async Task<ActionResult<AuthController.TokenExchangeResponse>> AppleMobileLogin(
[FromBody] AppleMobileSignInRequest request [FromBody] AppleMobileSignInRequest request
) )
{ {
@@ -118,7 +113,7 @@ public class OidcController(
); );
var token = auth.CreateToken(session); var token = auth.CreateToken(session);
return Ok(new TokenExchangeResponse { Token = token }); return Ok(new AuthController.TokenExchangeResponse { Token = token });
} }
catch (SecurityTokenValidationException ex) catch (SecurityTokenValidationException ex)
{ {

View File

@@ -77,7 +77,7 @@ public class TokenAuthService(
"AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})", "AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})",
sessionId, sessionId,
session.AccountId, session.AccountId,
session.Challenge?.Scopes.Count, session.Scopes.Count,
session.ExpiredAt session.ExpiredAt
); );
return (true, session, null); return (true, session, null);
@@ -87,7 +87,6 @@ public class TokenAuthService(
session = await db.AuthSessions session = await db.AuthSessions
.AsNoTracking() .AsNoTracking()
.Include(e => e.Challenge)
.Include(e => e.Client) .Include(e => e.Client)
.Include(e => e.Account) .Include(e => e.Account)
.ThenInclude(e => e.Profile) .ThenInclude(e => e.Profile)
@@ -112,9 +111,9 @@ public class TokenAuthService(
session.AccountId, session.AccountId,
session.ClientId, session.ClientId,
session.AppId, session.AppId,
session.Challenge?.Scopes.Count, session.Scopes.Count,
session.Challenge?.IpAddress, session.IpAddress,
(session.Challenge?.UserAgent ?? string.Empty).Length (session.UserAgent ?? string.Empty).Length
); );
logger.LogDebug("AuthenticateTokenAsync: enriching account with subscription (accountId={AccountId})", session.AccountId); logger.LogDebug("AuthenticateTokenAsync: enriching account with subscription (accountId={AccountId})", session.AccountId);

View File

@@ -6,16 +6,17 @@ using Quartz;
namespace DysonNetwork.Pass.Handlers; namespace DysonNetwork.Pass.Handlers;
public class ActionLogFlushHandler(IServiceProvider serviceProvider) : IFlushHandler<SnActionLog> public class ActionLogFlushHandler(IServiceProvider sp) : IFlushHandler<SnActionLog>
{ {
public async Task FlushAsync(IReadOnlyList<SnActionLog> items) public async Task FlushAsync(IReadOnlyList<SnActionLog> items)
{ {
using var scope = serviceProvider.CreateScope(); using var scope = sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>(); var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var now = SystemClock.Instance.GetCurrentInstant();
await db.BulkInsertAsync(items.Select(x => await db.BulkInsertAsync(items.Select(x =>
{ {
x.CreatedAt = SystemClock.Instance.GetCurrentInstant(); x.CreatedAt = now;
x.UpdatedAt = x.CreatedAt; x.UpdatedAt = x.CreatedAt;
return x; return x;
}), config => config.ConflictOption = ConflictOption.Ignore); }), config => config.ConflictOption = ConflictOption.Ignore);

View File

@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Pass.Wallet; using DysonNetwork.Pass.Wallet;
using DysonNetwork.Pass.Permission; using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Auth;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -81,7 +82,7 @@ public class LotteryController(AppDatabase db, LotteryService lotteryService) :
[HttpPost("draw")] [HttpPost("draw")]
[Authorize] [Authorize]
[RequiredPermission("maintenance", "lotteries.draw.perform")] [AskPermission("lotteries.draw.perform")]
public async Task<IActionResult> PerformLotteryDraw() public async Task<IActionResult> PerformLotteryDraw()
{ {
await lotteryService.DrawLotteries(); await lotteryService.DrawLotteries();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,90 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddAffiliationSpell : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "affiliation_spells",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
spell = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
type = table.Column<int>(type: "integer", nullable: false),
expires_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
affected_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: false),
account_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_affiliation_spells", x => x.id);
table.ForeignKey(
name: "fk_affiliation_spells_accounts_account_id",
column: x => x.account_id,
principalTable: "accounts",
principalColumn: "id");
});
migrationBuilder.CreateTable(
name: "affiliation_results",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
resource_identifier = table.Column<string>(type: "character varying(8192)", maxLength: 8192, nullable: false),
spell_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_affiliation_results", x => x.id);
table.ForeignKey(
name: "fk_affiliation_results_affiliation_spells_spell_id",
column: x => x.spell_id,
principalTable: "affiliation_spells",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_affiliation_results_spell_id",
table: "affiliation_results",
column: "spell_id");
migrationBuilder.CreateIndex(
name: "ix_affiliation_spells_account_id",
table: "affiliation_spells",
column: "account_id");
migrationBuilder.CreateIndex(
name: "ix_affiliation_spells_spell",
table: "affiliation_spells",
column: "spell",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "affiliation_results");
migrationBuilder.DropTable(
name: "affiliation_spells");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class SimplifiedPermissionNode : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_permission_nodes_key_area_actor",
table: "permission_nodes");
migrationBuilder.DropColumn(
name: "area",
table: "permission_nodes");
migrationBuilder.AddColumn<int>(
name: "type",
table: "permission_nodes",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateIndex(
name: "ix_permission_nodes_key_actor",
table: "permission_nodes",
columns: new[] { "key", "actor" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_permission_nodes_key_actor",
table: "permission_nodes");
migrationBuilder.DropColumn(
name: "type",
table: "permission_nodes");
migrationBuilder.AddColumn<string>(
name: "area",
table: "permission_nodes",
type: "character varying(1024)",
maxLength: 1024,
nullable: false,
defaultValue: "");
migrationBuilder.CreateIndex(
name: "ix_permission_nodes_key_area_actor",
table: "permission_nodes",
columns: new[] { "key", "area", "actor" });
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,105 @@
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class SimplifiedAuthSession : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_auth_sessions_auth_challenges_challenge_id",
table: "auth_sessions");
migrationBuilder.DropIndex(
name: "ix_auth_sessions_challenge_id",
table: "auth_sessions");
migrationBuilder.DropColumn(
name: "type",
table: "auth_challenges");
migrationBuilder.AddColumn<List<string>>(
name: "audiences",
table: "auth_sessions",
type: "jsonb",
nullable: false,
defaultValue: new List<string>());
migrationBuilder.AddColumn<string>(
name: "ip_address",
table: "auth_sessions",
type: "character varying(128)",
maxLength: 128,
nullable: true);
migrationBuilder.AddColumn<List<string>>(
name: "scopes",
table: "auth_sessions",
type: "jsonb",
nullable: false,
defaultValue: new List<string>());
migrationBuilder.AddColumn<int>(
name: "type",
table: "auth_sessions",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "user_agent",
table: "auth_sessions",
type: "character varying(512)",
maxLength: 512,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "audiences",
table: "auth_sessions");
migrationBuilder.DropColumn(
name: "ip_address",
table: "auth_sessions");
migrationBuilder.DropColumn(
name: "scopes",
table: "auth_sessions");
migrationBuilder.DropColumn(
name: "type",
table: "auth_sessions");
migrationBuilder.DropColumn(
name: "user_agent",
table: "auth_sessions");
migrationBuilder.AddColumn<int>(
name: "type",
table: "auth_challenges",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateIndex(
name: "ix_auth_sessions_challenge_id",
table: "auth_sessions",
column: "challenge_id");
migrationBuilder.AddForeignKey(
name: "fk_auth_sessions_auth_challenges_challenge_id",
table: "auth_sessions",
column: "challenge_id",
principalTable: "auth_challenges",
principalColumn: "id");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using DysonNetwork.Shared.GeoIp;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddLocationToSession : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<GeoPoint>(
name: "location",
table: "auth_sessions",
type: "jsonb",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "location",
table: "auth_sessions");
}
}
}

View File

@@ -712,6 +712,103 @@ namespace DysonNetwork.Pass.Migrations
b.ToTable("action_logs", (string)null); b.ToTable("action_logs", (string)null);
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAffiliationResult", 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<string>("ResourceIdentifier")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("resource_identifier");
b.Property<Guid>("SpellId")
.HasColumnType("uuid")
.HasColumnName("spell_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_affiliation_results");
b.HasIndex("SpellId")
.HasDatabaseName("ix_affiliation_results_spell_id");
b.ToTable("affiliation_results", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAffiliationSpell", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid?>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant?>("AffectedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("affected_at");
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?>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<Dictionary<string, object>>("Meta")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("meta");
b.Property<string>("Spell")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("spell");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_affiliation_spells");
b.HasIndex("AccountId")
.HasDatabaseName("ix_affiliation_spells_account_id");
b.HasIndex("Spell")
.IsUnique()
.HasDatabaseName("ix_affiliation_spells_spell");
b.ToTable("affiliation_spells", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnApiKey", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnApiKey", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -836,10 +933,6 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("integer") .HasColumnType("integer")
.HasColumnName("step_total"); .HasColumnName("step_total");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt") b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("updated_at"); .HasColumnName("updated_at");
@@ -926,6 +1019,11 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("app_id"); .HasColumnName("app_id");
b.Property<List<string>>("Audiences")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("audiences");
b.Property<Guid?>("ChallengeId") b.Property<Guid?>("ChallengeId")
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("challenge_id"); .HasColumnName("challenge_id");
@@ -946,27 +1044,47 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("expired_at"); .HasColumnName("expired_at");
b.Property<string>("IpAddress")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("ip_address");
b.Property<Instant?>("LastGrantedAt") b.Property<Instant?>("LastGrantedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("last_granted_at"); .HasColumnName("last_granted_at");
b.Property<GeoPoint>("Location")
.HasColumnType("jsonb")
.HasColumnName("location");
b.Property<Guid?>("ParentSessionId") b.Property<Guid?>("ParentSessionId")
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("parent_session_id"); .HasColumnName("parent_session_id");
b.Property<List<string>>("Scopes")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("scopes");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt") b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("updated_at"); .HasColumnName("updated_at");
b.Property<string>("UserAgent")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("user_agent");
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_auth_sessions"); .HasName("pk_auth_sessions");
b.HasIndex("AccountId") b.HasIndex("AccountId")
.HasDatabaseName("ix_auth_sessions_account_id"); .HasDatabaseName("ix_auth_sessions_account_id");
b.HasIndex("ChallengeId")
.HasDatabaseName("ix_auth_sessions_challenge_id");
b.HasIndex("ClientId") b.HasIndex("ClientId")
.HasDatabaseName("ix_auth_sessions_client_id"); .HasDatabaseName("ix_auth_sessions_client_id");
@@ -1336,12 +1454,6 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("affected_at"); .HasColumnName("affected_at");
b.Property<string>("Area")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("area");
b.Property<Instant>("CreatedAt") b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("created_at"); .HasColumnName("created_at");
@@ -1364,6 +1476,10 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("character varying(1024)") .HasColumnType("character varying(1024)")
.HasColumnName("key"); .HasColumnName("key");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt") b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("updated_at"); .HasColumnName("updated_at");
@@ -1379,8 +1495,8 @@ namespace DysonNetwork.Pass.Migrations
b.HasIndex("GroupId") b.HasIndex("GroupId")
.HasDatabaseName("ix_permission_nodes_group_id"); .HasDatabaseName("ix_permission_nodes_group_id");
b.HasIndex("Key", "Area", "Actor") b.HasIndex("Key", "Actor")
.HasDatabaseName("ix_permission_nodes_key_area_actor"); .HasDatabaseName("ix_permission_nodes_key_actor");
b.ToTable("permission_nodes", (string)null); b.ToTable("permission_nodes", (string)null);
}); });
@@ -2366,6 +2482,28 @@ namespace DysonNetwork.Pass.Migrations
b.Navigation("Account"); b.Navigation("Account");
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAffiliationResult", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAffiliationSpell", "Spell")
.WithMany()
.HasForeignKey("SpellId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_affiliation_results_affiliation_spells_spell_id");
b.Navigation("Spell");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAffiliationSpell", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
.WithMany()
.HasForeignKey("AccountId")
.HasConstraintName("fk_affiliation_spells_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnApiKey", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnApiKey", b =>
{ {
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account") b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
@@ -2420,11 +2558,6 @@ namespace DysonNetwork.Pass.Migrations
.IsRequired() .IsRequired()
.HasConstraintName("fk_auth_sessions_accounts_account_id"); .HasConstraintName("fk_auth_sessions_accounts_account_id");
b.HasOne("DysonNetwork.Shared.Models.SnAuthChallenge", "Challenge")
.WithMany()
.HasForeignKey("ChallengeId")
.HasConstraintName("fk_auth_sessions_auth_challenges_challenge_id");
b.HasOne("DysonNetwork.Shared.Models.SnAuthClient", "Client") b.HasOne("DysonNetwork.Shared.Models.SnAuthClient", "Client")
.WithMany() .WithMany()
.HasForeignKey("ClientId") .HasForeignKey("ClientId")
@@ -2437,8 +2570,6 @@ namespace DysonNetwork.Pass.Migrations
b.Navigation("Account"); b.Navigation("Account");
b.Navigation("Challenge");
b.Navigation("Client"); b.Navigation("Client");
b.Navigation("ParentSession"); b.Navigation("ParentSession");

View File

@@ -1,17 +1,12 @@
using DysonNetwork.Shared.Auth;
namespace DysonNetwork.Pass.Permission; namespace DysonNetwork.Pass.Permission;
using System; using System;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using DysonNetwork.Shared.Models; using Shared.Models;
[AttributeUsage(AttributeTargets.Method)] public class LocalPermissionMiddleware(RequestDelegate next, ILogger<LocalPermissionMiddleware> logger)
public class RequiredPermissionAttribute(string area, string key) : Attribute
{
public string Area { get; set; } = area;
public string Key { get; } = key;
}
public class PermissionMiddleware(RequestDelegate next, ILogger<PermissionMiddleware> logger)
{ {
private const string ForbiddenMessage = "Insufficient permissions"; private const string ForbiddenMessage = "Insufficient permissions";
private const string UnauthorizedMessage = "Authentication required"; private const string UnauthorizedMessage = "Authentication required";
@@ -21,15 +16,15 @@ public class PermissionMiddleware(RequestDelegate next, ILogger<PermissionMiddle
var endpoint = httpContext.GetEndpoint(); var endpoint = httpContext.GetEndpoint();
var attr = endpoint?.Metadata var attr = endpoint?.Metadata
.OfType<RequiredPermissionAttribute>() .OfType<AskPermissionAttribute>()
.FirstOrDefault(); .FirstOrDefault();
if (attr != null) if (attr != null)
{ {
// Validate permission attributes // Validate permission attributes
if (string.IsNullOrWhiteSpace(attr.Area) || string.IsNullOrWhiteSpace(attr.Key)) if (string.IsNullOrWhiteSpace(attr.Key))
{ {
logger.LogWarning("Invalid permission attribute: Area='{Area}', Key='{Key}'", attr.Area, attr.Key); logger.LogWarning("Invalid permission attribute: Key='{Key}'", attr.Key);
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
await httpContext.Response.WriteAsync("Server configuration error"); await httpContext.Response.WriteAsync("Server configuration error");
return; return;
@@ -37,7 +32,7 @@ public class PermissionMiddleware(RequestDelegate next, ILogger<PermissionMiddle
if (httpContext.Items["CurrentUser"] is not SnAccount currentUser) if (httpContext.Items["CurrentUser"] is not SnAccount currentUser)
{ {
logger.LogWarning("Permission check failed: No authenticated user for {Area}/{Key}", attr.Area, attr.Key); logger.LogWarning("Permission check failed: No authenticated user for {Key}", attr.Key);
httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized; httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
await httpContext.Response.WriteAsync(UnauthorizedMessage); await httpContext.Response.WriteAsync(UnauthorizedMessage);
return; return;
@@ -46,33 +41,29 @@ public class PermissionMiddleware(RequestDelegate next, ILogger<PermissionMiddle
if (currentUser.IsSuperuser) if (currentUser.IsSuperuser)
{ {
// Bypass the permission check for performance // Bypass the permission check for performance
logger.LogDebug("Superuser {UserId} bypassing permission check for {Area}/{Key}", logger.LogDebug("Superuser {UserId} bypassing permission check for {Key}", currentUser.Id, attr.Key);
currentUser.Id, attr.Area, attr.Key);
await next(httpContext); await next(httpContext);
return; return;
} }
var actor = $"user:{currentUser.Id}"; var actor = currentUser.Id.ToString();
try try
{ {
var permNode = await pm.GetPermissionAsync<bool>(actor, attr.Area, attr.Key); var permNode = await pm.GetPermissionAsync<bool>(actor, attr.Key);
if (!permNode) if (!permNode)
{ {
logger.LogWarning("Permission denied for user {UserId}: {Area}/{Key}", logger.LogWarning("Permission denied for user {UserId}: {Key}", currentUser.Id, attr.Key);
currentUser.Id, attr.Area, attr.Key);
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden; httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
await httpContext.Response.WriteAsync(ForbiddenMessage); await httpContext.Response.WriteAsync(ForbiddenMessage);
return; return;
} }
logger.LogDebug("Permission granted for user {UserId}: {Area}/{Key}", logger.LogDebug("Permission granted for user {UserId}: {Key}", currentUser.Id, attr.Key);
currentUser.Id, attr.Area, attr.Key);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error checking permission for user {UserId}: {Area}/{Key}", logger.LogError(ex, "Error checking permission for user {UserId}: {Key}", currentUser.Id, attr.Key);
currentUser.Id, attr.Area, attr.Key);
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
await httpContext.Response.WriteAsync("Permission check failed"); await httpContext.Response.WriteAsync("Permission check failed");
return; return;

View File

@@ -4,6 +4,7 @@ using Microsoft.Extensions.Options;
using NodaTime; using NodaTime;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
namespace DysonNetwork.Pass.Permission; namespace DysonNetwork.Pass.Permission;
@@ -28,8 +29,8 @@ public class PermissionService(
private const string PermissionGroupCacheKeyPrefix = "perm-cg:"; private const string PermissionGroupCacheKeyPrefix = "perm-cg:";
private const string PermissionGroupPrefix = "perm-g:"; private const string PermissionGroupPrefix = "perm-g:";
private static string GetPermissionCacheKey(string actor, string area, string key) => private static string GetPermissionCacheKey(string actor, string key) =>
PermissionCacheKeyPrefix + actor + ":" + area + ":" + key; PermissionCacheKeyPrefix + actor + ":" + key;
private static string GetGroupsCacheKey(string actor) => private static string GetGroupsCacheKey(string actor) =>
PermissionGroupCacheKeyPrefix + actor; PermissionGroupCacheKeyPrefix + actor;
@@ -37,50 +38,56 @@ public class PermissionService(
private static string GetPermissionGroupKey(string actor) => private static string GetPermissionGroupKey(string actor) =>
PermissionGroupPrefix + actor; PermissionGroupPrefix + actor;
public async Task<bool> HasPermissionAsync(string actor, string area, string key) public async Task<bool> HasPermissionAsync(
string actor,
string key,
PermissionNodeActorType type = PermissionNodeActorType.Account
)
{ {
var value = await GetPermissionAsync<bool>(actor, area, key); var value = await GetPermissionAsync<bool>(actor, key, type);
return value; return value;
} }
public async Task<T?> GetPermissionAsync<T>(string actor, string area, string key) public async Task<T?> GetPermissionAsync<T>(
string actor,
string key,
PermissionNodeActorType type = PermissionNodeActorType.Account
)
{ {
// Input validation // Input validation
if (string.IsNullOrWhiteSpace(actor)) if (string.IsNullOrWhiteSpace(actor))
throw new ArgumentException("Actor cannot be null or empty", nameof(actor)); throw new ArgumentException("Actor cannot be null or empty", nameof(actor));
if (string.IsNullOrWhiteSpace(area))
throw new ArgumentException("Area cannot be null or empty", nameof(area));
if (string.IsNullOrWhiteSpace(key)) if (string.IsNullOrWhiteSpace(key))
throw new ArgumentException("Key cannot be null or empty", nameof(key)); throw new ArgumentException("Key cannot be null or empty", nameof(key));
var cacheKey = GetPermissionCacheKey(actor, area, key); var cacheKey = GetPermissionCacheKey(actor, key);
try try
{ {
var (hit, cachedValue) = await cache.GetAsyncWithStatus<T>(cacheKey); var (hit, cachedValue) = await cache.GetAsyncWithStatus<T>(cacheKey);
if (hit) if (hit)
{ {
logger.LogDebug("Permission cache hit for {Actor}:{Area}:{Key}", actor, area, key); logger.LogDebug("Permission cache hit for {Type}:{Actor}:{Key}", type, actor, key);
return cachedValue; return cachedValue;
} }
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
var groupsId = await GetOrCacheUserGroupsAsync(actor, now); var groupsId = await GetOrCacheUserGroupsAsync(actor, now);
var permission = await FindPermissionNodeAsync(actor, area, key, groupsId, now); var permission = await FindPermissionNodeAsync(type, actor, key, groupsId);
var result = permission != null ? DeserializePermissionValue<T>(permission.Value) : default; var result = permission != null ? DeserializePermissionValue<T>(permission.Value) : default;
await cache.SetWithGroupsAsync(cacheKey, result, await cache.SetWithGroupsAsync(cacheKey, result,
[GetPermissionGroupKey(actor)], [GetPermissionGroupKey(actor)],
_options.CacheExpiration); _options.CacheExpiration);
logger.LogDebug("Permission resolved for {Actor}:{Area}:{Key} = {Result}", logger.LogDebug("Permission resolved for {Type}:{Actor}:{Key} = {Result}", type, actor, key,
actor, area, key, result != null); result != null);
return result; return result;
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error retrieving permission for {Actor}:{Area}:{Key}", actor, area, key); logger.LogError(ex, "Error retrieving permission for {Type}:{Actor}:{Key}", type, actor, key);
throw; throw;
} }
} }
@@ -109,33 +116,34 @@ public class PermissionService(
return groupsId; return groupsId;
} }
private async Task<SnPermissionNode?> FindPermissionNodeAsync(string actor, string area, string key, private async Task<SnPermissionNode?> FindPermissionNodeAsync(
List<Guid> groupsId, Instant now) PermissionNodeActorType type,
string actor,
string key,
List<Guid> groupsId
)
{ {
var now = SystemClock.Instance.GetCurrentInstant();
// First try exact match (highest priority) // First try exact match (highest priority)
var exactMatch = await db.PermissionNodes var exactMatch = await db.PermissionNodes
.Where(n => (n.GroupId == null && n.Actor == actor) || .Where(n => (n.GroupId == null && n.Actor == actor && n.Type == type) ||
(n.GroupId != null && groupsId.Contains(n.GroupId.Value))) (n.GroupId != null && groupsId.Contains(n.GroupId.Value)))
.Where(n => n.Key == key && n.Area == area) .Where(n => n.Key == key)
.Where(n => n.ExpiredAt == null || n.ExpiredAt > now) .Where(n => n.ExpiredAt == null || n.ExpiredAt > now)
.Where(n => n.AffectedAt == null || n.AffectedAt <= now) .Where(n => n.AffectedAt == null || n.AffectedAt <= now)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (exactMatch != null) if (exactMatch != null)
{
return exactMatch; return exactMatch;
}
// If no exact match and wildcards are enabled, try wildcard matches // If no exact match and wildcards are enabled, try wildcard matches
if (!_options.EnableWildcardMatching) if (!_options.EnableWildcardMatching)
{
return null; return null;
}
var wildcardMatches = await db.PermissionNodes var wildcardMatches = await db.PermissionNodes
.Where(n => (n.GroupId == null && n.Actor == actor) || .Where(n => (n.GroupId == null && n.Actor == actor && n.Type == type) ||
(n.GroupId != null && groupsId.Contains(n.GroupId.Value))) (n.GroupId != null && groupsId.Contains(n.GroupId.Value)))
.Where(n => (n.Key.Contains("*") || n.Area.Contains("*"))) .Where(n => EF.Functions.Like(n.Key, "%*%"))
.Where(n => n.ExpiredAt == null || n.ExpiredAt > now) .Where(n => n.ExpiredAt == null || n.ExpiredAt > now)
.Where(n => n.AffectedAt == null || n.AffectedAt <= now) .Where(n => n.AffectedAt == null || n.AffectedAt <= now)
.Take(_options.MaxWildcardMatches) .Take(_options.MaxWildcardMatches)
@@ -147,36 +155,21 @@ public class PermissionService(
foreach (var node in wildcardMatches) foreach (var node in wildcardMatches)
{ {
var score = CalculateWildcardMatchScore(node.Area, node.Key, area, key); var score = CalculateWildcardMatchScore(node.Key, key);
if (score > bestMatchScore) if (score <= bestMatchScore) continue;
{ bestMatch = node;
bestMatch = node; bestMatchScore = score;
bestMatchScore = score;
}
} }
if (bestMatch != null) if (bestMatch != null)
{ logger.LogDebug("Found wildcard permission match: {NodeKey} for {Key}", bestMatch.Key, key);
logger.LogDebug("Found wildcard permission match: {NodeArea}:{NodeKey} for {Area}:{Key}",
bestMatch.Area, bestMatch.Key, area, key);
}
return bestMatch; return bestMatch;
} }
private static int CalculateWildcardMatchScore(string nodeArea, string nodeKey, string targetArea, string targetKey) private static int CalculateWildcardMatchScore(string nodeKey, string targetKey)
{ {
// Calculate how well the wildcard pattern matches return CalculatePatternMatchScore(nodeKey, targetKey);
// Higher score = better match
var areaScore = CalculatePatternMatchScore(nodeArea, targetArea);
var keyScore = CalculatePatternMatchScore(nodeKey, targetKey);
// Perfect match gets highest score
if (areaScore == int.MaxValue && keyScore == int.MaxValue)
return int.MaxValue;
// Prefer area matches over key matches, more specific patterns over general ones
return (areaScore * 1000) + keyScore;
} }
private static int CalculatePatternMatchScore(string pattern, string target) private static int CalculatePatternMatchScore(string pattern, string target)
@@ -184,31 +177,30 @@ public class PermissionService(
if (pattern == target) if (pattern == target)
return int.MaxValue; // Exact match return int.MaxValue; // Exact match
if (!pattern.Contains("*")) if (!pattern.Contains('*'))
return -1; // No wildcard, not a match return -1; // No wildcard, not a match
// Simple wildcard matching: * matches any sequence of characters // Simple wildcard matching: * matches any sequence of characters
var regexPattern = "^" + System.Text.RegularExpressions.Regex.Escape(pattern).Replace("\\*", ".*") + "$"; var regexPattern = "^" + System.Text.RegularExpressions.Regex.Escape(pattern).Replace("\\*", ".*") + "$";
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase); var regex = new System.Text.RegularExpressions.Regex(regexPattern,
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (regex.IsMatch(target)) if (!regex.IsMatch(target)) return -1; // No match
{
// Score based on specificity (shorter patterns are less specific)
var wildcardCount = pattern.Count(c => c == '*');
var length = pattern.Length;
return Math.Max(1, 1000 - (wildcardCount * 100) - length);
}
return -1; // No match // Score based on specificity (shorter patterns are less specific)
var wildcardCount = pattern.Count(c => c == '*');
var length = pattern.Length;
return Math.Max(1, 1000 - wildcardCount * 100 - length);
} }
public async Task<SnPermissionNode> AddPermissionNode<T>( public async Task<SnPermissionNode> AddPermissionNode<T>(
string actor, string actor,
string area,
string key, string key,
T value, T value,
Instant? expiredAt = null, Instant? expiredAt = null,
Instant? affectedAt = null Instant? affectedAt = null,
PermissionNodeActorType type = PermissionNodeActorType.Account
) )
{ {
if (value is null) throw new ArgumentNullException(nameof(value)); if (value is null) throw new ArgumentNullException(nameof(value));
@@ -216,8 +208,8 @@ public class PermissionService(
var node = new SnPermissionNode var node = new SnPermissionNode
{ {
Actor = actor, Actor = actor,
Type = type,
Key = key, Key = key,
Area = area,
Value = SerializePermissionValue(value), Value = SerializePermissionValue(value),
ExpiredAt = expiredAt, ExpiredAt = expiredAt,
AffectedAt = affectedAt AffectedAt = affectedAt
@@ -227,7 +219,7 @@ public class PermissionService(
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Invalidate related caches // Invalidate related caches
await InvalidatePermissionCacheAsync(actor, area, key); await InvalidatePermissionCacheAsync(actor, key);
return node; return node;
} }
@@ -235,11 +227,11 @@ public class PermissionService(
public async Task<SnPermissionNode> AddPermissionNodeToGroup<T>( public async Task<SnPermissionNode> AddPermissionNodeToGroup<T>(
SnPermissionGroup group, SnPermissionGroup group,
string actor, string actor,
string area,
string key, string key,
T value, T value,
Instant? expiredAt = null, Instant? expiredAt = null,
Instant? affectedAt = null Instant? affectedAt = null,
PermissionNodeActorType type = PermissionNodeActorType.Account
) )
{ {
if (value is null) throw new ArgumentNullException(nameof(value)); if (value is null) throw new ArgumentNullException(nameof(value));
@@ -247,8 +239,8 @@ public class PermissionService(
var node = new SnPermissionNode var node = new SnPermissionNode
{ {
Actor = actor, Actor = actor,
Type = type,
Key = key, Key = key,
Area = area,
Value = SerializePermissionValue(value), Value = SerializePermissionValue(value),
ExpiredAt = expiredAt, ExpiredAt = expiredAt,
AffectedAt = affectedAt, AffectedAt = affectedAt,
@@ -260,44 +252,45 @@ public class PermissionService(
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Invalidate related caches // Invalidate related caches
await InvalidatePermissionCacheAsync(actor, area, key); await InvalidatePermissionCacheAsync(actor, key);
await cache.RemoveAsync(GetGroupsCacheKey(actor)); await cache.RemoveAsync(GetGroupsCacheKey(actor));
await cache.RemoveGroupAsync(GetPermissionGroupKey(actor)); await cache.RemoveGroupAsync(GetPermissionGroupKey(actor));
return node; return node;
} }
public async Task RemovePermissionNode(string actor, string area, string key) public async Task RemovePermissionNode(string actor, string key, PermissionNodeActorType? type)
{ {
var node = await db.PermissionNodes var node = await db.PermissionNodes
.Where(n => n.Actor == actor && n.Area == area && n.Key == key) .Where(n => n.Actor == actor && n.Key == key)
.If(type is not null, q => q.Where(n => n.Type == type))
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (node is not null) db.PermissionNodes.Remove(node); if (node is not null) db.PermissionNodes.Remove(node);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Invalidate cache // Invalidate cache
await InvalidatePermissionCacheAsync(actor, area, key); await InvalidatePermissionCacheAsync(actor, key);
} }
public async Task RemovePermissionNodeFromGroup<T>(SnPermissionGroup group, string actor, string area, string key) public async Task RemovePermissionNodeFromGroup<T>(SnPermissionGroup group, string actor, string key)
{ {
var node = await db.PermissionNodes var node = await db.PermissionNodes
.Where(n => n.GroupId == group.Id) .Where(n => n.GroupId == group.Id)
.Where(n => n.Actor == actor && n.Area == area && n.Key == key) .Where(n => n.Actor == actor && n.Key == key && n.Type == PermissionNodeActorType.Group)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (node is null) return; if (node is null) return;
db.PermissionNodes.Remove(node); db.PermissionNodes.Remove(node);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Invalidate caches // Invalidate caches
await InvalidatePermissionCacheAsync(actor, area, key); await InvalidatePermissionCacheAsync(actor, key);
await cache.RemoveAsync(GetGroupsCacheKey(actor)); await cache.RemoveAsync(GetGroupsCacheKey(actor));
await cache.RemoveGroupAsync(GetPermissionGroupKey(actor)); await cache.RemoveGroupAsync(GetPermissionGroupKey(actor));
} }
private async Task InvalidatePermissionCacheAsync(string actor, string area, string key) private async Task InvalidatePermissionCacheAsync(string actor, string key)
{ {
var cacheKey = GetPermissionCacheKey(actor, area, key); var cacheKey = GetPermissionCacheKey(actor, key);
await cache.RemoveAsync(cacheKey); await cache.RemoveAsync(cacheKey);
} }
@@ -312,12 +305,11 @@ public class PermissionService(
return JsonDocument.Parse(str); return JsonDocument.Parse(str);
} }
public static SnPermissionNode NewPermissionNode<T>(string actor, string area, string key, T value) public static SnPermissionNode NewPermissionNode<T>(string actor, string key, T value)
{ {
return new SnPermissionNode return new SnPermissionNode
{ {
Actor = actor, Actor = actor,
Area = area,
Key = key, Key = key,
Value = SerializePermissionValue(value), Value = SerializePermissionValue(value),
}; };
@@ -341,8 +333,7 @@ public class PermissionService(
(n.GroupId != null && groupsId.Contains(n.GroupId.Value))) (n.GroupId != null && groupsId.Contains(n.GroupId.Value)))
.Where(n => n.ExpiredAt == null || n.ExpiredAt > now) .Where(n => n.ExpiredAt == null || n.ExpiredAt > now)
.Where(n => n.AffectedAt == null || n.AffectedAt <= now) .Where(n => n.AffectedAt == null || n.AffectedAt <= now)
.OrderBy(n => n.Area) .OrderBy(n => n.Key)
.ThenBy(n => n.Key)
.ToListAsync(); .ToListAsync();
logger.LogDebug("Listed {Count} effective permissions for actor {Actor}", permissions.Count, actor); logger.LogDebug("Listed {Count} effective permissions for actor {Actor}", permissions.Count, actor);
@@ -370,8 +361,7 @@ public class PermissionService(
.Where(n => n.GroupId == null && n.Actor == actor) .Where(n => n.GroupId == null && n.Actor == actor)
.Where(n => n.ExpiredAt == null || n.ExpiredAt > now) .Where(n => n.ExpiredAt == null || n.ExpiredAt > now)
.Where(n => n.AffectedAt == null || n.AffectedAt <= now) .Where(n => n.AffectedAt == null || n.AffectedAt <= now)
.OrderBy(n => n.Area) .OrderBy(n => n.Key)
.ThenBy(n => n.Key)
.ToListAsync(); .ToListAsync();
logger.LogDebug("Listed {Count} direct permissions for actor {Actor}", permissions.Count, actor); logger.LogDebug("Listed {Count} direct permissions for actor {Actor}", permissions.Count, actor);
@@ -424,4 +414,4 @@ public class PermissionService(
throw; throw;
} }
} }
} }

View File

@@ -9,31 +9,33 @@ using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Pass.Permission; namespace DysonNetwork.Pass.Permission;
public class PermissionServiceGrpc( public class PermissionServiceGrpc(
PermissionService permissionService, PermissionService psv,
AppDatabase db, AppDatabase db,
ILogger<PermissionServiceGrpc> logger ILogger<PermissionServiceGrpc> logger
) : DysonNetwork.Shared.Proto.PermissionService.PermissionServiceBase ) : DysonNetwork.Shared.Proto.PermissionService.PermissionServiceBase
{ {
public override async Task<HasPermissionResponse> HasPermission(HasPermissionRequest request, ServerCallContext context) public override async Task<HasPermissionResponse> HasPermission(HasPermissionRequest request, ServerCallContext context)
{ {
var type = SnPermissionNode.ConvertProtoActorType(request.Type);
try try
{ {
var hasPermission = await permissionService.HasPermissionAsync(request.Actor, request.Area, request.Key); var hasPermission = await psv.HasPermissionAsync(request.Actor, request.Key, type);
return new HasPermissionResponse { HasPermission = hasPermission }; return new HasPermissionResponse { HasPermission = hasPermission };
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error checking permission for actor {Actor}, area {Area}, key {Key}", logger.LogError(ex, "Error checking permission for {Type}:{Area}:{Key}",
request.Actor, request.Area, request.Key); type, request.Actor, request.Key);
throw new RpcException(new Status(StatusCode.Internal, "Permission check failed")); throw new RpcException(new Status(StatusCode.Internal, "Permission check failed"));
} }
} }
public override async Task<GetPermissionResponse> GetPermission(GetPermissionRequest request, ServerCallContext context) public override async Task<GetPermissionResponse> GetPermission(GetPermissionRequest request, ServerCallContext context)
{ {
var type = SnPermissionNode.ConvertProtoActorType(request.Type);
try try
{ {
var permissionValue = await permissionService.GetPermissionAsync<JsonDocument>(request.Actor, request.Area, request.Key); var permissionValue = await psv.GetPermissionAsync<JsonDocument>(request.Actor, request.Key, type);
return new GetPermissionResponse return new GetPermissionResponse
{ {
Value = permissionValue != null ? Value.Parser.ParseJson(permissionValue.RootElement.GetRawText()) : null Value = permissionValue != null ? Value.Parser.ParseJson(permissionValue.RootElement.GetRawText()) : null
@@ -41,14 +43,15 @@ public class PermissionServiceGrpc(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error getting permission for actor {Actor}, area {Area}, key {Key}", logger.LogError(ex, "Error getting permission for {Type}:{Area}:{Key}",
request.Actor, request.Area, request.Key); type, request.Actor, request.Key);
throw new RpcException(new Status(StatusCode.Internal, "Failed to retrieve permission")); throw new RpcException(new Status(StatusCode.Internal, "Failed to retrieve permission"));
} }
} }
public override async Task<AddPermissionNodeResponse> AddPermissionNode(AddPermissionNodeRequest request, ServerCallContext context) public override async Task<AddPermissionNodeResponse> AddPermissionNode(AddPermissionNodeRequest request, ServerCallContext context)
{ {
var type = SnPermissionNode.ConvertProtoActorType(request.Type);
try try
{ {
JsonDocument jsonValue; JsonDocument jsonValue;
@@ -58,18 +61,18 @@ public class PermissionServiceGrpc(
} }
catch (JsonException ex) catch (JsonException ex)
{ {
logger.LogWarning(ex, "Invalid JSON in permission value for actor {Actor}, area {Area}, key {Key}", logger.LogError(ex, "Invalid JSON in permission value for {Type}:{Area}:{Key}",
request.Actor, request.Area, request.Key); type, request.Actor, request.Key);
throw new RpcException(new Status(StatusCode.InvalidArgument, "Invalid permission value format")); throw new RpcException(new Status(StatusCode.InvalidArgument, "Invalid permission value format"));
} }
var node = await permissionService.AddPermissionNode( var node = await psv.AddPermissionNode(
request.Actor, request.Actor,
request.Area,
request.Key, request.Key,
jsonValue, jsonValue,
request.ExpiredAt?.ToInstant(), request.ExpiredAt?.ToInstant(),
request.AffectedAt?.ToInstant() request.AffectedAt?.ToInstant(),
type
); );
return new AddPermissionNodeResponse { Node = node.ToProtoValue() }; return new AddPermissionNodeResponse { Node = node.ToProtoValue() };
} }
@@ -79,14 +82,15 @@ public class PermissionServiceGrpc(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error adding permission node for actor {Actor}, area {Area}, key {Key}", logger.LogError(ex, "Error adding permission for {Type}:{Area}:{Key}",
request.Actor, request.Area, request.Key); type, request.Actor, request.Key);
throw new RpcException(new Status(StatusCode.Internal, "Failed to add permission node")); throw new RpcException(new Status(StatusCode.Internal, "Failed to add permission node"));
} }
} }
public override async Task<AddPermissionNodeToGroupResponse> AddPermissionNodeToGroup(AddPermissionNodeToGroupRequest request, ServerCallContext context) public override async Task<AddPermissionNodeToGroupResponse> AddPermissionNodeToGroup(AddPermissionNodeToGroupRequest request, ServerCallContext context)
{ {
var type = SnPermissionNode.ConvertProtoActorType(request.Type);
try try
{ {
var group = await FindPermissionGroupAsync(request.Group.Id); var group = await FindPermissionGroupAsync(request.Group.Id);
@@ -102,19 +106,19 @@ public class PermissionServiceGrpc(
} }
catch (JsonException ex) catch (JsonException ex)
{ {
logger.LogWarning(ex, "Invalid JSON in permission value for group {GroupId}, actor {Actor}, area {Area}, key {Key}", logger.LogError(ex, "Invalid JSON in permission value for {Type}:{Area}:{Key}",
request.Group.Id, request.Actor, request.Area, request.Key); type, request.Actor, request.Key);
throw new RpcException(new Status(StatusCode.InvalidArgument, "Invalid permission value format")); throw new RpcException(new Status(StatusCode.InvalidArgument, "Invalid permission value format"));
} }
var node = await permissionService.AddPermissionNodeToGroup( var node = await psv.AddPermissionNodeToGroup(
group, group,
request.Actor, request.Actor,
request.Area,
request.Key, request.Key,
jsonValue, jsonValue,
request.ExpiredAt?.ToInstant(), request.ExpiredAt?.ToInstant(),
request.AffectedAt?.ToInstant() request.AffectedAt?.ToInstant(),
type
); );
return new AddPermissionNodeToGroupResponse { Node = node.ToProtoValue() }; return new AddPermissionNodeToGroupResponse { Node = node.ToProtoValue() };
} }
@@ -124,23 +128,24 @@ public class PermissionServiceGrpc(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error adding permission node to group {GroupId} for actor {Actor}, area {Area}, key {Key}", logger.LogError(ex, "Error adding permission for {Type}:{Area}:{Key}",
request.Group.Id, request.Actor, request.Area, request.Key); type, request.Actor, request.Key);
throw new RpcException(new Status(StatusCode.Internal, "Failed to add permission node to group")); throw new RpcException(new Status(StatusCode.Internal, "Failed to add permission node to group"));
} }
} }
public override async Task<RemovePermissionNodeResponse> RemovePermissionNode(RemovePermissionNodeRequest request, ServerCallContext context) public override async Task<RemovePermissionNodeResponse> RemovePermissionNode(RemovePermissionNodeRequest request, ServerCallContext context)
{ {
var type = SnPermissionNode.ConvertProtoActorType(request.Type);
try try
{ {
await permissionService.RemovePermissionNode(request.Actor, request.Area, request.Key); await psv.RemovePermissionNode(request.Actor, request.Key, type);
return new RemovePermissionNodeResponse { Success = true }; return new RemovePermissionNodeResponse { Success = true };
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error removing permission node for actor {Actor}, area {Area}, key {Key}", logger.LogError(ex, "Error removing permission for {Type}:{Area}:{Key}",
request.Actor, request.Area, request.Key); type, request.Actor, request.Key);
throw new RpcException(new Status(StatusCode.Internal, "Failed to remove permission node")); throw new RpcException(new Status(StatusCode.Internal, "Failed to remove permission node"));
} }
} }
@@ -155,7 +160,7 @@ public class PermissionServiceGrpc(
throw new RpcException(new Status(StatusCode.NotFound, "Permission group not found")); throw new RpcException(new Status(StatusCode.NotFound, "Permission group not found"));
} }
await permissionService.RemovePermissionNodeFromGroup<JsonDocument>(group, request.Actor, request.Area, request.Key); await psv.RemovePermissionNodeFromGroup<JsonDocument>(group, request.Actor, request.Key);
return new RemovePermissionNodeFromGroupResponse { Success = true }; return new RemovePermissionNodeFromGroupResponse { Success = true };
} }
catch (RpcException) catch (RpcException)
@@ -164,20 +169,18 @@ public class PermissionServiceGrpc(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error removing permission node from group {GroupId} for actor {Actor}, area {Area}, key {Key}", logger.LogError(ex, "Error removing permission from group for {Area}:{Key}",
request.Group.Id, request.Actor, request.Area, request.Key); request.Actor, request.Key);
throw new RpcException(new Status(StatusCode.Internal, "Failed to remove permission node from group")); throw new RpcException(new Status(StatusCode.Internal, "Failed to remove permission node from group"));
} }
} }
private async Task<SnPermissionGroup?> FindPermissionGroupAsync(string groupId) private async Task<SnPermissionGroup?> FindPermissionGroupAsync(string groupId)
{ {
if (!Guid.TryParse(groupId, out var guid)) if (Guid.TryParse(groupId, out var guid))
{ return await db.PermissionGroups.FirstOrDefaultAsync(g => g.Id == guid);
logger.LogWarning("Invalid GUID format for group ID: {GroupId}", groupId); logger.LogWarning("Invalid GUID format for group ID: {GroupId}", groupId);
return null; return null;
}
return await db.PermissionGroups.FirstOrDefaultAsync(g => g.Id == guid);
} }
} }

View File

@@ -5,6 +5,7 @@ using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using NodaTime; using NodaTime;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Shared.Auth;
namespace DysonNetwork.Pass; namespace DysonNetwork.Pass;
@@ -19,16 +20,20 @@ public class PermissionController(
/// <summary> /// <summary>
/// Check if an actor has a specific permission /// Check if an actor has a specific permission
/// </summary> /// </summary>
[HttpGet("check/{actor}/{area}/{key}")] [HttpGet("check/{actor}/{key}")]
[RequiredPermission("maintenance", "permissions.check")] [AskPermission("permissions.check")]
[ProducesResponseType<bool>(StatusCodes.Status200OK)] [ProducesResponseType<bool>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> CheckPermission(string actor, string area, string key) public async Task<IActionResult> CheckPermission(
[FromRoute] string actor,
[FromRoute] string key,
[FromQuery] PermissionNodeActorType type = PermissionNodeActorType.Account
)
{ {
try try
{ {
var hasPermission = await permissionService.HasPermissionAsync(actor, area, key); var hasPermission = await permissionService.HasPermissionAsync(actor, key, type);
return Ok(hasPermission); return Ok(hasPermission);
} }
catch (ArgumentException ex) catch (ArgumentException ex)
@@ -45,7 +50,7 @@ public class PermissionController(
/// Get all effective permissions for an actor (including group permissions) /// Get all effective permissions for an actor (including group permissions)
/// </summary> /// </summary>
[HttpGet("actors/{actor}/permissions/effective")] [HttpGet("actors/{actor}/permissions/effective")]
[RequiredPermission("maintenance", "permissions.check")] [AskPermission("permissions.check")]
[ProducesResponseType<List<SnPermissionNode>>(StatusCodes.Status200OK)] [ProducesResponseType<List<SnPermissionNode>>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
@@ -70,7 +75,7 @@ public class PermissionController(
/// Get all direct permissions for an actor (excluding group permissions) /// Get all direct permissions for an actor (excluding group permissions)
/// </summary> /// </summary>
[HttpGet("actors/{actor}/permissions/direct")] [HttpGet("actors/{actor}/permissions/direct")]
[RequiredPermission("maintenance", "permissions.check")] [AskPermission("permissions.check")]
[ProducesResponseType<List<SnPermissionNode>>(StatusCodes.Status200OK)] [ProducesResponseType<List<SnPermissionNode>>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
@@ -94,28 +99,27 @@ public class PermissionController(
/// <summary> /// <summary>
/// Give a permission to an actor /// Give a permission to an actor
/// </summary> /// </summary>
[HttpPost("actors/{actor}/permissions/{area}/{key}")] [HttpPost("actors/{actor}/permissions/{key}")]
[RequiredPermission("maintenance", "permissions.manage")] [AskPermission("permissions.manage")]
[ProducesResponseType<SnPermissionNode>(StatusCodes.Status201Created)] [ProducesResponseType<SnPermissionNode>(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> GivePermission( public async Task<IActionResult> GivePermission(
string actor, string actor,
string area,
string key, string key,
[FromBody] PermissionRequest request) [FromBody] PermissionRequest request
)
{ {
try try
{ {
var permission = await permissionService.AddPermissionNode( var permission = await permissionService.AddPermissionNode(
actor, actor,
area,
key, key,
JsonDocument.Parse(JsonSerializer.Serialize(request.Value)), JsonDocument.Parse(JsonSerializer.Serialize(request.Value)),
request.ExpiredAt, request.ExpiredAt,
request.AffectedAt request.AffectedAt
); );
return Created($"/api/permissions/actors/{actor}/permissions/{area}/{key}", permission); return Created($"/api/permissions/actors/{actor}/permissions/{key}", permission);
} }
catch (ArgumentException ex) catch (ArgumentException ex)
{ {
@@ -130,16 +134,20 @@ public class PermissionController(
/// <summary> /// <summary>
/// Remove a permission from an actor /// Remove a permission from an actor
/// </summary> /// </summary>
[HttpDelete("actors/{actor}/permissions/{area}/{key}")] [HttpDelete("actors/{actor}/permissions/{key}")]
[RequiredPermission("maintenance", "permissions.manage")] [AskPermission("permissions.manage")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> RemovePermission(string actor, string area, string key) public async Task<IActionResult> RemovePermission(
string actor,
string key,
[FromQuery] PermissionNodeActorType type = PermissionNodeActorType.Account
)
{ {
try try
{ {
await permissionService.RemovePermissionNode(actor, area, key); await permissionService.RemovePermissionNode(actor, key, type);
return NoContent(); return NoContent();
} }
catch (ArgumentException ex) catch (ArgumentException ex)
@@ -156,7 +164,7 @@ public class PermissionController(
/// Get all groups for an actor /// Get all groups for an actor
/// </summary> /// </summary>
[HttpGet("actors/{actor}/groups")] [HttpGet("actors/{actor}/groups")]
[RequiredPermission("maintenance", "permissions.groups.check")] [AskPermission("permissions.groups.check")]
[ProducesResponseType<List<SnPermissionGroupMember>>(StatusCodes.Status200OK)] [ProducesResponseType<List<SnPermissionGroupMember>>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
@@ -183,8 +191,8 @@ public class PermissionController(
/// <summary> /// <summary>
/// Add an actor to a permission group /// Add an actor to a permission group
/// </summary> /// </summary>
[HttpPost("actors/{actor}/groups/{groupId}")] [HttpPost("actors/{actor}/groups/{groupId:guid}")]
[RequiredPermission("maintenance", "permissions.groups.manage")] [AskPermission("permissions.groups.manage")]
[ProducesResponseType<SnPermissionGroupMember>(StatusCodes.Status201Created)] [ProducesResponseType<SnPermissionGroupMember>(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -192,7 +200,8 @@ public class PermissionController(
public async Task<IActionResult> AddActorToGroup( public async Task<IActionResult> AddActorToGroup(
string actor, string actor,
Guid groupId, Guid groupId,
[FromBody] GroupMembershipRequest? request = null) [FromBody] GroupMembershipRequest? request = null
)
{ {
try try
{ {
@@ -238,7 +247,7 @@ public class PermissionController(
/// Remove an actor from a permission group /// Remove an actor from a permission group
/// </summary> /// </summary>
[HttpDelete("actors/{actor}/groups/{groupId}")] [HttpDelete("actors/{actor}/groups/{groupId}")]
[RequiredPermission("maintenance", "permissions.groups.manage")] [AskPermission("permissions.groups.manage")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
@@ -272,7 +281,7 @@ public class PermissionController(
/// Clear permission cache for an actor /// Clear permission cache for an actor
/// </summary> /// </summary>
[HttpPost("actors/{actor}/cache/clear")] [HttpPost("actors/{actor}/cache/clear")]
[RequiredPermission("maintenance", "permissions.cache.manage")] [AskPermission("permissions.cache.manage")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
@@ -297,7 +306,7 @@ public class PermissionController(
/// Validate a permission pattern /// Validate a permission pattern
/// </summary> /// </summary>
[HttpPost("validate-pattern")] [HttpPost("validate-pattern")]
[RequiredPermission("maintenance", "permissions.check")] [AskPermission("permissions.check")]
[ProducesResponseType<PatternValidationResponse>(StatusCodes.Status200OK)] [ProducesResponseType<PatternValidationResponse>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
public IActionResult ValidatePattern([FromBody] PatternValidationRequest request) public IActionResult ValidatePattern([FromBody] PatternValidationRequest request)
@@ -322,14 +331,14 @@ public class PermissionController(
public class PermissionRequest public class PermissionRequest
{ {
public object? Value { get; set; } public object? Value { get; set; }
public NodaTime.Instant? ExpiredAt { get; set; } public Instant? ExpiredAt { get; set; }
public NodaTime.Instant? AffectedAt { get; set; } public Instant? AffectedAt { get; set; }
} }
public class GroupMembershipRequest public class GroupMembershipRequest
{ {
public NodaTime.Instant? ExpiredAt { get; set; } public Instant? ExpiredAt { get; set; }
public NodaTime.Instant? AffectedAt { get; set; } public Instant? AffectedAt { get; set; }
} }
public class PatternValidationRequest public class PatternValidationRequest
@@ -342,4 +351,4 @@ public class PatternValidationResponse
public string Pattern { get; set; } = string.Empty; public string Pattern { get; set; } = string.Empty;
public bool IsValid { get; set; } public bool IsValid { get; set; }
public string Message { get; set; } = string.Empty; public string Message { get; set; } = string.Empty;
} }

View File

@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Permission; using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -51,7 +52,7 @@ public class SnAbuseReportController(
[HttpGet("")] [HttpGet("")]
[Authorize] [Authorize]
[RequiredPermission("safety", "reports.view")] [AskPermission("reports.view")]
[ProducesResponseType<List<SnAbuseReport>>(StatusCodes.Status200OK)] [ProducesResponseType<List<SnAbuseReport>>(StatusCodes.Status200OK)]
public async Task<ActionResult<List<SnAbuseReport>>> GetReports( public async Task<ActionResult<List<SnAbuseReport>>> GetReports(
[FromQuery] int offset = 0, [FromQuery] int offset = 0,
@@ -85,7 +86,7 @@ public class SnAbuseReportController(
[HttpGet("{id}")] [HttpGet("{id}")]
[Authorize] [Authorize]
[RequiredPermission("safety", "reports.view")] [AskPermission("reports.view")]
[ProducesResponseType<SnAbuseReport>(StatusCodes.Status200OK)] [ProducesResponseType<SnAbuseReport>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SnAbuseReport>> GetReportById(Guid id) public async Task<ActionResult<SnAbuseReport>> GetReportById(Guid id)
@@ -122,7 +123,7 @@ public class SnAbuseReportController(
[HttpPost("{id}/resolve")] [HttpPost("{id}/resolve")]
[Authorize] [Authorize]
[RequiredPermission("safety", "reports.resolve")] [AskPermission("reports.resolve")]
[ProducesResponseType<SnAbuseReport>(StatusCodes.Status200OK)] [ProducesResponseType<SnAbuseReport>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SnAbuseReport>> ResolveReport(Guid id, [FromBody] ResolveReportRequest request) public async Task<ActionResult<SnAbuseReport>> ResolveReport(Guid id, [FromBody] ResolveReportRequest request)
@@ -144,7 +145,7 @@ public class SnAbuseReportController(
[HttpGet("count")] [HttpGet("count")]
[Authorize] [Authorize]
[RequiredPermission("safety", "reports.view")] [AskPermission("reports.view")]
[ProducesResponseType<object>(StatusCodes.Status200OK)] [ProducesResponseType<object>(StatusCodes.Status200OK)]
public async Task<ActionResult<object>> GetReportsCount() public async Task<ActionResult<object>> GetReportsCount()
{ {

View File

@@ -22,7 +22,7 @@ public static class ApplicationConfiguration
app.UseWebSockets(); app.UseWebSockets();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseMiddleware<PermissionMiddleware>(); app.UseMiddleware<LocalPermissionMiddleware>();
app.MapControllers().RequireRateLimiting("fixed"); app.MapControllers().RequireRateLimiting("fixed");

View File

@@ -11,6 +11,7 @@ using NodaTime.Serialization.SystemTextJson;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DysonNetwork.Pass.Account.Presences; using DysonNetwork.Pass.Account.Presences;
using DysonNetwork.Pass.Affiliation;
using DysonNetwork.Pass.Auth.OidcProvider.Options; using DysonNetwork.Pass.Auth.OidcProvider.Options;
using DysonNetwork.Pass.Auth.OidcProvider.Services; using DysonNetwork.Pass.Auth.OidcProvider.Services;
using DysonNetwork.Pass.Credit; using DysonNetwork.Pass.Credit;
@@ -34,9 +35,7 @@ public static class ServiceCollectionExtensions
services.AddLocalization(options => options.ResourcesPath = "Resources"); services.AddLocalization(options => options.ResourcesPath = "Resources");
services.AddDbContext<AppDatabase>(); services.AddDbContext<AppDatabase>();
services.AddSingleton<IClock>(SystemClock.Instance);
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddSingleton<ICacheService, CacheServiceRedis>();
services.AddHttpClient(); services.AddHttpClient();
@@ -159,6 +158,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<ExperienceService>(); services.AddScoped<ExperienceService>();
services.AddScoped<RealmService>(); services.AddScoped<RealmService>();
services.AddScoped<LotteryService>(); services.AddScoped<LotteryService>();
services.AddScoped<AffiliationSpellService>();
services.AddScoped<SpotifyPresenceService>(); services.AddScoped<SpotifyPresenceService>();
services.AddScoped<SteamPresenceService>(); services.AddScoped<SteamPresenceService>();

View File

@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Auth; using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Permission; using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -196,7 +197,7 @@ public class WalletController(
[HttpPost("balance")] [HttpPost("balance")]
[Authorize] [Authorize]
[RequiredPermission("maintenance", "wallets.balance.modify")] [AskPermission("wallets.balance.modify")]
public async Task<ActionResult<SnWalletTransaction>> ModifyWalletBalance([FromBody] WalletBalanceRequest request) public async Task<ActionResult<SnWalletTransaction>> ModifyWalletBalance([FromBody] WalletBalanceRequest request)
{ {
var wallet = await ws.GetWalletAsync(request.AccountId); var wallet = await ws.GetWalletAsync(request.AccountId);

View File

@@ -1,75 +1,84 @@
{ {
"Debug": true, "Debug": true,
"BaseUrl": "http://localhost:5001", "BaseUrl": "http://localhost:5001",
"SiteUrl": "http://localhost:3000", "SiteUrl": "http://localhost:3000",
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
},
"AllowedHosts": "*",
"ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_pass;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
},
"Authentication": {
"Schemes": {
"Bearer": {
"ValidAudiences": ["http://localhost:5071", "https://localhost:7099"],
"ValidIssuer": "solar-network"
}
}
},
"AuthToken": {
"CookieDomain": "localhost",
"PublicKeyPath": "Keys/PublicKey.pem",
"PrivateKeyPath": "Keys/PrivateKey.pem"
},
"OidcProvider": {
"IssuerUri": "https://nt.solian.app",
"PublicKeyPath": "Keys/PublicKey.pem",
"PrivateKeyPath": "Keys/PrivateKey.pem",
"AccessTokenLifetime": "01:00:00",
"RefreshTokenLifetime": "30.00:00:00",
"AuthorizationCodeLifetime": "00:30:00",
"RequireHttpsMetadata": true
},
"Captcha": {
"Provider": "cloudflare",
"ApiKey": "0x4AAAAAABCDUdOujj4feOb_",
"ApiSecret": "0x4AAAAAABCDUWABiJQweqlB7tYq-IqIm8U"
},
"GeoIp": {
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
},
"Oidc": {
"Google": {
"ClientId": "961776991058-963m1qin2vtp8fv693b5fdrab5hmpl89.apps.googleusercontent.com",
"ClientSecret": ""
}, },
"Apple": { "AllowedHosts": "*",
"ClientId": "dev.solsynth.solian", "ConnectionStrings": {
"TeamId": "W7HPZ53V6B", "App": "Host=localhost;Port=5432;Database=dyson_pass;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
"KeyId": "B668YP4KBG",
"PrivateKeyPath": "./Keys/Solarpass.p8"
}, },
"Microsoft": { "Authentication": {
"ClientId": "YOUR_MICROSOFT_CLIENT_ID", "Schemes": {
"ClientSecret": "YOUR_MICROSOFT_CLIENT_SECRET", "Bearer": {
"DiscoveryEndpoint": "YOUR_MICROSOFT_DISCOVERY_ENDPOINT" "ValidAudiences": [
} "http://localhost:5071",
}, "https://localhost:7099"
"Payment": { ],
"Auth": { "ValidIssuer": "solar-network"
"Afdian": "<token here>" }
}
}, },
"Subscriptions": { "AuthToken": {
"Afdian": { "CookieDomain": "localhost",
"7d17aae23c9611f0b5705254001e7c00": "solian.stellar.primary", "PublicKeyPath": "Keys/PublicKey.pem",
"7dfae4743c9611f0b3a55254001e7c00": "solian.stellar.nova", "PrivateKeyPath": "Keys/PrivateKey.pem"
"141713ee3d6211f085b352540025c377": "solian.stellar.supernova" },
} "OidcProvider": {
} "IssuerUri": "https://nt.solian.app",
}, "PublicKeyPath": "Keys/PublicKey.pem",
"KnownProxies": ["127.0.0.1", "::1"] "PrivateKeyPath": "Keys/PrivateKey.pem",
"AccessTokenLifetime": "01:00:00",
"RefreshTokenLifetime": "30.00:00:00",
"AuthorizationCodeLifetime": "00:30:00",
"RequireHttpsMetadata": true
},
"Captcha": {
"Provider": "cloudflare",
"ApiKey": "0x4AAAAAABCDUdOujj4feOb_",
"ApiSecret": "0x4AAAAAABCDUWABiJQweqlB7tYq-IqIm8U"
},
"GeoIp": {
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
},
"Oidc": {
"Google": {
"ClientId": "961776991058-963m1qin2vtp8fv693b5fdrab5hmpl89.apps.googleusercontent.com",
"ClientSecret": ""
},
"Apple": {
"ClientId": "dev.solsynth.solian",
"TeamId": "W7HPZ53V6B",
"KeyId": "B668YP4KBG",
"PrivateKeyPath": "./Keys/Solarpass.p8"
},
"Microsoft": {
"ClientId": "YOUR_MICROSOFT_CLIENT_ID",
"ClientSecret": "YOUR_MICROSOFT_CLIENT_SECRET",
"DiscoveryEndpoint": "YOUR_MICROSOFT_DISCOVERY_ENDPOINT"
}
},
"Cache": {
"Serializer": "MessagePack"
},
"Payment": {
"Auth": {
"Afdian": "<token here>"
},
"Subscriptions": {
"Afdian": {
"7d17aae23c9611f0b5705254001e7c00": "solian.stellar.primary",
"7dfae4743c9611f0b3a55254001e7c00": "solian.stellar.nova",
"141713ee3d6211f085b352540025c377": "solian.stellar.supernova"
}
}
},
"KnownProxies": [
"127.0.0.1",
"::1"
]
} }

View File

@@ -4,7 +4,6 @@ using DysonNetwork.Shared.Stream;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NATS.Client.Core; using NATS.Client.Core;
using NATS.Net;
using Swashbuckle.AspNetCore.Annotations; using Swashbuckle.AspNetCore.Annotations;
using WebSocketPacket = DysonNetwork.Shared.Models.WebSocketPacket; using WebSocketPacket = DysonNetwork.Shared.Models.WebSocketPacket;
@@ -40,10 +39,10 @@ public class WebSocketController(
} }
var accountId = Guid.Parse(currentUser.Id!); var accountId = Guid.Parse(currentUser.Id!);
var deviceId = currentSession.Challenge?.DeviceId ?? Guid.NewGuid().ToString(); var deviceId = currentSession.ClientId;
// TODO temporary fix due to the server update if (string.IsNullOrEmpty(deviceId))
if (string.IsNullOrEmpty(deviceId)) deviceId = Guid.NewGuid().ToString().Replace("-", ""); deviceId = Guid.NewGuid().ToString();
if (deviceAlt is not null) if (deviceAlt is not null)
deviceId = $"{deviceId}+{deviceAlt}"; deviceId = $"{deviceId}+{deviceAlt}";

View File

@@ -93,7 +93,7 @@ public class NotificationController(
var result = var result =
await nty.SubscribeDevice( await nty.SubscribeDevice(
currentSession.Challenge.DeviceId, currentSession.ClientId,
request.DeviceToken, request.DeviceToken,
request.Provider, request.Provider,
currentUser currentUser
@@ -117,7 +117,7 @@ public class NotificationController(
var affectedRows = await db.PushSubscriptions var affectedRows = await db.PushSubscriptions
.Where(s => .Where(s =>
s.AccountId == accountId && s.AccountId == accountId &&
s.DeviceId == currentSession.Challenge.DeviceId s.DeviceId == currentSession.ClientId
).ExecuteDeleteAsync(); ).ExecuteDeleteAsync();
return Ok(affectedRows); return Ok(affectedRows);
} }
@@ -139,7 +139,7 @@ public class NotificationController(
[HttpPost("send")] [HttpPost("send")]
[Authorize] [Authorize]
[RequiredPermission("global", "notifications.send")] [AskPermission("notifications.send")]
public async Task<ActionResult> SendNotification( public async Task<ActionResult> SendNotification(
[FromBody] NotificationWithAimRequest request, [FromBody] NotificationWithAimRequest request,
[FromQuery] bool save = false [FromQuery] bool save = false

View File

@@ -2,6 +2,7 @@ using CorePush.Apple;
using CorePush.Firebase; using CorePush.Firebase;
using DysonNetwork.Ring.Connection; using DysonNetwork.Ring.Connection;
using DysonNetwork.Ring.Services; using DysonNetwork.Ring.Services;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -17,12 +18,14 @@ public class PushService
private readonly ILogger<PushService> _logger; private readonly ILogger<PushService> _logger;
private readonly FirebaseSender? _fcm; private readonly FirebaseSender? _fcm;
private readonly ApnSender? _apns; private readonly ApnSender? _apns;
private readonly FlushBufferService _fbs;
private readonly string? _apnsTopic; private readonly string? _apnsTopic;
public PushService( public PushService(
IConfiguration config, IConfiguration config,
AppDatabase db, AppDatabase db,
QueueService queueService, QueueService queueService,
FlushBufferService fbs,
IHttpClientFactory httpFactory, IHttpClientFactory httpFactory,
ILogger<PushService> logger ILogger<PushService> logger
) )
@@ -52,6 +55,7 @@ public class PushService
} }
_db = db; _db = db;
_fbs = fbs;
_queueService = queueService; _queueService = queueService;
_logger = logger; _logger = logger;
} }
@@ -144,14 +148,15 @@ public class PushService
_ = _queueService.EnqueuePushNotification(notification, Guid.Parse(accountId), save); _ = _queueService.EnqueuePushNotification(notification, Guid.Parse(accountId), save);
} }
public async Task DeliverPushNotification(SnNotification notification, CancellationToken cancellationToken = default) public async Task DeliverPushNotification(SnNotification notification,
CancellationToken cancellationToken = default)
{ {
WebSocketService.SendPacketToAccount(notification.AccountId, new WebSocketPacket() WebSocketService.SendPacketToAccount(notification.AccountId, new WebSocketPacket()
{ {
Type = "notifications.new", Type = "notifications.new",
Data = notification, Data = notification,
}); });
try try
{ {
_logger.LogInformation( _logger.LogInformation(
@@ -260,7 +265,8 @@ public class PushService
await DeliverPushNotification(notification); await DeliverPushNotification(notification);
} }
private async Task SendPushNotificationAsync(SnNotificationPushSubscription subscription, SnNotification notification) private async Task SendPushNotificationAsync(SnNotificationPushSubscription subscription,
SnNotification notification)
{ {
try try
{ {
@@ -302,7 +308,9 @@ public class PushService
} }
}); });
if (fcmResult.Error != null) if (fcmResult.StatusCode is 404 or 410)
_fbs.Enqueue(new PushSubRemovalRequest { SubId = subscription.Id });
else if (fcmResult.Error != null)
throw new Exception($"Notification pushed failed ({fcmResult.StatusCode}) {fcmResult.Error}"); throw new Exception($"Notification pushed failed ({fcmResult.StatusCode}) {fcmResult.Error}");
break; break;
@@ -338,7 +346,10 @@ public class PushService
apnsPriority: notification.Priority, apnsPriority: notification.Priority,
apnPushType: ApnPushType.Alert apnPushType: ApnPushType.Alert
); );
if (apnResult.Error != null)
if (apnResult.StatusCode is 404 or 410)
_fbs.Enqueue(new PushSubRemovalRequest { SubId = subscription.Id });
else if (apnResult.Error != null)
throw new Exception($"Notification pushed failed ({apnResult.StatusCode}) {apnResult.Error}"); throw new Exception($"Notification pushed failed ({apnResult.StatusCode}) {apnResult.Error}");
break; break;

View File

@@ -0,0 +1,35 @@
using DysonNetwork.Shared.Cache;
using Microsoft.EntityFrameworkCore;
using Quartz;
namespace DysonNetwork.Ring.Services;
public class PushSubRemovalRequest
{
public Guid SubId { get; set; }
}
public class PushSubFlushHandler(IServiceProvider sp) : IFlushHandler<PushSubRemovalRequest>
{
public async Task FlushAsync(IReadOnlyList<PushSubRemovalRequest> items)
{
using var scope = sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<PushSubFlushHandler>>();
var tokenIds = items.Select(x => x.SubId).Distinct().ToList();
var count = await db.PushSubscriptions
.Where(s => tokenIds.Contains(s.Id))
.ExecuteDeleteAsync();
logger.LogInformation("Removed {Count} invalid push notification tokens...", count);
}
}
public class PushSubFlushJob(FlushBufferService fbs, PushSubFlushHandler hdl) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
await fbs.FlushAsync(hdl);
}
}

View File

@@ -1,4 +1,4 @@
using DysonNetwork.Ring.Notification; using DysonNetwork.Ring.Services;
using Quartz; using Quartz;
namespace DysonNetwork.Ring.Startup; namespace DysonNetwork.Ring.Startup;
@@ -15,6 +15,15 @@ public static class ScheduledJobsConfiguration
.ForJob(appDatabaseRecyclingJob) .ForJob(appDatabaseRecyclingJob)
.WithIdentity("AppDatabaseRecyclingTrigger") .WithIdentity("AppDatabaseRecyclingTrigger")
.WithCronSchedule("0 0 0 * * ?")); .WithCronSchedule("0 0 0 * * ?"));
q.AddJob<PushSubFlushJob>(opts => opts.WithIdentity("PushSubFlush"));
q.AddTrigger(opts => opts
.ForJob("PushSubFlush")
.WithIdentity("PushSubFlushTrigger")
.WithSimpleSchedule(o => o
.WithIntervalInMinutes(5)
.RepeatForever())
);
}); });
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);

View File

@@ -17,9 +17,7 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
{ {
services.AddDbContext<AppDatabase>(); services.AddDbContext<AppDatabase>();
services.AddSingleton<IClock>(SystemClock.Instance);
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddSingleton<ICacheService, CacheServiceRedis>();
services.AddHttpClient(); services.AddHttpClient();
@@ -57,6 +55,7 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddAppFlushHandlers(this IServiceCollection services) public static IServiceCollection AddAppFlushHandlers(this IServiceCollection services)
{ {
services.AddSingleton<FlushBufferService>(); services.AddSingleton<FlushBufferService>();
services.AddScoped<PushSubFlushHandler>();
return services; return services;
} }

View File

@@ -1,47 +1,53 @@
{ {
"Debug": true, "Debug": true,
"BaseUrl": "http://localhost:5212", "BaseUrl": "http://localhost:5212",
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_ring;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
},
"Notifications": {
"Push": {
"Production": true,
"Google": "./Keys/Solian.json",
"Apple": {
"PrivateKey": "./Keys/Solian.p8",
"PrivateKeyId": "4US4KSX4W6",
"TeamId": "W7HPZ53V6B",
"BundleIdentifier": "dev.solsynth.solian"
}
}
},
"Email": {
"Server": "smtp4dev.orb.local",
"Port": 25,
"UseSsl": false,
"Username": "no-reply@mail.solsynth.dev",
"Password": "password",
"FromAddress": "no-reply@mail.solsynth.dev",
"FromName": "Alphabot",
"SubjectPrefix": "Solar Network"
},
"GeoIp": {
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
},
"KnownProxies": [
"127.0.0.1",
"::1"
],
"Service": {
"Name": "DysonNetwork.Ring",
"Url": "https://localhost:7259"
},
"Cache": {
"Serializer": "MessagePack"
},
"Etcd": {
"Insecure": true
} }
},
"AllowedHosts": "*",
"ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_ring;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
},
"Notifications": {
"Push": {
"Production": true,
"Google": "./Keys/Solian.json",
"Apple": {
"PrivateKey": "./Keys/Solian.p8",
"PrivateKeyId": "4US4KSX4W6",
"TeamId": "W7HPZ53V6B",
"BundleIdentifier": "dev.solsynth.solian"
}
}
},
"Email": {
"Server": "smtp4dev.orb.local",
"Port": 25,
"UseSsl": false,
"Username": "no-reply@mail.solsynth.dev",
"Password": "password",
"FromAddress": "no-reply@mail.solsynth.dev",
"FromName": "Alphabot",
"SubjectPrefix": "Solar Network"
},
"GeoIp": {
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
},
"KnownProxies": ["127.0.0.1", "::1"],
"Service": {
"Name": "DysonNetwork.Ring",
"Url": "https://localhost:7259"
},
"Etcd": {
"Insecure": true
}
} }

View File

@@ -60,7 +60,7 @@ public class DysonTokenAuthHandler(
}; };
// Add scopes as claims // Add scopes as claims
session.Challenge?.Scopes.ToList().ForEach(scope => claims.Add(new Claim("scope", scope))); session.Scopes.ToList().ForEach(scope => claims.Add(new Claim("scope", scope)));
// Add superuser claim if applicable // Add superuser claim if applicable
if (session.Account.IsSuperuser) if (session.Account.IsSuperuser)

View File

@@ -1,72 +0,0 @@
using DysonNetwork.Shared.Proto;
using Grpc.Core;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace DysonNetwork.Shared.Auth
{
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public class RequiredPermissionAttribute(string area, string key) : Attribute
{
public string Area { get; set; } = area;
public string Key { get; } = key;
}
public class PermissionMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext httpContext, PermissionService.PermissionServiceClient permissionService, ILogger<PermissionMiddleware> logger)
{
var endpoint = httpContext.GetEndpoint();
var attr = endpoint?.Metadata
.OfType<RequiredPermissionAttribute>()
.FirstOrDefault();
if (attr != null)
{
if (httpContext.Items["CurrentUser"] is not Account currentUser)
{
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
await httpContext.Response.WriteAsync("Unauthorized");
return;
}
// Assuming Account proto has a bool field 'is_superuser' which is generated as 'IsSuperuser'
if (currentUser.IsSuperuser)
{
// Bypass the permission check for performance
await next(httpContext);
return;
}
var actor = $"user:{currentUser.Id}";
try
{
var permResp = await permissionService.HasPermissionAsync(new HasPermissionRequest
{
Actor = actor,
Area = attr.Area,
Key = attr.Key
});
if (!permResp.HasPermission)
{
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
await httpContext.Response.WriteAsync($"Permission {attr.Area}/{attr.Key} was required.");
return;
}
}
catch (RpcException ex)
{
logger.LogError(ex, "gRPC call to PermissionService failed while checking permission {Area}/{Key} for actor {Actor}", attr.Area, attr.Key, actor);
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
await httpContext.Response.WriteAsync("Error checking permissions.");
return;
}
}
await next(httpContext);
}
}
}

View File

@@ -0,0 +1,72 @@
using DysonNetwork.Shared.Proto;
using Grpc.Core;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace DysonNetwork.Shared.Auth;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public class AskPermissionAttribute(string key, PermissionNodeActorType type = PermissionNodeActorType.Account)
: Attribute
{
public string Key { get; } = key;
public PermissionNodeActorType Type { get; } = type;
}
public class RemotePermissionMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext httpContext, PermissionService.PermissionServiceClient permissionService,
ILogger<RemotePermissionMiddleware> logger)
{
var endpoint = httpContext.GetEndpoint();
var attr = endpoint?.Metadata
.OfType<AskPermissionAttribute>()
.FirstOrDefault();
if (attr != null)
{
if (httpContext.Items["CurrentUser"] is not Account currentUser)
{
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
await httpContext.Response.WriteAsync("Unauthorized");
return;
}
// Superuser will bypass all the permission check
if (currentUser.IsSuperuser)
{
await next(httpContext);
return;
}
try
{
var permResp = await permissionService.HasPermissionAsync(new HasPermissionRequest
{
Actor = currentUser.Id,
Key = attr.Key
});
if (!permResp.HasPermission)
{
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
await httpContext.Response.WriteAsync($"Permission {attr.Key} was required.");
return;
}
}
catch (RpcException ex)
{
logger.LogError(ex,
"gRPC call to PermissionService failed while checking permission {Key} for actor {Actor}", attr.Key,
currentUser.Id
);
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
await httpContext.Response.WriteAsync("Error checking permissions.");
return;
}
}
await next(httpContext);
}
}

View File

@@ -1,396 +1,201 @@
using System.Text.Json; using Microsoft.Extensions.Caching.Distributed;
using System.Text.Json.Serialization; using RedLockNet;
using System.Text.Json.Serialization.Metadata;
using DysonNetwork.Shared.Data;
using NodaTime;
using NodaTime.Serialization.SystemTextJson;
using StackExchange.Redis; using StackExchange.Redis;
namespace DysonNetwork.Shared.Cache; namespace DysonNetwork.Shared.Cache;
/// <summary> public class CacheServiceRedis(
/// Represents a distributed lock that can be used to synchronize access across multiple processes IDistributedCache cache,
/// </summary> IConnectionMultiplexer redis,
public interface IDistributedLock : IAsyncDisposable ICacheSerializer serializer,
IDistributedLockFactory lockFactory
)
: ICacheService
{ {
/// <summary> private const string GlobalKeyPrefix = "dyson:";
/// The resource identifier this lock is protecting private const string GroupKeyPrefix = GlobalKeyPrefix + "cg:";
/// </summary> private const string LockKeyPrefix = GlobalKeyPrefix + "lock:";
string Resource { get; }
/// <summary> private static string Normalize(string key) => $"{GlobalKeyPrefix}{key}";
/// Unique identifier for this lock instance
/// </summary>
string LockId { get; }
/// <summary> // -----------------------------------------------------
/// Extends the lock's expiration time // BASIC OPERATIONS
/// </summary> // -----------------------------------------------------
Task<bool> ExtendAsync(TimeSpan timeSpan);
/// <summary>
/// Releases the lock immediately
/// </summary>
Task ReleaseAsync();
}
public interface ICacheService
{
/// <summary>
/// Sets a value in the cache with an optional expiration time
/// </summary>
Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null);
/// <summary>
/// Gets a value from the cache
/// </summary>
Task<T?> GetAsync<T>(string key);
/// <summary>
/// Get a value from the cache with the found status
/// </summary>
Task<(bool found, T? value)> GetAsyncWithStatus<T>(string key);
/// <summary>
/// Removes a specific key from the cache
/// </summary>
Task<bool> RemoveAsync(string key);
/// <summary>
/// Adds a key to a group for group-based operations
/// </summary>
Task AddToGroupAsync(string key, string group);
/// <summary>
/// Removes all keys associated with a specific group
/// </summary>
Task RemoveGroupAsync(string group);
/// <summary>
/// Gets all keys belonging to a specific group
/// </summary>
Task<IEnumerable<string>> GetGroupKeysAsync(string group);
/// <summary>
/// Helper method to set a value in cache and associate it with multiple groups in one operation
/// </summary>
/// <typeparam name="T">The type of value being cached</typeparam>
/// <param name="key">Cache key</param>
/// <param name="value">The value to cache</param>
/// <param name="groups">Optional collection of group names to associate the key with</param>
/// <param name="expiry">Optional expiration time for the cached item</param>
/// <returns>True if the set operation was successful</returns>
Task<bool> SetWithGroupsAsync<T>(string key, T value, IEnumerable<string>? groups = null, TimeSpan? expiry = null);
/// <summary>
/// Acquires a distributed lock on the specified resource
/// </summary>
/// <param name="resource">The resource identifier to lock</param>
/// <param name="expiry">How long the lock should be held before automatically expiring</param>
/// <param name="waitTime">How long to wait for the lock before giving up</param>
/// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
/// <returns>A distributed lock instance if acquired, null otherwise</returns>
Task<IDistributedLock?> AcquireLockAsync(string resource, TimeSpan expiry, TimeSpan? waitTime = null,
TimeSpan? retryInterval = null);
/// <summary>
/// Executes an action with a distributed lock, ensuring the lock is properly released afterwards
/// </summary>
/// <param name="resource">The resource identifier to lock</param>
/// <param name="action">The action to execute while holding the lock</param>
/// <param name="expiry">How long the lock should be held before automatically expiring</param>
/// <param name="waitTime">How long to wait for the lock before giving up</param>
/// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
/// <returns>True if the lock was acquired and the action was executed, false otherwise</returns>
Task<bool> ExecuteWithLockAsync(string resource, Func<Task> action, TimeSpan expiry, TimeSpan? waitTime = null,
TimeSpan? retryInterval = null);
/// <summary>
/// Executes a function with a distributed lock, ensuring the lock is properly released afterwards
/// </summary>
/// <typeparam name="T">The return type of the function</typeparam>
/// <param name="resource">The resource identifier to lock</param>
/// <param name="func">The function to execute while holding the lock</param>
/// <param name="expiry">How long the lock should be held before automatically expiring</param>
/// <param name="waitTime">How long to wait for the lock before giving up</param>
/// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
/// <returns>The result of the function if the lock was acquired, default(T) otherwise</returns>
Task<(bool Acquired, T? Result)> ExecuteWithLockAsync<T>(string resource, Func<Task<T>> func, TimeSpan expiry,
TimeSpan? waitTime = null, TimeSpan? retryInterval = null);
}
public class RedisDistributedLock : IDistributedLock
{
private readonly IDatabase _database;
private bool _disposed;
public string Resource { get; }
public string LockId { get; }
internal RedisDistributedLock(IDatabase database, string resource, string lockId)
{
_database = database;
Resource = resource;
LockId = lockId;
}
public async Task<bool> ExtendAsync(TimeSpan timeSpan)
{
if (_disposed)
throw new ObjectDisposedException(nameof(RedisDistributedLock));
var script = @"
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('pexpire', KEYS[1], ARGV[2])
else
return 0
end
";
var result = await _database.ScriptEvaluateAsync(
script,
[$"{CacheServiceRedis.LockKeyPrefix}{Resource}"],
[LockId, (long)timeSpan.TotalMilliseconds]
);
return (long)result! == 1;
}
public async Task ReleaseAsync()
{
if (_disposed)
return;
var script = @"
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
";
await _database.ScriptEvaluateAsync(
script,
[$"{CacheServiceRedis.LockKeyPrefix}{Resource}"],
[LockId]
);
_disposed = true;
}
public async ValueTask DisposeAsync()
{
await ReleaseAsync();
GC.SuppressFinalize(this);
}
}
public class CacheServiceRedis : ICacheService
{
private readonly IDatabase _database;
private readonly JsonSerializerOptions _jsonOptions;
// Global prefix for all cache keys
public const string GlobalKeyPrefix = "dyson:";
// Using prefixes for different types of keys
public const string GroupKeyPrefix = GlobalKeyPrefix + "cg:";
public const string LockKeyPrefix = GlobalKeyPrefix + "lock:";
public CacheServiceRedis(IConnectionMultiplexer redis)
{
var rds = redis ?? throw new ArgumentNullException(nameof(redis));
_database = rds.GetDatabase();
// Configure System.Text.Json with proper NodaTime serialization
_jsonOptions = new JsonSerializerOptions
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers = { JsonExtensions.UnignoreAllProperties() },
},
ReferenceHandler = ReferenceHandler.Preserve,
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
Converters = { new ByteStringConverter() }
};
_jsonOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
_jsonOptions.PropertyNameCaseInsensitive = true;
}
public async Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null) public async Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null)
{ {
key = $"{GlobalKeyPrefix}{key}"; key = Normalize(key);
if (string.IsNullOrEmpty(key))
throw new ArgumentException("Key cannot be null or empty", nameof(key));
var serializedValue = JsonSerializer.Serialize(value, _jsonOptions); var json = serializer.Serialize(value);
return await _database.StringSetAsync(key, serializedValue, expiry);
var options = new DistributedCacheEntryOptions();
if (expiry.HasValue)
options.SetAbsoluteExpiration(expiry.Value);
await cache.SetStringAsync(key, json, options);
return true;
} }
public async Task<T?> GetAsync<T>(string key) public async Task<T?> GetAsync<T>(string key)
{ {
key = $"{GlobalKeyPrefix}{key}"; key = Normalize(key);
if (string.IsNullOrEmpty(key))
throw new ArgumentException("Key cannot be null or empty", nameof(key));
var value = await _database.StringGetAsync(key); var json = await cache.GetStringAsync(key);
if (json is null)
return default;
return value.IsNullOrEmpty ? default : return serializer.Deserialize<T>(json);
// For NodaTime serialization, use the configured JSON options
JsonSerializer.Deserialize<T>(value.ToString(), _jsonOptions);
} }
public async Task<(bool found, T? value)> GetAsyncWithStatus<T>(string key) public async Task<(bool found, T? value)> GetAsyncWithStatus<T>(string key)
{ {
key = $"{GlobalKeyPrefix}{key}"; key = Normalize(key);
if (string.IsNullOrEmpty(key))
throw new ArgumentException("Key cannot be null or empty", nameof(key));
var value = await _database.StringGetAsync(key); var json = await cache.GetStringAsync(key);
if (json is null)
return (false, default);
return value.IsNullOrEmpty ? (false, default) : return (true, serializer.Deserialize<T>(json));
// For NodaTime serialization, use the configured JSON options
(true, JsonSerializer.Deserialize<T>(value!.ToString(), _jsonOptions));
} }
public async Task<bool> RemoveAsync(string key) public async Task<bool> RemoveAsync(string key)
{ {
key = $"{GlobalKeyPrefix}{key}"; key = Normalize(key);
if (string.IsNullOrEmpty(key))
throw new ArgumentException("Key cannot be null or empty", nameof(key));
// Before removing the key, find all groups it belongs to and remove it from them // Remove key from all groups
var script = @" var db = redis.GetDatabase();
local groups = redis.call('KEYS', ARGV[1])
for _, group in ipairs(groups) do
redis.call('SREM', group, ARGV[2])
end
return redis.call('DEL', ARGV[2])
";
var result = await _database.ScriptEvaluateAsync( var groupPattern = $"{GroupKeyPrefix}*";
script, var server = redis.GetServers().First();
values: [$"{GroupKeyPrefix}*", key]
);
return (long)result! > 0; var groups = server.Keys(pattern: groupPattern);
foreach (var group in groups)
{
await db.SetRemoveAsync(group, key);
}
await cache.RemoveAsync(key);
return true;
} }
// -----------------------------------------------------
// GROUP OPERATIONS
// -----------------------------------------------------
public async Task AddToGroupAsync(string key, string group) public async Task AddToGroupAsync(string key, string group)
{ {
if (string.IsNullOrEmpty(key)) key = Normalize(key);
throw new ArgumentException(@"Key cannot be null or empty.", nameof(key)); var db = redis.GetDatabase();
if (string.IsNullOrEmpty(group))
throw new ArgumentException(@"Group cannot be null or empty.", nameof(group));
var groupKey = $"{GroupKeyPrefix}{group}"; var groupKey = $"{GroupKeyPrefix}{group}";
key = $"{GlobalKeyPrefix}{key}"; await db.SetAddAsync(groupKey, key);
await _database.SetAddAsync(groupKey, key);
} }
public async Task RemoveGroupAsync(string group) public async Task RemoveGroupAsync(string group)
{ {
if (string.IsNullOrEmpty(group))
throw new ArgumentException(@"Group cannot be null or empty.", nameof(group));
var groupKey = $"{GroupKeyPrefix}{group}"; var groupKey = $"{GroupKeyPrefix}{group}";
var db = redis.GetDatabase();
// Get all keys in the group var keys = await db.SetMembersAsync(groupKey);
var keys = await _database.SetMembersAsync(groupKey);
if (keys.Length > 0) if (keys.Length > 0)
{ {
// Delete all the keys foreach (var key in keys)
var keysTasks = keys.Select(key => _database.KeyDeleteAsync(key.ToString())); await cache.RemoveAsync(key.ToString());
await Task.WhenAll(keysTasks);
} }
// Delete the group itself await db.KeyDeleteAsync(groupKey);
await _database.KeyDeleteAsync(groupKey);
} }
public async Task<IEnumerable<string>> GetGroupKeysAsync(string group) public async Task<IEnumerable<string>> GetGroupKeysAsync(string group)
{ {
if (string.IsNullOrEmpty(group)) var groupKey = $"{GroupKeyPrefix}{group}";
throw new ArgumentException("Group cannot be null or empty.", nameof(group)); var db = redis.GetDatabase();
var members = await db.SetMembersAsync(groupKey);
var groupKey = string.Concat(GroupKeyPrefix, group); return members.Select(x => x.ToString());
var members = await _database.SetMembersAsync(groupKey);
return members.Select(m => m.ToString());
} }
public async Task<bool> SetWithGroupsAsync<T>(string key, T value, IEnumerable<string>? groups = null, public async Task<bool> SetWithGroupsAsync<T>(
string key,
T value,
IEnumerable<string>? groups = null,
TimeSpan? expiry = null) TimeSpan? expiry = null)
{ {
// First, set the value in the cache var result = await SetAsync(key, value, expiry);
var setResult = await SetAsync(key, value, expiry); if (!result || groups == null)
return result;
// If successful and there are groups to associate, add the key to each group var tasks = groups.Select(g => AddToGroupAsync(key, g));
if (!setResult || groups == null) return setResult;
var groupsArray = groups.Where(g => !string.IsNullOrEmpty(g)).ToArray();
if (groupsArray.Length <= 0) return setResult;
var tasks = groupsArray.Select(group => AddToGroupAsync(key, group));
await Task.WhenAll(tasks); await Task.WhenAll(tasks);
return true;
return setResult;
} }
public async Task<IDistributedLock?> AcquireLockAsync(string resource, TimeSpan expiry, TimeSpan? waitTime = null, // -----------------------------------------------------
// DISTRIBUTED LOCK (RedLock wrapper)
// -----------------------------------------------------
private readonly TimeSpan _defaultRetry = TimeSpan.FromMilliseconds(100);
public async Task<IDistributedLock?> AcquireLockAsync(
string resource,
TimeSpan expiry,
TimeSpan? waitTime = null,
TimeSpan? retryInterval = null) TimeSpan? retryInterval = null)
{ {
if (string.IsNullOrEmpty(resource)) if (string.IsNullOrWhiteSpace(resource))
throw new ArgumentException("Resource cannot be null or empty", nameof(resource)); throw new ArgumentException("Resource cannot be null", nameof(resource));
var lockKey = $"{LockKeyPrefix}{resource}"; var lockKey = $"{LockKeyPrefix}{resource}";
var lockId = Guid.NewGuid().ToString("N"); var redlock = await lockFactory.CreateLockAsync(
var waitTimeSpan = waitTime ?? TimeSpan.Zero; lockKey,
var retryIntervalSpan = retryInterval ?? TimeSpan.FromMilliseconds(100); expiry,
waitTime ?? TimeSpan.Zero,
retryInterval ?? _defaultRetry
);
var startTime = DateTime.UtcNow; return !redlock.IsAcquired ? null : new RedLockAdapter(redlock, resource);
var acquired = false;
// Try to acquire the lock, retry until waitTime is exceeded
while (!acquired && (DateTime.UtcNow - startTime) < waitTimeSpan)
{
acquired = await _database.StringSetAsync(lockKey, lockId, expiry, When.NotExists);
if (!acquired)
{
await Task.Delay(retryIntervalSpan);
}
}
if (!acquired)
{
return null; // Could not acquire the lock within the wait time
}
return new RedisDistributedLock(_database, resource, lockId);
} }
public async Task<bool> ExecuteWithLockAsync(string resource, Func<Task> action, TimeSpan expiry, public async Task<bool> ExecuteWithLockAsync(
TimeSpan? waitTime = null, TimeSpan? retryInterval = null) string resource,
Func<Task> action,
TimeSpan expiry,
TimeSpan? waitTime = null,
TimeSpan? retryInterval = null)
{ {
await using var lockObj = await AcquireLockAsync(resource, expiry, waitTime, retryInterval); await using var l = await AcquireLockAsync(resource, expiry, waitTime, retryInterval);
if (l is null)
if (lockObj == null) return false;
return false; // Could not acquire the lock
await action(); await action();
return true; return true;
} }
public async Task<(bool Acquired, T? Result)> ExecuteWithLockAsync<T>(string resource, Func<Task<T>> func, public async Task<(bool Acquired, T? Result)> ExecuteWithLockAsync<T>(
TimeSpan expiry, TimeSpan? waitTime = null, TimeSpan? retryInterval = null) string resource,
Func<Task<T>> func,
TimeSpan expiry,
TimeSpan? waitTime = null,
TimeSpan? retryInterval = null)
{ {
await using var lockObj = await AcquireLockAsync(resource, expiry, waitTime, retryInterval); await using var l = await AcquireLockAsync(resource, expiry, waitTime, retryInterval);
if (l is null)
if (lockObj == null) return (false, default);
return (false, default); // Could not acquire the lock
var result = await func(); var result = await func();
return (true, result); return (true, result);
} }
} }
public class RedLockAdapter(IRedLock inner, string resource) : IDistributedLock
{
public string Resource { get; } = resource;
public string LockId => inner.LockId;
public ValueTask ReleaseAsync() => inner.DisposeAsync();
public async ValueTask DisposeAsync()
{
await inner.DisposeAsync();
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,7 @@
namespace DysonNetwork.Shared.Cache;
public interface ICacheSerializer
{
string Serialize<T>(T value);
T? Deserialize<T>(string data);
}

View File

@@ -0,0 +1,86 @@
namespace DysonNetwork.Shared.Cache;
public interface ICacheService
{
/// <summary>
/// Sets a value in the cache with an optional expiration time
/// </summary>
Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null);
/// <summary>
/// Gets a value from the cache
/// </summary>
Task<T?> GetAsync<T>(string key);
/// <summary>
/// Get a value from the cache with the found status
/// </summary>
Task<(bool found, T? value)> GetAsyncWithStatus<T>(string key);
/// <summary>
/// Removes a specific key from the cache
/// </summary>
Task<bool> RemoveAsync(string key);
/// <summary>
/// Adds a key to a group for group-based operations
/// </summary>
Task AddToGroupAsync(string key, string group);
/// <summary>
/// Removes all keys associated with a specific group
/// </summary>
Task RemoveGroupAsync(string group);
/// <summary>
/// Gets all keys belonging to a specific group
/// </summary>
Task<IEnumerable<string>> GetGroupKeysAsync(string group);
/// <summary>
/// Helper method to set a value in cache and associate it with multiple groups in one operation
/// </summary>
/// <typeparam name="T">The type of value being cached</typeparam>
/// <param name="key">Cache key</param>
/// <param name="value">The value to cache</param>
/// <param name="groups">Optional collection of group names to associate the key with</param>
/// <param name="expiry">Optional expiration time for the cached item</param>
/// <returns>True if the set operation was successful</returns>
Task<bool> SetWithGroupsAsync<T>(string key, T value, IEnumerable<string>? groups = null, TimeSpan? expiry = null);
/// <summary>
/// Acquires a distributed lock on the specified resource
/// </summary>
/// <param name="resource">The resource identifier to lock</param>
/// <param name="expiry">How long the lock should be held before automatically expiring</param>
/// <param name="waitTime">How long to wait for the lock before giving up</param>
/// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
/// <returns>A distributed lock instance if acquired, null otherwise</returns>
Task<IDistributedLock?> AcquireLockAsync(string resource, TimeSpan expiry, TimeSpan? waitTime = null,
TimeSpan? retryInterval = null);
/// <summary>
/// Executes an action with a distributed lock, ensuring the lock is properly released afterwards
/// </summary>
/// <param name="resource">The resource identifier to lock</param>
/// <param name="action">The action to execute while holding the lock</param>
/// <param name="expiry">How long the lock should be held before automatically expiring</param>
/// <param name="waitTime">How long to wait for the lock before giving up</param>
/// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
/// <returns>True if the lock was acquired and the action was executed, false otherwise</returns>
Task<bool> ExecuteWithLockAsync(string resource, Func<Task> action, TimeSpan expiry, TimeSpan? waitTime = null,
TimeSpan? retryInterval = null);
/// <summary>
/// Executes a function with a distributed lock, ensuring the lock is properly released afterwards
/// </summary>
/// <typeparam name="T">The return type of the function</typeparam>
/// <param name="resource">The resource identifier to lock</param>
/// <param name="func">The function to execute while holding the lock</param>
/// <param name="expiry">How long the lock should be held before automatically expiring</param>
/// <param name="waitTime">How long to wait for the lock before giving up</param>
/// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
/// <returns>The result of the function if the lock was acquired, default(T) otherwise</returns>
Task<(bool Acquired, T? Result)> ExecuteWithLockAsync<T>(string resource, Func<Task<T>> func, TimeSpan expiry,
TimeSpan? waitTime = null, TimeSpan? retryInterval = null);
}

View File

@@ -0,0 +1,22 @@
namespace DysonNetwork.Shared.Cache;
/// <summary>
/// Represents a distributed lock that can be used to synchronize access across multiple processes
/// </summary>
public interface IDistributedLock : IAsyncDisposable
{
/// <summary>
/// The resource identifier this lock is protecting
/// </summary>
string Resource { get; }
/// <summary>
/// Unique identifier for this lock instance
/// </summary>
string LockId { get; }
/// <summary>
/// Releases the lock immediately
/// </summary>
ValueTask ReleaseAsync();
}

View File

@@ -0,0 +1,35 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using DysonNetwork.Shared.Data;
using NodaTime;
using NodaTime.Serialization.SystemTextJson;
namespace DysonNetwork.Shared.Cache;
public class JsonCacheSerializer : ICacheSerializer
{
private readonly JsonSerializerOptions _options;
public JsonCacheSerializer()
{
_options = new JsonSerializerOptions
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers = { JsonExtensions.UnignoreAllProperties() },
},
ReferenceHandler = ReferenceHandler.Preserve,
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
Converters = { new ByteStringConverter() }
};
_options.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
_options.PropertyNameCaseInsensitive = true;
}
public string Serialize<T>(T value)
=> JsonSerializer.Serialize(value, _options);
public T? Deserialize<T>(string data)
=> JsonSerializer.Deserialize<T>(data, _options);
}

View File

@@ -0,0 +1,32 @@
using MessagePack;
using MessagePack.NodaTime;
using MessagePack.Resolvers;
namespace DysonNetwork.Shared.Cache;
public class MessagePackCacheSerializer(MessagePackSerializerOptions? options = null) : ICacheSerializer
{
private readonly MessagePackSerializerOptions _options = options ?? MessagePackSerializerOptions.Standard
.WithResolver(CompositeResolver.Create(
BuiltinResolver.Instance,
AttributeFormatterResolver.Instance,
NodatimeResolver.Instance,
DynamicEnumAsStringResolver.Instance,
ContractlessStandardResolver.Instance
))
.WithCompression(MessagePackCompression.Lz4BlockArray)
.WithSecurity(MessagePackSecurity.UntrustedData)
.WithOmitAssemblyVersion(true);
public string Serialize<T>(T value)
{
var bytes = MessagePackSerializer.Serialize(value!, _options);
return Convert.ToBase64String(bytes);
}
public T? Deserialize<T>(string data)
{
var bytes = Convert.FromBase64String(data);
return MessagePackSerializer.Deserialize<T>(bytes, _options);
}
}

View File

@@ -26,7 +26,7 @@ public static class SoftDeleteExtension
var method = typeof(SoftDeleteExtension) var method = typeof(SoftDeleteExtension)
.GetMethod(nameof(SetSoftDeleteFilter), BindingFlags.NonPublic | BindingFlags.Static)! .GetMethod(nameof(SetSoftDeleteFilter), BindingFlags.NonPublic | BindingFlags.Static)!
.MakeGenericMethod(entityType.ClrType); .MakeGenericMethod(entityType.ClrType);
method.Invoke(null, new object[] { modelBuilder }); method.Invoke(null, [modelBuilder]);
} }
} }

View File

@@ -22,8 +22,11 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" /> <PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" />
<PackageReference Include="MessagePack" Version="3.1.4" />
<PackageReference Include="MessagePack.NodaTime" Version="3.5.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="10.0.0" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.0" />
<PackageReference Include="NATS.Net" Version="2.6.11" /> <PackageReference Include="NATS.Net" Version="2.6.11" />
<PackageReference Include="NodaTime" Version="3.2.2" /> <PackageReference Include="NodaTime" Version="3.2.2" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" /> <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
@@ -31,6 +34,8 @@
<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="RedLock.net" Version="2.3.2" />
<PackageReference Include="StackExchange.Redis" Version="2.10.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.1" /> <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.1" />
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" /> <PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />

View File

@@ -1,11 +1,18 @@
using DysonNetwork.Shared.Cache;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NodaTime;
using OpenTelemetry; using OpenTelemetry;
using OpenTelemetry.Metrics; using OpenTelemetry.Metrics;
using OpenTelemetry.Trace; using OpenTelemetry.Trace;
using RedLockNet;
using RedLockNet.SERedis;
using RedLockNet.SERedis.Configuration;
using StackExchange.Redis;
namespace Microsoft.Extensions.Hosting; namespace Microsoft.Extensions.Hosting;
@@ -43,11 +50,27 @@ public static class Extensions
// options.AllowedSchemes = ["https"]; // options.AllowedSchemes = ["https"];
// }); // });
builder.Services.AddSingleton<IClock>(SystemClock.Instance);
builder.AddNatsClient("queue"); builder.AddNatsClient("queue");
builder.AddRedisClient("cache", configureOptions: opts => builder.AddRedisClient("cache", configureOptions: opts => { opts.AbortOnConnectFail = false; });
// Setup cache service
builder.Services.AddStackExchangeRedisCache(options =>
{ {
opts.AbortOnConnectFail = false; options.Configuration = builder.Configuration.GetConnectionString("cache");
options.InstanceName = "dyson:";
}); });
builder.Services.AddSingleton<IDistributedLockFactory, RedLockFactory>(sp =>
{
var mux = sp.GetRequiredService<IConnectionMultiplexer>();
return RedLockFactory.Create(new List<RedLockMultiplexer> { new(mux) });
});
builder.Services.AddSingleton<ICacheService, CacheServiceRedis>();
if (builder.Configuration.GetSection("Cache")["Serializer"] == "MessagePack")
builder.Services.AddSingleton<ICacheSerializer, MessagePackCacheSerializer>();
else
builder.Services.AddSingleton<ICacheSerializer, JsonCacheSerializer>();
return builder; return builder;
} }
@@ -129,4 +152,4 @@ public static class Extensions
return app; return app;
} }
} }

View File

@@ -1,6 +1,7 @@
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 MessagePack;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using NodaTime.Serialization.Protobuf; using NodaTime.Serialization.Protobuf;
@@ -26,13 +27,13 @@ public class SnAccount : ModelBase
public ICollection<SnAccountContact> Contacts { get; set; } = []; public ICollection<SnAccountContact> Contacts { get; set; } = [];
public ICollection<SnAccountBadge> Badges { get; set; } = []; public ICollection<SnAccountBadge> Badges { get; set; } = [];
[JsonIgnore] public ICollection<SnAccountAuthFactor> AuthFactors { get; set; } = []; [IgnoreMember] [JsonIgnore] public ICollection<SnAccountAuthFactor> AuthFactors { get; set; } = [];
[JsonIgnore] public ICollection<SnAccountConnection> Connections { get; set; } = []; [IgnoreMember] [JsonIgnore] public ICollection<SnAccountConnection> Connections { get; set; } = [];
[JsonIgnore] public ICollection<SnAuthSession> Sessions { get; set; } = []; [IgnoreMember] [JsonIgnore] public ICollection<SnAuthSession> Sessions { get; set; } = [];
[JsonIgnore] public ICollection<SnAuthChallenge> Challenges { get; set; } = []; [IgnoreMember] [JsonIgnore] public ICollection<SnAuthChallenge> Challenges { get; set; } = [];
[JsonIgnore] public ICollection<SnAccountRelationship> OutgoingRelationships { get; set; } = []; [IgnoreMember] [JsonIgnore] public ICollection<SnAccountRelationship> OutgoingRelationships { get; set; } = [];
[JsonIgnore] public ICollection<SnAccountRelationship> IncomingRelationships { get; set; } = []; [IgnoreMember] [JsonIgnore] public ICollection<SnAccountRelationship> IncomingRelationships { get; set; } = [];
[NotMapped] public SnSubscriptionReferenceObject? PerkSubscription { get; set; } [NotMapped] public SnSubscriptionReferenceObject? PerkSubscription { get; set; }
@@ -217,7 +218,7 @@ public class SnAccountProfile : ModelBase, IIdentifiedResource
[Column(TypeName = "jsonb")] public SnCloudFileReferenceObject? Background { get; set; } [Column(TypeName = "jsonb")] public SnCloudFileReferenceObject? Background { get; set; }
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
[JsonIgnore] public SnAccount Account { get; set; } = null!; [IgnoreMember] [JsonIgnore] public SnAccount Account { get; set; } = null!;
public Proto.AccountProfile ToProtoValue() public Proto.AccountProfile ToProtoValue()
{ {
@@ -331,7 +332,7 @@ public class SnAccountContact : ModelBase
[MaxLength(1024)] public string Content { get; set; } = string.Empty; [MaxLength(1024)] public string Content { get; set; } = string.Empty;
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
[JsonIgnore] public SnAccount Account { get; set; } = null!; [IgnoreMember] [JsonIgnore] public SnAccount Account { get; set; } = null!;
public Proto.AccountContact ToProtoValue() public Proto.AccountContact ToProtoValue()
{ {

View File

@@ -2,24 +2,35 @@ 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.GeoIp; using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Proto;
using NodaTime; using NodaTime;
using NodaTime.Serialization.Protobuf; using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Shared.Models; namespace DysonNetwork.Shared.Models;
public enum SessionType
{
Login,
OAuth, // Trying to authorize other platforms
Oidc // Trying to connect other platforms
}
public class SnAuthSession : ModelBase public class SnAuthSession : ModelBase
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public SessionType Type { get; set; } = SessionType.Login;
public Instant? LastGrantedAt { get; set; } public Instant? LastGrantedAt { get; set; }
public Instant? ExpiredAt { get; set; } public Instant? ExpiredAt { get; set; }
[Column(TypeName = "jsonb")] public List<string> Audiences { get; set; } = [];
[Column(TypeName = "jsonb")] public List<string> Scopes { get; set; } = [];
[MaxLength(128)] public string? IpAddress { get; set; }
[MaxLength(512)] public string? UserAgent { get; set; }
[Column(TypeName = "jsonb")] public GeoPoint? Location { get; set; }
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
[JsonIgnore] public SnAccount Account { get; set; } = null!; [JsonIgnore] public SnAccount Account { get; set; } = null!;
// The challenge that created this session
public Guid? ChallengeId { get; set; }
public SnAuthChallenge? Challenge { get; set; } = null!;
// The client device for this session // The client device for this session
public Guid? ClientId { get; set; } public Guid? ClientId { get; set; }
public SnAuthClient? Client { get; set; } = null!; public SnAuthClient? Client { get; set; } = null!;
@@ -28,30 +39,41 @@ public class SnAuthSession : ModelBase
public Guid? ParentSessionId { get; set; } public Guid? ParentSessionId { get; set; }
public SnAuthSession? ParentSession { get; set; } public SnAuthSession? ParentSession { get; set; }
// The origin challenge for this session
public Guid? ChallengeId { get; set; }
// Indicates the session is for an OIDC connection // Indicates the session is for an OIDC connection
public Guid? AppId { get; set; } public Guid? AppId { get; set; }
public Proto.AuthSession ToProtoValue() => new() public AuthSession ToProtoValue()
{ {
Id = Id.ToString(), var proto = new AuthSession
LastGrantedAt = LastGrantedAt?.ToTimestamp(), {
ExpiredAt = ExpiredAt?.ToTimestamp(), Id = Id.ToString(),
AccountId = AccountId.ToString(), LastGrantedAt = LastGrantedAt?.ToTimestamp(),
Account = Account.ToProtoValue(), Type = Type switch
ChallengeId = ChallengeId.ToString(), {
Challenge = Challenge?.ToProtoValue(), SessionType.Login => Proto.SessionType.Login,
ClientId = ClientId.ToString(), SessionType.OAuth => Proto.SessionType.Oauth,
Client = Client?.ToProtoValue(), SessionType.Oidc => Proto.SessionType.Oidc,
ParentSessionId = ParentSessionId.ToString(), _ => Proto.SessionType.ChallengeTypeUnspecified
AppId = AppId?.ToString() },
}; IpAddress = IpAddress,
} UserAgent = UserAgent,
ExpiredAt = ExpiredAt?.ToTimestamp(),
AccountId = AccountId.ToString(),
Account = Account.ToProtoValue(),
ClientId = ClientId.ToString(),
Client = Client?.ToProtoValue(),
ParentSessionId = ParentSessionId.ToString(),
AppId = AppId?.ToString()
};
proto.Audiences.AddRange(Audiences);
proto.Scopes.AddRange(Scopes);
public enum ChallengeType return proto;
{ }
Login,
OAuth, // Trying to authorize other platforms
Oidc // Trying to connect other platforms
} }
public enum ClientPlatform public enum ClientPlatform
@@ -72,10 +94,9 @@ public class SnAuthChallenge : ModelBase
public int StepRemain { get; set; } public int StepRemain { get; set; }
public int StepTotal { get; set; } public int StepTotal { get; set; }
public int FailedAttempts { get; set; } public int FailedAttempts { get; set; }
public ChallengeType Type { get; set; } = ChallengeType.Login; [Column(TypeName = "jsonb")] public List<Guid> BlacklistFactors { get; set; } = [];
[Column(TypeName = "jsonb")] public List<Guid> BlacklistFactors { get; set; } = new(); [Column(TypeName = "jsonb")] public List<string> Audiences { get; set; } = [];
[Column(TypeName = "jsonb")] public List<string> Audiences { get; set; } = new(); [Column(TypeName = "jsonb")] public List<string> Scopes { get; set; } = [];
[Column(TypeName = "jsonb")] public List<string> Scopes { get; set; } = new();
[MaxLength(128)] public string? IpAddress { get; set; } [MaxLength(128)] public string? IpAddress { get; set; }
[MaxLength(512)] public string? UserAgent { get; set; } [MaxLength(512)] public string? UserAgent { get; set; }
[MaxLength(512)] public string DeviceId { get; set; } = null!; [MaxLength(512)] public string DeviceId { get; set; } = null!;
@@ -93,14 +114,13 @@ public class SnAuthChallenge : ModelBase
return this; return this;
} }
public Proto.AuthChallenge ToProtoValue() => new() public AuthChallenge ToProtoValue() => new()
{ {
Id = Id.ToString(), Id = Id.ToString(),
ExpiredAt = ExpiredAt?.ToTimestamp(), ExpiredAt = ExpiredAt?.ToTimestamp(),
StepRemain = StepRemain, StepRemain = StepRemain,
StepTotal = StepTotal, StepTotal = StepTotal,
FailedAttempts = FailedAttempts, FailedAttempts = FailedAttempts,
Type = (Proto.ChallengeType)Type,
BlacklistFactors = { BlacklistFactors.Select(x => x.ToString()) }, BlacklistFactors = { BlacklistFactors.Select(x => x.ToString()) },
Audiences = { Audiences }, Audiences = { Audiences },
Scopes = { Scopes }, Scopes = { Scopes },
@@ -134,13 +154,13 @@ public class SnAuthClient : ModelBase
}; };
} }
public class SnAuthClientWithChallenge : SnAuthClient public class SnAuthClientWithSessions : SnAuthClient
{ {
public List<SnAuthChallenge> Challenges { get; set; } = []; public List<SnAuthSession> Sessions { get; set; } = [];
public static SnAuthClientWithChallenge FromClient(SnAuthClient client) public static SnAuthClientWithSessions FromClient(SnAuthClient client)
{ {
return new SnAuthClientWithChallenge return new SnAuthClientWithSessions
{ {
Id = client.Id, Id = client.Id,
Platform = client.Platform, Platform = client.Platform,
@@ -150,4 +170,4 @@ public class SnAuthClientWithChallenge : SnAuthClient
AccountId = client.AccountId, AccountId = client.AccountId,
}; };
} }
} }

View File

@@ -2,6 +2,7 @@ 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 DysonNetwork.Shared.Proto;
using MessagePack;
using NodaTime; using NodaTime;
using NodaTime.Serialization.Protobuf; using NodaTime.Serialization.Protobuf;
@@ -18,7 +19,7 @@ public class SnAccountBadge : ModelBase
public Instant? ExpiredAt { get; set; } public Instant? ExpiredAt { get; set; }
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
[JsonIgnore] public SnAccount Account { get; set; } = null!; [IgnoreMember] [JsonIgnore] public SnAccount Account { get; set; } = null!;
public SnAccountBadgeRef ToReference() public SnAccountBadgeRef ToReference()
{ {

View File

@@ -1,6 +1,7 @@
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 MessagePack;
using NodaTime; using NodaTime;
namespace DysonNetwork.Shared.Models; namespace DysonNetwork.Shared.Models;
@@ -8,25 +9,40 @@ namespace DysonNetwork.Shared.Models;
public enum ChatRoomType public enum ChatRoomType
{ {
Group, Group,
DirectMessage DirectMessage,
} }
public class SnChatRoom : ModelBase, IIdentifiedResource public class SnChatRoom : ModelBase, IIdentifiedResource
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
[MaxLength(1024)] public string? Name { get; set; }
[MaxLength(4096)] public string? Description { get; set; } [MaxLength(1024)]
public string? Name { get; set; }
[MaxLength(4096)]
public string? Description { get; set; }
public ChatRoomType Type { get; set; } public ChatRoomType Type { get; set; }
public bool IsCommunity { get; set; } public bool IsCommunity { get; set; }
public bool IsPublic { get; set; } public bool IsPublic { get; set; }
[Column(TypeName = "jsonb")] public SnCloudFileReferenceObject? Picture { get; set; } [Column(TypeName = "jsonb")]
[Column(TypeName = "jsonb")] public SnCloudFileReferenceObject? Background { get; set; } public SnCloudFileReferenceObject? Picture { get; set; }
[JsonIgnore] public ICollection<SnChatMember> Members { get; set; } = new List<SnChatMember>(); [Column(TypeName = "jsonb")]
public SnCloudFileReferenceObject? Background { get; set; }
[IgnoreMember]
[JsonIgnore]
public ICollection<SnChatMember> Members { get; set; } = new List<SnChatMember>();
public Guid? AccountId { get; set; }
[NotMapped]
public SnAccount? Account { get; set; }
public Guid? RealmId { get; set; } public Guid? RealmId { get; set; }
[NotMapped] public SnRealm? Realm { get; set; }
[NotMapped]
public SnRealm? Realm { get; set; }
[NotMapped] [NotMapped]
[JsonPropertyName("members")] [JsonPropertyName("members")]
@@ -36,18 +52,11 @@ public class SnChatRoom : ModelBase, IIdentifiedResource
public string ResourceIdentifier => $"chatroom:{Id}"; public string ResourceIdentifier => $"chatroom:{Id}";
} }
public abstract class ChatMemberRole
{
public const int Owner = 100;
public const int Moderator = 50;
public const int Member = 0;
}
public enum ChatMemberNotify public enum ChatMemberNotify
{ {
All, All,
Mentions, Mentions,
None None,
} }
public enum ChatTimeoutCauseType public enum ChatTimeoutCauseType
@@ -58,8 +67,11 @@ public enum ChatTimeoutCauseType
public class ChatTimeoutCause public class ChatTimeoutCause
{ {
[MaxLength(4096)]
public string? Reason { get; set; } = null;
public ChatTimeoutCauseType Type { get; set; } public ChatTimeoutCauseType Type { get; set; }
public Guid? SenderId { get; set; } public Guid? SenderId { get; set; }
public Instant? Since { get; set; }
} }
public class SnChatMember : ModelBase public class SnChatMember : ModelBase
@@ -68,32 +80,48 @@ public class SnChatMember : ModelBase
public Guid ChatRoomId { get; set; } public Guid ChatRoomId { get; set; }
public SnChatRoom ChatRoom { get; set; } = null!; public SnChatRoom ChatRoom { get; set; } = null!;
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
[NotMapped] public SnAccount? Account { get; set; }
[NotMapped] public SnAccountStatus? Status { get; set; }
[MaxLength(1024)] public string? Nick { get; set; } [NotMapped]
public SnAccount? Account { get; set; }
[NotMapped]
public SnAccountStatus? Status { get; set; }
[MaxLength(1024)]
public string? Nick { get; set; }
public int Role { get; set; } = ChatMemberRole.Member;
public ChatMemberNotify Notify { get; set; } = ChatMemberNotify.All; public ChatMemberNotify Notify { get; set; } = ChatMemberNotify.All;
public Instant? LastReadAt { get; set; } public Instant? LastReadAt { get; set; }
public Instant? JoinedAt { get; set; } public Instant? JoinedAt { get; set; }
public Instant? LeaveAt { get; set; } public Instant? LeaveAt { get; set; }
public bool IsBot { get; set; } = false;
public Guid? InvitedById { get; set; }
public SnChatMember? InvitedBy { get; set; }
// Backwards support field
[NotMapped]
public int Role { get; } = 0;
[NotMapped]
public bool IsBot { get; } = false;
/// <summary> /// <summary>
/// The break time is the user doesn't receive any message from this member for a while. /// The break time is the user doesn't receive any message from this member for a while.
/// Expect mentioned him or her. /// Expect mentioned him or her.
/// </summary> /// </summary>
public Instant? BreakUntil { get; set; } public Instant? BreakUntil { get; set; }
/// <summary> /// <summary>
/// The timeout is the user can't send any message. /// The timeout is the user can't send any message.
/// Set by the moderator of the chat room. /// Set by the moderator of the chat room.
/// </summary> /// </summary>
public Instant? TimeoutUntil { get; set; } public Instant? TimeoutUntil { get; set; }
/// <summary> /// <summary>
/// The timeout cause is the reason why the user is timeout. /// The timeout cause is the reason why the user is timeout.
/// </summary> /// </summary>
[Column(TypeName = "jsonb")] public ChatTimeoutCause? TimeoutCause { get; set; } [Column(TypeName = "jsonb")]
public ChatTimeoutCause? TimeoutCause { get; set; }
} }
public class ChatMemberTransmissionObject : ModelBase public class ChatMemberTransmissionObject : ModelBase
@@ -101,20 +129,31 @@ public class ChatMemberTransmissionObject : ModelBase
public Guid Id { get; set; } public Guid Id { get; set; }
public Guid ChatRoomId { get; set; } public Guid ChatRoomId { get; set; }
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
[NotMapped] public SnAccount Account { get; set; } = null!;
[MaxLength(1024)] public string? Nick { get; set; } [NotMapped]
public SnAccount Account { get; set; } = null!;
[MaxLength(1024)]
public string? Nick { get; set; }
public int Role { get; set; } = ChatMemberRole.Member;
public ChatMemberNotify Notify { get; set; } = ChatMemberNotify.All; public ChatMemberNotify Notify { get; set; } = ChatMemberNotify.All;
public Instant? JoinedAt { get; set; } public Instant? JoinedAt { get; set; }
public Instant? LeaveAt { get; set; } public Instant? LeaveAt { get; set; }
public bool IsBot { get; set; } = false;
public Guid? InvitedById { get; set; }
public SnChatMember? InvitedBy { get; set; }
public Instant? BreakUntil { get; set; } public Instant? BreakUntil { get; set; }
public Instant? TimeoutUntil { get; set; } public Instant? TimeoutUntil { get; set; }
public ChatTimeoutCause? TimeoutCause { get; set; } public ChatTimeoutCause? TimeoutCause { get; set; }
// Backwards support field
[NotMapped]
public int Role { get; } = 0;
[NotMapped]
public bool IsBot { get; } = false;
public static ChatMemberTransmissionObject FromEntity(SnChatMember member) public static ChatMemberTransmissionObject FromEntity(SnChatMember member)
{ {
return new ChatMemberTransmissionObject return new ChatMemberTransmissionObject
@@ -124,17 +163,17 @@ public class ChatMemberTransmissionObject : ModelBase
AccountId = member.AccountId, AccountId = member.AccountId,
Account = member.Account!, Account = member.Account!,
Nick = member.Nick, Nick = member.Nick,
Role = member.Role,
Notify = member.Notify, Notify = member.Notify,
JoinedAt = member.JoinedAt, JoinedAt = member.JoinedAt,
LeaveAt = member.LeaveAt, LeaveAt = member.LeaveAt,
IsBot = member.IsBot,
BreakUntil = member.BreakUntil, BreakUntil = member.BreakUntil,
TimeoutUntil = member.TimeoutUntil, TimeoutUntil = member.TimeoutUntil,
TimeoutCause = member.TimeoutCause, TimeoutCause = member.TimeoutCause,
InvitedById = member.InvitedById,
InvitedBy = member.InvitedBy,
CreatedAt = member.CreatedAt, CreatedAt = member.CreatedAt,
UpdatedAt = member.UpdatedAt, UpdatedAt = member.UpdatedAt,
DeletedAt = member.DeletedAt DeletedAt = member.DeletedAt,
}; };
} }
} }

View File

@@ -129,7 +129,7 @@ public class SnCloudFileReference : ModelBase
/// <returns>The protobuf message representation of this object</returns> /// <returns>The protobuf message representation of this object</returns>
public CloudFileReference ToProtoValue() public CloudFileReference ToProtoValue()
{ {
return new Proto.CloudFileReference return new CloudFileReference
{ {
Id = Id.ToString(), Id = Id.ToString(),
FileId = FileId, FileId = FileId,

View File

@@ -38,7 +38,7 @@ public class SnCustomApp : ModelBase, IIdentifiedResource
[NotMapped] [NotMapped]
public SnDeveloper Developer => Project.Developer; public SnDeveloper Developer => Project.Developer;
[NotMapped] public string ResourceIdentifier => "custom-app:" + Id; [NotMapped] public string ResourceIdentifier => "developer.app:" + Id;
public Proto.CustomApp ToProto() public Proto.CustomApp ToProto()
{ {

View File

@@ -50,5 +50,5 @@ public class FilePool : ModelBase, IIdentifiedResource
public Guid? AccountId { get; set; } public Guid? AccountId { get; set; }
public string ResourceIdentifier => $"file-pool/{Id}"; public string ResourceIdentifier => $"file.pool:{Id}";
} }

View File

@@ -12,7 +12,7 @@ public enum MagicSpellType
AccountDeactivation, AccountDeactivation,
AccountRemoval, AccountRemoval,
AuthPasswordReset, AuthPasswordReset,
ContactVerification, ContactVerification
} }
[Index(nameof(Spell), IsUnique = true)] [Index(nameof(Spell), IsUnique = true)]
@@ -27,4 +27,40 @@ public class SnMagicSpell : ModelBase
public Guid? AccountId { get; set; } public Guid? AccountId { get; set; }
public SnAccount? Account { get; set; } public SnAccount? Account { get; set; }
}
public enum AffiliationSpellType
{
RegistrationInvite
}
/// <summary>
/// Different from the magic spell, this is for the regeneration invite and other marketing usage.
/// </summary>
[Index(nameof(Spell), IsUnique = true)]
public class SnAffiliationSpell : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Spell { get; set; } = null!;
public AffiliationSpellType Type { get; set; }
public Instant? ExpiresAt { get; set; }
public Instant? AffectedAt { get; set; }
[Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
public List<SnAffiliationResult> Results = [];
public Guid? AccountId { get; set; }
public SnAccount? Account { get; set; }
}
/// <summary>
/// The record for who used the affiliation spells
/// </summary>
public class SnAffiliationResult : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(8192)] public string ResourceIdentifier { get; set; } = null!;
public Guid SpellId { get; set; }
[JsonIgnore] public SnAffiliationSpell Spell { get; set; } = null!;
} }

View File

@@ -8,6 +8,12 @@ using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Shared.Models; namespace DysonNetwork.Shared.Models;
public enum PermissionNodeActorType
{
Account,
Group
}
/// The permission node model provides the infrastructure of permission control in Dyson Network. /// The permission node model provides the infrastructure of permission control in Dyson Network.
/// It based on the ABAC permission model. /// It based on the ABAC permission model.
/// ///
@@ -19,12 +25,12 @@ namespace DysonNetwork.Shared.Models;
/// And the actor shows who owns the permission, in most cases, the user:&lt;userId&gt; /// And the actor shows who owns the permission, in most cases, the user:&lt;userId&gt;
/// and when the permission node has a GroupId, the actor will be set to the group, but it won't work on checking /// and when the permission node has a GroupId, the actor will be set to the group, but it won't work on checking
/// expect the member of that permission group inherent the permission from the group. /// expect the member of that permission group inherent the permission from the group.
[Index(nameof(Key), nameof(Area), nameof(Actor))] [Index(nameof(Key), nameof(Actor))]
public class SnPermissionNode : ModelBase, IDisposable public class SnPermissionNode : ModelBase, IDisposable
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public PermissionNodeActorType Type { get; set; } = PermissionNodeActorType.Account;
[MaxLength(1024)] public string Actor { get; set; } = null!; [MaxLength(1024)] public string Actor { get; set; } = null!;
[MaxLength(1024)] public string Area { get; set; } = null!;
[MaxLength(1024)] public string Key { get; set; } = null!; [MaxLength(1024)] public string Key { get; set; } = null!;
[Column(TypeName = "jsonb")] public JsonDocument Value { get; set; } = null!; [Column(TypeName = "jsonb")] public JsonDocument Value { get; set; } = null!;
public Instant? ExpiredAt { get; set; } = null; public Instant? ExpiredAt { get; set; } = null;
@@ -39,7 +45,12 @@ public class SnPermissionNode : ModelBase, IDisposable
{ {
Id = Id.ToString(), Id = Id.ToString(),
Actor = Actor, Actor = Actor,
Area = Area, Type = Type switch
{
PermissionNodeActorType.Account => Proto.PermissionNodeActorType.Account,
PermissionNodeActorType.Group => Proto.PermissionNodeActorType.Group,
_ => throw new ArgumentOutOfRangeException()
},
Key = Key, Key = Key,
Value = Google.Protobuf.WellKnownTypes.Value.Parser.ParseJson(Value.RootElement.GetRawText()), Value = Google.Protobuf.WellKnownTypes.Value.Parser.ParseJson(Value.RootElement.GetRawText()),
ExpiredAt = ExpiredAt?.ToTimestamp(), ExpiredAt = ExpiredAt?.ToTimestamp(),
@@ -48,6 +59,16 @@ public class SnPermissionNode : ModelBase, IDisposable
}; };
} }
public static PermissionNodeActorType ConvertProtoActorType(Proto.PermissionNodeActorType? val)
{
return val switch
{
Proto.PermissionNodeActorType.Account => PermissionNodeActorType.Account,
Proto.PermissionNodeActorType.Group => PermissionNodeActorType.Group,
_ => PermissionNodeActorType.Account
};
}
public void Dispose() public void Dispose()
{ {
Value.Dispose(); Value.Dispose();

View File

@@ -20,6 +20,19 @@ public enum PublicationSiteMode
SelfManaged SelfManaged
} }
public class PublicationSiteConfig
{
public string? StyleOverride { get; set; }
public List<PublicationSiteNavItem>? NavItems { get; set; } = [];
}
public class PublicationSiteNavItem
{
[MaxLength(1024)] public string Label { get; set; } = null!;
[MaxLength(8192)] public string Href { get; set; } = null!;
Dictionary<string, object> Attributes { get; set; } = new();
}
public class SnPublicationSite : ModelBase public class SnPublicationSite : ModelBase
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
@@ -29,6 +42,7 @@ public class SnPublicationSite : ModelBase
public PublicationSiteMode Mode { get; set; } = PublicationSiteMode.FullyManaged; public PublicationSiteMode Mode { get; set; } = PublicationSiteMode.FullyManaged;
public List<SnPublicationPage> Pages { get; set; } = []; public List<SnPublicationPage> Pages { get; set; } = [];
[Column(TypeName = "jsonb")] public PublicationSiteConfig Config { get; set; } = new();
public Guid PublisherId { get; set; } public Guid PublisherId { get; set; }
[NotMapped] public SnPublisher Publisher { get; set; } = null!; [NotMapped] public SnPublisher Publisher { get; set; } = null!;

View File

@@ -2,6 +2,8 @@ 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 DysonNetwork.Shared.Proto;
using Google.Protobuf.WellKnownTypes;
using MessagePack;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using NodaTime.Extensions; using NodaTime.Extensions;
@@ -29,11 +31,11 @@ public class SnPublisher : ModelBase, IIdentifiedResource
[Column(TypeName = "jsonb")] public SnVerificationMark? Verification { get; set; } [Column(TypeName = "jsonb")] public SnVerificationMark? Verification { get; set; }
[JsonIgnore] public ICollection<SnPost> Posts { get; set; } = []; [IgnoreMember] [JsonIgnore] public ICollection<SnPost> Posts { get; set; } = [];
[JsonIgnore] public ICollection<SnPoll> Polls { get; set; } = []; [IgnoreMember] [JsonIgnore] public ICollection<SnPoll> Polls { get; set; } = [];
[JsonIgnore] public ICollection<SnPostCollection> Collections { get; set; } = []; [IgnoreMember] [JsonIgnore] public ICollection<SnPostCollection> Collections { get; set; } = [];
[JsonIgnore] public ICollection<SnPublisherMember> Members { get; set; } = []; [IgnoreMember] [JsonIgnore] public ICollection<SnPublisherMember> Members { get; set; } = [];
[JsonIgnore] public ICollection<SnPublisherFeature> Features { get; set; } = []; [IgnoreMember] [JsonIgnore] public ICollection<SnPublisherFeature> Features { get; set; } = [];
[JsonIgnore] [JsonIgnore]
public ICollection<SnPublisherSubscription> Subscriptions { get; set; } = []; public ICollection<SnPublisherSubscription> Subscriptions { get; set; } = [];
@@ -45,7 +47,7 @@ public class SnPublisher : ModelBase, IIdentifiedResource
public string ResourceIdentifier => $"publisher:{Id}"; public string ResourceIdentifier => $"publisher:{Id}";
public static SnPublisher FromProtoValue(Proto.Publisher proto) public static SnPublisher FromProtoValue(Publisher proto)
{ {
var publisher = new SnPublisher var publisher = new SnPublisher
{ {
@@ -87,25 +89,25 @@ public class SnPublisher : ModelBase, IIdentifiedResource
return publisher; return publisher;
} }
public Proto.Publisher ToProtoValue() public Publisher ToProtoValue()
{ {
var p = new Proto.Publisher() var p = new Publisher
{ {
Id = Id.ToString(), Id = Id.ToString(),
Type = Type == PublisherType.Individual Type = Type == PublisherType.Individual
? Shared.Proto.PublisherType.PubIndividual ? Proto.PublisherType.PubIndividual
: Shared.Proto.PublisherType.PubOrganizational, : Proto.PublisherType.PubOrganizational,
Name = Name, Name = Name,
Nick = Nick, Nick = Nick,
Bio = Bio, Bio = Bio,
AccountId = AccountId?.ToString() ?? string.Empty, AccountId = AccountId?.ToString() ?? string.Empty,
RealmId = RealmId?.ToString() ?? string.Empty, RealmId = RealmId?.ToString() ?? string.Empty,
CreatedAt = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(CreatedAt.ToDateTimeOffset()), CreatedAt = Timestamp.FromDateTimeOffset(CreatedAt.ToDateTimeOffset()),
UpdatedAt = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(UpdatedAt.ToDateTimeOffset()) UpdatedAt = Timestamp.FromDateTimeOffset(UpdatedAt.ToDateTimeOffset())
}; };
if (Picture is not null) if (Picture is not null)
{ {
p.Picture = new Proto.CloudFile p.Picture = new CloudFile
{ {
Id = Picture.Id, Id = Picture.Id,
Name = Picture.Name, Name = Picture.Name,
@@ -117,7 +119,7 @@ public class SnPublisher : ModelBase, IIdentifiedResource
if (Background is not null) if (Background is not null)
{ {
p.Background = new Proto.CloudFile p.Background = new CloudFile
{ {
Id = Background.Id, Id = Background.Id,
Name = Background.Name, Name = Background.Name,

View File

@@ -2,6 +2,7 @@ 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 DysonNetwork.Shared.Proto;
using MessagePack;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using NodaTime.Serialization.Protobuf; using NodaTime.Serialization.Protobuf;
@@ -23,7 +24,7 @@ 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>(); [IgnoreMember] [JsonIgnore] public ICollection<SnRealmMember> Members { get; set; } = new List<SnRealmMember>();
public Guid AccountId { get; set; } public Guid AccountId { get; set; }

View File

@@ -1,6 +1,7 @@
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 MessagePack;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Shared.Models; namespace DysonNetwork.Shared.Models;
@@ -9,32 +10,34 @@ namespace DysonNetwork.Shared.Models;
public class SnSticker : ModelBase, IIdentifiedResource public class SnSticker : ModelBase, IIdentifiedResource
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(128)] public string Slug { get; set; } = null!; [MaxLength(128)] public string Slug { get; set; } = null!;
[Column(TypeName = "jsonb")] public SnCloudFileReferenceObject Image { get; set; } = null!;
// Outdated fields, for backward compability
[MaxLength(32)] public string? ImageId { get; set; }
[Column(TypeName = "jsonb")] public SnCloudFileReferenceObject? Image { get; set; } = null!;
public Guid PackId { get; set; } public Guid PackId { get; set; }
[JsonIgnore] public StickerPack Pack { get; set; } = null!; [IgnoreMember] [JsonIgnore] public StickerPack Pack { get; set; } = null!;
public string ResourceIdentifier => $"sticker/{Id}"; public string ResourceIdentifier => $"sticker:{Id}";
} }
[Index(nameof(Prefix), IsUnique = true)] [Index(nameof(Prefix), IsUnique = true)]
public class StickerPack : ModelBase public class StickerPack : ModelBase, IIdentifiedResource
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
[Column(TypeName = "jsonb")] public SnCloudFileReferenceObject? Icon { get; set; }
[MaxLength(1024)] public string Name { get; set; } = null!; [MaxLength(1024)] public string Name { get; set; } = null!;
[MaxLength(4096)] public string Description { get; set; } = string.Empty; [MaxLength(4096)] public string Description { get; set; } = string.Empty;
[MaxLength(128)] public string Prefix { get; set; } = null!; [MaxLength(128)] public string Prefix { get; set; } = null!;
public List<SnSticker> Stickers { get; set; } = []; public List<SnSticker> Stickers { get; set; } = [];
[JsonIgnore] public List<StickerPackOwnership> Ownerships { get; set; } = [];
[IgnoreMember] [JsonIgnore] public List<StickerPackOwnership> Ownerships { get; set; } = [];
public Guid PublisherId { get; set; } public Guid PublisherId { get; set; }
public SnPublisher Publisher { get; set; } = null!; public SnPublisher Publisher { get; set; } = null!;
public string ResourceIdentifier => $"sticker.pack:{Id}";
} }
public class StickerPackOwnership : ModelBase public class StickerPackOwnership : ModelBase
@@ -44,5 +47,6 @@ public class StickerPackOwnership : ModelBase
public Guid PackId { get; set; } public Guid PackId { get; set; }
public StickerPack Pack { get; set; } = null!; public StickerPack Pack { get; set; } = null!;
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
[NotMapped] public SnAccount Account { get; set; } = null!; [NotMapped] public SnAccount Account { get; set; } = null!;
} }

View File

@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Globalization; using System.Globalization;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using MessagePack;
using NodaTime; using NodaTime;
using NodaTime.Serialization.Protobuf; using NodaTime.Serialization.Protobuf;
@@ -46,7 +47,7 @@ public class SnWalletPocket : ModelBase
public decimal Amount { get; set; } public decimal Amount { get; set; }
public Guid WalletId { get; set; } public Guid WalletId { get; set; }
[JsonIgnore] public SnWallet Wallet { get; set; } = null!; [IgnoreMember] [JsonIgnore] public SnWallet Wallet { get; set; } = null!;
public Proto.WalletPocket ToProtoValue() => new() public Proto.WalletPocket ToProtoValue() => new()
{ {

View File

@@ -12,193 +12,199 @@ import "account.proto";
// Represents a user session // Represents a user session
message AuthSession { message AuthSession {
string id = 1; string id = 1;
optional google.protobuf.Timestamp last_granted_at = 3; optional google.protobuf.Timestamp last_granted_at = 3;
optional google.protobuf.Timestamp expired_at = 4; optional google.protobuf.Timestamp expired_at = 4;
string account_id = 5; string account_id = 5;
Account account = 6; Account account = 6;
string challenge_id = 7; google.protobuf.StringValue app_id = 9;
AuthChallenge challenge = 8; optional string client_id = 10;
google.protobuf.StringValue app_id = 9; optional string parent_session_id = 11;
optional string client_id = 10; AuthClient client = 12;
optional string parent_session_id = 11; repeated string audiences = 13;
AuthClient client = 12; repeated string scopes = 14;
google.protobuf.StringValue ip_address = 15;
google.protobuf.StringValue user_agent = 16;
SessionType type = 17;
} }
// Represents an authentication challenge // Represents an authentication challenge
message AuthChallenge { message AuthChallenge {
string id = 1; string id = 1;
google.protobuf.Timestamp expired_at = 2; google.protobuf.Timestamp expired_at = 2;
int32 step_remain = 3; int32 step_remain = 3;
int32 step_total = 4; int32 step_total = 4;
int32 failed_attempts = 5; int32 failed_attempts = 5;
ChallengeType type = 7; repeated string blacklist_factors = 8;
repeated string blacklist_factors = 8; repeated string audiences = 9;
repeated string audiences = 9; repeated string scopes = 10;
repeated string scopes = 10; google.protobuf.StringValue ip_address = 11;
google.protobuf.StringValue ip_address = 11; google.protobuf.StringValue user_agent = 12;
google.protobuf.StringValue user_agent = 12; google.protobuf.StringValue device_id = 13;
google.protobuf.StringValue device_id = 13; google.protobuf.StringValue nonce = 14;
google.protobuf.StringValue nonce = 14; // Point location is omitted as there is no direct proto equivalent.
// Point location is omitted as there is no direct proto equivalent. string account_id = 15;
string account_id = 15; google.protobuf.StringValue device_name = 16;
google.protobuf.StringValue device_name = 16; ClientPlatform platform = 17;
ClientPlatform platform = 17;
} }
message AuthClient { message AuthClient {
string id = 1; string id = 1;
ClientPlatform platform = 2; ClientPlatform platform = 2;
google.protobuf.StringValue device_name = 3; google.protobuf.StringValue device_name = 3;
google.protobuf.StringValue device_label = 4; google.protobuf.StringValue device_label = 4;
string device_id = 5; string device_id = 5;
string account_id = 6; string account_id = 6;
} }
// Enum for challenge types // Enum for challenge types
enum ChallengeType { enum SessionType {
CHALLENGE_TYPE_UNSPECIFIED = 0; CHALLENGE_TYPE_UNSPECIFIED = 0;
LOGIN = 1; LOGIN = 1;
OAUTH = 2; OAUTH = 2;
OIDC = 3; OIDC = 3;
} }
// Enum for client platforms // Enum for client platforms
enum ClientPlatform { enum ClientPlatform {
CLIENT_PLATFORM_UNSPECIFIED = 0; CLIENT_PLATFORM_UNSPECIFIED = 0;
UNIDENTIFIED = 1; UNIDENTIFIED = 1;
WEB = 2; WEB = 2;
IOS = 3; IOS = 3;
ANDROID = 4; ANDROID = 4;
MACOS = 5; MACOS = 5;
WINDOWS = 6; WINDOWS = 6;
LINUX = 7; LINUX = 7;
} }
service AuthService { service AuthService {
rpc Authenticate(AuthenticateRequest) returns (AuthenticateResponse) {} rpc Authenticate(AuthenticateRequest) returns (AuthenticateResponse) {}
rpc ValidatePin(ValidatePinRequest) returns (ValidateResponse) {} rpc ValidatePin(ValidatePinRequest) returns (ValidateResponse) {}
rpc ValidateCaptcha(ValidateCaptchaRequest) returns (ValidateResponse) {} rpc ValidateCaptcha(ValidateCaptchaRequest) returns (ValidateResponse) {}
} }
message AuthenticateRequest { message AuthenticateRequest {
string token = 1; string token = 1;
optional google.protobuf.StringValue ip_address = 2; optional google.protobuf.StringValue ip_address = 2;
} }
message AuthenticateResponse { message AuthenticateResponse {
bool valid = 1; bool valid = 1;
optional string message = 2; optional string message = 2;
optional AuthSession session = 3; optional AuthSession session = 3;
} }
message ValidatePinRequest { message ValidatePinRequest {
string account_id = 1; string account_id = 1;
string pin = 2; string pin = 2;
} }
message ValidateCaptchaRequest { message ValidateCaptchaRequest {
string token = 1; string token = 1;
} }
message ValidateResponse { message ValidateResponse {
bool valid = 1; bool valid = 1;
}
enum PermissionNodeActorType {
ACCOUNT = 0;
GROUP = 1;
} }
// Permission related messages and services // Permission related messages and services
message PermissionNode { message PermissionNode {
string id = 1; string id = 1;
string actor = 2; string actor = 2;
string area = 3; PermissionNodeActorType type = 3;
string key = 4; string key = 4;
google.protobuf.Value value = 5; // Using Value to represent generic type google.protobuf.Value value = 5; // Using Value to represent generic type
google.protobuf.Timestamp expired_at = 6; google.protobuf.Timestamp expired_at = 6;
google.protobuf.Timestamp affected_at = 7; google.protobuf.Timestamp affected_at = 7;
string group_id = 8; // Optional group ID string group_id = 8; // Optional group ID
} }
message PermissionGroup { message PermissionGroup {
string id = 1; string id = 1;
string name = 2; string name = 2;
google.protobuf.Timestamp created_at = 3; google.protobuf.Timestamp created_at = 3;
} }
message HasPermissionRequest { message HasPermissionRequest {
string actor = 1; string actor = 1;
string area = 2; string key = 2;
string key = 3; optional PermissionNodeActorType type = 3;
} }
message HasPermissionResponse { message HasPermissionResponse {
bool has_permission = 1; bool has_permission = 1;
} }
message GetPermissionRequest { message GetPermissionRequest {
string actor = 1; string actor = 1;
string area = 2; optional PermissionNodeActorType type = 2;
string key = 3; string key = 3;
} }
message GetPermissionResponse { message GetPermissionResponse {
google.protobuf.Value value = 1; // Using Value to represent generic type google.protobuf.Value value = 1; // Using Value to represent generic type
} }
message AddPermissionNodeRequest { message AddPermissionNodeRequest {
string actor = 1; string actor = 1;
string area = 2; optional PermissionNodeActorType type = 2;
string key = 3; string key = 3;
google.protobuf.Value value = 4; google.protobuf.Value value = 4;
google.protobuf.Timestamp expired_at = 5; google.protobuf.Timestamp expired_at = 5;
google.protobuf.Timestamp affected_at = 6; google.protobuf.Timestamp affected_at = 6;
} }
message AddPermissionNodeResponse { message AddPermissionNodeResponse {
PermissionNode node = 1; PermissionNode node = 1;
} }
message AddPermissionNodeToGroupRequest { message AddPermissionNodeToGroupRequest {
PermissionGroup group = 1; PermissionGroup group = 1;
string actor = 2; string actor = 2;
string area = 3; optional PermissionNodeActorType type = 3;
string key = 4; string key = 4;
google.protobuf.Value value = 5; google.protobuf.Value value = 5;
google.protobuf.Timestamp expired_at = 6; google.protobuf.Timestamp expired_at = 6;
google.protobuf.Timestamp affected_at = 7; google.protobuf.Timestamp affected_at = 7;
} }
message AddPermissionNodeToGroupResponse { message AddPermissionNodeToGroupResponse {
PermissionNode node = 1; PermissionNode node = 1;
} }
message RemovePermissionNodeRequest { message RemovePermissionNodeRequest {
string actor = 1; string actor = 1;
string area = 2; optional PermissionNodeActorType type = 2;
string key = 3; string key = 3;
} }
message RemovePermissionNodeResponse { message RemovePermissionNodeResponse {
bool success = 1; bool success = 1;
} }
message RemovePermissionNodeFromGroupRequest { message RemovePermissionNodeFromGroupRequest {
PermissionGroup group = 1; PermissionGroup group = 1;
string actor = 2; string actor = 2;
string area = 3; string key = 4;
string key = 4;
} }
message RemovePermissionNodeFromGroupResponse { message RemovePermissionNodeFromGroupResponse {
bool success = 1; bool success = 1;
} }
service PermissionService { service PermissionService {
rpc HasPermission(HasPermissionRequest) returns (HasPermissionResponse) {} rpc HasPermission(HasPermissionRequest) returns (HasPermissionResponse) {}
rpc GetPermission(GetPermissionRequest) returns (GetPermissionResponse) {} rpc GetPermission(GetPermissionRequest) returns (GetPermissionResponse) {}
rpc AddPermissionNode(AddPermissionNodeRequest) returns (AddPermissionNodeResponse) {} rpc AddPermissionNode(AddPermissionNodeRequest) returns (AddPermissionNodeResponse) {}
rpc AddPermissionNodeToGroup(AddPermissionNodeToGroupRequest) returns (AddPermissionNodeToGroupResponse) {} rpc AddPermissionNodeToGroup(AddPermissionNodeToGroupRequest) returns (AddPermissionNodeToGroupResponse) {}
rpc RemovePermissionNode(RemovePermissionNodeRequest) returns (RemovePermissionNodeResponse) {} rpc RemovePermissionNode(RemovePermissionNodeRequest) returns (RemovePermissionNodeResponse) {}
rpc RemovePermissionNodeFromGroup(RemovePermissionNodeFromGroupRequest) rpc RemovePermissionNodeFromGroup(RemovePermissionNodeFromGroupRequest)
returns (RemovePermissionNodeFromGroupResponse) {} returns (RemovePermissionNodeFromGroupResponse) {}
} }

View File

@@ -12,6 +12,7 @@ using Grpc.Core;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Chat; namespace DysonNetwork.Sphere.Chat;
@@ -112,7 +113,7 @@ public partial class ChatController(
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && .Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null &&
m.LeaveAt == null) m.LeaveAt == null)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Member) if (member == null)
return StatusCode(403, "You are not a member of this chat room."); return StatusCode(403, "You are not a member of this chat room.");
} }
@@ -155,7 +156,7 @@ public partial class ChatController(
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && .Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null &&
m.LeaveAt == null) m.LeaveAt == null)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Member) if (member == null)
return StatusCode(403, "You are not a member of this chat room."); return StatusCode(403, "You are not a member of this chat room.");
} }
@@ -242,10 +243,11 @@ public partial class ChatController(
[HttpPost("{roomId:guid}/messages")] [HttpPost("{roomId:guid}/messages")]
[Authorize] [Authorize]
[RequiredPermission("global", "chat.messages.create")] [AskPermission("chat.messages.create")]
public async Task<ActionResult> SendMessage([FromBody] SendMessageRequest request, Guid roomId) public async Task<ActionResult> SendMessage([FromBody] SendMessageRequest request, Guid roomId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
request.Content = TextSanitizer.Sanitize(request.Content); request.Content = TextSanitizer.Sanitize(request.Content);
if (string.IsNullOrWhiteSpace(request.Content) && if (string.IsNullOrWhiteSpace(request.Content) &&
@@ -254,9 +256,12 @@ public partial class ChatController(
!request.PollId.HasValue) !request.PollId.HasValue)
return BadRequest("You cannot send an empty message."); return BadRequest("You cannot send an empty message.");
var member = await crs.GetRoomMember(Guid.Parse(currentUser.Id), roomId); var now = SystemClock.Instance.GetCurrentInstant();
if (member == null || member.Role < ChatMemberRole.Member) var member = await crs.GetRoomMember(accountId, roomId);
return StatusCode(403, "You need to be a normal member to send messages here."); if (member == null)
return StatusCode(403, "You need to be a member to send messages here.");
if (member.TimeoutUntil.HasValue && member.TimeoutUntil.Value > now)
return StatusCode(403, "You has been timed out in this chat.");
// Validate fund if provided // Validate fund if provided
if (request.FundId.HasValue) if (request.FundId.HasValue)
@@ -382,6 +387,7 @@ public partial class ChatController(
public async Task<ActionResult> UpdateMessage([FromBody] SendMessageRequest request, Guid roomId, Guid messageId) public async Task<ActionResult> UpdateMessage([FromBody] SendMessageRequest request, Guid roomId, Guid messageId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
request.Content = TextSanitizer.Sanitize(request.Content); request.Content = TextSanitizer.Sanitize(request.Content);
@@ -392,9 +398,11 @@ public partial class ChatController(
if (message == null) return NotFound(); if (message == null) return NotFound();
var accountId = Guid.Parse(currentUser.Id); var now = SystemClock.Instance.GetCurrentInstant();
if (message.Sender.AccountId != accountId) if (message.Sender.AccountId != accountId)
return StatusCode(403, "You can only edit your own messages."); return StatusCode(403, "You can only edit your own messages.");
if (message.Sender.TimeoutUntil.HasValue && message.Sender.TimeoutUntil.Value > now)
return StatusCode(403, "You has been timed out in this chat.");
if (string.IsNullOrWhiteSpace(request.Content) && if (string.IsNullOrWhiteSpace(request.Content) &&
(request.AttachmentsId == null || request.AttachmentsId.Count == 0) && (request.AttachmentsId == null || request.AttachmentsId.Count == 0) &&
@@ -402,23 +410,6 @@ public partial class ChatController(
!request.PollId.HasValue) !request.PollId.HasValue)
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)
{
var repliedMessage = await db.ChatMessages
.FirstOrDefaultAsync(m => m.Id == request.RepliedMessageId.Value && m.ChatRoomId == roomId);
if (repliedMessage == null)
return BadRequest("The message you're replying to does not exist.");
}
if (request.ForwardedMessageId.HasValue)
{
var forwardedMessage = await db.ChatMessages
.FirstOrDefaultAsync(m => m.Id == request.ForwardedMessageId.Value);
if (forwardedMessage == null)
return BadRequest("The message you're forwarding does not exist.");
}
// Update mentions based on new content and references // Update mentions based on new content and references
var updatedMentions = await ExtractMentionedUsersAsync(request.Content, request.RepliedMessageId, var updatedMentions = await ExtractMentionedUsersAsync(request.Content, request.RepliedMessageId,
request.ForwardedMessageId, roomId, accountId); request.ForwardedMessageId, roomId, accountId);

View File

@@ -6,7 +6,6 @@ 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 Grpc.Core; using Grpc.Core;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
@@ -80,6 +79,7 @@ public class ChatRoomController(
{ {
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 relatedUser = await accounts.GetAccountAsync( var relatedUser = await accounts.GetAccountAsync(
new GetAccountRequest { Id = request.RelatedUserId.ToString() } new GetAccountRequest { Id = request.RelatedUserId.ToString() }
@@ -112,18 +112,17 @@ public class ChatRoomController(
{ {
Type = ChatRoomType.DirectMessage, Type = ChatRoomType.DirectMessage,
IsPublic = false, IsPublic = false,
AccountId = accountId,
Members = new List<SnChatMember> Members = new List<SnChatMember>
{ {
new() new()
{ {
AccountId = Guid.Parse(currentUser.Id), AccountId = accountId,
Role = ChatMemberRole.Owner, JoinedAt = SystemClock.Instance.GetCurrentInstant()
JoinedAt = Instant.FromDateTimeUtc(DateTime.UtcNow)
}, },
new() new()
{ {
AccountId = request.RelatedUserId, AccountId = request.RelatedUserId,
Role = ChatMemberRole.Member,
JoinedAt = null, // Pending status JoinedAt = null, // Pending status
} }
} }
@@ -154,11 +153,12 @@ public class ChatRoomController(
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized(); return Unauthorized();
var currentId = Guid.Parse(currentUser.Id);
var room = await db.ChatRooms var room = await db.ChatRooms
.Include(c => c.Members) .Include(c => c.Members)
.Where(c => c.Type == ChatRoomType.DirectMessage && c.Members.Count == 2) .Where(c => c.Type == ChatRoomType.DirectMessage && c.Members.Count == 2)
.Where(c => c.Members.Any(m => m.AccountId == Guid.Parse(currentUser.Id))) .Where(c => c.Members.Any(m => m.AccountId == currentId))
.Where(c => c.Members.Any(m => m.AccountId == accountId)) .Where(c => c.Members.Any(m => m.AccountId == accountId))
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (room is null) return NotFound(); if (room is null) return NotFound();
@@ -168,7 +168,7 @@ public class ChatRoomController(
public class ChatRoomRequest public class ChatRoomRequest
{ {
[Required][MaxLength(1024)] public string? Name { get; set; } [Required] [MaxLength(1024)] public string? Name { get; set; }
[MaxLength(4096)] public string? Description { get; set; } [MaxLength(4096)] public string? Description { get; set; }
[MaxLength(32)] public string? PictureId { get; set; } [MaxLength(32)] public string? PictureId { get; set; }
[MaxLength(32)] public string? BackgroundId { get; set; } [MaxLength(32)] public string? BackgroundId { get; set; }
@@ -179,11 +179,12 @@ public class ChatRoomController(
[HttpPost] [HttpPost]
[Authorize] [Authorize]
[RequiredPermission("global", "chat.create")] [AskPermission("chat.create")]
public async Task<ActionResult<SnChatRoom>> CreateChatRoom(ChatRoomRequest request) public async Task<ActionResult<SnChatRoom>> CreateChatRoom(ChatRoomRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (request.Name is null) return BadRequest("You cannot create a chat room without a name."); if (request.Name is null) return BadRequest("You cannot create a chat room without a name.");
var accountId = Guid.Parse(currentUser.Id);
var chatRoom = new SnChatRoom var chatRoom = new SnChatRoom
{ {
@@ -192,13 +193,13 @@ public class ChatRoomController(
IsCommunity = request.IsCommunity ?? false, IsCommunity = request.IsCommunity ?? false,
IsPublic = request.IsPublic ?? false, IsPublic = request.IsPublic ?? false,
Type = ChatRoomType.Group, Type = ChatRoomType.Group,
AccountId = accountId,
Members = new List<SnChatMember> Members = new List<SnChatMember>
{ {
new() new()
{ {
Role = ChatMemberRole.Owner, AccountId = accountId,
AccountId = Guid.Parse(currentUser.Id), JoinedAt = SystemClock.Instance.GetCurrentInstant()
JoinedAt = Instant.FromDateTimeUtc(DateTime.UtcNow)
} }
} }
}; };
@@ -294,7 +295,8 @@ public class ChatRoomController(
[HttpPatch("{id:guid}")] [HttpPatch("{id:guid}")]
public async Task<ActionResult<SnChatRoom>> UpdateChatRoom(Guid id, [FromBody] ChatRoomRequest request) public async Task<ActionResult<SnChatRoom>> UpdateChatRoom(Guid id, [FromBody] ChatRoomRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var chatRoom = await db.ChatRooms var chatRoom = await db.ChatRooms
.Where(e => e.Id == id) .Where(e => e.Id == id)
@@ -303,16 +305,18 @@ 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, accountId, [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 (chatRoom.Type == ChatRoomType.DirectMessage && await crs.IsChatMember(chatRoom.Id, accountId))
return StatusCode(403, "You need at least be a moderator to update the chat."); return StatusCode(403, "You need be part of the DM to update the chat.");
else if (chatRoom.AccountId != accountId)
return StatusCode(403, "You need be the owner to update the chat.");
if (request.RealmId is not null) if (request.RealmId is not null)
{ {
if (!await rs.IsMemberWithRole(request.RealmId.Value, Guid.Parse(currentUser.Id), [RealmMemberRole.Moderator])) if (!await rs.IsMemberWithRole(request.RealmId.Value, Guid.Parse(currentUser.Id),
[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 = request.RealmId; chatRoom.RealmId = request.RealmId;
} }
@@ -404,7 +408,8 @@ public class ChatRoomController(
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
public async Task<ActionResult> DeleteChatRoom(Guid id) public async Task<ActionResult> DeleteChatRoom(Guid id)
{ {
if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var chatRoom = await db.ChatRooms var chatRoom = await db.ChatRooms
.Where(e => e.Id == id) .Where(e => e.Id == id)
@@ -413,12 +418,13 @@ 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, accountId, [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 (chatRoom.Type == ChatRoomType.DirectMessage && await crs.IsChatMember(chatRoom.Id, accountId))
return StatusCode(403, "You need at least be the owner to delete the chat."); return StatusCode(403, "You need be part of the DM to update the chat.");
else if (chatRoom.AccountId != accountId)
return StatusCode(403, "You need be the owner to update the chat.");
var chatRoomResourceId = $"chatroom:{chatRoom.Id}"; var chatRoomResourceId = $"chatroom:{chatRoom.Id}";
@@ -495,9 +501,11 @@ public class ChatRoomController(
{ {
if (currentUser is null) return Unauthorized(); if (currentUser is null) return Unauthorized();
var member = await db.ChatMembers var member = await db.ChatMembers
.Where(m => m.ChatRoomId == roomId && m.AccountId == Guid.Parse(currentUser.Id) && m.JoinedAt != null && m.LeaveAt == null) .Where(m => m.ChatRoomId == roomId && m.AccountId == Guid.Parse(currentUser.Id) && m.JoinedAt != null &&
m.LeaveAt == null)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (member is null) return StatusCode(403, "You need to be a member to see online count of private chat room."); if (member is null)
return StatusCode(403, "You need to be a member to see online count of private chat room.");
} }
var members = await db.ChatMembers var members = await db.ChatMembers
@@ -530,7 +538,7 @@ public class ChatRoomController(
{ {
if (currentUser is null) return Unauthorized(); if (currentUser is null) return Unauthorized();
var member = await db.ChatMembers var member = await db.ChatMembers
.Where(m => m.ChatRoomId == roomId && m.AccountId == Guid.Parse(currentUser.Id) && m.JoinedAt != null && m.LeaveAt == null) .Where(m => m.ChatRoomId == roomId && m.AccountId == Guid.Parse(currentUser.Id) && m.LeaveAt == null)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (member is null) return StatusCode(403, "You need to be a member to see members of private chat room."); if (member is null) return StatusCode(403, "You need to be a member to see members of private chat room.");
} }
@@ -592,8 +600,7 @@ public class ChatRoomController(
[HttpPost("invites/{roomId:guid}")] [HttpPost("invites/{roomId:guid}")]
[Authorize] [Authorize]
public async Task<ActionResult<SnChatMember>> InviteMember(Guid roomId, public async Task<ActionResult<SnChatMember>> InviteMember(Guid roomId, [FromBody] ChatMemberRequest request)
[FromBody] ChatMemberRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
@@ -611,7 +618,7 @@ public class ChatRoomController(
Status = -100 Status = -100
}); });
if (relationship?.Relationship != null && relationship.Relationship.Status == -100) if (relationship?.Relationship is { Status: -100 })
return StatusCode(403, "You cannot invite a user that blocked you."); return StatusCode(403, "You cannot invite a user that blocked you.");
var chatRoom = await db.ChatRooms var chatRoom = await db.ChatRooms
@@ -619,26 +626,22 @@ public class ChatRoomController(
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (chatRoom is null) return NotFound(); if (chatRoom is null) return NotFound();
var operatorMember = await db.ChatMembers
.Where(p => p.AccountId == accountId && p.ChatRoomId == chatRoom.Id)
.FirstOrDefaultAsync();
if (operatorMember is null)
return StatusCode(403, "You need to be a part of chat to invite member to the chat.");
// Handle realm-owned chat rooms // Handle realm-owned chat rooms
if (chatRoom.RealmId is not null) if (chatRoom.RealmId is not null)
{ {
if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, accountId, [RealmMemberRole.Moderator])) if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, accountId, [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 if (chatRoom.Type == ChatRoomType.DirectMessage && await crs.IsChatMember(chatRoom.Id, accountId))
{ return StatusCode(403, "You need be part of the DM to invite member to the chat.");
var chatMember = await db.ChatMembers else if (chatRoom.AccountId != accountId)
.Where(m => m.AccountId == accountId) return StatusCode(403, "You need be the owner to invite member to this chat.");
.Where(m => m.ChatRoomId == roomId)
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
if (chatMember is null) return StatusCode(403, "You are not even a member of the targeted chat room.");
if (chatMember.Role < ChatMemberRole.Moderator)
return StatusCode(403,
"You need at least be a moderator to invite other members to this chat room.");
if (chatMember.Role < request.Role)
return StatusCode(403, "You cannot invite member with higher permission than yours.");
}
var existingMember = await db.ChatMembers var existingMember = await db.ChatMembers
.Where(m => m.AccountId == request.RelatedUserId) .Where(m => m.AccountId == request.RelatedUserId)
@@ -646,9 +649,7 @@ public class ChatRoomController(
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (existingMember != null) if (existingMember != null)
{ {
if (existingMember.LeaveAt == null) existingMember.InvitedById = operatorMember.Id;
return BadRequest("This user has been joined the chat cannot be invited again.");
existingMember.LeaveAt = null; existingMember.LeaveAt = null;
existingMember.JoinedAt = null; existingMember.JoinedAt = null;
db.ChatMembers.Update(existingMember); db.ChatMembers.Update(existingMember);
@@ -659,10 +660,10 @@ public class ChatRoomController(
{ {
Action = "chatrooms.invite", Action = "chatrooms.invite",
Meta = Meta =
{ {
{ "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(chatRoom.Id.ToString()) }, { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(chatRoom.Id.ToString()) },
{ "account_id", Google.Protobuf.WellKnownTypes.Value.ForString(relatedUser.Id.ToString()) } { "account_id", Google.Protobuf.WellKnownTypes.Value.ForString(relatedUser.Id.ToString()) }
}, },
AccountId = currentUser.Id, AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent, UserAgent = Request.Headers.UserAgent,
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
@@ -673,9 +674,9 @@ public class ChatRoomController(
var newMember = new SnChatMember var newMember = new SnChatMember
{ {
InvitedById = operatorMember.Id,
AccountId = Guid.Parse(relatedUser.Id), AccountId = Guid.Parse(relatedUser.Id),
ChatRoomId = roomId, ChatRoomId = roomId,
Role = request.Role,
}; };
db.ChatMembers.Add(newMember); db.ChatMembers.Add(newMember);
@@ -768,7 +769,7 @@ public class ChatRoomController(
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (member is null) return NotFound(); if (member is null) return NotFound();
member.LeaveAt = SystemClock.Instance.GetCurrentInstant(); db.ChatMembers.Remove(member);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return NoContent(); return NoContent();
@@ -812,92 +813,42 @@ public class ChatRoomController(
return Ok(targetMember); return Ok(targetMember);
} }
[HttpPatch("{roomId:guid}/members/{memberId:guid}/role")] public class ChatTimeoutRequest
[Authorize]
public async Task<ActionResult<SnChatMember>> UpdateChatMemberRole(Guid roomId, Guid memberId, [FromBody] int newRole)
{ {
if (newRole >= ChatMemberRole.Owner) return BadRequest("Unable to set chat member to owner or greater role."); [MaxLength(4096)] public string? Reason { get; set; }
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); public Instant TimeoutUntil { get; set; }
var chatRoom = await db.ChatRooms
.Where(r => r.Id == roomId)
.FirstOrDefaultAsync();
if (chatRoom is null) return NotFound();
// Check if the chat room is owned by a realm
if (chatRoom.RealmId is not null)
{
if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id), [RealmMemberRole.Moderator]))
return StatusCode(403, "You need at least be a realm moderator to change member roles.");
}
else
{
var targetMember = await db.ChatMembers
.Where(m => m.AccountId == memberId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
if (targetMember is null) return NotFound();
// Check if the current user has permission to change roles
if (
!await crs.IsMemberWithRole(
chatRoom.Id,
Guid.Parse(currentUser.Id),
ChatMemberRole.Moderator,
targetMember.Role,
newRole
)
)
return StatusCode(403, "You don't have enough permission to edit the roles of members.");
targetMember.Role = newRole;
db.ChatMembers.Update(targetMember);
await db.SaveChangesAsync();
await crs.PurgeRoomMembersCache(roomId);
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "chatrooms.role.edit",
Meta =
{
{ "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(roomId.ToString()) },
{ "account_id", Google.Protobuf.WellKnownTypes.Value.ForString(memberId.ToString()) },
{ "new_role", Google.Protobuf.WellKnownTypes.Value.ForNumber(newRole) }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent,
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
});
return Ok(targetMember);
}
return BadRequest();
} }
[HttpDelete("{roomId:guid}/members/{memberId:guid}")] [HttpPost("{roomId:guid}/members/{memberId:guid}/timeout")]
[Authorize] [Authorize]
public async Task<ActionResult> RemoveChatMember(Guid roomId, Guid memberId) public async Task<ActionResult> TimeoutChatMember(Guid roomId, Guid memberId, [FromBody] ChatTimeoutRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var now = SystemClock.Instance.GetCurrentInstant();
if (now >= request.TimeoutUntil)
return BadRequest("Timeout can only until a time in the future.");
var chatRoom = await db.ChatRooms var chatRoom = await db.ChatRooms
.Where(r => r.Id == roomId) .Where(r => r.Id == roomId)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (chatRoom is null) return NotFound(); if (chatRoom is null) return NotFound();
var operatorMember = await db.ChatMembers
.FirstOrDefaultAsync(m => m.AccountId == accountId && m.ChatRoomId == chatRoom.Id);
if (operatorMember is null) return BadRequest("You have not joined this chat room.");
// 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)
{ {
if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id), if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, accountId, [RealmMemberRole.Moderator]))
[RealmMemberRole.Moderator])) return StatusCode(403, "You need at least be a realm moderator to timeout members.");
return StatusCode(403, "You need at least be a realm moderator to remove members.");
}
else
{
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.");
} }
else if (chatRoom.Type == ChatRoomType.DirectMessage)
return BadRequest("You cannot timeout member in a direct message.");
else if (chatRoom.AccountId != accountId)
return StatusCode(403, "You need be the owner to timeout member in the chat.");
// Find the target member // Find the target member
var member = await db.ChatMembers var member = await db.ChatMembers
@@ -905,9 +856,113 @@ public class ChatRoomController(
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (member is null) return NotFound(); if (member is null) return NotFound();
// Check if the current user has sufficient permissions member.TimeoutCause = new ChatTimeoutCause
if (!await crs.IsMemberWithRole(chatRoom.Id, memberId, member.Role)) {
return StatusCode(403, "You cannot remove members with equal or higher roles."); Reason = request.Reason,
SenderId = operatorMember.Id,
Type = ChatTimeoutCauseType.ByModerator,
Since = now
};
member.TimeoutUntil = request.TimeoutUntil;
db.Update(member);
await db.SaveChangesAsync();
_ = crs.PurgeRoomMembersCache(roomId);
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "chatrooms.timeout",
Meta =
{
{ "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(roomId.ToString()) },
{ "account_id", Google.Protobuf.WellKnownTypes.Value.ForString(memberId.ToString()) }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent,
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
});
return NoContent();
}
[HttpDelete("{roomId:guid}/members/{memberId:guid}/timeout")]
[Authorize]
public async Task<ActionResult> RemoveChatMemberTimeout(Guid roomId, Guid memberId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var chatRoom = await db.ChatRooms
.Where(r => r.Id == roomId)
.FirstOrDefaultAsync();
if (chatRoom is null) return NotFound();
// Check if the chat room is owned by a realm
if (chatRoom.RealmId is not null)
{
if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, accountId, [RealmMemberRole.Moderator]))
return StatusCode(403, "You need at least be a realm moderator to remove members.");
}
else if (chatRoom.Type == ChatRoomType.DirectMessage && await crs.IsChatMember(chatRoom.Id, accountId))
return StatusCode(403, "You need be part of the DM to update the chat.");
else if (chatRoom.AccountId != accountId)
return StatusCode(403, "You need be the owner to update the chat.");
// Find the target member
var member = await db.ChatMembers
.Where(m => m.AccountId == memberId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
if (member is null) return NotFound();
member.TimeoutCause = null;
member.TimeoutUntil = null;
db.Update(member);
await db.SaveChangesAsync();
_ = crs.PurgeRoomMembersCache(roomId);
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "chatrooms.timeout.remove",
Meta =
{
{ "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(roomId.ToString()) },
{ "account_id", Google.Protobuf.WellKnownTypes.Value.ForString(memberId.ToString()) }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent,
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
});
return NoContent();
}
[HttpDelete("{roomId:guid}/members/{memberId:guid}")]
[Authorize]
public async Task<ActionResult> RemoveChatMember(Guid roomId, Guid memberId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var chatRoom = await db.ChatRooms
.Where(r => r.Id == roomId)
.FirstOrDefaultAsync();
if (chatRoom is null) return NotFound();
// Check if the chat room is owned by a realm
if (chatRoom.RealmId is not null)
{
if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, accountId, [RealmMemberRole.Moderator]))
return StatusCode(403, "You need at least be a realm moderator to remove members.");
}
else if (chatRoom.Type == ChatRoomType.DirectMessage && await crs.IsChatMember(chatRoom.Id, accountId))
return StatusCode(403, "You need be part of the DM to update the chat.");
else if (chatRoom.AccountId != accountId)
return StatusCode(403, "You need be the owner to update the chat.");
// Find the target member
var member = await db.ChatMembers
.Where(m => m.AccountId == memberId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
if (member is null) return NotFound();
member.LeaveAt = SystemClock.Instance.GetCurrentInstant(); member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -962,8 +1017,7 @@ public class ChatRoomController(
{ {
AccountId = Guid.Parse(currentUser.Id), AccountId = Guid.Parse(currentUser.Id),
ChatRoomId = roomId, ChatRoomId = roomId,
Role = ChatMemberRole.Member, JoinedAt = SystemClock.Instance.GetCurrentInstant()
JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow)
}; };
db.ChatMembers.Add(newMember); db.ChatMembers.Add(newMember);
@@ -987,6 +1041,12 @@ public class ChatRoomController(
public async Task<ActionResult> LeaveChat(Guid roomId) public async Task<ActionResult> LeaveChat(Guid roomId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var chat = await db.ChatRooms.FirstOrDefaultAsync(c => c.Id == roomId);
if (chat is null) return NotFound();
if (chat.AccountId == accountId)
return BadRequest("You cannot leave you own chat room");
var member = await db.ChatMembers var member = await db.ChatMembers
.Where(m => m.JoinedAt != null && m.LeaveAt == null) .Where(m => m.JoinedAt != null && m.LeaveAt == null)
@@ -995,20 +1055,7 @@ public class ChatRoomController(
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (member is null) return NotFound(); if (member is null) return NotFound();
if (member.Role == ChatMemberRole.Owner) member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
{
// Check if this is the only owner
var otherOwners = await db.ChatMembers
.Where(m => m.ChatRoomId == roomId)
.Where(m => m.Role == ChatMemberRole.Owner)
.Where(m => m.AccountId != Guid.Parse(currentUser.Id))
.AnyAsync();
if (!otherOwners)
return BadRequest("The last owner cannot leave the chat. Transfer ownership first or delete the chat.");
}
member.LeaveAt = Instant.FromDateTimeUtc(DateTime.UtcNow);
db.Update(member); db.Update(member);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await crs.PurgeRoomMembersCache(roomId); await crs.PurgeRoomMembersCache(roomId);
@@ -1054,4 +1101,4 @@ public class ChatRoomController(
} }
); );
} }
} }

View File

@@ -92,11 +92,10 @@ public class ChatRoomService(
.ToList(); .ToList();
if (directRoomsId.Count == 0) return rooms; if (directRoomsId.Count == 0) return rooms;
List<SnChatMember> members = directRoomsId.Count != 0 var members = directRoomsId.Count != 0
? await db.ChatMembers ? await db.ChatMembers
.Where(m => directRoomsId.Contains(m.ChatRoomId)) .Where(m => directRoomsId.Contains(m.ChatRoomId))
.Where(m => m.AccountId != userId) .Where(m => m.AccountId != userId)
// Ignored the joined at condition here to keep showing userinfo when other didn't accept the invite of DM
.ToListAsync() .ToListAsync()
: []; : [];
members = await LoadMemberAccounts(members); members = await LoadMemberAccounts(members);
@@ -122,7 +121,6 @@ public class ChatRoomService(
if (room.Type != ChatRoomType.DirectMessage) return room; if (room.Type != ChatRoomType.DirectMessage) return room;
var members = await db.ChatMembers var members = await db.ChatMembers
.Where(m => m.ChatRoomId == room.Id && m.AccountId != userId) .Where(m => m.ChatRoomId == room.Id && m.AccountId != userId)
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
.ToListAsync(); .ToListAsync();
if (members.Count <= 0) return room; if (members.Count <= 0) return room;
@@ -133,16 +131,11 @@ public class ChatRoomService(
return room; return room;
} }
public async Task<bool> IsMemberWithRole(Guid roomId, Guid accountId, params int[] requiredRoles) public async Task<bool> IsChatMember(Guid roomId, Guid accountId)
{ {
if (requiredRoles.Length == 0) return await db.ChatMembers
return false;
var maxRequiredRole = requiredRoles.Max();
var member = await db.ChatMembers
.Where(m => m.ChatRoomId == roomId && m.AccountId == accountId && m.JoinedAt != null && m.LeaveAt == null) .Where(m => m.ChatRoomId == roomId && m.AccountId == accountId && m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync(); .AnyAsync();
return member?.Role >= maxRequiredRole;
} }
public async Task<SnChatMember> LoadMemberAccount(SnChatMember member) public async Task<SnChatMember> LoadMemberAccount(SnChatMember member)

View File

@@ -580,7 +580,7 @@ public partial class ChatService(
{ {
var call = await GetCallOngoingAsync(roomId); var call = await GetCallOngoingAsync(roomId);
if (call is null) throw new InvalidOperationException("No ongoing call was not found."); if (call is null) throw new InvalidOperationException("No ongoing call was not found.");
if (sender.Role < ChatMemberRole.Moderator && call.SenderId != sender.Id) if (sender.AccountId != call.Room.AccountId && call.SenderId != sender.Id)
throw new InvalidOperationException("You are not the call initiator either the chat room moderator."); throw new InvalidOperationException("You are not the call initiator either the chat room moderator.");
// End the realtime session if it exists // End the realtime session if it exists
@@ -707,14 +707,7 @@ public partial class ChatService(
if (content is not null) if (content is not null)
message.Content = content; message.Content = content;
if (meta is not null) // Update do not override meta, replies to and forwarded to
message.Meta = meta;
if (repliedMessageId.HasValue)
message.RepliedMessageId = repliedMessageId;
if (forwardedMessageId.HasValue)
message.ForwardedMessageId = forwardedMessageId;
if (attachmentsId is not null) if (attachmentsId is not null)
await UpdateFileReferencesForMessageAsync(message, attachmentsId); await UpdateFileReferencesForMessageAsync(message, attachmentsId);

View File

@@ -4,6 +4,7 @@ using DysonNetwork.Sphere.Chat.Realtime;
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 Swashbuckle.AspNetCore.Annotations; using Swashbuckle.AspNetCore.Annotations;
namespace DysonNetwork.Sphere.Chat; namespace DysonNetwork.Sphere.Chat;
@@ -55,7 +56,7 @@ public class RealtimeCallController(
.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)
return StatusCode(403, "You need to be a member to view call status."); return StatusCode(403, "You need to be a member to view call status.");
var ongoingCall = await db.ChatRealtimeCall var ongoingCall = await db.ChatRealtimeCall
@@ -81,8 +82,11 @@ public class RealtimeCallController(
.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) var now = SystemClock.Instance.GetCurrentInstant();
if (member == null)
return StatusCode(403, "You need to be a member to join a call."); return StatusCode(403, "You need to be a member to join a call.");
if (member.TimeoutUntil.HasValue && member.TimeoutUntil.Value > now)
return StatusCode(403, "You has been timed out in this chat.");
// Get ongoing call // Get ongoing call
var ongoingCall = await cs.GetCallOngoingAsync(roomId); var ongoingCall = await cs.GetCallOngoingAsync(roomId);
@@ -93,7 +97,7 @@ public class RealtimeCallController(
if (string.IsNullOrEmpty(ongoingCall.SessionId)) if (string.IsNullOrEmpty(ongoingCall.SessionId))
return BadRequest("Call session is not properly configured."); return BadRequest("Call session is not properly configured.");
var isAdmin = member.Role >= ChatMemberRole.Moderator; var isAdmin = member.AccountId == ongoingCall.Room.AccountId || ongoingCall.Room.Type == ChatRoomType.DirectMessage;
var userToken = realtime.GetUserToken(currentUser, ongoingCall.SessionId, isAdmin); var userToken = realtime.GetUserToken(currentUser, ongoingCall.SessionId, isAdmin);
// Get LiveKit endpoint from configuration // Get LiveKit endpoint from configuration
@@ -150,12 +154,16 @@ public class RealtimeCallController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
var now = SystemClock.Instance.GetCurrentInstant();
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)
.Include(m => m.ChatRoom) .Include(m => m.ChatRoom)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Member) if (member == null)
return StatusCode(403, "You need to be a normal member to start a call."); return StatusCode(403, "You need to be a member to start a call.");
if (member.TimeoutUntil.HasValue && member.TimeoutUntil.Value > now)
return StatusCode(403, "You has been timed out in this chat.");
var ongoingCall = await cs.GetCallOngoingAsync(roomId); var ongoingCall = await cs.GetCallOngoingAsync(roomId);
if (ongoingCall is not null) return StatusCode(423, "There is already an ongoing call inside the chatroom."); if (ongoingCall is not null) return StatusCode(423, "There is already an ongoing call inside the chatroom.");
@@ -173,8 +181,8 @@ public class RealtimeCallController(
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)
return StatusCode(403, "You need to be a normal member to end a call."); return StatusCode(403, "You need to be a member to end a call.");
try try
{ {

View File

@@ -24,7 +24,7 @@ public class DiscoveryService(RemoteRealmService remoteRealmService)
// Since we don't have CreatedAt in the proto model, we'll just apply randomizer if requested // Since we don't have CreatedAt in the proto model, we'll just apply randomizer if requested
var orderedRealms = randomizer var orderedRealms = randomizer
? communityRealms.OrderBy(_ => Random.Shared.Next()) ? communityRealms.OrderBy(_ => Random.Shared.Next())
: communityRealms; : communityRealms.OrderByDescending(q => q.Members.Count());
return orderedRealms.Skip(offset).Take(take).ToList(); return orderedRealms.Skip(offset).Take(take).ToList();
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class AddAccountIdBackToChatRoom : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "account_id",
table: "chat_rooms",
type: "uuid",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "account_id",
table: "chat_rooms");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class SimplerChatRoom : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "is_bot",
table: "chat_members");
migrationBuilder.DropColumn(
name: "role",
table: "chat_members");
migrationBuilder.AddColumn<Guid>(
name: "invited_by_id",
table: "chat_members",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "ix_chat_members_invited_by_id",
table: "chat_members",
column: "invited_by_id");
migrationBuilder.AddForeignKey(
name: "fk_chat_members_chat_members_invited_by_id",
table: "chat_members",
column: "invited_by_id",
principalTable: "chat_members",
principalColumn: "id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_chat_members_chat_members_invited_by_id",
table: "chat_members");
migrationBuilder.DropIndex(
name: "ix_chat_members_invited_by_id",
table: "chat_members");
migrationBuilder.DropColumn(
name: "invited_by_id",
table: "chat_members");
migrationBuilder.AddColumn<bool>(
name: "is_bot",
table: "chat_members",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "role",
table: "chat_members",
type: "integer",
nullable: false,
defaultValue: 0);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class AddStickerPackIcon : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "image_id",
table: "stickers");
migrationBuilder.AlterColumn<SnCloudFileReferenceObject>(
name: "image",
table: "stickers",
type: "jsonb",
nullable: false,
oldClrType: typeof(SnCloudFileReferenceObject),
oldType: "jsonb",
oldNullable: true);
migrationBuilder.AddColumn<SnCloudFileReferenceObject>(
name: "icon",
table: "sticker_packs",
type: "jsonb",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "icon",
table: "sticker_packs");
migrationBuilder.AlterColumn<SnCloudFileReferenceObject>(
name: "image",
table: "stickers",
type: "jsonb",
nullable: true,
oldClrType: typeof(SnCloudFileReferenceObject),
oldType: "jsonb");
migrationBuilder.AddColumn<string>(
name: "image_id",
table: "stickers",
type: "character varying(32)",
maxLength: 32,
nullable: true);
}
}
}

View File

@@ -54,9 +54,9 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at"); .HasColumnName("deleted_at");
b.Property<bool>("IsBot") b.Property<Guid?>("InvitedById")
.HasColumnType("boolean") .HasColumnType("uuid")
.HasColumnName("is_bot"); .HasColumnName("invited_by_id");
b.Property<Instant?>("JoinedAt") b.Property<Instant?>("JoinedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
@@ -79,10 +79,6 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("integer") .HasColumnType("integer")
.HasColumnName("notify"); .HasColumnName("notify");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<ChatTimeoutCause>("TimeoutCause") b.Property<ChatTimeoutCause>("TimeoutCause")
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("timeout_cause"); .HasColumnName("timeout_cause");
@@ -101,6 +97,9 @@ namespace DysonNetwork.Sphere.Migrations
b.HasAlternateKey("ChatRoomId", "AccountId") b.HasAlternateKey("ChatRoomId", "AccountId")
.HasName("ak_chat_members_chat_room_id_account_id"); .HasName("ak_chat_members_chat_room_id_account_id");
b.HasIndex("InvitedById")
.HasDatabaseName("ix_chat_members_invited_by_id");
b.ToTable("chat_members", (string)null); b.ToTable("chat_members", (string)null);
}); });
@@ -247,6 +246,10 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("id"); .HasColumnName("id");
b.Property<Guid?>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<SnCloudFileReferenceObject>("Background") b.Property<SnCloudFileReferenceObject>("Background")
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("background"); .HasColumnName("background");
@@ -1144,14 +1147,10 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnName("deleted_at"); .HasColumnName("deleted_at");
b.Property<SnCloudFileReferenceObject>("Image") b.Property<SnCloudFileReferenceObject>("Image")
.IsRequired()
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("image"); .HasColumnName("image");
b.Property<string>("ImageId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("image_id");
b.Property<Guid>("PackId") b.Property<Guid>("PackId")
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("pack_id"); .HasColumnName("pack_id");
@@ -1199,6 +1198,10 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("character varying(4096)") .HasColumnType("character varying(4096)")
.HasColumnName("description"); .HasColumnName("description");
b.Property<SnCloudFileReferenceObject>("Icon")
.HasColumnType("jsonb")
.HasColumnName("icon");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(1024) .HasMaxLength(1024)
@@ -1501,7 +1504,14 @@ namespace DysonNetwork.Sphere.Migrations
.IsRequired() .IsRequired()
.HasConstraintName("fk_chat_members_chat_rooms_chat_room_id"); .HasConstraintName("fk_chat_members_chat_rooms_chat_room_id");
b.HasOne("DysonNetwork.Shared.Models.SnChatMember", "InvitedBy")
.WithMany()
.HasForeignKey("InvitedById")
.HasConstraintName("fk_chat_members_chat_members_invited_by_id");
b.Navigation("ChatRoom"); b.Navigation("ChatRoom");
b.Navigation("InvitedBy");
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMessage", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMessage", b =>

View File

@@ -0,0 +1,831 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere.Poll;
using DysonNetwork.Sphere.Wallet;
using DysonNetwork.Sphere.WebReader;
using Grpc.Core;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Swashbuckle.AspNetCore.Annotations;
using PostType = DysonNetwork.Shared.Models.PostType;
using PublisherMemberRole = DysonNetwork.Shared.Models.PublisherMemberRole;
using PublisherService = DysonNetwork.Sphere.Publisher.PublisherService;
namespace DysonNetwork.Sphere.Post;
[ApiController]
[Route("/api/posts")]
public class PostActionController(
AppDatabase db,
PostService ps,
PublisherService pub,
AccountService.AccountServiceClient accounts,
ActionLogService.ActionLogServiceClient als,
PaymentService.PaymentServiceClient payments,
PollService polls,
RemoteRealmService rs
) : ControllerBase
{
public class PostRequest
{
[MaxLength(1024)] public string? Title { get; set; }
[MaxLength(4096)] public string? Description { get; set; }
[MaxLength(1024)] public string? Slug { get; set; }
public string? Content { get; set; }
public Shared.Models.PostVisibility? Visibility { get; set; } =
Shared.Models.PostVisibility.Public;
public Shared.Models.PostType? Type { get; set; }
public Shared.Models.PostEmbedView? EmbedView { get; set; }
[MaxLength(16)] public List<string>? Tags { get; set; }
[MaxLength(8)] public List<string>? Categories { get; set; }
[MaxLength(32)] public List<string>? Attachments { get; set; }
public Dictionary<string, object>? Meta { get; set; }
public Instant? PublishedAt { get; set; }
public Guid? RepliedPostId { get; set; }
public Guid? ForwardedPostId { get; set; }
public Guid? RealmId { get; set; }
public Guid? PollId { get; set; }
public Guid? FundId { get; set; }
public string? ThumbnailId { get; set; }
}
[HttpPost]
[AskPermission("posts.create")]
public async Task<ActionResult<SnPost>> CreatePost(
[FromBody] PostRequest request,
[FromQuery(Name = "pub")] string? pubName
)
{
request.Content = TextSanitizer.Sanitize(request.Content);
if (string.IsNullOrWhiteSpace(request.Content) && request.Attachments is { Count: 0 })
return BadRequest("Content is required.");
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
if (!string.IsNullOrWhiteSpace(request.ThumbnailId) && request.Type != PostType.Article)
return BadRequest("Thumbnail only supported in article.");
if (!string.IsNullOrWhiteSpace(request.ThumbnailId) &&
!(request.Attachments?.Contains(request.ThumbnailId) ?? false))
return BadRequest("Thumbnail must be presented in attachment list.");
var accountId = Guid.Parse(currentUser.Id);
SnPublisher? publisher;
if (pubName is null)
{
// Use the first personal publisher
publisher = await db.Publishers.FirstOrDefaultAsync(e =>
e.AccountId == accountId && e.Type == Shared.Models.PublisherType.Individual
);
}
else
{
publisher = await pub.GetPublisherByName(pubName);
if (publisher is null)
return BadRequest("Publisher was not found.");
if (!await pub.IsMemberWithRole(publisher.Id, accountId, PublisherMemberRole.Editor))
return StatusCode(403, "You need at least be an editor to post as this publisher.");
}
if (publisher is null)
return BadRequest("Publisher was not found.");
var post = new SnPost
{
Title = request.Title,
Description = request.Description,
Slug = request.Slug,
Content = request.Content,
Visibility = request.Visibility ?? Shared.Models.PostVisibility.Public,
PublishedAt = request.PublishedAt,
Type = request.Type ?? Shared.Models.PostType.Moment,
Meta = request.Meta,
EmbedView = request.EmbedView,
Publisher = publisher,
};
if (request.RepliedPostId is not null)
{
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.");
post.RepliedPost = repliedPost;
post.RepliedPostId = repliedPost.Id;
}
if (request.ForwardedPostId is not null)
{
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.");
post.ForwardedPost = forwardedPost;
post.ForwardedPostId = forwardedPost.Id;
}
if (request.RealmId is not null)
{
var realm = await rs.GetRealm(request.RealmId.Value.ToString());
if (
!await rs.IsMemberWithRole(
realm.Id,
accountId,
new List<int> { RealmMemberRole.Normal }
)
)
return StatusCode(403, "You are not a member of this realm.");
post.RealmId = realm.Id;
}
if (request.PollId.HasValue)
{
try
{
var pollEmbed = await polls.MakePollEmbed(request.PollId.Value);
post.Meta ??= new Dictionary<string, object>();
if (
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|| existingEmbeds is not List<EmbeddableBase>
)
post.Meta["embeds"] = new List<Dictionary<string, object>>();
var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
embeds.Add(EmbeddableBase.ToDictionary(pollEmbed));
post.Meta["embeds"] = embeds;
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
if (request.FundId.HasValue)
{
try
{
var fundResponse = await payments.GetWalletFundAsync(new GetWalletFundRequest
{
FundId = request.FundId.Value.ToString()
});
// Check if the fund was created by the current user
if (fundResponse.CreatorAccountId != currentUser.Id)
return BadRequest("You can only share funds that you created.");
var fundEmbed = new FundEmbed { Id = request.FundId.Value };
post.Meta ??= new Dictionary<string, object>();
if (
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|| existingEmbeds is not List<EmbeddableBase>
)
post.Meta["embeds"] = new List<Dictionary<string, object>>();
var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
embeds.Add(EmbeddableBase.ToDictionary(fundEmbed));
post.Meta["embeds"] = embeds;
}
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
{
return BadRequest("The specified fund does not exist.");
}
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument)
{
return BadRequest("Invalid fund ID.");
}
}
if (request.ThumbnailId is not null)
{
post.Meta ??= new Dictionary<string, object>();
post.Meta["thumbnail"] = request.ThumbnailId;
}
try
{
post = await ps.PostAsync(
post,
attachments: request.Attachments,
tags: request.Tags,
categories: request.Categories
);
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
_ = als.CreateActionLogAsync(
new CreateActionLogRequest
{
Action = ActionLogType.PostCreate,
Meta =
{
{
"post_id",
Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString())
},
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent,
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
}
);
post.Publisher = publisher;
return post;
}
public class PostReactionRequest
{
[MaxLength(256)] public string Symbol { get; set; } = null!;
public Shared.Models.PostReactionAttitude Attitude { get; set; }
}
public static readonly List<string> ReactionsAllowedDefault =
[
"thumb_up",
"thumb_down",
"just_okay",
"cry",
"confuse",
"clap",
"laugh",
"angry",
"party",
"pray",
"heart",
];
[HttpPost("{id:guid}/reactions")]
[Authorize]
[AskPermission("posts.react")]
public async Task<ActionResult<SnPostReaction>> ReactPost(
Guid id,
[FromBody] PostReactionRequest request
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var friendsResponse = await accounts.ListFriendsAsync(
new ListRelationshipSimpleRequest { AccountId = currentUser.Id.ToString() }
);
var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
if (!ReactionsAllowedDefault.Contains(request.Symbol))
if (currentUser.PerkSubscription is null)
return BadRequest("You need subscription to send custom reactions");
var post = await db
.Posts.Where(e => e.Id == id)
.Include(e => e.Publisher)
.FilterWithVisibility(currentUser, userFriends, userPublishers)
.FirstOrDefaultAsync();
if (post is null)
return NotFound();
var accountId = Guid.Parse(currentUser.Id);
var isSelfReact =
post.Publisher.AccountId is not null && post.Publisher.AccountId == accountId;
var isExistingReaction = await db.PostReactions.AnyAsync(r =>
r.PostId == post.Id && r.Symbol == request.Symbol && r.AccountId == accountId
);
var reaction = new SnPostReaction
{
Symbol = request.Symbol,
Attitude = request.Attitude,
PostId = post.Id,
AccountId = accountId,
};
var isRemoving = await ps.ModifyPostVotes(
post,
reaction,
currentUser,
isExistingReaction,
isSelfReact
);
if (isRemoving)
return NoContent();
_ = als.CreateActionLogAsync(
new CreateActionLogRequest
{
Action = ActionLogType.PostReact,
Meta =
{
{
"post_id",
Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString())
},
{ "reaction", Google.Protobuf.WellKnownTypes.Value.ForString(request.Symbol) },
},
AccountId = currentUser.Id.ToString(),
UserAgent = Request.Headers.UserAgent,
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
}
);
return Ok(reaction);
}
public class PostAwardRequest
{
public decimal Amount { get; set; }
public Shared.Models.PostReactionAttitude Attitude { get; set; }
[MaxLength(4096)] public string? Message { get; set; }
}
[HttpGet("{id:guid}/awards")]
public async Task<ActionResult<SnPostAward>> GetPostAwards(
Guid id,
[FromQuery] int offset = 0,
[FromQuery] int take = 20
)
{
var queryable = db.PostAwards.Where(a => a.PostId == id).AsQueryable();
var totalCount = await queryable.CountAsync();
Response.Headers.Append("X-Total", totalCount.ToString());
var awards = await queryable.Take(take).Skip(offset).ToListAsync();
return Ok(awards);
}
public class PostAwardResponse
{
public Guid OrderId { get; set; }
}
[HttpPost("{id:guid}/awards")]
[Authorize]
public async Task<ActionResult<PostAwardResponse>> AwardPost(
Guid id,
[FromBody] PostAwardRequest request
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
if (request.Attitude == Shared.Models.PostReactionAttitude.Neutral)
return BadRequest("You cannot create a neutral post award");
var friendsResponse = await accounts.ListFriendsAsync(
new ListRelationshipSimpleRequest { AccountId = currentUser.Id.ToString() }
);
var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
var post = await db
.Posts.Where(e => e.Id == id)
.Include(e => e.Publisher)
.FilterWithVisibility(currentUser, userFriends, userPublishers)
.FirstOrDefaultAsync();
if (post is null)
return NotFound();
var accountId = Guid.Parse(currentUser.Id);
var orderRemark = string.IsNullOrWhiteSpace(post.Title)
? "from @" + post.Publisher.Name
: post.Title;
var order = await payments.CreateOrderAsync(
new CreateOrderRequest
{
ProductIdentifier = "posts.award",
Currency = "points", // NSP - Source Points
Remarks = $"Award post {orderRemark}",
Amount = request.Amount.ToString(CultureInfo.InvariantCulture),
Meta = GrpcTypeHelper.ConvertObjectToByteString(
new Dictionary<string, object?>
{
["account_id"] = accountId,
["post_id"] = post.Id,
["amount"] = request.Amount.ToString(CultureInfo.InvariantCulture),
["message"] = request.Message,
["attitude"] = request.Attitude,
}
),
}
);
return Ok(new PostAwardResponse() { OrderId = Guid.Parse(order.Id) });
}
public class PostPinRequest
{
[Required] public Shared.Models.PostPinMode Mode { get; set; }
}
[HttpPost("{id:guid}/pin")]
[Authorize]
public async Task<ActionResult<SnPost>> PinPost(Guid id, [FromBody] PostPinRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var post = await db
.Posts.Where(e => e.Id == id)
.Include(e => e.Publisher)
.Include(e => e.RepliedPost)
.FirstOrDefaultAsync();
if (post is null)
return NotFound();
var accountId = Guid.Parse(currentUser.Id);
if (!await pub.IsMemberWithRole(post.PublisherId, accountId, PublisherMemberRole.Editor))
return StatusCode(403, "You are not an editor of this publisher");
if (request.Mode == Shared.Models.PostPinMode.RealmPage && post.RealmId != null)
{
if (
!await rs.IsMemberWithRole(
post.RealmId.Value,
accountId,
new List<int> { RealmMemberRole.Moderator }
)
)
return StatusCode(403, "You are not a moderator of this realm");
}
try
{
await ps.PinPostAsync(post, currentUser, request.Mode);
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
_ = als.CreateActionLogAsync(
new CreateActionLogRequest
{
Action = ActionLogType.PostPin,
Meta =
{
{
"post_id",
Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString())
},
{
"mode",
Google.Protobuf.WellKnownTypes.Value.ForString(request.Mode.ToString())
},
},
AccountId = currentUser.Id.ToString(),
UserAgent = Request.Headers.UserAgent,
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
}
);
return Ok(post);
}
[HttpDelete("{id:guid}/pin")]
[Authorize]
public async Task<ActionResult<SnPost>> UnpinPost(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var post = await db
.Posts.Where(e => e.Id == id)
.Include(e => e.Publisher)
.Include(e => e.RepliedPost)
.FirstOrDefaultAsync();
if (post is null)
return NotFound();
var accountId = Guid.Parse(currentUser.Id);
if (!await pub.IsMemberWithRole(post.PublisherId, accountId, PublisherMemberRole.Editor))
return StatusCode(403, "You are not an editor of this publisher");
if (post is { PinMode: Shared.Models.PostPinMode.RealmPage, RealmId: not null })
{
if (
!await rs.IsMemberWithRole(
post.RealmId.Value,
accountId,
new List<int> { RealmMemberRole.Moderator }
)
)
return StatusCode(403, "You are not a moderator of this realm");
}
try
{
await ps.UnpinPostAsync(post, currentUser);
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
_ = als.CreateActionLogAsync(
new CreateActionLogRequest
{
Action = ActionLogType.PostUnpin,
Meta =
{
{
"post_id",
Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString())
},
},
AccountId = currentUser.Id.ToString(),
UserAgent = Request.Headers.UserAgent,
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
}
);
return Ok(post);
}
[HttpPatch("{id:guid}")]
public async Task<ActionResult<SnPost>> UpdatePost(
Guid id,
[FromBody] PostRequest request,
[FromQuery(Name = "pub")] string? pubName
)
{
request.Content = TextSanitizer.Sanitize(request.Content);
if (string.IsNullOrWhiteSpace(request.Content) && request.Attachments is { Count: 0 })
return BadRequest("Content is required.");
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
if (!string.IsNullOrWhiteSpace(request.ThumbnailId) && request.Type != PostType.Article)
return BadRequest("Thumbnail only supported in article.");
if (!string.IsNullOrWhiteSpace(request.ThumbnailId) &&
!(request.Attachments?.Contains(request.ThumbnailId) ?? false))
return BadRequest("Thumbnail must be presented in attachment list.");
var post = await db
.Posts.Where(e => e.Id == id)
.Include(e => e.Publisher)
.Include(e => e.Categories)
.Include(e => e.Tags)
.Include(e => e.FeaturedRecords)
.FirstOrDefaultAsync();
if (post is null)
return NotFound();
var accountId = Guid.Parse(currentUser.Id);
if (!await pub.IsMemberWithRole(post.Publisher.Id, accountId, PublisherMemberRole.Editor))
return StatusCode(403, "You need at least be an editor to edit this publisher's post.");
if (pubName is not null)
{
var publisher = await pub.GetPublisherByName(pubName);
if (publisher is null)
return NotFound();
if (!await pub.IsMemberWithRole(publisher.Id, accountId, PublisherMemberRole.Editor))
return StatusCode(
403,
"You need at least be an editor to transfer this post to this publisher."
);
post.PublisherId = publisher.Id;
post.Publisher = publisher;
}
if (request.Title is not null)
post.Title = request.Title;
if (request.Description is not null)
post.Description = request.Description;
if (request.Slug is not null)
post.Slug = request.Slug;
if (request.Content is not null)
post.Content = request.Content;
if (request.Visibility is not null)
post.Visibility = request.Visibility.Value;
if (request.Type is not null)
post.Type = request.Type.Value;
if (request.Meta is not null)
post.Meta = request.Meta;
// The same, this field can be null, so update it anyway.
post.EmbedView = request.EmbedView;
// All the fields are updated when the request contains the specific fields
// But the Poll can be null, so it will be updated whatever it included in requests or not
if (request.PollId.HasValue)
{
try
{
var pollEmbed = await polls.MakePollEmbed(request.PollId.Value);
post.Meta ??= new Dictionary<string, object>();
if (
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|| existingEmbeds is not List<EmbeddableBase>
)
post.Meta["embeds"] = new List<Dictionary<string, object>>();
var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
// Remove all old poll embeds
embeds.RemoveAll(e =>
e.TryGetValue("type", out var type) && type.ToString() == "poll"
);
embeds.Add(EmbeddableBase.ToDictionary(pollEmbed));
post.Meta["embeds"] = embeds;
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
else
{
post.Meta ??= new Dictionary<string, object>();
if (
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|| existingEmbeds is not List<EmbeddableBase>
)
post.Meta["embeds"] = new List<Dictionary<string, object>>();
var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
// Remove all old poll embeds
embeds.RemoveAll(e => e.TryGetValue("type", out var type) && type.ToString() == "poll");
}
// Handle fund embeds
if (request.FundId.HasValue)
{
try
{
var fundResponse = await payments.GetWalletFundAsync(new GetWalletFundRequest
{
FundId = request.FundId.Value.ToString()
});
// Check if the fund was created by the current user
if (fundResponse.CreatorAccountId != currentUser.Id)
return BadRequest("You can only share funds that you created.");
var fundEmbed = new FundEmbed { Id = request.FundId.Value };
post.Meta ??= new Dictionary<string, object>();
if (
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|| existingEmbeds is not List<EmbeddableBase>
)
post.Meta["embeds"] = new List<Dictionary<string, object>>();
var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
// Remove all old fund embeds
embeds.RemoveAll(e =>
e.TryGetValue("type", out var type) && type.ToString() == "fund"
);
embeds.Add(EmbeddableBase.ToDictionary(fundEmbed));
post.Meta["embeds"] = embeds;
}
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
{
return BadRequest("The specified fund does not exist.");
}
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument)
{
return BadRequest("Invalid fund ID.");
}
}
else
{
post.Meta ??= new Dictionary<string, object>();
if (
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|| existingEmbeds is not List<EmbeddableBase>
)
post.Meta["embeds"] = new List<Dictionary<string, object>>();
var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
// Remove all old fund embeds
embeds.RemoveAll(e => e.TryGetValue("type", out var type) && type.ToString() == "fund");
}
if (request.ThumbnailId is not null)
{
post.Meta ??= new Dictionary<string, object>();
post.Meta["thumbnail"] = request.ThumbnailId;
}
else
{
post.Meta ??= new Dictionary<string, object>();
post.Meta.Remove("thumbnail");
}
// The realm is the same as well as the poll
if (request.RealmId is not null)
{
var realm = await rs.GetRealm(request.RealmId.Value.ToString());
if (
!await rs.IsMemberWithRole(
realm.Id,
accountId,
new List<int> { RealmMemberRole.Normal }
)
)
return StatusCode(403, "You are not a member of this realm.");
post.RealmId = realm.Id;
}
else
{
post.RealmId = null;
}
try
{
post = await ps.UpdatePostAsync(
post,
attachments: request.Attachments,
tags: request.Tags,
categories: request.Categories,
publishedAt: request.PublishedAt
);
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
_ = als.CreateActionLogAsync(
new CreateActionLogRequest
{
Action = ActionLogType.PostUpdate,
Meta =
{
{
"post_id",
Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString())
},
},
AccountId = currentUser.Id.ToString(),
UserAgent = Request.Headers.UserAgent,
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
}
);
return Ok(post);
}
[HttpDelete("{id:guid}")]
public async Task<ActionResult<SnPost>> DeletePost(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var post = await db
.Posts.Where(e => e.Id == id)
.Include(e => e.Publisher)
.FirstOrDefaultAsync();
if (post is null)
return NotFound();
if (
!await pub.IsMemberWithRole(
post.Publisher.Id,
Guid.Parse(currentUser.Id),
PublisherMemberRole.Editor
)
)
return StatusCode(
403,
"You need at least be an editor to delete the publisher's post."
);
await ps.DeletePostAsync(post);
_ = als.CreateActionLogAsync(
new CreateActionLogRequest
{
Action = ActionLogType.PostDelete,
Meta =
{
{
"post_id",
Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString())
},
},
AccountId = currentUser.Id.ToString(),
UserAgent = Request.Headers.UserAgent,
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
}
);
return NoContent();
}
}

View File

@@ -27,9 +27,6 @@ public class PostController(
PublisherService pub, PublisherService pub,
RemoteAccountService remoteAccountsHelper, RemoteAccountService remoteAccountsHelper,
AccountService.AccountServiceClient accounts, AccountService.AccountServiceClient accounts,
ActionLogService.ActionLogServiceClient als,
PaymentService.PaymentServiceClient payments,
PollService polls,
RemoteRealmService rs RemoteRealmService rs
) : ControllerBase ) : ControllerBase
{ {
@@ -43,27 +40,6 @@ public class PostController(
return Ok(posts); return Ok(posts);
} }
/// <summary>
/// Retrieves a paginated list of posts with optional filtering and sorting.
/// </summary>
/// <param name="includeReplies">Whether to include reply posts in the results. If false, only root posts are returned.</param>
/// <param name="offset">The number of posts to skip for pagination.</param>
/// <param name="take">The maximum number of posts to return (default: 20).</param>
/// <param name="pubName">Filter posts by publisher name.</param>
/// <param name="realmName">Filter posts by realm slug.</param>
/// <param name="type">Filter posts by post type (as integer).</param>
/// <param name="categories">Filter posts by category slugs.</param>
/// <param name="tags">Filter posts by tag slugs.</param>
/// <param name="queryTerm">Search term to filter posts by title, description, or content.</param>
/// <param name="queryVector">If true, uses vector search with the query term. If false, performs a simple ILIKE search.</param>
/// <param name="onlyMedia">If true, only returns posts that have attachments.</param>
/// <param name="shuffle">If true, returns posts in random order. If false, orders by published/created date (newest first).</param>
/// <param name="pinned">If true, returns posts that pinned. If false, returns posts that are not pinned. If null, returns all posts.</param>
/// <returns>
/// Returns an ActionResult containing a list of Post objects that match the specified criteria.
/// Includes an X-Total header with the total count of matching posts before pagination.
/// </returns>
/// <response code="200">Returns the list of posts matching the criteria.</response>
[HttpGet] [HttpGet]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<SnPost>))] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<SnPost>))]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
@@ -493,771 +469,4 @@ public class PostController(
return Ok(posts); return Ok(posts);
} }
public class PostRequest
{
[MaxLength(1024)] public string? Title { get; set; }
[MaxLength(4096)] public string? Description { get; set; }
[MaxLength(1024)] public string? Slug { get; set; }
public string? Content { get; set; }
public Shared.Models.PostVisibility? Visibility { get; set; } =
Shared.Models.PostVisibility.Public;
public Shared.Models.PostType? Type { get; set; }
public Shared.Models.PostEmbedView? EmbedView { get; set; }
[MaxLength(16)] public List<string>? Tags { get; set; }
[MaxLength(8)] public List<string>? Categories { get; set; }
[MaxLength(32)] public List<string>? Attachments { get; set; }
public Dictionary<string, object>? Meta { get; set; }
public Instant? PublishedAt { get; set; }
public Guid? RepliedPostId { get; set; }
public Guid? ForwardedPostId { get; set; }
public Guid? RealmId { get; set; }
public Guid? PollId { get; set; }
public Guid? FundId { get; set; }
}
[HttpPost]
[RequiredPermission("global", "posts.create")]
public async Task<ActionResult<SnPost>> CreatePost(
[FromBody] PostRequest request,
[FromQuery(Name = "pub")] string? pubName
)
{
request.Content = TextSanitizer.Sanitize(request.Content);
if (string.IsNullOrWhiteSpace(request.Content) && request.Attachments is { Count: 0 })
return BadRequest("Content is required.");
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
SnPublisher? publisher;
if (pubName is null)
{
// Use the first personal publisher
publisher = await db.Publishers.FirstOrDefaultAsync(e =>
e.AccountId == accountId && e.Type == Shared.Models.PublisherType.Individual
);
}
else
{
publisher = await pub.GetPublisherByName(pubName);
if (publisher is null)
return BadRequest("Publisher was not found.");
if (!await pub.IsMemberWithRole(publisher.Id, accountId, PublisherMemberRole.Editor))
return StatusCode(403, "You need at least be an editor to post as this publisher.");
}
if (publisher is null)
return BadRequest("Publisher was not found.");
var post = new SnPost
{
Title = request.Title,
Description = request.Description,
Slug = request.Slug,
Content = request.Content,
Visibility = request.Visibility ?? Shared.Models.PostVisibility.Public,
PublishedAt = request.PublishedAt,
Type = request.Type ?? Shared.Models.PostType.Moment,
Meta = request.Meta,
EmbedView = request.EmbedView,
Publisher = publisher,
};
if (request.RepliedPostId is not null)
{
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.");
post.RepliedPost = repliedPost;
post.RepliedPostId = repliedPost.Id;
}
if (request.ForwardedPostId is not null)
{
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.");
post.ForwardedPost = forwardedPost;
post.ForwardedPostId = forwardedPost.Id;
}
if (request.RealmId is not null)
{
var realm = await rs.GetRealm(request.RealmId.Value.ToString());
if (
!await rs.IsMemberWithRole(
realm.Id,
accountId,
new List<int> { RealmMemberRole.Normal }
)
)
return StatusCode(403, "You are not a member of this realm.");
post.RealmId = realm.Id;
}
if (request.PollId.HasValue)
{
try
{
var pollEmbed = await polls.MakePollEmbed(request.PollId.Value);
post.Meta ??= new Dictionary<string, object>();
if (
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|| existingEmbeds is not List<EmbeddableBase>
)
post.Meta["embeds"] = new List<Dictionary<string, object>>();
var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
embeds.Add(EmbeddableBase.ToDictionary(pollEmbed));
post.Meta["embeds"] = embeds;
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
if (request.FundId.HasValue)
{
try
{
var fundResponse = await payments.GetWalletFundAsync(new GetWalletFundRequest
{
FundId = request.FundId.Value.ToString()
});
// Check if the fund was created by the current user
if (fundResponse.CreatorAccountId != currentUser.Id)
return BadRequest("You can only share funds that you created.");
var fundEmbed = new FundEmbed { Id = request.FundId.Value };
post.Meta ??= new Dictionary<string, object>();
if (
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|| existingEmbeds is not List<EmbeddableBase>
)
post.Meta["embeds"] = new List<Dictionary<string, object>>();
var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
embeds.Add(EmbeddableBase.ToDictionary(fundEmbed));
post.Meta["embeds"] = embeds;
}
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
{
return BadRequest("The specified fund does not exist.");
}
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument)
{
return BadRequest("Invalid fund ID.");
}
}
try
{
post = await ps.PostAsync(
post,
attachments: request.Attachments,
tags: request.Tags,
categories: request.Categories
);
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
_ = als.CreateActionLogAsync(
new CreateActionLogRequest
{
Action = ActionLogType.PostCreate,
Meta =
{
{
"post_id",
Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString())
},
},
AccountId = currentUser.Id.ToString(),
UserAgent = Request.Headers.UserAgent,
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
}
);
post.Publisher = publisher;
return post;
}
public class PostReactionRequest
{
[MaxLength(256)] public string Symbol { get; set; } = null!;
public Shared.Models.PostReactionAttitude Attitude { get; set; }
}
public static readonly List<string> ReactionsAllowedDefault =
[
"thumb_up",
"thumb_down",
"just_okay",
"cry",
"confuse",
"clap",
"laugh",
"angry",
"party",
"pray",
"heart",
];
[HttpPost("{id:guid}/reactions")]
[Authorize]
[RequiredPermission("global", "posts.react")]
public async Task<ActionResult<SnPostReaction>> ReactPost(
Guid id,
[FromBody] PostReactionRequest request
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var friendsResponse = await accounts.ListFriendsAsync(
new ListRelationshipSimpleRequest { AccountId = currentUser.Id.ToString() }
);
var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
if (!ReactionsAllowedDefault.Contains(request.Symbol))
if (currentUser.PerkSubscription is null)
return BadRequest("You need subscription to send custom reactions");
var post = await db
.Posts.Where(e => e.Id == id)
.Include(e => e.Publisher)
.FilterWithVisibility(currentUser, userFriends, userPublishers)
.FirstOrDefaultAsync();
if (post is null)
return NotFound();
var accountId = Guid.Parse(currentUser.Id);
var isSelfReact =
post.Publisher.AccountId is not null && post.Publisher.AccountId == accountId;
var isExistingReaction = await db.PostReactions.AnyAsync(r =>
r.PostId == post.Id && r.Symbol == request.Symbol && r.AccountId == accountId
);
var reaction = new SnPostReaction
{
Symbol = request.Symbol,
Attitude = request.Attitude,
PostId = post.Id,
AccountId = accountId,
};
var isRemoving = await ps.ModifyPostVotes(
post,
reaction,
currentUser,
isExistingReaction,
isSelfReact
);
if (isRemoving)
return NoContent();
_ = als.CreateActionLogAsync(
new CreateActionLogRequest
{
Action = ActionLogType.PostReact,
Meta =
{
{
"post_id",
Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString())
},
{ "reaction", Google.Protobuf.WellKnownTypes.Value.ForString(request.Symbol) },
},
AccountId = currentUser.Id.ToString(),
UserAgent = Request.Headers.UserAgent,
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
}
);
return Ok(reaction);
}
public class PostAwardRequest
{
public decimal Amount { get; set; }
public Shared.Models.PostReactionAttitude Attitude { get; set; }
[MaxLength(4096)] public string? Message { get; set; }
}
[HttpGet("{id:guid}/awards")]
public async Task<ActionResult<SnPostAward>> GetPostAwards(
Guid id,
[FromQuery] int offset = 0,
[FromQuery] int take = 20
)
{
var queryable = db.PostAwards.Where(a => a.PostId == id).AsQueryable();
var totalCount = await queryable.CountAsync();
Response.Headers.Append("X-Total", totalCount.ToString());
var awards = await queryable.Take(take).Skip(offset).ToListAsync();
return Ok(awards);
}
public class PostAwardResponse
{
public Guid OrderId { get; set; }
}
[HttpPost("{id:guid}/awards")]
[Authorize]
public async Task<ActionResult<PostAwardResponse>> AwardPost(
Guid id,
[FromBody] PostAwardRequest request
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
if (request.Attitude == Shared.Models.PostReactionAttitude.Neutral)
return BadRequest("You cannot create a neutral post award");
var friendsResponse = await accounts.ListFriendsAsync(
new ListRelationshipSimpleRequest { AccountId = currentUser.Id.ToString() }
);
var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
var post = await db
.Posts.Where(e => e.Id == id)
.Include(e => e.Publisher)
.FilterWithVisibility(currentUser, userFriends, userPublishers)
.FirstOrDefaultAsync();
if (post is null)
return NotFound();
var accountId = Guid.Parse(currentUser.Id);
var orderRemark = string.IsNullOrWhiteSpace(post.Title)
? "from @" + post.Publisher.Name
: post.Title;
var order = await payments.CreateOrderAsync(
new CreateOrderRequest
{
ProductIdentifier = "posts.award",
Currency = "points", // NSP - Source Points
Remarks = $"Award post {orderRemark}",
Amount = request.Amount.ToString(CultureInfo.InvariantCulture),
Meta = GrpcTypeHelper.ConvertObjectToByteString(
new Dictionary<string, object?>
{
["account_id"] = accountId,
["post_id"] = post.Id,
["amount"] = request.Amount.ToString(CultureInfo.InvariantCulture),
["message"] = request.Message,
["attitude"] = request.Attitude,
}
),
}
);
return Ok(new PostAwardResponse() { OrderId = Guid.Parse(order.Id) });
}
public class PostPinRequest
{
[Required] public Shared.Models.PostPinMode Mode { get; set; }
}
[HttpPost("{id:guid}/pin")]
[Authorize]
public async Task<ActionResult<SnPost>> PinPost(Guid id, [FromBody] PostPinRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var post = await db
.Posts.Where(e => e.Id == id)
.Include(e => e.Publisher)
.Include(e => e.RepliedPost)
.FirstOrDefaultAsync();
if (post is null)
return NotFound();
var accountId = Guid.Parse(currentUser.Id);
if (!await pub.IsMemberWithRole(post.PublisherId, accountId, PublisherMemberRole.Editor))
return StatusCode(403, "You are not an editor of this publisher");
if (request.Mode == Shared.Models.PostPinMode.RealmPage && post.RealmId != null)
{
if (
!await rs.IsMemberWithRole(
post.RealmId.Value,
accountId,
new List<int> { RealmMemberRole.Moderator }
)
)
return StatusCode(403, "You are not a moderator of this realm");
}
try
{
await ps.PinPostAsync(post, currentUser, request.Mode);
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
_ = als.CreateActionLogAsync(
new CreateActionLogRequest
{
Action = ActionLogType.PostPin,
Meta =
{
{
"post_id",
Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString())
},
{
"mode",
Google.Protobuf.WellKnownTypes.Value.ForString(request.Mode.ToString())
},
},
AccountId = currentUser.Id.ToString(),
UserAgent = Request.Headers.UserAgent,
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
}
);
return Ok(post);
}
[HttpDelete("{id:guid}/pin")]
[Authorize]
public async Task<ActionResult<SnPost>> UnpinPost(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var post = await db
.Posts.Where(e => e.Id == id)
.Include(e => e.Publisher)
.Include(e => e.RepliedPost)
.FirstOrDefaultAsync();
if (post is null)
return NotFound();
var accountId = Guid.Parse(currentUser.Id);
if (!await pub.IsMemberWithRole(post.PublisherId, accountId, PublisherMemberRole.Editor))
return StatusCode(403, "You are not an editor of this publisher");
if (post is { PinMode: Shared.Models.PostPinMode.RealmPage, RealmId: not null })
{
if (
!await rs.IsMemberWithRole(
post.RealmId.Value,
accountId,
new List<int> { RealmMemberRole.Moderator }
)
)
return StatusCode(403, "You are not a moderator of this realm");
}
try
{
await ps.UnpinPostAsync(post, currentUser);
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
_ = als.CreateActionLogAsync(
new CreateActionLogRequest
{
Action = ActionLogType.PostUnpin,
Meta =
{
{
"post_id",
Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString())
},
},
AccountId = currentUser.Id.ToString(),
UserAgent = Request.Headers.UserAgent,
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
}
);
return Ok(post);
}
[HttpPatch("{id:guid}")]
public async Task<ActionResult<SnPost>> UpdatePost(
Guid id,
[FromBody] PostRequest request,
[FromQuery(Name = "pub")] string? pubName
)
{
request.Content = TextSanitizer.Sanitize(request.Content);
if (string.IsNullOrWhiteSpace(request.Content) && request.Attachments is { Count: 0 })
return BadRequest("Content is required.");
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var post = await db
.Posts.Where(e => e.Id == id)
.Include(e => e.Publisher)
.Include(e => e.Categories)
.Include(e => e.Tags)
.Include(e => e.FeaturedRecords)
.FirstOrDefaultAsync();
if (post is null)
return NotFound();
var accountId = Guid.Parse(currentUser.Id);
if (!await pub.IsMemberWithRole(post.Publisher.Id, accountId, PublisherMemberRole.Editor))
return StatusCode(403, "You need at least be an editor to edit this publisher's post.");
if (pubName is not null)
{
var publisher = await pub.GetPublisherByName(pubName);
if (publisher is null)
return NotFound();
if (!await pub.IsMemberWithRole(publisher.Id, accountId, PublisherMemberRole.Editor))
return StatusCode(
403,
"You need at least be an editor to transfer this post to this publisher."
);
post.PublisherId = publisher.Id;
post.Publisher = publisher;
}
if (request.Title is not null)
post.Title = request.Title;
if (request.Description is not null)
post.Description = request.Description;
if (request.Slug is not null)
post.Slug = request.Slug;
if (request.Content is not null)
post.Content = request.Content;
if (request.Visibility is not null)
post.Visibility = request.Visibility.Value;
if (request.Type is not null)
post.Type = request.Type.Value;
if (request.Meta is not null)
post.Meta = request.Meta;
// The same, this field can be null, so update it anyway.
post.EmbedView = request.EmbedView;
// All the fields are updated when the request contains the specific fields
// But the Poll can be null, so it will be updated whatever it included in requests or not
if (request.PollId.HasValue)
{
try
{
var pollEmbed = await polls.MakePollEmbed(request.PollId.Value);
post.Meta ??= new Dictionary<string, object>();
if (
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|| existingEmbeds is not List<EmbeddableBase>
)
post.Meta["embeds"] = new List<Dictionary<string, object>>();
var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
// Remove all old poll embeds
embeds.RemoveAll(e =>
e.TryGetValue("type", out var type) && type.ToString() == "poll"
);
embeds.Add(EmbeddableBase.ToDictionary(pollEmbed));
post.Meta["embeds"] = embeds;
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
else
{
post.Meta ??= new Dictionary<string, object>();
if (
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|| existingEmbeds is not List<EmbeddableBase>
)
post.Meta["embeds"] = new List<Dictionary<string, object>>();
var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
// Remove all old poll embeds
embeds.RemoveAll(e => e.TryGetValue("type", out var type) && type.ToString() == "poll");
}
// Handle fund embeds
if (request.FundId.HasValue)
{
try
{
var fundResponse = await payments.GetWalletFundAsync(new GetWalletFundRequest
{
FundId = request.FundId.Value.ToString()
});
// Check if the fund was created by the current user
if (fundResponse.CreatorAccountId != currentUser.Id)
return BadRequest("You can only share funds that you created.");
var fundEmbed = new FundEmbed { Id = request.FundId.Value };
post.Meta ??= new Dictionary<string, object>();
if (
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|| existingEmbeds is not List<EmbeddableBase>
)
post.Meta["embeds"] = new List<Dictionary<string, object>>();
var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
// Remove all old fund embeds
embeds.RemoveAll(e =>
e.TryGetValue("type", out var type) && type.ToString() == "fund"
);
embeds.Add(EmbeddableBase.ToDictionary(fundEmbed));
post.Meta["embeds"] = embeds;
}
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
{
return BadRequest("The specified fund does not exist.");
}
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument)
{
return BadRequest("Invalid fund ID.");
}
}
else
{
post.Meta ??= new Dictionary<string, object>();
if (
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|| existingEmbeds is not List<EmbeddableBase>
)
post.Meta["embeds"] = new List<Dictionary<string, object>>();
var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
// Remove all old fund embeds
embeds.RemoveAll(e => e.TryGetValue("type", out var type) && type.ToString() == "fund");
}
// The realm is the same as well as the poll
if (request.RealmId is not null)
{
var realm = await rs.GetRealm(request.RealmId.Value.ToString());
if (
!await rs.IsMemberWithRole(
realm.Id,
accountId,
new List<int> { RealmMemberRole.Normal }
)
)
return StatusCode(403, "You are not a member of this realm.");
post.RealmId = realm.Id;
}
else
{
post.RealmId = null;
}
try
{
post = await ps.UpdatePostAsync(
post,
attachments: request.Attachments,
tags: request.Tags,
categories: request.Categories,
publishedAt: request.PublishedAt
);
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
_ = als.CreateActionLogAsync(
new CreateActionLogRequest
{
Action = ActionLogType.PostUpdate,
Meta =
{
{
"post_id",
Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString())
},
},
AccountId = currentUser.Id.ToString(),
UserAgent = Request.Headers.UserAgent,
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
}
);
return Ok(post);
}
[HttpDelete("{id:guid}")]
public async Task<ActionResult<SnPost>> DeletePost(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var post = await db
.Posts.Where(e => e.Id == id)
.Include(e => e.Publisher)
.FirstOrDefaultAsync();
if (post is null)
return NotFound();
if (
!await pub.IsMemberWithRole(
post.Publisher.Id,
Guid.Parse(currentUser.Id),
PublisherMemberRole.Editor
)
)
return StatusCode(
403,
"You need at least be an editor to delete the publisher's post."
);
await ps.DeletePostAsync(post);
_ = als.CreateActionLogAsync(
new CreateActionLogRequest
{
Action = ActionLogType.PostDelete,
Meta =
{
{
"post_id",
Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString())
},
},
AccountId = currentUser.Id.ToString(),
UserAgent = Request.Headers.UserAgent,
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
}
);
return NoContent();
}
} }

Some files were not shown because too many files have changed in this diff Show More