Compare commits

..

153 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
5418489f77 🐛 Fix function call bug, for real this time 2025-11-16 00:52:02 +08:00
310f2c1497 🐛 Fix function call in chat history issue 2025-11-16 00:34:31 +08:00
0ae8a2cfd4 🐛 Fix function calls in thought 2025-11-15 23:43:22 +08:00
c69256bda6 🐛 Fix some issues in new thought system 2025-11-15 17:11:39 +08:00
80ea44f2cc ♻️ Refactored the think message part 2025-11-15 16:21:26 +08:00
b5f9faa724 ♻️ Refactored the thought Solar Network related plugins 2025-11-15 13:05:58 +08:00
05985e0852 Unindxed files 2025-11-15 02:59:26 +08:00
6814b5690e ⬇️ Downgrade EFCore to 9 from 10 since it's not ready for use 2025-11-15 00:18:34 +08:00
78447de1b6 🔨 Update dockerfile to use dotnet 10 images as base instead of 9 2025-11-14 23:54:16 +08:00
e54dcccad9 🐛 Fix obslete API call according to https://github.com/aspnet/Announcements/issues/523 2025-11-14 23:53:20 +08:00
429a08930f ♻️ Refactored the server-side versioning by move that logic to Gateway only 2025-11-14 23:49:38 +08:00
b94b288755 ⬆️ Upgrade PgSQL to 10.0.0-rc.2 2025-11-14 23:45:05 +08:00
1c50c2f822 ⬆️ Upgrade dependecies to use dotnet10 version 2025-11-14 23:01:33 +08:00
73700e7cfd Revert "♻️ Proper folder system to index"
This reverts commit 1647aa2f1e.
2025-11-14 22:11:21 +08:00
bd2943345a ⬆️ Upgrade the dotnet framework to 10.0 2025-11-14 22:11:16 +08:00
314 changed files with 43811 additions and 7275 deletions

View File

@@ -27,8 +27,8 @@ jobs:
run: | run: |
files="${{ steps.changed-files.outputs.files }}" files="${{ steps.changed-files.outputs.files }}"
matrix="{\"include\":[]}" matrix="{\"include\":[]}"
services=("Sphere" "Pass" "Ring" "Drive" "Develop" "Gateway" "Insight") services=("Sphere" "Pass" "Ring" "Drive" "Develop" "Gateway" "Insight" "Zone")
images=("sphere" "pass" "ring" "drive" "develop" "gateway" "insight") images=("sphere" "pass" "ring" "drive" "develop" "gateway" "insight" "zone")
changed_services=() changed_services=()
for file in $files; do 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="9.5.2" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.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="9.5.2" />
<PackageReference Include="Aspire.Hosting.Docker" Version="9.4.2-preview.1.25428.12" />
<PackageReference Include="Aspire.Hosting.Nats" Version="9.5.2" />
<PackageReference Include="Aspire.Hosting.Redis" Version="9.5.2" />
</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 DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Design;
@@ -33,36 +34,15 @@ public class AppDatabase(
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{ {
var now = SystemClock.Instance.GetCurrentInstant(); this.ApplyAuditableAndSoftDelete();
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;
}
}
return await base.SaveChangesAsync(cancellationToken); return await base.SaveChangesAsync(cancellationToken);
} }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
modelBuilder.ApplySoftDeleteFilters();
} }
} }

View File

@@ -1,10 +1,10 @@
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
USER $APP_UID USER $APP_UID
WORKDIR /app WORKDIR /app
EXPOSE 8080 EXPOSE 8080
EXPOSE 8081 EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
WORKDIR /src WORKDIR /src
COPY ["DysonNetwork.Develop/DysonNetwork.Develop.csproj", "DysonNetwork.Develop/"] COPY ["DysonNetwork.Develop/DysonNetwork.Develop.csproj", "DysonNetwork.Develop/"]

View File

@@ -1,23 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10"> <PrivateAssets>all</PrivateAssets>
<PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" /> <PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
<PackageReference Include="NodaTime" Version="3.2.2"/> <PackageReference Include="NodaTime" Version="3.2.2"/>
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/>
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0"/> <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0"/>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,31 @@
{ {
"Debug": true, "Debug": true,
"BaseUrl": "http://localhost:5071", "BaseUrl": "http://localhost:5071",
"SiteUrl": "https://solian.app", "SiteUrl": "https://solian.app",
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_develop;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
"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.Linq.Expressions;
using System.Reflection;
using DysonNetwork.Drive.Billing; using DysonNetwork.Drive.Billing;
using DysonNetwork.Drive.Storage; using DysonNetwork.Drive.Storage;
using DysonNetwork.Drive.Storage.Model; using DysonNetwork.Drive.Storage.Model;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Query;
using NodaTime; using NodaTime;
using Quartz; using Quartz;
using TaskStatus = DysonNetwork.Drive.Storage.Model.TaskStatus; using TaskStatus = DysonNetwork.Drive.Storage.Model.TaskStatus;
@@ -24,9 +23,8 @@ public class AppDatabase(
public DbSet<QuotaRecord> QuotaRecords { get; set; } = null!; public DbSet<QuotaRecord> QuotaRecords { get; set; } = null!;
public DbSet<SnCloudFile> Files { 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<SnCloudFileIndex> FileIndexes { get; set; }
public DbSet<SnCloudFolder> Folders { get; set; } = null!;
public DbSet<PersistentTask> Tasks { get; set; } = null!; public DbSet<PersistentTask> Tasks { get; set; } = null!;
public DbSet<PersistentUploadTask> UploadTasks { get; set; } = null!; // Backward compatibility public DbSet<PersistentUploadTask> UploadTasks { get; set; } = null!; // Backward compatibility
@@ -47,61 +45,12 @@ public class AppDatabase(
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
modelBuilder.ApplySoftDeleteFilters();
// 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);
} }
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{ {
var now = SystemClock.Instance.GetCurrentInstant(); this.ApplyAuditableAndSoftDelete();
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;
}
}
return await base.SaveChangesAsync(cancellationToken); return await base.SaveChangesAsync(cancellationToken);
} }
} }
@@ -205,35 +154,3 @@ public class AppDatabaseFactory : IDesignTimeDbContextFactory<AppDatabase>
return new AppDatabase(optionsBuilder.Options, configuration); 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

