Compare commits

...

138 Commits

Author SHA1 Message Date
24c756a9a8 🗑️ Remove gateway got replaced by turbine one 2025-12-13 19:49:56 +08:00
7ecb64742f ♻️ Updated discovery resolver 2025-12-13 19:28:24 +08:00
3a7140f0a6 ♻️ Update service discovery code 2025-12-13 18:52:55 +08:00
42082fbefa 🔨 Reconfigured to use new discovery 2025-12-13 17:38:49 +08:00
bc3d030a1e New service discovery system 2025-12-13 14:23:28 +08:00
8642737a07 Configurable post page 2025-12-12 00:10:57 +08:00
8181938aaf Managed mode page will render with layout 2025-12-11 22:25:40 +08:00
922afc2239 🐛 Fix realm query 2025-12-10 22:59:18 +08:00
a071bd2738 Publication site global config data structure 2025-12-10 19:33:00 +08:00
43945fc524 🐛 Fix discovery realms order incorrect 2025-12-07 14:28:41 +08:00
e477429a35 👔 Increase the chance of other type of activities show up
🗑️ Remove debug include in timeline
2025-12-06 21:12:08 +08:00
fe3a057185 👔 Discovery realms will show desc by member count 2025-12-06 21:10:08 +08:00
ad3c104c5c Proper trace for auth session 2025-12-04 00:38:44 +08:00
2020d625aa 🗃️ Add migration of add sticker pack icon 2025-12-04 00:27:09 +08:00
f471c5635d Post article thumbnail 2025-12-04 00:26:54 +08:00
eaeaa28c60 Sticker icon 2025-12-04 00:19:36 +08:00
ee5c7cb7ce 🐛 Fix get device API 2025-12-03 23:29:31 +08:00
33abf12e41 🐛 Fix pass service swagger docs duplicate schema name cause 500 2025-12-03 22:46:47 +08:00
4a71f92ef0 ♻️ Updated auth challenges and device API to fit new design 2025-12-03 22:43:35 +08:00
4faa1a4b64 🐛 Fix message pack cache serilaize issue in sticker 2025-12-03 22:09:56 +08:00
e49a1ec49a Push token clean up when invalid 2025-12-03 21:42:18 +08:00
a88f42b26a Rolling back to old logic to provide mock device id in websocket gateway 2025-12-03 21:30:29 +08:00
c45be62331 Support switching from JSON to MessagePack in cache during runtime 2025-12-03 21:27:26 +08:00
c8228e0c8e Use JSON to serialize cache 2025-12-03 01:47:57 +08:00
c642c6d646 Resend self activation email API 2025-12-03 01:17:39 +08:00
270c211cb8 ♻️ Refactored to make a simplifier auth session system 2025-12-03 00:38:28 +08:00
74c8f3490d 🐛 Fix the message pack serializer 2025-12-03 00:38:12 +08:00
b364edc74b Use Json Serializer in cache again 2025-12-02 22:59:43 +08:00
9addf38677 🐛 Enable contractless serilization in cache to fix message pack serilizer 2025-12-02 22:51:12 +08:00
a02ed10434 🐛 Fix use wrong DI type in cache service 2025-12-02 22:45:30 +08:00
aca28f9318 ♻️ Refactored the cache service 2025-12-02 22:38:47 +08:00
c2f72993b7 🐛 Fix app snapshot didn't included in release 2025-12-02 21:52:24 +08:00
158cc75c5b 💥 Simplified permission node system and data structure 2025-12-02 21:42:26 +08:00
fa2f53ff7a 🐛 Fix file reference created with wrong date 2025-12-02 21:03:57 +08:00
2cce5ebf80 Use affiliation spell for registeration 2025-12-02 00:54:57 +08:00
13b2e46ecc Affliation spell CRUD 2025-12-01 23:33:48 +08:00
cbd68c9ae6 Proper site manager send file method 2025-12-01 22:55:20 +08:00
b99b61e0f9 🐛 Fix chat backward comapbility 2025-11-30 21:33:39 +08:00
94f4e68120 Timeout prevent send message logic 2025-11-30 21:13:54 +08:00
d5510f7e4d Chat timeout APIs
🐛 Fix member listing in chat
2025-11-30 21:08:07 +08:00
c038ab9e3c ♻️ A more robust and simpler chat system 2025-11-30 20:58:48 +08:00
e97719ec84 🗃️ Add missing account id migrations 2025-11-30 20:13:15 +08:00
40b8ea8eb8 🗃️ Bring account id back to chat room 2025-11-30 19:59:30 +08:00
f9b4dd45d7 🐛 Trying to fix relationship bugs 2025-11-30 17:52:19 +08:00
a46de4662c 🐛 Fix gateway 2025-11-30 17:51:27 +08:00
fdd14b860e 🐛 Fix wrong required status of validate account create request 2025-11-30 17:37:34 +08:00
cb62df81e2 👔 Adjust lookup account logic 2025-11-30 17:20:20 +08:00
46717e39a7 Admin delete account endpoint 2025-11-30 17:19:33 +08:00
344ed6e348 Account validation endpoint 2025-11-30 17:16:11 +08:00
a8b62fb0eb Auth via authorized device 2025-11-30 00:00:13 +08:00
00b3087d6a ♻️ Refactored auth service for better security 2025-11-29 18:00:23 +08:00
78f3873a0c 🐛 Fix birthday check in 2025-11-27 22:22:22 +08:00
a7f4173df7 Special birthday check in tips 2025-11-27 21:49:25 +08:00
f51c3c1724 🐛 Fix birthday check in result didn't show up 2025-11-27 21:41:30 +08:00
a92dc7e140 👔 Remove single file 1MB limit in site 2025-11-24 22:54:16 +08:00
c42befed6b ♻️ Refactored notification meta 2025-11-23 13:20:40 +08:00
2b95d58611 All unread messages endpoint 2025-11-23 12:28:57 +08:00
726a752fbb :zsap: Pagination in chat sync 2025-11-23 12:07:58 +08:00
2024972832 🐛 Trying to fix Pass service issues 2025-11-23 03:02:51 +08:00
d553ca2ca7 🐛 Dozens of bug fixes in chat 2025-11-23 01:17:15 +08:00
aeef16495f 🐛 Fix sitemap and rss still respond all types of posts 2025-11-22 18:55:29 +08:00
9b26a2a7eb 🐛 Fix replace of markdown convertion 2025-11-22 18:53:48 +08:00
2317033dae 👔 Stop rendering post attachments in article post on hosted pages 2025-11-22 18:24:32 +08:00
fd6e9c9780 🐛 Fix some stupid bugs 2025-11-22 18:22:53 +08:00
af0a2ff493 💄 Enrich post susbcription notification 2025-11-22 18:08:11 +08:00
b142a71c32 🐛 Fix publisher member didn't include publisher in response 2025-11-22 17:57:17 +08:00
27e3cc853a 🐛 Fix post service grpc call made type filter wrong 2025-11-22 17:55:45 +08:00
590519c28f 🐛 Fix index shows all type of posts in managed page 2025-11-22 17:53:52 +08:00
8ccf8100d4 👔 Make listing on the hosted page shows article only 2025-11-22 17:50:19 +08:00
ec21a94921 🐛 Serval bug fixes in hosted page 2025-11-22 17:43:52 +08:00
7b7a6c9218 Extend the ability of the hosted page markdown parser 2025-11-22 17:40:17 +08:00
0e44d9c514 🐛 Fix publisher invite controller still use int user id 2025-11-22 17:25:45 +08:00
e449e16d33 🐛 Fix pagination overflow in hosted page 2025-11-22 17:20:36 +08:00
3ce2b36c15 🐛 Fix featured post on hosted page uses wrong order 2025-11-22 17:13:02 +08:00
f7388822e0 🐛 Unable to use random split in open fund 2025-11-22 16:54:29 +08:00
3800dae8b7 SEO optimization on the hosted pages 2025-11-22 16:45:44 +08:00
c62ed191f3 File deploy smart mode 2025-11-22 16:00:30 +08:00
8b77f0e0ad Site management purge files and deploy from zip 2025-11-22 15:50:20 +08:00
2b56c6f1e5 Static site hosting support access directory as index.html 2025-11-22 15:49:29 +08:00
ef02265ccd 💄 Optimize hosted page index 2025-11-22 14:16:40 +08:00
f4505d2ecc 💄 Add titles to the hosted pages 2025-11-22 13:49:26 +08:00
9d2242d331 💄 Hosted page SEO optimization 2025-11-22 13:42:13 +08:00
c806365a81 Render markdown on hosted pages 2025-11-22 13:28:37 +08:00
bd1715c9a3 💄 Optimize hosted post details page 2025-11-22 13:17:34 +08:00
0b0598712e 💄 Updated the hosted site post page 2025-11-22 13:09:57 +08:00
92a4899e7c The posts page basis 2025-11-22 02:33:22 +08:00
bdc8db3091 About page also contains site info 2025-11-22 02:18:57 +08:00
a16da37221 Account about page 2025-11-22 01:47:10 +08:00
70a18b07ff 🐛 Bug fixes in the publication site hosting 2025-11-21 23:36:38 +08:00
98b8d5f33b ♻️ New error page 2025-11-21 23:30:43 +08:00
2a35786204 🐛 Fix self-managed files hosting 2025-11-21 22:27:27 +08:00
7016a0a943 Render self-managed site 2025-11-21 01:55:22 +08:00
cad72502d9 Managed page rendering 2025-11-21 01:41:25 +08:00
226a64df41 💄 Optimize the page rendering in zone 2025-11-21 01:21:40 +08:00
75b8567a28 🐛 Fix file management of the site 2025-11-21 00:40:58 +08:00
3aa5561a07 🐛 Fix hosted sites 2025-11-20 23:47:41 +08:00
c0ebb496fe Site manager 2025-11-20 22:54:24 +08:00
afccb27bd4 Site mode 2025-11-20 22:40:36 +08:00
6ed96780ab 💥 Improvements in the URL of the publication site 2025-11-20 21:29:32 +08:00
8e5cdfbc62 Zone site placeholder 2025-11-19 23:14:22 +08:00
1b774c1de6 ♻️ Moved the site to the Zone project 2025-11-19 22:34:01 +08:00
9b4cbade5c :heavy_plus_arrow: Add alpine.js to zone 2025-11-19 22:05:26 +08:00
a52e54f672 🔨 Setup the docker build for tailwindcss 2025-11-19 22:02:26 +08:00
aa48d5e25d 🔨 Setup the tailwindcss and daisyui frontend for the zone 2025-11-19 21:33:14 +08:00
ce18b194a5 🔨 Finish the initial setup of the Zone project 2025-11-19 21:12:07 +08:00
382579a20e 🎉 Initial commit for the Zone project 2025-11-19 21:04:44 +08:00
18d50346a9 👔 Update publication site limits for perk members 2025-11-19 00:48:36 +08:00
ac51bbde6c Publication Sites aka Solian Pages 2025-11-18 23:39:00 +08:00
4ab0dcf1c2 🐛 Fix file reference JSON loop 2025-11-18 21:52:21 +08:00
587066d847 Delete files in batch API 2025-11-18 20:33:02 +08:00
faa375042a New drive api order etc 2025-11-18 18:50:39 +08:00
65b6f3a606 🐛 Fix bugs 2025-11-18 18:40:23 +08:00
fa1a40c637 File references listing endpoint 2025-11-18 01:06:02 +08:00
d43ce7cb11 🗑️ Remove the fast upload endpoint 2025-11-18 00:55:29 +08:00
92b28d830d Drive file name query 2025-11-18 00:48:35 +08:00
1fa6c893a5 🐛 Fix compile errors 2025-11-18 00:34:50 +08:00
ba57becba8 ♻️ Replace the soft delete logic with the new shared one 2025-11-17 23:43:59 +08:00
4280168002 🐛 Try to fix the soft delete filter didn't work in drive 2025-11-17 23:19:03 +08:00
a172128d84 🐛 Hide wrongly exposed method in FileController 2025-11-17 22:37:10 +08:00
34e78294a1 Unindexed files has similar filter to the list file API 2025-11-17 22:20:49 +08:00
82afdb3922 🐛 Fix unable to claim fund due to db issue 2025-11-17 01:12:00 +08:00
260b3e7bc6 🐛 Fix recieve fund save db together to prevent cocurrent db save 2025-11-17 00:49:10 +08:00
713777cd8a 🐛 Trying to fix actually affected 0 row 2025-11-17 00:43:12 +08:00
5cd09bc2d0 Open fund total amount of splits 2025-11-17 00:36:15 +08:00
861fc7cafa 🐛 Tried to fix fund claim cocurrency issue 2025-11-17 00:18:57 +08:00
6313f15375 Open funds 2025-11-16 23:32:03 +08:00
337cc1be97 👔 Allow to send poll only message 2025-11-16 22:52:43 +08:00
9b4f61fcda Embeddable funds
 Chat message embeddable poll
2025-11-16 21:22:45 +08:00
6252988390 Optimize typing indicator 2025-11-16 20:41:34 +08:00
aace3b48b1 Sharable thought 2025-11-16 20:36:04 +08:00
5a097c7518 🐛 Allow user to implitctly set oidc flow type 2025-11-16 18:30:03 +08:00
ba3be1e3bb 🔊 Add verbose logs for oidc 2025-11-16 17:05:28 +08:00
6fd90c424d ♻️ Refactored oidc onboard flow 2025-11-16 15:05:29 +08:00
a0ac3b5820 Friends overview online filter 2025-11-16 13:31:07 +08:00
076bf347c8 Account friends overview endpoint 2025-11-16 12:29:56 +08:00
788326381f Multi model support 2025-11-16 02:44:44 +08:00
a035b23242 Support multiple models in thought 2025-11-16 01:22:07 +08:00
b29f4fce4d Insight proper payment validation 2025-11-16 01:06:33 +08:00
274 changed files with 42735 additions and 4547 deletions

View File

@@ -27,8 +27,8 @@ jobs:
run: |
files="${{ steps.changed-files.outputs.files }}"
matrix="{\"include\":[]}"
services=("Sphere" "Pass" "Ring" "Drive" "Develop" "Gateway" "Insight")
images=("sphere" "pass" "ring" "drive" "develop" "gateway" "insight")
services=("Sphere" "Pass" "Ring" "Drive" "Develop" "Gateway" "Insight" "Zone")
images=("sphere" "pass" "ring" "drive" "develop" "gateway" "insight" "zone")
changed_services=()
for file in $files; do

View File

@@ -1,71 +0,0 @@
using Microsoft.Extensions.Hosting;
var builder = DistributedApplication.CreateBuilder(args);
var isDev = builder.Environment.IsDevelopment();
var cache = builder.AddRedis("cache");
var queue = builder.AddNats("queue").WithJetStream();
var ringService = builder.AddProject<Projects.DysonNetwork_Ring>("ring");
var passService = builder.AddProject<Projects.DysonNetwork_Pass>("pass")
.WithReference(ringService);
var driveService = builder.AddProject<Projects.DysonNetwork_Drive>("drive")
.WithReference(passService)
.WithReference(ringService);
var sphereService = builder.AddProject<Projects.DysonNetwork_Sphere>("sphere")
.WithReference(passService)
.WithReference(ringService)
.WithReference(driveService);
var developService = builder.AddProject<Projects.DysonNetwork_Develop>("develop")
.WithReference(passService)
.WithReference(ringService)
.WithReference(sphereService);
var insightService = builder.AddProject<Projects.DysonNetwork_Insight>("insight")
.WithReference(passService)
.WithReference(ringService)
.WithReference(sphereService)
.WithReference(developService);
passService.WithReference(developService).WithReference(driveService);
List<IResourceBuilder<ProjectResource>> services =
[ringService, passService, driveService, sphereService, developService, insightService];
for (var idx = 0; idx < services.Count; idx++)
{
var service = services[idx];
service.WithReference(cache).WithReference(queue);
var grpcPort = 7002 + idx;
if (isDev)
{
service.WithEnvironment("GRPC_PORT", grpcPort.ToString());
var httpPort = 8001 + idx;
service.WithEnvironment("HTTP_PORTS", httpPort.ToString());
service.WithHttpEndpoint(httpPort, targetPort: null, isProxied: false, name: "http");
}
else
{
service.WithHttpEndpoint(8080, targetPort: null, isProxied: false, name: "http");
}
service.WithEndpoint(isDev ? grpcPort : 7001, isDev ? null : 7001, "https", name: "grpc", isProxied: false);
}
// Extra double-ended references
ringService.WithReference(passService);
var gateway = builder.AddProject<Projects.DysonNetwork_Gateway>("gateway")
.WithEnvironment("HTTP_PORTS", "5001")
.WithHttpEndpoint(port: 5001, targetPort: null, isProxied: false, name: "http");
foreach (var service in services)
gateway.WithReference(service);
builder.AddDockerComposeEnvironment("docker-compose");
builder.Build().Run();

View File

@@ -1,28 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="13.0.0" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>a68b3195-a00d-40c2-b5ed-d675356b7cde</UserSecretsId>
<RootNamespace>DysonNetwork.Control</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.0.0" />
<PackageReference Include="Aspire.Hosting.Docker" Version="13.0.0-preview.1.25560.3" />
<PackageReference Include="Aspire.Hosting.Nats" Version="13.0.0" />
<PackageReference Include="Aspire.Hosting.Redis" Version="13.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.Develop\DysonNetwork.Develop.csproj" />
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
<ProjectReference Include="..\DysonNetwork.Pass\DysonNetwork.Pass.csproj" />
<ProjectReference Include="..\DysonNetwork.Ring\DysonNetwork.Ring.csproj" />
<ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" />
<ProjectReference Include="..\DysonNetwork.Gateway\DysonNetwork.Gateway.csproj" />
<ProjectReference Include="..\DysonNetwork.Insight\DysonNetwork.Insight.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,32 +0,0 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:17169;http://localhost:15057",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21175",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22189",
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21260",
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22052"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:15057",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19163",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20185",
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:22108"
}
}
}
}

View File

@@ -1,11 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"cache": "localhost:6379"
}
}

View File

