Compare commits
42 Commits
78f3873a0c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
43945fc524
|
|||
|
e477429a35
|
|||
|
fe3a057185
|
|||
|
ad3c104c5c
|
|||
|
2020d625aa
|
|||
|
f471c5635d
|
|||
|
eaeaa28c60
|
|||
|
ee5c7cb7ce
|
|||
|
33abf12e41
|
|||
|
4a71f92ef0
|
|||
|
4faa1a4b64
|
|||
|
e49a1ec49a
|
|||
|
a88f42b26a
|
|||
|
c45be62331
|
|||
|
c8228e0c8e
|
|||
|
c642c6d646
|
|||
|
270c211cb8
|
|||
|
74c8f3490d
|
|||
|
b364edc74b
|
|||
|
9addf38677
|
|||
|
a02ed10434
|
|||
|
aca28f9318
|
|||
|
c2f72993b7
|
|||
|
158cc75c5b
|
|||
|
fa2f53ff7a
|
|||
|
2cce5ebf80
|
|||
|
13b2e46ecc
|
|||
|
cbd68c9ae6
|
|||
|
b99b61e0f9
|
|||
|
94f4e68120
|
|||
|
d5510f7e4d
|
|||
|
c038ab9e3c
|
|||
|
e97719ec84
|
|||
|
40b8ea8eb8
|
|||
|
f9b4dd45d7
|
|||
|
a46de4662c
|
|||
|
fdd14b860e
|
|||
|
cb62df81e2
|
|||
|
46717e39a7
|
|||
|
344ed6e348
|
|||
|
a8b62fb0eb
|
|||
|
00b3087d6a
|
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
"Etcd": {
|
"Etcd": {
|
||||||
"Insecure": true
|
"Insecure": true
|
||||||
},
|
},
|
||||||
|
"Cache": {
|
||||||
|
"Serializer": "MessagePack"
|
||||||
|
},
|
||||||
"Thinking": {
|
"Thinking": {
|
||||||
"DefaultService": "deepseek-chat",
|
"DefaultService": "deepseek-chat",
|
||||||
"Services": {
|
"Services": {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,6 +9,20 @@ 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)
|
||||||
{
|
{
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
134
DysonNetwork.Pass/Affiliation/AffiliationSpellController.cs
Normal file
134
DysonNetwork.Pass/Affiliation/AffiliationSpellController.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
62
DysonNetwork.Pass/Affiliation/AffiliationSpellService.cs
Normal file
62
DysonNetwork.Pass/Affiliation/AffiliationSpellService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
2755
DysonNetwork.Pass/Migrations/20251129095046_DecoupleAuthSessionAndChallenge.Designer.cs
generated
Normal file
2755
DysonNetwork.Pass/Migrations/20251129095046_DecoupleAuthSessionAndChallenge.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2874
DysonNetwork.Pass/Migrations/20251201145617_AddAffiliationSpell.Designer.cs
generated
Normal file
2874
DysonNetwork.Pass/Migrations/20251201145617_AddAffiliationSpell.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2872
DysonNetwork.Pass/Migrations/20251202134035_SimplifiedPermissionNode.Designer.cs
generated
Normal file
2872
DysonNetwork.Pass/Migrations/20251202134035_SimplifiedPermissionNode.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2882
DysonNetwork.Pass/Migrations/20251202160759_SimplifiedAuthSession.Designer.cs
generated
Normal file
2882
DysonNetwork.Pass/Migrations/20251202160759_SimplifiedAuthSession.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2886
DysonNetwork.Pass/Migrations/20251203163459_AddLocationToSession.Designer.cs
generated
Normal file
2886
DysonNetwork.Pass/Migrations/20251203163459_AddLocationToSession.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 =>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,7 +148,8 @@ 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()
|
||||||
{
|
{
|
||||||
@@ -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;
|
||||||
|
|||||||
35
DysonNetwork.Ring/Services/PushSubFlushHandler.cs
Normal file
35
DysonNetwork.Ring/Services/PushSubFlushHandler.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
72
DysonNetwork.Shared/Auth/RemotePermissionMiddleware.cs
Normal file
72
DysonNetwork.Shared/Auth/RemotePermissionMiddleware.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
DysonNetwork.Shared/Cache/ICacheSerializer.cs
Normal file
7
DysonNetwork.Shared/Cache/ICacheSerializer.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace DysonNetwork.Shared.Cache;
|
||||||
|
|
||||||
|
public interface ICacheSerializer
|
||||||
|
{
|
||||||
|
string Serialize<T>(T value);
|
||||||
|
T? Deserialize<T>(string data);
|
||||||
|
}
|
||||||
86
DysonNetwork.Shared/Cache/ICacheService.cs
Normal file
86
DysonNetwork.Shared/Cache/ICacheService.cs
Normal 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);
|
||||||
|
}
|
||||||
22
DysonNetwork.Shared/Cache/IDistributedLock.cs
Normal file
22
DysonNetwork.Shared/Cache/IDistributedLock.cs
Normal 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();
|
||||||
|
}
|
||||||
35
DysonNetwork.Shared/Cache/JsonCacheSerializer.cs
Normal file
35
DysonNetwork.Shared/Cache/JsonCacheSerializer.cs
Normal 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);
|
||||||
|
}
|
||||||
32
DysonNetwork.Shared/Cache/MessagePackCacheSerializer.cs
Normal file
32
DysonNetwork.Shared/Cache/MessagePackCacheSerializer.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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}";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)]
|
||||||
@@ -28,3 +28,39 @@ 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!;
|
||||||
|
}
|
||||||
@@ -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:<userId>
|
/// And the actor shows who owns the permission, in most cases, the user:<userId>
|
||||||
/// 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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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!;
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
1934
DysonNetwork.Sphere/Migrations/20251130115519_AddAccountIdBackToChatRoom.Designer.cs
generated
Normal file
1934
DysonNetwork.Sphere/Migrations/20251130115519_AddAccountIdBackToChatRoom.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1940
DysonNetwork.Sphere/Migrations/20251130125717_SimplerChatRoom.Designer.cs
generated
Normal file
1940
DysonNetwork.Sphere/Migrations/20251130125717_SimplerChatRoom.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1940
DysonNetwork.Sphere/Migrations/20251203161208_AddStickerPackIcon.Designer.cs
generated
Normal file
1940
DysonNetwork.Sphere/Migrations/20251203161208_AddStickerPackIcon.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
Reference in New Issue
Block a user