@@ -1,4 +1,4 @@
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app WORKDIR /app
EXPOSE 8080 EXPOSE 8080
EXPOSE 8081 EXPOSE 8081
@@ -20,7 +20,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
USER $APP_UID USER $APP_UID
# Stage 2: Build .NET application # Stage 2: Build .NET application
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
WORKDIR /src WORKDIR /src
COPY ["DysonNetwork.Drive/DysonNetwork.Drive.csproj", "DysonNetwork.Drive/"] COPY ["DysonNetwork.Drive/DysonNetwork.Drive.csproj", "DysonNetwork.Drive/"]

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
@@ -12,22 +12,18 @@
<PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" /> <PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" />
<PackageReference Include="FFMpegCore" Version="5.4.0" /> <PackageReference Include="FFMpegCore" Version="5.4.0" />
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" /> <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="MimeKit" Version="4.14.0" /> <PackageReference Include="MimeKit" Version="4.14.0" />
<PackageReference Include="MimeTypes" Version="2.5.2"> <PackageReference Include="MimeTypes" Version="2.5.2">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Minio" Version="6.0.5" /> <PackageReference Include="Minio" Version="7.0.0" />
<PackageReference Include="Nanoid" Version="3.1.0" /> <PackageReference Include="Nanoid" Version="3.1.0" />
<PackageReference Include="Nerdbank.GitVersioning" Version="3.8.118">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NetVips" Version="3.1.0" /> <PackageReference Include="NetVips" Version="3.1.0" />
<PackageReference Include="NetVips.Native.linux-x64" Version="8.17.3" /> <PackageReference Include="NetVips.Native.linux-x64" Version="8.17.3" />
<PackageReference Include="NetVips.Native.osx-arm64" Version="8.17.3" /> <PackageReference Include="NetVips.Native.osx-arm64" Version="8.17.3" />
@@ -35,26 +31,14 @@
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" /> <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
<PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" /> <PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" /> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.13.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.13.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.13.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.13.0" />
<PackageReference Include="Quartz" Version="3.15.1" /> <PackageReference Include="Quartz" Version="3.15.1" />
<PackageReference Include="Quartz.AspNetCore" Version="3.15.1" /> <PackageReference Include="Quartz.AspNetCore" Version="3.15.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" /> <PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.2" />
<PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.2" /> <PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.2" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<!-- Pin the SkiaSharp version at the 2.88.9 due to the BlurHash need this specific version --> <!-- Pin the SkiaSharp version at the 2.88.9 due to the BlurHash need this specific version -->
<PackageReference Include="SkiaSharp" Version="2.88.9" /> <PackageReference Include="SkiaSharp" Version="2.88.9" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.9" /> <PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.9" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.9" /> <PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.9" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.6" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -1,4 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Drive.Storage;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http; using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
@@ -13,7 +15,6 @@ namespace DysonNetwork.Drive.Index;
[Authorize] [Authorize]
public class FileIndexController( public class FileIndexController(
FileIndexService fileIndexService, FileIndexService fileIndexService,
FolderService folderService,
AppDatabase db, AppDatabase db,
ILogger<FileIndexController> logger ILogger<FileIndexController> logger
) : ControllerBase ) : ControllerBase
@@ -22,9 +23,17 @@ public class FileIndexController(
/// Gets files in a specific path for the current user /// Gets files in a specific path for the current user
/// </summary> /// </summary>
/// <param name="path">The path to browse (defaults to root "/")</param> /// <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> /// <returns>List of files in the specified path</returns>
[HttpGet("browse")] [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) if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
@@ -35,8 +44,29 @@ public class FileIndexController(
{ {
var fileIndexes = await fileIndexService.GetByPathAsync(accountId, path); var fileIndexes = await fileIndexService.GetByPathAsync(accountId, path);
// Get child folders using the folder system if (!string.IsNullOrWhiteSpace(query))
var childFolders = await GetChildFoldersAsync(accountId, path); {
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 return Ok(new
{ {
@@ -59,116 +89,53 @@ public class FileIndexController(
} }
/// <summary> /// <summary>
/// Gets child folders for a given parent path using the folder system /// Extracts unique child folder paths from all file indexes for a given parent path
/// </summary> /// </summary>
/// <param name="accountId">The account ID</param> /// <param name="allFileIndexes">All file indexes for the account</param>
/// <param name="parentPath">The parent path to find children for</param> /// <param name="parentPath">The parent path to find children for</param>
/// <returns>List of child folder objects</returns> /// <returns>List of unique child folder names</returns>
private async Task<List<SnCloudFolder>> GetChildFoldersAsync(Guid accountId, string parentPath) private List<string> ExtractChildFolders(List<SnCloudFileIndex> allFileIndexes, string parentPath)
{ {
var normalizedParentPath = FileIndexService.NormalizePath(parentPath); var normalizedParentPath = FileIndexService.NormalizePath(parentPath);
var childFolders = new HashSet<string>();
// Try to find a folder that corresponds to this path foreach (var index in allFileIndexes)
var parentFolder = await FindFolderByPathAsync(accountId, normalizedParentPath);
if (parentFolder != null)
{ {
// Use folder-based approach var normalizedIndexPath = FileIndexService.NormalizePath(index.Path);
return await folderService.GetChildFoldersAsync(parentFolder.Id, accountId);
}
else
{
// Fall back to path-based approach - find folders that start with this path
var allFolders = await folderService.GetByAccountIdAsync(accountId);
var childFolders = new List<SnCloudFolder>();
foreach (var folder in allFolders) // Check if this path is a direct child of the parent path
if (normalizedIndexPath.StartsWith(normalizedParentPath) &&
normalizedIndexPath != normalizedParentPath)
{ {
// For path-based folders, we need to check if they belong under this path // Remove the parent path prefix to get the relative path
// This is a simplified approach - in a full implementation, folders would have path information var relativePath = normalizedIndexPath.Substring(normalizedParentPath.Length);
if (folder.ParentFolderId == null && normalizedParentPath == "/")
// Extract the first folder name (direct child)
var firstSlashIndex = relativePath.IndexOf('/');
if (firstSlashIndex > 0)
{ {
// Root level folders var folderName = relativePath.Substring(0, firstSlashIndex);
childFolders.Add(folder); childFolders.Add(folderName);
} }
// For nested folders, we'd need path information in the folder model
} }
return childFolders.OrderBy(f => f.Name).ToList();
}
}
/// <summary>
/// Attempts to find a folder by its path
/// </summary>
/// <param name="accountId">The account ID</param>
/// <param name="path">The path to search for</param>
/// <returns>The folder if found, null otherwise</returns>
private async Task<SnCloudFolder?> FindFolderByPathAsync(Guid accountId, string path)
{
// This is a simplified implementation
// In a full implementation, folders would have path information stored
var allFolders = await folderService.GetByAccountIdAsync(accountId);
// For now, just return null to use path-based approach
// TODO: Implement proper path-to-folder mapping
return null;
}
/// <summary>
/// Gets or creates a folder hierarchy based on a file path
/// </summary>
/// <param name="filePath">The file path (e.g., "/folder/sub/file.txt")</param>
/// <param name="accountId">The account ID</param>
/// <returns>The folder where the file should be placed</returns>
private async Task<SnCloudFolder> GetOrCreateFolderByPathAsync(string filePath, Guid accountId)
{
// Extract folder path from file path (remove filename)
var lastSlashIndex = filePath.LastIndexOf('/');
var folderPath = lastSlashIndex == 0 ? "/" : filePath[..(lastSlashIndex + 1)];
// Ensure root folder exists
var rootFolder = await folderService.EnsureRootFolderAsync(accountId);
// If it's the root folder, return it
if (folderPath == "/")
return rootFolder;
// Split the folder path into segments
var pathSegments = folderPath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries);
var currentParent = rootFolder;
var currentPath = "/";
// Create folder hierarchy
foreach (var segment in pathSegments)
{
currentPath += segment + "/";
// Check if folder already exists
var existingFolder = await db.Folders
.FirstOrDefaultAsync(f => f.AccountId == accountId && f.Path == currentPath);
if (existingFolder != null)
{
currentParent = existingFolder;
continue;
}
// Create new folder
var newFolder = await folderService.CreateAsync(segment, accountId, currentParent.Id);
currentParent = newFolder;
} }
return currentParent; return childFolders.OrderBy(f => f).ToList();
} }
/// <summary> /// <summary>
/// Gets all files for the current user (across all paths) /// Gets all files for the current user (across all paths)
/// </summary> /// </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> /// <returns>List of all files for the user</returns>
[HttpGet("all")] [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) if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
@@ -179,10 +146,28 @@ public class FileIndexController(
{ {
var fileIndexes = await fileIndexService.GetByAccountIdAsync(accountId); 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 return Ok(new
{ {
Files = fileIndexes, Files = fileIndexes,
TotalCount = fileIndexes.Count TotalCount = fileIndexes.Count()
}); });
} }
catch (Exception ex) catch (Exception ex)
@@ -197,6 +182,83 @@ 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] 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 };
var accountId = Guid.Parse(currentUser.Id);
try
{
var filesQuery = db.Files
.Where(f => f.AccountId == accountId
&& 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();
Response.Headers.Append("X-Total", totalCount.ToString());
var unindexedFiles = await filesQuery
.Skip(offset)
.Take(take)
.ToListAsync();
return Ok(unindexedFiles);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get unindexed files for account {AccountId}", accountId);
return new ObjectResult(new ApiError
{
Code = "GET_UNINDEXED_FAILED",
Message = "Failed to get unindexed files",
Status = 500
}) { StatusCode = 500 };
}
}
/// <summary> /// <summary>
/// Moves a file to a new path /// Moves a file to a new path
/// </summary> /// </summary>
@@ -317,7 +379,9 @@ public class FileIndexController(
return Ok(new 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, FileId = fileId,
FileName = fileName, FileName = fileName,
Path = filePath, Path = filePath,
@@ -378,13 +442,14 @@ public class FileIndexController(
db.Files.Remove(file); db.Files.Remove(file);
logger.LogInformation("Deleted orphaned file {FileId} after clearing path {Path}", fileId, path); logger.LogInformation("Deleted orphaned file {FileId} after clearing path {Path}", fileId, path);
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new return Ok(new
{ {
Message = deleteFiles ? Message = deleteFiles
$"Cleared {removedCount} file indexes from path and deleted orphaned files" : ? $"Cleared {removedCount} file indexes from path and deleted orphaned files"
$"Cleared {removedCount} file indexes from path", : $"Cleared {removedCount} file indexes from path",
Path = path, Path = path,
RemovedCount = removedCount, RemovedCount = removedCount,
FilesDeleted = deleteFiles FilesDeleted = deleteFiles
@@ -425,11 +490,10 @@ public class FileIndexController(
if (file.AccountId != accountId) if (file.AccountId != accountId)
return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 }; return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
// Check if index already exists for this file and path - since Path is now optional, we need to check by folder // Check if index already exists for this file and path
// For now, we'll check if any index exists for this file in the same folder that would result from the path
var targetFolder = await GetOrCreateFolderByPathAsync(FileIndexService.NormalizePath(request.Path), accountId);
var existingIndex = await db.FileIndexes var existingIndex = await db.FileIndexes
.FirstOrDefaultAsync(fi => fi.FileId == request.FileId && fi.FolderId == targetFolder.Id && fi.AccountId == accountId); .FirstOrDefaultAsync(fi =>
fi.FileId == request.FileId && fi.Path == request.Path && fi.AccountId == accountId);
if (existingIndex != null) if (existingIndex != null)
return new ObjectResult(ApiError.Validation(new Dictionary<string, string[]> return new ObjectResult(ApiError.Validation(new Dictionary<string, string[]>
@@ -443,7 +507,7 @@ public class FileIndexController(
{ {
IndexId = fileIndex.Id, IndexId = fileIndex.Id,
fileIndex.FileId, fileIndex.FileId,
Path = fileIndex.Path, fileIndex.Path,
Message = "File index created successfully" Message = "File index created successfully"
}); });
} }
@@ -460,56 +524,6 @@ public class FileIndexController(
} }
} }
/// <summary>
/// Gets unindexed files for the current user (files that exist but don't have file indexes)
/// </summary>
/// <param name="offset">Pagination offset</param>
/// <param name="take">Number of files to take</param>
/// <returns>List of unindexed files</returns>
[HttpGet("unindexed")]
public async Task<IActionResult> GetUnindexedFiles([FromQuery] int offset = 0, [FromQuery] int take = 20)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
var accountId = Guid.Parse(currentUser.Id);
try
{
// Get files that belong to the user but don't have any file indexes
var unindexedFiles = await db.Files
.Where(f => f.AccountId == accountId)
.Where(f => !db.FileIndexes.Any(fi => fi.FileId == f.Id && fi.AccountId == accountId))
.OrderByDescending(f => f.CreatedAt)
.Skip(offset)
.Take(take)
.ToListAsync();
var totalCount = await db.Files
.Where(f => f.AccountId == accountId)
.Where(f => !db.FileIndexes.Any(fi => fi.FileId == f.Id && fi.AccountId == accountId))
.CountAsync();
return Ok(new
{
Files = unindexedFiles,
TotalCount = totalCount,
Offset = offset,
Take = take
});
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get unindexed files for account {AccountId}", accountId);
return new ObjectResult(new ApiError
{
Code = "GET_UNINDEXED_FAILED",
Message = "Failed to get unindexed files",
Status = 500
}) { StatusCode = 500 };
}
}
/// <summary> /// <summary>
/// Searches for files by name or metadata /// Searches for files by name or metadata
/// </summary> /// </summary>
@@ -528,29 +542,14 @@ public class FileIndexController(
{ {
// Build the query with all conditions at once // Build the query with all conditions at once
var searchTerm = query.ToLower(); var searchTerm = query.ToLower();
var baseQuery = db.FileIndexes var fileIndexes = await db.FileIndexes
.Where(fi => fi.AccountId == accountId) .Where(fi => fi.AccountId == accountId)
.Include(fi => fi.File); .Include(fi => fi.File)
IQueryable<SnCloudFileIndex> queryable;
// If a path is specified, find the folder and filter by folder ID
if (!string.IsNullOrEmpty(path))
{
var normalizedPath = FileIndexService.NormalizePath(path);
var targetFolder = await GetOrCreateFolderByPathAsync(normalizedPath, accountId);
queryable = baseQuery.Where(fi => fi.FolderId == targetFolder.Id);
}
else
{
queryable = baseQuery;
}
var fileIndexes = await queryable
.Where(fi => .Where(fi =>
fi.File.Name.ToLower().Contains(searchTerm) || (string.IsNullOrEmpty(path) || fi.Path == FileIndexService.NormalizePath(path)) &&
(fi.File.Description != null && fi.File.Description.ToLower().Contains(searchTerm)) || (fi.File.Name.ToLower().Contains(searchTerm) ||
(fi.File.MimeType != null && fi.File.MimeType.ToLower().Contains(searchTerm))) (fi.File.Description != null && fi.File.Description.ToLower().Contains(searchTerm)) ||
(fi.File.MimeType != null && fi.File.MimeType.ToLower().Contains(searchTerm))))
.ToListAsync(); .ToListAsync();
return Ok(new return Ok(new
@@ -572,249 +571,6 @@ public class FileIndexController(
}) { StatusCode = 500 }; }) { StatusCode = 500 };
} }
} }
/// <summary>
/// Creates a new folder
/// </summary>
/// <param name="request">The folder creation request</param>
/// <returns>The created folder</returns>
[HttpPost("folders")]
public async Task<IActionResult> CreateFolder([FromBody] CreateFolderRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
var accountId = Guid.Parse(currentUser.Id);
try
{
var folder = await folderService.CreateAsync(request.Name, accountId, request.ParentFolderId);
return Ok(folder);
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
catch (InvalidOperationException ex)
{
return Conflict(ex.Message);
}
}
/// <summary>
/// Gets a folder by ID with its contents
/// </summary>
/// <param name="folderId">The folder ID</param>
/// <returns>The folder with child folders and files</returns>
[HttpGet("folders/{folderId:guid}")]
public async Task<IActionResult> GetFolderById(Guid folderId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
var accountId = Guid.Parse(currentUser.Id);
var folder = await folderService.GetByIdAsync(folderId, accountId);
if (folder == null)
{
return NotFound($"Folder with ID '{folderId}' not found");
}
return Ok(folder);
}
/// <summary>
/// Gets all folders for the current account
/// </summary>
/// <returns>List of folders</returns>
[HttpGet("folders")]
public async Task<IActionResult> GetAllFolders()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
var accountId = Guid.Parse(currentUser.Id);
var folders = await folderService.GetByAccountIdAsync(accountId);
return Ok(folders);
}
/// <summary>
/// Gets child folders of a parent folder
/// </summary>
/// <param name="parentFolderId">The parent folder ID</param>
/// <returns>List of child folders</returns>
[HttpGet("folders/children/{parentFolderId:guid}")]
public async Task<IActionResult> GetChildFolders(Guid parentFolderId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
var accountId = Guid.Parse(currentUser.Id);
var folders = await folderService.GetChildFoldersAsync(parentFolderId, accountId);
return Ok(folders);
}
/// <summary>
/// Updates a folder's name
/// </summary>
/// <param name="folderId">The folder ID</param>
/// <param name="request">The update request</param>
/// <returns>The updated folder</returns>
[HttpPut("folders/{folderId:guid}")]
public async Task<IActionResult> UpdateFolder(Guid folderId, [FromBody] UpdateFolderRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
var accountId = Guid.Parse(currentUser.Id);
try
{
var folder = await folderService.UpdateAsync(folderId, request.Name, accountId);
if (folder == null)
{
return NotFound($"Folder with ID '{folderId}' not found");
}
return Ok(folder);
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
catch (InvalidOperationException ex)
{
return Conflict(ex.Message);
}
}
/// <summary>
/// Deletes a folder and all its contents
/// </summary>
/// <param name="folderId">The folder ID</param>
/// <returns>Success status</returns>
[HttpDelete("folders/{folderId:guid}")]
public async Task<IActionResult> DeleteFolder(Guid folderId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
var accountId = Guid.Parse(currentUser.Id);
var deleted = await folderService.DeleteAsync(folderId, accountId);
if (!deleted)
{
return NotFound($"Folder with ID '{folderId}' not found");
}
return NoContent();
}
/// <summary>
/// Moves a folder to a new parent folder
/// </summary>
/// <param name="folderId">The folder ID</param>
/// <param name="request">The move request</param>
/// <returns>The moved folder</returns>
[HttpPost("folders/{folderId:guid}/move")]
public async Task<IActionResult> MoveFolder(Guid folderId, [FromBody] MoveFolderRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
var accountId = Guid.Parse(currentUser.Id);
try
{
var folder = await folderService.MoveAsync(folderId, request.NewParentFolderId, accountId);
if (folder == null)
{
return NotFound($"Folder with ID '{folderId}' not found");
}
return Ok(folder);
}
catch (InvalidOperationException ex)
{
return Conflict(ex.Message);
}
}
/// <summary>
/// Searches for folders by name
/// </summary>
/// <param name="searchTerm">The search term</param>
/// <returns>List of matching folders</returns>
[HttpGet("folders/search")]
public async Task<IActionResult> SearchFolders([FromQuery] string searchTerm)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
var accountId = Guid.Parse(currentUser.Id);
if (string.IsNullOrWhiteSpace(searchTerm))
{
return BadRequest("Search term cannot be empty");
}
var folders = await folderService.SearchAsync(accountId, searchTerm);
return Ok(folders);
}
/// <summary>
/// Gets files in a specific folder
/// </summary>
/// <param name="folderId">The folder ID</param>
/// <returns>List of files in the folder</returns>
[HttpGet("folders/{folderId:guid}/files")]
public async Task<IActionResult> GetFilesInFolder(Guid folderId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
var accountId = Guid.Parse(currentUser.Id);
var files = await fileIndexService.GetByFolderAsync(accountId, folderId);
return Ok(files);
}
/// <summary>
/// Moves a file to a different folder
/// </summary>
/// <param name="fileIndexId">The file index ID</param>
/// <param name="request">The move request</param>
/// <returns>The updated file index</returns>
[HttpPost("files/{fileIndexId:guid}/move-to-folder")]
public async Task<IActionResult> MoveFileToFolder(Guid fileIndexId, [FromBody] MoveFileToFolderRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
var accountId = Guid.Parse(currentUser.Id);
try
{
var fileIndex = await fileIndexService.MoveAsync(fileIndexId, request.NewFolderId, accountId);
if (fileIndex == null)
{
return NotFound($"File index with ID '{fileIndexId}' not found");
}
return Ok(fileIndex);
}
catch (InvalidOperationException ex)
{
return Conflict(ex.Message);
}
}
} }
public class MoveFileRequest public class MoveFileRequest
@@ -827,52 +583,3 @@ public class CreateFileIndexRequest
[MaxLength(32)] public string FileId { get; set; } = null!; [MaxLength(32)] public string FileId { get; set; } = null!;
public string Path { get; set; } = null!; public string Path { get; set; } = null!;
} }
/// <summary>
/// Request model for creating a folder
/// </summary>
public class CreateFolderRequest
{
/// <summary>
/// The name of the folder
/// </summary>
public string Name { get; set; } = null!;
/// <summary>
/// Optional parent folder ID (null for root folder)
/// </summary>
public Guid? ParentFolderId { get; set; }
}
/// <summary>
/// Request model for updating a folder
/// </summary>
public class UpdateFolderRequest
{
/// <summary>
/// The new name for the folder
/// </summary>
public string Name { get; set; } = null!;
}
/// <summary>
/// Request model for moving a folder
/// </summary>
public class MoveFolderRequest
{
/// <summary>
/// The new parent folder ID (null for root)
/// </summary>
public Guid? NewParentFolderId { get; set; }
}
/// <summary>
/// Request model for moving a file to a folder
/// </summary>
public class MoveFileToFolderRequest
{
/// <summary>
/// The new folder ID
/// </summary>
public Guid NewFolderId { get; set; }
}

View File

@@ -3,111 +3,37 @@ using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Drive.Index; namespace DysonNetwork.Drive.Index;
public class FileIndexService(AppDatabase db, FolderService folderService) public class FileIndexService(AppDatabase db)
{ {
/// <summary> /// <summary>
/// Normalizes a path to ensure consistent formatting /// Creates a new file index entry
/// </summary> /// </summary>
/// <param name="path">The path to normalize</param> /// <param name="path">The parent folder path with a trailing slash</param>
/// <returns>The normalized path</returns>
public static string NormalizePath(string path)
{
if (string.IsNullOrEmpty(path))
return "/";
// Ensure path starts with /
if (!path.StartsWith('/'))
path = "/" + path;
// Remove trailing slash unless it's the root
if (path.Length > 1 && path.EndsWith('/'))
path = path.TrimEnd('/');
// Normalize double slashes
while (path.Contains("//"))
path = path.Replace("//", "/");
return path;
}
/// <summary>
/// Gets or creates a folder hierarchy based on a file path
/// </summary>
/// <param name="filePath">The file path (e.g., "/folder/sub/file.txt")</param>
/// <param name="accountId">The account ID</param>
/// <returns>The folder where the file should be placed</returns>
private async Task<SnCloudFolder> GetOrCreateFolderByPathAsync(string filePath, Guid accountId)
{
// Extract folder path from file path (remove filename)
var lastSlashIndex = filePath.LastIndexOf('/');
var folderPath = lastSlashIndex == 0 ? "/" : filePath[..(lastSlashIndex + 1)];
// Ensure root folder exists
var rootFolder = await folderService.EnsureRootFolderAsync(accountId);
// If it's the root folder, return it
if (folderPath == "/")
return rootFolder;
// Split the folder path into segments
var pathSegments = folderPath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries);
var currentParent = rootFolder;
var currentPath = "/";
// Create folder hierarchy
foreach (var segment in pathSegments)
{
currentPath += segment + "/";
// Check if folder already exists
var existingFolder = await db.Folders
.FirstOrDefaultAsync(f => f.AccountId == accountId && f.Path == currentPath);
if (existingFolder != null)
{
currentParent = existingFolder;
continue;
}
// Create new folder
var newFolder = await folderService.CreateAsync(segment, accountId, currentParent.Id);
currentParent = newFolder;
}
return currentParent;
}
/// <summary>
/// Creates a new file index entry at a specific path (creates folder hierarchy if needed)
/// </summary>
/// <param name="path">The path where the file should be indexed</param>
/// <param name="fileId">The file ID</param> /// <param name="fileId">The file ID</param>
/// <param name="accountId">The account ID</param> /// <param name="accountId">The account ID</param>
/// <returns>The created file index</returns> /// <returns>The created file index</returns>
public async Task<SnCloudFileIndex> CreateAsync(string path, string fileId, Guid accountId) public async Task<SnCloudFileIndex> CreateAsync(string path, string fileId, Guid accountId)
{ {
// Ensure a path has a trailing slash and is query-safe
var normalizedPath = NormalizePath(path); var normalizedPath = NormalizePath(path);
// Get the file to extract the file name // Check if a file with the same name already exists in the same path for this account
var file = await db.Files
.FirstOrDefaultAsync(f => f.Id == fileId) ?? throw new InvalidOperationException($"File with ID '{fileId}' not found");
// Get or create the folder hierarchy based on the path
var folder = await GetOrCreateFolderByPathAsync(normalizedPath, accountId);
// Check if a file with the same name already exists in the same folder for this account
var existingFileIndex = await db.FileIndexes var existingFileIndex = await db.FileIndexes
.FirstOrDefaultAsync(fi => fi.AccountId == accountId && .FirstOrDefaultAsync(fi => fi.AccountId == accountId && fi.Path == normalizedPath && fi.FileId == fileId);
fi.FolderId == folder.Id &&
fi.File.Name == file.Name);
if (existingFileIndex != null) if (existingFileIndex != null)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
$"A file with name '{file.Name}' already exists in folder '{folder.Name}' for account '{accountId}'"); $"A file with ID '{fileId}' already exists in path '{normalizedPath}' for account '{accountId}'");
} }
var fileIndex = SnCloudFileIndex.Create(folder, file, accountId); var fileIndex = new SnCloudFileIndex
{
Path = normalizedPath,
FileId = fileId,
AccountId = accountId
};
db.FileIndexes.Add(fileIndex); db.FileIndexes.Add(fileIndex);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -115,94 +41,26 @@ public class FileIndexService(AppDatabase db, FolderService folderService)
} }
/// <summary> /// <summary>
/// Creates a new file index entry in a specific folder /// Updates an existing file index entry by removing the old one and creating a new one
/// </summary> /// </summary>
/// <param name="folderId">The folder ID where the file should be placed</param> /// <param name="id">The file index ID</param>
/// <param name="fileId">The file ID</param> /// <param name="newPath">The new parent folder path with trailing slash</param>
/// <param name="accountId">The account ID</param>
/// <returns>The created file index</returns>
public async Task<SnCloudFileIndex> CreateInFolderAsync(Guid folderId, string fileId, Guid accountId)
{
// Verify the folder exists and belongs to the account
var folder = await db.Folders
.FirstOrDefaultAsync(f => f.Id == folderId && f.AccountId == accountId);
if (folder == null)
{
throw new InvalidOperationException($"Folder with ID '{folderId}' not found or access denied");
}
// Get the file to extract the file name
var file = await db.Files
.FirstOrDefaultAsync(f => f.Id == fileId);
if (file == null)
{
throw new InvalidOperationException($"File with ID '{fileId}' not found");
}
// Check if a file with the same name already exists in the same folder for this account
var existingFileIndex = await db.FileIndexes
.FirstOrDefaultAsync(fi => fi.AccountId == accountId &&
fi.FolderId == folderId &&
fi.File.Name == file.Name);
if (existingFileIndex != null)
{
throw new InvalidOperationException(
$"A file with name '{file.Name}' already exists in folder '{folder.Name}' for account '{accountId}'");
}
var fileIndex = SnCloudFileIndex.Create(folder, file, accountId);
db.FileIndexes.Add(fileIndex);
await db.SaveChangesAsync();
return fileIndex;
}
/// <summary>
/// Moves a file to a different folder
/// </summary>
/// <param name="fileIndexId">The file index ID</param>
/// <param name="newFolderId">The new folder ID</param>
/// <param name="accountId">The account ID</param>
/// <returns>The updated file index</returns> /// <returns>The updated file index</returns>
public async Task<SnCloudFileIndex?> MoveAsync(Guid fileIndexId, Guid newFolderId, Guid accountId) public async Task<SnCloudFileIndex?> UpdateAsync(Guid id, string newPath)
{ {
var fileIndex = await db.FileIndexes var fileIndex = await db.FileIndexes.FindAsync(id);
.Include(fi => fi.File)
.FirstOrDefaultAsync(fi => fi.Id == fileIndexId && fi.AccountId == accountId);
if (fileIndex == null) if (fileIndex == null)
return null; return null;
// Verify the new folder exists and belongs to the account
var newFolder = await db.Folders
.FirstOrDefaultAsync(f => f.Id == newFolderId && f.AccountId == accountId);
if (newFolder == null)
{
throw new InvalidOperationException($"Target folder with ID '{newFolderId}' not found or access denied");
}
// Check if a file with the same name already exists in the target folder
var existingFileIndex = await db.FileIndexes
.FirstOrDefaultAsync(fi => fi.AccountId == accountId &&
fi.FolderId == newFolderId &&
fi.File.Name == fileIndex.File.Name &&
fi.Id != fileIndexId);
if (existingFileIndex != null)
{
throw new InvalidOperationException(
$"A file with name '{fileIndex.File.Name}' already exists in folder '{newFolder.Name}'");
}
// Since properties are init-only, we need to remove the old index and create a new one // Since properties are init-only, we need to remove the old index and create a new one
db.FileIndexes.Remove(fileIndex); db.FileIndexes.Remove(fileIndex);
var newFileIndex = SnCloudFileIndex.Create(newFolder, fileIndex.File, accountId); var newFileIndex = new SnCloudFileIndex
newFileIndex.Id = fileIndexId; // Keep the same ID {
Path = NormalizePath(newPath),
FileId = fileIndex.FileId,
AccountId = fileIndex.AccountId
};
db.FileIndexes.Add(newFileIndex); db.FileIndexes.Add(newFileIndex);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -248,15 +106,17 @@ public class FileIndexService(AppDatabase db, FolderService folderService)
} }
/// <summary> /// <summary>
/// Removes file index entries by account ID and folder /// Removes file index entries by account ID and path
/// </summary> /// </summary>
/// <param name="accountId">The account ID</param> /// <param name="accountId">The account ID</param>
/// <param name="folderId">The folder ID</param> /// <param name="path">The parent folder path</param>
/// <returns>The number of indexes removed</returns> /// <returns>The number of indexes removed</returns>
public async Task<int> RemoveByFolderAsync(Guid accountId, Guid folderId) public async Task<int> RemoveByPathAsync(Guid accountId, string path)
{ {
var normalizedPath = NormalizePath(path);
var indexes = await db.FileIndexes var indexes = await db.FileIndexes
.Where(fi => fi.AccountId == accountId && fi.FolderId == folderId) .Where(fi => fi.AccountId == accountId && fi.Path == normalizedPath)
.ToListAsync(); .ToListAsync();
if (!indexes.Any()) if (!indexes.Any())
@@ -269,21 +129,23 @@ public class FileIndexService(AppDatabase db, FolderService folderService)
} }
/// <summary> /// <summary>
/// Gets file indexes by account ID and folder /// Gets file indexes by account ID and path
/// </summary> /// </summary>
/// <param name="accountId">The account ID</param> /// <param name="accountId">The account ID</param>
/// <param name="folderId">The folder ID</param> /// <param name="path">The parent folder path</param>
/// <returns>List of file indexes</returns> /// <returns>List of file indexes</returns>
public async Task<List<SnCloudFileIndex>> GetByFolderAsync(Guid accountId, Guid folderId) public async Task<List<SnCloudFileIndex>> GetByPathAsync(Guid accountId, string path)
{ {
var normalizedPath = NormalizePath(path);
return await db.FileIndexes return await db.FileIndexes
.Where(fi => fi.AccountId == accountId && fi.FolderId == folderId) .Where(fi => fi.AccountId == accountId && fi.Path == normalizedPath)
.Include(fi => fi.File) .Include(fi => fi.File)
.ToListAsync(); .ToListAsync();
} }
/// <summary> /// <summary>
/// Gets file indexes by file ID with folder information /// Gets file indexes by file ID
/// </summary> /// </summary>
/// <param name="fileId">The file ID</param> /// <param name="fileId">The file ID</param>
/// <returns>List of file indexes</returns> /// <returns>List of file indexes</returns>
@@ -292,7 +154,6 @@ public class FileIndexService(AppDatabase db, FolderService folderService)
return await db.FileIndexes return await db.FileIndexes
.Where(fi => fi.FileId == fileId) .Where(fi => fi.FileId == fileId)
.Include(fi => fi.File) .Include(fi => fi.File)
.Include(fi => fi.Folder)
.ToListAsync(); .ToListAsync();
} }
@@ -306,107 +167,31 @@ public class FileIndexService(AppDatabase db, FolderService folderService)
return await db.FileIndexes return await db.FileIndexes
.Where(fi => fi.AccountId == accountId) .Where(fi => fi.AccountId == accountId)
.Include(fi => fi.File) .Include(fi => fi.File)
.Include(fi => fi.Folder)
.ToListAsync(); .ToListAsync();
} }
/// <summary> /// <summary>
/// Gets file indexes by path for an account (finds folder by path and gets files in that folder) /// Normalizes the path to ensure it has a trailing slash and is query-safe
/// </summary> /// </summary>
/// <param name="accountId">The account ID</param> /// <param name="path">The original path</param>
/// <param name="path">The path to search for</param> /// <returns>The normalized path</returns>
/// <returns>List of file indexes at the specified path</returns> public static string NormalizePath(string path)
public async Task<List<SnCloudFileIndex>> GetByPathAsync(Guid accountId, string path)
{ {
var normalizedPath = NormalizePath(path); if (string.IsNullOrEmpty(path))
return "/";
// Find the folder that corresponds to this path // Ensure the path starts with a slash
var folder = await db.Folders if (!path.StartsWith('/'))
.FirstOrDefaultAsync(f => f.AccountId == accountId && f.Path == normalizedPath + (normalizedPath == "/" ? "" : "/")); path = "/" + path;
if (folder == null) // Ensure the path ends with a slash (unless it's just the root)
return new List<SnCloudFileIndex>(); if (path != "/" && !path.EndsWith('/'))
path += "/";
return await db.FileIndexes // Make path query-safe by removing problematic characters
.Where(fi => fi.AccountId == accountId && fi.FolderId == folder.Id) // This is a basic implementation - you might want to add more robust validation
.Include(fi => fi.File) path = path.Replace("%", "").Replace("'", "").Replace("\"", "");
.Include(fi => fi.Folder)
.ToListAsync();
}
/// <summary> return path;
/// Updates the path of a file index
/// </summary>
/// <param name="fileIndexId">The file index ID</param>
/// <param name="newPath">The new path</param>
/// <returns>The updated file index, or null if not found</returns>
public async Task<SnCloudFileIndex?> UpdateAsync(Guid fileIndexId, string newPath)
{
var fileIndex = await db.FileIndexes
.Include(fi => fi.File)
.Include(fi => fi.Folder)
.FirstOrDefaultAsync(fi => fi.Id == fileIndexId);
if (fileIndex == null)
return null;
var normalizedPath = NormalizePath(newPath);
// Get or create the folder hierarchy based on the new path
var newFolder = await GetOrCreateFolderByPathAsync(normalizedPath, fileIndex.AccountId);
// Check if a file with the same name already exists in the new folder
var existingFileIndex = await db.FileIndexes
.FirstOrDefaultAsync(fi => fi.AccountId == fileIndex.AccountId &&
fi.FolderId == newFolder.Id &&
fi.File.Name == fileIndex.File.Name &&
fi.Id != fileIndexId);
if (existingFileIndex != null)
{
throw new InvalidOperationException(
$"A file with name '{fileIndex.File.Name}' already exists in folder '{newFolder.Name}'");
}
// Since properties are init-only, we need to remove the old index and create a new one
db.FileIndexes.Remove(fileIndex);
var updatedFileIndex = SnCloudFileIndex.Create(newFolder, fileIndex.File, fileIndex.AccountId);
updatedFileIndex.Id = fileIndexId; // Keep the same ID
db.FileIndexes.Add(updatedFileIndex);
await db.SaveChangesAsync();
return updatedFileIndex;
}
/// <summary>
/// Removes all file index entries at a specific path for an account (finds folder by path and removes files from that folder)
/// </summary>
/// <param name="accountId">The account ID</param>
/// <param name="path">The path to clear</param>
/// <returns>The number of indexes removed</returns>
public async Task<int> RemoveByPathAsync(Guid accountId, string path)
{
var normalizedPath = NormalizePath(path);
// Find the folder that corresponds to this path
var folder = await db.Folders
.FirstOrDefaultAsync(f => f.AccountId == accountId && f.Path == normalizedPath + (normalizedPath == "/" ? "" : "/"));
if (folder == null)
return 0;
var indexes = await db.FileIndexes
.Where(fi => fi.AccountId == accountId && fi.FolderId == folder.Id)
.ToListAsync();
if (!indexes.Any())
return 0;
db.FileIndexes.RemoveRange(indexes);
await db.SaveChangesAsync();
return indexes.Count;
} }
} }

