42 Commits

Author SHA1 Message Date
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
a46de4662c 🐛 Fix gateway 2025-11-30 17:51:27 +08:00
fdd14b860e 🐛 Fix wrong required status of validate account create request 2025-11-30 17:37:34 +08:00
cb62df81e2 👔 Adjust lookup account logic 2025-11-30 17:20:20 +08:00
46717e39a7 Admin delete account endpoint 2025-11-30 17:19:33 +08:00
344ed6e348 Account validation endpoint 2025-11-30 17:16:11 +08:00
a8b62fb0eb Auth via authorized device 2025-11-30 00:00:13 +08:00
00b3087d6a ♻️ Refactored auth service for better security 2025-11-29 18:00:23 +08:00
119 changed files with 24341 additions and 2409 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
{ {
@@ -34,7 +37,7 @@ public class AccountController(
.Include(e => e.Badges) .Include(e => e.Badges)
.Include(e => e.Profile) .Include(e => e.Profile)
.Include(e => e.Contacts.Where(c => c.IsPublic)) .Include(e => e.Contacts.Where(c => c.IsPublic))
.Where(a => a.Name == name) .Where(a => EF.Functions.Like(a.Name, name))
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (account is null) return NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier)); if (account is null) return NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier));
@@ -103,6 +106,52 @@ 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
{
[MinLength(2)]
[MaxLength(256)]
[RegularExpression(@"^[A-Za-z0-9_-]+$",
ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.")
]
public string? Name { get; set; }
[EmailAddress]
[RegularExpression(@"^[^+]+@[^@]+\.[^@]+$", ErrorMessage = "Email address cannot contain '+' symbol.")]
[MaxLength(1024)]
public string? Email { get; set; }
public string? AffiliationSpell { get; set; }
}
[HttpPost("validate")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<string>> ValidateCreateAccountRequest(
[FromBody] AccountCreateValidateRequest request)
{
if (request.Name is not null)
{
if (await accounts.CheckAccountNameHasTaken(request.Name))
return BadRequest("Account name has already been taken.");
}
if (request.Email is not null)
{
if (await accounts.CheckEmailHasBeenUsed(request.Email))
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.");
} }
[HttpPost] [HttpPost]
@@ -271,10 +320,21 @@ 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();
return Ok(); return Ok();
} }
}
[HttpDelete("{name}")]
[Authorize]
[AskPermission("accounts.deletion")]
public async Task<IActionResult> AdminDeleteAccount(string name)
{
var account = await accounts.LookupAccount(name);
if (account is null) return NotFound();
await accounts.DeleteAccount(account);
return Ok();
}
}

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.Id).ToList(); var clientIds = sessionDevices.Select(x => x.Id).ToList();
var authChallenges = await db.AuthChallenges var authSessions = await db.AuthSessions
.Where(c => c.ClientId != null && deviceIds.Contains(c.ClientId.Value)) .Where(c => c.ClientId != null && clientIds.Contains(c.ClientId.Value))
.GroupBy(c => c.ClientId) .GroupBy(c => c.ClientId!.Value)
.ToDictionaryAsync(c => c.Key!.Value, c => c.ToList()); .ToDictionaryAsync(c => c.Key, c => c.ToList());
foreach (var challengeDevice in challengeDevices) foreach (var dev in sessionDevices)
if (authChallenges.TryGetValue(challengeDevice.Id, 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();
@@ -688,7 +711,7 @@ public class AccountCurrentController(
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();
var device = await db.AuthClients.FirstOrDefaultAsync(d => d.Id == currentSession.Challenge.ClientId); var device = await db.AuthClients.FirstOrDefaultAsync(d => d.Id == currentSession.ClientId);
if (device is null) return NotFound(); if (device is null) return NotFound();
try try

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,
@@ -54,11 +56,13 @@ public class AccountService(
public async Task<SnAccount?> LookupAccount(string probe) public async Task<SnAccount?> LookupAccount(string probe)
{ {
var account = await db.Accounts.Where(a => a.Name == probe).FirstOrDefaultAsync(); var account = await db.Accounts.Where(a => EF.Functions.ILike(a.Name, probe)).FirstOrDefaultAsync();
if (account is not null) return account; if (account is not null) return account;
var contact = await db.AccountContacts var contact = await db.AccountContacts
.Where(c => c.Content == probe) .Where(c => c.Type == Shared.Models.AccountContactType.Email ||
c.Type == Shared.Models.AccountContactType.PhoneNumber)
.Where(c => EF.Functions.ILike(c.Content, probe))
.Include(c => c.Account) .Include(c => c.Account)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
return contact?.Account; return contact?.Account;
@@ -81,6 +85,17 @@ public class AccountService(
return profile?.Level; return profile?.Level;
} }
public async Task<bool> CheckAccountNameHasTaken(string name)
{
return await db.Accounts.AnyAsync(a => EF.Functions.ILike(a.Name, name));
}
public async Task<bool> CheckEmailHasBeenUsed(string email)
{
return await db.AccountContacts.AnyAsync(c =>
c.Type == Shared.Models.AccountContactType.Email && EF.Functions.ILike(c.Content, email));
}
public async Task<SnAccount> CreateAccount( public async Task<SnAccount> CreateAccount(
string name, string name,
string nick, string nick,
@@ -88,12 +103,12 @@ 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
) )
{ {
var dupeNameCount = await db.Accounts.Where(a => a.Name == name).CountAsync(); if (await CheckAccountNameHasTaken(name))
if (dupeNameCount > 0)
throw new InvalidOperationException("Account name has already been taken."); throw new InvalidOperationException("Account name has already been taken.");
var dupeEmailCount = await db.AccountContacts var dupeEmailCount = await db.AccountContacts
@@ -101,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,
@@ -110,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,
@@ -132,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();
@@ -140,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
}); });
} }
@@ -181,10 +199,7 @@ public class AccountService(
displayName, displayName,
userInfo.Email, userInfo.Email,
null, null,
"en-US", isEmailVerified: userInfo.EmailVerified
"en",
userInfo.EmailVerified,
userInfo.EmailVerified
); );
} }
@@ -274,7 +289,8 @@ public class AccountService(
return isExists; return isExists;
} }
public async Task<SnAccountAuthFactor?> CreateAuthFactor(SnAccount account, Shared.Models.AccountAuthFactorType type, string? secret) public async Task<SnAccountAuthFactor?> CreateAuthFactor(SnAccount account,
Shared.Models.AccountAuthFactorType type, string? secret)
{ {
SnAccountAuthFactor? factor = null; SnAccountAuthFactor? factor = null;
switch (type) switch (type)
@@ -352,7 +368,8 @@ public class AccountService(
public async Task<SnAccountAuthFactor> EnableAuthFactor(SnAccountAuthFactor factor, string? code) public async Task<SnAccountAuthFactor> EnableAuthFactor(SnAccountAuthFactor factor, string? code)
{ {
if (factor.EnabledAt is not null) throw new ArgumentException("The factor has been enabled."); if (factor.EnabledAt is not null) throw new ArgumentException("The factor has been enabled.");
if (factor.Type is Shared.Models.AccountAuthFactorType.Password or Shared.Models.AccountAuthFactorType.TimedCode) if (factor.Type is Shared.Models.AccountAuthFactorType.Password
or Shared.Models.AccountAuthFactorType.TimedCode)
{ {
if (code is null || !factor.VerifyPassword(code)) if (code is null || !factor.VerifyPassword(code))
throw new InvalidOperationException( throw new InvalidOperationException(
@@ -508,9 +525,7 @@ public class AccountService(
private async Task<bool> IsDeviceActive(Guid id) private async Task<bool> IsDeviceActive(Guid id)
{ {
return await db.AuthSessions return await db.AuthSessions.AnyAsync(s => s.ClientId == id);
.Include(s => s.Challenge)
.AnyAsync(s => s.Challenge.ClientId == id);
} }
public async Task<SnAuthClient> UpdateDeviceName(SnAccount account, string deviceId, string label) public async Task<SnAuthClient> UpdateDeviceName(SnAccount account, string deviceId, string label)
@@ -529,8 +544,7 @@ public class AccountService(
public async Task DeleteSession(SnAccount account, Guid sessionId) public async Task DeleteSession(SnAccount account, Guid sessionId)
{ {
var session = await db.AuthSessions var session = await db.AuthSessions
.Include(s => s.Challenge) .Include(s => s.Client)
.ThenInclude(s => s.Client)
.Where(s => s.Id == sessionId && s.AccountId == account.Id) .Where(s => s.Id == sessionId && s.AccountId == account.Id)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (session is null) throw new InvalidOperationException("Session was not found."); if (session is null) throw new InvalidOperationException("Session was not found.");
@@ -539,11 +553,11 @@ public class AccountService(
db.AuthSessions.Remove(session); db.AuthSessions.Remove(session);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
if (session.Challenge.ClientId.HasValue) if (session.ClientId.HasValue)
{ {
if (!await IsDeviceActive(session.Challenge.ClientId.Value)) if (!await IsDeviceActive(session.ClientId.Value))
await pusher.UnsubscribePushNotificationsAsync(new UnsubscribePushNotificationsRequest() await pusher.UnsubscribePushNotificationsAsync(new UnsubscribePushNotificationsRequest()
{ DeviceId = session.Challenge.Client!.DeviceId } { DeviceId = session.Client!.DeviceId }
); );
} }
@@ -564,15 +578,13 @@ public class AccountService(
); );
var sessions = await db.AuthSessions var sessions = await db.AuthSessions
.Include(s => s.Challenge) .Where(s => s.ClientId == device.Id && s.AccountId == account.Id)
.Where(s => s.Challenge.ClientId == device.Id && s.AccountId == account.Id)
.ToListAsync(); .ToListAsync();
// The current session should be included in the sessions' list // The current session should be included in the sessions' list
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
await db.AuthSessions await db.AuthSessions
.Include(s => s.Challenge) .Where(s => s.ClientId == device.Id)
.Where(s => s.Challenge.ClientId == device.Id)
.ExecuteUpdateAsync(p => p.SetProperty(s => s.DeletedAt, s => now)); .ExecuteUpdateAsync(p => p.SetProperty(s => s.DeletedAt, s => now));
db.AuthClients.Remove(device); db.AuthClients.Remove(device);
@@ -582,7 +594,8 @@ public class AccountService(
await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{item.Id}"); await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{item.Id}");
} }
public async Task<SnAccountContact> CreateContactMethod(SnAccount account, Shared.Models.AccountContactType type, string content) public async Task<SnAccountContact> CreateContactMethod(SnAccount account, Shared.Models.AccountContactType type,
string content)
{ {
var isExists = await db.AccountContacts var isExists = await db.AccountContacts
.Where(x => x.AccountId == account.Id && x.Type == type && x.Content == content) .Where(x => x.AccountId == account.Id && x.Type == type && x.Content == content)
@@ -644,7 +657,8 @@ public class AccountService(
} }
} }
public async Task<SnAccountContact> SetContactMethodPublic(SnAccount account, SnAccountContact contact, bool isPublic) public async Task<SnAccountContact> SetContactMethodPublic(SnAccount account, SnAccountContact contact,
bool isPublic)
{ {
contact.IsPublic = isPublic; contact.IsPublic = isPublic;
db.AccountContacts.Update(contact); db.AccountContacts.Update(contact);

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

@@ -30,12 +30,12 @@ public class AuthController(
public class ChallengeRequest public class ChallengeRequest
{ {
[Required] public ClientPlatform Platform { get; set; } [Required] public Shared.Models.ClientPlatform Platform { get; set; }
[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")]
@@ -61,9 +61,6 @@ public class AuthController(
request.DeviceName ??= userAgent; request.DeviceName ??= userAgent;
var device =
await auth.GetOrCreateDeviceAsync(account.Id, request.DeviceId, request.DeviceName, request.Platform);
// Trying to pick up challenges from the same IP address and user agent // Trying to pick up challenges from the same IP address and user agent
var existingChallenge = await db.AuthChallenges var existingChallenge = await db.AuthChallenges
.Where(e => e.AccountId == account.Id) .Where(e => e.AccountId == account.Id)
@@ -71,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.ClientId == device.Id)
.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
{ {
@@ -90,7 +81,9 @@ public class AuthController(
IpAddress = ipAddress, IpAddress = ipAddress,
UserAgent = userAgent, UserAgent = userAgent,
Location = geo.GetPointFromIp(ipAddress), Location = geo.GetPointFromIp(ipAddress),
ClientId = device.Id, DeviceId = request.DeviceId,
DeviceName = request.DeviceName,
Platform = request.Platform,
AccountId = account.Id AccountId = account.Id
}.Normalize(); }.Normalize();
@@ -112,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")]
@@ -176,7 +166,6 @@ public class AuthController(
{ {
var challenge = await db.AuthChallenges var challenge = await db.AuthChallenges
.Include(e => e.Account) .Include(e => e.Account)
.Include(authChallenge => authChallenge.Client)
.FirstOrDefaultAsync(e => e.Id == id); .FirstOrDefaultAsync(e => e.Id == id);
if (challenge is null) return NotFound("Auth challenge was not found."); if (challenge is null) return NotFound("Auth challenge was not found.");
@@ -218,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);
@@ -231,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.");
} }
@@ -242,11 +234,11 @@ 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"],
Body = localizer["NewLoginBody", challenge.Client?.DeviceName ?? "unknown", Body = localizer["NewLoginBody", challenge.DeviceName ?? "unknown",
challenge.IpAddress ?? "unknown"], challenge.IpAddress ?? "unknown"],
IsSavable = true IsSavable = true
}, },
@@ -277,6 +269,14 @@ public class AuthController(
public string Token { get; set; } = string.Empty; public string Token { get; set; } = string.Empty;
} }
public class NewSessionRequest
{
[Required] [MaxLength(512)] public string DeviceId { get; set; } = null!;
[MaxLength(1024)] public string? DeviceName { get; set; }
[Required] public Shared.Models.ClientPlatform Platform { get; set; }
public Instant? ExpiredAt { get; set; }
}
[HttpPost("token")] [HttpPost("token")]
public async Task<ActionResult<TokenExchangeResponse>> ExchangeToken([FromBody] TokenExchangeRequest request) public async Task<ActionResult<TokenExchangeResponse>> ExchangeToken([FromBody] TokenExchangeRequest request)
{ {
@@ -327,4 +327,35 @@ public class AuthController(
}); });
return Ok(); return Ok();
} }
}
[HttpPost("login/session")]
[Microsoft.AspNetCore.Authorization.Authorize] // Use full namespace to avoid ambiguity with DysonNetwork.Pass.Permission.Authorize
public async Task<ActionResult<TokenExchangeResponse>> LoginFromSession([FromBody] NewSessionRequest request)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount ||
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession)
return Unauthorized();
var newSession = await auth.CreateSessionFromParentAsync(
currentSession,
request.DeviceId,
request.DeviceName,
request.Platform,
request.ExpiredAt
);
var tk = auth.CreateToken(newSession);
// Set cookie using HttpContext, similar to CreateSessionAndIssueToken
HttpContext.Response.Cookies.Append(AuthConstants.CookieTokenName, tk, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
Domain = _cookieDomain,
Expires = request.ExpiredAt?.ToDateTimeOffset() ?? DateTime.UtcNow.AddYears(20)
});
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)
{ {
@@ -157,7 +164,7 @@ public class AuthService(
// 8) Device Trust Assessment // 8) Device Trust Assessment
var trustedDeviceIds = recentSessions var trustedDeviceIds = recentSessions
.Where(s => s.CreatedAt > now.Minus(Duration.FromDays(30))) // Trust devices from last 30 days .Where(s => s.CreatedAt > now.Minus(Duration.FromDays(30))) // Trust devices from last 30 days
.Select(s => s.Challenge?.ClientId) .Select(s => s.ClientId)
.Where(id => id.HasValue) .Where(id => id.HasValue)
.Distinct() .Distinct()
.ToList(); .ToList();
@@ -181,29 +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) 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
};
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,
AppId = customAppId UserAgent = HttpContext.Request.Headers.UserAgent,
Location = geoLocation,
AppId = customAppId,
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();
@@ -217,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
{ {
@@ -288,35 +295,71 @@ public class AuthService(
/// <summary> /// <summary>
/// Immediately revoke a session by setting expiry to now and clearing from cache /// Immediately revoke a session by setting expiry to now and clearing from cache
/// This provides immediate invalidation of tokens and sessions /// This provides immediate invalidation of tokens and sessions, including all child sessions recursively.
/// </summary> /// </summary>
/// <param name="sessionId">Session ID to revoke</param> /// <param name="sessionId">Session ID to revoke</param>
/// <returns>True if session was found and revoked, false otherwise</returns> /// <returns>True if session was found and revoked, false otherwise</returns>
public async Task<bool> RevokeSessionAsync(Guid sessionId) public async Task<bool> RevokeSessionAsync(Guid sessionId)
{ {
var session = await db.AuthSessions.FirstOrDefaultAsync(s => s.Id == sessionId); var sessionsToRevokeIds = new HashSet<Guid>();
if (session == null) await CollectSessionsToRevoke(sessionId, sessionsToRevokeIds);
if (sessionsToRevokeIds.Count == 0)
{ {
return false; return false;
} }
// Set expiry to now (immediate invalidation)
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
session.ExpiredAt = now; var accountIdsToClearCache = new HashSet<Guid>();
db.AuthSessions.Update(session);
// Clear from cache immediately // Fetch all sessions to be revoked in one go
var cacheKey = $"{AuthCachePrefix}{session.Id}"; var sessions = await db.AuthSessions
await cache.RemoveAsync(cacheKey); .Where(s => sessionsToRevokeIds.Contains(s.Id))
.ToListAsync();
// Clear account-level cache groups that include this session foreach (var session in sessions)
await cache.RemoveAsync($"{AuthCachePrefix}{session.AccountId}"); {
session.ExpiredAt = now;
accountIdsToClearCache.Add(session.AccountId);
// Clear from cache immediately for each session
await cache.RemoveAsync($"{AuthCachePrefix}{session.Id}");
}
db.AuthSessions.UpdateRange(sessions);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Clear account-level cache groups
foreach (var accountId in accountIdsToClearCache)
{
await cache.RemoveAsync($"{AuthCachePrefix}{accountId}");
}
return true; return true;
} }
/// <summary>
/// Recursively collects all session IDs that need to be revoked, starting from a given session.
/// </summary>
/// <param name="currentSessionId">The session ID to start collecting from.</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)
{
if (!sessionsToRevoke.Add(currentSessionId))
return; // Already processed this session
// Find direct children
var childSessions = await db.AuthSessions
.Where(s => s.ParentSessionId == currentSessionId)
.Select(s => s.Id)
.ToListAsync();
foreach (var childId in childSessions)
{
await CollectSessionsToRevoke(childId, sessionsToRevoke);
}
}
/// <summary> /// <summary>
/// Revoke all sessions for an account (logout everywhere) /// Revoke all sessions for an account (logout everywhere)
/// </summary> /// </summary>
@@ -375,10 +418,12 @@ 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 now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
var session = new SnAuthSession var session = new SnAuthSession
@@ -386,7 +431,13 @@ public class AuthService(
LastGrantedAt = now, LastGrantedAt = now,
ExpiredAt = now.Plus(Duration.FromDays(7)), ExpiredAt = now.Plus(Duration.FromDays(7)),
AccountId = challenge.AccountId, AccountId = challenge.AccountId,
ChallengeId = challenge.Id IpAddress = challenge.IpAddress,
UserAgent = challenge.UserAgent,
Location = challenge.Location,
Scopes = challenge.Scopes,
Audiences = challenge.Audiences,
ChallengeId = challenge.Id,
ClientId = device.Id,
}; };
db.AuthSessions.Add(session); db.AuthSessions.Add(session);
@@ -409,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();
@@ -500,7 +551,8 @@ public class AuthService(
return key; return key;
} }
public async Task<SnApiKey> CreateApiKey(Guid accountId, string label, Instant? expiredAt = null) public async Task<SnApiKey> CreateApiKey(Guid accountId, string label, Instant? expiredAt = null,
SnAuthSession? parentSession = null)
{ {
var key = new SnApiKey var key = new SnApiKey
{ {
@@ -509,7 +561,8 @@ public class AuthService(
Session = new SnAuthSession Session = new SnAuthSession
{ {
AccountId = accountId, AccountId = accountId,
ExpiredAt = expiredAt ExpiredAt = expiredAt,
ParentSessionId = parentSession?.Id
}, },
}; };
@@ -615,4 +668,47 @@ public class AuthService(
return Convert.FromBase64String(padded); return Convert.FromBase64String(padded);
} }
}
/// <summary>
/// Creates a new session derived from an existing parent session.
/// </summary>
/// <param name="parentSession">The existing session from which the new session is derived.</param>
/// <param name="deviceId">The ID of the device for the new session.</param>
/// <param name="deviceName">The name of the device for the new session.</param>
/// <param name="platform">The platform of the device for the new session.</param>
/// <param name="expiredAt">Optional: The expiration time for the new session.</param>
/// <returns>The newly created SnAuthSession.</returns>
public async Task<SnAuthSession> CreateSessionFromParentAsync(
SnAuthSession parentSession,
string deviceId,
string? deviceName,
ClientPlatform platform,
Instant? expiredAt = null
)
{
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 session = new SnAuthSession
{
IpAddress = ipAddress,
UserAgent = userAgent,
Location = geoLocation,
AccountId = parentSession.AccountId,
CreatedAt = now,
LastGrantedAt = now,
ExpiredAt = expiredAt,
ParentSessionId = parentSession.Id,
ClientId = device.Id,
};
db.AuthSessions.Add(session);
await db.SaveChangesAsync();
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

@@ -342,13 +342,19 @@ public class ConnectionController(
callbackData.State.Split('|').FirstOrDefault() : callbackData.State.Split('|').FirstOrDefault() :
string.Empty; string.Empty;
var challenge = await oidcService.CreateChallengeForUserAsync( if (HttpContext.Items["CurrentSession"] is not SnAuthSession parentSession) parentSession = null;
var session = await oidcService.CreateSessionForUserAsync(
userInfo, userInfo,
connection.Account, connection.Account,
HttpContext, HttpContext,
deviceId ?? string.Empty); deviceId ?? string.Empty,
null,
ClientPlatform.Web,
parentSession);
var redirectUrl = QueryHelpers.AddQueryString(redirectBaseUrl, "challenge", challenge.Id.ToString()); var token = auth.CreateToken(session);
var redirectUrl = QueryHelpers.AddQueryString(redirectBaseUrl, "token", token);
logger.LogInformation("OIDC login successful for user {UserId}. Redirecting to {RedirectUrl}", connection.AccountId, redirectUrl); logger.LogInformation("OIDC login successful for user {UserId}. Redirecting to {RedirectUrl}", connection.AccountId, redirectUrl);
return Redirect(redirectUrl); return Redirect(redirectUrl);
} }

View File

@@ -14,6 +14,7 @@ public class OidcController(
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
AppDatabase db, AppDatabase db,
AccountService accounts, AccountService accounts,
AuthService auth,
ICacheService cache, ICacheService cache,
ILogger<OidcController> logger ILogger<OidcController> logger
) )
@@ -75,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<SnAuthChallenge>> AppleMobileLogin( public async Task<ActionResult<AuthController.TokenExchangeResponse>> AppleMobileLogin(
[FromBody] AppleMobileSignInRequest request [FromBody] AppleMobileSignInRequest request
) )
{ {
@@ -98,16 +99,21 @@ public class OidcController(
// Find or create user account using existing logic // Find or create user account using existing logic
var account = await FindOrCreateAccount(userInfo, "apple"); var account = await FindOrCreateAccount(userInfo, "apple");
if (HttpContext.Items["CurrentSession"] is not SnAuthSession parentSession) parentSession = null;
// Create session using the OIDC service // Create session using the OIDC service
var challenge = await appleService.CreateChallengeForUserAsync( var session = await appleService.CreateSessionForUserAsync(
userInfo, userInfo,
account, account,
HttpContext, HttpContext,
request.DeviceId, request.DeviceId,
request.DeviceName request.DeviceName,
ClientPlatform.Ios,
parentSession
); );
return Ok(challenge); var token = auth.CreateToken(session);
return Ok(new AuthController.TokenExchangeResponse { Token = token });
} }
catch (SecurityTokenValidationException ex) catch (SecurityTokenValidationException ex)
{ {

View File

@@ -1,4 +1,3 @@
using System;
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
@@ -250,15 +249,17 @@ public abstract class OidcService(
} }
/// <summary> /// <summary>
/// Creates a challenge and session for an authenticated user /// Creates a session for an authenticated user
/// Also creates or updates the account connection /// Also creates or updates the account connection
/// </summary> /// </summary>
public async Task<SnAuthChallenge> CreateChallengeForUserAsync( public async Task<SnAuthSession> CreateSessionForUserAsync(
OidcUserInfo userInfo, OidcUserInfo userInfo,
SnAccount account, SnAccount account,
HttpContext request, HttpContext request,
string deviceId, string deviceId,
string? deviceName = null string? deviceName = null,
ClientPlatform platform = ClientPlatform.Web,
SnAuthSession? parentSession = null
) )
{ {
// Create or update the account connection // Create or update the account connection
@@ -282,28 +283,24 @@ public abstract class OidcService(
await Db.AccountConnections.AddAsync(connection); await Db.AccountConnections.AddAsync(connection);
} }
// Create a challenge that's already completed // Create a session directly
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
var device = await auth.GetOrCreateDeviceAsync(account.Id, deviceId, deviceName, ClientPlatform.Ios); var device = await auth.GetOrCreateDeviceAsync(account.Id, deviceId, deviceName, platform);
var challenge = new SnAuthChallenge
{
ExpiredAt = now.Plus(Duration.FromHours(1)),
StepTotal = await auth.DetectChallengeRisk(request.Request, account),
Type = ChallengeType.Oidc,
Audiences = [ProviderName],
Scopes = ["*"],
AccountId = account.Id,
ClientId = device.Id,
IpAddress = request.Connection.RemoteIpAddress?.ToString() ?? null,
UserAgent = request.Request.Headers.UserAgent,
};
challenge.StepRemain--;
if (challenge.StepRemain < 0) challenge.StepRemain = 0;
await Db.AuthChallenges.AddAsync(challenge); var session = new SnAuthSession
{
AccountId = account.Id,
CreatedAt = now,
LastGrantedAt = now,
ParentSessionId = parentSession?.Id,
ClientId = device.Id,
ExpiredAt = now.Plus(Duration.FromDays(30))
};
await Db.AuthSessions.AddAsync(session);
await Db.SaveChangesAsync(); await Db.SaveChangesAsync();
return challenge; return session;
} }
} }

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,8 +87,7 @@ public class TokenAuthService(
session = await db.AuthSessions session = await db.AuthSessions
.AsNoTracking() .AsNoTracking()
.Include(e => e.Challenge) .Include(e => e.Client)
.ThenInclude(e => e.Client)
.Include(e => e.Account) .Include(e => e.Account)
.ThenInclude(e => e.Profile) .ThenInclude(e => e.Profile)
.FirstOrDefaultAsync(s => s.Id == sessionId); .FirstOrDefaultAsync(s => s.Id == sessionId);
@@ -110,11 +109,11 @@ public class TokenAuthService(
"AuthenticateTokenAsync: DB session loaded (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId}, appId={AppId}, scopes={ScopeCount}, ip={Ip}, uaLen={UaLen})", "AuthenticateTokenAsync: DB session loaded (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId}, appId={AppId}, scopes={ScopeCount}, ip={Ip}, uaLen={UaLen})",
sessionId, sessionId,
session.AccountId, session.AccountId,
session.Challenge?.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);
@@ -143,7 +142,7 @@ public class TokenAuthService(
"AuthenticateTokenAsync: success via DB (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId})", "AuthenticateTokenAsync: success via DB (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId})",
sessionId, sessionId,
session.AccountId, session.AccountId,
session.Challenge?.ClientId session.ClientId
); );
return (true, session, null); return (true, session, null);
} }

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,143 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class DecoupleAuthSessionAndChallenge : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_auth_challenges_auth_clients_client_id",
table: "auth_challenges");
migrationBuilder.DropIndex(
name: "ix_auth_challenges_client_id",
table: "auth_challenges");
migrationBuilder.DropColumn(
name: "client_id",
table: "auth_challenges");
migrationBuilder.AddColumn<Guid>(
name: "client_id",
table: "auth_sessions",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "parent_session_id",
table: "auth_sessions",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "device_id",
table: "auth_challenges",
type: "character varying(512)",
maxLength: 512,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "device_name",
table: "auth_challenges",
type: "character varying(1024)",
maxLength: 1024,
nullable: true);
migrationBuilder.AddColumn<int>(
name: "platform",
table: "auth_challenges",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateIndex(
name: "ix_auth_sessions_client_id",
table: "auth_sessions",
column: "client_id");
migrationBuilder.CreateIndex(
name: "ix_auth_sessions_parent_session_id",
table: "auth_sessions",
column: "parent_session_id");
migrationBuilder.AddForeignKey(
name: "fk_auth_sessions_auth_clients_client_id",
table: "auth_sessions",
column: "client_id",
principalTable: "auth_clients",
principalColumn: "id");
migrationBuilder.AddForeignKey(
name: "fk_auth_sessions_auth_sessions_parent_session_id",
table: "auth_sessions",
column: "parent_session_id",
principalTable: "auth_sessions",
principalColumn: "id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_auth_sessions_auth_clients_client_id",
table: "auth_sessions");
migrationBuilder.DropForeignKey(
name: "fk_auth_sessions_auth_sessions_parent_session_id",
table: "auth_sessions");
migrationBuilder.DropIndex(
name: "ix_auth_sessions_client_id",
table: "auth_sessions");
migrationBuilder.DropIndex(
name: "ix_auth_sessions_parent_session_id",
table: "auth_sessions");
migrationBuilder.DropColumn(
name: "client_id",
table: "auth_sessions");
migrationBuilder.DropColumn(
name: "parent_session_id",
table: "auth_sessions");
migrationBuilder.DropColumn(
name: "device_id",
table: "auth_challenges");
migrationBuilder.DropColumn(
name: "device_name",
table: "auth_challenges");
migrationBuilder.DropColumn(
name: "platform",
table: "auth_challenges");
migrationBuilder.AddColumn<Guid>(
name: "client_id",
table: "auth_challenges",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "ix_auth_challenges_client_id",
table: "auth_challenges",
column: "client_id");
migrationBuilder.AddForeignKey(
name: "fk_auth_challenges_auth_clients_client_id",
table: "auth_challenges",
column: "client_id",
principalTable: "auth_clients",
principalColumn: "id");
}
}
}

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")
@@ -778,10 +875,6 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("blacklist_factors"); .HasColumnName("blacklist_factors");
b.Property<Guid?>("ClientId")
.HasColumnType("uuid")
.HasColumnName("client_id");
b.Property<Instant>("CreatedAt") b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("created_at"); .HasColumnName("created_at");
@@ -790,6 +883,17 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at"); .HasColumnName("deleted_at");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("device_id");
b.Property<string>("DeviceName")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("device_name");
b.Property<Instant?>("ExpiredAt") b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("expired_at"); .HasColumnName("expired_at");
@@ -812,6 +916,10 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("character varying(1024)") .HasColumnType("character varying(1024)")
.HasColumnName("nonce"); .HasColumnName("nonce");
b.Property<int>("Platform")
.HasColumnType("integer")
.HasColumnName("platform");
b.Property<List<string>>("Scopes") b.Property<List<string>>("Scopes")
.IsRequired() .IsRequired()
.HasColumnType("jsonb") .HasColumnType("jsonb")
@@ -825,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");
@@ -844,9 +948,6 @@ namespace DysonNetwork.Pass.Migrations
b.HasIndex("AccountId") b.HasIndex("AccountId")
.HasDatabaseName("ix_auth_challenges_account_id"); .HasDatabaseName("ix_auth_challenges_account_id");
b.HasIndex("ClientId")
.HasDatabaseName("ix_auth_challenges_client_id");
b.ToTable("auth_challenges", (string)null); b.ToTable("auth_challenges", (string)null);
}); });
@@ -918,10 +1019,19 @@ 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");
b.Property<Guid?>("ClientId")
.HasColumnType("uuid")
.HasColumnName("client_id");
b.Property<Instant>("CreatedAt") b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("created_at"); .HasColumnName("created_at");
@@ -934,22 +1044,52 @@ 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")
.HasColumnType("uuid")
.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") b.HasIndex("ClientId")
.HasDatabaseName("ix_auth_sessions_challenge_id"); .HasDatabaseName("ix_auth_sessions_client_id");
b.HasIndex("ParentSessionId")
.HasDatabaseName("ix_auth_sessions_parent_session_id");
b.ToTable("auth_sessions", (string)null); b.ToTable("auth_sessions", (string)null);
}); });
@@ -1314,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");
@@ -1342,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");
@@ -1357,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);
}); });
@@ -2344,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")
@@ -2374,14 +2534,7 @@ namespace DysonNetwork.Pass.Migrations
.IsRequired() .IsRequired()
.HasConstraintName("fk_auth_challenges_accounts_account_id"); .HasConstraintName("fk_auth_challenges_accounts_account_id");
b.HasOne("DysonNetwork.Shared.Models.SnAuthClient", "Client")
.WithMany()
.HasForeignKey("ClientId")
.HasConstraintName("fk_auth_challenges_auth_clients_client_id");
b.Navigation("Account"); b.Navigation("Account");
b.Navigation("Client");
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAuthClient", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnAuthClient", b =>
@@ -2405,14 +2558,21 @@ 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") b.HasOne("DysonNetwork.Shared.Models.SnAuthClient", "Client")
.WithMany() .WithMany()
.HasForeignKey("ChallengeId") .HasForeignKey("ClientId")
.HasConstraintName("fk_auth_sessions_auth_challenges_challenge_id"); .HasConstraintName("fk_auth_sessions_auth_clients_client_id");
b.HasOne("DysonNetwork.Shared.Models.SnAuthSession", "ParentSession")
.WithMany()
.HasForeignKey("ParentSessionId")
.HasConstraintName("fk_auth_sessions_auth_sessions_parent_session_id");
b.Navigation("Account"); b.Navigation("Account");
b.Navigation("Challenge"); b.Navigation("Client");
b.Navigation("ParentSession");
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCheckInResult", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnCheckInResult", b =>

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;
if (string.IsNullOrEmpty(deviceId)) if (string.IsNullOrEmpty(deviceId))
return BadRequest("Unable to get device ID from session."); deviceId = Guid.NewGuid().ToString();
if (deviceAlt is not null) if (deviceAlt is not null)
deviceId = $"{deviceId}+{deviceAlt}"; deviceId = $"{deviceId}+{deviceAlt}";
@@ -101,9 +100,9 @@ public class WebSocketController(
} }
catch (WebSocketException ex) catch (WebSocketException ex)
when (ex.Message.Contains( when (ex.Message.Contains(
"The remote party closed the WebSocket connection without completing the close handshake" "The remote party closed the WebSocket connection without completing the close handshake"
) )
) )
{ {
logger.LogDebug( logger.LogDebug(
"WebSocket disconnected with user @{UserName}#{UserId} and device #{DeviceId} - client closed connection without proper handshake", "WebSocket disconnected with user @{UserName}#{UserId} and device #{DeviceId} - client closed connection without proper handshake",
@@ -187,4 +186,4 @@ public class WebSocketController(
} }
} }
} }
} }

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,47 +2,80 @@ 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 class SnAuthSession : ModelBase public enum SessionType
{
public Guid Id { get; set; } = Guid.NewGuid();
public Instant? LastGrantedAt { get; set; }
public Instant? ExpiredAt { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public SnAccount Account { get; set; } = null!;
// When the challenge is null, indicates the session is for an API Key
public Guid? ChallengeId { get; set; }
public SnAuthChallenge? Challenge { get; set; } = null!;
// Indicates the session is for an OIDC connection
public Guid? AppId { get; set; }
public Proto.AuthSession ToProtoValue() => new()
{
Id = Id.ToString(),
LastGrantedAt = LastGrantedAt?.ToTimestamp(),
ExpiredAt = ExpiredAt?.ToTimestamp(),
AccountId = AccountId.ToString(),
Account = Account.ToProtoValue(),
ChallengeId = ChallengeId.ToString(),
Challenge = Challenge?.ToProtoValue(),
AppId = AppId?.ToString()
};
}
public enum ChallengeType
{ {
Login, Login,
OAuth, // Trying to authorize other platforms OAuth, // Trying to authorize other platforms
Oidc // Trying to connect other platforms Oidc // Trying to connect other platforms
} }
public class SnAuthSession : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public SessionType Type { get; set; } = SessionType.Login;
public Instant? LastGrantedAt { 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; }
[JsonIgnore] public SnAccount Account { get; set; } = null!;
// The client device for this session
public Guid? ClientId { get; set; }
public SnAuthClient? Client { get; set; } = null!;
// For sub-sessions (e.g. OAuth)
public Guid? ParentSessionId { 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
public Guid? AppId { get; set; }
public AuthSession ToProtoValue()
{
var proto = new AuthSession
{
Id = Id.ToString(),
LastGrantedAt = LastGrantedAt?.ToTimestamp(),
Type = Type switch
{
SessionType.Login => Proto.SessionType.Login,
SessionType.OAuth => Proto.SessionType.Oauth,
SessionType.Oidc => Proto.SessionType.Oidc,
_ => Proto.SessionType.ChallengeTypeUnspecified
},
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);
return proto;
}
}
public enum ClientPlatform public enum ClientPlatform
{ {
Unidentified, Unidentified,
@@ -61,19 +94,19 @@ 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(1024)] public string? DeviceName { get; set; }
public ClientPlatform Platform { get; set; }
[MaxLength(1024)] public string? Nonce { get; set; } [MaxLength(1024)] public string? Nonce { get; set; }
[Column(TypeName = "jsonb")] public GeoPoint? Location { 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!;
public Guid? ClientId { get; set; }
public SnAuthClient? Client { get; set; } = null!;
public SnAuthChallenge Normalize() public SnAuthChallenge Normalize()
{ {
@@ -81,20 +114,19 @@ 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 },
IpAddress = IpAddress, IpAddress = IpAddress,
UserAgent = UserAgent, UserAgent = UserAgent,
DeviceId = Client!.DeviceId, DeviceId = DeviceId,
Nonce = Nonce, Nonce = Nonce,
AccountId = AccountId.ToString() AccountId = AccountId.ToString()
}; };
@@ -110,15 +142,25 @@ public class SnAuthClient : ModelBase
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
[JsonIgnore] public SnAccount Account { get; set; } = null!; [JsonIgnore] public SnAccount Account { get; set; } = null!;
public Proto.AuthClient ToProtoValue() => new()
{
Id = Id.ToString(),
Platform = (Proto.ClientPlatform)Platform,
DeviceName = DeviceName,
DeviceLabel = DeviceLabel,
DeviceId = DeviceId,
AccountId = AccountId.ToString()
};
} }
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,
@@ -128,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

@@ -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

@@ -8,182 +8,203 @@ import "google/protobuf/timestamp.proto";
import "google/protobuf/wrappers.proto"; import "google/protobuf/wrappers.proto";
import "google/protobuf/struct.proto"; import "google/protobuf/struct.proto";
import 'account.proto'; 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;
AuthClient client = 12;
repeated string audiences = 13;
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;
ClientPlatform platform = 17;
}
message AuthClient {
string id = 1;
ClientPlatform platform = 2;
google.protobuf.StringValue device_name = 3;
google.protobuf.StringValue device_label = 4;
string device_id = 5;
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 challenge platforms // Enum for client platforms
enum ChallengePlatform { enum ClientPlatform {
CHALLENGE_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) returns (RemovePermissionNodeFromGroupResponse) {} rpc RemovePermissionNodeFromGroup(RemovePermissionNodeFromGroupRequest)
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);
}
}
}

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