@@ -1,357 +0,0 @@
{
"$schema": "https://json.schemastore.org/aspire-8.0.json",
"resources": {
"cache": {
"type": "container.v1",
"connectionString": "{cache.bindings.tcp.host}:{cache.bindings.tcp.port},password={cache-password.value}",
"image": "docker.io/library/redis:8.2",
"entrypoint": "/bin/sh",
"args": [
"-c",
"redis-server --requirepass $REDIS_PASSWORD"
],
"env": {
"REDIS_PASSWORD": "{cache-password.value}"
},
"bindings": {
"tcp": {
"scheme": "tcp",
"protocol": "tcp",
"transport": "tcp",
"targetPort": 6379
}
}
},
"queue": {
"type": "container.v1",
"connectionString": "nats://nats:{queue-password.value}@{queue.bindings.tcp.host}:{queue.bindings.tcp.port}",
"image": "docker.io/library/nats:2.11",
"args": [
"--user",
"nats",
"--pass",
"{queue-password.value}",
"-js"
],
"bindings": {
"tcp": {
"scheme": "tcp",
"protocol": "tcp",
"transport": "tcp",
"targetPort": 4222
}
}
},
"ring": {
"type": "project.v1",
"path": "../DysonNetwork.Ring/DysonNetwork.Ring.csproj",
"env": {
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
"OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
"ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
"HTTP_PORTS": "8001",
"HTTPS_PORTS": "{ring.bindings.grpc.targetPort}",
"ConnectionStrings__cache": "{cache.connectionString}",
"ConnectionStrings__queue": "{queue.connectionString}",
"GRPC_PORT": "7002",
"services__pass__http__0": "{pass.bindings.http.url}",
"services__pass__grpc__0": "{pass.bindings.grpc.url}",
"OTEL_EXPORTER_OTLP_ENDPOINT": "{docker-compose-dashboard.bindings.otlp-grpc.url}",
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
"OTEL_SERVICE_NAME": "ring"
},
"bindings": {
"http": {
"scheme": "http",
"protocol": "tcp",
"transport": "http",
"targetPort": 8001
},
"grpc": {
"scheme": "https",
"protocol": "tcp",
"transport": "http",
"targetPort": 7002
}
}
},
"pass": {
"type": "project.v1",
"path": "../DysonNetwork.Pass/DysonNetwork.Pass.csproj",
"env": {
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
"OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
"ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
"HTTP_PORTS": "8002",
"HTTPS_PORTS": "{pass.bindings.grpc.targetPort}",
"services__ring__http__0": "{ring.bindings.http.url}",
"services__ring__grpc__0": "{ring.bindings.grpc.url}",
"services__develop__http__0": "{develop.bindings.http.url}",
"services__develop__grpc__0": "{develop.bindings.grpc.url}",
"services__drive__http__0": "{drive.bindings.http.url}",
"services__drive__grpc__0": "{drive.bindings.grpc.url}",
"ConnectionStrings__cache": "{cache.connectionString}",
"ConnectionStrings__queue": "{queue.connectionString}",
"GRPC_PORT": "7003",
"OTEL_EXPORTER_OTLP_ENDPOINT": "{docker-compose-dashboard.bindings.otlp-grpc.url}",
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
"OTEL_SERVICE_NAME": "pass"
},
"bindings": {
"http": {
"scheme": "http",
"protocol": "tcp",
"transport": "http",
"targetPort": 8002
},
"grpc": {
"scheme": "https",
"protocol": "tcp",
"transport": "http",
"targetPort": 7003
}
}
},
"drive": {
"type": "project.v1",
"path": "../DysonNetwork.Drive/DysonNetwork.Drive.csproj",
"env": {
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
"OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
"ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
"HTTP_PORTS": "8003",
"HTTPS_PORTS": "{drive.bindings.grpc.targetPort}",
"services__pass__http__0": "{pass.bindings.http.url}",
"services__pass__grpc__0": "{pass.bindings.grpc.url}",
"services__ring__http__0": "{ring.bindings.http.url}",
"services__ring__grpc__0": "{ring.bindings.grpc.url}",
"ConnectionStrings__cache": "{cache.connectionString}",
"ConnectionStrings__queue": "{queue.connectionString}",
"GRPC_PORT": "7004",
"OTEL_EXPORTER_OTLP_ENDPOINT": "{docker-compose-dashboard.bindings.otlp-grpc.url}",
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
"OTEL_SERVICE_NAME": "drive"
},
"bindings": {
"http": {
"scheme": "http",
"protocol": "tcp",
"transport": "http",
"targetPort": 8003
},
"grpc": {
"scheme": "https",
"protocol": "tcp",
"transport": "http",
"targetPort": 7004
}
}
},
"sphere": {
"type": "project.v1",
"path": "../DysonNetwork.Sphere/DysonNetwork.Sphere.csproj",
"env": {
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
"OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
"ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
"HTTP_PORTS": "8004",
"HTTPS_PORTS": "{sphere.bindings.grpc.targetPort}",
"services__pass__http__0": "{pass.bindings.http.url}",
"services__pass__grpc__0": "{pass.bindings.grpc.url}",
"services__ring__http__0": "{ring.bindings.http.url}",
"services__ring__grpc__0": "{ring.bindings.grpc.url}",
"services__drive__http__0": "{drive.bindings.http.url}",
"services__drive__grpc__0": "{drive.bindings.grpc.url}",
"ConnectionStrings__cache": "{cache.connectionString}",
"ConnectionStrings__queue": "{queue.connectionString}",
"GRPC_PORT": "7005",
"OTEL_EXPORTER_OTLP_ENDPOINT": "{docker-compose-dashboard.bindings.otlp-grpc.url}",
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
"OTEL_SERVICE_NAME": "sphere"
},
"bindings": {
"http": {
"scheme": "http",
"protocol": "tcp",
"transport": "http",
"targetPort": 8004
},
"grpc": {
"scheme": "https",
"protocol": "tcp",
"transport": "http",
"targetPort": 7005
}
}
},
"develop": {
"type": "project.v1",
"path": "../DysonNetwork.Develop/DysonNetwork.Develop.csproj",
"env": {
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
"OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
"ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
"HTTP_PORTS": "8005",
"HTTPS_PORTS": "{develop.bindings.grpc.targetPort}",
"services__pass__http__0": "{pass.bindings.http.url}",
"services__pass__grpc__0": "{pass.bindings.grpc.url}",
"services__ring__http__0": "{ring.bindings.http.url}",
"services__ring__grpc__0": "{ring.bindings.grpc.url}",
"services__sphere__http__0": "{sphere.bindings.http.url}",
"services__sphere__grpc__0": "{sphere.bindings.grpc.url}",
"ConnectionStrings__cache": "{cache.connectionString}",
"ConnectionStrings__queue": "{queue.connectionString}",
"GRPC_PORT": "7006",
"OTEL_EXPORTER_OTLP_ENDPOINT": "{docker-compose-dashboard.bindings.otlp-grpc.url}",
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
"OTEL_SERVICE_NAME": "develop"
},
"bindings": {
"http": {
"scheme": "http",
"protocol": "tcp",
"transport": "http",
"targetPort": 8005
},
"grpc": {
"scheme": "https",
"protocol": "tcp",
"transport": "http",
"targetPort": 7006
}
}
},
"insight": {
"type": "project.v1",
"path": "../DysonNetwork.Insight/DysonNetwork.Insight.csproj",
"env": {
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
"OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
"ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
"HTTP_PORTS": "8006",
"HTTPS_PORTS": "{insight.bindings.grpc.targetPort}",
"services__pass__http__0": "{pass.bindings.http.url}",
"services__pass__grpc__0": "{pass.bindings.grpc.url}",
"services__ring__http__0": "{ring.bindings.http.url}",
"services__ring__grpc__0": "{ring.bindings.grpc.url}",
"services__sphere__http__0": "{sphere.bindings.http.url}",
"services__sphere__grpc__0": "{sphere.bindings.grpc.url}",
"services__develop__http__0": "{develop.bindings.http.url}",
"services__develop__grpc__0": "{develop.bindings.grpc.url}",
"ConnectionStrings__cache": "{cache.connectionString}",
"ConnectionStrings__queue": "{queue.connectionString}",
"GRPC_PORT": "7007",
"OTEL_EXPORTER_OTLP_ENDPOINT": "{docker-compose-dashboard.bindings.otlp-grpc.url}",
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
"OTEL_SERVICE_NAME": "insight"
},
"bindings": {
"http": {
"scheme": "http",
"protocol": "tcp",
"transport": "http",
"targetPort": 8006
},
"grpc": {
"scheme": "https",
"protocol": "tcp",
"transport": "http",
"targetPort": 7007
}
}
},
"gateway": {
"type": "project.v1",
"path": "../DysonNetwork.Gateway/DysonNetwork.Gateway.csproj",
"env": {
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
"OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
"ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
"HTTP_PORTS": "5001",
"services__ring__http__0": "{ring.bindings.http.url}",
"services__ring__grpc__0": "{ring.bindings.grpc.url}",
"services__pass__http__0": "{pass.bindings.http.url}",
"services__pass__grpc__0": "{pass.bindings.grpc.url}",
"services__drive__http__0": "{drive.bindings.http.url}",
"services__drive__grpc__0": "{drive.bindings.grpc.url}",
"services__sphere__http__0": "{sphere.bindings.http.url}",
"services__sphere__grpc__0": "{sphere.bindings.grpc.url}",
"services__develop__http__0": "{develop.bindings.http.url}",
"services__develop__grpc__0": "{develop.bindings.grpc.url}",
"services__insight__http__0": "{insight.bindings.http.url}",
"services__insight__grpc__0": "{insight.bindings.grpc.url}",
"OTEL_EXPORTER_OTLP_ENDPOINT": "{docker-compose-dashboard.bindings.otlp-grpc.url}",
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
"OTEL_SERVICE_NAME": "gateway"
},
"bindings": {
"http": {
"scheme": "http",
"protocol": "tcp",
"transport": "http",
"targetPort": 5001
}
}
},
"docker-compose": {
"error": "This resource does not support generation in the manifest."
},
"cache-password": {
"type": "parameter.v0",
"value": "{cache-password.inputs.value}",
"inputs": {
"value": {
"type": "string",
"secret": true,
"default": {
"generate": {
"minLength": 22,
"special": false
}
}
}
}
},
"queue-password": {
"type": "parameter.v0",
"value": "{queue-password.inputs.value}",
"inputs": {
"value": {
"type": "string",
"secret": true,
"default": {
"generate": {
"minLength": 22,
"special": false
}
}
}
}
},
"docker-compose-dashboard": {
"type": "container.v1",
"image": "mcr.microsoft.com/dotnet/nightly/aspire-dashboard:latest",
"bindings": {
"http": {
"scheme": "http",
"protocol": "tcp",
"transport": "http",
"targetPort": 18888
},
"otlp-grpc": {
"scheme": "http",
"protocol": "tcp",
"transport": "http",
"targetPort": 18889
}
}
}
}
}

View File

@@ -1,3 +1,4 @@
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
@@ -33,36 +34,15 @@ public class AppDatabase(
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var now = SystemClock.Instance.GetCurrentInstant();
foreach (var entry in ChangeTracker.Entries<ModelBase>())
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedAt = now;
entry.Entity.UpdatedAt = now;
break;
case EntityState.Modified:
entry.Entity.UpdatedAt = now;
break;
case EntityState.Deleted:
entry.State = EntityState.Modified;
entry.Entity.DeletedAt = now;
break;
case EntityState.Detached:
case EntityState.Unchanged:
default:
break;
}
}
this.ApplyAuditableAndSoftDelete();
return await base.SaveChangesAsync(cancellationToken);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplySoftDeleteFilters();
}
}

View File

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

View File

@@ -7,16 +7,15 @@ using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.AddServiceDefaults("develop");
builder.Services.Configure<ServiceRegistrationOptions>(opts => { opts.Name = "develop"; });
builder.ConfigureAppKestrel(builder.Configuration);
builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppAuthentication();
builder.Services.AddDysonAuth();
builder.Services.AddSphereService();
builder.Services.AddAccountService();
builder.Services.AddDriveService();
builder.AddSwaggerManifest(
"DysonNetwork.Develop",

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,11 @@
using System.Linq.Expressions;
using System.Reflection;
using DysonNetwork.Drive.Billing;
using DysonNetwork.Drive.Storage;
using DysonNetwork.Drive.Storage.Model;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Query;
using NodaTime;
using Quartz;
using TaskStatus = DysonNetwork.Drive.Storage.Model.TaskStatus;
@@ -24,7 +23,7 @@ public class AppDatabase(
public DbSet<QuotaRecord> QuotaRecords { get; set; } = null!;
public DbSet<SnCloudFile> Files { get; set; } = null!;
public DbSet<CloudFileReference> FileReferences { get; set; } = null!;
public DbSet<SnCloudFileReference> FileReferences { get; set; } = null!;
public DbSet<SnCloudFileIndex> FileIndexes { get; set; }
public DbSet<PersistentTask> Tasks { get; set; } = null!;
@@ -46,61 +45,12 @@ public class AppDatabase(
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Apply soft-delete filter only to root entities, not derived types
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (!typeof(ModelBase).IsAssignableFrom(entityType.ClrType)) continue;
// Skip derived types to avoid filter conflicts
var clrType = entityType.ClrType;
if (clrType.BaseType != typeof(object) &&
typeof(ModelBase).IsAssignableFrom(clrType.BaseType))
{
continue; // Skip derived types
}
var method = typeof(AppDatabase)
.GetMethod(nameof(SetSoftDeleteFilter),
BindingFlags.NonPublic | BindingFlags.Static)!
.MakeGenericMethod(clrType);
method.Invoke(null, [modelBuilder]);
}
}
private static void SetSoftDeleteFilter<TEntity>(ModelBuilder modelBuilder)
where TEntity : ModelBase
{
modelBuilder.Entity<TEntity>().HasQueryFilter(e => e.DeletedAt == null);
modelBuilder.ApplySoftDeleteFilters();
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var now = SystemClock.Instance.GetCurrentInstant();
foreach (var entry in ChangeTracker.Entries<ModelBase>())
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedAt = now;
entry.Entity.UpdatedAt = now;
break;
case EntityState.Modified:
entry.Entity.UpdatedAt = now;
break;
case EntityState.Deleted:
entry.State = EntityState.Modified;
entry.Entity.DeletedAt = now;
break;
case EntityState.Detached:
case EntityState.Unchanged:
default:
break;
}
}
this.ApplyAuditableAndSoftDelete();
return await base.SaveChangesAsync(cancellationToken);
}
}
@@ -204,35 +154,3 @@ public class AppDatabaseFactory : IDesignTimeDbContextFactory<AppDatabase>
return new AppDatabase(optionsBuilder.Options, configuration);
}
}
public static class OptionalQueryExtensions
{
public static IQueryable<T> If<T>(
this IQueryable<T> source,
bool condition,
Func<IQueryable<T>, IQueryable<T>> transform
)
{
return condition ? transform(source) : source;
}
public static IQueryable<T> If<T, TP>(
this IIncludableQueryable<T, TP> source,
bool condition,
Func<IIncludableQueryable<T, TP>, IQueryable<T>> transform
)
where T : class
{
return condition ? transform(source) : source;
}
public static IQueryable<T> If<T, TP>(
this IIncludableQueryable<T, IEnumerable<TP>> source,
bool condition,
Func<IIncludableQueryable<T, IEnumerable<TP>>, IQueryable<T>> transform
)
where T : class
{
return condition ? transform(source) : source;
}
}

View File