View File

@@ -1,313 +0,0 @@
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Drive.Index;
public class FolderService(AppDatabase db)
{
/// <summary>
/// Creates a new folder
/// </summary>
/// <param name="name">The folder name</param>
/// <param name="accountId">The account ID</param>
/// <param name="parentFolderId">Optional parent folder ID</param>
/// <returns>The created folder</returns>
public async Task<SnCloudFolder> CreateAsync(string name, Guid accountId, Guid? parentFolderId = null)
{
// Validate folder name
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Folder name cannot be empty", nameof(name));
// Check if parent folder exists and belongs to the same account
SnCloudFolder? parentFolder = null;
if (parentFolderId.HasValue)
{
parentFolder = await db.Folders
.FirstOrDefaultAsync(f => f.Id == parentFolderId && f.AccountId == accountId);
if (parentFolder == null)
throw new InvalidOperationException($"Parent folder with ID '{parentFolderId}' not found or access denied");
}
// Check if folder with same name already exists in the same location
var existingFolder = await db.Folders
.FirstOrDefaultAsync(f => f.AccountId == accountId &&
f.ParentFolderId == parentFolderId &&
f.Name == name);
if (existingFolder != null)
{
throw new InvalidOperationException(
$"A folder with name '{name}' already exists in the specified location");
}
var folder = SnCloudFolder.Create(name, accountId, parentFolder);
db.Folders.Add(folder);
await db.SaveChangesAsync();
return folder;
}
/// <summary>
/// Creates the root folder for an account (if it doesn't exist)
/// </summary>
/// <param name="accountId">The account ID</param>
/// <returns>The root folder</returns>
public async Task<SnCloudFolder> EnsureRootFolderAsync(Guid accountId)
{
var rootFolder = await db.Folders
.FirstOrDefaultAsync(f => f.AccountId == accountId && f.ParentFolderId == null);
if (rootFolder == null)
{
rootFolder = SnCloudFolder.CreateRoot(accountId);
db.Folders.Add(rootFolder);
await db.SaveChangesAsync();
}
return rootFolder;
}
/// <summary>
/// Gets a folder by ID with its contents
/// </summary>
/// <param name="folderId">The folder ID</param>
/// <param name="accountId">The account ID (for authorization)</param>
/// <returns>The folder with child folders and files</returns>
public async Task<SnCloudFolder?> GetByIdAsync(Guid folderId, Guid accountId)
{
return await db.Folders
.Include(f => f.ChildFolders.OrderBy(cf => cf.Name))
.Include(f => f.Files.OrderBy(fi => fi.File.Name))
.ThenInclude(fi => fi.File)
.FirstOrDefaultAsync(f => f.Id == folderId && f.AccountId == accountId);
}
/// <summary>
/// Gets all folders for an account
/// </summary>
/// <param name="accountId">The account ID</param>
/// <returns>List of folders</returns>
public async Task<List<SnCloudFolder>> GetByAccountIdAsync(Guid accountId)
{
return await db.Folders
.Where(f => f.AccountId == accountId)
.Include(f => f.ParentFolder)
.OrderBy(f => f.Path)
.ToListAsync();
}
/// <summary>
/// Gets child folders of a parent folder
/// </summary>
/// <param name="parentFolderId">The parent folder ID</param>
/// <param name="accountId">The account ID</param>
/// <returns>List of child folders</returns>
public async Task<List<SnCloudFolder>> GetChildFoldersAsync(Guid parentFolderId, Guid accountId)
{
return await db.Folders
.Where(f => f.ParentFolderId == parentFolderId && f.AccountId == accountId)
.OrderBy(f => f.Name)
.ToListAsync();
}
/// <summary>
/// Updates a folder's name and path
/// </summary>
/// <param name="folderId">The folder ID</param>
/// <param name="newName">The new folder name</param>
/// <param name="accountId">The account ID</param>
/// <returns>The updated folder</returns>
public async Task<SnCloudFolder?> UpdateAsync(Guid folderId, string newName, Guid accountId)
{
var folder = await db.Folders
.Include(f => f.ParentFolder)
.FirstOrDefaultAsync(f => f.Id == folderId && f.AccountId == accountId);
if (folder == null)
return null;
// Check if folder with same name already exists in the same location
var existingFolder = await db.Folders
.FirstOrDefaultAsync(f => f.AccountId == accountId &&
f.ParentFolderId == folder.ParentFolderId &&
f.Name == newName && f.Id != folderId);
if (existingFolder != null)
{
throw new InvalidOperationException(
$"A folder with name '{newName}' already exists in the specified location");
}
// Update folder name and path
var oldPath = folder.Path;
folder = SnCloudFolder.Create(newName, accountId, folder.ParentFolder);
folder.Id = folderId; // Keep the same ID
// Update all child folders' paths recursively
await UpdateChildFolderPathsAsync(folderId, oldPath, folder.Path);
db.Folders.Update(folder);
await db.SaveChangesAsync();
return folder;
}
/// <summary>
/// Recursively updates child folder paths when a parent folder is renamed
/// </summary>
private async Task UpdateChildFolderPathsAsync(Guid parentFolderId, string oldParentPath, string newParentPath)
{
var childFolders = await db.Folders
.Where(f => f.ParentFolderId == parentFolderId)
.ToListAsync();
foreach (var childFolder in childFolders)
{
var newPath = childFolder.Path.Replace(oldParentPath, newParentPath);
childFolder.Path = newPath;
// Recursively update grandchildren
await UpdateChildFolderPathsAsync(childFolder.Id, oldParentPath, newParentPath);
}
await db.SaveChangesAsync();
}
/// <summary>
/// Deletes a folder and all its contents
/// </summary>
/// <param name="folderId">The folder ID</param>
/// <param name="accountId">The account ID</param>
/// <returns>True if the folder was deleted, false otherwise</returns>
public async Task<bool> DeleteAsync(Guid folderId, Guid accountId)
{
var folder = await db.Folders
.Include(f => f.ChildFolders)
.Include(f => f.Files)
.FirstOrDefaultAsync(f => f.Id == folderId && f.AccountId == accountId);
if (folder == null)
return false;
// Recursively delete child folders
foreach (var childFolder in folder.ChildFolders.ToList())
{
await DeleteAsync(childFolder.Id, accountId);
}
// Remove file indexes
db.FileIndexes.RemoveRange(folder.Files);
// Remove the folder itself
db.Folders.Remove(folder);
await db.SaveChangesAsync();
return true;
}
/// <summary>
/// Moves a folder to a new parent folder
/// </summary>
/// <param name="folderId">The folder ID</param>
/// <param name="newParentFolderId">The new parent folder ID</param>
/// <param name="accountId">The account ID</param>
/// <returns>The moved folder</returns>
public async Task<SnCloudFolder?> MoveAsync(Guid folderId, Guid? newParentFolderId, Guid accountId)
{
var folder = await db.Folders
.FirstOrDefaultAsync(f => f.Id == folderId && f.AccountId == accountId);
if (folder == null)
return null;
// Check if new parent exists and belongs to the same account
SnCloudFolder? newParentFolder = null;
if (newParentFolderId.HasValue)
{
newParentFolder = await db.Folders
.FirstOrDefaultAsync(f => f.Id == newParentFolderId && f.AccountId == accountId);
if (newParentFolder == null)
throw new InvalidOperationException($"Target folder with ID '{newParentFolderId}' not found or access denied");
}
// Check for circular reference
if (newParentFolderId.HasValue && await IsCircularReferenceAsync(folderId, newParentFolderId.Value))
{
throw new InvalidOperationException("Cannot move folder to its own descendant");
}
// Check if folder with same name already exists in the target location
var existingFolder = await db.Folders
.FirstOrDefaultAsync(f => f.AccountId == accountId &&
f.ParentFolderId == newParentFolderId &&
f.Name == folder.Name);
if (existingFolder != null)
{
throw new InvalidOperationException(
$"A folder with name '{folder.Name}' already exists in the target location");
}
var oldPath = folder.Path;
var newPath = newParentFolder != null
? $"{newParentFolder.Path.TrimEnd('/')}/{folder.Name}/"
: $"/{folder.Name}/";
// Update folder parent and path
folder.ParentFolderId = newParentFolderId;
folder.Path = newPath;
// Update all child folders' paths recursively
await UpdateChildFolderPathsAsync(folderId, oldPath, newPath);
db.Folders.Update(folder);
await db.SaveChangesAsync();
return folder;
}
/// <summary>
/// Checks if moving a folder would create a circular reference
/// </summary>
private async Task<bool> IsCircularReferenceAsync(Guid folderId, Guid potentialParentId)
{
if (folderId == potentialParentId)
return true;
var currentFolderId = potentialParentId;
while (currentFolderId != Guid.Empty)
{
var currentFolder = await db.Folders
.Where(f => f.Id == currentFolderId)
.Select(f => new { f.Id, f.ParentFolderId })
.FirstOrDefaultAsync();
if (currentFolder == null)
break;
if (currentFolder.Id == folderId)
return true;
currentFolderId = currentFolder.ParentFolderId ?? Guid.Empty;
}
return false;
}
/// <summary>
/// Searches for folders by name
/// </summary>
/// <param name="accountId">The account ID</param>
/// <param name="searchTerm">The search term</param>
/// <returns>List of matching folders</returns>
public async Task<List<SnCloudFolder>> SearchAsync(Guid accountId, string searchTerm)
{
return await db.Folders
.Where(f => f.AccountId == accountId && f.Name.Contains(searchTerm))
.Include(f => f.ParentFolder)
.OrderBy(f => f.Name)
.ToListAsync();
}
}

View File

@@ -12,11 +12,9 @@ And all the arguments will be transformed into snake case via the gateway.
### Core Components ### Core Components
1. **SnCloudFileIndex Model** - Represents the file-to-path mapping 1. **SnCloudFileIndex Model** - Represents the file-to-path mapping
2. **SnCloudFolder Model** - Represents hierarchical folder structure 2. **FileIndexService** - Business logic for file index operations
3. **FileIndexService** - Business logic for file index operations 3. **FileIndexController** - REST API endpoints for file management
4. **FolderService** - Business logic for folder operations 4. **FileUploadController Integration** - Automatic index creation during upload
5. **FileIndexController** - REST API endpoints for file and folder management
6. **FileUploadController Integration** - Automatic index creation during upload
### Database Schema ### Database Schema
@@ -202,138 +200,6 @@ Search for files by name or metadata.
} }
``` ```
### Folder Management
The system provides comprehensive folder management capabilities alongside file indexing.
#### Create Folder
**POST** `/api/index/folders`
Create a new folder.
**Request Body:**
```json
{
"name": "Documents",
"parentFolderId": null // null for root folder
}
```
**Response:**
```json
{
"id": "guid",
"name": "Documents",
"parentFolderId": null,
"accountId": "guid",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z"
}
```
#### Get Folder by ID
**GET** `/api/index/folders/{folderId}`
Get a folder with its contents.
**Path Parameters:**
- `folderId` - The folder ID
**Response:**
```json
{
"id": "guid",
"name": "Documents",
"parentFolderId": null,
"accountId": "guid",
"childFolders": [
{
"id": "guid",
"name": "Reports",
"parentFolderId": "guid",
"accountId": "guid"
}
],
"files": [
// File index objects
],
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z"
}
```
#### Get All Folders
**GET** `/api/index/folders`
Get all folders for the current user.
**Response:**
```json
[
{
"id": "guid",
"name": "Documents",
"parentFolderId": null,
"accountId": "guid",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z"
}
]
```
#### Update Folder
**PUT** `/api/index/folders/{folderId}`
Update a folder's name.
**Path Parameters:**
- `folderId` - The folder ID
**Request Body:**
```json
{
"name": "Updated Documents"
}
```
#### Delete Folder
**DELETE** `/api/index/folders/{folderId}`
Delete a folder and all its contents.
**Path Parameters:**
- `folderId` - The folder ID
#### Move File to Folder
**POST** `/api/index/files/{fileIndexId}/move-to-folder`
Move a file to a different folder.
**Path Parameters:**
- `fileIndexId` - The file index ID
**Request Body:**
```json
{
"newFolderId": "guid"
}
```
#### Get Files in Folder
**GET** `/api/index/folders/{folderId}/files`
Get all files in a specific folder.
**Path Parameters:**
- `folderId` - The folder ID
**Response:**
```json
[
// File index objects
]
```
## Path Normalization ## Path Normalization
The system automatically normalizes paths to ensure consistency: The system automatically normalizes paths to ensure consistency:
@@ -371,75 +237,32 @@ The system will automatically create a file index when the upload completes succ
```csharp ```csharp
public class FileIndexService public class FileIndexService
{ {
// Create a new file index at path // Create a new file index
Task<SnCloudFileIndex> CreateAsync(string path, string fileId, Guid accountId); Task<SnCloudFileIndex> CreateAsync(string path, Guid fileId, Guid accountId);
// Create a new file index in folder
Task<SnCloudFileIndex> CreateInFolderAsync(Guid folderId, string fileId, Guid accountId);
// Get files by path // Get files by path
Task<List<SnCloudFileIndex>> GetByPathAsync(Guid accountId, string path); Task<List<SnCloudFileIndex>> GetByPathAsync(Guid accountId, string path);
// Get files by folder
Task<List<SnCloudFileIndex>> GetByFolderAsync(Guid accountId, Guid folderId);
// Get all files for account // Get all files for account
Task<List<SnCloudFileIndex>> GetByAccountIdAsync(Guid accountId); Task<List<SnCloudFileIndex>> GetByAccountIdAsync(Guid accountId);
// Get indexes for specific file // Get indexes for specific file
Task<List<SnCloudFileIndex>> GetByFileIdAsync(string fileId); Task<List<SnCloudFileIndex>> GetByFileIdAsync(Guid fileId);
// Move file to new path // Move file to new path
Task<SnCloudFileIndex?> UpdateAsync(Guid indexId, string newPath); Task<SnCloudFileIndex?> UpdateAsync(Guid indexId, string newPath);
// Move file to different folder
Task<SnCloudFileIndex?> MoveAsync(Guid fileIndexId, Guid newFolderId, Guid accountId);
// Remove file index // Remove file index
Task<bool> RemoveAsync(Guid indexId); Task<bool> RemoveAsync(Guid indexId);
// Remove all indexes in path // Remove all indexes in path
Task<int> RemoveByPathAsync(Guid accountId, string path); Task<int> RemoveByPathAsync(Guid accountId, string path);
// Remove all indexes in folder
Task<int> RemoveByFolderAsync(Guid accountId, Guid folderId);
// Normalize path format // Normalize path format
public static string NormalizePath(string path); public static string NormalizePath(string path);
} }
``` ```
### FolderService
```csharp
public class FolderService
{
// Create a new folder
Task<SnCloudFolder> CreateAsync(string name, Guid accountId, Guid? parentFolderId = null);
// Get folder by ID with contents
Task<SnCloudFolder?> GetByIdAsync(Guid folderId, Guid accountId);
// Get all folders for account
Task<List<SnCloudFolder>> GetByAccountIdAsync(Guid accountId);
// Get child folders
Task<List<SnCloudFolder>> GetChildFoldersAsync(Guid parentFolderId, Guid accountId);
// Update folder name
Task<SnCloudFolder?> UpdateAsync(Guid folderId, string name, Guid accountId);
// Move folder to new parent
Task<SnCloudFolder?> MoveAsync(Guid folderId, Guid? newParentFolderId, Guid accountId);
// Delete folder and contents
Task<bool> DeleteAsync(Guid folderId, Guid accountId);
// Search folders by name
Task<List<SnCloudFolder>> SearchAsync(Guid accountId, string searchTerm);
}
```
## Error Handling ## Error Handling
The API returns appropriate HTTP status codes and error messages: The API returns appropriate HTTP status codes and error messages:

View File

