Compare commits
153 Commits
1647aa2f1e
...
refactor/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
24c756a9a8
|
|||
|
7ecb64742f
|
|||
|
3a7140f0a6
|
|||
|
42082fbefa
|
|||
|
bc3d030a1e
|
|||
|
8642737a07
|
|||
|
8181938aaf
|
|||
|
922afc2239
|
|||
|
a071bd2738
|
|||
|
43945fc524
|
|||
|
e477429a35
|
|||
|
fe3a057185
|
|||
|
ad3c104c5c
|
|||
|
2020d625aa
|
|||
|
f471c5635d
|
|||
|
eaeaa28c60
|
|||
|
ee5c7cb7ce
|
|||
|
33abf12e41
|
|||
|
4a71f92ef0
|
|||
|
4faa1a4b64
|
|||
|
e49a1ec49a
|
|||
|
a88f42b26a
|
|||
|
c45be62331
|
|||
|
c8228e0c8e
|
|||
|
c642c6d646
|
|||
|
270c211cb8
|
|||
|
74c8f3490d
|
|||
|
b364edc74b
|
|||
|
9addf38677
|
|||
|
a02ed10434
|
|||
|
aca28f9318
|
|||
|
c2f72993b7
|
|||
|
158cc75c5b
|
|||
|
fa2f53ff7a
|
|||
|
2cce5ebf80
|
|||
|
13b2e46ecc
|
|||
|
cbd68c9ae6
|
|||
|
b99b61e0f9
|
|||
|
94f4e68120
|
|||
|
d5510f7e4d
|
|||
|
c038ab9e3c
|
|||
|
e97719ec84
|
|||
|
40b8ea8eb8
|
|||
|
f9b4dd45d7
|
|||
|
a46de4662c
|
|||
|
fdd14b860e
|
|||
|
cb62df81e2
|
|||
|
46717e39a7
|
|||
|
344ed6e348
|
|||
|
a8b62fb0eb
|
|||
|
00b3087d6a
|
|||
|
78f3873a0c
|
|||
|
a7f4173df7
|
|||
|
f51c3c1724
|
|||
|
a92dc7e140
|
|||
|
c42befed6b
|
|||
|
2b95d58611
|
|||
|
726a752fbb
|
|||
|
2024972832
|
|||
|
d553ca2ca7
|
|||
|
aeef16495f
|
|||
|
9b26a2a7eb
|
|||
|
2317033dae
|
|||
|
fd6e9c9780
|
|||
|
af0a2ff493
|
|||
|
b142a71c32
|
|||
|
27e3cc853a
|
|||
|
590519c28f
|
|||
|
8ccf8100d4
|
|||
|
ec21a94921
|
|||
|
7b7a6c9218
|
|||
|
0e44d9c514
|
|||
|
e449e16d33
|
|||
|
3ce2b36c15
|
|||
|
f7388822e0
|
|||
|
3800dae8b7
|
|||
|
c62ed191f3
|
|||
|
8b77f0e0ad
|
|||
|
2b56c6f1e5
|
|||
|
ef02265ccd
|
|||
|
f4505d2ecc
|
|||
|
9d2242d331
|
|||
|
c806365a81
|
|||
|
bd1715c9a3
|
|||
|
0b0598712e
|
|||
|
92a4899e7c
|
|||
|
bdc8db3091
|
|||
|
a16da37221
|
|||
|
70a18b07ff
|
|||
|
98b8d5f33b
|
|||
|
2a35786204
|
|||
|
7016a0a943
|
|||
|
cad72502d9
|
|||
|
226a64df41
|
|||
|
75b8567a28
|
|||
|
3aa5561a07
|
|||
|
c0ebb496fe
|
|||
|
afccb27bd4
|
|||
|
6ed96780ab
|
|||
|
8e5cdfbc62
|
|||
|
1b774c1de6
|
|||
|
9b4cbade5c
|
|||
|
a52e54f672
|
|||
|
aa48d5e25d
|
|||
|
ce18b194a5
|
|||
|
382579a20e
|
|||
|
18d50346a9
|
|||
|
ac51bbde6c
|
|||
|
4ab0dcf1c2
|
|||
|
587066d847
|
|||
|
faa375042a
|
|||
|
65b6f3a606
|
|||
|
fa1a40c637
|
|||
|
d43ce7cb11
|
|||
|
92b28d830d
|
|||
|
1fa6c893a5
|
|||
|
ba57becba8
|
|||
|
4280168002
|
|||
|
a172128d84
|
|||
|
34e78294a1
|
|||
|
82afdb3922
|
|||
|
260b3e7bc6
|
|||
|
713777cd8a
|
|||
|
5cd09bc2d0
|
|||
|
861fc7cafa
|
|||
|
6313f15375
|
|||
|
337cc1be97
|
|||
|
9b4f61fcda
|
|||
|
6252988390
|
|||
|
aace3b48b1
|
|||
|
5a097c7518
|
|||
|
ba3be1e3bb
|
|||
|
6fd90c424d
|
|||
|
a0ac3b5820
|
|||
|
076bf347c8
|
|||
|
788326381f
|
|||
|
a035b23242
|
|||
|
b29f4fce4d
|
|||
|
5418489f77
|
|||
|
310f2c1497
|
|||
|
0ae8a2cfd4
|
|||
|
c69256bda6
|
|||
|
80ea44f2cc
|
|||
|
b5f9faa724
|
|||
|
05985e0852
|
|||
|
6814b5690e
|
|||
|
78447de1b6
|
|||
|
e54dcccad9
|
|||
|
429a08930f
|
|||
|
b94b288755
|
|||
|
1c50c2f822
|
|||
|
73700e7cfd
|
|||
|
bd2943345a
|
4
.github/workflows/docker-build.yml
vendored
4
.github/workflows/docker-build.yml
vendored
@@ -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
|
||||||
|
|||||||
@@ -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();
|
|
||||||
@@ -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>
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ConnectionStrings": {
|
|
||||||
"cache": "localhost:6379"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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/"]
|
||||||
|
|||||||
@@ -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"/>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ public static class ApplicationConfiguration
|
|||||||
|
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.UseMiddleware<PermissionMiddleware>();
|
app.UseMiddleware<RemotePermissionMiddleware>();
|
||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,7 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddLocalization();
|
services.AddLocalization();
|
||||||
|
|
||||||
services.AddDbContext<AppDatabase>();
|
services.AddDbContext<AppDatabase>();
|
||||||
services.AddSingleton<IClock>(SystemClock.Instance);
|
|
||||||
services.AddHttpContextAccessor();
|
services.AddHttpContextAccessor();
|
||||||
services.AddSingleton<ICacheService, CacheServiceRedis>();
|
|
||||||
|
|
||||||
services.AddHttpClient();
|
services.AddHttpClient();
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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/"]
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,21 +23,50 @@ 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 };
|
||||||
|
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
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,130 +89,85 @@ 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 };
|
||||||
|
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
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>
|
||||||
@@ -210,7 +272,7 @@ public class FileIndexController(
|
|||||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Verify ownership
|
// Verify ownership
|
||||||
@@ -222,7 +284,7 @@ public class FileIndexController(
|
|||||||
return new ObjectResult(ApiError.NotFound("File index")) { StatusCode = 404 };
|
return new ObjectResult(ApiError.NotFound("File index")) { StatusCode = 404 };
|
||||||
|
|
||||||
var updatedIndex = await fileIndexService.UpdateAsync(indexId, request.NewPath);
|
var updatedIndex = await fileIndexService.UpdateAsync(indexId, request.NewPath);
|
||||||
|
|
||||||
if (updatedIndex == null)
|
if (updatedIndex == null)
|
||||||
return new ObjectResult(ApiError.NotFound("File index")) { StatusCode = 404 };
|
return new ObjectResult(ApiError.NotFound("File index")) { StatusCode = 404 };
|
||||||
|
|
||||||
@@ -260,7 +322,7 @@ public class FileIndexController(
|
|||||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Verify ownership
|
// Verify ownership
|
||||||
@@ -277,7 +339,7 @@ public class FileIndexController(
|
|||||||
|
|
||||||
// Remove the index
|
// Remove the index
|
||||||
var removed = await fileIndexService.RemoveAsync(indexId);
|
var removed = await fileIndexService.RemoveAsync(indexId);
|
||||||
|
|
||||||
if (!removed)
|
if (!removed)
|
||||||
return new ObjectResult(ApiError.NotFound("File index")) { StatusCode = 404 };
|
return new ObjectResult(ApiError.NotFound("File index")) { StatusCode = 404 };
|
||||||
|
|
||||||
@@ -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,
|
||||||
@@ -349,7 +413,7 @@ public class FileIndexController(
|
|||||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var removedCount = await fileIndexService.RemoveByPathAsync(accountId, path);
|
var removedCount = await fileIndexService.RemoveByPathAsync(accountId, path);
|
||||||
@@ -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
|
||||||
@@ -414,7 +479,7 @@ public class FileIndexController(
|
|||||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Verify the file exists and belongs to the user
|
// Verify the file exists and belongs to the user
|
||||||
@@ -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,13 +507,13 @@ 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"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Failed to create file index for file {FileId} at path {Path} for account {AccountId}",
|
logger.LogError(ex, "Failed to create file index for file {FileId} at path {Path} for account {AccountId}",
|
||||||
request.FileId, request.Path, accountId);
|
request.FileId, request.Path, accountId);
|
||||||
return new ObjectResult(new ApiError
|
return new ObjectResult(new ApiError
|
||||||
{
|
{
|
||||||
@@ -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>
|
||||||
@@ -523,34 +537,19 @@ public class FileIndexController(
|
|||||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 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; }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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();
|
return path;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
/// <summary>
|
|
||||||
/// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ public class FileUploadController(
|
|||||||
if (currentUser.IsSuperuser) return null;
|
if (currentUser.IsSuperuser) return null;
|
||||||
|
|
||||||
var allowed = await permission.HasPermissionAsync(new HasPermissionRequest
|
var allowed = await permission.HasPermissionAsync(new HasPermissionRequest
|
||||||
{ Actor = $"user:{currentUser.Id}", Area = "global", Key = "files.create" });
|
{ Actor = currentUser.Id, Key = "files.create" });
|
||||||
|
|
||||||
return allowed.HasPermission
|
return allowed.HasPermission
|
||||||
? null
|
? null
|
||||||
|
|||||||
@@ -1,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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "1.0",
|
|
||||||
"publicReleaseRefSpec": ["^refs/heads/main$"],
|
|
||||||
"cloudBuild": {
|
|
||||||
"setVersionVariables": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"]);
|
|
||||||
}
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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>
|
|
||||||
@@ -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();
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"AllowedHosts": "*",
|
|
||||||
"SiteUrl": "http://localhost:3000",
|
|
||||||
"Client": {
|
|
||||||
"SomeSetting": "SomeValue"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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." });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/"]
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
142
DysonNetwork.Insight/Migrations/20251115084746_RefactorThoughtMessage.Designer.cs
generated
Normal file
142
DysonNetwork.Insight/Migrations/20251115084746_RefactorThoughtMessage.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
142
DysonNetwork.Insight/Migrations/20251115162347_UpdatedFunctionCallModels.Designer.cs
generated
Normal file
142
DysonNetwork.Insight/Migrations/20251115162347_UpdatedFunctionCallModels.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
159
DysonNetwork.Insight/Migrations/20251115165833_AddUnpaidAccounts.Designer.cs
generated
Normal file
159
DysonNetwork.Insight/Migrations/20251115165833_AddUnpaidAccounts.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
163
DysonNetwork.Insight/Migrations/20251116123552_SharableThought.Designer.cs
generated
Normal file
163
DysonNetwork.Insight/Migrations/20251116123552_SharableThought.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using DysonNetwork.Insight.Thought;
|
||||||
using Quartz;
|
using Quartz;
|
||||||
|
|
||||||
namespace DysonNetwork.Insight.Startup;
|
namespace DysonNetwork.Insight.Startup;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
155
DysonNetwork.Insight/Thought/CLIENT_UPDATE_GUIDE.md
Normal file
155
DysonNetwork.Insight/Thought/CLIENT_UPDATE_GUIDE.md
Normal 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.
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
DysonNetwork.Insight/Thought/Plugins/SnPostKernelPlugin.cs
Normal file
98
DysonNetwork.Insight/Thought/Plugins/SnPostKernelPlugin.cs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,40 +36,52 @@ 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);
|
||||||
|
|
||||||
// Update sequence total tokens only for assistant responses
|
// Update sequence total tokens only for assistant responses
|
||||||
if (role == ThinkingThoughtRole.Assistant)
|
if (role == ThinkingThoughtRole.Assistant)
|
||||||
sequence.TotalToken += tokenCount;
|
sequence.TotalToken += tokenCount;
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
// Invalidate cache for this sequence's thoughts
|
// Invalidate cache for this sequence's thoughts
|
||||||
await cache.RemoveGroupAsync($"sequence:{sequence.Id}");
|
await cache.RemoveGroupAsync($"sequence:{sequence.Id}");
|
||||||
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 accountBirthday = accountProfile?.Birthday;
|
||||||
|
|
||||||
var now = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
if (accountBirthday.HasValue && accountBirthday.Value.InUtc().Date == now)
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -100,7 +116,7 @@ public class AccountService(
|
|||||||
).CountAsync();
|
).CountAsync();
|
||||||
if (dupeEmailCount > 0)
|
if (dupeEmailCount > 0)
|
||||||
throw new InvalidOperationException("Account email has already been used.");
|
throw new InvalidOperationException("Account email has already been used.");
|
||||||
|
|
||||||
var account = new SnAccount
|
var account = new SnAccount
|
||||||
{
|
{
|
||||||
Name = name,
|
Name = name,
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
55
DysonNetwork.Pass/Account/FriendsController.cs
Normal file
55
DysonNetwork.Pass/Account/FriendsController.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -7,17 +9,31 @@ namespace DysonNetwork.Pass.Account;
|
|||||||
[Route("/api/spells")]
|
[Route("/api/spells")]
|
||||||
public class MagicSpellController(AppDatabase db, MagicSpellService sp) : ControllerBase
|
public class MagicSpellController(AppDatabase db, MagicSpellService sp) : ControllerBase
|
||||||
{
|
{
|
||||||
|
[HttpPost("activation/resend")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult> ResendActivationMagicSpell()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var spell = await db.MagicSpells.FirstOrDefaultAsync(s =>
|
||||||
|
s.Type == MagicSpellType.AccountActivation && s.AccountId == currentUser.Id);
|
||||||
|
if (spell is null) return BadRequest("Unable to find activation magic spell.");
|
||||||
|
|
||||||
|
await sp.NotifyMagicSpell(spell, true);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("{spellId:guid}/resend")]
|
[HttpPost("{spellId:guid}/resend")]
|
||||||
public async Task<ActionResult> ResendMagicSpell(Guid spellId)
|
public async Task<ActionResult> ResendMagicSpell(Guid spellId)
|
||||||
{
|
{
|
||||||
var spell = db.MagicSpells.FirstOrDefault(x => x.Id == spellId);
|
var spell = db.MagicSpells.FirstOrDefault(x => x.Id == spellId);
|
||||||
if (spell == null)
|
if (spell == null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
await sp.NotifyMagicSpell(spell, true);
|
await sp.NotifyMagicSpell(spell, true);
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{spellWord}")]
|
[HttpGet("{spellWord}")]
|
||||||
public async Task<ActionResult> GetMagicSpell(string spellWord)
|
public async Task<ActionResult> GetMagicSpell(string spellWord)
|
||||||
{
|
{
|
||||||
@@ -38,7 +54,8 @@ public class MagicSpellController(AppDatabase db, MagicSpellService sp) : Contro
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{spellWord}/apply")]
|
[HttpPost("{spellWord}/apply")]
|
||||||
public async Task<ActionResult> ApplyMagicSpell([FromRoute] string spellWord, [FromBody] MagicSpellApplyRequest? request)
|
public async Task<ActionResult> ApplyMagicSpell([FromRoute] string spellWord,
|
||||||
|
[FromBody] MagicSpellApplyRequest? request)
|
||||||
{
|
{
|
||||||
var word = Uri.UnescapeDataString(spellWord);
|
var word = Uri.UnescapeDataString(spellWord);
|
||||||
var spell = await db.MagicSpells
|
var spell = await db.MagicSpells
|
||||||
@@ -59,6 +76,7 @@ public class MagicSpellController(AppDatabase db, MagicSpellService sp) : Contro
|
|||||||
{
|
{
|
||||||
return BadRequest(ex.Message);
|
return BadRequest(ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,6 +26,7 @@ public class MagicSpellService(
|
|||||||
Dictionary<string, object> meta,
|
Dictionary<string, object> meta,
|
||||||
Instant? expiredAt = null,
|
Instant? expiredAt = null,
|
||||||
Instant? affectedAt = null,
|
Instant? affectedAt = null,
|
||||||
|
string? code = null,
|
||||||
bool preventRepeat = false
|
bool preventRepeat = false
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
@@ -41,7 +42,7 @@ public class MagicSpellService(
|
|||||||
return existingSpell;
|
return existingSpell;
|
||||||
}
|
}
|
||||||
|
|
||||||
var spellWord = _GenerateRandomString(128);
|
var spellWord = code ?? _GenerateRandomString(128);
|
||||||
var spell = new SnMagicSpell
|
var spell = new SnMagicSpell
|
||||||
{
|
{
|
||||||
Spell = spellWord,
|
Spell = spellWord,
|
||||||
@@ -193,7 +194,7 @@ public class MagicSpellService(
|
|||||||
{
|
{
|
||||||
db.PermissionGroupMembers.Add(new SnPermissionGroupMember
|
db.PermissionGroupMembers.Add(new SnPermissionGroupMember
|
||||||
{
|
{
|
||||||
Actor = $"user:{account.Id}",
|
Actor = account.Id.ToString(),
|
||||||
Group = defaultGroup
|
Group = defaultGroup
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,18 @@ public class RelationshipService(
|
|||||||
{
|
{
|
||||||
private const string UserFriendsCacheKeyPrefix = "accounts:friends:";
|
private const string UserFriendsCacheKeyPrefix = "accounts:friends:";
|
||||||
private const string UserBlockedCacheKeyPrefix = "accounts:blocked:";
|
private const string UserBlockedCacheKeyPrefix = "accounts:blocked:";
|
||||||
|
private static readonly TimeSpan CacheExpiration = TimeSpan.FromHours(1);
|
||||||
|
|
||||||
public async Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId)
|
public async Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId)
|
||||||
{
|
{
|
||||||
|
if (accountId == Guid.Empty || relatedId == Guid.Empty)
|
||||||
|
throw new ArgumentException("Account IDs cannot be empty.");
|
||||||
|
if (accountId == relatedId)
|
||||||
|
return false; // Prevent self-relationships
|
||||||
|
|
||||||
var count = await db.AccountRelationships
|
var count = await db.AccountRelationships
|
||||||
.Where(r => (r.AccountId == accountId && r.RelatedId == relatedId) ||
|
.Where(r => (r.AccountId == accountId && r.RelatedId == relatedId) ||
|
||||||
(r.AccountId == relatedId && r.AccountId == accountId))
|
(r.AccountId == relatedId && r.RelatedId == accountId))
|
||||||
.CountAsync();
|
.CountAsync();
|
||||||
return count > 0;
|
return count > 0;
|
||||||
}
|
}
|
||||||
@@ -34,6 +40,9 @@ public class RelationshipService(
|
|||||||
bool ignoreExpired = false
|
bool ignoreExpired = false
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
if (accountId == Guid.Empty || relatedId == Guid.Empty)
|
||||||
|
throw new ArgumentException("Account IDs cannot be empty.");
|
||||||
|
|
||||||
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
|
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
|
||||||
var queries = db.AccountRelationships.AsQueryable()
|
var queries = db.AccountRelationships.AsQueryable()
|
||||||
.Where(r => r.AccountId == accountId && r.RelatedId == relatedId);
|
.Where(r => r.AccountId == accountId && r.RelatedId == relatedId);
|
||||||
@@ -61,7 +70,7 @@ public class RelationshipService(
|
|||||||
db.AccountRelationships.Add(relationship);
|
db.AccountRelationships.Add(relationship);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
await PurgeRelationshipCache(sender.Id, target.Id);
|
await PurgeRelationshipCache(sender.Id, target.Id, status);
|
||||||
|
|
||||||
return relationship;
|
return relationship;
|
||||||
}
|
}
|
||||||
@@ -80,7 +89,7 @@ public class RelationshipService(
|
|||||||
db.Remove(relationship);
|
db.Remove(relationship);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
await PurgeRelationshipCache(sender.Id, target.Id);
|
await PurgeRelationshipCache(sender.Id, target.Id, RelationshipStatus.Blocked);
|
||||||
|
|
||||||
return relationship;
|
return relationship;
|
||||||
}
|
}
|
||||||
@@ -114,19 +123,24 @@ public class RelationshipService(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await PurgeRelationshipCache(sender.Id, target.Id, RelationshipStatus.Pending);
|
||||||
|
|
||||||
return relationship;
|
return relationship;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteFriendRequest(Guid accountId, Guid relatedId)
|
public async Task DeleteFriendRequest(Guid accountId, Guid relatedId)
|
||||||
{
|
{
|
||||||
var relationship = await GetRelationship(accountId, relatedId, RelationshipStatus.Pending);
|
if (accountId == Guid.Empty || relatedId == Guid.Empty)
|
||||||
if (relationship is null) throw new ArgumentException("Friend request was not found.");
|
throw new ArgumentException("Account IDs cannot be empty.");
|
||||||
|
|
||||||
await db.AccountRelationships
|
var affectedRows = await db.AccountRelationships
|
||||||
.Where(r => r.AccountId == accountId && r.RelatedId == relatedId && r.Status == RelationshipStatus.Pending)
|
.Where(r => r.AccountId == accountId && r.RelatedId == relatedId && r.Status == RelationshipStatus.Pending)
|
||||||
.ExecuteDeleteAsync();
|
.ExecuteDeleteAsync();
|
||||||
|
|
||||||
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
|
if (affectedRows == 0)
|
||||||
|
throw new ArgumentException("Friend request was not found.");
|
||||||
|
|
||||||
|
await PurgeRelationshipCache(accountId, relatedId, RelationshipStatus.Pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SnAccountRelationship> AcceptFriendRelationship(
|
public async Task<SnAccountRelationship> AcceptFriendRelationship(
|
||||||
@@ -155,7 +169,7 @@ public class RelationshipService(
|
|||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
|
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId, RelationshipStatus.Friends, status);
|
||||||
|
|
||||||
return relationshipBackward;
|
return relationshipBackward;
|
||||||
}
|
}
|
||||||
@@ -165,11 +179,12 @@ public class RelationshipService(
|
|||||||
var relationship = await GetRelationship(accountId, relatedId);
|
var relationship = await GetRelationship(accountId, relatedId);
|
||||||
if (relationship is null) throw new ArgumentException("There is no relationship between you and the user.");
|
if (relationship is null) throw new ArgumentException("There is no relationship between you and the user.");
|
||||||
if (relationship.Status == status) return relationship;
|
if (relationship.Status == status) return relationship;
|
||||||
|
var oldStatus = relationship.Status;
|
||||||
relationship.Status = status;
|
relationship.Status = status;
|
||||||
db.Update(relationship);
|
db.Update(relationship);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
await PurgeRelationshipCache(accountId, relatedId);
|
await PurgeRelationshipCache(accountId, relatedId, oldStatus, status);
|
||||||
|
|
||||||
return relationship;
|
return relationship;
|
||||||
}
|
}
|
||||||
@@ -181,21 +196,7 @@ public class RelationshipService(
|
|||||||
|
|
||||||
public async Task<List<Guid>> ListAccountFriends(Guid accountId)
|
public async Task<List<Guid>> ListAccountFriends(Guid accountId)
|
||||||
{
|
{
|
||||||
var cacheKey = $"{UserFriendsCacheKeyPrefix}{accountId}";
|
return await GetCachedRelationships(accountId, RelationshipStatus.Friends, UserFriendsCacheKeyPrefix);
|
||||||
var friends = await cache.GetAsync<List<Guid>>(cacheKey);
|
|
||||||
|
|
||||||
if (friends == null)
|
|
||||||
{
|
|
||||||
friends = await db.AccountRelationships
|
|
||||||
.Where(r => r.RelatedId == accountId)
|
|
||||||
.Where(r => r.Status == RelationshipStatus.Friends)
|
|
||||||
.Select(r => r.AccountId)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
await cache.SetAsync(cacheKey, friends, TimeSpan.FromHours(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
return friends ?? [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<Guid>> ListAccountBlocked(SnAccount account)
|
public async Task<List<Guid>> ListAccountBlocked(SnAccount account)
|
||||||
@@ -205,21 +206,7 @@ public class RelationshipService(
|
|||||||
|
|
||||||
public async Task<List<Guid>> ListAccountBlocked(Guid accountId)
|
public async Task<List<Guid>> ListAccountBlocked(Guid accountId)
|
||||||
{
|
{
|
||||||
var cacheKey = $"{UserBlockedCacheKeyPrefix}{accountId}";
|
return await GetCachedRelationships(accountId, RelationshipStatus.Blocked, UserBlockedCacheKeyPrefix);
|
||||||
var blocked = await cache.GetAsync<List<Guid>>(cacheKey);
|
|
||||||
|
|
||||||
if (blocked == null)
|
|
||||||
{
|
|
||||||
blocked = await db.AccountRelationships
|
|
||||||
.Where(r => r.RelatedId == accountId)
|
|
||||||
.Where(r => r.Status == RelationshipStatus.Blocked)
|
|
||||||
.Select(r => r.AccountId)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
await cache.SetAsync(cacheKey, blocked, TimeSpan.FromHours(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
return blocked ?? [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> HasRelationshipWithStatus(Guid accountId, Guid relatedId,
|
public async Task<bool> HasRelationshipWithStatus(Guid accountId, Guid relatedId,
|
||||||
@@ -229,11 +216,52 @@ public class RelationshipService(
|
|||||||
return relationship is not null;
|
return relationship is not null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task PurgeRelationshipCache(Guid accountId, Guid relatedId)
|
private async Task<List<Guid>> GetCachedRelationships(Guid accountId, RelationshipStatus status, string cachePrefix)
|
||||||
{
|
{
|
||||||
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}");
|
if (accountId == Guid.Empty)
|
||||||
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relatedId}");
|
throw new ArgumentException("Account ID cannot be empty.");
|
||||||
await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{accountId}");
|
|
||||||
await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{relatedId}");
|
var cacheKey = $"{cachePrefix}{accountId}";
|
||||||
|
var relationships = await cache.GetAsync<List<Guid>>(cacheKey);
|
||||||
|
|
||||||
|
if (relationships == null)
|
||||||
|
{
|
||||||
|
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
|
||||||
|
relationships = await db.AccountRelationships
|
||||||
|
.Where(r => r.RelatedId == accountId)
|
||||||
|
.Where(r => r.Status == status)
|
||||||
|
.Where(r => r.ExpiredAt == null || r.ExpiredAt > now)
|
||||||
|
.Select(r => r.AccountId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
await cache.SetAsync(cacheKey, relationships, CacheExpiration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return relationships ?? new List<Guid>();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private async Task PurgeRelationshipCache(Guid accountId, Guid relatedId, params RelationshipStatus[] statuses)
|
||||||
|
{
|
||||||
|
if (statuses.Length == 0)
|
||||||
|
{
|
||||||
|
statuses = Enum.GetValues<RelationshipStatus>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var keysToRemove = new List<string>();
|
||||||
|
|
||||||
|
if (statuses.Contains(RelationshipStatus.Friends) || statuses.Contains(RelationshipStatus.Pending))
|
||||||
|
{
|
||||||
|
keysToRemove.Add($"{UserFriendsCacheKeyPrefix}{accountId}");
|
||||||
|
keysToRemove.Add($"{UserFriendsCacheKeyPrefix}{relatedId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statuses.Contains(RelationshipStatus.Blocked))
|
||||||
|
{
|
||||||
|
keysToRemove.Add($"{UserBlockedCacheKeyPrefix}{accountId}");
|
||||||
|
keysToRemove.Add($"{UserBlockedCacheKeyPrefix}{relatedId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var removeTasks = keysToRemove.Select(key => cache.RemoveAsync(key));
|
||||||
|
await Task.WhenAll(removeTasks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
134
DysonNetwork.Pass/Affiliation/AffiliationSpellController.cs
Normal file
134
DysonNetwork.Pass/Affiliation/AffiliationSpellController.cs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Affiliation;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("/api/affiliations")]
|
||||||
|
public class AffiliationSpellController(AppDatabase db, AffiliationSpellService ars) : ControllerBase
|
||||||
|
{
|
||||||
|
public class CreateAffiliationSpellRequest
|
||||||
|
{
|
||||||
|
[MaxLength(1024)] public string? Spell { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<SnAffiliationSpell>> CreateSpell([FromBody] CreateAffiliationSpellRequest request)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var spell = await ars.CreateAffiliationSpell(currentUser.Id, request.Spell);
|
||||||
|
return Ok(spell);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException e)
|
||||||
|
{
|
||||||
|
return BadRequest(e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<List<SnAffiliationSpell>>> ListCreatedSpells(
|
||||||
|
[FromQuery(Name = "order")] string orderBy = "date",
|
||||||
|
[FromQuery(Name = "desc")] bool orderDesc = false,
|
||||||
|
[FromQuery] int take = 10,
|
||||||
|
[FromQuery] int offset = 0
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var queryable = db.AffiliationSpells
|
||||||
|
.Where(s => s.AccountId == currentUser.Id)
|
||||||
|
.AsQueryable();
|
||||||
|
|
||||||
|
queryable = orderBy switch
|
||||||
|
{
|
||||||
|
"usage" => orderDesc
|
||||||
|
? queryable.OrderByDescending(q => q.Results.Count)
|
||||||
|
: queryable.OrderBy(q => q.Results.Count),
|
||||||
|
_ => orderDesc
|
||||||
|
? queryable.OrderByDescending(q => q.CreatedAt)
|
||||||
|
: queryable.OrderBy(q => q.CreatedAt)
|
||||||
|
};
|
||||||
|
|
||||||
|
var totalCount = queryable.Count();
|
||||||
|
Response.Headers["X-Total"] = totalCount.ToString();
|
||||||
|
|
||||||
|
var spells = await queryable
|
||||||
|
.Skip(offset)
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync();
|
||||||
|
return Ok(spells);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<SnAffiliationSpell>> GetSpell([FromRoute] Guid id)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var spell = await db.AffiliationSpells
|
||||||
|
.Where(s => s.AccountId == currentUser.Id)
|
||||||
|
.Where(s => s.Id == id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (spell is null) return NotFound();
|
||||||
|
|
||||||
|
return Ok(spell);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}/results")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<List<SnAffiliationResult>>> ListResults(
|
||||||
|
[FromRoute] Guid id,
|
||||||
|
[FromQuery(Name = "desc")] bool orderDesc = false,
|
||||||
|
[FromQuery] int take = 10,
|
||||||
|
[FromQuery] int offset = 0
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var queryable = db.AffiliationResults
|
||||||
|
.Include(r => r.Spell)
|
||||||
|
.Where(r => r.Spell.AccountId == currentUser.Id)
|
||||||
|
.Where(r => r.SpellId == id)
|
||||||
|
.AsQueryable();
|
||||||
|
|
||||||
|
// Order by creation date
|
||||||
|
queryable = orderDesc
|
||||||
|
? queryable.OrderByDescending(r => r.CreatedAt)
|
||||||
|
: queryable.OrderBy(r => r.CreatedAt);
|
||||||
|
|
||||||
|
var totalCount = queryable.Count();
|
||||||
|
Response.Headers["X-Total"] = totalCount.ToString();
|
||||||
|
|
||||||
|
var results = await queryable
|
||||||
|
.Skip(offset)
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync();
|
||||||
|
return Ok(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult> DeleteSpell([FromRoute] Guid id)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var spell = await db.AffiliationSpells
|
||||||
|
.Where(s => s.AccountId == currentUser.Id)
|
||||||
|
.Where(s => s.Id == id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (spell is null) return NotFound();
|
||||||
|
|
||||||
|
db.AffiliationSpells.Remove(spell);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
62
DysonNetwork.Pass/Affiliation/AffiliationSpellService.cs
Normal file
62
DysonNetwork.Pass/Affiliation/AffiliationSpellService.cs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Affiliation;
|
||||||
|
|
||||||
|
public class AffiliationSpellService(AppDatabase db)
|
||||||
|
{
|
||||||
|
public async Task<SnAffiliationSpell> CreateAffiliationSpell(Guid accountId, string? spellWord)
|
||||||
|
{
|
||||||
|
spellWord ??= _GenerateRandomString(8);
|
||||||
|
if (await CheckAffiliationSpellHasTaken(spellWord))
|
||||||
|
throw new InvalidOperationException("The spell has been taken.");
|
||||||
|
|
||||||
|
var spell = new SnAffiliationSpell
|
||||||
|
{
|
||||||
|
AccountId = accountId,
|
||||||
|
Spell = spellWord
|
||||||
|
};
|
||||||
|
|
||||||
|
db.AffiliationSpells.Add(spell);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return spell;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SnAffiliationResult> CreateAffiliationResult(string spellWord, string resourceId)
|
||||||
|
{
|
||||||
|
var spell =
|
||||||
|
await db.AffiliationSpells.FirstOrDefaultAsync(a => a.Spell == spellWord);
|
||||||
|
if (spell is null) throw new InvalidOperationException("The spell was not found.");
|
||||||
|
|
||||||
|
var result = new SnAffiliationResult
|
||||||
|
{
|
||||||
|
Spell = spell,
|
||||||
|
ResourceIdentifier = resourceId
|
||||||
|
};
|
||||||
|
db.AffiliationResults.Add(result);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> CheckAffiliationSpellHasTaken(string spellWord)
|
||||||
|
{
|
||||||
|
return await db.AffiliationSpells.AnyAsync(s => s.Spell == spellWord);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string _GenerateRandomString(int length)
|
||||||
|
{
|
||||||
|
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||||
|
var result = new char[length];
|
||||||
|
using var rng = RandomNumberGenerator.Create();
|
||||||
|
for (var i = 0; i < length; i++)
|
||||||
|
{
|
||||||
|
var bytes = new byte[1];
|
||||||
|
rng.GetBytes(bytes);
|
||||||
|
result[i] = chars[bytes[0] % chars.Length];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new string(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,8 +154,13 @@ 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}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,12 +320,21 @@ 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)
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using System;
|
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
@@ -250,15 +249,17 @@ public abstract class OidcService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a challenge and session for an authenticated user
|
/// Creates a session for an authenticated user
|
||||||
/// Also creates or updates the account connection
|
/// Also creates or updates the account connection
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<SnAuthChallenge> CreateChallengeForUserAsync(
|
public async Task<SnAuthSession> CreateSessionForUserAsync(
|
||||||
OidcUserInfo userInfo,
|
OidcUserInfo userInfo,
|
||||||
SnAccount account,
|
SnAccount account,
|
||||||
HttpContext request,
|
HttpContext request,
|
||||||
string deviceId,
|
string deviceId,
|
||||||
string? deviceName = null
|
string? deviceName = null,
|
||||||
|
ClientPlatform platform = ClientPlatform.Web,
|
||||||
|
SnAuthSession? parentSession = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
// Create or update the account connection
|
// Create or update the account connection
|
||||||
@@ -282,28 +283,24 @@ public abstract class OidcService(
|
|||||||
await Db.AccountConnections.AddAsync(connection);
|
await Db.AccountConnections.AddAsync(connection);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a challenge that's already completed
|
// Create a session directly
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
var device = await auth.GetOrCreateDeviceAsync(account.Id, deviceId, deviceName, ClientPlatform.Ios);
|
var device = await auth.GetOrCreateDeviceAsync(account.Id, deviceId, deviceName, platform);
|
||||||
var challenge = new SnAuthChallenge
|
|
||||||
{
|
|
||||||
ExpiredAt = now.Plus(Duration.FromHours(1)),
|
|
||||||
StepTotal = await auth.DetectChallengeRisk(request.Request, account),
|
|
||||||
Type = ChallengeType.Oidc,
|
|
||||||
Audiences = [ProviderName],
|
|
||||||
Scopes = ["*"],
|
|
||||||
AccountId = account.Id,
|
|
||||||
ClientId = device.Id,
|
|
||||||
IpAddress = request.Connection.RemoteIpAddress?.ToString() ?? null,
|
|
||||||
UserAgent = request.Request.Headers.UserAgent,
|
|
||||||
};
|
|
||||||
challenge.StepRemain--;
|
|
||||||
if (challenge.StepRemain < 0) challenge.StepRemain = 0;
|
|
||||||
|
|
||||||
await Db.AuthChallenges.AddAsync(challenge);
|
var session = new SnAuthSession
|
||||||
|
{
|
||||||
|
AccountId = account.Id,
|
||||||
|
CreatedAt = now,
|
||||||
|
LastGrantedAt = now,
|
||||||
|
ParentSessionId = parentSession?.Id,
|
||||||
|
ClientId = device.Id,
|
||||||
|
ExpiredAt = now.Plus(Duration.FromDays(30))
|
||||||
|
};
|
||||||
|
|
||||||
|
await Db.AuthSessions.AddAsync(session);
|
||||||
await Db.SaveChangesAsync();
|
await Db.SaveChangesAsync();
|
||||||
|
|
||||||
return challenge;
|
return session;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ public class TokenAuthService(
|
|||||||
"AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})",
|
"AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})",
|
||||||
sessionId,
|
sessionId,
|
||||||
session.AccountId,
|
session.AccountId,
|
||||||
session.Challenge?.Scopes.Count,
|
session.Scopes.Count,
|
||||||
session.ExpiredAt
|
session.ExpiredAt
|
||||||
);
|
);
|
||||||
return (true, session, null);
|
return (true, session, null);
|
||||||
@@ -87,8 +87,7 @@ public class TokenAuthService(
|
|||||||
|
|
||||||
session = await db.AuthSessions
|
session = await db.AuthSessions
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(e => e.Challenge)
|
.Include(e => e.Client)
|
||||||
.ThenInclude(e => e.Client)
|
|
||||||
.Include(e => e.Account)
|
.Include(e => e.Account)
|
||||||
.ThenInclude(e => e.Profile)
|
.ThenInclude(e => e.Profile)
|
||||||
.FirstOrDefaultAsync(s => s.Id == sessionId);
|
.FirstOrDefaultAsync(s => s.Id == sessionId);
|
||||||
@@ -110,11 +109,11 @@ public class TokenAuthService(
|
|||||||
"AuthenticateTokenAsync: DB session loaded (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId}, appId={AppId}, scopes={ScopeCount}, ip={Ip}, uaLen={UaLen})",
|
"AuthenticateTokenAsync: DB session loaded (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId}, appId={AppId}, scopes={ScopeCount}, ip={Ip}, uaLen={UaLen})",
|
||||||
sessionId,
|
sessionId,
|
||||||
session.AccountId,
|
session.AccountId,
|
||||||
session.Challenge?.ClientId,
|
session.ClientId,
|
||||||
session.AppId,
|
session.AppId,
|
||||||
session.Challenge?.Scopes.Count,
|
session.Scopes.Count,
|
||||||
session.Challenge?.IpAddress,
|
session.IpAddress,
|
||||||
(session.Challenge?.UserAgent ?? string.Empty).Length
|
(session.UserAgent ?? string.Empty).Length
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.LogDebug("AuthenticateTokenAsync: enriching account with subscription (accountId={AccountId})", session.AccountId);
|
logger.LogDebug("AuthenticateTokenAsync: enriching account with subscription (accountId={AccountId})", session.AccountId);
|
||||||
@@ -143,7 +142,7 @@ public class TokenAuthService(
|
|||||||
"AuthenticateTokenAsync: success via DB (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId})",
|
"AuthenticateTokenAsync: success via DB (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId})",
|
||||||
sessionId,
|
sessionId,
|
||||||
session.AccountId,
|
session.AccountId,
|
||||||
session.Challenge?.ClientId
|
session.ClientId
|
||||||
);
|
);
|
||||||
return (true, session, null);
|
return (true, session, null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user