Compare commits
43 Commits
78f3873a0c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
a071bd2738
|
|||
|
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
|
||||||
@@ -101,7 +116,7 @@ public class AccountService(
|
|||||||
).CountAsync();
|
).CountAsync();
|
||||||
if (dupeEmailCount > 0)
|
if (dupeEmailCount > 0)
|
||||||
throw new InvalidOperationException("Account email has already been used.");
|
throw new InvalidOperationException("Account email has already been used.");
|
||||||
|
|
||||||
var account = new SnAccount
|
var account = new SnAccount
|
||||||
{
|
{
|
||||||
Name = name,
|
Name = name,
|
||||||
@@ -110,7 +125,7 @@ public class AccountService(
|
|||||||
Region = region,
|
Region = region,
|
||||||
Contacts =
|
Contacts =
|
||||||
[
|
[
|
||||||
new()
|
new SnAccountContact
|
||||||
{
|
{
|
||||||
Type = Shared.Models.AccountContactType.Email,
|
Type = Shared.Models.AccountContactType.Email,
|
||||||
Content = email,
|
Content = email,
|
||||||
@@ -132,6 +147,9 @@ public class AccountService(
|
|||||||
Profile = new SnAccountProfile()
|
Profile = new SnAccountProfile()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (affiliationSpell is not null)
|
||||||
|
await ars.CreateAffiliationResult(affiliationSpell, $"account:{account.Id}");
|
||||||
|
|
||||||
if (isActivated)
|
if (isActivated)
|
||||||
{
|
{
|
||||||
account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
|
account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
|
||||||
@@ -140,7 +158,7 @@ public class AccountService(
|
|||||||
{
|
{
|
||||||
db.PermissionGroupMembers.Add(new SnPermissionGroupMember
|
db.PermissionGroupMembers.Add(new SnPermissionGroupMember
|
||||||
{
|
{
|
||||||
Actor = $"user:{account.Id}",
|
Actor = account.Id.ToString(),
|
||||||
Group = defaultGroup
|
Group = defaultGroup
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -181,10 +199,7 @@ public class AccountService(
|
|||||||
displayName,
|
displayName,
|
||||||
userInfo.Email,
|
userInfo.Email,
|
||||||
null,
|
null,
|
||||||
"en-US",
|
isEmailVerified: userInfo.EmailVerified
|
||||||
"en",
|
|
||||||
userInfo.EmailVerified,
|
|
||||||
userInfo.EmailVerified
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +289,8 @@ public class AccountService(
|
|||||||
return isExists;
|
return isExists;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SnAccountAuthFactor?> CreateAuthFactor(SnAccount account, Shared.Models.AccountAuthFactorType type, string? secret)
|
public async Task<SnAccountAuthFactor?> CreateAuthFactor(SnAccount account,
|
||||||
|
Shared.Models.AccountAuthFactorType type, string? secret)
|
||||||
{
|
{
|
||||||
SnAccountAuthFactor? factor = null;
|
SnAccountAuthFactor? factor = null;
|
||||||
switch (type)
|
switch (type)
|
||||||
@@ -352,7 +368,8 @@ public class AccountService(
|
|||||||
public async Task<SnAccountAuthFactor> EnableAuthFactor(SnAccountAuthFactor factor, string? code)
|
public async Task<SnAccountAuthFactor> EnableAuthFactor(SnAccountAuthFactor factor, string? code)
|
||||||
{
|
{
|
||||||
if (factor.EnabledAt is not null) throw new ArgumentException("The factor has been enabled.");
|
if (factor.EnabledAt is not null) throw new ArgumentException("The factor has been enabled.");
|
||||||
if (factor.Type is Shared.Models.AccountAuthFactorType.Password or Shared.Models.AccountAuthFactorType.TimedCode)
|
if (factor.Type is Shared.Models.AccountAuthFactorType.Password
|
||||||
|
or Shared.Models.AccountAuthFactorType.TimedCode)
|
||||||
{
|
{
|
||||||
if (code is null || !factor.VerifyPassword(code))
|
if (code is null || !factor.VerifyPassword(code))
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
@@ -508,9 +525,7 @@ public class AccountService(
|
|||||||
|
|
||||||
private async Task<bool> IsDeviceActive(Guid id)
|
private async Task<bool> IsDeviceActive(Guid id)
|
||||||
{
|
{
|
||||||
return await db.AuthSessions
|
return await db.AuthSessions.AnyAsync(s => s.ClientId == id);
|
||||||
.Include(s => s.Challenge)
|
|
||||||
.AnyAsync(s => s.Challenge.ClientId == id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SnAuthClient> UpdateDeviceName(SnAccount account, string deviceId, string label)
|
public async Task<SnAuthClient> UpdateDeviceName(SnAccount account, string deviceId, string label)
|
||||||
@@ -529,8 +544,7 @@ public class AccountService(
|
|||||||
public async Task DeleteSession(SnAccount account, Guid sessionId)
|
public async Task DeleteSession(SnAccount account, Guid sessionId)
|
||||||
{
|
{
|
||||||
var session = await db.AuthSessions
|
var session = await db.AuthSessions
|
||||||
.Include(s => s.Challenge)
|
.Include(s => s.Client)
|
||||||
.ThenInclude(s => s.Client)
|
|
||||||
.Where(s => s.Id == sessionId && s.AccountId == account.Id)
|
.Where(s => s.Id == sessionId && s.AccountId == account.Id)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (session is null) throw new InvalidOperationException("Session was not found.");
|
if (session is null) throw new InvalidOperationException("Session was not found.");
|
||||||
@@ -539,11 +553,11 @@ public class AccountService(
|
|||||||
db.AuthSessions.Remove(session);
|
db.AuthSessions.Remove(session);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
if (session.Challenge.ClientId.HasValue)
|
if (session.ClientId.HasValue)
|
||||||
{
|
{
|
||||||
if (!await IsDeviceActive(session.Challenge.ClientId.Value))
|
if (!await IsDeviceActive(session.ClientId.Value))
|
||||||
await pusher.UnsubscribePushNotificationsAsync(new UnsubscribePushNotificationsRequest()
|
await pusher.UnsubscribePushNotificationsAsync(new UnsubscribePushNotificationsRequest()
|
||||||
{ DeviceId = session.Challenge.Client!.DeviceId }
|
{ DeviceId = session.Client!.DeviceId }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,15 +578,13 @@ public class AccountService(
|
|||||||
);
|
);
|
||||||
|
|
||||||
var sessions = await db.AuthSessions
|
var sessions = await db.AuthSessions
|
||||||
.Include(s => s.Challenge)
|
.Where(s => s.ClientId == device.Id && s.AccountId == account.Id)
|
||||||
.Where(s => s.Challenge.ClientId == device.Id && s.AccountId == account.Id)
|
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
// The current session should be included in the sessions' list
|
// The current session should be included in the sessions' list
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
await db.AuthSessions
|
await db.AuthSessions
|
||||||
.Include(s => s.Challenge)
|
.Where(s => s.ClientId == device.Id)
|
||||||
.Where(s => s.Challenge.ClientId == device.Id)
|
|
||||||
.ExecuteUpdateAsync(p => p.SetProperty(s => s.DeletedAt, s => now));
|
.ExecuteUpdateAsync(p => p.SetProperty(s => s.DeletedAt, s => now));
|
||||||
|
|
||||||
db.AuthClients.Remove(device);
|
db.AuthClients.Remove(device);
|
||||||
@@ -582,7 +594,8 @@ public class AccountService(
|
|||||||
await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{item.Id}");
|
await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{item.Id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SnAccountContact> CreateContactMethod(SnAccount account, Shared.Models.AccountContactType type, string content)
|
public async Task<SnAccountContact> CreateContactMethod(SnAccount account, Shared.Models.AccountContactType type,
|
||||||
|
string content)
|
||||||
{
|
{
|
||||||
var isExists = await db.AccountContacts
|
var isExists = await db.AccountContacts
|
||||||
.Where(x => x.AccountId == account.Id && x.Type == type && x.Content == content)
|
.Where(x => x.AccountId == account.Id && x.Type == type && x.Content == content)
|
||||||
@@ -644,7 +657,8 @@ public class AccountService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SnAccountContact> SetContactMethodPublic(SnAccount account, SnAccountContact contact, bool isPublic)
|
public async Task<SnAccountContact> SetContactMethodPublic(SnAccount account, SnAccountContact contact,
|
||||||
|
bool isPublic)
|
||||||
{
|
{
|
||||||
contact.IsPublic = isPublic;
|
contact.IsPublic = isPublic;
|
||||||
db.AccountContacts.Update(contact);
|
db.AccountContacts.Update(contact);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -7,17 +9,31 @@ namespace DysonNetwork.Pass.Account;
|
|||||||
[Route("/api/spells")]
|
[Route("/api/spells")]
|
||||||
public class MagicSpellController(AppDatabase db, MagicSpellService sp) : ControllerBase
|
public class MagicSpellController(AppDatabase db, MagicSpellService sp) : ControllerBase
|
||||||
{
|
{
|
||||||
|
[HttpPost("activation/resend")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult> ResendActivationMagicSpell()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var spell = await db.MagicSpells.FirstOrDefaultAsync(s =>
|
||||||
|
s.Type == MagicSpellType.AccountActivation && s.AccountId == currentUser.Id);
|
||||||
|
if (spell is null) return BadRequest("Unable to find activation magic spell.");
|
||||||
|
|
||||||
|
await sp.NotifyMagicSpell(spell, true);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("{spellId:guid}/resend")]
|
[HttpPost("{spellId:guid}/resend")]
|
||||||
public async Task<ActionResult> ResendMagicSpell(Guid spellId)
|
public async Task<ActionResult> ResendMagicSpell(Guid spellId)
|
||||||
{
|
{
|
||||||
var spell = db.MagicSpells.FirstOrDefault(x => x.Id == spellId);
|
var spell = db.MagicSpells.FirstOrDefault(x => x.Id == spellId);
|
||||||
if (spell == null)
|
if (spell == null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
await sp.NotifyMagicSpell(spell, true);
|
await sp.NotifyMagicSpell(spell, true);
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{spellWord}")]
|
[HttpGet("{spellWord}")]
|
||||||
public async Task<ActionResult> GetMagicSpell(string spellWord)
|
public async Task<ActionResult> GetMagicSpell(string spellWord)
|
||||||
{
|
{
|
||||||
@@ -38,7 +54,8 @@ public class MagicSpellController(AppDatabase db, MagicSpellService sp) : Contro
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{spellWord}/apply")]
|
[HttpPost("{spellWord}/apply")]
|
||||||
public async Task<ActionResult> ApplyMagicSpell([FromRoute] string spellWord, [FromBody] MagicSpellApplyRequest? request)
|
public async Task<ActionResult> ApplyMagicSpell([FromRoute] string spellWord,
|
||||||
|
[FromBody] MagicSpellApplyRequest? request)
|
||||||
{
|
{
|
||||||
var word = Uri.UnescapeDataString(spellWord);
|
var word = Uri.UnescapeDataString(spellWord);
|
||||||
var spell = await db.MagicSpells
|
var spell = await db.MagicSpells
|
||||||
@@ -59,6 +76,7 @@ public class MagicSpellController(AppDatabase db, MagicSpellService sp) : Contro
|
|||||||
{
|
{
|
||||||
return BadRequest(ex.Message);
|
return BadRequest(ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
@@ -424,4 +414,4 @@ public class PermissionService(
|
|||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -342,4 +351,4 @@ public class PatternValidationResponse
|
|||||||
public string Pattern { get; set; } = string.Empty;
|
public string Pattern { get; set; } = string.Empty;
|
||||||
public bool IsValid { get; set; }
|
public bool IsValid { get; set; }
|
||||||
public string Message { get; set; } = string.Empty;
|
public string Message { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
@@ -187,4 +186,4 @@ public class WebSocketController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,14 +148,15 @@ public class PushService
|
|||||||
_ = _queueService.EnqueuePushNotification(notification, Guid.Parse(accountId), save);
|
_ = _queueService.EnqueuePushNotification(notification, Guid.Parse(accountId), save);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeliverPushNotification(SnNotification notification, CancellationToken cancellationToken = default)
|
public async Task DeliverPushNotification(SnNotification notification,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
WebSocketService.SendPacketToAccount(notification.AccountId, new WebSocketPacket()
|
WebSocketService.SendPacketToAccount(notification.AccountId, new WebSocketPacket()
|
||||||
{
|
{
|
||||||
Type = "notifications.new",
|
Type = "notifications.new",
|
||||||
Data = notification,
|
Data = notification,
|
||||||
});
|
});
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
@@ -260,7 +265,8 @@ public class PushService
|
|||||||
await DeliverPushNotification(notification);
|
await DeliverPushNotification(notification);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SendPushNotificationAsync(SnNotificationPushSubscription subscription, SnNotification notification)
|
private async Task SendPushNotificationAsync(SnNotificationPushSubscription subscription,
|
||||||
|
SnNotification notification)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -302,7 +308,9 @@ public class PushService
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (fcmResult.Error != null)
|
if (fcmResult.StatusCode is 404 or 410)
|
||||||
|
_fbs.Enqueue(new PushSubRemovalRequest { SubId = subscription.Id });
|
||||||
|
else if (fcmResult.Error != null)
|
||||||
throw new Exception($"Notification pushed failed ({fcmResult.StatusCode}) {fcmResult.Error}");
|
throw new Exception($"Notification pushed failed ({fcmResult.StatusCode}) {fcmResult.Error}");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -338,7 +346,10 @@ public class PushService
|
|||||||
apnsPriority: notification.Priority,
|
apnsPriority: notification.Priority,
|
||||||
apnPushType: ApnPushType.Alert
|
apnPushType: ApnPushType.Alert
|
||||||
);
|
);
|
||||||
if (apnResult.Error != null)
|
|
||||||
|
if (apnResult.StatusCode is 404 or 410)
|
||||||
|
_fbs.Enqueue(new PushSubRemovalRequest { SubId = subscription.Id });
|
||||||
|
else if (apnResult.Error != null)
|
||||||
throw new Exception($"Notification pushed failed ({apnResult.StatusCode}) {apnResult.Error}");
|
throw new Exception($"Notification pushed failed ({apnResult.StatusCode}) {apnResult.Error}");
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|||||||
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;
|
||||||
}
|
}
|
||||||
@@ -129,4 +152,4 @@ public static class Extensions
|
|||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
@@ -128,4 +170,4 @@ public class SnAuthClientWithChallenge : SnAuthClient
|
|||||||
AccountId = client.AccountId,
|
AccountId = client.AccountId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)]
|
||||||
@@ -27,4 +27,40 @@ public class SnMagicSpell : ModelBase
|
|||||||
|
|
||||||
public Guid? AccountId { get; set; }
|
public Guid? AccountId { get; set; }
|
||||||
public SnAccount? Account { get; set; }
|
public SnAccount? Account { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AffiliationSpellType
|
||||||
|
{
|
||||||
|
RegistrationInvite
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Different from the magic spell, this is for the regeneration invite and other marketing usage.
|
||||||
|
/// </summary>
|
||||||
|
[Index(nameof(Spell), IsUnique = true)]
|
||||||
|
public class SnAffiliationSpell : ModelBase
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
[MaxLength(1024)] public string Spell { get; set; } = null!;
|
||||||
|
public AffiliationSpellType Type { get; set; }
|
||||||
|
public Instant? ExpiresAt { get; set; }
|
||||||
|
public Instant? AffectedAt { get; set; }
|
||||||
|
[Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
|
||||||
|
|
||||||
|
public List<SnAffiliationResult> Results = [];
|
||||||
|
|
||||||
|
public Guid? AccountId { get; set; }
|
||||||
|
public SnAccount? Account { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The record for who used the affiliation spells
|
||||||
|
/// </summary>
|
||||||
|
public class SnAffiliationResult : ModelBase
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
[MaxLength(8192)] public string ResourceIdentifier { get; set; } = null!;
|
||||||
|
|
||||||
|
public Guid SpellId { get; set; }
|
||||||
|
[JsonIgnore] public SnAffiliationSpell Spell { get; set; } = null!;
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -20,6 +20,19 @@ public enum PublicationSiteMode
|
|||||||
SelfManaged
|
SelfManaged
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class PublicationSiteConfig
|
||||||
|
{
|
||||||
|
public string? StyleOverride { get; set; }
|
||||||
|
public List<PublicationSiteNavItem>? NavItems { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PublicationSiteNavItem
|
||||||
|
{
|
||||||
|
[MaxLength(1024)] public string Label { get; set; } = null!;
|
||||||
|
[MaxLength(8192)] public string Href { get; set; } = null!;
|
||||||
|
Dictionary<string, object> Attributes { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
public class SnPublicationSite : ModelBase
|
public class SnPublicationSite : ModelBase
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
@@ -29,6 +42,7 @@ public class SnPublicationSite : ModelBase
|
|||||||
|
|
||||||
public PublicationSiteMode Mode { get; set; } = PublicationSiteMode.FullyManaged;
|
public PublicationSiteMode Mode { get; set; } = PublicationSiteMode.FullyManaged;
|
||||||
public List<SnPublicationPage> Pages { get; set; } = [];
|
public List<SnPublicationPage> Pages { get; set; } = [];
|
||||||
|
[Column(TypeName = "jsonb")] public PublicationSiteConfig Config { get; set; } = new();
|
||||||
|
|
||||||
public Guid PublisherId { get; set; }
|
public Guid PublisherId { get; set; }
|
||||||
[NotMapped] public SnPublisher Publisher { get; set; } = null!;
|
[NotMapped] public SnPublisher Publisher { get; set; } = null!;
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -1054,4 +1101,4 @@ public class ChatRoomController(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user