@@ -23,25 +23,51 @@ public class FileIndexController(
/// Gets files in a specific path for the current user
/// </summary>
/// <param name="path">The path to browse (defaults to root "/")</param>
/// <param name="query">Optional query to filter files by name</param>
/// <param name="order">The field to order by (date, size, name - defaults to date)</param>
/// <param name="orderDesc">Whether to order in descending order (defaults to true)</param>
/// <returns>List of files in the specified path</returns>
[HttpGet("browse")]
public async Task<IActionResult> BrowseFiles([FromQuery] string path = "/")
public async Task<IActionResult> BrowseFiles(
[FromQuery] string path = "/",
[FromQuery] string? query = null,
[FromQuery] string order = "date",
[FromQuery] bool orderDesc = true
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
var accountId = Guid.Parse(currentUser.Id);
try
{
var fileIndexes = await fileIndexService.GetByPathAsync(accountId, path);
if (!string.IsNullOrWhiteSpace(query))
{
fileIndexes = fileIndexes
.Where(fi => fi.File.Name.Contains(query, StringComparison.OrdinalIgnoreCase))
.ToList();
}
// Apply sorting
fileIndexes = order.ToLower() switch
{
"name" => orderDesc ? fileIndexes.OrderByDescending(fi => fi.File.Name).ToList()
: fileIndexes.OrderBy(fi => fi.File.Name).ToList(),
"size" => orderDesc ? fileIndexes.OrderByDescending(fi => fi.File.Size).ToList()
: fileIndexes.OrderBy(fi => fi.File.Size).ToList(),
_ => orderDesc ? fileIndexes.OrderByDescending(fi => fi.File.CreatedAt).ToList()
: fileIndexes.OrderBy(fi => fi.File.CreatedAt).ToList()
};
// Get all file indexes for this account to extract child folders
var allFileIndexes = await fileIndexService.GetByAccountIdAsync(accountId);
// Extract unique child folder paths
var childFolders = ExtractChildFolders(allFileIndexes, path);
return Ok(new
{
Path = path,
@@ -76,14 +102,14 @@ public class FileIndexController(
foreach (var index in allFileIndexes)
{
var normalizedIndexPath = FileIndexService.NormalizePath(index.Path);
// Check if this path is a direct child of the parent path
if (normalizedIndexPath.StartsWith(normalizedParentPath) &&
if (normalizedIndexPath.StartsWith(normalizedParentPath) &&
normalizedIndexPath != normalizedParentPath)
{
// Remove the parent path prefix to get the relative path
var relativePath = normalizedIndexPath.Substring(normalizedParentPath.Length);
// Extract the first folder name (direct child)
var firstSlashIndex = relativePath.IndexOf('/');
if (firstSlashIndex > 0)
@@ -100,19 +126,44 @@ public class FileIndexController(
/// <summary>
/// Gets all files for the current user (across all paths)
/// </summary>
/// <param name="query">Optional query to filter files by name</param>
/// <param name="order">The field to order by (date, size, name - defaults to date)</param>
/// <param name="orderDesc">Whether to order in descending order (defaults to true)</param>
/// <returns>List of all files for the user</returns>
[HttpGet("all")]
public async Task<IActionResult> GetAllFiles()
public async Task<IActionResult> GetAllFiles(
[FromQuery] string? query = null,
[FromQuery] string order = "date",
[FromQuery] bool orderDesc = true
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
var accountId = Guid.Parse(currentUser.Id);
try
{
var fileIndexes = await fileIndexService.GetByAccountIdAsync(accountId);
if (!string.IsNullOrWhiteSpace(query))
{
fileIndexes = fileIndexes
.Where(fi => fi.File.Name.Contains(query, StringComparison.OrdinalIgnoreCase))
.ToList();
}
// Apply sorting
fileIndexes = order.ToLower() switch
{
"name" => orderDesc ? fileIndexes.OrderByDescending(fi => fi.File.Name).ToList()
: fileIndexes.OrderBy(fi => fi.File.Name).ToList(),
"size" => orderDesc ? fileIndexes.OrderByDescending(fi => fi.File.Size).ToList()
: fileIndexes.OrderBy(fi => fi.File.Size).ToList(),
_ => orderDesc ? fileIndexes.OrderByDescending(fi => fi.File.CreatedAt).ToList()
: fileIndexes.OrderBy(fi => fi.File.CreatedAt).ToList()
};
return Ok(new
{
Files = fileIndexes,
@@ -134,11 +185,24 @@ public class FileIndexController(
/// <summary>
/// Gets files that have not been indexed for the current user.
/// </summary>
/// <param name="recycled">Shows recycled files or not</param>
/// <param name="offset">The number of files to skip</param>
/// <param name="take">The number of files to return</param>
/// <param name="pool">The pool ID of those files</param>
/// <param name="query">Optional query to filter files by name</param>
/// <param name="order">The field to order by (date, size, name - defaults to date)</param>
/// <param name="orderDesc">Whether to order in descending order (defaults to true)</param>
/// <returns>List of unindexed files</returns>
[HttpGet("unindexed")]
public async Task<IActionResult> GetUnindexedFiles([FromQuery] int offset = 0, [FromQuery] int take = 20)
public async Task<IActionResult> GetUnindexedFiles(
[FromQuery] Guid? pool,
[FromQuery] bool recycled = false,
[FromQuery] int offset = 0,
[FromQuery] int take = 20,
[FromQuery] string? query = null,
[FromQuery] string order = "date",
[FromQuery] bool orderDesc = true
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
@@ -147,17 +211,36 @@ public class FileIndexController(
try
{
var query = db.Files
var filesQuery = db.Files
.Where(f => f.AccountId == accountId
&& !f.IsMarkedRecycle
&& !db.FileIndexes.Any(fi => fi.FileId == f.Id && fi.AccountId == accountId))
.OrderByDescending(f => f.CreatedAt);
&& f.IsMarkedRecycle == recycled
&& !db.FileIndexes.Any(fi => fi.FileId == f.Id && fi.AccountId == accountId)
)
.AsQueryable();
// Apply sorting
filesQuery = order.ToLower() switch
{
"name" => orderDesc ? filesQuery.OrderByDescending(f => f.Name)
: filesQuery.OrderBy(f => f.Name),
"size" => orderDesc ? filesQuery.OrderByDescending(f => f.Size)
: filesQuery.OrderBy(f => f.Size),
_ => orderDesc ? filesQuery.OrderByDescending(f => f.CreatedAt)
: filesQuery.OrderBy(f => f.CreatedAt)
};
if (pool.HasValue) filesQuery = filesQuery.Where(f => f.PoolId == pool);
if (!string.IsNullOrWhiteSpace(query))
{
filesQuery = filesQuery.Where(f => f.Name.Contains(query));
}
var totalCount = await filesQuery.CountAsync();
var totalCount = await query.CountAsync();
Response.Headers.Append("X-Total", totalCount.ToString());
var unindexedFiles = await query
var unindexedFiles = await filesQuery
.Skip(offset)
.Take(take)
.ToListAsync();
@@ -189,7 +272,7 @@ public class FileIndexController(
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
var accountId = Guid.Parse(currentUser.Id);
try
{
// Verify ownership
@@ -201,7 +284,7 @@ public class FileIndexController(
return new ObjectResult(ApiError.NotFound("File index")) { StatusCode = 404 };
var updatedIndex = await fileIndexService.UpdateAsync(indexId, request.NewPath);
if (updatedIndex == null)
return new ObjectResult(ApiError.NotFound("File index")) { StatusCode = 404 };
@@ -239,7 +322,7 @@ public class FileIndexController(
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
var accountId = Guid.Parse(currentUser.Id);
try
{
// Verify ownership
@@ -256,7 +339,7 @@ public class FileIndexController(
// Remove the index
var removed = await fileIndexService.RemoveAsync(indexId);
if (!removed)
return new ObjectResult(ApiError.NotFound("File index")) { StatusCode = 404 };
@@ -296,7 +379,9 @@ public class FileIndexController(
return Ok(new
{
Message = deleteFile ? "File index and file data removed successfully" : "File index removed successfully",
Message = deleteFile
? "File index and file data removed successfully"
: "File index removed successfully",
FileId = fileId,
FileName = fileName,
Path = filePath,
@@ -328,7 +413,7 @@ public class FileIndexController(
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
var accountId = Guid.Parse(currentUser.Id);
try
{
var removedCount = await fileIndexService.RemoveByPathAsync(accountId, path);
@@ -357,13 +442,14 @@ public class FileIndexController(
db.Files.Remove(file);
logger.LogInformation("Deleted orphaned file {FileId} after clearing path {Path}", fileId, path);
}
await db.SaveChangesAsync();
return Ok(new
{
Message = deleteFiles ?
$"Cleared {removedCount} file indexes from path and deleted orphaned files" :
$"Cleared {removedCount} file indexes from path",
Message = deleteFiles
? $"Cleared {removedCount} file indexes from path and deleted orphaned files"
: $"Cleared {removedCount} file indexes from path",
Path = path,
RemovedCount = removedCount,
FilesDeleted = deleteFiles
@@ -393,7 +479,7 @@ public class FileIndexController(
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
var accountId = Guid.Parse(currentUser.Id);
try
{
// Verify the file exists and belongs to the user
@@ -406,7 +492,8 @@ public class FileIndexController(
// Check if index already exists for this file and path
var existingIndex = await db.FileIndexes
.FirstOrDefaultAsync(fi => fi.FileId == request.FileId && fi.Path == request.Path && fi.AccountId == accountId);
.FirstOrDefaultAsync(fi =>
fi.FileId == request.FileId && fi.Path == request.Path && fi.AccountId == accountId);
if (existingIndex != null)
return new ObjectResult(ApiError.Validation(new Dictionary<string, string[]>
@@ -426,7 +513,7 @@ public class FileIndexController(
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create file index for file {FileId} at path {Path} for account {AccountId}",
logger.LogError(ex, "Failed to create file index for file {FileId} at path {Path} for account {AccountId}",
request.FileId, request.Path, accountId);
return new ObjectResult(new ApiError
{
@@ -450,7 +537,7 @@ public class FileIndexController(
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
var accountId = Guid.Parse(currentUser.Id);
try
{
// Build the query with all conditions at once
@@ -458,7 +545,7 @@ public class FileIndexController(
var fileIndexes = await db.FileIndexes
.Where(fi => fi.AccountId == accountId)
.Include(fi => fi.File)
.Where(fi =>
.Where(fi =>
(string.IsNullOrEmpty(path) || fi.Path == FileIndexService.NormalizePath(path)) &&
(fi.File.Name.ToLower().Contains(searchTerm) ||
(fi.File.Description != null && fi.File.Description.ToLower().Contains(searchTerm)) ||

View File

@@ -179,7 +179,7 @@ namespace DysonNetwork.Drive.Migrations
b.UseTphMappingStrategy();
});
modelBuilder.Entity("DysonNetwork.Shared.Models.CloudFileReference", b =>
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileReference", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -571,7 +571,7 @@ namespace DysonNetwork.Drive.Migrations
b.HasDiscriminator().HasValue("PersistentUploadTask");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.CloudFileReference", b =>
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileReference", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnCloudFile", "File")
.WithMany("References")

View File

@@ -7,7 +7,9 @@ using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.AddServiceDefaults("drive");
builder.Services.Configure<ServiceRegistrationOptions>(opts => { opts.Name = "drive"; });
// Configure Kestrel and server options
builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxValue);
@@ -17,8 +19,6 @@ builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxV
builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppAuthentication();
builder.Services.AddDysonAuth();
builder.Services.AddRingService();
builder.Services.AddAccountService();
builder.Services.AddAppFlushHandlers();
builder.Services.AddAppBusinessServices();

View File

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

View File

@@ -1,4 +1,3 @@
using DysonNetwork.Drive.Billing;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
@@ -14,9 +13,9 @@ namespace DysonNetwork.Drive.Storage;
public class FileController(
AppDatabase db,
FileService fs,
QuotaService qs,
IConfiguration configuration,
IWebHostEnvironment env
IWebHostEnvironment env,
FileReferenceService fileReferenceService
) : ControllerBase
{
[HttpGet("{id}")]
@@ -63,30 +62,31 @@ public class FileController(
return null;
}
private async Task<ActionResult> ServeLocalFile(SnCloudFile file)
private Task<ActionResult> ServeLocalFile(SnCloudFile file)
{
// Try temp storage first
var tempFilePath = Path.Combine(Path.GetTempPath(), file.Id);
if (System.IO.File.Exists(tempFilePath))
{
if (file.IsEncrypted)
return StatusCode(StatusCodes.Status403Forbidden, "Encrypted files cannot be accessed before they are processed and stored.");
return Task.FromResult<ActionResult>(StatusCode(StatusCodes.Status403Forbidden,
"Encrypted files cannot be accessed before they are processed and stored."));
return PhysicalFile(tempFilePath, file.MimeType ?? "application/octet-stream", file.Name, enableRangeProcessing: true);
return Task.FromResult<ActionResult>(PhysicalFile(tempFilePath, file.MimeType ?? "application/octet-stream",
file.Name, enableRangeProcessing: true));
}
// Fallback for tus uploads
var tusStorePath = configuration.GetValue<string>("Storage:Uploads");
if (!string.IsNullOrEmpty(tusStorePath))
{
var tusFilePath = Path.Combine(env.ContentRootPath, tusStorePath, file.Id);
if (System.IO.File.Exists(tusFilePath))
{
return PhysicalFile(tusFilePath, file.MimeType ?? "application/octet-stream", file.Name, enableRangeProcessing: true);
}
}
return StatusCode(StatusCodes.Status400BadRequest, "File is being processed. Please try again later.");
if (string.IsNullOrEmpty(tusStorePath))
return Task.FromResult<ActionResult>(StatusCode(StatusCodes.Status400BadRequest,
"File is being processed. Please try again later."));
var tusFilePath = Path.Combine(env.ContentRootPath, tusStorePath, file.Id);
return System.IO.File.Exists(tusFilePath)
? Task.FromResult<ActionResult>(PhysicalFile(tusFilePath, file.MimeType ?? "application/octet-stream",
file.Name, enableRangeProcessing: true))
: Task.FromResult<ActionResult>(StatusCode(StatusCodes.Status400BadRequest,
"File is being processed. Please try again later."));
}
private async Task<ActionResult> ServeRemoteFile(
@@ -99,7 +99,8 @@ public class FileController(
)
{
if (!file.PoolId.HasValue)
return StatusCode(StatusCodes.Status500InternalServerError, "File is in an inconsistent state: uploaded but no pool ID.");
return StatusCode(StatusCodes.Status500InternalServerError,
"File is in an inconsistent state: uploaded but no pool ID.");
var pool = await fs.GetPoolAsync(file.PoolId.Value);
if (pool is null)
@@ -148,15 +149,10 @@ public class FileController(
return Redirect(BuildProxyUrl(dest.ImageProxy, fileName));
}
if (dest.AccessProxy is not null)
{
return Redirect(BuildProxyUrl(dest.AccessProxy, fileName));
}
return null;
return dest.AccessProxy is not null ? Redirect(BuildProxyUrl(dest.AccessProxy, fileName)) : null;
}
private string BuildProxyUrl(string proxyUrl, string fileName)
private static string BuildProxyUrl(string proxyUrl, string fileName)
{
var baseUri = new Uri(proxyUrl.EndsWith('/') ? proxyUrl : $"{proxyUrl}/");
var fullUri = new Uri(baseUri, fileName);
@@ -189,7 +185,7 @@ public class FileController(
return Redirect(openUrl);
}
private Dictionary<string, string> BuildSignedUrlHeaders(
private static Dictionary<string, string> BuildSignedUrlHeaders(
SnCloudFile file,
string? fileExtension,
string? overrideMimeType,
@@ -234,6 +230,21 @@ public class FileController(
return file;
}
[HttpGet("{id}/references")]
public async Task<ActionResult<List<Shared.Models.SnCloudFileReference>>> GetFileReferences(string id)
{
var file = await fs.GetFileAsync(id);
if (file is null) return NotFound("File not found.");
// Check if user has access to the file
var accessResult = await ValidateFileAccess(file, null);
if (accessResult is not null) return accessResult;
// Get references using the injected FileReferenceService
var references = await fileReferenceService.GetReferencesAsync(id);
return Ok(references);
}
[Authorize]
[HttpPatch("{id}/name")]
public async Task<ActionResult<SnCloudFile>> UpdateFileName(string id, [FromBody] string name)
@@ -281,25 +292,40 @@ public class FileController(
[FromQuery] Guid? pool,
[FromQuery] bool recycled = false,
[FromQuery] int offset = 0,
[FromQuery] int take = 20
[FromQuery] int take = 20,
[FromQuery] string? query = null,
[FromQuery] string order = "date",
[FromQuery] bool orderDesc = true
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var query = db.Files
var filesQuery = db.Files
.Where(e => e.IsMarkedRecycle == recycled)
.Where(e => e.AccountId == accountId)
.Include(e => e.Pool)
.OrderByDescending(e => e.CreatedAt)
.AsQueryable();
if (pool.HasValue) query = query.Where(e => e.PoolId == pool);
if (pool.HasValue) filesQuery = filesQuery.Where(e => e.PoolId == pool);
var total = await query.CountAsync();
if (!string.IsNullOrWhiteSpace(query))
{
filesQuery = filesQuery.Where(e => e.Name.Contains(query));
}
filesQuery = order.ToLower() switch
{
"date" => orderDesc ? filesQuery.OrderByDescending(e => e.CreatedAt) : filesQuery.OrderBy(e => e.CreatedAt),
"size" => orderDesc ? filesQuery.OrderByDescending(e => e.Size) : filesQuery.OrderBy(e => e.Size),
"name" => orderDesc ? filesQuery.OrderByDescending(e => e.Name) : filesQuery.OrderBy(e => e.Name),
_ => filesQuery.OrderByDescending(e => e.CreatedAt)
};
var total = await filesQuery.CountAsync();
Response.Headers.Append("X-Total", total.ToString());
var files = await query
var files = await filesQuery
.Skip(offset)
.Take(take)
.ToListAsync();
@@ -307,9 +333,25 @@ public class FileController(
return Ok(files);
}
public class FileBatchDeletionRequest
{
public List<string> FileIds { get; set; } = [];
}
[Authorize]
[HttpPost("batches/delete")]
public async Task<ActionResult> DeleteFileBatch([FromBody] FileBatchDeletionRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var userId = Guid.Parse(currentUser.Id);
var count = await fs.DeleteAccountFileBatchAsync(userId, request.FileIds);
return Ok(new { Count = count });
}
[Authorize]
[HttpDelete("{id}")]
public async Task<ActionResult> DeleteFile(string id)
public async Task<ActionResult<SnCloudFile>> DeleteFile(string id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var userId = Guid.Parse(currentUser.Id);
@@ -321,9 +363,9 @@ public class FileController(
if (file is null) return NotFound();
await fs.DeleteFileDataAsync(file, force: true);
await fs.DeleteFileAsync(file);
await fs.DeleteFileAsync(file, skipData: true);
return NoContent();
return Ok(file);
}
[Authorize]
@@ -339,116 +381,10 @@ public class FileController(
[Authorize]
[HttpDelete("recycle")]
[RequiredPermission("maintenance", "files.delete.recycle")]
[AskPermission("files.delete.recycle")]
public async Task<ActionResult> DeleteAllRecycledFiles()
{
var count = await fs.DeleteAllRecycledFilesAsync();
return Ok(new { Count = count });
}
public class CreateFastFileRequest
{
public string Name { get; set; } = null!;
public long Size { get; set; }
public string Hash { get; set; } = null!;
public string? MimeType { get; set; }
public string? Description { get; set; }
public Dictionary<string, object?>? UserMeta { get; set; }
public Dictionary<string, object?>? FileMeta { get; set; }
public List<Shared.Models.ContentSensitiveMark>? SensitiveMarks { get; set; }
public Guid PoolId { get; set; }
}
[Authorize]
[HttpPost("fast")]
[RequiredPermission("global", "files.create")]
public async Task<ActionResult<SnCloudFile>> CreateFastFile([FromBody] CreateFastFileRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var pool = await db.Pools.FirstOrDefaultAsync(p => p.Id == request.PoolId);
if (pool is null) return BadRequest();
if (!currentUser.IsSuperuser && pool.AccountId != accountId)
return StatusCode(403, "You don't have permission to create files in this pool.");
if (!pool.PolicyConfig.EnableFastUpload)
return StatusCode(
403,
"This pool does not allow fast upload"
);
if (pool.PolicyConfig.RequirePrivilege > 0)
{
if (currentUser.PerkSubscription is null)
{
return StatusCode(
403,
$"You need to have join the Stellar Program to use this pool"
);
}
var privilege =
PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier);
if (privilege < pool.PolicyConfig.RequirePrivilege)
{
return StatusCode(
403,
$"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use this pool, you are tier {privilege}"
);
}
}
if (request.Size > pool.PolicyConfig.MaxFileSize)
{
return StatusCode(
403,
$"File size {request.Size} is larger than the pool's maximum file size {pool.PolicyConfig.MaxFileSize}"
);
}
var (ok, billableUnit, quota) = await qs.IsFileAcceptable(
accountId,
pool.BillingConfig.CostMultiplier ?? 1.0,
request.Size
);
if (!ok)
{
return StatusCode(
403,
$"File size {billableUnit} is larger than the user's quota {quota}"
);
}
await using var transaction = await db.Database.BeginTransactionAsync();
try
{
var file = new SnCloudFile
{
Name = request.Name,
Size = request.Size,
Hash = request.Hash,
MimeType = request.MimeType,
Description = request.Description,
AccountId = accountId,
UserMeta = request.UserMeta,
FileMeta = request.FileMeta,
SensitiveMarks = request.SensitiveMarks,
PoolId = request.PoolId
};
db.Files.Add(file);
await db.SaveChangesAsync();
await fs._PurgeCacheAsync(file.Id);
await transaction.CommitAsync();
file.FastUploadLink = await fs.CreateFastUploadLinkAsync(file);
return file;
}
catch (Exception)
{
await transaction.RollbackAsync();
throw;
}
}
}
}

View File

@@ -1,4 +1,5 @@
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
@@ -20,7 +21,7 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
/// <param name="expiredAt">Optional expiration time for the file</param>
/// <param name="duration">Optional duration after which the file expires (alternative to expiredAt)</param>
/// <returns>The created file reference</returns>
public async Task<CloudFileReference> CreateReferenceAsync(
public async Task<SnCloudFileReference> CreateReferenceAsync(
string fileId,
string usage,
string resourceId,
@@ -33,7 +34,7 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
if (duration.HasValue)
finalExpiration = SystemClock.Instance.GetCurrentInstant() + duration.Value;
var reference = new CloudFileReference
var reference = new SnCloudFileReference
{
FileId = fileId,
Usage = usage,
@@ -49,7 +50,7 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
return reference;
}
public async Task<List<CloudFileReference>> CreateReferencesAsync(
public async Task<List<SnCloudFileReference>> CreateReferencesAsync(
List<string> fileId,
string usage,
string resourceId,
@@ -57,12 +58,15 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
Duration? duration = null
)
{
var data = fileId.Select(id => new CloudFileReference
var now = SystemClock.Instance.GetCurrentInstant();
var data = fileId.Select(id => new SnCloudFileReference
{
FileId = id,
Usage = usage,
ResourceId = resourceId,
ExpiredAt = expiredAt ?? SystemClock.Instance.GetCurrentInstant() + duration
ExpiredAt = expiredAt ?? now + duration,
CreatedAt = now,
UpdatedAt = now
}).ToList();
await db.BulkInsertAsync(data);
return data;
@@ -73,11 +77,11 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
/// </summary>
/// <param name="fileId">The ID of the file</param>
/// <returns>A list of all references to the file</returns>
public async Task<List<CloudFileReference>> GetReferencesAsync(string fileId)
public async Task<List<SnCloudFileReference>> GetReferencesAsync(string fileId)
{
var cacheKey = $"{CacheKeyPrefix}list:{fileId}";
var cachedReferences = await cache.GetAsync<List<CloudFileReference>>(cacheKey);
var cachedReferences = await cache.GetAsync<List<SnCloudFileReference>>(cacheKey);
if (cachedReferences is not null)
return cachedReferences;
@@ -90,17 +94,17 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
return references;
}
public async Task<Dictionary<string, List<CloudFileReference>>> GetReferencesAsync(IEnumerable<string> fileIds)
public async Task<Dictionary<string, List<SnCloudFileReference>>> GetReferencesAsync(IEnumerable<string> fileIds)
{
var fileIdList = fileIds.ToList();
var result = new Dictionary<string, List<CloudFileReference>>();
var result = new Dictionary<string, List<SnCloudFileReference>>();
// Check cache for each file ID
var uncachedFileIds = new List<string>();
foreach (var fileId in fileIdList)
{
var cacheKey = $"{CacheKeyPrefix}list:{fileId}";
var cachedReferences = await cache.GetAsync<List<CloudFileReference>>(cacheKey);
var cachedReferences = await cache.GetAsync<List<SnCloudFileReference>>(cacheKey);
if (cachedReferences is not null)
{
result[fileId] = cachedReferences;
@@ -158,11 +162,11 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
/// </summary>
/// <param name="resourceId">The ID of the resource</param>
/// <returns>A list of file references associated with the resource</returns>
public async Task<List<CloudFileReference>> GetResourceReferencesAsync(string resourceId)
public async Task<List<SnCloudFileReference>> GetResourceReferencesAsync(string resourceId)
{
var cacheKey = $"{CacheKeyPrefix}resource:{resourceId}";
var cachedReferences = await cache.GetAsync<List<CloudFileReference>>(cacheKey);
var cachedReferences = await cache.GetAsync<List<SnCloudFileReference>>(cacheKey);
if (cachedReferences is not null)
return cachedReferences;
@@ -180,11 +184,11 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
/// </summary>
/// <param name="usage">The usage context</param>
/// <returns>A list of file references with the specified usage</returns>
public async Task<List<CloudFileReference>> GetUsageReferencesAsync(string usage)
public async Task<List<SnCloudFileReference>> GetUsageReferencesAsync(string usage)
{
var cacheKey = $"{CacheKeyPrefix}usage:{usage}";
var cachedReferences = await cache.GetAsync<List<CloudFileReference>>(cacheKey);
var cachedReferences = await cache.GetAsync<List<SnCloudFileReference>>(cacheKey);
if (cachedReferences is not null)
return cachedReferences;
@@ -306,7 +310,7 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
/// <param name="expiredAt">Optional expiration time for newly added files</param>
/// <param name="duration">Optional duration after which newly added files expire</param>
/// <returns>A list of the updated file references</returns>
public async Task<List<CloudFileReference>> UpdateResourceFilesAsync(
public async Task<List<SnCloudFileReference>> UpdateResourceFilesAsync(
string resourceId,
IEnumerable<string>? newFileIds,
string usage,
@@ -314,7 +318,7 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
Duration? duration = null)
{
if (newFileIds == null)
return new List<CloudFileReference>();
return new List<SnCloudFileReference>();
var existingReferences = await db.FileReferences
.Where(r => r.ResourceId == resourceId && r.Usage == usage)
@@ -332,7 +336,7 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
// Files to add
var toAdd = newFileIdsList
.Where(id => !existingFileIds.Contains(id))
.Select(id => new CloudFileReference
.Select(id => new SnCloudFileReference
{
FileId = id,
Usage = usage,
@@ -484,7 +488,7 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
/// <param name="resourceId">The resource ID</param>
/// <param name="usageType">The usage type</param>
/// <returns>List of file references</returns>
public async Task<List<CloudFileReference>> GetResourceReferencesAsync(string resourceId, string usageType)
public async Task<List<SnCloudFileReference>> GetResourceReferencesAsync(string resourceId, string usageType)
{
return await db.FileReferences
.Where(r => r.ResourceId == resourceId && r.Usage == usageType)

View File

@@ -103,7 +103,8 @@ public class FileService(
var bundle = await ValidateAndGetBundleAsync(fileBundleId, accountId);
var finalExpiredAt = CalculateFinalExpiration(expiredAt, pool, bundle);
var (managedTempPath, fileSize, finalContentType) = await PrepareFileAsync(fileId, filePath, fileName, contentType);
var (managedTempPath, fileSize, finalContentType) =
await PrepareFileAsync(fileId, filePath, fileName, contentType);
var file = CreateFileObject(fileId, fileName, finalContentType, fileSize, finalExpiredAt, bundle, accountId);
@@ -112,7 +113,8 @@ public class FileService(
await ExtractMetadataAsync(file, managedTempPath);
}
var (processingPath, isTempFile) = await ProcessEncryptionAsync(fileId, managedTempPath, encryptPassword, pool, file);
var (processingPath, isTempFile) =
await ProcessEncryptionAsync(fileId, managedTempPath, encryptPassword, pool, file);
file.Hash = await HashFileAsync(processingPath);
@@ -231,7 +233,8 @@ public class FileService(
file.StorageId ??= file.Id;
}
private async Task PublishFileUploadedEventAsync(SnCloudFile file, FilePool pool, string processingPath, bool isTempFile)
private async Task PublishFileUploadedEventAsync(SnCloudFile file, FilePool pool, string processingPath,
bool isTempFile)
{
var js = nats.CreateJetStreamContext();
await js.PublishAsync(
@@ -471,13 +474,14 @@ public class FileService(
return await db.Files.AsNoTracking().FirstAsync(f => f.Id == file.Id);
}
public async Task DeleteFileAsync(SnCloudFile file)
public async Task DeleteFileAsync(SnCloudFile file, bool skipData = false)
{
db.Remove(file);
await db.SaveChangesAsync();
await _PurgeCacheAsync(file.Id);
await DeleteFileDataAsync(file);
if (!skipData)
await DeleteFileDataAsync(file);
}
public async Task DeleteFileDataAsync(SnCloudFile file, bool force = false)
@@ -660,9 +664,12 @@ public class FileService(
}
}
return [.. references
.Select(r => cachedFiles.GetValueOrDefault(r.Id))
.Where(f => f != null)];
return
[
.. references
.Select(r => cachedFiles.GetValueOrDefault(r.Id))
.Where(f => f != null)
];
}
public async Task<int> GetReferenceCountAsync(string fileId)
@@ -711,6 +718,21 @@ public class FileService(
return count;
}
public async Task<int> DeleteAccountFileBatchAsync(Guid accountId, List<string> fileIds)
{
var files = await db.Files
.Where(f => f.AccountId == accountId && fileIds.Contains(f.Id))
.ToListAsync();
var count = files.Count;
var tasks = files.Select(f => DeleteFileDataAsync(f, true));
await Task.WhenAll(tasks);
var fileIdsList = files.Select(f => f.Id).ToList();
await _PurgeCacheRangeAsync(fileIdsList);
db.RemoveRange(files);
await db.SaveChangesAsync();
return count;
}
public async Task<int> DeletePoolRecycledFilesAsync(Guid poolId)
{
var files = await db.Files

View File

@@ -113,7 +113,7 @@ public class FileUploadController(
if (currentUser.IsSuperuser) return null;
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
? null

View File

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

View File

@@ -1,12 +0,0 @@
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("config")]
public class ConfigurationController(IConfiguration configuration) : ControllerBase
{
[HttpGet]
public IActionResult Get() => Ok(configuration.GetSection("Client").Get<Dictionary<string, object>>());
[HttpGet("site")]
public IActionResult GetSiteUrl() => Ok(configuration["SiteUrl"]);
}

View File

@@ -1,23 +0,0 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["DysonNetwork.Gateway/DysonNetwork.Gateway.csproj", "DysonNetwork.Gateway/"]
RUN dotnet restore "DysonNetwork.Gateway/DysonNetwork.Gateway.csproj"
COPY . .
WORKDIR "/src/DysonNetwork.Gateway"
RUN dotnet build "./DysonNetwork.Gateway.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./DysonNetwork.Gateway.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "DysonNetwork.Gateway.dll"]

View File

@@ -1,22 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery.Yarp" Version="10.0.0" />
<PackageReference Include="Nerdbank.GitVersioning" Version="3.9.50">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,168 +0,0 @@
using System.Threading.RateLimiting;
using DysonNetwork.Shared.Http;
using Yarp.ReverseProxy.Configuration;
using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxValue, enableGrpc: false);
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(
policy =>
{
policy.SetIsOriginAllowed(origin => true)
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials()
.WithExposedHeaders("X-Total");
});
});
builder.Services.AddRateLimiter(options =>
{
options.AddPolicy("fixed", context =>
{
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: ip,
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 120, // 120 requests...
Window = TimeSpan.FromMinutes(1), // ...per minute per IP
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 10 // allow short bursts instead of instant 503s
});
});
options.OnRejected = async (context, token) =>
{
// Log the rejected IP
var logger = context.HttpContext.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger("RateLimiter");
var ip = context.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
logger.LogWarning("Rate limit exceeded for IP: {IP}", ip);
// Respond to the client
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsync(
"Rate limit exceeded. Try again later.", token);
};
});
var serviceNames = new[] { "ring", "pass", "drive", "sphere", "develop", "insight" };
var specialRoutes = new[]
{
new RouteConfig
{
RouteId = "ring-ws",
ClusterId = "ring",
Match = new RouteMatch { Path = "/ws" }
},
new RouteConfig
{
RouteId = "pass-openid",
ClusterId = "pass",
Match = new RouteMatch { Path = "/.well-known/openid-configuration" }
},
new RouteConfig
{
RouteId = "pass-jwks",
ClusterId = "pass",
Match = new RouteMatch { Path = "/.well-known/jwks" }
},
new RouteConfig
{
RouteId = "drive-tus",
ClusterId = "drive",
Match = new RouteMatch { Path = "/api/tus" }
}
};
var apiRoutes = serviceNames.Select(serviceName =>
{
var apiPath = serviceName switch
{
_ => $"/{serviceName}"
};
return new RouteConfig
{
RouteId = $"{serviceName}-api",
ClusterId = serviceName,
Match = new RouteMatch { Path = $"{apiPath}/{{**catch-all}}" },
Transforms =
[
new Dictionary<string, string> { { "PathRemovePrefix", apiPath } },
new Dictionary<string, string> { { "PathPrefix", "/api" } }
]
};
});
var swaggerRoutes = serviceNames.Select(serviceName => new RouteConfig
{
RouteId = $"{serviceName}-swagger",
ClusterId = serviceName,
Match = new RouteMatch { Path = $"/swagger/{serviceName}/{{**catch-all}}" },
Transforms =
[
new Dictionary<string, string> { { "PathRemovePrefix", $"/swagger/{serviceName}" } },
new Dictionary<string, string> { { "PathPrefix", "/swagger" } }
]
});
var routes = specialRoutes.Concat(apiRoutes).Concat(swaggerRoutes).ToArray();
var clusters = serviceNames.Select(serviceName => new ClusterConfig
{
ClusterId = serviceName,
HealthCheck = new HealthCheckConfig
{
Active = new ActiveHealthCheckConfig
{
Enabled = true,
Interval = TimeSpan.FromSeconds(10),
Timeout = TimeSpan.FromSeconds(5),
Path = "/health"
},
Passive = new()
{
Enabled = true
}
},
Destinations = new Dictionary<string, DestinationConfig>
{
{ "destination1", new DestinationConfig { Address = $"http://{serviceName}" } }
}
}).ToArray();
builder.Services
.AddReverseProxy()
.LoadFromMemory(routes, clusters)
.AddServiceDiscoveryDestinationResolver();
builder.Services.AddControllers();
var app = builder.Build();
var forwardedHeadersOptions = new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.All
};
forwardedHeadersOptions.KnownNetworks.Clear();
forwardedHeadersOptions.KnownProxies.Clear();
app.UseForwardedHeaders(forwardedHeadersOptions);
app.UseCors();
app.MapReverseProxy().RequireRateLimiting("fixed");
app.MapControllers();
app.Run();

View File

@@ -1,20 +0,0 @@
using DysonNetwork.Shared.Data;
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Gateway;
[ApiController]
[Route("/version")]
public class VersionController : ControllerBase
{
[HttpGet]
public IActionResult Get()
{
return Ok(new AppVersion
{
Version = ThisAssembly.AssemblyVersion,
Commit = ThisAssembly.GitCommitId,
UpdateDate = ThisAssembly.GitCommitDate
});
}
}

View File

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

View File

@@ -1,7 +0,0 @@
{
"version": "1.0",
"publicReleaseRefSpec": ["^refs/heads/main$"],
"cloudBuild": {
"setVersionVariables": true
}
}

View File

@@ -1,3 +1,4 @@
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
@@ -12,6 +13,7 @@ public class AppDatabase(
{
public DbSet<SnThinkingSequence> ThinkingSequences { get; set; }
public DbSet<SnThinkingThought> ThinkingThoughts { get; set; }
public DbSet<SnUnpaidAccount> UnpaidAccounts { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
@@ -28,36 +30,15 @@ public class AppDatabase(
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var now = SystemClock.Instance.GetCurrentInstant();
foreach (var entry in ChangeTracker.Entries<ModelBase>())
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedAt = now;
entry.Entity.UpdatedAt = now;
break;
case EntityState.Modified:
entry.Entity.UpdatedAt = now;
break;
case EntityState.Deleted:
entry.State = EntityState.Modified;
entry.Entity.DeletedAt = now;
break;
case EntityState.Detached:
case EntityState.Unchanged:
default:
break;
}
}
this.ApplyAuditableAndSoftDelete();
return await base.SaveChangesAsync(cancellationToken);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplySoftDeleteFilters();
}
}

View File

@@ -1,21 +1,42 @@
using DysonNetwork.Insight.Thought;
using DysonNetwork.Shared.Auth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.EntityFrameworkCore;
using DysonNetwork.Shared.Proto;
namespace DysonNetwork.Insight.Controllers;
[ApiController]
[Route("/api/billing")]
public class BillingController(ThoughtService thoughtService, ILogger<BillingController> logger) : ControllerBase
[Route("api/billing")]
public class BillingController(AppDatabase db, ThoughtService thoughtService, ILogger<BillingController> logger)
: ControllerBase
{
[HttpPost("settle")]
[Authorize]
[RequiredPermission("maintenance", "insight.billing.settle")]
public async Task<IActionResult> ProcessTokenBilling()
[HttpGet("status")]
public async Task<IActionResult> GetBillingStatus()
{
await thoughtService.SettleThoughtBills(logger);
return Ok();
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var isMarked = await db.UnpaidAccounts.AnyAsync(u => u.AccountId == accountId);
return Ok(isMarked ? new { status = "unpaid" } : new { status = "ok" });
}
}
[HttpPost("retry")]
public async Task<IActionResult> RetryBilling()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var (success, cost) = await thoughtService.RetryBillingForAccountAsync(accountId, logger);
if (success)
{
return Ok(cost > 0
? new { message = $"Billing retry successful. Billed {cost} points." }
: new { message = "No outstanding payment found." });
}
return BadRequest(new { message = "Billing retry failed. Please check your balance and try again." });
}
}

View File

@@ -0,0 +1,159 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using DysonNetwork.Insight;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DysonNetwork.Insight.Migrations
{
[DbContext(typeof(AppDatabase))]
[Migration("20251115165833_AddUnpaidAccounts")]
partial class AddUnpaidAccounts
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingSequence", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<long>("PaidToken")
.HasColumnType("bigint")
.HasColumnName("paid_token");
b.Property<string>("Topic")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("topic");
b.Property<long>("TotalToken")
.HasColumnType("bigint")
.HasColumnName("total_token");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_thinking_sequences");
b.ToTable("thinking_sequences", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", 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<List<SnCloudFileReferenceObject>>("Files")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("files");
b.Property<string>("ModelName")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("model_name");
b.Property<List<SnThinkingMessagePart>>("Parts")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("parts");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<Guid>("SequenceId")
.HasColumnType("uuid")
.HasColumnName("sequence_id");
b.Property<long>("TokenCount")
.HasColumnType("bigint")
.HasColumnName("token_count");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_thinking_thoughts");
b.HasIndex("SequenceId")
.HasDatabaseName("ix_thinking_thoughts_sequence_id");
b.ToTable("thinking_thoughts", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnUnpaidAccount", b =>
{
b.Property<Guid>("AccountId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<DateTime>("MarkedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("marked_at");
b.HasKey("AccountId")
.HasName("pk_unpaid_accounts");
b.ToTable("unpaid_accounts", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnThinkingSequence", "Sequence")
.WithMany()
.HasForeignKey("SequenceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_thinking_thoughts_thinking_sequences_sequence_id");
b.Navigation("Sequence");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,34 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Insight.Migrations
{
/// <inheritdoc />
public partial class AddUnpaidAccounts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "unpaid_accounts",
columns: table => new
{
account_id = table.Column<Guid>(type: "uuid", nullable: false),
marked_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_unpaid_accounts", x => x.account_id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "unpaid_accounts");
}
}
}

View File

@@ -0,0 +1,163 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using DysonNetwork.Insight;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DysonNetwork.Insight.Migrations
{
[DbContext(typeof(AppDatabase))]
[Migration("20251116123552_SharableThought")]
partial class SharableThought
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingSequence", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<bool>("IsPublic")
.HasColumnType("boolean")
.HasColumnName("is_public");
b.Property<long>("PaidToken")
.HasColumnType("bigint")
.HasColumnName("paid_token");
b.Property<string>("Topic")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("topic");
b.Property<long>("TotalToken")
.HasColumnType("bigint")
.HasColumnName("total_token");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_thinking_sequences");
b.ToTable("thinking_sequences", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", 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<List<SnCloudFileReferenceObject>>("Files")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("files");
b.Property<string>("ModelName")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("model_name");
b.Property<List<SnThinkingMessagePart>>("Parts")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("parts");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<Guid>("SequenceId")
.HasColumnType("uuid")
.HasColumnName("sequence_id");
b.Property<long>("TokenCount")
.HasColumnType("bigint")
.HasColumnName("token_count");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_thinking_thoughts");
b.HasIndex("SequenceId")
.HasDatabaseName("ix_thinking_thoughts_sequence_id");
b.ToTable("thinking_thoughts", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnUnpaidAccount", b =>
{
b.Property<Guid>("AccountId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<DateTime>("MarkedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("marked_at");
b.HasKey("AccountId")
.HasName("pk_unpaid_accounts");
b.ToTable("unpaid_accounts", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnThinkingSequence", "Sequence")
.WithMany()
.HasForeignKey("SequenceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_thinking_thoughts_thinking_sequences_sequence_id");
b.Navigation("Sequence");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Insight.Migrations
{
/// <inheritdoc />
public partial class SharableThought : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "is_public",
table: "thinking_sequences",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "is_public",
table: "thinking_sequences");
}
}
}

View File

@@ -44,6 +44,10 @@ namespace DysonNetwork.Insight.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<bool>("IsPublic")
.HasColumnType("boolean")
.HasColumnName("is_public");
b.Property<long>("PaidToken")
.HasColumnType("bigint")
.HasColumnName("paid_token");
@@ -122,6 +126,23 @@ namespace DysonNetwork.Insight.Migrations
b.ToTable("thinking_thoughts", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnUnpaidAccount", b =>
{
b.Property<Guid>("AccountId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<DateTime>("MarkedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("marked_at");
b.HasKey("AccountId")
.HasName("pk_unpaid_accounts");
b.ToTable("unpaid_accounts", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnThinkingSequence", "Sequence")

View File

@@ -7,7 +7,9 @@ using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.Configure<ServiceRegistrationOptions>(opts => { opts.Name = "insight"; });
builder.AddServiceDefaults("insight");
builder.ConfigureAppKestrel(builder.Configuration);
@@ -19,8 +21,6 @@ builder.Services.AddAppBusinessServices();
builder.Services.AddAppScheduledJobs();
builder.Services.AddDysonAuth();
builder.Services.AddAccountService();
builder.Services.AddSphereService();
builder.Services.AddThinkingServices(builder.Configuration);
builder.AddSwaggerManifest(

View File

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

View File

@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Text.Json;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Mvc;
@@ -19,12 +20,50 @@ public class ThoughtController(ThoughtProvider provider, ThoughtService service)
public class StreamThinkingRequest
{
[Required] public string UserMessage { get; set; } = null!;
public string? ServiceId { get; set; }
public Guid? SequenceId { get; set; }
public List<string>? AttachedPosts { get; set; }
public List<string>? AttachedPosts { get; set; } = [];
public List<Dictionary<string, dynamic>>? AttachedMessages { get; set; }
public List<string> AcceptProposals { get; set; } = [];
}
public class UpdateSharingRequest
{
public bool IsPublic { get; set; }
}
public class ThoughtServiceInfo
{
public string ServiceId { get; set; } = null!;
public double BillingMultiplier { get; set; }
public int PerkLevel { get; set; }
}
public class ThoughtServicesResponse
{
public string DefaultService { get; set; } = null!;
public IEnumerable<ThoughtServiceInfo> Services { get; set; } = null!;
}
[HttpGet("services")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<ThoughtServicesResponse> GetAvailableServices()
{
var services = provider.GetAvailableServicesInfo()
.Select(s => new ThoughtServiceInfo
{
ServiceId = s.ServiceId,
BillingMultiplier = s.BillingMultiplier,
PerkLevel = s.PerkLevel
});
return Ok(new ThoughtServicesResponse
{
DefaultService = provider.GetDefaultServiceId(),
Services = services
});
}
[HttpPost]
[Experimental("SKEXP0110")]
public async Task<ActionResult> Think([FromBody] StreamThinkingRequest request)
@@ -35,6 +74,25 @@ public class ThoughtController(ThoughtProvider provider, ThoughtService service)
if (request.AcceptProposals.Any(e => !AvailableProposals.Contains(e)))
return BadRequest("Request contains unavailable proposal");
var serviceId = provider.GetServiceId(request.ServiceId);
var serviceInfo = provider.GetServiceInfo(serviceId);
if (serviceInfo is null)
{
return BadRequest("Service not found or configured.");
}
if (serviceInfo.PerkLevel > 0 && !currentUser.IsSuperuser)
if (currentUser.PerkSubscription is null ||
PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier) <
serviceInfo.PerkLevel)
return StatusCode(403, "Not enough perk level");
var kernel = provider.GetKernel(request.ServiceId);
if (kernel is null)
{
return BadRequest("Service not found or configured.");
}
// Generate a topic if creating a new sequence
string? topic = null;
if (!request.SequenceId.HasValue)
@@ -46,7 +104,13 @@ public class ThoughtController(ThoughtProvider provider, ThoughtService service)
);
summaryHistory.AddUserMessage(request.UserMessage);
var summaryResult = await provider.Kernel
var summaryKernel = provider.GetKernel(); // Get default kernel
if (summaryKernel is null)
{
return BadRequest("Default service not found or configured.");
}
var summaryResult = await summaryKernel
.GetRequiredService<IChatCompletionService>()
.GetChatMessageContentAsync(summaryHistory);
@@ -58,14 +122,13 @@ public class ThoughtController(ThoughtProvider provider, ThoughtService service)
if (sequence == null) return Forbid(); // or NotFound
// Save user thought
await service.SaveThoughtAsync(sequence, new List<SnThinkingMessagePart>
{
new()
await service.SaveThoughtAsync(sequence, [
new SnThinkingMessagePart
{
Type = ThinkingMessagePartType.Text,
Text = request.UserMessage
}
}, ThinkingThoughtRole.User);
], ThinkingThoughtRole.User);
// Build chat history
var chatHistory = new ChatHistory(
@@ -172,12 +235,10 @@ public class ThoughtController(ThoughtProvider provider, ThoughtService service)
chatHistory.Add(assistantMessage);
if (functionResults.Count > 0)
if (functionResults.Count <= 0) continue;
foreach (var fr in functionResults)
{
foreach (var fr in functionResults)
{
chatHistory.Add(fr.ToChatMessage());
}
chatHistory.Add(fr.ToChatMessage());
}
}
}
@@ -188,9 +249,8 @@ public class ThoughtController(ThoughtProvider provider, ThoughtService service)
Response.Headers.Append("Content-Type", "text/event-stream");
Response.StatusCode = 200;
var kernel = provider.Kernel;
var chatCompletionService = kernel.GetRequiredService<IChatCompletionService>();
var executionSettings = provider.CreatePromptExecutionSettings();
var executionSettings = provider.CreatePromptExecutionSettings(request.ServiceId);
var assistantParts = new List<SnThinkingMessagePart>();
@@ -300,7 +360,7 @@ public class ThoughtController(ThoughtProvider provider, ThoughtService service)
sequence,
assistantParts,
ThinkingThoughtRole.Assistant,
provider.ModelDefault
serviceId
);
// Write the topic if it was newly set, then the thought object as JSON to the stream
@@ -351,6 +411,25 @@ public class ThoughtController(ThoughtProvider provider, ThoughtService service)
return Ok(sequences);
}
[HttpPatch("sequences/{sequenceId:guid}/sharing")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> UpdateSequenceSharing(Guid sequenceId, [FromBody] UpdateSharingRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var sequence = await service.GetSequenceAsync(sequenceId);
if (sequence == null) return NotFound();
if (sequence.AccountId != accountId) return Forbid();
sequence.IsPublic = request.IsPublic;
await service.UpdateSequenceAsync(sequence);
return NoContent();
}
/// <summary>
/// Retrieves the thoughts in a specific thinking sequence.
/// </summary>
@@ -363,12 +442,18 @@ public class ThoughtController(ThoughtProvider provider, ThoughtService service)
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<List<SnThinkingThought>>> GetSequenceThoughts(Guid sequenceId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var sequence = await service.GetOrCreateSequenceAsync(accountId, sequenceId);
var sequence = await service.GetSequenceAsync(sequenceId);
if (sequence == null) return NotFound();
if (!sequence.IsPublic)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
if (sequence.AccountId != accountId)
return StatusCode(403);
}
var thoughts = await service.GetPreviousThoughtsAsync(sequence);
return Ok(thoughts);

View File

@@ -1,76 +1,102 @@
using System.ClientModel;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using DysonNetwork.Insight.Thought.Plugins;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.Ollama;
using Microsoft.SemanticKernel.Connectors.OpenAI;
using OpenAI;
using PostType = DysonNetwork.Shared.Proto.PostType;
using Microsoft.SemanticKernel.Plugins.Web;
using Microsoft.SemanticKernel.Plugins.Web.Bing;
using Microsoft.SemanticKernel.Plugins.Web.Google;
using NodaTime.Serialization.Protobuf;
using NodaTime.Text;
namespace DysonNetwork.Insight.Thought;
public class ThoughtServiceModel
{
public string ServiceId { get; set; } = null!;
public string? Provider { get; set; }
public string? Model { get; set; }
public double BillingMultiplier { get; set; }
public int PerkLevel { get; set; }
}
public class ThoughtProvider
{
private readonly PostService.PostServiceClient _postClient;
private readonly AccountService.AccountServiceClient _accountClient;
private readonly IConfiguration _configuration;
private readonly ILogger<ThoughtProvider> _logger;
public Kernel Kernel { get; }
private string? ModelProviderType { get; set; }
public string? ModelDefault { get; set; }
private readonly Dictionary<string, Kernel> _kernels = new();
private readonly Dictionary<string, string> _serviceProviders = new();
private readonly Dictionary<string, ThoughtServiceModel> _serviceModels = new();
private readonly string _defaultServiceId;
[Experimental("SKEXP0050")]
public ThoughtProvider(
IConfiguration configuration,
PostService.PostServiceClient postServiceClient,
AccountService.AccountServiceClient accountServiceClient,
ILogger<ThoughtProvider> logger
AccountService.AccountServiceClient accountServiceClient
)
{
_logger = logger;
_postClient = postServiceClient;
_accountClient = accountServiceClient;
_configuration = configuration;
Kernel = InitializeThinkingProvider(configuration);
InitializeHelperFunctions();
var cfg = configuration.GetSection("Thinking");
_defaultServiceId = cfg.GetValue<string>("DefaultService")!;
var services = cfg.GetSection("Services").GetChildren();
foreach (var service in services)
{
var serviceId = service.Key;
var serviceModel = new ThoughtServiceModel
{
ServiceId = serviceId,
Provider = service.GetValue<string>("Provider"),
Model = service.GetValue<string>("Model"),
BillingMultiplier = service.GetValue<double>("BillingMultiplier", 1.0),
PerkLevel = service.GetValue<int>("PerkLevel", 0)
};
_serviceModels[serviceId] = serviceModel;
var providerType = service.GetValue<string>("Provider")?.ToLower();
if (providerType is null) continue;
var kernel = InitializeThinkingService(service);
InitializeHelperFunctions(kernel);
_kernels[serviceId] = kernel;
_serviceProviders[serviceId] = providerType;
}
}
private Kernel InitializeThinkingProvider(IConfiguration configuration)
private Kernel InitializeThinkingService(IConfigurationSection serviceConfig)
{
var cfg = configuration.GetSection("Thinking");
ModelProviderType = cfg.GetValue<string>("Provider")?.ToLower();
ModelDefault = cfg.GetValue<string>("Model");
var endpoint = cfg.GetValue<string>("Endpoint");
var apiKey = cfg.GetValue<string>("ApiKey");
var providerType = serviceConfig.GetValue<string>("Provider")?.ToLower();
var model = serviceConfig.GetValue<string>("Model");
var endpoint = serviceConfig.GetValue<string>("Endpoint");
var apiKey = serviceConfig.GetValue<string>("ApiKey");
var builder = Kernel.CreateBuilder();
switch (ModelProviderType)
switch (providerType)
{
case "ollama":
builder.AddOllamaChatCompletion(ModelDefault!, new Uri(endpoint ?? "http://localhost:11434/api"));
builder.AddOllamaChatCompletion(
model!,
new Uri(endpoint ?? "http://localhost:11434/api")
);
break;
case "deepseek":
var client = new OpenAIClient(
new ApiKeyCredential(apiKey!),
new OpenAIClientOptions { Endpoint = new Uri(endpoint ?? "https://api.deepseek.com/v1") }
);
builder.AddOpenAIChatCompletion(ModelDefault!, client);
builder.AddOpenAIChatCompletion(model!, client);
break;
default:
throw new IndexOutOfRangeException("Unknown thinking provider: " + ModelProviderType);
throw new IndexOutOfRangeException("Unknown thinking provider: " + providerType);
}
// Add gRPC clients for Thought Plugins
@@ -78,7 +104,7 @@ public class ThoughtProvider
builder.Services.AddServiceDiscovery();
builder.Services.AddAccountService();
builder.Services.AddSphereService();
builder.Plugins.AddFromObject(new SnAccountKernelPlugin(_accountClient));
builder.Plugins.AddFromObject(new SnPostKernelPlugin(_postClient));
@@ -86,7 +112,7 @@ public class ThoughtProvider
}
[Experimental("SKEXP0050")]
private void InitializeHelperFunctions()
private void InitializeHelperFunctions(Kernel kernel)
{
// Add web search plugins if configured
var bingApiKey = _configuration.GetValue<string>("Thinking:BingApiKey");
@@ -94,7 +120,7 @@ public class ThoughtProvider
{
var bingConnector = new BingConnector(bingApiKey);
var bing = new WebSearchEnginePlugin(bingConnector);
Kernel.ImportPluginFromObject(bing, "bing");
kernel.ImportPluginFromObject(bing, "bing");
}
var googleApiKey = _configuration.GetValue<string>("Thinking:GoogleApiKey");
@@ -105,26 +131,58 @@ public class ThoughtProvider
apiKey: googleApiKey,
searchEngineId: googleCx);
var google = new WebSearchEnginePlugin(googleConnector);
Kernel.ImportPluginFromObject(google, "google");
kernel.ImportPluginFromObject(google, "google");
}
}
public PromptExecutionSettings CreatePromptExecutionSettings()
public Kernel? GetKernel(string? serviceId = null)
{
switch (ModelProviderType)
serviceId ??= _defaultServiceId;
return _kernels.GetValueOrDefault(serviceId);
}
public string GetServiceId(string? serviceId = null)
{
return serviceId ?? _defaultServiceId;
}
public IEnumerable<string> GetAvailableServices()
{
return _kernels.Keys;
}
public IEnumerable<ThoughtServiceModel> GetAvailableServicesInfo()
{
return _serviceModels.Values;
}
public ThoughtServiceModel? GetServiceInfo(string? serviceId)
{
serviceId ??= _defaultServiceId;
return _serviceModels.GetValueOrDefault(serviceId);
}
public string GetDefaultServiceId()
{
return _defaultServiceId;
}
public PromptExecutionSettings CreatePromptExecutionSettings(string? serviceId = null)
{
serviceId ??= _defaultServiceId;
var providerType = _serviceProviders.GetValueOrDefault(serviceId);
return providerType switch
{
case "ollama":
return new OllamaPromptExecutionSettings
{
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(autoInvoke: false)
};
case "deepseek":
return new OpenAIPromptExecutionSettings
{
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(autoInvoke: false)
};
default:
throw new InvalidOperationException("Unknown provider: " + ModelProviderType);
}
"ollama" => new OllamaPromptExecutionSettings
{
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(autoInvoke: false)
},
"deepseek" => new OpenAIPromptExecutionSettings
{
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(autoInvoke: false), ModelId = serviceId
},
_ => throw new InvalidOperationException("Unknown provider for service: " + serviceId)
};
}
}

View File

@@ -36,6 +36,17 @@ public class ThoughtService(
}
}
public async Task<SnThinkingSequence?> GetSequenceAsync(Guid sequenceId)
{
return await db.ThinkingSequences.FindAsync(sequenceId);
}
public async Task UpdateSequenceAsync(SnThinkingSequence sequence)
{
db.ThinkingSequences.Update(sequence);
await db.SaveChangesAsync();
}
public async Task<SnThinkingThought> SaveThoughtAsync(
SnThinkingSequence sequence,
List<SnThinkingMessagePart> parts,
@@ -133,6 +144,13 @@ public class ThoughtService(
foreach (var accountGroup in groupedByAccount)
{
var accountId = accountGroup.Key;
if (await db.UnpaidAccounts.AnyAsync(u => u.AccountId == accountId))
{
logger.LogWarning("Skipping billing for marked account {accountId}", accountId);
continue;
}
var totalUnpaidTokens = accountGroup.Sum(s => s.TotalToken - s.PaidToken);
var cost = (long)Math.Ceiling(totalUnpaidTokens / 10.0);
@@ -166,9 +184,86 @@ public class ThoughtService(
catch (Exception ex)
{
logger.LogError(ex, "Error billing for account {accountId}", accountId);
if (!await db.UnpaidAccounts.AnyAsync(u => u.AccountId == accountId))
{
db.UnpaidAccounts.Add(new SnUnpaidAccount { AccountId = accountId, MarkedAt = DateTime.UtcNow });
}
}
}
await db.SaveChangesAsync();
}
public async Task<(bool success, long cost)> RetryBillingForAccountAsync(Guid accountId, ILogger logger)
{
var isMarked = await db.UnpaidAccounts.FirstOrDefaultAsync(u => u.AccountId == accountId);
if (isMarked == null)
{
logger.LogInformation("Account {accountId} is not marked for unpaid bills.", accountId);
return (true, 0);
}
var sequences = await db
.ThinkingSequences.Where(s => s.AccountId == accountId && s.PaidToken < s.TotalToken)
.ToListAsync();
if (!sequences.Any())
{
logger.LogInformation("No unpaid sequences found for account {accountId}. Unmarking.", accountId);
db.UnpaidAccounts.Remove(isMarked);
await db.SaveChangesAsync();
return (true, 0);
}
var totalUnpaidTokens = sequences.Sum(s => s.TotalToken - s.PaidToken);
var cost = (long)Math.Ceiling(totalUnpaidTokens / 10.0);
if (cost == 0)
{
logger.LogInformation("Unpaid tokens for {accountId} resulted in zero cost. Marking as paid and unmarking.", accountId);
foreach (var sequence in sequences)
{
sequence.PaidToken = sequence.TotalToken;
}
db.UnpaidAccounts.Remove(isMarked);
await db.SaveChangesAsync();
return (true, 0);
}
try
{
var date = DateTime.Now.ToString("yyyy-MM-dd");
await paymentService.CreateTransactionWithAccountAsync(
new CreateTransactionWithAccountRequest
{
PayerAccountId = accountId.ToString(),
Currency = WalletCurrency.SourcePoint,
Amount = cost.ToString(),
Remarks = $"Wage for SN-chan on {date} (Retry)",
Type = TransactionType.System,
}
);
foreach (var sequence in sequences)
{
sequence.PaidToken = sequence.TotalToken;
}
db.UnpaidAccounts.Remove(isMarked);
logger.LogInformation(
"Successfully billed {cost} points for account {accountId} on retry.",
cost,
accountId
);
await db.SaveChangesAsync();
return (true, cost);
}
catch (Exception ex)
{
logger.LogError(ex, "Error retrying billing for account {accountId}", accountId);
return (false, cost);
}
}
}

View File

@@ -10,7 +10,10 @@
},
"AllowedHosts": "*",
"ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_insight;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
"App": "Host=localhost;Port=5432;Database=dyson_insight;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
"Registrar": "127.0.0.1:2379",
"Cache": "127.0.0.1:6379",
"Queue": "127.0.0.1:4222"
},
"KnownProxies": [
"127.0.0.1",
@@ -19,9 +22,26 @@
"Etcd": {
"Insecure": true
},
"Cache": {
"Serializer": "MessagePack"
},
"Thinking": {
"Provider": "deepseek",
"Model": "deepseek-chat",
"ApiKey": "sk-bd20f6a2e9fa40b98c46899baa0e9f09"
"DefaultService": "deepseek-chat",
"Services": {
"deepseek-chat": {
"Provider": "deepseek",
"Model": "deepseek-chat",
"ApiKey": "sk-",
"BillingMultiplier": 1.0,
"PerkLevel": 0
},
"deepseek-reasoner": {
"Provider": "deepseek",
"Model": "deepseek-reasoner",
"ApiKey": "sk-",
"BillingMultiplier": 1.5,
"PerkLevel": 1
}
}
}
}
}

View File

@@ -1,9 +1,11 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Affiliation;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Geometry;
using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Authorization;
@@ -22,7 +24,8 @@ public class AccountController(
SubscriptionService subscriptions,
AccountEventService events,
SocialCreditService socialCreditService,
GeoIpService geo
AffiliationSpellService ars,
GeoService geo
) : ControllerBase
{
[HttpGet("{name}")]
@@ -34,7 +37,7 @@ public class AccountController(
.Include(e => e.Badges)
.Include(e => e.Profile)
.Include(e => e.Contacts.Where(c => c.IsPublic))
.Where(a => a.Name == name)
.Where(a => EF.Functions.Like(a.Name, name))
.FirstOrDefaultAsync();
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";
[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]
@@ -271,10 +320,21 @@ public class AccountController(
[HttpPost("credits/validate")]
[Authorize]
[RequiredPermission("maintenance", "credits.validate.perform")]
[AskPermission("credits.validate.perform")]
public async Task<IActionResult> PerformSocialCreditValidation()
{
await socialCreditService.ValidateSocialCredits();
return Ok();
}
}
[HttpDelete("{name}")]
[Authorize]
[AskPermission("accounts.deletion")]
public async Task<IActionResult> AdminDeleteAccount(string name)
{
var account = await accounts.LookupAccount(name);
if (account is null) return NotFound();
await accounts.DeleteAccount(account);
return Ok();
}
}

View File

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

View File

@@ -313,52 +313,84 @@ public class AccountEventService(
CultureInfo.CurrentCulture = cultureInfo;
CultureInfo.CurrentUICulture = cultureInfo;
// Generate 2 positive tips
var positiveIndices = Enumerable.Range(1, FortuneTipCount)
.OrderBy(_ => Random.Next())
.Take(2)
.ToList();
var tips = positiveIndices.Select(index => new CheckInFortuneTip
{
IsPositive = true,
Title = localizer[$"FortuneTipPositiveTitle_{index}"].Value,
Content = localizer[$"FortuneTipPositiveContent_{index}"].Value
}).ToList();
// Generate 2 negative tips
var negativeIndices = Enumerable.Range(1, FortuneTipCount)
.Except(positiveIndices)
.OrderBy(_ => Random.Next())
.Take(2)
.ToList();
tips.AddRange(negativeIndices.Select(index => new CheckInFortuneTip
{
IsPositive = false,
Title = localizer[$"FortuneTipNegativeTitle_{index}"].Value,
Content = localizer[$"FortuneTipNegativeContent_{index}"].Value
}));
// The 5 is specialized, keep it alone.
// Use weighted random distribution to make all levels reasonably achievable
// Weights: Worst: 10%, Worse: 20%, Normal: 40%, Better: 20%, Best: 10%
var randomValue = Random.Next(100);
var checkInLevel = randomValue switch
{
< 10 => CheckInResultLevel.Worst, // 0-9: 10% chance
< 30 => CheckInResultLevel.Worse, // 10-29: 20% chance
< 70 => CheckInResultLevel.Normal, // 30-69: 40% chance
< 90 => CheckInResultLevel.Better, // 70-89: 20% chance
_ => CheckInResultLevel.Best // 90-99: 10% chance
};
var accountBirthday = await db.AccountProfiles
var accountProfile = await db.AccountProfiles
.Where(x => x.AccountId == user.Id)
.Select(x => x.Birthday)
.Select(x => new { x.Birthday, x.TimeZone })
.FirstOrDefaultAsync();
var accountBirthday = accountProfile?.Birthday;
var now = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
if (accountBirthday.HasValue && accountBirthday.Value.InUtc().Date == now)
var now = SystemClock.Instance.GetCurrentInstant();
var userTimeZone = DateTimeZone.Utc;
if (!string.IsNullOrEmpty(accountProfile?.TimeZone))
{
userTimeZone = DateTimeZoneProviders.Tzdb.GetZoneOrNull(accountProfile.TimeZone) ?? DateTimeZone.Utc;
}
var todayInUserTz = now.InZone(userTimeZone).Date;
var birthdayDate = accountBirthday?.InZone(userTimeZone).Date;
var isBirthday = birthdayDate.HasValue &&
birthdayDate.Value.Month == todayInUserTz.Month &&
birthdayDate.Value.Day == todayInUserTz.Day;
List<CheckInFortuneTip> tips;
CheckInResultLevel checkInLevel;
if (isBirthday)
{
// Skip random logic and tips generation for birthday
checkInLevel = CheckInResultLevel.Special;
tips = [
new CheckInFortuneTip()
{
IsPositive = true,
Title = localizer["FortuneTipSpecialTitle_Birthday"].Value,
Content = localizer["FortuneTipSpecialContent_Birthday", user.Nick].Value,
}
];
}
else
{
// Generate 2 positive tips
var positiveIndices = Enumerable.Range(1, FortuneTipCount)
.OrderBy(_ => Random.Next())
.Take(2)
.ToList();
tips = positiveIndices.Select(index => new CheckInFortuneTip
{
IsPositive = true,
Title = localizer[$"FortuneTipPositiveTitle_{index}"].Value,
Content = localizer[$"FortuneTipPositiveContent_{index}"].Value
}).ToList();
// Generate 2 negative tips
var negativeIndices = Enumerable.Range(1, FortuneTipCount)
.Except(positiveIndices)
.OrderBy(_ => Random.Next())
.Take(2)
.ToList();
tips.AddRange(negativeIndices.Select(index => new CheckInFortuneTip
{
IsPositive = false,
Title = localizer[$"FortuneTipNegativeTitle_{index}"].Value,
Content = localizer[$"FortuneTipNegativeContent_{index}"].Value
}));
// The 5 is specialized, keep it alone.
// Use weighted random distribution to make all levels reasonably achievable
// Weights: Worst: 10%, Worse: 20%, Normal: 40%, Better: 20%, Best: 10%
var randomValue = Random.Next(100);
checkInLevel = randomValue switch
{
< 10 => CheckInResultLevel.Worst, // 0-9: 10% chance
< 30 => CheckInResultLevel.Worse, // 10-29: 20% chance
< 70 => CheckInResultLevel.Normal, // 30-69: 40% chance
< 90 => CheckInResultLevel.Better, // 70-89: 20% chance
_ => CheckInResultLevel.Best // 90-99: 10% chance
};
}
var result = new SnCheckInResult
{
@@ -478,6 +510,54 @@ public class AccountEventService(
return activities;
}
public async Task<Dictionary<Guid, List<SnPresenceActivity>>> GetActiveActivitiesBatch(List<Guid> userIds)
{
var results = new Dictionary<Guid, List<SnPresenceActivity>>();
var cacheMissUserIds = new List<Guid>();
// Try to get activities from cache first
foreach (var userId in userIds)
{
var cacheKey = $"{ActivityCacheKey}{userId}";
var cachedActivities = await cache.GetAsync<List<SnPresenceActivity>>(cacheKey);
if (cachedActivities != null)
{
results[userId] = cachedActivities;
}
else
{
cacheMissUserIds.Add(userId);
}
}
// If all activities were found in cache, return early
if (cacheMissUserIds.Count == 0) return results;
// Fetch remaining activities from database in a single query
var now = SystemClock.Instance.GetCurrentInstant();
var activitiesFromDb = await db.PresenceActivities
.Where(e => cacheMissUserIds.Contains(e.AccountId) && e.LeaseExpiresAt > now && e.DeletedAt == null)
.ToListAsync();
// Group activities by user ID and update cache
var activitiesByUser = activitiesFromDb
.GroupBy(a => a.AccountId)
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var userId in cacheMissUserIds)
{
var userActivities = activitiesByUser.GetValueOrDefault(userId, new List<SnPresenceActivity>());
results[userId] = userActivities;
// Update cache for this user
var cacheKey = $"{ActivityCacheKey}{userId}";
await cache.SetWithGroupsAsync(cacheKey, userActivities, [$"{AccountService.AccountCachePrefix}{userId}"],
TimeSpan.FromMinutes(1));
}
return results;
}
public async Task<(List<SnPresenceActivity>, int)> GetAllActivities(Guid userId, int offset = 0, int take = 20)
{
var query = db.PresenceActivities

View File

@@ -1,9 +1,11 @@
using System.Globalization;
using DysonNetwork.Pass.Affiliation;
using DysonNetwork.Pass.Auth.OpenId;
using DysonNetwork.Pass.Localization;
using DysonNetwork.Pass.Mailer;
using DysonNetwork.Pass.Resources.Emails;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Stream;
@@ -23,6 +25,7 @@ public class AccountService(
FileService.FileServiceClient files,
FileReferenceService.FileReferenceServiceClient fileRefs,
AccountUsernameService uname,
AffiliationSpellService ars,
EmailService mailer,
RingService.RingServiceClient pusher,
IStringLocalizer<NotificationResource> localizer,
@@ -53,11 +56,13 @@ public class AccountService(
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;
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)
.FirstOrDefaultAsync();
return contact?.Account;
@@ -80,6 +85,17 @@ public class AccountService(
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(
string name,
string nick,
@@ -87,12 +103,12 @@ public class AccountService(
string? password,
string language = "en-US",
string region = "en",
string? affiliationSpell = null,
bool isEmailVerified = false,
bool isActivated = false
)
{
var dupeNameCount = await db.Accounts.Where(a => a.Name == name).CountAsync();
if (dupeNameCount > 0)
if (await CheckAccountNameHasTaken(name))
throw new InvalidOperationException("Account name has already been taken.");
var dupeEmailCount = await db.AccountContacts
@@ -100,7 +116,7 @@ public class AccountService(
).CountAsync();
if (dupeEmailCount > 0)
throw new InvalidOperationException("Account email has already been used.");
var account = new SnAccount
{
Name = name,
@@ -109,7 +125,7 @@ public class AccountService(
Region = region,
Contacts =
[
new()
new SnAccountContact
{
Type = Shared.Models.AccountContactType.Email,
Content = email,
@@ -131,6 +147,9 @@ public class AccountService(
Profile = new SnAccountProfile()
};
if (affiliationSpell is not null)
await ars.CreateAffiliationResult(affiliationSpell, $"account:{account.Id}");
if (isActivated)
{
account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
@@ -139,7 +158,7 @@ public class AccountService(
{
db.PermissionGroupMembers.Add(new SnPermissionGroupMember
{
Actor = $"user:{account.Id}",
Actor = account.Id.ToString(),
Group = defaultGroup
});
}
@@ -180,10 +199,7 @@ public class AccountService(
displayName,
userInfo.Email,
null,
"en-US",
"en",
userInfo.EmailVerified,
userInfo.EmailVerified
isEmailVerified: userInfo.EmailVerified
);
}
@@ -273,7 +289,8 @@ public class AccountService(
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;
switch (type)
@@ -351,7 +368,8 @@ public class AccountService(
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.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))
throw new InvalidOperationException(
@@ -507,9 +525,7 @@ public class AccountService(
private async Task<bool> IsDeviceActive(Guid id)
{
return await db.AuthSessions
.Include(s => s.Challenge)
.AnyAsync(s => s.Challenge.ClientId == id);
return await db.AuthSessions.AnyAsync(s => s.ClientId == id);
}
public async Task<SnAuthClient> UpdateDeviceName(SnAccount account, string deviceId, string label)
@@ -528,8 +544,7 @@ public class AccountService(
public async Task DeleteSession(SnAccount account, Guid sessionId)
{
var session = await db.AuthSessions
.Include(s => s.Challenge)
.ThenInclude(s => s.Client)
.Include(s => s.Client)
.Where(s => s.Id == sessionId && s.AccountId == account.Id)
.FirstOrDefaultAsync();
if (session is null) throw new InvalidOperationException("Session was not found.");
@@ -538,11 +553,11 @@ public class AccountService(
db.AuthSessions.Remove(session);
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()
{ DeviceId = session.Challenge.Client!.DeviceId }
{ DeviceId = session.Client!.DeviceId }
);
}
@@ -563,15 +578,13 @@ public class AccountService(
);
var sessions = await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.Challenge.ClientId == device.Id && s.AccountId == account.Id)
.Where(s => s.ClientId == device.Id && s.AccountId == account.Id)
.ToListAsync();
// The current session should be included in the sessions' list
var now = SystemClock.Instance.GetCurrentInstant();
await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.Challenge.ClientId == device.Id)
.Where(s => s.ClientId == device.Id)
.ExecuteUpdateAsync(p => p.SetProperty(s => s.DeletedAt, s => now));
db.AuthClients.Remove(device);
@@ -581,7 +594,8 @@ public class AccountService(
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
.Where(x => x.AccountId == account.Id && x.Type == type && x.Content == content)
@@ -643,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;
db.AccountContacts.Update(contact);

View File

@@ -24,15 +24,16 @@ public class AccountServiceGrpc(
public override async Task<Shared.Proto.Account> GetAccount(GetAccountRequest request, ServerCallContext context)
{
if (!Guid.TryParse(request.Id, out var accountId))
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format"));
throw new RpcException(new Status(StatusCode.InvalidArgument, "Invalid account ID format"));
var account = await _db.Accounts
.AsNoTracking()
.Include(a => a.Profile)
.Include(a => a.Contacts.Where(c => c.IsPublic))
.FirstOrDefaultAsync(a => a.Id == accountId);
if (account == null)
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Account {request.Id} not found"));
throw new RpcException(new Status(StatusCode.NotFound, $"Account {request.Id} not found"));
var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id);
account.PerkSubscription = perk?.ToReference();

View File

@@ -1,10 +1,10 @@
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Geometry;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Pass.Account;
public class ActionLogService(GeoIpService geo, FlushBufferService fbs)
public class ActionLogService(GeoService geo, FlushBufferService fbs)
{
public void CreateActionLog(Guid accountId, string action, Dictionary<string, object> meta)
{

View File

@@ -0,0 +1,55 @@
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Pass.Account;
[ApiController]
[Route("/api/friends")]
public class FriendsController(AppDatabase db, RelationshipService rels, AccountEventService events) : ControllerBase
{
public class FriendOverviewItem
{
public SnAccount Account { get; set; } = null!;
public SnAccountStatus Status { get; set; } = null!;
public List<SnPresenceActivity> Activities { get; set; } = [];
}
[HttpGet("overview")]
[Authorize]
public async Task<ActionResult<List<FriendOverviewItem>>> GetOverview([FromQuery] bool includeOffline = false)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var friendIds = await rels.ListAccountFriends(currentUser);
// Fetch data in parallel using batch methods for better performance
var accountsTask = db.Accounts
.Where(a => friendIds.Contains(a.Id))
.Include(a => a.Profile)
.ToListAsync();
var statusesTask = events.GetStatuses(friendIds);
var activitiesTask = events.GetActiveActivitiesBatch(friendIds);
// Wait for all data to be fetched
await Task.WhenAll(accountsTask, statusesTask, activitiesTask);
var accounts = accountsTask.Result;
var statuses = statusesTask.Result;
var activities = activitiesTask.Result;
var result = (from account in accounts
let status = statuses.GetValueOrDefault(account.Id)
where includeOffline || status is { IsOnline: true }
let accountActivities = activities.GetValueOrDefault(account.Id, new List<SnPresenceActivity>())
select new FriendOverviewItem
{
Account = account, Status = status ?? new SnAccountStatus { AccountId = account.Id },
Activities = accountActivities
}).ToList();
return Ok(result);
}
}

View File

@@ -1,3 +1,5 @@
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -7,17 +9,31 @@ namespace DysonNetwork.Pass.Account;
[Route("/api/spells")]
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")]
public async Task<ActionResult> ResendMagicSpell(Guid spellId)
{
var spell = db.MagicSpells.FirstOrDefault(x => x.Id == spellId);
if (spell == null)
return NotFound();
await sp.NotifyMagicSpell(spell, true);
return Ok();
}
[HttpGet("{spellWord}")]
public async Task<ActionResult> GetMagicSpell(string spellWord)
{
@@ -38,7 +54,8 @@ public class MagicSpellController(AppDatabase db, MagicSpellService sp) : Contro
}
[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 spell = await db.MagicSpells
@@ -59,6 +76,7 @@ public class MagicSpellController(AppDatabase db, MagicSpellService sp) : Contro
{
return BadRequest(ex.Message);
}
return Ok();
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
using System.Linq.Expressions;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
@@ -61,6 +61,9 @@ public class AppDatabase(
public DbSet<SnLottery> Lotteries { 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)
{
optionsBuilder.UseNpgsql(
@@ -100,7 +103,7 @@ public class AppDatabase(
"stickers.packs.create",
"stickers.create"
}.Select(permission =>
PermissionService.NewPermissionNode("group:default", "global", permission, true))
PermissionService.NewPermissionNode("group:default", permission, true))
.ToList()
});
await context.SaveChangesAsync(cancellationToken);
@@ -143,51 +146,12 @@ public class AppDatabase(
.HasForeignKey(pm => pm.RealmId)
.OnDelete(DeleteBehavior.Cascade);
// Automatically apply soft-delete filter to all entities inheriting BaseModel
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (!typeof(ModelBase).IsAssignableFrom(entityType.ClrType)) continue;
var method = typeof(AppDatabase)
.GetMethod(nameof(SetSoftDeleteFilter),
BindingFlags.NonPublic | BindingFlags.Static)!
.MakeGenericMethod(entityType.ClrType);
method.Invoke(null, [modelBuilder]);
}
}
private static void SetSoftDeleteFilter<TEntity>(ModelBuilder modelBuilder)
where TEntity : ModelBase
{
modelBuilder.Entity<TEntity>().HasQueryFilter(e => e.DeletedAt == null);
modelBuilder.ApplySoftDeleteFilters();
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var now = SystemClock.Instance.GetCurrentInstant();
foreach (var entry in ChangeTracker.Entries<ModelBase>())
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedAt = now;
entry.Entity.UpdatedAt = now;
break;
case EntityState.Modified:
entry.Entity.UpdatedAt = now;
break;
case EntityState.Deleted:
entry.State = EntityState.Modified;
entry.Entity.DeletedAt = now;
break;
case EntityState.Detached:
case EntityState.Unchanged:
default:
break;
}
}
this.ApplyAuditableAndSoftDelete();
return await base.SaveChangesAsync(cancellationToken);
}
}
@@ -266,34 +230,3 @@ public class AppDatabaseFactory : IDesignTimeDbContextFactory<AppDatabase>
}
}
public static class OptionalQueryExtensions
{
public static IQueryable<T> If<T>(
this IQueryable<T> source,
bool condition,
Func<IQueryable<T>, IQueryable<T>> transform
)
{
return condition ? transform(source) : source;
}
public static IQueryable<T> If<T, TP>(
this IIncludableQueryable<T, TP> source,
bool condition,
Func<IIncludableQueryable<T, TP>, IQueryable<T>> transform
)
where T : class
{
return condition ? transform(source) : source;
}
public static IQueryable<T> If<T, TP>(
this IIncludableQueryable<T, IEnumerable<TP>> source,
bool condition,
Func<IIncludableQueryable<T, IEnumerable<TP>>, IQueryable<T>> transform
)
where T : class
{
return condition ? transform(source) : source;
}
}

View File

@@ -70,7 +70,7 @@ public class DysonTokenAuthHandler(
};
// 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
if (session.Account.IsSuperuser)
@@ -117,16 +117,17 @@ public class DysonTokenAuthHandler(
{
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
var token = authHeader["Bearer ".Length..].Trim();
var parts = token.Split('.');
var tokenText = authHeader["Bearer ".Length..].Trim();
var parts = tokenText.Split('.');
return new TokenInfo
{
Token = token,
Token = tokenText,
Type = parts.Length == 3 ? TokenType.OidcKey : TokenType.AuthKey
};
}
else if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
{
return new TokenInfo
{
@@ -134,7 +135,8 @@ public class DysonTokenAuthHandler(
Type = TokenType.AuthKey
};
}
else if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
{
return new TokenInfo
{

View File

@@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using NodaTime;
using Microsoft.EntityFrameworkCore;
using DysonNetwork.Pass.Localization;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Geometry;
using DysonNetwork.Shared.Proto;
using Microsoft.Extensions.Localization;
using AccountService = DysonNetwork.Pass.Account.AccountService;
@@ -18,7 +18,7 @@ public class AuthController(
AppDatabase db,
AccountService accounts,
AuthService auth,
GeoIpService geo,
GeoService geo,
ActionLogService als,
RingService.RingServiceClient pusher,
IConfiguration configuration,
@@ -30,12 +30,12 @@ public class AuthController(
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(512)] public string DeviceId { get; set; } = null!;
[MaxLength(1024)] public string? DeviceName { get; set; }
public List<string> Audiences { get; set; } = new();
public List<string> Scopes { get; set; } = new();
public List<string> Audiences { get; set; } = [];
public List<string> Scopes { get; set; } = [];
}
[HttpPost("challenge")]
@@ -61,9 +61,6 @@ public class AuthController(
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
var existingChallenge = await db.AuthChallenges
.Where(e => e.AccountId == account.Id)
@@ -71,15 +68,9 @@ public class AuthController(
.Where(e => e.UserAgent == userAgent)
.Where(e => e.StepRemain > 0)
.Where(e => e.ExpiredAt != null && now < e.ExpiredAt)
.Where(e => e.Type == Shared.Models.ChallengeType.Login)
.Where(e => e.ClientId == device.Id)
.Where(e => e.DeviceId == request.DeviceId)
.FirstOrDefaultAsync();
if (existingChallenge is not null)
{
var existingSession = await db.AuthSessions.Where(e => e.ChallengeId == existingChallenge.Id)
.FirstOrDefaultAsync();
if (existingSession is null) return existingChallenge;
}
if (existingChallenge is not null) return existingChallenge;
var challenge = new SnAuthChallenge
{
@@ -90,7 +81,9 @@ public class AuthController(
IpAddress = ipAddress,
UserAgent = userAgent,
Location = geo.GetPointFromIp(ipAddress),
ClientId = device.Id,
DeviceId = request.DeviceId,
DeviceName = request.DeviceName,
Platform = request.Platform,
AccountId = account.Id
}.Normalize();
@@ -112,14 +105,11 @@ public class AuthController(
.ThenInclude(e => e.Profile)
.FirstOrDefaultAsync(e => e.Id == id);
if (challenge is null)
{
logger.LogWarning("GetChallenge: challenge not found (challengeId={ChallengeId}, ip={IpAddress})",
id, HttpContext.Connection.RemoteIpAddress?.ToString());
return NotFound("Auth challenge was not found.");
}
if (challenge is not null) return challenge;
logger.LogWarning("GetChallenge: challenge not found (challengeId={ChallengeId}, ip={IpAddress})",
id, HttpContext.Connection.RemoteIpAddress?.ToString());
return NotFound("Auth challenge was not found.");
return challenge;
}
[HttpGet("challenge/{id:guid}/factors")]
@@ -176,7 +166,6 @@ public class AuthController(
{
var challenge = await db.AuthChallenges
.Include(e => e.Account)
.Include(authChallenge => authChallenge.Client)
.FirstOrDefaultAsync(e => e.Id == id);
if (challenge is null) return NotFound("Auth challenge was not found.");
@@ -218,7 +207,7 @@ public class AuthController(
throw new ArgumentException("Invalid password.");
}
}
catch (Exception ex)
catch (Exception)
{
challenge.FailedAttempts++;
db.Update(challenge);
@@ -231,8 +220,11 @@ public class AuthController(
);
await db.SaveChangesAsync();
logger.LogWarning("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);
logger.LogWarning(
"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.");
}
@@ -242,11 +234,11 @@ public class AuthController(
AccountService.SetCultureInfo(challenge.Account);
await pusher.SendPushNotificationToUserAsync(new SendPushNotificationToUserRequest
{
Notification = new PushNotification()
Notification = new PushNotification
{
Topic = "auth.login",
Title = localizer["NewLoginTitle"],
Body = localizer["NewLoginBody", challenge.Client?.DeviceName ?? "unknown",
Body = localizer["NewLoginBody", challenge.DeviceName ?? "unknown",
challenge.IpAddress ?? "unknown"],
IsSavable = true
},
@@ -277,6 +269,14 @@ public class AuthController(
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")]
public async Task<ActionResult<TokenExchangeResponse>> ExchangeToken([FromBody] TokenExchangeRequest request)
{
@@ -327,4 +327,35 @@ public class AuthController(
});
return Ok();
}
}
[HttpPost("login/session")]
[Microsoft.AspNetCore.Authorization.Authorize] // Use full namespace to avoid ambiguity with DysonNetwork.Pass.Permission.Authorize
public async Task<ActionResult<TokenExchangeResponse>> LoginFromSession([FromBody] NewSessionRequest request)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount ||
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession)
return Unauthorized();
var newSession = await auth.CreateSessionFromParentAsync(
currentSession,
request.DeviceId,
request.DeviceName,
request.Platform,
request.ExpiredAt
);
var tk = auth.CreateToken(newSession);
// Set cookie using HttpContext, similar to CreateSessionAndIssueToken
HttpContext.Response.Cookies.Append(AuthConstants.CookieTokenName, tk, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
Domain = _cookieDomain,
Expires = request.ExpiredAt?.ToDateTimeOffset() ?? DateTime.UtcNow.AddYears(20)
});
return Ok(new TokenExchangeResponse { Token = tk });
}
}

View File

@@ -2,6 +2,8 @@ using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Geometry;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using NodaTime;
@@ -13,7 +15,8 @@ public class AuthService(
IConfiguration config,
IHttpClientFactory httpClientFactory,
IHttpContextAccessor httpContextAccessor,
ICacheService cache
ICacheService cache,
GeoService geo
)
{
private HttpContext HttpContext => httpContextAccessor.HttpContext!;
@@ -30,7 +33,7 @@ public class AuthService(
{
// 1) Find out how many authentication factors the account has enabled.
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)
.ToListAsync();
var maxSteps = enabledFactors.Count;
@@ -41,13 +44,18 @@ public class AuthService(
// 2) Get login context from recent sessions
var recentSessions = await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.AccountId == account.Id)
.Where(s => s.LastGrantedAt != null)
.OrderByDescending(s => s.LastGrantedAt)
.Take(10)
.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 userAgent = request.Headers.UserAgent.ToString();
@@ -59,14 +67,14 @@ public class AuthService(
else
{
// 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)
{
riskScore += 8;
}
// 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)
{
riskScore += 6;
@@ -80,9 +88,9 @@ public class AuthService(
}
else
{
var uaPreviouslyUsed = recentSessions.Any(s =>
!string.IsNullOrWhiteSpace(s.Challenge?.UserAgent) &&
string.Equals(s.Challenge.UserAgent, userAgent, StringComparison.OrdinalIgnoreCase));
var uaPreviouslyUsed = recentChallenges.Any(c =>
!string.IsNullOrWhiteSpace(c.UserAgent) &&
string.Equals(c.UserAgent, userAgent, StringComparison.OrdinalIgnoreCase));
if (!uaPreviouslyUsed)
{
@@ -156,7 +164,7 @@ public class AuthService(
// 8) Device Trust Assessment
var trustedDeviceIds = recentSessions
.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)
.Distinct()
.ToList();
@@ -180,29 +188,28 @@ public class AuthService(
return totalRequiredSteps;
}
public async Task<SnAuthSession> CreateSessionForOidcAsync(SnAccount account, Instant time,
Guid? customAppId = null)
public async Task<SnAuthSession> CreateSessionForOidcAsync(
SnAccount account,
Instant time,
Guid? customAppId = null,
SnAuthSession? parentSession = null
)
{
var challenge = new SnAuthChallenge
{
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 ipAddr = HttpContext.Connection.RemoteIpAddress?.ToString();
var geoLocation = ipAddr is not null ? geo.GetPointFromIp(ipAddr) : null;
var session = new SnAuthSession
{
AccountId = account.Id,
CreatedAt = time,
LastGrantedAt = time,
Challenge = challenge,
AppId = customAppId
IpAddress = ipAddr,
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);
await db.SaveChangesAsync();
@@ -216,7 +223,8 @@ public class AuthService(
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;
device = new SnAuthClient
{
@@ -287,35 +295,71 @@ public class AuthService(
/// <summary>
/// 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>
/// <param name="sessionId">Session ID to revoke</param>
/// <returns>True if session was found and revoked, false otherwise</returns>
public async Task<bool> RevokeSessionAsync(Guid sessionId)
{
var session = await db.AuthSessions.FirstOrDefaultAsync(s => s.Id == sessionId);
if (session == null)
var sessionsToRevokeIds = new HashSet<Guid>();
await CollectSessionsToRevoke(sessionId, sessionsToRevokeIds);
if (sessionsToRevokeIds.Count == 0)
{
return false;
}
// Set expiry to now (immediate invalidation)
var now = SystemClock.Instance.GetCurrentInstant();
session.ExpiredAt = now;
db.AuthSessions.Update(session);
var accountIdsToClearCache = new HashSet<Guid>();
// Clear from cache immediately
var cacheKey = $"{AuthCachePrefix}{session.Id}";
await cache.RemoveAsync(cacheKey);
// Fetch all sessions to be revoked in one go
var sessions = await db.AuthSessions
.Where(s => sessionsToRevokeIds.Contains(s.Id))
.ToListAsync();
// Clear account-level cache groups that include this session
await cache.RemoveAsync($"{AuthCachePrefix}{session.AccountId}");
foreach (var session in sessions)
{
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();
// Clear account-level cache groups
foreach (var accountId in accountIdsToClearCache)
{
await cache.RemoveAsync($"{AuthCachePrefix}{accountId}");
}
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>
/// Revoke all sessions for an account (logout everywhere)
/// </summary>
@@ -374,10 +418,12 @@ public class AuthService(
if (challenge.StepRemain != 0)
throw new ArgumentException("Challenge not yet completed.");
var hasSession = await db.AuthSessions
.AnyAsync(e => e.ChallengeId == challenge.Id);
if (hasSession)
throw new ArgumentException("Session already exists for this challenge.");
var device = await GetOrCreateDeviceAsync(
challenge.AccountId,
challenge.DeviceId,
challenge.DeviceName,
challenge.Platform
);
var now = SystemClock.Instance.GetCurrentInstant();
var session = new SnAuthSession
@@ -385,7 +431,13 @@ public class AuthService(
LastGrantedAt = now,
ExpiredAt = now.Plus(Duration.FromDays(7)),
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);
@@ -408,7 +460,7 @@ public class AuthService(
return tk;
}
private string CreateCompactToken(Guid sessionId, RSA rsa)
private static string CreateCompactToken(Guid sessionId, RSA rsa)
{
// Create the payload: just the session ID
var payloadBytes = sessionId.ToByteArray();
@@ -499,7 +551,8 @@ public class AuthService(
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
{
@@ -508,7 +561,8 @@ public class AuthService(
Session = new SnAuthSession
{
AccountId = accountId,
ExpiredAt = expiredAt
ExpiredAt = expiredAt,
ParentSessionId = parentSession?.Id
},
};
@@ -614,4 +668,47 @@ public class AuthService(
return Convert.FromBase64String(padded);
}
}
/// <summary>
/// Creates a new session derived from an existing parent session.
/// </summary>
/// <param name="parentSession">The existing session from which the new session is derived.</param>
/// <param name="deviceId">The ID of the device for the new session.</param>
/// <param name="deviceName">The name of the device for the new session.</param>
/// <param name="platform">The platform of the device for the new session.</param>
/// <param name="expiredAt">Optional: The expiration time for the new session.</param>
/// <returns>The newly created SnAuthSession.</returns>
public async Task<SnAuthSession> CreateSessionFromParentAsync(
SnAuthSession parentSession,
string deviceId,
string? deviceName,
ClientPlatform platform,
Instant? expiredAt = null
)
{
var device = await GetOrCreateDeviceAsync(parentSession.AccountId, deviceId, deviceName, platform);
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
var geoLocation = ipAddress is not null ? geo.GetPointFromIp(ipAddress) : null;
var now = SystemClock.Instance.GetCurrentInstant();
var session = new SnAuthSession
{
IpAddress = ipAddress,
UserAgent = userAgent,
Location = geoLocation,
AccountId = parentSession.AccountId,
CreatedAt = now,
LastGrantedAt = now,
ExpiredAt = expiredAt,
ParentSessionId = parentSession.Id,
ClientId = device.Id,
};
db.AuthSessions.Add(session);
await db.SaveChangesAsync();
return session;
}
}

View File

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

View File

@@ -5,7 +5,8 @@ namespace DysonNetwork.Pass.Auth.OidcProvider.Models;
public class AuthorizationCodeInfo
{
public Guid ClientId { get; set; }
public Guid AccountId { get; set; }
public Guid? AccountId { get; set; }
public ExternalUserInfo? ExternalUserInfo { get; set; }
public string RedirectUri { get; set; } = string.Empty;
public List<string> Scopes { get; set; } = new();
public string? CodeChallenge { get; set; }

View File

@@ -0,0 +1,9 @@
namespace DysonNetwork.Pass.Auth.OidcProvider.Models;
public class ExternalUserInfo
{
public string Provider { get; set; } = null!;
public string UserId { get; set; } = null!;
public string? Email { get; set; }
public string? Name { get; set; }
}

View File

@@ -5,7 +5,7 @@ namespace DysonNetwork.Pass.Auth.OidcProvider.Responses;
public class TokenResponse
{
[JsonPropertyName("access_token")]
public string AccessToken { get; set; } = null!;
public string? AccessToken { get; set; } = null!;
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; set; }
@@ -22,4 +22,7 @@ public class TokenResponse
[JsonPropertyName("id_token")]
public string? IdToken { get; set; }
[JsonPropertyName("onboarding_token")]
public string? OnboardingToken { get; set; }
}

View File

@@ -72,7 +72,6 @@ public class OidcProviderService(
var now = SystemClock.Instance.GetCurrentInstant();
var queryable = db.AuthSessions
.Include(s => s.Challenge)
.AsQueryable();
if (withAccount)
queryable = queryable
@@ -85,8 +84,7 @@ public class OidcProviderService(
.Where(s => s.AccountId == accountId &&
s.AppId == clientId &&
(s.ExpiredAt == null || s.ExpiredAt > now) &&
s.Challenge != null &&
s.Challenge.Type == Shared.Models.ChallengeType.OAuth)
s.Type == Shared.Models.SessionType.OAuth)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync();
}
@@ -257,18 +255,15 @@ public class OidcProviderService(
}
private async Task<(SnAuthSession session, string? nonce, List<string>? scopes)> HandleAuthorizationCodeFlowAsync(
string authorizationCode,
Guid clientId,
string? redirectUri,
string? codeVerifier
AuthorizationCodeInfo authCode,
Guid clientId
)
{
var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier);
if (authCode == null)
throw new InvalidOperationException("Invalid authorization code");
if (authCode.AccountId == null)
throw new InvalidOperationException("Invalid authorization code, account id is missing.");
// Load the session for the user
var existingSession = await FindValidSessionAsync(authCode.AccountId, clientId, withAccount: true);
var existingSession = await FindValidSessionAsync(authCode.AccountId.Value, clientId, withAccount: true);
SnAuthSession session;
if (existingSession == null)
@@ -315,31 +310,124 @@ public class OidcProviderService(
var client = await FindClientByIdAsync(clientId) ?? throw new InvalidOperationException("Client not found");
var (session, nonce, scopes) = authorizationCode != null
? await HandleAuthorizationCodeFlowAsync(authorizationCode, clientId, redirectUri, codeVerifier)
: sessionId.HasValue
? await HandleRefreshTokenFlowAsync(sessionId.Value)
: throw new InvalidOperationException("Either authorization code or session ID must be provided");
if (authorizationCode != null)
{
var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier);
if (authCode == null)
{
throw new InvalidOperationException("Invalid authorization code");
}
if (authCode.AccountId.HasValue)
{
var (session, nonce, scopes) = await HandleAuthorizationCodeFlowAsync(authCode, clientId);
var clock = SystemClock.Instance;
var now = clock.GetCurrentInstant();
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
// Generate tokens
var accessToken = GenerateJwtToken(client, session, expiresAt, scopes);
var idToken = GenerateIdToken(client, session, nonce, scopes);
var refreshToken = GenerateRefreshToken(session);
return new TokenResponse
{
AccessToken = accessToken,
IdToken = idToken,
ExpiresIn = expiresIn,
TokenType = "Bearer",
RefreshToken = refreshToken,
Scope = scopes != null ? string.Join(" ", scopes) : null
};
}
if (authCode.ExternalUserInfo != null)
{
var onboardingToken = GenerateOnboardingToken(client, authCode.ExternalUserInfo, authCode.Nonce, authCode.Scopes);
return new TokenResponse
{
OnboardingToken = onboardingToken,
TokenType = "Onboarding"
};
}
throw new InvalidOperationException("Invalid authorization code state.");
}
if (sessionId.HasValue)
{
var (session, nonce, scopes) = await HandleRefreshTokenFlowAsync(sessionId.Value);
var clock = SystemClock.Instance;
var now = clock.GetCurrentInstant();
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
var accessToken = GenerateJwtToken(client, session, expiresAt, scopes);
var idToken = GenerateIdToken(client, session, nonce, scopes);
var refreshToken = GenerateRefreshToken(session);
return new TokenResponse
{
AccessToken = accessToken,
IdToken = idToken,
ExpiresIn = expiresIn,
TokenType = "Bearer",
RefreshToken = refreshToken,
Scope = scopes != null ? string.Join(" ", scopes) : null
};
}
throw new InvalidOperationException("Either authorization code or session ID must be provided");
}
private string GenerateOnboardingToken(CustomApp client, ExternalUserInfo externalUserInfo, string? nonce,
List<string> scopes)
{
var tokenHandler = new JwtSecurityTokenHandler();
var clock = SystemClock.Instance;
var now = clock.GetCurrentInstant();
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
// Generate tokens
var accessToken = GenerateJwtToken(client, session, expiresAt, scopes);
var idToken = GenerateIdToken(client, session, nonce, scopes);
var refreshToken = GenerateRefreshToken(session);
return new TokenResponse
var claims = new List<Claim>
{
AccessToken = accessToken,
IdToken = idToken,
ExpiresIn = expiresIn,
TokenType = "Bearer",
RefreshToken = refreshToken,
Scope = scopes != null ? string.Join(" ", scopes) : null
new(JwtRegisteredClaimNames.Iss, _options.IssuerUri),
new(JwtRegisteredClaimNames.Aud, client.Slug),
new(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
new(JwtRegisteredClaimNames.Exp,
now.Plus(Duration.FromMinutes(15)).ToUnixTimeSeconds()
.ToString(), ClaimValueTypes.Integer64),
new("provider", externalUserInfo.Provider),
new("provider_user_id", externalUserInfo.UserId)
};
if (!string.IsNullOrEmpty(externalUserInfo.Email))
{
claims.Add(new Claim(JwtRegisteredClaimNames.Email, externalUserInfo.Email));
}
if (!string.IsNullOrEmpty(externalUserInfo.Name))
{
claims.Add(new Claim("name", externalUserInfo.Name));
}
if (!string.IsNullOrEmpty(nonce))
{
claims.Add(new Claim("nonce", nonce));
}
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Issuer = _options.IssuerUri,
Audience = client.Slug,
Expires = now.Plus(Duration.FromMinutes(15)).ToDateTimeUtc(),
NotBefore = now.ToDateTimeUtc(),
SigningCredentials = new SigningCredentials(
new RsaSecurityKey(_options.GetRsaPrivateKey()),
SecurityAlgorithms.RsaSha256
)
};
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
private string GenerateJwtToken(
@@ -421,7 +509,6 @@ public class OidcProviderService(
{
return await db.AuthSessions
.Include(s => s.Account)
.Include(s => s.Challenge)
.FirstOrDefaultAsync(s => s.Id == sessionId);
}
@@ -440,12 +527,6 @@ public class OidcProviderService(
string? nonce = null
)
{
// Generate a random code
var clock = SystemClock.Instance;
var code = GenerateRandomString(32);
var now = clock.GetCurrentInstant();
// Create the authorization code info
var authCodeInfo = new AuthorizationCodeInfo
{
ClientId = clientId,
@@ -455,17 +536,47 @@ public class OidcProviderService(
CodeChallenge = codeChallenge,
CodeChallengeMethod = codeChallengeMethod,
Nonce = nonce,
CreatedAt = now
CreatedAt = SystemClock.Instance.GetCurrentInstant()
};
// Store the code with its metadata in the cache
return await StoreAuthorizationCode(authCodeInfo);
}
public async Task<string> GenerateAuthorizationCodeAsync(
Guid clientId,
ExternalUserInfo externalUserInfo,
string redirectUri,
IEnumerable<string> scopes,
string? codeChallenge = null,
string? codeChallengeMethod = null,
string? nonce = null
)
{
var authCodeInfo = new AuthorizationCodeInfo
{
ClientId = clientId,
ExternalUserInfo = externalUserInfo,
RedirectUri = redirectUri,
Scopes = scopes.ToList(),
CodeChallenge = codeChallenge,
CodeChallengeMethod = codeChallengeMethod,
Nonce = nonce,
CreatedAt = SystemClock.Instance.GetCurrentInstant()
};
return await StoreAuthorizationCode(authCodeInfo);
}
private async Task<string> StoreAuthorizationCode(AuthorizationCodeInfo authCodeInfo)
{
var code = GenerateRandomString(32);
var cacheKey = $"{CacheKeyPrefixAuthCode}{code}";
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId);
logger.LogInformation("Generated authorization code for client {ClientId}", authCodeInfo.ClientId);
return code;
}
private async Task<AuthorizationCodeInfo?> ValidateAuthorizationCodeAsync(
string code,
Guid clientId,

View File

@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DysonNetwork.Shared.Cache;
using Microsoft.AspNetCore.WebUtilities;
using NodaTime;
using DysonNetwork.Shared.Models;
@@ -17,7 +18,8 @@ public class ConnectionController(
AccountService accounts,
AuthService auth,
ICacheService cache,
IConfiguration configuration
IConfiguration configuration,
ILogger<ConnectionController> logger
) : ControllerBase
{
private const string StateCachePrefix = "oidc-state:";
@@ -152,8 +154,13 @@ public class ConnectionController(
{
var stateValue = await cache.GetAsync<string>(stateKey);
if (string.IsNullOrEmpty(stateValue) || !OidcState.TryParse(stateValue, out oidcState) || oidcState == null)
{
logger.LogWarning("Invalid or expired OIDC state: {State}", callbackData.State);
return BadRequest("Invalid or expired state parameter");
}
}
logger.LogInformation("OIDC callback for provider {Provider} with state {State} and flow {FlowType}", provider, callbackData.State, oidcState.FlowType);
// Remove the state from cache to prevent replay attacks
await cache.RemoveAsync(stateKey);
@@ -166,19 +173,24 @@ public class ConnectionController(
{
callbackData.State = oidcState.DeviceId;
}
return await HandleManualConnection(provider, oidcService, callbackData, oidcState.AccountId.Value);
}
else if (oidcState.FlowType == OidcFlowType.Login)
if (oidcState.FlowType == OidcFlowType.Login)
{
// Login/Registration flow
if (!string.IsNullOrEmpty(oidcState.DeviceId))
{
callbackData.State = oidcState.DeviceId;
}
// Store return URL if provided
if (string.IsNullOrEmpty(oidcState.ReturnUrl) || oidcState.ReturnUrl == "/")
{
logger.LogInformation("No returnUrl provided in OIDC state, will use default.");
return await HandleLoginOrRegistration(provider, oidcService, callbackData);
}
logger.LogInformation("Storing returnUrl {ReturnUrl} for state {State}", oidcState.ReturnUrl, callbackData.State);
var returnUrlKey = $"{ReturnUrlCachePrefix}{callbackData.State}";
await cache.SetAsync(returnUrlKey, oidcState.ReturnUrl, StateExpiration);
@@ -204,6 +216,7 @@ public class ConnectionController(
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing OIDC callback for provider {Provider} during connection flow", provider);
return BadRequest($"Error processing {provider} authentication: {ex.Message}");
}
@@ -268,8 +281,9 @@ public class ConnectionController(
{
await db.SaveChangesAsync();
}
catch (DbUpdateException)
catch (DbUpdateException ex)
{
logger.LogError(ex, "Failed to save OIDC connection for provider {Provider}", provider);
return StatusCode(500, $"Failed to save {provider} connection. Please try again.");
}
@@ -279,8 +293,10 @@ public class ConnectionController(
await cache.RemoveAsync(returnUrlKey);
var siteUrl = configuration["SiteUrl"];
return Redirect(string.IsNullOrEmpty(returnUrl) ? siteUrl + "/auth/callback" : returnUrl);
var redirectUrl = string.IsNullOrEmpty(returnUrl) ? siteUrl + "/auth/callback" : returnUrl;
logger.LogInformation("Redirecting after OIDC connection to {RedirectUrl}", redirectUrl);
return Redirect(redirectUrl);
}
private async Task<IActionResult> HandleLoginOrRegistration(
@@ -296,6 +312,7 @@ public class ConnectionController(
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing OIDC callback for provider {Provider} during login/registration flow", provider);
return BadRequest($"Error processing callback: {ex.Message}");
}
@@ -303,12 +320,21 @@ public class ConnectionController(
{
return BadRequest($"Email or user ID is missing from {provider}'s response");
}
// Retrieve and clean up the return URL
var returnUrlKey = $"{ReturnUrlCachePrefix}{callbackData.State}";
var returnUrl = await cache.GetAsync<string>(returnUrlKey);
await cache.RemoveAsync(returnUrlKey);
var siteUrl = configuration["SiteUrl"];
var redirectBaseUrl = string.IsNullOrEmpty(returnUrl) ? siteUrl + "/auth/callback" : returnUrl;
var connection = await db.AccountConnections
.Include(c => c.Account)
.FirstOrDefaultAsync(c => c.Provider == provider && c.ProvidedIdentifier == userInfo.UserId);
var clock = SystemClock.Instance;
if (connection != null)
{
// Login existing user
@@ -316,12 +342,21 @@ public class ConnectionController(
callbackData.State.Split('|').FirstOrDefault() :
string.Empty;
var challenge = await oidcService.CreateChallengeForUserAsync(
if (HttpContext.Items["CurrentSession"] is not SnAuthSession parentSession) parentSession = null;
var session = await oidcService.CreateSessionForUserAsync(
userInfo,
connection.Account,
HttpContext,
deviceId ?? string.Empty);
return Redirect($"/auth/callback?challenge={challenge.Id}");
deviceId ?? string.Empty,
null,
ClientPlatform.Web,
parentSession);
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);
return Redirect(redirectUrl);
}
// Register new user
@@ -345,9 +380,9 @@ public class ConnectionController(
var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant());
var loginToken = auth.CreateToken(loginSession);
var siteUrl = configuration["SiteUrl"];
return Redirect(siteUrl + $"/auth/callback?token={loginToken}");
var finalRedirectUrl = QueryHelpers.AddQueryString(redirectBaseUrl, "token", loginToken);
logger.LogInformation("OIDC registration successful for new user {UserId}. Redirecting to {RedirectUrl}", account.Id, finalRedirectUrl);
return Redirect(finalRedirectUrl);
}
private static async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request)

View File

@@ -14,7 +14,9 @@ public class OidcController(
IServiceProvider serviceProvider,
AppDatabase db,
AccountService accounts,
ICacheService cache
AuthService auth,
ICacheService cache,
ILogger<OidcController> logger
)
: ControllerBase
{
@@ -25,15 +27,17 @@ public class OidcController(
public async Task<ActionResult> OidcLogin(
[FromRoute] string provider,
[FromQuery] string? returnUrl = "/",
[FromHeader(Name = "X-Device-Id")] string? deviceId = null
[FromQuery] string? deviceId = null,
[FromQuery] string? flow = null
)
{
logger.LogInformation("OIDC login request for provider {Provider} with returnUrl {ReturnUrl}, deviceId {DeviceId} and flow {Flow}", provider, returnUrl, deviceId, flow);
try
{
var oidcService = GetOidcService(provider);
// If the user is already authenticated, treat as an account connection request
if (HttpContext.Items["CurrentUser"] is SnAccount currentUser)
if (flow != "login" && HttpContext.Items["CurrentUser"] is SnAccount currentUser)
{
var state = Guid.NewGuid().ToString();
var nonce = Guid.NewGuid().ToString();
@@ -41,6 +45,7 @@ public class OidcController(
// Create and store connection state
var oidcState = OidcState.ForConnection(currentUser.Id, provider, nonce, deviceId);
await cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration);
logger.LogInformation("OIDC connection flow started for user {UserId} with state {State}", currentUser.Id, state);
// The state parameter sent to the provider is the GUID key for the cache.
var authUrl = await oidcService.GetAuthorizationUrlAsync(state, nonce);
@@ -54,12 +59,14 @@ public class OidcController(
// Create login state with return URL and device ID
var oidcState = OidcState.ForLogin(returnUrl ?? "/", deviceId);
await cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration);
logger.LogInformation("OIDC login flow started with state {State} and returnUrl {ReturnUrl}", state, oidcState.ReturnUrl);
var authUrl = await oidcService.GetAuthorizationUrlAsync(state, nonce);
return Redirect(authUrl);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error initiating OIDC flow for provider {Provider}", provider);
return BadRequest($"Error initiating OpenID Connect flow: {ex.Message}");
}
}
@@ -69,7 +76,7 @@ public class OidcController(
/// Handles Apple authentication directly from mobile apps
/// </summary>
[HttpPost("apple/mobile")]
public async Task<ActionResult<SnAuthChallenge>> AppleMobileLogin(
public async Task<ActionResult<AuthController.TokenExchangeResponse>> AppleMobileLogin(
[FromBody] AppleMobileSignInRequest request
)
{
@@ -92,16 +99,21 @@ public class OidcController(
// Find or create user account using existing logic
var account = await FindOrCreateAccount(userInfo, "apple");
if (HttpContext.Items["CurrentSession"] is not SnAuthSession parentSession) parentSession = null;
// Create session using the OIDC service
var challenge = await appleService.CreateChallengeForUserAsync(
var session = await appleService.CreateSessionForUserAsync(
userInfo,
account,
HttpContext,
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)
{

View File

@@ -1,4 +1,3 @@
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using System.Text;
@@ -250,15 +249,17 @@ public abstract class OidcService(
}
/// <summary>
/// Creates a challenge and session for an authenticated user
/// Creates a session for an authenticated user
/// Also creates or updates the account connection
/// </summary>
public async Task<SnAuthChallenge> CreateChallengeForUserAsync(
public async Task<SnAuthSession> CreateSessionForUserAsync(
OidcUserInfo userInfo,
SnAccount account,
HttpContext request,
string deviceId,
string? deviceName = null
string? deviceName = null,
ClientPlatform platform = ClientPlatform.Web,
SnAuthSession? parentSession = null
)
{
// Create or update the account connection
@@ -282,28 +283,24 @@ public abstract class OidcService(
await Db.AccountConnections.AddAsync(connection);
}
// Create a challenge that's already completed
// Create a session directly
var now = SystemClock.Instance.GetCurrentInstant();
var device = await auth.GetOrCreateDeviceAsync(account.Id, deviceId, deviceName, ClientPlatform.Ios);
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;
var device = await auth.GetOrCreateDeviceAsync(account.Id, deviceId, deviceName, platform);
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();
return challenge;
return session;
}
}

View File

@@ -77,7 +77,7 @@ public class TokenAuthService(
"AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})",
sessionId,
session.AccountId,
session.Challenge?.Scopes.Count,
session.Scopes.Count,
session.ExpiredAt
);
return (true, session, null);
@@ -87,8 +87,7 @@ public class TokenAuthService(
session = await db.AuthSessions
.AsNoTracking()
.Include(e => e.Challenge)
.ThenInclude(e => e.Client)
.Include(e => e.Client)
.Include(e => e.Account)
.ThenInclude(e => e.Profile)
.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})",
sessionId,
session.AccountId,
session.Challenge?.ClientId,
session.ClientId,
session.AppId,
session.Challenge?.Scopes.Count,
session.Challenge?.IpAddress,
(session.Challenge?.UserAgent ?? string.Empty).Length
session.Scopes.Count,
session.IpAddress,
(session.UserAgent ?? string.Empty).Length
);
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})",
sessionId,
session.AccountId,
session.Challenge?.ClientId
session.ClientId
);
return (true, session, null);
}

View File

@@ -6,16 +6,17 @@ using Quartz;
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)
{
using var scope = serviceProvider.CreateScope();
using var scope = sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var now = SystemClock.Instance.GetCurrentInstant();
await db.BulkInsertAsync(items.Select(x =>
{
x.CreatedAt = SystemClock.Instance.GetCurrentInstant();
x.CreatedAt = now;
x.UpdatedAt = x.CreatedAt;
return x;
}), config => config.ConflictOption = ConflictOption.Ignore);

View File

@@ -24,7 +24,7 @@ public class ExperienceService(AppDatabase db, SubscriptionService subscriptions
{
SubscriptionType.Stellar => 1.5,
SubscriptionType.Nova => 2,
SubscriptionType.Supernova => 2,
SubscriptionType.Supernova => 2.5,
_ => 1
};
if (record.Delta >= 0)

View File

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

View File

@@ -1,4 +1,4 @@
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Geometry;
using Microsoft.EntityFrameworkCore.Migrations;
using NetTopologySuite.Geometries;

View File

@@ -3,7 +3,7 @@ using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Pass;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Geometry;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -445,7 +445,7 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<List<ProfileLink>>("Links")
b.Property<List<SnProfileLink>>("Links")
.HasColumnType("jsonb")
.HasColumnName("links");

View File

@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;

View File

@@ -3,7 +3,7 @@ using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Pass;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Geometry;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -445,7 +445,7 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<List<ProfileLink>>("Links")
b.Property<List<SnProfileLink>>("Links")
.HasColumnType("jsonb")
.HasColumnName("links");

View File

@@ -3,7 +3,7 @@ using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Pass;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Geometry;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -445,7 +445,7 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<List<ProfileLink>>("Links")
b.Property<List<SnProfileLink>>("Links")
.HasColumnType("jsonb")
.HasColumnName("links");

View File

@@ -3,7 +3,7 @@ using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Pass;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Geometry;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -445,7 +445,7 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<List<ProfileLink>>("Links")
b.Property<List<SnProfileLink>>("Links")
.HasColumnType("jsonb")
.HasColumnName("links");

View File

@@ -3,7 +3,7 @@ using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Pass;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Geometry;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -445,7 +445,7 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<List<ProfileLink>>("Links")
b.Property<List<SnProfileLink>>("Links")
.HasColumnType("jsonb")
.HasColumnName("links");

View File

@@ -3,7 +3,7 @@ using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Pass;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Geometry;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -445,7 +445,7 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<List<ProfileLink>>("Links")
b.Property<List<SnProfileLink>>("Links")
.HasColumnType("jsonb")
.HasColumnName("links");

View File

@@ -3,7 +3,7 @@ using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Pass;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Geometry;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -445,7 +445,7 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<List<ProfileLink>>("Links")
b.Property<List<SnProfileLink>>("Links")
.HasColumnType("jsonb")
.HasColumnName("links");

View File

@@ -3,7 +3,7 @@ using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Pass;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Geometry;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -445,7 +445,7 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<List<ProfileLink>>("Links")
b.Property<List<SnProfileLink>>("Links")
.HasColumnType("jsonb")
.HasColumnName("links");

View File

@@ -3,7 +3,7 @@ using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Pass;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Geometry;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -445,7 +445,7 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<List<ProfileLink>>("Links")
b.Property<List<SnProfileLink>>("Links")
.HasColumnType("jsonb")
.HasColumnName("links");

View File

@@ -3,7 +3,7 @@ using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Pass;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Geometry;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -445,7 +445,7 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<List<ProfileLink>>("Links")
b.Property<List<SnProfileLink>>("Links")
.HasColumnType("jsonb")
.HasColumnName("links");

View File

@@ -3,7 +3,7 @@ using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Pass;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Geometry;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -445,7 +445,7 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<List<ProfileLink>>("Links")
b.Property<List<SnProfileLink>>("Links")
.HasColumnType("jsonb")
.HasColumnName("links");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class OpenableFunds : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "is_open",
table: "wallet_funds",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<decimal>(
name: "remaining_amount",
table: "wallet_funds",
type: "numeric",
nullable: false,
defaultValue: 0m);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "is_open",
table: "wallet_funds");
migrationBuilder.DropColumn(
name: "remaining_amount",
table: "wallet_funds");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class OpenFundsTotalSplits : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "amount_of_splits",
table: "wallet_funds",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "amount_of_splits",
table: "wallet_funds");
}
}
}

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -3,7 +3,7 @@ using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Pass;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Geometry;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -22,7 +22,7 @@ namespace DysonNetwork.Pass.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.10")
.HasAnnotation("ProductVersion", "9.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -442,7 +442,7 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<List<ProfileLink>>("Links")
b.Property<List<SnProfileLink>>("Links")
.HasColumnType("jsonb")
.HasColumnName("links");
@@ -712,6 +712,103 @@ namespace DysonNetwork.Pass.Migrations
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 =>
{
b.Property<Guid>("Id")
@@ -778,10 +875,6 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("jsonb")
.HasColumnName("blacklist_factors");
b.Property<Guid?>("ClientId")
.HasColumnType("uuid")
.HasColumnName("client_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
@@ -790,6 +883,17 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone")
.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")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
@@ -812,6 +916,10 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("character varying(1024)")
.HasColumnName("nonce");
b.Property<int>("Platform")
.HasColumnType("integer")
.HasColumnName("platform");
b.Property<List<string>>("Scopes")
.IsRequired()
.HasColumnType("jsonb")
@@ -825,10 +933,6 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("integer")
.HasColumnName("step_total");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
@@ -844,9 +948,6 @@ namespace DysonNetwork.Pass.Migrations
b.HasIndex("AccountId")
.HasDatabaseName("ix_auth_challenges_account_id");
b.HasIndex("ClientId")
.HasDatabaseName("ix_auth_challenges_client_id");
b.ToTable("auth_challenges", (string)null);
});
@@ -918,10 +1019,19 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("uuid")
.HasColumnName("app_id");
b.Property<List<string>>("Audiences")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("audiences");
b.Property<Guid?>("ChallengeId")
.HasColumnType("uuid")
.HasColumnName("challenge_id");
b.Property<Guid?>("ClientId")
.HasColumnType("uuid")
.HasColumnName("client_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
@@ -934,22 +1044,52 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("IpAddress")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("ip_address");
b.Property<Instant?>("LastGrantedAt")
.HasColumnType("timestamp with time zone")
.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")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("UserAgent")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("user_agent");
b.HasKey("Id")
.HasName("pk_auth_sessions");
b.HasIndex("AccountId")
.HasDatabaseName("ix_auth_sessions_account_id");
b.HasIndex("ChallengeId")
.HasDatabaseName("ix_auth_sessions_challenge_id");
b.HasIndex("ClientId")
.HasDatabaseName("ix_auth_sessions_client_id");
b.HasIndex("ParentSessionId")
.HasDatabaseName("ix_auth_sessions_parent_session_id");
b.ToTable("auth_sessions", (string)null);
});
@@ -1314,12 +1454,6 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("affected_at");
b.Property<string>("Area")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("area");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
@@ -1342,6 +1476,10 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("character varying(1024)")
.HasColumnName("key");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
@@ -1357,8 +1495,8 @@ namespace DysonNetwork.Pass.Migrations
b.HasIndex("GroupId")
.HasDatabaseName("ix_permission_nodes_group_id");
b.HasIndex("Key", "Area", "Actor")
.HasDatabaseName("ix_permission_nodes_key_area_actor");
b.HasIndex("Key", "Actor")
.HasDatabaseName("ix_permission_nodes_key_actor");
b.ToTable("permission_nodes", (string)null);
});
@@ -1712,6 +1850,10 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<int>("AmountOfSplits")
.HasColumnType("integer")
.HasColumnName("amount_of_splits");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
@@ -1734,11 +1876,19 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<bool>("IsOpen")
.HasColumnType("boolean")
.HasColumnName("is_open");
b.Property<string>("Message")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("message");
b.Property<decimal>("RemainingAmount")
.HasColumnType("numeric")
.HasColumnName("remaining_amount");
b.Property<int>("SplitType")
.HasColumnType("integer")
.HasColumnName("split_type");
@@ -2332,6 +2482,28 @@ namespace DysonNetwork.Pass.Migrations
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 =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
@@ -2362,14 +2534,7 @@ namespace DysonNetwork.Pass.Migrations
.IsRequired()
.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("Client");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAuthClient", b =>
@@ -2393,14 +2558,21 @@ namespace DysonNetwork.Pass.Migrations
.IsRequired()
.HasConstraintName("fk_auth_sessions_accounts_account_id");
b.HasOne("DysonNetwork.Shared.Models.SnAuthChallenge", "Challenge")
b.HasOne("DysonNetwork.Shared.Models.SnAuthClient", "Client")
.WithMany()
.HasForeignKey("ChallengeId")
.HasConstraintName("fk_auth_sessions_auth_challenges_challenge_id");
.HasForeignKey("ClientId")
.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("Challenge");
b.Navigation("Client");
b.Navigation("ParentSession");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCheckInResult", b =>

View File

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

View File

@@ -4,6 +4,7 @@ using Microsoft.Extensions.Options;
using NodaTime;
using System.Text.Json;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Pass.Permission;
@@ -28,8 +29,8 @@ public class PermissionService(
private const string PermissionGroupCacheKeyPrefix = "perm-cg:";
private const string PermissionGroupPrefix = "perm-g:";
private static string GetPermissionCacheKey(string actor, string area, string key) =>
PermissionCacheKeyPrefix + actor + ":" + area + ":" + key;
private static string GetPermissionCacheKey(string actor, string key) =>
PermissionCacheKeyPrefix + actor + ":" + key;
private static string GetGroupsCacheKey(string actor) =>
PermissionGroupCacheKeyPrefix + actor;
@@ -37,50 +38,56 @@ public class PermissionService(
private static string GetPermissionGroupKey(string 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;
}
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
if (string.IsNullOrWhiteSpace(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))
throw new ArgumentException("Key cannot be null or empty", nameof(key));
var cacheKey = GetPermissionCacheKey(actor, area, key);
var cacheKey = GetPermissionCacheKey(actor, key);
try
{
var (hit, cachedValue) = await cache.GetAsyncWithStatus<T>(cacheKey);
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;
}
var now = SystemClock.Instance.GetCurrentInstant();
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;
await cache.SetWithGroupsAsync(cacheKey, result,
[GetPermissionGroupKey(actor)],
_options.CacheExpiration);
logger.LogDebug("Permission resolved for {Actor}:{Area}:{Key} = {Result}",
actor, area, key, result != null);
logger.LogDebug("Permission resolved for {Type}:{Actor}:{Key} = {Result}", type, actor, key,
result != null);
return result;
}
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;
}
}
@@ -109,33 +116,34 @@ public class PermissionService(
return groupsId;
}
private async Task<SnPermissionNode?> FindPermissionNodeAsync(string actor, string area, string key,
List<Guid> groupsId, Instant now)
private async Task<SnPermissionNode?> FindPermissionNodeAsync(
PermissionNodeActorType type,
string actor,
string key,
List<Guid> groupsId
)
{
var now = SystemClock.Instance.GetCurrentInstant();
// First try exact match (highest priority)
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)))
.Where(n => n.Key == key && n.Area == area)
.Where(n => n.Key == key)
.Where(n => n.ExpiredAt == null || n.ExpiredAt > now)
.Where(n => n.AffectedAt == null || n.AffectedAt <= now)
.FirstOrDefaultAsync();
if (exactMatch != null)
{
return exactMatch;
}
// If no exact match and wildcards are enabled, try wildcard matches
if (!_options.EnableWildcardMatching)
{
return null;
}
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)))
.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.AffectedAt == null || n.AffectedAt <= now)
.Take(_options.MaxWildcardMatches)
@@ -147,36 +155,21 @@ public class PermissionService(
foreach (var node in wildcardMatches)
{
var score = CalculateWildcardMatchScore(node.Area, node.Key, area, key);
if (score > bestMatchScore)
{
bestMatch = node;
bestMatchScore = score;
}
var score = CalculateWildcardMatchScore(node.Key, key);
if (score <= bestMatchScore) continue;
bestMatch = node;
bestMatchScore = score;
}
if (bestMatch != null)
{
logger.LogDebug("Found wildcard permission match: {NodeArea}:{NodeKey} for {Area}:{Key}",
bestMatch.Area, bestMatch.Key, area, key);
}
logger.LogDebug("Found wildcard permission match: {NodeKey} for {Key}", bestMatch.Key, key);
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
// 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;
return CalculatePatternMatchScore(nodeKey, targetKey);
}
private static int CalculatePatternMatchScore(string pattern, string target)
@@ -184,31 +177,30 @@ public class PermissionService(
if (pattern == target)
return int.MaxValue; // Exact match
if (!pattern.Contains("*"))
if (!pattern.Contains('*'))
return -1; // No wildcard, not a match
// Simple wildcard matching: * matches any sequence of characters
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))
{
// 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);
}
if (!regex.IsMatch(target)) return -1; // No match
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>(
string actor,
string area,
string key,
T value,
Instant? expiredAt = null,
Instant? affectedAt = null
Instant? affectedAt = null,
PermissionNodeActorType type = PermissionNodeActorType.Account
)
{
if (value is null) throw new ArgumentNullException(nameof(value));
@@ -216,8 +208,8 @@ public class PermissionService(
var node = new SnPermissionNode
{
Actor = actor,
Type = type,
Key = key,
Area = area,
Value = SerializePermissionValue(value),
ExpiredAt = expiredAt,
AffectedAt = affectedAt
@@ -227,7 +219,7 @@ public class PermissionService(
await db.SaveChangesAsync();
// Invalidate related caches
await InvalidatePermissionCacheAsync(actor, area, key);
await InvalidatePermissionCacheAsync(actor, key);
return node;
}
@@ -235,11 +227,11 @@ public class PermissionService(
public async Task<SnPermissionNode> AddPermissionNodeToGroup<T>(
SnPermissionGroup group,
string actor,
string area,
string key,
T value,
Instant? expiredAt = null,
Instant? affectedAt = null
Instant? affectedAt = null,
PermissionNodeActorType type = PermissionNodeActorType.Account
)
{
if (value is null) throw new ArgumentNullException(nameof(value));
@@ -247,8 +239,8 @@ public class PermissionService(
var node = new SnPermissionNode
{
Actor = actor,
Type = type,
Key = key,
Area = area,
Value = SerializePermissionValue(value),
ExpiredAt = expiredAt,
AffectedAt = affectedAt,
@@ -260,44 +252,45 @@ public class PermissionService(
await db.SaveChangesAsync();
// Invalidate related caches
await InvalidatePermissionCacheAsync(actor, area, key);
await InvalidatePermissionCacheAsync(actor, key);
await cache.RemoveAsync(GetGroupsCacheKey(actor));
await cache.RemoveGroupAsync(GetPermissionGroupKey(actor));
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
.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();
if (node is not null) db.PermissionNodes.Remove(node);
await db.SaveChangesAsync();
// 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
.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();
if (node is null) return;
db.PermissionNodes.Remove(node);
await db.SaveChangesAsync();
// Invalidate caches
await InvalidatePermissionCacheAsync(actor, area, key);
await InvalidatePermissionCacheAsync(actor, key);
await cache.RemoveAsync(GetGroupsCacheKey(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);
}
@@ -312,12 +305,11 @@ public class PermissionService(
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
{
Actor = actor,
Area = area,
Key = key,
Value = SerializePermissionValue(value),
};
@@ -341,8 +333,7 @@ public class PermissionService(
(n.GroupId != null && groupsId.Contains(n.GroupId.Value)))
.Where(n => n.ExpiredAt == null || n.ExpiredAt > now)
.Where(n => n.AffectedAt == null || n.AffectedAt <= now)
.OrderBy(n => n.Area)
.ThenBy(n => n.Key)
.OrderBy(n => n.Key)
.ToListAsync();
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.ExpiredAt == null || n.ExpiredAt > now)
.Where(n => n.AffectedAt == null || n.AffectedAt <= now)
.OrderBy(n => n.Area)
.ThenBy(n => n.Key)
.OrderBy(n => n.Key)
.ToListAsync();
logger.LogDebug("Listed {Count} direct permissions for actor {Actor}", permissions.Count, actor);
@@ -424,4 +414,4 @@ public class PermissionService(
throw;
}
}
}
}

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