@@ -1,722 +0,0 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using DysonNetwork.Drive;
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.Drive.Migrations
{
[DbContext(typeof(AppDatabase))]
[Migration("20251113165508_AddFileFolders")]
partial class AddFileFolders
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", 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<string>("Description")
.IsRequired()
.HasColumnType("text")
.HasColumnName("description");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<long>("Quota")
.HasColumnType("bigint")
.HasColumnName("quota");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_quota_records");
b.ToTable("quota_records", (string)null);
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.Model.PersistentTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_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<string>("Description")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("description");
b.Property<string>("Discriminator")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("character varying(21)")
.HasColumnName("discriminator");
b.Property<string>("ErrorMessage")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("error_message");
b.Property<long?>("EstimatedDurationSeconds")
.HasColumnType("bigint")
.HasColumnName("estimated_duration_seconds");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<Instant>("LastActivity")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_activity");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("name");
b.Property<Dictionary<string, object>>("Parameters")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("parameters");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<double>("Progress")
.HasColumnType("double precision")
.HasColumnName("progress");
b.Property<Dictionary<string, object>>("Results")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("results");
b.Property<Instant?>("StartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("started_at");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<string>("TaskId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("task_id");
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_tasks");
b.ToTable("tasks", (string)null);
b.HasDiscriminator().HasValue("PersistentTask");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("DysonNetwork.Shared.Models.CloudFileReference", 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<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("FileId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("file_id");
b.Property<string>("ResourceId")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("resource_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("Usage")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("usage");
b.HasKey("Id")
.HasName("pk_file_references");
b.HasIndex("FileId")
.HasDatabaseName("ix_file_references_file_id");
b.ToTable("file_references", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.FilePool", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid?>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<BillingConfig>("BillingConfig")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("billing_config");
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>("Description")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("description");
b.Property<bool>("IsHidden")
.HasColumnType("boolean")
.HasColumnName("is_hidden");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<PolicyConfig>("PolicyConfig")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("policy_config");
b.Property<RemoteStorageConfig>("StorageConfig")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("storage_config");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_pools");
b.ToTable("pools", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
{
b.Property<string>("Id")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Guid?>("BundleId")
.HasColumnType("uuid")
.HasColumnName("bundle_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>("Description")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("description");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<Dictionary<string, object>>("FileMeta")
.HasColumnType("jsonb")
.HasColumnName("file_meta");
b.Property<bool>("HasCompression")
.HasColumnType("boolean")
.HasColumnName("has_compression");
b.Property<bool>("HasThumbnail")
.HasColumnType("boolean")
.HasColumnName("has_thumbnail");
b.Property<string>("Hash")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("hash");
b.Property<bool>("IsEncrypted")
.HasColumnType("boolean")
.HasColumnName("is_encrypted");
b.Property<bool>("IsMarkedRecycle")
.HasColumnType("boolean")
.HasColumnName("is_marked_recycle");
b.Property<string>("MimeType")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("mime_type");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<Guid?>("PoolId")
.HasColumnType("uuid")
.HasColumnName("pool_id");
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
.HasColumnType("jsonb")
.HasColumnName("sensitive_marks");
b.Property<long>("Size")
.HasColumnType("bigint")
.HasColumnName("size");
b.Property<string>("StorageId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("storage_id");
b.Property<string>("StorageUrl")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("storage_url");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<Instant?>("UploadedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("uploaded_at");
b.Property<Dictionary<string, object>>("UserMeta")
.HasColumnType("jsonb")
.HasColumnName("user_meta");
b.HasKey("Id")
.HasName("pk_files");
b.HasIndex("BundleId")
.HasDatabaseName("ix_files_bundle_id");
b.HasIndex("PoolId")
.HasDatabaseName("ix_files_pool_id");
b.ToTable("files", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileIndex", 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<string>("FileId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("file_id");
b.Property<Guid>("FolderId")
.HasColumnType("uuid")
.HasColumnName("folder_id");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)")
.HasColumnName("path");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_indexes");
b.HasIndex("FileId")
.HasDatabaseName("ix_file_indexes_file_id");
b.HasIndex("FolderId", "AccountId")
.HasDatabaseName("ix_file_indexes_folder_id_account_id");
b.ToTable("file_indexes", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFolder", 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<string>("Description")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("description");
b.Property<Dictionary<string, object>>("FolderMeta")
.HasColumnType("jsonb")
.HasColumnName("folder_meta");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("name");
b.Property<Guid?>("ParentFolderId")
.HasColumnType("uuid")
.HasColumnName("parent_folder_id");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("path");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_folders");
b.HasIndex("ParentFolderId")
.HasDatabaseName("ix_folders_parent_folder_id");
b.HasIndex("Path", "AccountId")
.HasDatabaseName("ix_folders_path_account_id");
b.ToTable("folders", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", 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<string>("Description")
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("description");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<string>("Passcode")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("passcode");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("slug");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_bundles");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_bundles_slug");
b.ToTable("bundles", (string)null);
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.Model.PersistentUploadTask", b =>
{
b.HasBaseType("DysonNetwork.Drive.Storage.Model.PersistentTask");
b.Property<Guid?>("BundleId")
.HasColumnType("uuid")
.HasColumnName("bundle_id");
b.Property<long>("ChunkSize")
.HasColumnType("bigint")
.HasColumnName("chunk_size");
b.Property<int>("ChunksCount")
.HasColumnType("integer")
.HasColumnName("chunks_count");
b.Property<int>("ChunksUploaded")
.HasColumnType("integer")
.HasColumnName("chunks_uploaded");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("content_type");
b.Property<string>("EncryptPassword")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("encrypt_password");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("file_name");
b.Property<long>("FileSize")
.HasColumnType("bigint")
.HasColumnName("file_size");
b.Property<string>("Hash")
.IsRequired()
.HasColumnType("text")
.HasColumnName("hash");
b.Property<string>("Path")
.HasColumnType("text")
.HasColumnName("path");
b.Property<Guid>("PoolId")
.HasColumnType("uuid")
.HasColumnName("pool_id");
b.PrimitiveCollection<List<int>>("UploadedChunks")
.IsRequired()
.HasColumnType("integer[]")
.HasColumnName("uploaded_chunks");
b.HasDiscriminator().HasValue("PersistentUploadTask");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.CloudFileReference", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnCloudFile", "File")
.WithMany("References")
.HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_references_files_file_id");
b.Navigation("File");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnFileBundle", "Bundle")
.WithMany("Files")
.HasForeignKey("BundleId")
.HasConstraintName("fk_files_bundles_bundle_id");
b.HasOne("DysonNetwork.Shared.Models.FilePool", "Pool")
.WithMany()
.HasForeignKey("PoolId")
.HasConstraintName("fk_files_pools_pool_id");
b.Navigation("Bundle");
b.Navigation("Pool");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileIndex", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnCloudFile", "File")
.WithMany("FileIndexes")
.HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_indexes_files_file_id");
b.HasOne("DysonNetwork.Shared.Models.SnCloudFolder", "Folder")
.WithMany("Files")
.HasForeignKey("FolderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_indexes_folders_folder_id");
b.Navigation("File");
b.Navigation("Folder");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFolder", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnCloudFolder", "ParentFolder")
.WithMany("ChildFolders")
.HasForeignKey("ParentFolderId")
.HasConstraintName("fk_folders_folders_parent_folder_id");
b.Navigation("ParentFolder");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
{
b.Navigation("FileIndexes");
b.Navigation("References");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFolder", b =>
{
b.Navigation("ChildFolders");
b.Navigation("Files");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b =>
{
b.Navigation("Files");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,120 +0,0 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Drive.Migrations
{
/// <inheritdoc />
public partial class AddFileFolders : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_file_indexes_path_account_id",
table: "file_indexes");
migrationBuilder.AlterColumn<string>(
name: "path",
table: "file_indexes",
type: "character varying(2048)",
maxLength: 2048,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(8192)",
oldMaxLength: 8192);
migrationBuilder.AddColumn<Guid>(
name: "folder_id",
table: "file_indexes",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
migrationBuilder.CreateTable(
name: "folders",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
path = table.Column<string>(type: "character varying(8192)", maxLength: 8192, nullable: false),
parent_folder_id = table.Column<Guid>(type: "uuid", nullable: true),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
folder_meta = table.Column<Dictionary<string, object>>(type: "jsonb", 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_folders", x => x.id);
table.ForeignKey(
name: "fk_folders_folders_parent_folder_id",
column: x => x.parent_folder_id,
principalTable: "folders",
principalColumn: "id");
});
migrationBuilder.CreateIndex(
name: "ix_file_indexes_folder_id_account_id",
table: "file_indexes",
columns: new[] { "folder_id", "account_id" });
migrationBuilder.CreateIndex(
name: "ix_folders_parent_folder_id",
table: "folders",
column: "parent_folder_id");
migrationBuilder.CreateIndex(
name: "ix_folders_path_account_id",
table: "folders",
columns: new[] { "path", "account_id" });
migrationBuilder.AddForeignKey(
name: "fk_file_indexes_folders_folder_id",
table: "file_indexes",
column: "folder_id",
principalTable: "folders",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_file_indexes_folders_folder_id",
table: "file_indexes");
migrationBuilder.DropTable(
name: "folders");
migrationBuilder.DropIndex(
name: "ix_file_indexes_folder_id_account_id",
table: "file_indexes");
migrationBuilder.DropColumn(
name: "folder_id",
table: "file_indexes");
migrationBuilder.AlterColumn<string>(
name: "path",
table: "file_indexes",
type: "character varying(8192)",
maxLength: 8192,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(2048)",
oldMaxLength: 2048);
migrationBuilder.CreateIndex(
name: "ix_file_indexes_path_account_id",
table: "file_indexes",
columns: new[] { "path", "account_id" });
}
}
}

View File

@@ -179,7 +179,7 @@ namespace DysonNetwork.Drive.Migrations
b.UseTphMappingStrategy(); b.UseTphMappingStrategy();
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.CloudFileReference", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileReference", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -428,14 +428,10 @@ namespace DysonNetwork.Drive.Migrations
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)")
.HasColumnName("file_id"); .HasColumnName("file_id");
b.Property<Guid>("FolderId")
.HasColumnType("uuid")
.HasColumnName("folder_id");
b.Property<string>("Path") b.Property<string>("Path")
.IsRequired() .IsRequired()
.HasMaxLength(2048) .HasMaxLength(8192)
.HasColumnType("character varying(2048)") .HasColumnType("character varying(8192)")
.HasColumnName("path"); .HasColumnName("path");
b.Property<Instant>("UpdatedAt") b.Property<Instant>("UpdatedAt")
@@ -448,72 +444,12 @@ namespace DysonNetwork.Drive.Migrations
b.HasIndex("FileId") b.HasIndex("FileId")
.HasDatabaseName("ix_file_indexes_file_id"); .HasDatabaseName("ix_file_indexes_file_id");
b.HasIndex("FolderId", "AccountId") b.HasIndex("Path", "AccountId")
.HasDatabaseName("ix_file_indexes_folder_id_account_id"); .HasDatabaseName("ix_file_indexes_path_account_id");
b.ToTable("file_indexes", (string)null); b.ToTable("file_indexes", (string)null);
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFolder", 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<string>("Description")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("description");
b.Property<Dictionary<string, object>>("FolderMeta")
.HasColumnType("jsonb")
.HasColumnName("folder_meta");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("name");
b.Property<Guid?>("ParentFolderId")
.HasColumnType("uuid")
.HasColumnName("parent_folder_id");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("path");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_folders");
b.HasIndex("ParentFolderId")
.HasDatabaseName("ix_folders_parent_folder_id");
b.HasIndex("Path", "AccountId")
.HasDatabaseName("ix_folders_path_account_id");
b.ToTable("folders", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -635,7 +571,7 @@ namespace DysonNetwork.Drive.Migrations
b.HasDiscriminator().HasValue("PersistentUploadTask"); 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") b.HasOne("DysonNetwork.Shared.Models.SnCloudFile", "File")
.WithMany("References") .WithMany("References")
@@ -673,26 +609,7 @@ namespace DysonNetwork.Drive.Migrations
.IsRequired() .IsRequired()
.HasConstraintName("fk_file_indexes_files_file_id"); .HasConstraintName("fk_file_indexes_files_file_id");
b.HasOne("DysonNetwork.Shared.Models.SnCloudFolder", "Folder")
.WithMany("Files")
.HasForeignKey("FolderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_indexes_folders_folder_id");
b.Navigation("File"); b.Navigation("File");
b.Navigation("Folder");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFolder", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnCloudFolder", "ParentFolder")
.WithMany("ChildFolders")
.HasForeignKey("ParentFolderId")
.HasConstraintName("fk_folders_folders_parent_folder_id");
b.Navigation("ParentFolder");
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
@@ -702,13 +619,6 @@ namespace DysonNetwork.Drive.Migrations
b.Navigation("References"); b.Navigation("References");
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFolder", b =>
{
b.Navigation("ChildFolders");
b.Navigation("Files");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b =>
{ {
b.Navigation("Files"); b.Navigation("Files");

View File

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

View File

@@ -12,9 +12,7 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
{ {
services.AddDbContext<AppDatabase>(); // Assuming you'll have an AppDatabase services.AddDbContext<AppDatabase>(); // Assuming you'll have an AppDatabase
services.AddSingleton<IClock>(SystemClock.Instance);
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddSingleton<ICacheService, CacheServiceRedis>(); // Uncomment if you have CacheServiceRedis
services.AddHttpClient(); services.AddHttpClient();
@@ -57,7 +55,6 @@ public static class ServiceCollectionExtensions
services.AddScoped<Storage.FileService>(); services.AddScoped<Storage.FileService>();
services.AddScoped<Storage.FileReferenceService>(); services.AddScoped<Storage.FileReferenceService>();
services.AddScoped<Storage.PersistentTaskService>(); services.AddScoped<Storage.PersistentTaskService>();
services.AddScoped<FolderService>();
services.AddScoped<FileIndexService>(); services.AddScoped<FileIndexService>();
services.AddScoped<Billing.UsageService>(); services.AddScoped<Billing.UsageService>();
services.AddScoped<Billing.QuotaService>(); services.AddScoped<Billing.QuotaService>();

View File

@@ -1,4 +1,3 @@
using DysonNetwork.Drive.Billing;
using DysonNetwork.Shared.Auth; using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
@@ -14,9 +13,9 @@ namespace DysonNetwork.Drive.Storage;
public class FileController( public class FileController(
AppDatabase db, AppDatabase db,
FileService fs, FileService fs,
QuotaService qs,
IConfiguration configuration, IConfiguration configuration,
IWebHostEnvironment env IWebHostEnvironment env,
FileReferenceService fileReferenceService
) : ControllerBase ) : ControllerBase
{ {
[HttpGet("{id}")] [HttpGet("{id}")]
@@ -63,30 +62,31 @@ public class FileController(
return null; return null;
} }
private async Task<ActionResult> ServeLocalFile(SnCloudFile file) private Task<ActionResult> ServeLocalFile(SnCloudFile file)
{ {
// Try temp storage first // Try temp storage first
var tempFilePath = Path.Combine(Path.GetTempPath(), file.Id); var tempFilePath = Path.Combine(Path.GetTempPath(), file.Id);
if (System.IO.File.Exists(tempFilePath)) if (System.IO.File.Exists(tempFilePath))
{ {
if (file.IsEncrypted) 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 // Fallback for tus uploads
var tusStorePath = configuration.GetValue<string>("Storage:Uploads"); var tusStorePath = configuration.GetValue<string>("Storage:Uploads");
if (!string.IsNullOrEmpty(tusStorePath)) if (string.IsNullOrEmpty(tusStorePath))
{ return Task.FromResult<ActionResult>(StatusCode(StatusCodes.Status400BadRequest,
var tusFilePath = Path.Combine(env.ContentRootPath, tusStorePath, file.Id); "File is being processed. Please try again later."));
if (System.IO.File.Exists(tusFilePath)) var tusFilePath = Path.Combine(env.ContentRootPath, tusStorePath, file.Id);
{ return System.IO.File.Exists(tusFilePath)
return PhysicalFile(tusFilePath, file.MimeType ?? "application/octet-stream", file.Name, enableRangeProcessing: true); ? 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."));
return StatusCode(StatusCodes.Status400BadRequest, "File is being processed. Please try again later.");
} }
private async Task<ActionResult> ServeRemoteFile( private async Task<ActionResult> ServeRemoteFile(
@@ -99,7 +99,8 @@ public class FileController(
) )
{ {
if (!file.PoolId.HasValue) 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); var pool = await fs.GetPoolAsync(file.PoolId.Value);
if (pool is null) if (pool is null)
@@ -148,15 +149,10 @@ public class FileController(
return Redirect(BuildProxyUrl(dest.ImageProxy, fileName)); return Redirect(BuildProxyUrl(dest.ImageProxy, fileName));
} }
if (dest.AccessProxy is not null) return dest.AccessProxy is not null ? Redirect(BuildProxyUrl(dest.AccessProxy, fileName)) : null;
{
return Redirect(BuildProxyUrl(dest.AccessProxy, fileName));
}
return 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 baseUri = new Uri(proxyUrl.EndsWith('/') ? proxyUrl : $"{proxyUrl}/");
var fullUri = new Uri(baseUri, fileName); var fullUri = new Uri(baseUri, fileName);
@@ -189,7 +185,7 @@ public class FileController(
return Redirect(openUrl); return Redirect(openUrl);
} }
private Dictionary<string, string> BuildSignedUrlHeaders( private static Dictionary<string, string> BuildSignedUrlHeaders(
SnCloudFile file, SnCloudFile file,
string? fileExtension, string? fileExtension,
string? overrideMimeType, string? overrideMimeType,
@@ -234,6 +230,21 @@ public class FileController(
return file; 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] [Authorize]
[HttpPatch("{id}/name")] [HttpPatch("{id}/name")]
public async Task<ActionResult<SnCloudFile>> UpdateFileName(string id, [FromBody] string name) public async Task<ActionResult<SnCloudFile>> UpdateFileName(string id, [FromBody] string name)
@@ -281,25 +292,40 @@ public class FileController(
[FromQuery] Guid? pool, [FromQuery] Guid? pool,
[FromQuery] bool recycled = false, [FromQuery] bool recycled = false,
[FromQuery] int offset = 0, [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(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
var query = db.Files var filesQuery = db.Files
.Where(e => e.IsMarkedRecycle == recycled) .Where(e => e.IsMarkedRecycle == recycled)
.Where(e => e.AccountId == accountId) .Where(e => e.AccountId == accountId)
.Include(e => e.Pool) .Include(e => e.Pool)
.OrderByDescending(e => e.CreatedAt)
.AsQueryable(); .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()); Response.Headers.Append("X-Total", total.ToString());
var files = await query var files = await filesQuery
.Skip(offset) .Skip(offset)
.Take(take) .Take(take)
.ToListAsync(); .ToListAsync();
@@ -307,9 +333,25 @@ public class FileController(
return Ok(files); 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] [Authorize]
[HttpDelete("{id}")] [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(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var userId = Guid.Parse(currentUser.Id); var userId = Guid.Parse(currentUser.Id);
@@ -321,9 +363,9 @@ public class FileController(
if (file is null) return NotFound(); if (file is null) return NotFound();
await fs.DeleteFileDataAsync(file, force: true); await fs.DeleteFileDataAsync(file, force: true);
await fs.DeleteFileAsync(file); await fs.DeleteFileAsync(file, skipData: true);
return NoContent(); return Ok(file);
} }
[Authorize] [Authorize]
@@ -339,116 +381,10 @@ public class FileController(
[Authorize] [Authorize]
[HttpDelete("recycle")] [HttpDelete("recycle")]
[RequiredPermission("maintenance", "files.delete.recycle")] [AskPermission("files.delete.recycle")]
public async Task<ActionResult> DeleteAllRecycledFiles() public async Task<ActionResult> DeleteAllRecycledFiles()
{ {
var count = await fs.DeleteAllRecycledFilesAsync(); var count = await fs.DeleteAllRecycledFilesAsync();
return Ok(new { Count = count }); 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.Cache;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using EFCore.BulkExtensions; using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore; 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="expiredAt">Optional expiration time for the file</param>
/// <param name="duration">Optional duration after which the file expires (alternative to expiredAt)</param> /// <param name="duration">Optional duration after which the file expires (alternative to expiredAt)</param>
/// <returns>The created file reference</returns> /// <returns>The created file reference</returns>
public async Task<CloudFileReference> CreateReferenceAsync( public async Task<SnCloudFileReference> CreateReferenceAsync(
string fileId, string fileId,
string usage, string usage,
string resourceId, string resourceId,
@@ -33,7 +34,7 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
if (duration.HasValue) if (duration.HasValue)
finalExpiration = SystemClock.Instance.GetCurrentInstant() + duration.Value; finalExpiration = SystemClock.Instance.GetCurrentInstant() + duration.Value;
var reference = new CloudFileReference var reference = new SnCloudFileReference
{ {
FileId = fileId, FileId = fileId,
Usage = usage, Usage = usage,
@@ -49,7 +50,7 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
return reference; return reference;
} }
public async Task<List<CloudFileReference>> CreateReferencesAsync( public async Task<List<SnCloudFileReference>> CreateReferencesAsync(
List<string> fileId, List<string> fileId,
string usage, string usage,
string resourceId, string resourceId,
@@ -57,12 +58,15 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
Duration? duration = null Duration? duration = null
) )
{ {
var data = fileId.Select(id => new CloudFileReference var now = SystemClock.Instance.GetCurrentInstant();
var data = fileId.Select(id => new SnCloudFileReference
{ {
FileId = id, FileId = id,
Usage = usage, Usage = usage,
ResourceId = resourceId, ResourceId = resourceId,
ExpiredAt = expiredAt ?? SystemClock.Instance.GetCurrentInstant() + duration ExpiredAt = expiredAt ?? now + duration,
CreatedAt = now,
UpdatedAt = now
}).ToList(); }).ToList();
await db.BulkInsertAsync(data); await db.BulkInsertAsync(data);
return data; return data;
@@ -73,11 +77,11 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
/// </summary> /// </summary>
/// <param name="fileId">The ID of the file</param> /// <param name="fileId">The ID of the file</param>
/// <returns>A list of all references to the file</returns> /// <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 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) if (cachedReferences is not null)
return cachedReferences; return cachedReferences;
@@ -90,17 +94,17 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
return references; 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 fileIdList = fileIds.ToList();
var result = new Dictionary<string, List<CloudFileReference>>(); var result = new Dictionary<string, List<SnCloudFileReference>>();
// Check cache for each file ID // Check cache for each file ID
var uncachedFileIds = new List<string>(); var uncachedFileIds = new List<string>();
foreach (var fileId in fileIdList) foreach (var fileId in fileIdList)
{ {
var cacheKey = $"{CacheKeyPrefix}list:{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) if (cachedReferences is not null)
{ {
result[fileId] = cachedReferences; result[fileId] = cachedReferences;
@@ -158,11 +162,11 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
/// </summary> /// </summary>
/// <param name="resourceId">The ID of the resource</param> /// <param name="resourceId">The ID of the resource</param>
/// <returns>A list of file references associated with the resource</returns> /// <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 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) if (cachedReferences is not null)
return cachedReferences; return cachedReferences;
@@ -180,11 +184,11 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
/// </summary> /// </summary>
/// <param name="usage">The usage context</param> /// <param name="usage">The usage context</param>
/// <returns>A list of file references with the specified usage</returns> /// <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 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) if (cachedReferences is not null)
return cachedReferences; 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="expiredAt">Optional expiration time for newly added files</param>
/// <param name="duration">Optional duration after which newly added files expire</param> /// <param name="duration">Optional duration after which newly added files expire</param>
/// <returns>A list of the updated file references</returns> /// <returns>A list of the updated file references</returns>
public async Task<List<CloudFileReference>> UpdateResourceFilesAsync( public async Task<List<SnCloudFileReference>> UpdateResourceFilesAsync(
string resourceId, string resourceId,
IEnumerable<string>? newFileIds, IEnumerable<string>? newFileIds,
string usage, string usage,
@@ -314,7 +318,7 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
Duration? duration = null) Duration? duration = null)
{ {
if (newFileIds == null) if (newFileIds == null)
return new List<CloudFileReference>(); return new List<SnCloudFileReference>();
var existingReferences = await db.FileReferences var existingReferences = await db.FileReferences
.Where(r => r.ResourceId == resourceId && r.Usage == usage) .Where(r => r.ResourceId == resourceId && r.Usage == usage)
@@ -332,7 +336,7 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
// Files to add // Files to add
var toAdd = newFileIdsList var toAdd = newFileIdsList
.Where(id => !existingFileIds.Contains(id)) .Where(id => !existingFileIds.Contains(id))
.Select(id => new CloudFileReference .Select(id => new SnCloudFileReference
{ {
FileId = id, FileId = id,
Usage = usage, Usage = usage,
@@ -484,7 +488,7 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
/// <param name="resourceId">The resource ID</param> /// <param name="resourceId">The resource ID</param>
/// <param name="usageType">The usage type</param> /// <param name="usageType">The usage type</param>
/// <returns>List of file references</returns> /// <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 return await db.FileReferences
.Where(r => r.ResourceId == resourceId && r.Usage == usageType) .Where(r => r.ResourceId == resourceId && r.Usage == usageType)

View File

@@ -103,7 +103,8 @@ public class FileService(
var bundle = await ValidateAndGetBundleAsync(fileBundleId, accountId); var bundle = await ValidateAndGetBundleAsync(fileBundleId, accountId);
var finalExpiredAt = CalculateFinalExpiration(expiredAt, pool, bundle); 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); var file = CreateFileObject(fileId, fileName, finalContentType, fileSize, finalExpiredAt, bundle, accountId);
@@ -112,7 +113,8 @@ public class FileService(
await ExtractMetadataAsync(file, managedTempPath); 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); file.Hash = await HashFileAsync(processingPath);
@@ -231,7 +233,8 @@ public class FileService(
file.StorageId ??= file.Id; 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(); var js = nats.CreateJetStreamContext();
await js.PublishAsync( await js.PublishAsync(
@@ -471,13 +474,14 @@ public class FileService(
return await db.Files.AsNoTracking().FirstAsync(f => f.Id == file.Id); 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); db.Remove(file);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await _PurgeCacheAsync(file.Id); await _PurgeCacheAsync(file.Id);
await DeleteFileDataAsync(file); if (!skipData)
await DeleteFileDataAsync(file);
} }
public async Task DeleteFileDataAsync(SnCloudFile file, bool force = false) public async Task DeleteFileDataAsync(SnCloudFile file, bool force = false)
@@ -660,9 +664,12 @@ public class FileService(
} }
} }
return [.. references return
.Select(r => cachedFiles.GetValueOrDefault(r.Id)) [
.Where(f => f != null)]; .. references
.Select(r => cachedFiles.GetValueOrDefault(r.Id))
.Where(f => f != null)
];
} }
public async Task<int> GetReferenceCountAsync(string fileId) public async Task<int> GetReferenceCountAsync(string fileId)
@@ -711,6 +718,21 @@ public class FileService(
return count; 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) public async Task<int> DeletePoolRecycledFilesAsync(Guid poolId)
{ {
var files = await db.Files var files = await db.Files

View File

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

View File

@@ -1,20 +0,0 @@
using DysonNetwork.Shared.Data;
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Drive;
[ApiController]
[Route("/api/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,118 +1,124 @@
{ {
"Debug": true, "Debug": true,
"BaseUrl": "http://localhost:5090", "BaseUrl": "http://localhost:5090",
"GatewayUrl": "http://localhost:5094", "GatewayUrl": "http://localhost:5094",
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": { "ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60" "App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
}, "Registrar": "127.0.0.1:2379",
"Authentication": { "Cache": "127.0.0.1:6379",
"Schemes": { "Queue": "127.0.0.1:4222"
"Bearer": { },
"ValidAudiences": [ "Authentication": {
"http://localhost:5071", "Schemes": {
"https://localhost:7099" "Bearer": {
], "ValidAudiences": [
"ValidIssuer": "solar-network" "http://localhost:5071",
} "https://localhost:7099"
} ],
}, "ValidIssuer": "solar-network"
"AuthToken": { }
"PublicKeyPath": "Keys/PublicKey.pem", }
"PrivateKeyPath": "Keys/PrivateKey.pem" },
}, "AuthToken": {
"Storage": { "PublicKeyPath": "Keys/PublicKey.pem",
"Uploads": "Uploads", "PrivateKeyPath": "Keys/PrivateKey.pem"
"PreferredRemote": "c53136a6-9152-4ecb-9f88-43c41438c23e", },
"Remote": [ "Storage": {
{ "Uploads": "Uploads",
"Id": "minio", "PreferredRemote": "c53136a6-9152-4ecb-9f88-43c41438c23e",
"Label": "Minio", "Remote": [
"Region": "auto", {
"Bucket": "solar-network-development", "Id": "minio",
"Endpoint": "localhost:9000", "Label": "Minio",
"SecretId": "littlesheep", "Region": "auto",
"SecretKey": "password", "Bucket": "solar-network-development",
"EnabledSigned": true, "Endpoint": "localhost:9000",
"EnableSsl": false "SecretId": "littlesheep",
}, "SecretKey": "password",
{ "EnabledSigned": true,
"Id": "cloudflare", "EnableSsl": false
"Label": "Cloudflare R2", },
"Region": "auto", {
"Bucket": "solar-network", "Id": "cloudflare",
"Endpoint": "0a70a6d1b7128888c823359d0008f4e1.r2.cloudflarestorage.com", "Label": "Cloudflare R2",
"SecretId": "8ff5d06c7b1639829d60bc6838a542e6", "Region": "auto",
"SecretKey": "fd58158c5201be16d1872c9209d9cf199421dae3c2f9972f94b2305976580d67", "Bucket": "solar-network",
"EnableSigned": true, "Endpoint": "0a70a6d1b7128888c823359d0008f4e1.r2.cloudflarestorage.com",
"EnableSsl": true "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,7 +0,0 @@
{
"version": "1.0",
"publicReleaseRefSpec": ["^refs/heads/main$"],
"cloudBuild": {
"setVersionVariables": true
}
}

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:9.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:9.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,18 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery.Yarp" Version="9.5.2" />
<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,13 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"SiteUrl": "http://localhost:3000",
"Client": {
"SomeSetting": "SomeValue"
}
}

View File

@@ -1,3 +1,4 @@
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Design;
@@ -12,6 +13,7 @@ public class AppDatabase(
{ {
public DbSet<SnThinkingSequence> ThinkingSequences { get; set; } public DbSet<SnThinkingSequence> ThinkingSequences { get; set; }
public DbSet<SnThinkingThought> ThinkingThoughts { get; set; } public DbSet<SnThinkingThought> ThinkingThoughts { get; set; }
public DbSet<SnUnpaidAccount> UnpaidAccounts { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
@@ -28,36 +30,15 @@ public class AppDatabase(
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{ {
var now = SystemClock.Instance.GetCurrentInstant(); this.ApplyAuditableAndSoftDelete();
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;
}
}
return await base.SaveChangesAsync(cancellationToken); return await base.SaveChangesAsync(cancellationToken);
} }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
modelBuilder.ApplySoftDeleteFilters();
} }
} }

View File

@@ -1,21 +1,42 @@
using DysonNetwork.Insight.Thought; using DysonNetwork.Insight.Thought;
using DysonNetwork.Shared.Auth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.EntityFrameworkCore;
using DysonNetwork.Shared.Proto;
namespace DysonNetwork.Insight.Controllers; namespace DysonNetwork.Insight.Controllers;
[ApiController] [ApiController]
[Route("/api/billing")] [Route("api/billing")]
public class BillingController(ThoughtService thoughtService, ILogger<BillingController> logger) : ControllerBase public class BillingController(AppDatabase db, ThoughtService thoughtService, ILogger<BillingController> logger)
: ControllerBase
{ {
[HttpPost("settle")] [HttpGet("status")]
[Authorize] public async Task<IActionResult> GetBillingStatus()
[RequiredPermission("maintenance", "insight.billing.settle")]
public async Task<IActionResult> ProcessTokenBilling()
{ {
await thoughtService.SettleThoughtBills(logger); if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Ok(); 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

@@ -1,12 +1,12 @@
#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. #See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
USER app USER app
WORKDIR /app WORKDIR /app
EXPOSE 8080 EXPOSE 8080
EXPOSE 8081 EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
WORKDIR /src WORKDIR /src
COPY ["DysonNetwork.Insight/DysonNetwork.Insight.csproj", "DysonNetwork.Insight/"] COPY ["DysonNetwork.Insight/DysonNetwork.Insight.csproj", "DysonNetwork.Insight/"]

View File

@@ -1,22 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.SemanticKernel" Version="1.66.0" /> <PackageReference Include="Microsoft.SemanticKernel" Version="1.67.1" />
<PackageReference Include="Microsoft.SemanticKernel.Connectors.Ollama" Version="1.66.0-alpha" /> <PackageReference Include="Microsoft.SemanticKernel.Connectors.Ollama" Version="1.66.0-alpha" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="Microsoft.SemanticKernel.Plugins.Web" Version="1.66.0-alpha" /> <PackageReference Include="Microsoft.SemanticKernel.Plugins.Web" Version="1.66.0-alpha" />
<PackageReference Include="Quartz" Version="3.15.1" /> <PackageReference Include="Quartz" Version="3.15.1" />
<PackageReference Include="Quartz.AspNetCore" Version="3.15.1" /> <PackageReference Include="Quartz.AspNetCore" Version="3.15.1" />

View File

@@ -69,11 +69,6 @@ namespace DysonNetwork.Insight.Migrations
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("id"); .HasColumnName("id");
b.Property<List<SnThinkingChunk>>("Chunks")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("chunks");
b.Property<string>("Content") b.Property<string>("Content")
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("content"); .HasColumnName("content");

View File

@@ -12,21 +12,13 @@ namespace DysonNetwork.Insight.Migrations
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<List<SnThinkingChunk>>( // The chunk type has been removed, so this did nothing
name: "chunks",
table: "thinking_thoughts",
type: "jsonb",
nullable: false,
defaultValue: new List<SnThinkingChunk>()
);
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn( // The chunk type has been removed, so this did nothing
name: "chunks",
table: "thinking_thoughts");
} }
} }
} }

View File

@@ -77,11 +77,6 @@ namespace DysonNetwork.Insight.Migrations
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("id"); .HasColumnName("id");
b.Property<List<SnThinkingChunk>>("Chunks")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("chunks");
b.Property<string>("Content") b.Property<string>("Content")
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("content"); .HasColumnName("content");

View File

@@ -0,0 +1,142 @@
// <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("20251115084746_RefactorThoughtMessage")]
partial class RefactorThoughtMessage
{
/// <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.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,30 @@
using System.Collections.Generic;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Insight.Migrations
{
/// <inheritdoc />
public partial class RefactorThoughtMessage : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<List<SnThinkingMessagePart>>(
name: "parts",
table: "thinking_thoughts",
type: "jsonb",
nullable: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "parts",
table: "thinking_thoughts");
}
}
}

View File

@@ -0,0 +1,142 @@
// <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("20251115162347_UpdatedFunctionCallModels")]
partial class UpdatedFunctionCallModels
{
/// <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.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,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Insight.Migrations
{
/// <inheritdoc />
public partial class UpdatedFunctionCallModels : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

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

@@ -20,7 +20,7 @@ namespace DysonNetwork.Insight.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "9.0.10") .HasAnnotation("ProductVersion", "9.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -44,6 +44,10 @@ namespace DysonNetwork.Insight.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at"); .HasColumnName("deleted_at");
b.Property<bool>("IsPublic")
.HasColumnType("boolean")
.HasColumnName("is_public");
b.Property<long>("PaidToken") b.Property<long>("PaidToken")
.HasColumnType("bigint") .HasColumnType("bigint")
.HasColumnName("paid_token"); .HasColumnName("paid_token");
@@ -74,15 +78,6 @@ namespace DysonNetwork.Insight.Migrations
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("id"); .HasColumnName("id");
b.Property<List<SnThinkingChunk>>("Chunks")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("chunks");
b.Property<string>("Content")
.HasColumnType("text")
.HasColumnName("content");
b.Property<Instant>("CreatedAt") b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("created_at"); .HasColumnName("created_at");
@@ -101,6 +96,11 @@ namespace DysonNetwork.Insight.Migrations
.HasColumnType("character varying(4096)") .HasColumnType("character varying(4096)")
.HasColumnName("model_name"); .HasColumnName("model_name");
b.Property<List<SnThinkingMessagePart>>("Parts")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("parts");
b.Property<int>("Role") b.Property<int>("Role")
.HasColumnType("integer") .HasColumnType("integer")
.HasColumnName("role"); .HasColumnName("role");
@@ -126,6 +126,23 @@ namespace DysonNetwork.Insight.Migrations
b.ToTable("thinking_thoughts", (string)null); 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 => modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b =>
{ {
b.HasOne("DysonNetwork.Shared.Models.SnThinkingSequence", "Sequence") b.HasOne("DysonNetwork.Shared.Models.SnThinkingSequence", "Sequence")

View File

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

View File

@@ -1,3 +1,4 @@
using DysonNetwork.Insight.Thought;
using Quartz; using Quartz;
namespace DysonNetwork.Insight.Startup; namespace DysonNetwork.Insight.Startup;

View File

@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DysonNetwork.Insight.Thought; using DysonNetwork.Insight.Thought;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Registry;
using Microsoft.SemanticKernel; using Microsoft.SemanticKernel;
using NodaTime; using NodaTime;
using NodaTime.Serialization.SystemTextJson; using NodaTime.Serialization.SystemTextJson;
@@ -13,9 +14,7 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddAppServices(this IServiceCollection services) public static IServiceCollection AddAppServices(this IServiceCollection services)
{ {
services.AddDbContext<AppDatabase>(); services.AddDbContext<AppDatabase>();
services.AddSingleton<IClock>(SystemClock.Instance);
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddSingleton<ICacheService, CacheServiceRedis>();
services.AddHttpClient(); services.AddHttpClient();
@@ -66,14 +65,6 @@ public static class ServiceCollectionExtensions
services.AddSingleton<ThoughtProvider>(); services.AddSingleton<ThoughtProvider>();
services.AddScoped<ThoughtService>(); services.AddScoped<ThoughtService>();
// Add gRPC clients for ThoughtService
services.AddGrpcClient<Shared.Proto.PaymentService.PaymentServiceClient>(o => o.Address = new Uri("https://_grpc.pass"))
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true });
services.AddGrpcClient<Shared.Proto.WalletService.WalletServiceClient>(o => o.Address = new Uri("https://_grpc.pass"))
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true });
return services; return services;
} }
} }

View File

@@ -0,0 +1,155 @@
# Client-Side Guide: Handling the New Message Structure
This document outlines how to update your client application to support the new rich message structure for the thinking/chat feature. The backend now sends structured messages that can include plain text, function calls, and function results, allowing for a more interactive and transparent user experience.
When using with gateway, all the response type are in snake case
## 1. Data Models
When you receive a complete message (a "thought"), it will be in the form of an `SnThinkingThought` object. The core of this object is the `Parts` array, which contains the different components of the message.
Here are the primary data models you will be working with, represented here in a TypeScript-like format for clarity:
```typescript
// The main message object from the assistant or user
interface SnThinkingThought {
id: string;
parts: SnThinkingMessagePart[];
role: 'Assistant' /*Value is (0)*/ | 'User' /*Value is (1)*/;
createdAt: string; // ISO 8601 date string
// ... other metadata
}
// A single part of a message
interface SnThinkingMessagePart {
type: ThinkingMessagePartType;
text?: string;
functionCall?: SnFunctionCall;
functionResult?: SnFunctionResult;
}
// Enum for the different part types
enum ThinkingMessagePartType {
Text = 0,
FunctionCall = 1,
FunctionResult = 2,
}
// Represents a function/tool call made by the assistant
interface SnFunctionCall {
id: string;
name: string;
arguments: string; // A JSON string of the arguments
}
// Represents the result of a function call
interface SnFunctionResult {
callId: string; // The ID of the corresponding function call
result: any; // The data returned by the function
isError: boolean;
}
```
## 2. Handling the SSE Stream
The response is streamed using Server-Sent Events (SSE). Your client should listen to this stream and process events as they arrive to build the UI in real-time.
The stream sends different types of messages, identified by a `type` field in the JSON payload.
| Event Type | `data` Payload | Client-Side Action |
| ------------------------ | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `text` | `{ "type": "text", "data": "some text" }` | Append the text content to the current message being displayed. This is the most common event. |
| `function_call_update` | `{ "type": "function_call_update", "data": { ... } }` | This provides real-time updates as the AI decides on a function call. You can use this to show an advanced "thinking" state, but it's optional. The key events to handle are `function_call` and `function_result`. |
| `function_call` | `{ "type": "function_call", "data": SnFunctionCall }` | The AI has committed to using a tool. Display a "Using tool..." indicator. You can show the `name` of the tool for more clarity. |
| `function_result` | `{ "type": "function_result", "data": SnFunctionResult }` | The tool has finished running. You can hide the "thinking" indicator for this tool and optionally display a summary of the result. |
| `topic` | `{ "type": "topic", "data": "A new topic" }` | If this is the first message in a new conversation, this event provides the auto-generated topic title. Update your UI accordingly. |
| `thought` | `{ "type": "thought", "data": SnThinkingThought }` | This is the **final event** in the stream. It contains the complete, persisted message object with all its `Parts`. You should use this final object to replace the incrementally-built message in your state to ensure consistency. |
## 3. Rendering a Message from `SnThinkingThought`
Once you have the final `SnThinkingThought` object (either from the `thought` event in the stream or by fetching conversation history), you can render it by iterating through the `parts` array.
### Pseudocode for Rendering
```javascript
function renderThought(thought: SnThinkingThought) {
const messageContainer = document.createElement('div');
messageContainer.className = `message message-role-${thought.role}`;
// User messages are simple and will only have one text part
if (thought.role === 'User') {
const textPart = thought.parts[0];
messageContainer.innerText = textPart.text;
return messageContainer;
}
// Assistant messages can have multiple parts
let textBuffer = '';
thought.parts.forEach(part => {
switch (part.type) {
case ThinkingMessagePartType.Text:
// Buffer text to combine consecutive text parts
textBuffer += part.text;
break;
case ThinkingMessagePartType.FunctionCall:
// First, render any buffered text
if (textBuffer) {
messageContainer.appendChild(renderText(textBuffer));
textBuffer = '';
}
// Then, render the function call UI component
messageContainer.appendChild(renderFunctionCall(part.functionCall));
break;
case ThinkingMessagePartType.FunctionResult:
// Render buffered text
if (textBuffer) {
messageContainer.appendChild(renderText(textBuffer));
textBuffer = '';
}
// Then, render the function result UI component
messageContainer.appendChild(renderFunctionResult(part.functionResult));
break;
}
});
// Render any remaining text at the end
if (textBuffer) {
messageContainer.appendChild(renderText(textBuffer));
}
return messageContainer;
}
// Helper functions to create UI components
function renderText(text) {
const p = document.createElement('p');
p.innerText = text;
return p;
}
function renderFunctionCall(functionCall) {
const el = document.createElement('div');
el.className = 'function-call-indicator';
el.innerHTML = `<i>Using tool: <strong>${functionCall.name}</strong>...</i>`;
// You could add a button to show functionCall.arguments
return el;
}
function renderFunctionResult(functionResult) {
const el = document.createElement('div');
el.className = 'function-result-indicator';
if (functionResult.isError) {
el.classList.add('error');
el.innerText = 'An error occurred while using the tool.';
} else {
el.innerText = 'Tool finished.';
}
// You could expand this to show a summary of functionResult.result
return el;
}
```
This approach ensures that text and tool-use indicators are rendered inline and in the correct order, providing a clear and accurate representation of the assistant's actions.

View File

@@ -0,0 +1,29 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.IdentityModel.Tokens;
using Microsoft.SemanticKernel;
namespace DysonNetwork.Insight.Thought.Plugins;
public class SnAccountKernelPlugin(
AccountService.AccountServiceClient accountClient
)
{
[KernelFunction("get_account")]
public async Task<SnAccount?> GetAccount(string userId)
{
var request = new GetAccountRequest { Id = userId };
var response = await accountClient.GetAccountAsync(request);
if (response is null) return null;
return SnAccount.FromProtoValue(response);
}
[KernelFunction("get_account_by_name")]
public async Task<SnAccount?> GetAccountByName(string username)
{
var request = new LookupAccountBatchRequest();
request.Names.Add(username);
var response = await accountClient.LookupAccountBatchAsync(request);
return response.Accounts.IsNullOrEmpty() ? null : SnAccount.FromProtoValue(response.Accounts[0]);
}
}

View File

@@ -0,0 +1,98 @@
using System.ComponentModel;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.SemanticKernel;
using NodaTime;
using NodaTime.Serialization.Protobuf;
using NodaTime.Text;
namespace DysonNetwork.Insight.Thought.Plugins;
public class SnPostKernelPlugin(
PostService.PostServiceClient postClient
)
{
[KernelFunction("get_post")]
public async Task<SnPost?> GetPost(string postId)
{
var request = new GetPostRequest { Id = postId };
var response = await postClient.GetPostAsync(request);
return response is null ? null : SnPost.FromProtoValue(response);
}
[KernelFunction("search_posts")]
[Description("Perform a full-text search in all Solar Network posts.")]
public async Task<List<SnPost>> SearchPostsContent(string contentQuery, int pageSize = 10, int page = 1)
{
var request = new SearchPostsRequest
{
Query = contentQuery,
PageSize = pageSize,
PageToken = ((page - 1) * pageSize).ToString()
};
var response = await postClient.SearchPostsAsync(request);
return response.Posts.Select(SnPost.FromProtoValue).ToList();
}
public class KernelPostListResult
{
public List<SnPost> Posts { get; set; } = [];
public int TotalCount { get; set; }
}
[KernelFunction("list_posts")]
[Description("List all posts on the Solar Network without filters, orderBy can be date or popularity")]
public async Task<KernelPostListResult> ListPosts(
string orderBy = "date",
bool orderDesc = true,
int pageSize = 10,
int page = 1
)
{
var request = new ListPostsRequest
{
OrderBy = orderBy,
OrderDesc = orderDesc,
PageSize = pageSize,
PageToken = ((page - 1) * pageSize).ToString()
};
var response = await postClient.ListPostsAsync(request);
return new KernelPostListResult
{
Posts = response.Posts.Select(SnPost.FromProtoValue).ToList(),
TotalCount = response.TotalSize,
};
}
[KernelFunction("list_posts_within_time")]
[Description(
"List posts in a period of time, the time requires ISO-8601 format, one of the start and end must be provided.")]
public async Task<KernelPostListResult> ListPostsWithinTime(
string? beforeTime,
string? afterTime,
int pageSize = 10,
int page = 1
)
{
var pattern = InstantPattern.General;
Instant? before = !string.IsNullOrWhiteSpace(beforeTime)
? pattern.Parse(beforeTime).TryGetValue(default, out var beforeValue) ? beforeValue : null
: null;
Instant? after = !string.IsNullOrWhiteSpace(afterTime)
? pattern.Parse(afterTime).TryGetValue(default, out var afterValue) ? afterValue : null
: null;
var request = new ListPostsRequest
{
After = after?.ToTimestamp(),
Before = before?.ToTimestamp(),
PageSize = pageSize,
PageToken = ((page - 1) * pageSize).ToString()
};
var response = await postClient.ListPostsAsync(request);
return new KernelPostListResult
{
Posts = response.Posts.Select(SnPost.FromProtoValue).ToList(),
TotalCount = response.TotalSize,
};
}
}

View File

@@ -1,15 +1,13 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.SemanticKernel; using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel.Connectors.Ollama;
namespace DysonNetwork.Insight.Thought; namespace DysonNetwork.Insight.Thought;
@@ -22,12 +20,50 @@ public class ThoughtController(ThoughtProvider provider, ThoughtService service)
public class StreamThinkingRequest public class StreamThinkingRequest
{ {
[Required] public string UserMessage { get; set; } = null!; [Required] public string UserMessage { get; set; } = null!;
public string? ServiceId { get; set; }
public Guid? SequenceId { 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<Dictionary<string, dynamic>>? AttachedMessages { get; set; }
public List<string> AcceptProposals { 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] [HttpPost]
[Experimental("SKEXP0110")] [Experimental("SKEXP0110")]
public async Task<ActionResult> Think([FromBody] StreamThinkingRequest request) public async Task<ActionResult> Think([FromBody] StreamThinkingRequest request)
@@ -38,6 +74,25 @@ public class ThoughtController(ThoughtProvider provider, ThoughtService service)
if (request.AcceptProposals.Any(e => !AvailableProposals.Contains(e))) if (request.AcceptProposals.Any(e => !AvailableProposals.Contains(e)))
return BadRequest("Request contains unavailable proposal"); 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 // Generate a topic if creating a new sequence
string? topic = null; string? topic = null;
if (!request.SequenceId.HasValue) if (!request.SequenceId.HasValue)
@@ -49,7 +104,13 @@ public class ThoughtController(ThoughtProvider provider, ThoughtService service)
); );
summaryHistory.AddUserMessage(request.UserMessage); 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>() .GetRequiredService<IChatCompletionService>()
.GetChatMessageContentAsync(summaryHistory); .GetChatMessageContentAsync(summaryHistory);
@@ -61,7 +122,13 @@ public class ThoughtController(ThoughtProvider provider, ThoughtService service)
if (sequence == null) return Forbid(); // or NotFound if (sequence == null) return Forbid(); // or NotFound
// Save user thought // Save user thought
await service.SaveThoughtAsync(sequence, request.UserMessage, ThinkingThoughtRole.User); await service.SaveThoughtAsync(sequence, [
new SnThinkingMessagePart
{
Type = ThinkingMessagePartType.Text,
Text = request.UserMessage
}
], ThinkingThoughtRole.User);
// Build chat history // Build chat history
var chatHistory = new ChatHistory( var chatHistory = new ChatHistory(
@@ -108,19 +175,71 @@ public class ThoughtController(ThoughtProvider provider, ThoughtService service)
// Add previous thoughts (excluding the current user thought, which is the first one since descending) // Add previous thoughts (excluding the current user thought, which is the first one since descending)
var previousThoughts = await service.GetPreviousThoughtsAsync(sequence); var previousThoughts = await service.GetPreviousThoughtsAsync(sequence);
var count = previousThoughts.Count; var count = previousThoughts.Count;
for (var i = 1; i < count; i++) // skip first (the newest, current user) for (var i = count - 1; i >= 1; i--) // skip first (the newest, current user)
{ {
var thought = previousThoughts[i]; var thought = previousThoughts[i];
switch (thought.Role) var textContent = new StringBuilder();
var functionCalls = new List<FunctionCallContent>();
var functionResults = new List<FunctionResultContent>();
foreach (var part in thought.Parts)
{ {
case ThinkingThoughtRole.User: switch (part.Type)
chatHistory.AddUserMessage(thought.Content ?? ""); {
break; case ThinkingMessagePartType.Text:
case ThinkingThoughtRole.Assistant: textContent.Append(part.Text);
chatHistory.AddAssistantMessage(thought.Content ?? ""); break;
break; case ThinkingMessagePartType.FunctionCall:
default: var arguments = !string.IsNullOrEmpty(part.FunctionCall!.Arguments)
throw new ArgumentOutOfRangeException(); ? JsonSerializer.Deserialize<Dictionary<string, object?>>(part.FunctionCall!.Arguments)
: null;
var kernelArgs = arguments is not null ? new KernelArguments(arguments) : null;
functionCalls.Add(new FunctionCallContent(
functionName: part.FunctionCall!.Name,
pluginName: part.FunctionCall.PluginName,
id: part.FunctionCall.Id,
arguments: kernelArgs
));
break;
case ThinkingMessagePartType.FunctionResult:
var resultObject = part.FunctionResult!.Result;
var resultString = resultObject as string ?? JsonSerializer.Serialize(resultObject);
functionResults.Add(new FunctionResultContent(
callId: part.FunctionResult.CallId,
functionName: part.FunctionResult.FunctionName,
pluginName: part.FunctionResult.PluginName,
result: resultString
));
break;
default:
throw new ArgumentOutOfRangeException();
}
}
if (thought.Role == ThinkingThoughtRole.User)
{
chatHistory.AddUserMessage(textContent.ToString());
}
else
{
var assistantMessage = new ChatMessageContent(AuthorRole.Assistant, textContent.ToString());
if (functionCalls.Count > 0)
{
assistantMessage.Items = [];
foreach (var fc in functionCalls)
{
assistantMessage.Items.Add(fc);
}
}
chatHistory.Add(assistantMessage);
if (functionResults.Count <= 0) continue;
foreach (var fr in functionResults)
{
chatHistory.Add(fr.ToChatMessage());
}
} }
} }
@@ -130,75 +249,118 @@ public class ThoughtController(ThoughtProvider provider, ThoughtService service)
Response.Headers.Append("Content-Type", "text/event-stream"); Response.Headers.Append("Content-Type", "text/event-stream");
Response.StatusCode = 200; Response.StatusCode = 200;
var kernel = provider.Kernel;
var chatCompletionService = kernel.GetRequiredService<IChatCompletionService>(); var chatCompletionService = kernel.GetRequiredService<IChatCompletionService>();
var executionSettings = provider.CreatePromptExecutionSettings(request.ServiceId);
// Kick off streaming generation var assistantParts = new List<SnThinkingMessagePart>();
var accumulatedContent = new StringBuilder();
var thinkingChunks = new List<SnThinkingChunk>(); while (true)
await foreach (var chunk in chatCompletionService.GetStreamingChatMessageContentsAsync(
chatHistory,
provider.CreatePromptExecutionSettings(),
kernel: kernel
))
{ {
// Process each item in the chunk for detailed streaming var textContentBuilder = new StringBuilder();
foreach (var item in chunk.Items) AuthorRole? authorRole = null;
var functionCallBuilder = new FunctionCallContentBuilder();
await foreach (
var streamingContent in chatCompletionService.GetStreamingChatMessageContentsAsync(
chatHistory, executionSettings, kernel)
)
{ {
var streamingChunk = item switch authorRole ??= streamingContent.Role;
if (streamingContent.Content is not null)
{ {
StreamingTextContent textContent => new SnThinkingChunk textContentBuilder.Append(streamingContent.Content);
{ Type = StreamingContentType.Text, Data = new() { ["text"] = textContent.Text ?? "" } }, var messageJson = JsonSerializer.Serialize(new
StreamingReasoningContent reasoningContent => new SnThinkingChunk { type = "text", data = streamingContent.Content });
{ await Response.Body.WriteAsync(Encoding.UTF8.GetBytes($"data: {messageJson}\n\n"));
Type = StreamingContentType.Reasoning, Data = new() { ["text"] = reasoningContent.Text } await Response.Body.FlushAsync();
}, }
StreamingFunctionCallUpdateContent functionCall => string.IsNullOrEmpty(functionCall.CallId)
? null
: new SnThinkingChunk
{
Type = StreamingContentType.FunctionCall,
Data = JsonSerializer.Deserialize<Dictionary<string, object>>(
JsonSerializer.Serialize(functionCall)) ?? new Dictionary<string, object>()
},
_ => new SnThinkingChunk
{
Type = StreamingContentType.Unknown, Data = new() { ["data"] = JsonSerializer.Serialize(item) }
}
};
if (streamingChunk == null) continue;
thinkingChunks.Add(streamingChunk); functionCallBuilder.Append(streamingContent);
var messageJson = item switch
{
StreamingTextContent textContent =>
JsonSerializer.Serialize(new { type = "text", data = textContent.Text ?? "" }),
StreamingReasoningContent reasoningContent =>
JsonSerializer.Serialize(new { type = "reasoning", data = reasoningContent.Text }),
StreamingFunctionCallUpdateContent functionCall =>
JsonSerializer.Serialize(new { type = "function_call", data = functionCall }),
_ =>
JsonSerializer.Serialize(new { type = "unknown", data = item })
};
// Write a structured JSON message to the HTTP response as SSE
var messageBytes = Encoding.UTF8.GetBytes($"data: {messageJson}\n\n");
await Response.Body.WriteAsync(messageBytes);
await Response.Body.FlushAsync();
} }
// Accumulate content for saving (only text content) var finalMessageText = textContentBuilder.ToString();
accumulatedContent.Append(chunk.Content ?? ""); if (!string.IsNullOrEmpty(finalMessageText))
{
assistantParts.Add(new SnThinkingMessagePart
{ Type = ThinkingMessagePartType.Text, Text = finalMessageText });
}
var functionCalls = functionCallBuilder.Build()
.Where(fc => !string.IsNullOrEmpty(fc.Id)).ToList();
if (functionCalls.Count == 0)
break;
var assistantMessage = new ChatMessageContent(
authorRole ?? AuthorRole.Assistant,
string.IsNullOrEmpty(finalMessageText) ? null : finalMessageText
);
foreach (var functionCall in functionCalls)
{
assistantMessage.Items.Add(functionCall);
}
chatHistory.Add(assistantMessage);
foreach (var functionCall in functionCalls)
{
var part = new SnThinkingMessagePart
{
Type = ThinkingMessagePartType.FunctionCall,
FunctionCall = new SnFunctionCall
{
Id = functionCall.Id!,
PluginName = functionCall.PluginName,
Name = functionCall.FunctionName,
Arguments = JsonSerializer.Serialize(functionCall.Arguments)
}
};
assistantParts.Add(part);
var messageJson = JsonSerializer.Serialize(new { type = "function_call", data = part.FunctionCall });
await Response.Body.WriteAsync(Encoding.UTF8.GetBytes($"data: {messageJson}\n\n"));
await Response.Body.FlushAsync();
FunctionResultContent resultContent;
try
{
resultContent = await functionCall.InvokeAsync(kernel);
}
catch (Exception ex)
{
resultContent = new FunctionResultContent(functionCall.Id!, ex.Message);
}
chatHistory.Add(resultContent.ToChatMessage());
var resultPart = new SnThinkingMessagePart
{
Type = ThinkingMessagePartType.FunctionResult,
FunctionResult = new SnFunctionResult
{
CallId = resultContent.CallId!,
PluginName = resultContent.PluginName,
FunctionName = resultContent.FunctionName,
Result = resultContent.Result!,
IsError = resultContent.Result is Exception
}
};
assistantParts.Add(resultPart);
var resultMessageJson =
JsonSerializer.Serialize(new { type = "function_result", data = resultPart.FunctionResult });
await Response.Body.WriteAsync(Encoding.UTF8.GetBytes($"data: {resultMessageJson}\n\n"));
await Response.Body.FlushAsync();
}
} }
// Save assistant thought // Save assistant thought
var savedThought = await service.SaveThoughtAsync( var savedThought = await service.SaveThoughtAsync(
sequence, sequence,
accumulatedContent.ToString(), assistantParts,
ThinkingThoughtRole.Assistant, ThinkingThoughtRole.Assistant,
thinkingChunks, serviceId
provider.ModelDefault
); );
// Write the topic if it was newly set, then the thought object as JSON to the stream // Write the topic if it was newly set, then the thought object as JSON to the stream
@@ -209,7 +371,6 @@ public class ThoughtController(ThoughtProvider provider, ThoughtService service)
{ {
var topicJson = JsonSerializer.Serialize(new { type = "topic", data = sequence.Topic ?? "" }); var topicJson = JsonSerializer.Serialize(new { type = "topic", data = sequence.Topic ?? "" });
await streamBuilder.WriteAsync(Encoding.UTF8.GetBytes($"topic: {topicJson}\n\n")); await streamBuilder.WriteAsync(Encoding.UTF8.GetBytes($"topic: {topicJson}\n\n"));
savedThought.Sequence.Topic = topic;
} }
var thoughtJson = JsonSerializer.Serialize(new { type = "thought", data = savedThought }, var thoughtJson = JsonSerializer.Serialize(new { type = "thought", data = savedThought },
@@ -250,6 +411,25 @@ public class ThoughtController(ThoughtProvider provider, ThoughtService service)
return Ok(sequences); 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> /// <summary>
/// Retrieves the thoughts in a specific thinking sequence. /// Retrieves the thoughts in a specific thinking sequence.
/// </summary> /// </summary>
@@ -262,12 +442,18 @@ public class ThoughtController(ThoughtProvider provider, ThoughtService service)
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<List<SnThinkingThought>>> GetSequenceThoughts(Guid sequenceId) public async Task<ActionResult<List<SnThinkingThought>>> GetSequenceThoughts(Guid sequenceId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); var sequence = await service.GetSequenceAsync(sequenceId);
var accountId = Guid.Parse(currentUser.Id);
var sequence = await service.GetOrCreateSequenceAsync(accountId, sequenceId);
if (sequence == null) return NotFound(); 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); var thoughts = await service.GetPreviousThoughtsAsync(sequence);
return Ok(thoughts); return Ok(thoughts);

View File

@@ -1,158 +1,126 @@
using System.ClientModel; using System.ClientModel;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Text.Json; using DysonNetwork.Insight.Thought.Plugins;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using Microsoft.SemanticKernel; using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.Ollama; using Microsoft.SemanticKernel.Connectors.Ollama;
using Microsoft.SemanticKernel.Connectors.OpenAI; using Microsoft.SemanticKernel.Connectors.OpenAI;
using OpenAI; using OpenAI;
using PostType = DysonNetwork.Shared.Proto.PostType;
using Microsoft.SemanticKernel.Plugins.Web; using Microsoft.SemanticKernel.Plugins.Web;
using Microsoft.SemanticKernel.Plugins.Web.Bing; using Microsoft.SemanticKernel.Plugins.Web.Bing;
using Microsoft.SemanticKernel.Plugins.Web.Google; using Microsoft.SemanticKernel.Plugins.Web.Google;
using NodaTime.Serialization.Protobuf;
using NodaTime.Text;
namespace DysonNetwork.Insight.Thought; 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 public class ThoughtProvider
{ {
private readonly PostService.PostServiceClient _postClient; private readonly PostService.PostServiceClient _postClient;
private readonly AccountService.AccountServiceClient _accountClient; private readonly AccountService.AccountServiceClient _accountClient;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly ILogger<ThoughtProvider> _logger;
public Kernel Kernel { get; } private readonly Dictionary<string, Kernel> _kernels = new();
private readonly Dictionary<string, string> _serviceProviders = new();
private string? ModelProviderType { get; set; } private readonly Dictionary<string, ThoughtServiceModel> _serviceModels = new();
public string? ModelDefault { get; set; } private readonly string _defaultServiceId;
[Experimental("SKEXP0050")] [Experimental("SKEXP0050")]
public ThoughtProvider( public ThoughtProvider(
IConfiguration configuration, IConfiguration configuration,
PostService.PostServiceClient postServiceClient, PostService.PostServiceClient postServiceClient,
AccountService.AccountServiceClient accountServiceClient, AccountService.AccountServiceClient accountServiceClient
ILogger<ThoughtProvider> logger
) )
{ {
_logger = logger;
_postClient = postServiceClient; _postClient = postServiceClient;
_accountClient = accountServiceClient; _accountClient = accountServiceClient;
_configuration = configuration; _configuration = configuration;
Kernel = InitializeThinkingProvider(configuration); var cfg = configuration.GetSection("Thinking");
InitializeHelperFunctions(); _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"); var providerType = serviceConfig.GetValue<string>("Provider")?.ToLower();
ModelProviderType = cfg.GetValue<string>("Provider")?.ToLower(); var model = serviceConfig.GetValue<string>("Model");
ModelDefault = cfg.GetValue<string>("Model"); var endpoint = serviceConfig.GetValue<string>("Endpoint");
var endpoint = cfg.GetValue<string>("Endpoint"); var apiKey = serviceConfig.GetValue<string>("ApiKey");
var apiKey = cfg.GetValue<string>("ApiKey");
var builder = Kernel.CreateBuilder(); var builder = Kernel.CreateBuilder();
switch (ModelProviderType) switch (providerType)
{ {
case "ollama": case "ollama":
builder.AddOllamaChatCompletion(ModelDefault!, new Uri(endpoint ?? "http://localhost:11434/api")); builder.AddOllamaChatCompletion(
model!,
new Uri(endpoint ?? "http://localhost:11434/api")
);
break; break;
case "deepseek": case "deepseek":
var client = new OpenAIClient( var client = new OpenAIClient(
new ApiKeyCredential(apiKey!), new ApiKeyCredential(apiKey!),
new OpenAIClientOptions { Endpoint = new Uri(endpoint ?? "https://api.deepseek.com/v1") } new OpenAIClientOptions { Endpoint = new Uri(endpoint ?? "https://api.deepseek.com/v1") }
); );
builder.AddOpenAIChatCompletion(ModelDefault!, client); builder.AddOpenAIChatCompletion(model!, client);
break; break;
default: default:
throw new IndexOutOfRangeException("Unknown thinking provider: " + ModelProviderType); throw new IndexOutOfRangeException("Unknown thinking provider: " + providerType);
} }
// Add gRPC clients for Thought Plugins
builder.Services.AddServiceDiscoveryCore();
builder.Services.AddServiceDiscovery();
builder.Services.AddAccountService();
builder.Services.AddSphereService();
builder.Plugins.AddFromObject(new SnAccountKernelPlugin(_accountClient));
builder.Plugins.AddFromObject(new SnPostKernelPlugin(_postClient));
return builder.Build(); return builder.Build();
} }
[Experimental("SKEXP0050")] [Experimental("SKEXP0050")]
private void InitializeHelperFunctions() private void InitializeHelperFunctions(Kernel kernel)
{ {
// Add Solar Network tools plugin
Kernel.ImportPluginFromFunctions("solar_network", [
KernelFunctionFactory.CreateFromMethod(async (string userId) =>
{
var request = new GetAccountRequest { Id = userId };
var response = await _accountClient.GetAccountAsync(request);
return JsonSerializer.Serialize(response, GrpcTypeHelper.SerializerOptions);
}, "get_user", "Get a user profile from the Solar Network."),
KernelFunctionFactory.CreateFromMethod(async (string postId) =>
{
var request = new GetPostRequest { Id = postId };
var response = await _postClient.GetPostAsync(request);
return JsonSerializer.Serialize(response, GrpcTypeHelper.SerializerOptions);
}, "get_post", "Get a single post by ID from the Solar Network."),
KernelFunctionFactory.CreateFromMethod(async (string query) =>
{
var request = new SearchPostsRequest { Query = query, PageSize = 10 };
var response = await _postClient.SearchPostsAsync(request);
return JsonSerializer.Serialize(response.Posts, GrpcTypeHelper.SerializerOptions);
}, "search_posts",
"Search posts by query from the Solar Network. The input query is will be used to search with title, description and body content"),
KernelFunctionFactory.CreateFromMethod(async (
string? orderBy = null,
string? afterIso = null,
string? beforeIso = null
) =>
{
_logger.LogInformation("Begin building request to list post from sphere...");
var request = new ListPostsRequest
{
PageSize = 20,
OrderBy = orderBy,
};
if (!string.IsNullOrEmpty(afterIso))
try
{
request.After = InstantPattern.General.Parse(afterIso).Value.ToTimestamp();
}
catch (Exception)
{
_logger.LogWarning("Invalid afterIso format: {AfterIso}", afterIso);
}
if (!string.IsNullOrEmpty(beforeIso))
try
{
request.Before = InstantPattern.General.Parse(beforeIso).Value.ToTimestamp();
}
catch (Exception)
{
_logger.LogWarning("Invalid beforeIso format: {BeforeIso}", beforeIso);
}
_logger.LogInformation("Request built, {Request}", request);
var response = await _postClient.ListPostsAsync(request);
var data = response.Posts.Select(SnPost.FromProtoValue);
_logger.LogInformation("Sphere service returned posts: {Posts}", data);
return JsonSerializer.Serialize(data, GrpcTypeHelper.SerializerOptions);
}, "list_posts",
"Get posts from the Solar Network.\n" +
"Parameters:\n" +
"orderBy (optional, string: order by published date, accept asc or desc)\n" +
"afterIso (optional, string: ISO date for posts after this date)\n" +
"beforeIso (optional, string: ISO date for posts before this date)"
)
]);
// Add web search plugins if configured // Add web search plugins if configured
var bingApiKey = _configuration.GetValue<string>("Thinking:BingApiKey"); var bingApiKey = _configuration.GetValue<string>("Thinking:BingApiKey");
if (!string.IsNullOrEmpty(bingApiKey)) if (!string.IsNullOrEmpty(bingApiKey))
{ {
var bingConnector = new BingConnector(bingApiKey); var bingConnector = new BingConnector(bingApiKey);
var bing = new WebSearchEnginePlugin(bingConnector); var bing = new WebSearchEnginePlugin(bingConnector);
Kernel.ImportPluginFromObject(bing, "bing"); kernel.ImportPluginFromObject(bing, "bing");
} }
var googleApiKey = _configuration.GetValue<string>("Thinking:GoogleApiKey"); var googleApiKey = _configuration.GetValue<string>("Thinking:GoogleApiKey");
@@ -163,36 +131,58 @@ public class ThoughtProvider
apiKey: googleApiKey, apiKey: googleApiKey,
searchEngineId: googleCx); searchEngineId: googleCx);
var google = new WebSearchEnginePlugin(googleConnector); 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": "ollama" => new OllamaPromptExecutionSettings
return new OllamaPromptExecutionSettings {
{ FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(autoInvoke: false)
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto( },
options: new FunctionChoiceBehaviorOptions "deepseek" => new OpenAIPromptExecutionSettings
{ {
AllowParallelCalls = true, FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(autoInvoke: false), ModelId = serviceId
AllowConcurrentInvocation = true },
}) _ => throw new InvalidOperationException("Unknown provider for service: " + serviceId)
}; };
case "deepseek":
return new OpenAIPromptExecutionSettings
{
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(
options: new FunctionChoiceBehaviorOptions
{
AllowParallelCalls = true,
AllowConcurrentInvocation = true
})
};
default:
throw new InvalidOperationException("Unknown provider: " + ModelProviderType);
}
} }
} }

View File

@@ -11,8 +11,7 @@ namespace DysonNetwork.Insight.Thought;
public class ThoughtService( public class ThoughtService(
AppDatabase db, AppDatabase db,
ICacheService cache, ICacheService cache,
PaymentService.PaymentServiceClient paymentService, PaymentService.PaymentServiceClient paymentService
WalletService.WalletServiceClient walletService
) )
{ {
public async Task<SnThinkingSequence?> GetOrCreateSequenceAsync( public async Task<SnThinkingSequence?> GetOrCreateSequenceAsync(
@@ -37,25 +36,38 @@ 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( public async Task<SnThinkingThought> SaveThoughtAsync(
SnThinkingSequence sequence, SnThinkingSequence sequence,
string content, List<SnThinkingMessagePart> parts,
ThinkingThoughtRole role, ThinkingThoughtRole role,
List<SnThinkingChunk>? chunks = null,
string? model = null string? model = null
) )
{ {
// Approximate token count (1 token ≈ 4 characters for GPT-like models) // Approximate token count (1 token ≈ 4 characters for GPT-like models)
var tokenCount = content?.Length / 4 ?? 0; var totalChars = parts.Sum(part =>
(part.Type == ThinkingMessagePartType.Text ? part.Text?.Length : 0) ?? 0 +
(part.Type == ThinkingMessagePartType.FunctionCall ? part.FunctionCall?.Arguments.Length : 0) ?? 0
);
var tokenCount = totalChars / 4;
var thought = new SnThinkingThought var thought = new SnThinkingThought
{ {
SequenceId = sequence.Id, SequenceId = sequence.Id,
Content = content, Parts = parts,
Role = role, Role = role,
TokenCount = tokenCount, TokenCount = tokenCount,
ModelName = model, ModelName = model,
Chunks = chunks ?? new List<SnThinkingChunk>(),
}; };
db.ThinkingThoughts.Add(thought); db.ThinkingThoughts.Add(thought);
@@ -70,7 +82,6 @@ public class ThoughtService(
return thought; return thought;
} }
public async Task<List<SnThinkingThought>> GetPreviousThoughtsAsync(SnThinkingSequence sequence) public async Task<List<SnThinkingThought>> GetPreviousThoughtsAsync(SnThinkingSequence sequence)
{ {
var cacheKey = $"thoughts:{sequence.Id}"; var cacheKey = $"thoughts:{sequence.Id}";
@@ -133,6 +144,13 @@ public class ThoughtService(
foreach (var accountGroup in groupedByAccount) foreach (var accountGroup in groupedByAccount)
{ {
var accountId = accountGroup.Key; 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 totalUnpaidTokens = accountGroup.Sum(s => s.TotalToken - s.PaidToken);
var cost = (long)Math.Ceiling(totalUnpaidTokens / 10.0); var cost = (long)Math.Ceiling(totalUnpaidTokens / 10.0);
@@ -166,9 +184,86 @@ public class ThoughtService(
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error billing for account {accountId}", accountId); 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(); 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

@@ -1,7 +1,6 @@
using DysonNetwork.Insight.Thought;
using Quartz; using Quartz;
namespace DysonNetwork.Insight.Startup; namespace DysonNetwork.Insight.Thought;
public class TokenBillingJob(ThoughtService thoughtService, ILogger<TokenBillingJob> logger) : IJob public class TokenBillingJob(ThoughtService thoughtService, ILogger<TokenBillingJob> logger) : IJob
{ {

View File

@@ -10,7 +10,10 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": { "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": [ "KnownProxies": [
"127.0.0.1", "127.0.0.1",
@@ -19,9 +22,26 @@
"Etcd": { "Etcd": {
"Insecure": true "Insecure": true
}, },
"Cache": {
"Serializer": "MessagePack"
},
"Thinking": { "Thinking": {
"Provider": "deepseek", "DefaultService": "deepseek-chat",
"Model": "deepseek-chat", "Services": {
"ApiKey": "sk-bd20f6a2e9fa40b98c46899baa0e9f09" "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 System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Affiliation;
using DysonNetwork.Pass.Auth; using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Credit; using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Permission; using DysonNetwork.Pass.Permission;
using DysonNetwork.Pass.Wallet; using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.GeoIp; using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Geometry;
using DysonNetwork.Shared.Http; using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -22,7 +24,8 @@ public class AccountController(
SubscriptionService subscriptions, SubscriptionService subscriptions,
AccountEventService events, AccountEventService events,
SocialCreditService socialCreditService, SocialCreditService socialCreditService,
GeoIpService geo AffiliationSpellService ars,
GeoService geo
) : ControllerBase ) : ControllerBase
{ {
[HttpGet("{name}")] [HttpGet("{name}")]
@@ -34,7 +37,7 @@ public class AccountController(
.Include(e => e.Badges) .Include(e => e.Badges)
.Include(e => e.Profile) .Include(e => e.Profile)
.Include(e => e.Contacts.Where(c => c.IsPublic)) .Include(e => e.Contacts.Where(c => c.IsPublic))
.Where(a => a.Name == name) .Where(a => EF.Functions.Like(a.Name, name))
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (account is null) return NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier)); if (account is null) return NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier));
@@ -103,6 +106,52 @@ public class AccountController(
[MaxLength(32)] public string Language { get; set; } = "en-us"; [MaxLength(32)] public string Language { get; set; } = "en-us";
[Required] public string CaptchaToken { get; set; } = string.Empty; [Required] public string CaptchaToken { get; set; } = string.Empty;
public string? AffiliationSpell { get; set; }
}
public class AccountCreateValidateRequest
{
[MinLength(2)]
[MaxLength(256)]
[RegularExpression(@"^[A-Za-z0-9_-]+$",
ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.")
]
public string? Name { get; set; }
[EmailAddress]
[RegularExpression(@"^[^+]+@[^@]+\.[^@]+$", ErrorMessage = "Email address cannot contain '+' symbol.")]
[MaxLength(1024)]
public string? Email { get; set; }
public string? AffiliationSpell { get; set; }
}
[HttpPost("validate")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<string>> ValidateCreateAccountRequest(
[FromBody] AccountCreateValidateRequest request)
{
if (request.Name is not null)
{
if (await accounts.CheckAccountNameHasTaken(request.Name))
return BadRequest("Account name has already been taken.");
}
if (request.Email is not null)
{
if (await accounts.CheckEmailHasBeenUsed(request.Email))
return BadRequest("Email has already been used.");
}
if (request.AffiliationSpell is not null)
{
if (!await ars.CheckAffiliationSpellHasTaken(request.AffiliationSpell))
return BadRequest("No affiliation spell has been found.");
}
return Ok("Everything seems good.");
} }
[HttpPost] [HttpPost]
@@ -271,10 +320,21 @@ public class AccountController(
[HttpPost("credits/validate")] [HttpPost("credits/validate")]
[Authorize] [Authorize]
[RequiredPermission("maintenance", "credits.validate.perform")] [AskPermission("credits.validate.perform")]
public async Task<IActionResult> PerformSocialCreditValidation() public async Task<IActionResult> PerformSocialCreditValidation()
{ {
await socialCreditService.ValidateSocialCredits(); await socialCreditService.ValidateSocialCredits();
return Ok(); return Ok();
} }
[HttpDelete("{name}")]
[Authorize]
[AskPermission("accounts.deletion")]
public async Task<IActionResult> AdminDeleteAccount(string name)
{
var account = await accounts.LookupAccount(name);
if (account is null) return NotFound();
await accounts.DeleteAccount(account);
return Ok();
}
} }

View File

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

View File

@@ -313,52 +313,84 @@ public class AccountEventService(
CultureInfo.CurrentCulture = cultureInfo; CultureInfo.CurrentCulture = cultureInfo;
CultureInfo.CurrentUICulture = cultureInfo; CultureInfo.CurrentUICulture = cultureInfo;
// Generate 2 positive tips var accountProfile = await db.AccountProfiles
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
.Where(x => x.AccountId == user.Id) .Where(x => x.AccountId == user.Id)
.Select(x => x.Birthday) .Select(x => new { x.Birthday, x.TimeZone })
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
var now = SystemClock.Instance.GetCurrentInstant().InUtc().Date; var accountBirthday = accountProfile?.Birthday;
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; 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 var result = new SnCheckInResult
{ {
@@ -478,6 +510,54 @@ public class AccountEventService(
return activities; 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) public async Task<(List<SnPresenceActivity>, int)> GetAllActivities(Guid userId, int offset = 0, int take = 20)
{ {
var query = db.PresenceActivities var query = db.PresenceActivities

View File

@@ -1,9 +1,11 @@
using System.Globalization; using System.Globalization;
using DysonNetwork.Pass.Affiliation;
using DysonNetwork.Pass.Auth.OpenId; using DysonNetwork.Pass.Auth.OpenId;
using DysonNetwork.Pass.Localization; using DysonNetwork.Pass.Localization;
using DysonNetwork.Pass.Mailer; using DysonNetwork.Pass.Mailer;
using DysonNetwork.Pass.Resources.Emails; using DysonNetwork.Pass.Resources.Emails;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Stream; using DysonNetwork.Shared.Stream;
@@ -23,6 +25,7 @@ public class AccountService(
FileService.FileServiceClient files, FileService.FileServiceClient files,
FileReferenceService.FileReferenceServiceClient fileRefs, FileReferenceService.FileReferenceServiceClient fileRefs,
AccountUsernameService uname, AccountUsernameService uname,
AffiliationSpellService ars,
EmailService mailer, EmailService mailer,
RingService.RingServiceClient pusher, RingService.RingServiceClient pusher,
IStringLocalizer<NotificationResource> localizer, IStringLocalizer<NotificationResource> localizer,
@@ -53,11 +56,13 @@ public class AccountService(
public async Task<SnAccount?> LookupAccount(string probe) public async Task<SnAccount?> LookupAccount(string probe)
{ {
var account = await db.Accounts.Where(a => a.Name == probe).FirstOrDefaultAsync(); var account = await db.Accounts.Where(a => EF.Functions.ILike(a.Name, probe)).FirstOrDefaultAsync();
if (account is not null) return account; if (account is not null) return account;
var contact = await db.AccountContacts var contact = await db.AccountContacts
.Where(c => c.Content == probe) .Where(c => c.Type == Shared.Models.AccountContactType.Email ||
c.Type == Shared.Models.AccountContactType.PhoneNumber)
.Where(c => EF.Functions.ILike(c.Content, probe))
.Include(c => c.Account) .Include(c => c.Account)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
return contact?.Account; return contact?.Account;
@@ -80,6 +85,17 @@ public class AccountService(
return profile?.Level; return profile?.Level;
} }
public async Task<bool> CheckAccountNameHasTaken(string name)
{
return await db.Accounts.AnyAsync(a => EF.Functions.ILike(a.Name, name));
}
public async Task<bool> CheckEmailHasBeenUsed(string email)
{
return await db.AccountContacts.AnyAsync(c =>
c.Type == Shared.Models.AccountContactType.Email && EF.Functions.ILike(c.Content, email));
}
public async Task<SnAccount> CreateAccount( public async Task<SnAccount> CreateAccount(
string name, string name,
string nick, string nick,
@@ -87,12 +103,12 @@ public class AccountService(
string? password, string? password,
string language = "en-US", string language = "en-US",
string region = "en", string region = "en",
string? affiliationSpell = null,
bool isEmailVerified = false, bool isEmailVerified = false,
bool isActivated = false bool isActivated = false
) )
{ {
var dupeNameCount = await db.Accounts.Where(a => a.Name == name).CountAsync(); if (await CheckAccountNameHasTaken(name))
if (dupeNameCount > 0)
throw new InvalidOperationException("Account name has already been taken."); throw new InvalidOperationException("Account name has already been taken.");
var dupeEmailCount = await db.AccountContacts var dupeEmailCount = await db.AccountContacts
@@ -109,7 +125,7 @@ public class AccountService(
Region = region, Region = region,
Contacts = Contacts =
[ [
new() new SnAccountContact
{ {
Type = Shared.Models.AccountContactType.Email, Type = Shared.Models.AccountContactType.Email,
Content = email, Content = email,
@@ -131,6 +147,9 @@ public class AccountService(
Profile = new SnAccountProfile() Profile = new SnAccountProfile()
}; };
if (affiliationSpell is not null)
await ars.CreateAffiliationResult(affiliationSpell, $"account:{account.Id}");
if (isActivated) if (isActivated)
{ {
account.ActivatedAt = SystemClock.Instance.GetCurrentInstant(); account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
@@ -139,7 +158,7 @@ public class AccountService(
{ {
db.PermissionGroupMembers.Add(new SnPermissionGroupMember db.PermissionGroupMembers.Add(new SnPermissionGroupMember
{ {
Actor = $"user:{account.Id}", Actor = account.Id.ToString(),
Group = defaultGroup Group = defaultGroup
}); });
} }
@@ -180,10 +199,7 @@ public class AccountService(
displayName, displayName,
userInfo.Email, userInfo.Email,
null, null,
"en-US", isEmailVerified: userInfo.EmailVerified
"en",
userInfo.EmailVerified,
userInfo.EmailVerified
); );
} }
@@ -273,7 +289,8 @@ public class AccountService(
return isExists; return isExists;
} }
public async Task<SnAccountAuthFactor?> CreateAuthFactor(SnAccount account, Shared.Models.AccountAuthFactorType type, string? secret) public async Task<SnAccountAuthFactor?> CreateAuthFactor(SnAccount account,
Shared.Models.AccountAuthFactorType type, string? secret)
{ {
SnAccountAuthFactor? factor = null; SnAccountAuthFactor? factor = null;
switch (type) switch (type)
@@ -351,7 +368,8 @@ public class AccountService(
public async Task<SnAccountAuthFactor> EnableAuthFactor(SnAccountAuthFactor factor, string? code) public async Task<SnAccountAuthFactor> EnableAuthFactor(SnAccountAuthFactor factor, string? code)
{ {
if (factor.EnabledAt is not null) throw new ArgumentException("The factor has been enabled."); if (factor.EnabledAt is not null) throw new ArgumentException("The factor has been enabled.");
if (factor.Type is Shared.Models.AccountAuthFactorType.Password or Shared.Models.AccountAuthFactorType.TimedCode) if (factor.Type is Shared.Models.AccountAuthFactorType.Password
or Shared.Models.AccountAuthFactorType.TimedCode)
{ {
if (code is null || !factor.VerifyPassword(code)) if (code is null || !factor.VerifyPassword(code))
throw new InvalidOperationException( throw new InvalidOperationException(
@@ -507,9 +525,7 @@ public class AccountService(
private async Task<bool> IsDeviceActive(Guid id) private async Task<bool> IsDeviceActive(Guid id)
{ {
return await db.AuthSessions return await db.AuthSessions.AnyAsync(s => s.ClientId == id);
.Include(s => s.Challenge)
.AnyAsync(s => s.Challenge.ClientId == id);
} }
public async Task<SnAuthClient> UpdateDeviceName(SnAccount account, string deviceId, string label) public async Task<SnAuthClient> UpdateDeviceName(SnAccount account, string deviceId, string label)
@@ -528,8 +544,7 @@ public class AccountService(
public async Task DeleteSession(SnAccount account, Guid sessionId) public async Task DeleteSession(SnAccount account, Guid sessionId)
{ {
var session = await db.AuthSessions var session = await db.AuthSessions
.Include(s => s.Challenge) .Include(s => s.Client)
.ThenInclude(s => s.Client)
.Where(s => s.Id == sessionId && s.AccountId == account.Id) .Where(s => s.Id == sessionId && s.AccountId == account.Id)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (session is null) throw new InvalidOperationException("Session was not found."); if (session is null) throw new InvalidOperationException("Session was not found.");
@@ -538,11 +553,11 @@ public class AccountService(
db.AuthSessions.Remove(session); db.AuthSessions.Remove(session);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
if (session.Challenge.ClientId.HasValue) if (session.ClientId.HasValue)
{ {
if (!await IsDeviceActive(session.Challenge.ClientId.Value)) if (!await IsDeviceActive(session.ClientId.Value))
await pusher.UnsubscribePushNotificationsAsync(new UnsubscribePushNotificationsRequest() await pusher.UnsubscribePushNotificationsAsync(new UnsubscribePushNotificationsRequest()
{ DeviceId = session.Challenge.Client!.DeviceId } { DeviceId = session.Client!.DeviceId }
); );
} }
@@ -563,15 +578,13 @@ public class AccountService(
); );
var sessions = await db.AuthSessions var sessions = await db.AuthSessions
.Include(s => s.Challenge) .Where(s => s.ClientId == device.Id && s.AccountId == account.Id)
.Where(s => s.Challenge.ClientId == device.Id && s.AccountId == account.Id)
.ToListAsync(); .ToListAsync();
// The current session should be included in the sessions' list // The current session should be included in the sessions' list
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
await db.AuthSessions await db.AuthSessions
.Include(s => s.Challenge) .Where(s => s.ClientId == device.Id)
.Where(s => s.Challenge.ClientId == device.Id)
.ExecuteUpdateAsync(p => p.SetProperty(s => s.DeletedAt, s => now)); .ExecuteUpdateAsync(p => p.SetProperty(s => s.DeletedAt, s => now));
db.AuthClients.Remove(device); db.AuthClients.Remove(device);
@@ -581,7 +594,8 @@ public class AccountService(
await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{item.Id}"); await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{item.Id}");
} }
public async Task<SnAccountContact> CreateContactMethod(SnAccount account, Shared.Models.AccountContactType type, string content) public async Task<SnAccountContact> CreateContactMethod(SnAccount account, Shared.Models.AccountContactType type,
string content)
{ {
var isExists = await db.AccountContacts var isExists = await db.AccountContacts
.Where(x => x.AccountId == account.Id && x.Type == type && x.Content == content) .Where(x => x.AccountId == account.Id && x.Type == type && x.Content == content)
@@ -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; contact.IsPublic = isPublic;
db.AccountContacts.Update(contact); 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) public override async Task<Shared.Proto.Account> GetAccount(GetAccountRequest request, ServerCallContext context)
{ {
if (!Guid.TryParse(request.Id, out var accountId)) 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 var account = await _db.Accounts
.AsNoTracking() .AsNoTracking()
.Include(a => a.Profile) .Include(a => a.Profile)
.Include(a => a.Contacts.Where(c => c.IsPublic))
.FirstOrDefaultAsync(a => a.Id == accountId); .FirstOrDefaultAsync(a => a.Id == accountId);
if (account == null) 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); var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id);
account.PerkSubscription = perk?.ToReference(); account.PerkSubscription = perk?.ToReference();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Reflection;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DysonNetwork.Pass.Permission; using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Design;
@@ -61,6 +61,9 @@ public class AppDatabase(
public DbSet<SnLottery> Lotteries { get; set; } = null!; public DbSet<SnLottery> Lotteries { get; set; } = null!;
public DbSet<SnLotteryRecord> LotteryRecords { get; set; } = null!; public DbSet<SnLotteryRecord> LotteryRecords { get; set; } = null!;
public DbSet<SnAffiliationSpell> AffiliationSpells { get; set; } = null!;
public DbSet<SnAffiliationResult> AffiliationResults { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
optionsBuilder.UseNpgsql( optionsBuilder.UseNpgsql(
@@ -100,7 +103,7 @@ public class AppDatabase(
"stickers.packs.create", "stickers.packs.create",
"stickers.create" "stickers.create"
}.Select(permission => }.Select(permission =>
PermissionService.NewPermissionNode("group:default", "global", permission, true)) PermissionService.NewPermissionNode("group:default", permission, true))
.ToList() .ToList()
}); });
await context.SaveChangesAsync(cancellationToken); await context.SaveChangesAsync(cancellationToken);
@@ -143,51 +146,12 @@ public class AppDatabase(
.HasForeignKey(pm => pm.RealmId) .HasForeignKey(pm => pm.RealmId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
// Automatically apply soft-delete filter to all entities inheriting BaseModel modelBuilder.ApplySoftDeleteFilters();
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);
} }
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{ {
var now = SystemClock.Instance.GetCurrentInstant(); this.ApplyAuditableAndSoftDelete();
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;
}
}
return await base.SaveChangesAsync(cancellationToken); 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 // Add scopes as claims
session.Challenge?.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope))); session.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope)));
// Add superuser claim if applicable // Add superuser claim if applicable
if (session.Account.IsSuperuser) if (session.Account.IsSuperuser)
@@ -117,16 +117,17 @@ public class DysonTokenAuthHandler(
{ {
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{ {
var token = authHeader["Bearer ".Length..].Trim(); var tokenText = authHeader["Bearer ".Length..].Trim();
var parts = token.Split('.'); var parts = tokenText.Split('.');
return new TokenInfo return new TokenInfo
{ {
Token = token, Token = tokenText,
Type = parts.Length == 3 ? TokenType.OidcKey : TokenType.AuthKey Type = parts.Length == 3 ? TokenType.OidcKey : TokenType.AuthKey
}; };
} }
else if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
{ {
return new TokenInfo return new TokenInfo
{ {
@@ -134,7 +135,8 @@ public class DysonTokenAuthHandler(
Type = TokenType.AuthKey Type = TokenType.AuthKey
}; };
} }
else if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
{ {
return new TokenInfo return new TokenInfo
{ {

View File

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

View File

@@ -2,6 +2,8 @@ using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Geometry;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
@@ -13,7 +15,8 @@ public class AuthService(
IConfiguration config, IConfiguration config,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
ICacheService cache ICacheService cache,
GeoService geo
) )
{ {
private HttpContext HttpContext => httpContextAccessor.HttpContext!; private HttpContext HttpContext => httpContextAccessor.HttpContext!;
@@ -30,7 +33,7 @@ public class AuthService(
{ {
// 1) Find out how many authentication factors the account has enabled. // 1) Find out how many authentication factors the account has enabled.
var enabledFactors = await db.AccountAuthFactors var enabledFactors = await db.AccountAuthFactors
.Where(f => f.AccountId == account.Id) .Where(f => f.AccountId == account.Id && f.Type != AccountAuthFactorType.PinCode)
.Where(f => f.EnabledAt != null) .Where(f => f.EnabledAt != null)
.ToListAsync(); .ToListAsync();
var maxSteps = enabledFactors.Count; var maxSteps = enabledFactors.Count;
@@ -41,13 +44,18 @@ public class AuthService(
// 2) Get login context from recent sessions // 2) Get login context from recent sessions
var recentSessions = await db.AuthSessions var recentSessions = await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.AccountId == account.Id) .Where(s => s.AccountId == account.Id)
.Where(s => s.LastGrantedAt != null) .Where(s => s.LastGrantedAt != null)
.OrderByDescending(s => s.LastGrantedAt) .OrderByDescending(s => s.LastGrantedAt)
.Take(10) .Take(10)
.ToListAsync(); .ToListAsync();
var recentChallengeIds =
recentSessions
.Where(s => s.ChallengeId != null)
.Select(s => s.ChallengeId!.Value).ToList();
var recentChallenges = await db.AuthChallenges.Where(c => recentChallengeIds.Contains(c.Id)).ToListAsync();
var ipAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString(); var ipAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString();
var userAgent = request.Headers.UserAgent.ToString(); var userAgent = request.Headers.UserAgent.ToString();
@@ -59,14 +67,14 @@ public class AuthService(
else else
{ {
// Check if IP has been used before // Check if IP has been used before
var ipPreviouslyUsed = recentSessions.Any(s => s.Challenge?.IpAddress == ipAddress); var ipPreviouslyUsed = recentChallenges.Any(c => c.IpAddress == ipAddress);
if (!ipPreviouslyUsed) if (!ipPreviouslyUsed)
{ {
riskScore += 8; riskScore += 8;
} }
// Check geographical distance for last known location // Check geographical distance for last known location
var lastKnownIp = recentSessions.FirstOrDefault(s => !string.IsNullOrWhiteSpace(s.Challenge?.IpAddress))?.Challenge?.IpAddress; var lastKnownIp = recentChallenges.FirstOrDefault(c => !string.IsNullOrWhiteSpace(c.IpAddress))?.IpAddress;
if (!string.IsNullOrWhiteSpace(lastKnownIp) && lastKnownIp != ipAddress) if (!string.IsNullOrWhiteSpace(lastKnownIp) && lastKnownIp != ipAddress)
{ {
riskScore += 6; riskScore += 6;
@@ -80,9 +88,9 @@ public class AuthService(
} }
else else
{ {
var uaPreviouslyUsed = recentSessions.Any(s => var uaPreviouslyUsed = recentChallenges.Any(c =>
!string.IsNullOrWhiteSpace(s.Challenge?.UserAgent) && !string.IsNullOrWhiteSpace(c.UserAgent) &&
string.Equals(s.Challenge.UserAgent, userAgent, StringComparison.OrdinalIgnoreCase)); string.Equals(c.UserAgent, userAgent, StringComparison.OrdinalIgnoreCase));
if (!uaPreviouslyUsed) if (!uaPreviouslyUsed)
{ {
@@ -156,7 +164,7 @@ public class AuthService(
// 8) Device Trust Assessment // 8) Device Trust Assessment
var trustedDeviceIds = recentSessions var trustedDeviceIds = recentSessions
.Where(s => s.CreatedAt > now.Minus(Duration.FromDays(30))) // Trust devices from last 30 days .Where(s => s.CreatedAt > now.Minus(Duration.FromDays(30))) // Trust devices from last 30 days
.Select(s => s.Challenge?.ClientId) .Select(s => s.ClientId)
.Where(id => id.HasValue) .Where(id => id.HasValue)
.Distinct() .Distinct()
.ToList(); .ToList();
@@ -180,29 +188,28 @@ public class AuthService(
return totalRequiredSteps; return totalRequiredSteps;
} }
public async Task<SnAuthSession> CreateSessionForOidcAsync(SnAccount account, Instant time, public async Task<SnAuthSession> CreateSessionForOidcAsync(
Guid? customAppId = null) SnAccount account,
Instant time,
Guid? customAppId = null,
SnAuthSession? parentSession = null
)
{ {
var challenge = new SnAuthChallenge var ipAddr = HttpContext.Connection.RemoteIpAddress?.ToString();
{ var geoLocation = ipAddr is not null ? geo.GetPointFromIp(ipAddr) : null;
AccountId = account.Id,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
UserAgent = HttpContext.Request.Headers.UserAgent,
StepRemain = 1,
StepTotal = 1,
Type = customAppId is not null ? ChallengeType.OAuth : ChallengeType.Oidc
};
var session = new SnAuthSession var session = new SnAuthSession
{ {
AccountId = account.Id, AccountId = account.Id,
CreatedAt = time, CreatedAt = time,
LastGrantedAt = time, LastGrantedAt = time,
Challenge = challenge, IpAddress = ipAddr,
AppId = customAppId UserAgent = HttpContext.Request.Headers.UserAgent,
Location = geoLocation,
AppId = customAppId,
ParentSessionId = parentSession?.Id,
Type = customAppId is not null ? SessionType.OAuth : SessionType.Oidc,
}; };
db.AuthChallenges.Add(challenge);
db.AuthSessions.Add(session); db.AuthSessions.Add(session);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -216,7 +223,8 @@ public class AuthService(
ClientPlatform platform = ClientPlatform.Unidentified ClientPlatform platform = ClientPlatform.Unidentified
) )
{ {
var device = await db.AuthClients.FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.AccountId == accountId); var device = await db.AuthClients
.FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.AccountId == accountId);
if (device is not null) return device; if (device is not null) return device;
device = new SnAuthClient device = new SnAuthClient
{ {
@@ -287,35 +295,71 @@ public class AuthService(
/// <summary> /// <summary>
/// Immediately revoke a session by setting expiry to now and clearing from cache /// Immediately revoke a session by setting expiry to now and clearing from cache
/// This provides immediate invalidation of tokens and sessions /// This provides immediate invalidation of tokens and sessions, including all child sessions recursively.
/// </summary> /// </summary>
/// <param name="sessionId">Session ID to revoke</param> /// <param name="sessionId">Session ID to revoke</param>
/// <returns>True if session was found and revoked, false otherwise</returns> /// <returns>True if session was found and revoked, false otherwise</returns>
public async Task<bool> RevokeSessionAsync(Guid sessionId) public async Task<bool> RevokeSessionAsync(Guid sessionId)
{ {
var session = await db.AuthSessions.FirstOrDefaultAsync(s => s.Id == sessionId); var sessionsToRevokeIds = new HashSet<Guid>();
if (session == null) await CollectSessionsToRevoke(sessionId, sessionsToRevokeIds);
if (sessionsToRevokeIds.Count == 0)
{ {
return false; return false;
} }
// Set expiry to now (immediate invalidation)
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
session.ExpiredAt = now; var accountIdsToClearCache = new HashSet<Guid>();
db.AuthSessions.Update(session);
// Clear from cache immediately // Fetch all sessions to be revoked in one go
var cacheKey = $"{AuthCachePrefix}{session.Id}"; var sessions = await db.AuthSessions
await cache.RemoveAsync(cacheKey); .Where(s => sessionsToRevokeIds.Contains(s.Id))
.ToListAsync();
// Clear account-level cache groups that include this session foreach (var session in sessions)
await cache.RemoveAsync($"{AuthCachePrefix}{session.AccountId}"); {
session.ExpiredAt = now;
accountIdsToClearCache.Add(session.AccountId);
// Clear from cache immediately for each session
await cache.RemoveAsync($"{AuthCachePrefix}{session.Id}");
}
db.AuthSessions.UpdateRange(sessions);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Clear account-level cache groups
foreach (var accountId in accountIdsToClearCache)
{
await cache.RemoveAsync($"{AuthCachePrefix}{accountId}");
}
return true; return true;
} }
/// <summary>
/// Recursively collects all session IDs that need to be revoked, starting from a given session.
/// </summary>
/// <param name="currentSessionId">The session ID to start collecting from.</param>
/// <param name="sessionsToRevoke">A HashSet to store the IDs of all sessions to be revoked.</param>
private async Task CollectSessionsToRevoke(Guid currentSessionId, HashSet<Guid> sessionsToRevoke)
{
if (!sessionsToRevoke.Add(currentSessionId))
return; // Already processed this session
// Find direct children
var childSessions = await db.AuthSessions
.Where(s => s.ParentSessionId == currentSessionId)
.Select(s => s.Id)
.ToListAsync();
foreach (var childId in childSessions)
{
await CollectSessionsToRevoke(childId, sessionsToRevoke);
}
}
/// <summary> /// <summary>
/// Revoke all sessions for an account (logout everywhere) /// Revoke all sessions for an account (logout everywhere)
/// </summary> /// </summary>
@@ -374,10 +418,12 @@ public class AuthService(
if (challenge.StepRemain != 0) if (challenge.StepRemain != 0)
throw new ArgumentException("Challenge not yet completed."); throw new ArgumentException("Challenge not yet completed.");
var hasSession = await db.AuthSessions var device = await GetOrCreateDeviceAsync(
.AnyAsync(e => e.ChallengeId == challenge.Id); challenge.AccountId,
if (hasSession) challenge.DeviceId,
throw new ArgumentException("Session already exists for this challenge."); challenge.DeviceName,
challenge.Platform
);
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
var session = new SnAuthSession var session = new SnAuthSession
@@ -385,7 +431,13 @@ public class AuthService(
LastGrantedAt = now, LastGrantedAt = now,
ExpiredAt = now.Plus(Duration.FromDays(7)), ExpiredAt = now.Plus(Duration.FromDays(7)),
AccountId = challenge.AccountId, AccountId = challenge.AccountId,
ChallengeId = challenge.Id IpAddress = challenge.IpAddress,
UserAgent = challenge.UserAgent,
Location = challenge.Location,
Scopes = challenge.Scopes,
Audiences = challenge.Audiences,
ChallengeId = challenge.Id,
ClientId = device.Id,
}; };
db.AuthSessions.Add(session); db.AuthSessions.Add(session);
@@ -408,7 +460,7 @@ public class AuthService(
return tk; return tk;
} }
private string CreateCompactToken(Guid sessionId, RSA rsa) private static string CreateCompactToken(Guid sessionId, RSA rsa)
{ {
// Create the payload: just the session ID // Create the payload: just the session ID
var payloadBytes = sessionId.ToByteArray(); var payloadBytes = sessionId.ToByteArray();
@@ -499,7 +551,8 @@ public class AuthService(
return key; return key;
} }
public async Task<SnApiKey> CreateApiKey(Guid accountId, string label, Instant? expiredAt = null) public async Task<SnApiKey> CreateApiKey(Guid accountId, string label, Instant? expiredAt = null,
SnAuthSession? parentSession = null)
{ {
var key = new SnApiKey var key = new SnApiKey
{ {
@@ -508,7 +561,8 @@ public class AuthService(
Session = new SnAuthSession Session = new SnAuthSession
{ {
AccountId = accountId, AccountId = accountId,
ExpiredAt = expiredAt ExpiredAt = expiredAt,
ParentSessionId = parentSession?.Id
}, },
}; };
@@ -614,4 +668,47 @@ public class AuthService(
return Convert.FromBase64String(padded); return Convert.FromBase64String(padded);
} }
/// <summary>
/// Creates a new session derived from an existing parent session.
/// </summary>
/// <param name="parentSession">The existing session from which the new session is derived.</param>
/// <param name="deviceId">The ID of the device for the new session.</param>
/// <param name="deviceName">The name of the device for the new session.</param>
/// <param name="platform">The platform of the device for the new session.</param>
/// <param name="expiredAt">Optional: The expiration time for the new session.</param>
/// <returns>The newly created SnAuthSession.</returns>
public async Task<SnAuthSession> CreateSessionFromParentAsync(
SnAuthSession parentSession,
string deviceId,
string? deviceName,
ClientPlatform platform,
Instant? expiredAt = null
)
{
var device = await GetOrCreateDeviceAsync(parentSession.AccountId, deviceId, deviceName, platform);
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
var geoLocation = ipAddress is not null ? geo.GetPointFromIp(ipAddress) : null;
var now = SystemClock.Instance.GetCurrentInstant();
var session = new SnAuthSession
{
IpAddress = ipAddress,
UserAgent = userAgent,
Location = geoLocation,
AccountId = parentSession.AccountId,
CreatedAt = now,
LastGrantedAt = now,
ExpiredAt = expiredAt,
ParentSessionId = parentSession.Id,
ClientId = device.Id,
};
db.AuthSessions.Add(session);
await db.SaveChangesAsync();
return session;
}
} }

View File

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

View File

@@ -5,7 +5,8 @@ namespace DysonNetwork.Pass.Auth.OidcProvider.Models;
public class AuthorizationCodeInfo public class AuthorizationCodeInfo
{ {
public Guid ClientId { get; set; } 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 string RedirectUri { get; set; } = string.Empty;
public List<string> Scopes { get; set; } = new(); public List<string> Scopes { get; set; } = new();
public string? CodeChallenge { get; set; } 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 public class TokenResponse
{ {
[JsonPropertyName("access_token")] [JsonPropertyName("access_token")]
public string AccessToken { get; set; } = null!; public string? AccessToken { get; set; } = null!;
[JsonPropertyName("expires_in")] [JsonPropertyName("expires_in")]
public int ExpiresIn { get; set; } public int ExpiresIn { get; set; }
@@ -22,4 +22,7 @@ public class TokenResponse
[JsonPropertyName("id_token")] [JsonPropertyName("id_token")]
public string? IdToken { get; set; } 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 now = SystemClock.Instance.GetCurrentInstant();
var queryable = db.AuthSessions var queryable = db.AuthSessions
.Include(s => s.Challenge)
.AsQueryable(); .AsQueryable();
if (withAccount) if (withAccount)
queryable = queryable queryable = queryable
@@ -85,8 +84,7 @@ public class OidcProviderService(
.Where(s => s.AccountId == accountId && .Where(s => s.AccountId == accountId &&
s.AppId == clientId && s.AppId == clientId &&
(s.ExpiredAt == null || s.ExpiredAt > now) && (s.ExpiredAt == null || s.ExpiredAt > now) &&
s.Challenge != null && s.Type == Shared.Models.SessionType.OAuth)
s.Challenge.Type == Shared.Models.ChallengeType.OAuth)
.OrderByDescending(s => s.CreatedAt) .OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
} }
@@ -257,18 +255,15 @@ public class OidcProviderService(
} }
private async Task<(SnAuthSession session, string? nonce, List<string>? scopes)> HandleAuthorizationCodeFlowAsync( private async Task<(SnAuthSession session, string? nonce, List<string>? scopes)> HandleAuthorizationCodeFlowAsync(
string authorizationCode, AuthorizationCodeInfo authCode,
Guid clientId, Guid clientId
string? redirectUri,
string? codeVerifier
) )
{ {
var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier); if (authCode.AccountId == null)
if (authCode == null) throw new InvalidOperationException("Invalid authorization code, account id is missing.");
throw new InvalidOperationException("Invalid authorization code");
// Load the session for the user // 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; SnAuthSession session;
if (existingSession == null) if (existingSession == null)
@@ -315,31 +310,124 @@ public class OidcProviderService(
var client = await FindClientByIdAsync(clientId) ?? throw new InvalidOperationException("Client not found"); var client = await FindClientByIdAsync(clientId) ?? throw new InvalidOperationException("Client not found");
var (session, nonce, scopes) = authorizationCode != null if (authorizationCode != null)
? await HandleAuthorizationCodeFlowAsync(authorizationCode, clientId, redirectUri, codeVerifier) {
: sessionId.HasValue var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier);
? await HandleRefreshTokenFlowAsync(sessionId.Value) if (authCode == null)
: throw new InvalidOperationException("Either authorization code or session ID must be provided"); {
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 clock = SystemClock.Instance;
var now = clock.GetCurrentInstant(); var now = clock.GetCurrentInstant();
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
// Generate tokens var claims = new List<Claim>
var accessToken = GenerateJwtToken(client, session, expiresAt, scopes);
var idToken = GenerateIdToken(client, session, nonce, scopes);
var refreshToken = GenerateRefreshToken(session);
return new TokenResponse
{ {
AccessToken = accessToken, new(JwtRegisteredClaimNames.Iss, _options.IssuerUri),
IdToken = idToken, new(JwtRegisteredClaimNames.Aud, client.Slug),
ExpiresIn = expiresIn, new(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
TokenType = "Bearer", new(JwtRegisteredClaimNames.Exp,
RefreshToken = refreshToken, now.Plus(Duration.FromMinutes(15)).ToUnixTimeSeconds()
Scope = scopes != null ? string.Join(" ", scopes) : null .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( private string GenerateJwtToken(
@@ -421,7 +509,6 @@ public class OidcProviderService(
{ {
return await db.AuthSessions return await db.AuthSessions
.Include(s => s.Account) .Include(s => s.Account)
.Include(s => s.Challenge)
.FirstOrDefaultAsync(s => s.Id == sessionId); .FirstOrDefaultAsync(s => s.Id == sessionId);
} }
@@ -440,12 +527,6 @@ public class OidcProviderService(
string? nonce = null 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 var authCodeInfo = new AuthorizationCodeInfo
{ {
ClientId = clientId, ClientId = clientId,
@@ -455,17 +536,47 @@ public class OidcProviderService(
CodeChallenge = codeChallenge, CodeChallenge = codeChallenge,
CodeChallengeMethod = codeChallengeMethod, CodeChallengeMethod = codeChallengeMethod,
Nonce = nonce, 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}"; var cacheKey = $"{CacheKeyPrefixAuthCode}{code}";
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime); await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
logger.LogInformation("Generated authorization code for client {ClientId}", authCodeInfo.ClientId);
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId);
return code; return code;
} }
private async Task<AuthorizationCodeInfo?> ValidateAuthorizationCodeAsync( private async Task<AuthorizationCodeInfo?> ValidateAuthorizationCodeAsync(
string code, string code,
Guid clientId, Guid clientId,

View File

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

View File

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

View File

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

View File

@@ -77,7 +77,7 @@ public class TokenAuthService(
"AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})", "AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})",
sessionId, sessionId,
session.AccountId, session.AccountId,
session.Challenge?.Scopes.Count, session.Scopes.Count,
session.ExpiredAt session.ExpiredAt
); );
return (true, session, null); return (true, session, null);
@@ -87,8 +87,7 @@ public class TokenAuthService(
session = await db.AuthSessions session = await db.AuthSessions
.AsNoTracking() .AsNoTracking()
.Include(e => e.Challenge) .Include(e => e.Client)
.ThenInclude(e => e.Client)
.Include(e => e.Account) .Include(e => e.Account)
.ThenInclude(e => e.Profile) .ThenInclude(e => e.Profile)
.FirstOrDefaultAsync(s => s.Id == sessionId); .FirstOrDefaultAsync(s => s.Id == sessionId);
@@ -110,11 +109,11 @@ public class TokenAuthService(
"AuthenticateTokenAsync: DB session loaded (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId}, appId={AppId}, scopes={ScopeCount}, ip={Ip}, uaLen={UaLen})", "AuthenticateTokenAsync: DB session loaded (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId}, appId={AppId}, scopes={ScopeCount}, ip={Ip}, uaLen={UaLen})",
sessionId, sessionId,
session.AccountId, session.AccountId,
session.Challenge?.ClientId, session.ClientId,
session.AppId, session.AppId,
session.Challenge?.Scopes.Count, session.Scopes.Count,
session.Challenge?.IpAddress, session.IpAddress,
(session.Challenge?.UserAgent ?? string.Empty).Length (session.UserAgent ?? string.Empty).Length
); );
logger.LogDebug("AuthenticateTokenAsync: enriching account with subscription (accountId={AccountId})", session.AccountId); logger.LogDebug("AuthenticateTokenAsync: enriching account with subscription (accountId={AccountId})", session.AccountId);
@@ -143,7 +142,7 @@ public class TokenAuthService(
"AuthenticateTokenAsync: success via DB (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId})", "AuthenticateTokenAsync: success via DB (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId})",
sessionId, sessionId,
session.AccountId, session.AccountId,
session.Challenge?.ClientId session.ClientId
); );
return (true, session, null); return (true, session, null);
} }

View File

@@ -1,12 +1,12 @@
# Stage 1: Base runtime image # Stage 1: Base runtime image
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
USER $APP_UID USER $APP_UID
WORKDIR /app WORKDIR /app
EXPOSE 8080 EXPOSE 8080
EXPOSE 8081 EXPOSE 8081
# Stage 2: Build .NET application # Stage 2: Build .NET application
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src WORKDIR /src
# Copy .csproj and restore as distinct layers # Copy .csproj and restore as distinct layers

View File

@@ -1,48 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" /> <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PrivateAssets>all</PrivateAssets>
<PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Nager.Holiday" Version="1.0.1" /> <PackageReference Include="Nager.Holiday" Version="1.0.1" />
<PackageReference Include="Nerdbank.GitVersioning" Version="3.8.118">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="NodaTime" Version="3.2.2" /> <PackageReference Include="NodaTime" Version="3.2.2" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" /> <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
<PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" /> <PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" /> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="OpenGraph-Net" Version="4.0.1" /> <PackageReference Include="OpenGraph-Net" Version="4.0.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.13.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.13.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.13.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.13.0" />
<PackageReference Include="Otp.NET" Version="1.4.0" /> <PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="Quartz" Version="3.15.1" /> <PackageReference Include="Quartz" Version="3.15.1" />
<PackageReference Include="Quartz.AspNetCore" Version="3.15.1" /> <PackageReference Include="Quartz.AspNetCore" Version="3.15.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" /> <PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.2" />
<PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.2" /> <PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.2" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="SpotifyAPI.Web" Version="7.2.1" /> <PackageReference Include="SpotifyAPI.Web" Version="7.2.1" />
<PackageReference Include="SteamWebAPI2" Version="4.4.1" /> <PackageReference Include="SteamWebAPI2" Version="5.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.6" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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