Compare commits
195 Commits
c08503d2f3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
4443da5660
|
|||
|
b193224a2c
|
|||
|
4e9c5733d1
|
|||
|
1c6b324b0d
|
|||
|
ded3a70cb7
|
|||
|
9e54b61eee
|
|||
|
43d89299c3
|
|||
|
1af11b2a99
|
|||
|
1a31d7cbe7
|
|||
|
f0d6772dca
|
|||
|
24836fc606
|
|||
|
0bc77b948c
|
|||
|
f792d43ab9
|
|||
|
1b45be225a
|
|||
|
7811545726
|
|||
|
213608d4f0
|
|||
|
bca6a2ffde
|
|||
|
885b895a3a
|
|||
|
08941a282b
|
|||
|
4fd455acbf
|
|||
|
5ff1539f18
|
|||
|
3c023a71b1
|
|||
|
49d8eaa7b2
|
|||
|
16a37549fe
|
|||
|
2aff62c64f
|
|||
|
a49d485943
|
|||
|
4c65602465
|
|||
|
4242953969
|
|||
|
c9530ac8b5
|
|||
|
4ba7d38d78
|
|||
|
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
|
|||
|
1647aa2f1e
|
|||
|
b137021b1f
|
|||
|
ffca94f789
|
|||
|
e2b2bdd262
|
|||
|
ce715cd6b0
|
|||
|
f7b3926338
|
|||
|
68cd23d64f
|
|||
|
db7d994039
|
|||
|
741ed18ce5
|
|||
|
2bfb50cc71
|
|||
|
db98fa240e
|
|||
|
d96937aabc
|
|||
|
dc0be3467f
|
|||
|
6101de741f
|
|||
|
6c8ad05872
|
|||
|
f5b37e9419
|
|||
|
ce5f3434eb
|
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
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ var builder = DistributedApplication.CreateBuilder(args);
|
|||||||
|
|
||||||
var isDev = builder.Environment.IsDevelopment();
|
var isDev = builder.Environment.IsDevelopment();
|
||||||
|
|
||||||
var cache = builder.AddRedis("cache");
|
var cache = builder.AddRedis("Cache");
|
||||||
var queue = builder.AddNats("queue").WithJetStream();
|
var queue = builder.AddNats("Queue").WithJetStream();
|
||||||
|
|
||||||
var ringService = builder.AddProject<Projects.DysonNetwork_Ring>("ring");
|
var ringService = builder.AddProject<Projects.DysonNetwork_Ring>("ring");
|
||||||
var passService = builder.AddProject<Projects.DysonNetwork_Pass>("pass")
|
var passService = builder.AddProject<Projects.DysonNetwork_Pass>("pass")
|
||||||
@@ -26,11 +26,17 @@ var insightService = builder.AddProject<Projects.DysonNetwork_Insight>("insight"
|
|||||||
.WithReference(ringService)
|
.WithReference(ringService)
|
||||||
.WithReference(sphereService)
|
.WithReference(sphereService)
|
||||||
.WithReference(developService);
|
.WithReference(developService);
|
||||||
|
var zoneService = builder.AddProject<Projects.DysonNetwork_Zone>("zone")
|
||||||
|
.WithReference(passService)
|
||||||
|
.WithReference(ringService)
|
||||||
|
.WithReference(sphereService)
|
||||||
|
.WithReference(developService)
|
||||||
|
.WithReference(insightService);
|
||||||
|
|
||||||
passService.WithReference(developService).WithReference(driveService);
|
passService.WithReference(developService).WithReference(driveService);
|
||||||
|
|
||||||
List<IResourceBuilder<ProjectResource>> services =
|
List<IResourceBuilder<ProjectResource>> services =
|
||||||
[ringService, passService, driveService, sphereService, developService, insightService];
|
[ringService, passService, driveService, sphereService, developService, insightService, zoneService];
|
||||||
|
|
||||||
for (var idx = 0; idx < services.Count; idx++)
|
for (var idx = 0; idx < services.Count; idx++)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,28 +1,29 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<Sdk Name="Aspire.AppHost.Sdk" Version="9.5.2" />
|
<Sdk Name="Aspire.AppHost.Sdk" Version="13.0.0"/>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<UserSecretsId>a68b3195-a00d-40c2-b5ed-d675356b7cde</UserSecretsId>
|
<UserSecretsId>a68b3195-a00d-40c2-b5ed-d675356b7cde</UserSecretsId>
|
||||||
<RootNamespace>DysonNetwork.Control</RootNamespace>
|
<RootNamespace>DysonNetwork.Control</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.5.2" />
|
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.0.0"/>
|
||||||
<PackageReference Include="Aspire.Hosting.Docker" Version="9.4.2-preview.1.25428.12" />
|
<PackageReference Include="Aspire.Hosting.Docker" Version="13.0.0-preview.1.25560.3"/>
|
||||||
<PackageReference Include="Aspire.Hosting.Nats" Version="9.5.2" />
|
<PackageReference Include="Aspire.Hosting.Nats" Version="13.0.0"/>
|
||||||
<PackageReference Include="Aspire.Hosting.Redis" Version="9.5.2" />
|
<PackageReference Include="Aspire.Hosting.Redis" Version="13.0.0"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DysonNetwork.Develop\DysonNetwork.Develop.csproj" />
|
<ProjectReference Include="..\DysonNetwork.Develop\DysonNetwork.Develop.csproj"/>
|
||||||
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
|
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj"/>
|
||||||
<ProjectReference Include="..\DysonNetwork.Pass\DysonNetwork.Pass.csproj" />
|
<ProjectReference Include="..\DysonNetwork.Pass\DysonNetwork.Pass.csproj"/>
|
||||||
<ProjectReference Include="..\DysonNetwork.Ring\DysonNetwork.Ring.csproj" />
|
<ProjectReference Include="..\DysonNetwork.Ring\DysonNetwork.Ring.csproj"/>
|
||||||
<ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" />
|
<ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj"/>
|
||||||
<ProjectReference Include="..\DysonNetwork.Gateway\DysonNetwork.Gateway.csproj" />
|
<ProjectReference Include="..\DysonNetwork.Gateway\DysonNetwork.Gateway.csproj"/>
|
||||||
<ProjectReference Include="..\DysonNetwork.Insight\DysonNetwork.Insight.csproj" />
|
<ProjectReference Include="..\DysonNetwork.Insight\DysonNetwork.Insight.csproj"/>
|
||||||
</ItemGroup>
|
<ProjectReference Include="..\DysonNetwork.Zone\DysonNetwork.Zone.csproj"/>
|
||||||
</Project>
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": true,
|
"launchBrowser": true,
|
||||||
"applicationUrl": "https://localhost:17025;http://localhost:15057",
|
"applicationUrl": "https://localhost:17169;http://localhost:15057",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||||
"DOTNET_ENVIRONMENT": "Development",
|
"DOTNET_ENVIRONMENT": "Development",
|
||||||
|
|||||||
357
DysonNetwork.Control/aspire-manifest.json
Normal file
357
DysonNetwork.Control/aspire-manifest.json
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
{
|
||||||
|
"$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();
|
||||||
|
|||||||
@@ -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,25 @@
|
|||||||
{
|
{
|
||||||
"Debug": true,
|
"Debug": true,
|
||||||
"BaseUrl": "http://localhost:5071",
|
"BaseUrl": "http://localhost:5071",
|
||||||
"SiteUrl": "https://solian.app",
|
"SiteUrl": "https://solian.app",
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"App": "Host=localhost;Port=5432;Database=dyson_develop;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
||||||
|
},
|
||||||
|
"KnownProxies": [
|
||||||
|
"127.0.0.1",
|
||||||
|
"::1"
|
||||||
|
],
|
||||||
|
"Swagger": {
|
||||||
|
"PublicBasePath": "/develop"
|
||||||
|
},
|
||||||
|
"Cache": {
|
||||||
|
"Serializer": "MessagePack"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"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,14 @@
|
|||||||
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.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;
|
||||||
|
|
||||||
namespace DysonNetwork.Drive;
|
namespace DysonNetwork.Drive;
|
||||||
|
|
||||||
@@ -21,7 +23,11 @@ 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<PersistentTask> Tasks { get; set; } = null!;
|
||||||
|
public DbSet<PersistentUploadTask> UploadTasks { get; set; } = null!; // Backward compatibility
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
@@ -39,52 +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();
|
||||||
// Automatically apply soft-delete filter to all entities inheriting BaseModel
|
|
||||||
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
|
||||||
{
|
|
||||||
if (!typeof(ModelBase).IsAssignableFrom(entityType.ClrType)) continue;
|
|
||||||
var method = typeof(AppDatabase)
|
|
||||||
.GetMethod(nameof(SetSoftDeleteFilter),
|
|
||||||
BindingFlags.NonPublic | BindingFlags.Static)!
|
|
||||||
.MakeGenericMethod(entityType.ClrType);
|
|
||||||
|
|
||||||
method.Invoke(null, [modelBuilder]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SetSoftDeleteFilter<TEntity>(ModelBuilder modelBuilder)
|
|
||||||
where TEntity : ModelBase
|
|
||||||
{
|
|
||||||
modelBuilder.Entity<TEntity>().HasQueryFilter(e => e.DeletedAt == null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,6 +102,45 @@ public class AppDatabaseRecyclingJob(AppDatabase db, ILogger<AppDatabaseRecyclin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class PersistentTaskCleanupJob(
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
|
ILogger<PersistentTaskCleanupJob> logger
|
||||||
|
) : IJob
|
||||||
|
{
|
||||||
|
public async Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Cleaning up stale persistent tasks...");
|
||||||
|
|
||||||
|
// Get the PersistentTaskService from DI
|
||||||
|
using var scope = serviceProvider.CreateScope();
|
||||||
|
var persistentTaskService = scope.ServiceProvider.GetService(typeof(PersistentTaskService));
|
||||||
|
|
||||||
|
if (persistentTaskService is PersistentTaskService service)
|
||||||
|
{
|
||||||
|
// Clean up tasks for all users (you might want to add user-specific logic here)
|
||||||
|
// For now, we'll clean up tasks older than 30 days for all users
|
||||||
|
var cutoff = SystemClock.Instance.GetCurrentInstant() - Duration.FromDays(30);
|
||||||
|
var tasksToClean = await service.GetUserTasksAsync(
|
||||||
|
Guid.Empty, // This would need to be adjusted for multi-user cleanup
|
||||||
|
status: TaskStatus.Completed | TaskStatus.Failed | TaskStatus.Cancelled | TaskStatus.Expired
|
||||||
|
);
|
||||||
|
|
||||||
|
var cleanedCount = 0;
|
||||||
|
foreach (var task in tasksToClean.Items.Where(t => t.UpdatedAt < cutoff))
|
||||||
|
{
|
||||||
|
await service.CancelTaskAsync(task.TaskId); // Or implement a proper cleanup method
|
||||||
|
cleanedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Cleaned up {Count} stale persistent tasks", cleanedCount);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogWarning("PersistentTaskService not found in DI container");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public class AppDatabaseFactory : IDesignTimeDbContextFactory<AppDatabase>
|
public class AppDatabaseFactory : IDesignTimeDbContextFactory<AppDatabase>
|
||||||
{
|
{
|
||||||
public AppDatabase CreateDbContext(string[] args)
|
public AppDatabase CreateDbContext(string[] args)
|
||||||
@@ -149,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>
|
||||||
|
|||||||
585
DysonNetwork.Drive/Index/FileIndexController.cs
Normal file
585
DysonNetwork.Drive/Index/FileIndexController.cs
Normal file
@@ -0,0 +1,585 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using DysonNetwork.Drive.Storage;
|
||||||
|
using DysonNetwork.Shared.Auth;
|
||||||
|
using DysonNetwork.Shared.Http;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Index;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("/api/index")]
|
||||||
|
[Authorize]
|
||||||
|
public class FileIndexController(
|
||||||
|
FileIndexService fileIndexService,
|
||||||
|
AppDatabase db,
|
||||||
|
ILogger<FileIndexController> logger
|
||||||
|
) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets files in a specific path for the current user
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path to browse (defaults to root "/")</param>
|
||||||
|
/// <param name="query">Optional query to filter files by name</param>
|
||||||
|
/// <param name="order">The field to order by (date, size, name - defaults to date)</param>
|
||||||
|
/// <param name="orderDesc">Whether to order in descending order (defaults to true)</param>
|
||||||
|
/// <returns>List of files in the specified path</returns>
|
||||||
|
[HttpGet("browse")]
|
||||||
|
public async Task<IActionResult> BrowseFiles(
|
||||||
|
[FromQuery] string path = "/",
|
||||||
|
[FromQuery] string? query = null,
|
||||||
|
[FromQuery] string order = "date",
|
||||||
|
[FromQuery] bool orderDesc = true
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fileIndexes = await fileIndexService.GetByPathAsync(accountId, path);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(query))
|
||||||
|
{
|
||||||
|
fileIndexes = fileIndexes
|
||||||
|
.Where(fi => fi.File.Name.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
fileIndexes = order.ToLower() switch
|
||||||
|
{
|
||||||
|
"name" => orderDesc ? fileIndexes.OrderByDescending(fi => fi.File.Name).ToList()
|
||||||
|
: fileIndexes.OrderBy(fi => fi.File.Name).ToList(),
|
||||||
|
"size" => orderDesc ? fileIndexes.OrderByDescending(fi => fi.File.Size).ToList()
|
||||||
|
: fileIndexes.OrderBy(fi => fi.File.Size).ToList(),
|
||||||
|
_ => orderDesc ? fileIndexes.OrderByDescending(fi => fi.File.CreatedAt).ToList()
|
||||||
|
: fileIndexes.OrderBy(fi => fi.File.CreatedAt).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all file indexes for this account to extract child folders
|
||||||
|
var allFileIndexes = await fileIndexService.GetByAccountIdAsync(accountId);
|
||||||
|
|
||||||
|
// Extract unique child folder paths
|
||||||
|
var childFolders = ExtractChildFolders(allFileIndexes, path);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Path = path,
|
||||||
|
Files = fileIndexes,
|
||||||
|
Folders = childFolders,
|
||||||
|
TotalCount = fileIndexes.Count
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to browse files for account {AccountId} at path {Path}", accountId, path);
|
||||||
|
return new ObjectResult(new ApiError
|
||||||
|
{
|
||||||
|
Code = "BROWSE_FAILED",
|
||||||
|
Message = "Failed to browse files",
|
||||||
|
Status = 500
|
||||||
|
}) { StatusCode = 500 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts unique child folder paths from all file indexes for a given parent path
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="allFileIndexes">All file indexes for the account</param>
|
||||||
|
/// <param name="parentPath">The parent path to find children for</param>
|
||||||
|
/// <returns>List of unique child folder names</returns>
|
||||||
|
private List<string> ExtractChildFolders(List<SnCloudFileIndex> allFileIndexes, string parentPath)
|
||||||
|
{
|
||||||
|
var normalizedParentPath = FileIndexService.NormalizePath(parentPath);
|
||||||
|
var childFolders = new HashSet<string>();
|
||||||
|
|
||||||
|
foreach (var index in allFileIndexes)
|
||||||
|
{
|
||||||
|
var normalizedIndexPath = FileIndexService.NormalizePath(index.Path);
|
||||||
|
|
||||||
|
// Check if this path is a direct child of the parent path
|
||||||
|
if (normalizedIndexPath.StartsWith(normalizedParentPath) &&
|
||||||
|
normalizedIndexPath != normalizedParentPath)
|
||||||
|
{
|
||||||
|
// Remove the parent path prefix to get the relative path
|
||||||
|
var relativePath = normalizedIndexPath.Substring(normalizedParentPath.Length);
|
||||||
|
|
||||||
|
// Extract the first folder name (direct child)
|
||||||
|
var firstSlashIndex = relativePath.IndexOf('/');
|
||||||
|
if (firstSlashIndex > 0)
|
||||||
|
{
|
||||||
|
var folderName = relativePath.Substring(0, firstSlashIndex);
|
||||||
|
childFolders.Add(folderName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return childFolders.OrderBy(f => f).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all files for the current user (across all paths)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="query">Optional query to filter files by name</param>
|
||||||
|
/// <param name="order">The field to order by (date, size, name - defaults to date)</param>
|
||||||
|
/// <param name="orderDesc">Whether to order in descending order (defaults to true)</param>
|
||||||
|
/// <returns>List of all files for the user</returns>
|
||||||
|
[HttpGet("all")]
|
||||||
|
public async Task<IActionResult> GetAllFiles(
|
||||||
|
[FromQuery] string? query = null,
|
||||||
|
[FromQuery] string order = "date",
|
||||||
|
[FromQuery] bool orderDesc = true
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fileIndexes = await fileIndexService.GetByAccountIdAsync(accountId);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(query))
|
||||||
|
{
|
||||||
|
fileIndexes = fileIndexes
|
||||||
|
.Where(fi => fi.File.Name.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
fileIndexes = order.ToLower() switch
|
||||||
|
{
|
||||||
|
"name" => orderDesc ? fileIndexes.OrderByDescending(fi => fi.File.Name).ToList()
|
||||||
|
: fileIndexes.OrderBy(fi => fi.File.Name).ToList(),
|
||||||
|
"size" => orderDesc ? fileIndexes.OrderByDescending(fi => fi.File.Size).ToList()
|
||||||
|
: fileIndexes.OrderBy(fi => fi.File.Size).ToList(),
|
||||||
|
_ => orderDesc ? fileIndexes.OrderByDescending(fi => fi.File.CreatedAt).ToList()
|
||||||
|
: fileIndexes.OrderBy(fi => fi.File.CreatedAt).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Files = fileIndexes,
|
||||||
|
TotalCount = fileIndexes.Count()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to get all files for account {AccountId}", accountId);
|
||||||
|
return new ObjectResult(new ApiError
|
||||||
|
{
|
||||||
|
Code = "GET_ALL_FAILED",
|
||||||
|
Message = "Failed to get files",
|
||||||
|
Status = 500
|
||||||
|
}) { StatusCode = 500 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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>
|
||||||
|
/// Moves a file to a new path
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="indexId">The file index ID</param>
|
||||||
|
/// <param name="newPath">The new path</param>
|
||||||
|
/// <returns>The updated file index</returns>
|
||||||
|
[HttpPost("move/{indexId}")]
|
||||||
|
public async Task<IActionResult> MoveFile(Guid indexId, [FromBody] MoveFileRequest request)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Verify ownership
|
||||||
|
var existingIndex = await db.FileIndexes
|
||||||
|
.Include(fi => fi.File)
|
||||||
|
.FirstOrDefaultAsync(fi => fi.Id == indexId && fi.AccountId == accountId);
|
||||||
|
|
||||||
|
if (existingIndex == null)
|
||||||
|
return new ObjectResult(ApiError.NotFound("File index")) { StatusCode = 404 };
|
||||||
|
|
||||||
|
var updatedIndex = await fileIndexService.UpdateAsync(indexId, request.NewPath);
|
||||||
|
|
||||||
|
if (updatedIndex == null)
|
||||||
|
return new ObjectResult(ApiError.NotFound("File index")) { StatusCode = 404 };
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
updatedIndex.FileId,
|
||||||
|
IndexId = updatedIndex.Id,
|
||||||
|
OldPath = existingIndex.Path,
|
||||||
|
NewPath = updatedIndex.Path,
|
||||||
|
Message = "File moved successfully"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to move file index {IndexId} for account {AccountId}", indexId, accountId);
|
||||||
|
return new ObjectResult(new ApiError
|
||||||
|
{
|
||||||
|
Code = "MOVE_FAILED",
|
||||||
|
Message = "Failed to move file",
|
||||||
|
Status = 500
|
||||||
|
}) { StatusCode = 500 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes a file index (does not delete the actual file by default)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="indexId">The file index ID</param>
|
||||||
|
/// <param name="deleteFile">Whether to also delete the actual file data</param>
|
||||||
|
/// <returns>Success message</returns>
|
||||||
|
[HttpDelete("remove/{indexId}")]
|
||||||
|
public async Task<IActionResult> RemoveFileIndex(Guid indexId, [FromQuery] bool deleteFile = false)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Verify ownership
|
||||||
|
var existingIndex = await db.FileIndexes
|
||||||
|
.Include(fi => fi.File)
|
||||||
|
.FirstOrDefaultAsync(fi => fi.Id == indexId && fi.AccountId == accountId);
|
||||||
|
|
||||||
|
if (existingIndex == null)
|
||||||
|
return new ObjectResult(ApiError.NotFound("File index")) { StatusCode = 404 };
|
||||||
|
|
||||||
|
var fileId = existingIndex.FileId;
|
||||||
|
var fileName = existingIndex.File.Name;
|
||||||
|
var filePath = existingIndex.Path;
|
||||||
|
|
||||||
|
// Remove the index
|
||||||
|
var removed = await fileIndexService.RemoveAsync(indexId);
|
||||||
|
|
||||||
|
if (!removed)
|
||||||
|
return new ObjectResult(ApiError.NotFound("File index")) { StatusCode = 404 };
|
||||||
|
|
||||||
|
// Optionally delete the actual file
|
||||||
|
if (!deleteFile)
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Message = deleteFile
|
||||||
|
? "File index and file data removed successfully"
|
||||||
|
: "File index removed successfully",
|
||||||
|
FileId = fileId,
|
||||||
|
FileName = fileName,
|
||||||
|
Path = filePath,
|
||||||
|
FileDataDeleted = deleteFile
|
||||||
|
});
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Check if there are any other indexes for this file
|
||||||
|
var remainingIndexes = await fileIndexService.GetByFileIdAsync(fileId);
|
||||||
|
if (remainingIndexes.Count == 0)
|
||||||
|
{
|
||||||
|
// No other indexes exist, safe to delete the file
|
||||||
|
var file = await db.Files.FirstOrDefaultAsync(f => f.Id == fileId.ToString());
|
||||||
|
if (file != null)
|
||||||
|
{
|
||||||
|
db.Files.Remove(file);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
logger.LogInformation("Deleted file {FileId} ({FileName}) as requested", fileId, fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to delete file {FileId} while removing index", fileId);
|
||||||
|
// Continue even if file deletion fails
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Message = deleteFile
|
||||||
|
? "File index and file data removed successfully"
|
||||||
|
: "File index removed successfully",
|
||||||
|
FileId = fileId,
|
||||||
|
FileName = fileName,
|
||||||
|
Path = filePath,
|
||||||
|
FileDataDeleted = deleteFile
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to remove file index {IndexId} for account {AccountId}", indexId, accountId);
|
||||||
|
return new ObjectResult(new ApiError
|
||||||
|
{
|
||||||
|
Code = "REMOVE_FAILED",
|
||||||
|
Message = "Failed to remove file",
|
||||||
|
Status = 500
|
||||||
|
}) { StatusCode = 500 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes all file indexes in a specific path
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path to clear</param>
|
||||||
|
/// <param name="deleteFiles">Whether to also delete the actual file data</param>
|
||||||
|
/// <returns>Success message with count of removed items</returns>
|
||||||
|
[HttpDelete("clear-path")]
|
||||||
|
public async Task<IActionResult> ClearPath([FromQuery] string path = "/", [FromQuery] bool deleteFiles = false)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var removedCount = await fileIndexService.RemoveByPathAsync(accountId, path);
|
||||||
|
|
||||||
|
if (!deleteFiles || removedCount <= 0)
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Message = deleteFiles
|
||||||
|
? $"Cleared {removedCount} file indexes from path and deleted orphaned files"
|
||||||
|
: $"Cleared {removedCount} file indexes from path",
|
||||||
|
Path = path,
|
||||||
|
RemovedCount = removedCount,
|
||||||
|
FilesDeleted = deleteFiles
|
||||||
|
});
|
||||||
|
// Get the files that were in this path and check if they have other indexes
|
||||||
|
var filesInPath = await fileIndexService.GetByPathAsync(accountId, path);
|
||||||
|
var fileIdsToCheck = filesInPath.Select(fi => fi.FileId).Distinct().ToList();
|
||||||
|
|
||||||
|
foreach (var fileId in fileIdsToCheck)
|
||||||
|
{
|
||||||
|
var remainingIndexes = await fileIndexService.GetByFileIdAsync(fileId);
|
||||||
|
if (remainingIndexes.Count != 0) continue;
|
||||||
|
// No other indexes exist, safe to delete the file
|
||||||
|
var file = await db.Files.FirstOrDefaultAsync(f => f.Id == fileId.ToString());
|
||||||
|
if (file == null) continue;
|
||||||
|
db.Files.Remove(file);
|
||||||
|
logger.LogInformation("Deleted orphaned file {FileId} after clearing path {Path}", fileId, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Message = deleteFiles
|
||||||
|
? $"Cleared {removedCount} file indexes from path and deleted orphaned files"
|
||||||
|
: $"Cleared {removedCount} file indexes from path",
|
||||||
|
Path = path,
|
||||||
|
RemovedCount = removedCount,
|
||||||
|
FilesDeleted = deleteFiles
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to clear path {Path} for account {AccountId}", path, accountId);
|
||||||
|
return new ObjectResult(new ApiError
|
||||||
|
{
|
||||||
|
Code = "CLEAR_PATH_FAILED",
|
||||||
|
Message = "Failed to clear path",
|
||||||
|
Status = 500
|
||||||
|
}) { StatusCode = 500 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new file index (useful for adding existing files to a path)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The create index request</param>
|
||||||
|
/// <returns>The created file index</returns>
|
||||||
|
[HttpPost("create")]
|
||||||
|
public async Task<IActionResult> CreateFileIndex([FromBody] CreateFileIndexRequest request)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Verify the file exists and belongs to the user
|
||||||
|
var file = await db.Files.FirstOrDefaultAsync(f => f.Id == request.FileId);
|
||||||
|
if (file == null)
|
||||||
|
return new ObjectResult(ApiError.NotFound("File")) { StatusCode = 404 };
|
||||||
|
|
||||||
|
if (file.AccountId != accountId)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
|
||||||
|
|
||||||
|
// Check if index already exists for this file and path
|
||||||
|
var existingIndex = await db.FileIndexes
|
||||||
|
.FirstOrDefaultAsync(fi =>
|
||||||
|
fi.FileId == request.FileId && fi.Path == request.Path && fi.AccountId == accountId);
|
||||||
|
|
||||||
|
if (existingIndex != null)
|
||||||
|
return new ObjectResult(ApiError.Validation(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
{ "fileId", ["File index already exists for this path"] }
|
||||||
|
})) { StatusCode = 400 };
|
||||||
|
|
||||||
|
var fileIndex = await fileIndexService.CreateAsync(request.Path, request.FileId, accountId);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
IndexId = fileIndex.Id,
|
||||||
|
fileIndex.FileId,
|
||||||
|
fileIndex.Path,
|
||||||
|
Message = "File index created successfully"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to create file index for file {FileId} at path {Path} for account {AccountId}",
|
||||||
|
request.FileId, request.Path, accountId);
|
||||||
|
return new ObjectResult(new ApiError
|
||||||
|
{
|
||||||
|
Code = "CREATE_INDEX_FAILED",
|
||||||
|
Message = "Failed to create file index",
|
||||||
|
Status = 500
|
||||||
|
}) { StatusCode = 500 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches for files by name or metadata
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="query">The search query</param>
|
||||||
|
/// <param name="path">Optional path to limit search to</param>
|
||||||
|
/// <returns>Matching files</returns>
|
||||||
|
[HttpGet("search")]
|
||||||
|
public async Task<IActionResult> SearchFiles([FromQuery] string query, [FromQuery] string? path = null)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Build the query with all conditions at once
|
||||||
|
var searchTerm = query.ToLower();
|
||||||
|
var fileIndexes = await db.FileIndexes
|
||||||
|
.Where(fi => fi.AccountId == accountId)
|
||||||
|
.Include(fi => fi.File)
|
||||||
|
.Where(fi =>
|
||||||
|
(string.IsNullOrEmpty(path) || fi.Path == FileIndexService.NormalizePath(path)) &&
|
||||||
|
(fi.File.Name.ToLower().Contains(searchTerm) ||
|
||||||
|
(fi.File.Description != null && fi.File.Description.ToLower().Contains(searchTerm)) ||
|
||||||
|
(fi.File.MimeType != null && fi.File.MimeType.ToLower().Contains(searchTerm))))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Query = query,
|
||||||
|
Path = path,
|
||||||
|
Results = fileIndexes,
|
||||||
|
TotalCount = fileIndexes.Count()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to search files for account {AccountId} with query {Query}", accountId, query);
|
||||||
|
return new ObjectResult(new ApiError
|
||||||
|
{
|
||||||
|
Code = "SEARCH_FAILED",
|
||||||
|
Message = "Failed to search files",
|
||||||
|
Status = 500
|
||||||
|
}) { StatusCode = 500 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MoveFileRequest
|
||||||
|
{
|
||||||
|
public string NewPath { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateFileIndexRequest
|
||||||
|
{
|
||||||
|
[MaxLength(32)] public string FileId { get; set; } = null!;
|
||||||
|
public string Path { get; set; } = null!;
|
||||||
|
}
|
||||||
197
DysonNetwork.Drive/Index/FileIndexService.cs
Normal file
197
DysonNetwork.Drive/Index/FileIndexService.cs
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Index;
|
||||||
|
|
||||||
|
public class FileIndexService(AppDatabase db)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new file index entry
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The parent folder path with a trailing slash</param>
|
||||||
|
/// <param name="fileId">The file ID</param>
|
||||||
|
/// <param name="accountId">The account ID</param>
|
||||||
|
/// <returns>The created file index</returns>
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Check if a file with the same name already exists in the same path for this account
|
||||||
|
var existingFileIndex = await db.FileIndexes
|
||||||
|
.FirstOrDefaultAsync(fi => fi.AccountId == accountId && fi.Path == normalizedPath && fi.FileId == fileId);
|
||||||
|
|
||||||
|
if (existingFileIndex != null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"A file with ID '{fileId}' already exists in path '{normalizedPath}' for account '{accountId}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileIndex = new SnCloudFileIndex
|
||||||
|
{
|
||||||
|
Path = normalizedPath,
|
||||||
|
FileId = fileId,
|
||||||
|
AccountId = accountId
|
||||||
|
};
|
||||||
|
|
||||||
|
db.FileIndexes.Add(fileIndex);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return fileIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an existing file index entry by removing the old one and creating a new one
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The file index ID</param>
|
||||||
|
/// <param name="newPath">The new parent folder path with trailing slash</param>
|
||||||
|
/// <returns>The updated file index</returns>
|
||||||
|
public async Task<SnCloudFileIndex?> UpdateAsync(Guid id, string newPath)
|
||||||
|
{
|
||||||
|
var fileIndex = await db.FileIndexes.FindAsync(id);
|
||||||
|
if (fileIndex == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Since properties are init-only, we need to remove the old index and create a new one
|
||||||
|
db.FileIndexes.Remove(fileIndex);
|
||||||
|
|
||||||
|
var newFileIndex = new SnCloudFileIndex
|
||||||
|
{
|
||||||
|
Path = NormalizePath(newPath),
|
||||||
|
FileId = fileIndex.FileId,
|
||||||
|
AccountId = fileIndex.AccountId
|
||||||
|
};
|
||||||
|
|
||||||
|
db.FileIndexes.Add(newFileIndex);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return newFileIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes a file index entry by ID
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The file index ID</param>
|
||||||
|
/// <returns>True if the index was found and removed, false otherwise</returns>
|
||||||
|
public async Task<bool> RemoveAsync(Guid id)
|
||||||
|
{
|
||||||
|
var fileIndex = await db.FileIndexes.FindAsync(id);
|
||||||
|
if (fileIndex == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
db.FileIndexes.Remove(fileIndex);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes file index entries by file ID
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fileId">The file ID</param>
|
||||||
|
/// <returns>The number of indexes removed</returns>
|
||||||
|
public async Task<int> RemoveByFileIdAsync(string fileId)
|
||||||
|
{
|
||||||
|
var indexes = await db.FileIndexes
|
||||||
|
.Where(fi => fi.FileId == fileId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (indexes.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
db.FileIndexes.RemoveRange(indexes);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return indexes.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes file index entries by account ID and path
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="accountId">The account ID</param>
|
||||||
|
/// <param name="path">The parent folder path</param>
|
||||||
|
/// <returns>The number of indexes removed</returns>
|
||||||
|
public async Task<int> RemoveByPathAsync(Guid accountId, string path)
|
||||||
|
{
|
||||||
|
var normalizedPath = NormalizePath(path);
|
||||||
|
|
||||||
|
var indexes = await db.FileIndexes
|
||||||
|
.Where(fi => fi.AccountId == accountId && fi.Path == normalizedPath)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (!indexes.Any())
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
db.FileIndexes.RemoveRange(indexes);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return indexes.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets file indexes by account ID and path
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="accountId">The account ID</param>
|
||||||
|
/// <param name="path">The parent folder path</param>
|
||||||
|
/// <returns>List of file indexes</returns>
|
||||||
|
public async Task<List<SnCloudFileIndex>> GetByPathAsync(Guid accountId, string path)
|
||||||
|
{
|
||||||
|
var normalizedPath = NormalizePath(path);
|
||||||
|
|
||||||
|
return await db.FileIndexes
|
||||||
|
.Where(fi => fi.AccountId == accountId && fi.Path == normalizedPath)
|
||||||
|
.Include(fi => fi.File)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets file indexes by file ID
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fileId">The file ID</param>
|
||||||
|
/// <returns>List of file indexes</returns>
|
||||||
|
public async Task<List<SnCloudFileIndex>> GetByFileIdAsync(string fileId)
|
||||||
|
{
|
||||||
|
return await db.FileIndexes
|
||||||
|
.Where(fi => fi.FileId == fileId)
|
||||||
|
.Include(fi => fi.File)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all file indexes for an account
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="accountId">The account ID</param>
|
||||||
|
/// <returns>List of file indexes</returns>
|
||||||
|
public async Task<List<SnCloudFileIndex>> GetByAccountIdAsync(Guid accountId)
|
||||||
|
{
|
||||||
|
return await db.FileIndexes
|
||||||
|
.Where(fi => fi.AccountId == accountId)
|
||||||
|
.Include(fi => fi.File)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalizes the path to ensure it has a trailing slash and is query-safe
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The original path</param>
|
||||||
|
/// <returns>The normalized path</returns>
|
||||||
|
public static string NormalizePath(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path))
|
||||||
|
return "/";
|
||||||
|
|
||||||
|
// Ensure the path starts with a slash
|
||||||
|
if (!path.StartsWith('/'))
|
||||||
|
path = "/" + path;
|
||||||
|
|
||||||
|
// Ensure the path ends with a slash (unless it's just the root)
|
||||||
|
if (path != "/" && !path.EndsWith('/'))
|
||||||
|
path += "/";
|
||||||
|
|
||||||
|
// Make path query-safe by removing problematic characters
|
||||||
|
// This is a basic implementation - you might want to add more robust validation
|
||||||
|
path = path.Replace("%", "").Replace("'", "").Replace("\"", "");
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
341
DysonNetwork.Drive/Index/README.md
Normal file
341
DysonNetwork.Drive/Index/README.md
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
# File Indexing System Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The File Indexing System provides a hierarchical file organization layer on top of the existing file storage system in DysonNetwork Drive. It allows users to organize their files in folders and paths while maintaining the underlying file storage capabilities.
|
||||||
|
|
||||||
|
When using with the gateway, replace the `/api` with the `/drive` in the path.
|
||||||
|
And all the arguments will be transformed into snake case via the gateway.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
1. **SnCloudFileIndex Model** - Represents the file-to-path mapping
|
||||||
|
2. **FileIndexService** - Business logic for file index operations
|
||||||
|
3. **FileIndexController** - REST API endpoints for file management
|
||||||
|
4. **FileUploadController Integration** - Automatic index creation during upload
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- File Indexes table
|
||||||
|
CREATE TABLE "FileIndexes" (
|
||||||
|
"Id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"Path" character varying(8192) NOT NULL,
|
||||||
|
"FileId" uuid NOT NULL,
|
||||||
|
"AccountId" uuid NOT NULL,
|
||||||
|
"CreatedAt" timestamp with time zone NOT NULL DEFAULT (now() at time zone 'utc'),
|
||||||
|
"UpdatedAt" timestamp with time zone NOT NULL DEFAULT (now() at time zone 'utc'),
|
||||||
|
CONSTRAINT "PK_FileIndexes" PRIMARY KEY ("Id"),
|
||||||
|
CONSTRAINT "FK_FileIndexes_Files_FileId" FOREIGN KEY ("FileId") REFERENCES "Files" ("Id") ON DELETE CASCADE,
|
||||||
|
INDEX "IX_FileIndexes_Path_AccountId" ("Path", "AccountId")
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Browse Files
|
||||||
|
**GET** `/api/index/browse?path=/documents/`
|
||||||
|
|
||||||
|
Browse files in a specific path.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `path` (optional, default: "/") - The path to browse
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"path": "/documents/",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"id": "guid",
|
||||||
|
"path": "/documents/",
|
||||||
|
"fileId": "guid",
|
||||||
|
"accountId": "guid",
|
||||||
|
"createdAt": "2024-01-01T00:00:00Z",
|
||||||
|
"updatedAt": "2024-01-01T00:00:00Z",
|
||||||
|
"file": {
|
||||||
|
"id": "string",
|
||||||
|
"name": "document.pdf",
|
||||||
|
"size": 1024,
|
||||||
|
"mimeType": "application/pdf",
|
||||||
|
"hash": "sha256-hash",
|
||||||
|
"uploadedAt": "2024-01-01T00:00:00Z",
|
||||||
|
"expiredAt": null,
|
||||||
|
"hasCompression": false,
|
||||||
|
"hasThumbnail": true,
|
||||||
|
"isEncrypted": false,
|
||||||
|
"description": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalCount": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get All Files
|
||||||
|
**GET** `/api/index/all`
|
||||||
|
|
||||||
|
Get all files for the current user across all paths.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
// Same structure as browse endpoint
|
||||||
|
],
|
||||||
|
"totalCount": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Move File
|
||||||
|
**POST** `/api/index/move/{indexId}`
|
||||||
|
|
||||||
|
Move a file to a new path.
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `indexId` - The file index ID
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"newPath": "/archived/"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fileId": "guid",
|
||||||
|
"indexId": "guid",
|
||||||
|
"oldPath": "/documents/",
|
||||||
|
"newPath": "/archived/",
|
||||||
|
"message": "File moved successfully"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remove File Index
|
||||||
|
**DELETE** `/api/index/remove/{indexId}?deleteFile=false`
|
||||||
|
|
||||||
|
Remove a file index. Optionally delete the actual file data.
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `indexId` - The file index ID
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `deleteFile` (optional, default: false) - Whether to also delete the file data
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "File index removed successfully",
|
||||||
|
"fileId": "guid",
|
||||||
|
"fileName": "document.pdf",
|
||||||
|
"path": "/documents/",
|
||||||
|
"fileDataDeleted": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clear Path
|
||||||
|
**DELETE** `/api/index/clear-path?path=/temp/&deleteFiles=false`
|
||||||
|
|
||||||
|
Remove all file indexes in a specific path.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `path` (optional, default: "/") - The path to clear
|
||||||
|
- `deleteFiles` (optional, default: false) - Whether to also delete orphaned files
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Cleared 5 file indexes from path",
|
||||||
|
"path": "/temp/",
|
||||||
|
"removedCount": 5,
|
||||||
|
"filesDeleted": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create File Index
|
||||||
|
**POST** `/api/index/create`
|
||||||
|
|
||||||
|
Create a new file index for an existing file.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fileId": "guid",
|
||||||
|
"path": "/documents/"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"indexId": "guid",
|
||||||
|
"fileId": "guid",
|
||||||
|
"path": "/documents/",
|
||||||
|
"message": "File index created successfully"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search Files
|
||||||
|
**GET** `/api/index/search?query=report&path=/documents/`
|
||||||
|
|
||||||
|
Search for files by name or metadata.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `query` (required) - The search query
|
||||||
|
- `path` (optional) - Limit search to specific path
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"query": "report",
|
||||||
|
"path": "/documents/",
|
||||||
|
"results": [
|
||||||
|
// Same structure as browse endpoint
|
||||||
|
],
|
||||||
|
"totalCount": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Path Normalization
|
||||||
|
|
||||||
|
The system automatically normalizes paths to ensure consistency:
|
||||||
|
|
||||||
|
- **Trailing Slash**: All paths end with `/`
|
||||||
|
- **Root Path**: User home folder is represented as `/`
|
||||||
|
- **Query Safety**: Paths are validated to avoid SQL injection
|
||||||
|
- **Examples**:
|
||||||
|
- `/documents/` ✅ (correct)
|
||||||
|
- `/documents` → `/documents/` ✅ (normalized)
|
||||||
|
- `/documents/reports/` ✅ (correct)
|
||||||
|
- `/documents/reports` → `/documents/reports/` ✅ (normalized)
|
||||||
|
|
||||||
|
## File Upload Integration
|
||||||
|
|
||||||
|
When uploading files with the `FileUploadController`, you can specify a path to automatically create file indexes:
|
||||||
|
|
||||||
|
**Create Upload Task Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fileName": "document.pdf",
|
||||||
|
"fileSize": 1024,
|
||||||
|
"contentType": "application/pdf",
|
||||||
|
"hash": "sha256-hash",
|
||||||
|
"path": "/documents/" // New field for file indexing
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The system will automatically create a file index when the upload completes successfully.
|
||||||
|
|
||||||
|
## Service Methods
|
||||||
|
|
||||||
|
### FileIndexService
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class FileIndexService
|
||||||
|
{
|
||||||
|
// Create a new file index
|
||||||
|
Task<SnCloudFileIndex> CreateAsync(string path, Guid fileId, Guid accountId);
|
||||||
|
|
||||||
|
// Get files by path
|
||||||
|
Task<List<SnCloudFileIndex>> GetByPathAsync(Guid accountId, string path);
|
||||||
|
|
||||||
|
// Get all files for account
|
||||||
|
Task<List<SnCloudFileIndex>> GetByAccountIdAsync(Guid accountId);
|
||||||
|
|
||||||
|
// Get indexes for specific file
|
||||||
|
Task<List<SnCloudFileIndex>> GetByFileIdAsync(Guid fileId);
|
||||||
|
|
||||||
|
// Move file to new path
|
||||||
|
Task<SnCloudFileIndex?> UpdateAsync(Guid indexId, string newPath);
|
||||||
|
|
||||||
|
// Remove file index
|
||||||
|
Task<bool> RemoveAsync(Guid indexId);
|
||||||
|
|
||||||
|
// Remove all indexes in path
|
||||||
|
Task<int> RemoveByPathAsync(Guid accountId, string path);
|
||||||
|
|
||||||
|
// Normalize path format
|
||||||
|
public static string NormalizePath(string path);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The API returns appropriate HTTP status codes and error messages:
|
||||||
|
|
||||||
|
- **400 Bad Request**: Invalid input parameters
|
||||||
|
- **401 Unauthorized**: User not authenticated
|
||||||
|
- **403 Forbidden**: User lacks permission
|
||||||
|
- **404 Not Found**: Resource not found
|
||||||
|
- **500 Internal Server Error**: Server-side error
|
||||||
|
|
||||||
|
**Error Response Format:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "BROWSE_FAILED",
|
||||||
|
"message": "Failed to browse files",
|
||||||
|
"status": 500
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **Ownership Verification**: All operations verify that the user owns the file indexes
|
||||||
|
2. **Path Validation**: Paths are normalized and validated
|
||||||
|
3. **Cascade Deletion**: File indexes are automatically removed when files are deleted
|
||||||
|
4. **Safe File Deletion**: Files are only deleted when no other indexes reference them
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Upload File to Specific Path
|
||||||
|
```bash
|
||||||
|
# Create upload task with path
|
||||||
|
curl -X POST /api/files/upload/create \
|
||||||
|
-H "Authorization: Bearer {token}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"fileName": "report.pdf",
|
||||||
|
"fileSize": 2048,
|
||||||
|
"contentType": "application/pdf",
|
||||||
|
"path": "/documents/reports/"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Browse Files
|
||||||
|
```bash
|
||||||
|
curl -X GET "/api/index/browse?path=/documents/reports/" \
|
||||||
|
-H "Authorization: Bearer {token}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Move File
|
||||||
|
```bash
|
||||||
|
curl -X POST "/api/index/move/{indexId}" \
|
||||||
|
-H "Authorization: Bearer {token}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"newPath": "/archived/"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search Files
|
||||||
|
```bash
|
||||||
|
curl -X GET "/api/index/search?query=invoice&path=/documents/" \
|
||||||
|
-H "Authorization: Bearer {token}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Use Trailing Slashes**: Always include trailing slashes in paths
|
||||||
|
2. **Organize Hierarchically**: Use meaningful folder structures
|
||||||
|
3. **Search Efficiently**: Use the search endpoint instead of client-side filtering
|
||||||
|
4. **Clean Up**: Use the clear-path endpoint for temporary directories
|
||||||
|
5. **Monitor Usage**: Check total file counts for quota management
|
||||||
|
|
||||||
|
## Integration Notes
|
||||||
|
|
||||||
|
- The file indexing system works alongside the existing file storage
|
||||||
|
- Files can exist in multiple paths (hard links)
|
||||||
|
- File deletion is optional and only removes data when safe
|
||||||
|
- The system maintains referential integrity between files and indexes
|
||||||
567
DysonNetwork.Drive/Migrations/20251108191230_AddPersistentTask.Designer.cs
generated
Normal file
567
DysonNetwork.Drive/Migrations/20251108191230_AddPersistentTask.Designer.cs
generated
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
// <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("20251108191230_AddPersistentTask")]
|
||||||
|
partial class AddPersistentTask
|
||||||
|
{
|
||||||
|
/// <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.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<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.SnCloudFile", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("References");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Files");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPersistentTask : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "tasks",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
task_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||||
|
name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||||
|
description = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||||
|
type = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
status = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
progress = table.Column<double>(type: "double precision", nullable: false),
|
||||||
|
parameters = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: false),
|
||||||
|
results = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: false),
|
||||||
|
error_message = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||||
|
started_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
completed_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
last_activity = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||||
|
priority = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
estimated_duration_seconds = table.Column<long>(type: "bigint", nullable: true),
|
||||||
|
discriminator = table.Column<string>(type: "character varying(21)", maxLength: 21, nullable: false),
|
||||||
|
file_name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||||
|
file_size = table.Column<long>(type: "bigint", nullable: true),
|
||||||
|
content_type = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||||
|
chunk_size = table.Column<long>(type: "bigint", nullable: true),
|
||||||
|
chunks_count = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
chunks_uploaded = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
pool_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
bundle_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
encrypt_password = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||||
|
hash = table.Column<string>(type: "text", nullable: true),
|
||||||
|
uploaded_chunks = table.Column<List<int>>(type: "integer[]", 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_tasks", x => x.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "tasks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
632
DysonNetwork.Drive/Migrations/20251112135535_AddFileIndex.Designer.cs
generated
Normal file
632
DysonNetwork.Drive/Migrations/20251112135535_AddFileIndex.Designer.cs
generated
Normal file
@@ -0,0 +1,632 @@
|
|||||||
|
// <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("20251112135535_AddFileIndex")]
|
||||||
|
partial class AddFileIndex
|
||||||
|
{
|
||||||
|
/// <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<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_file_indexes");
|
||||||
|
|
||||||
|
b.HasIndex("FileId")
|
||||||
|
.HasDatabaseName("ix_file_indexes_file_id");
|
||||||
|
|
||||||
|
b.HasIndex("Path", "AccountId")
|
||||||
|
.HasDatabaseName("ix_file_indexes_path_account_id");
|
||||||
|
|
||||||
|
b.ToTable("file_indexes", (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.Navigation("File");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("FileIndexes");
|
||||||
|
|
||||||
|
b.Navigation("References");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Files");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
DysonNetwork.Drive/Migrations/20251112135535_AddFileIndex.cs
Normal file
66
DysonNetwork.Drive/Migrations/20251112135535_AddFileIndex.cs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddFileIndex : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "path",
|
||||||
|
table: "tasks",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "file_indexes",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
path = table.Column<string>(type: "character varying(8192)", maxLength: 8192, nullable: false),
|
||||||
|
file_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||||
|
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||||
|
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_file_indexes", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_file_indexes_files_file_id",
|
||||||
|
column: x => x.file_id,
|
||||||
|
principalTable: "files",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_file_indexes_file_id",
|
||||||
|
table: "file_indexes",
|
||||||
|
column: "file_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_file_indexes_path_account_id",
|
||||||
|
table: "file_indexes",
|
||||||
|
columns: new[] { "path", "account_id" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "file_indexes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "path",
|
||||||
|
table: "tasks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ namespace DysonNetwork.Drive.Migrations
|
|||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.HasAnnotation("ProductVersion", "9.0.7")
|
.HasAnnotation("ProductVersion", "9.0.10")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
@@ -72,7 +72,224 @@ namespace DysonNetwork.Drive.Migrations
|
|||||||
b.ToTable("quota_records", (string)null);
|
b.ToTable("quota_records", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
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.SnCloudFileReference", 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")
|
b.Property<string>("Id")
|
||||||
.HasMaxLength(32)
|
.HasMaxLength(32)
|
||||||
@@ -186,13 +403,17 @@ namespace DysonNetwork.Drive.Migrations
|
|||||||
b.ToTable("files", (string)null);
|
b.ToTable("files", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileIndex", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("id");
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<Guid>("AccountId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("account_id");
|
||||||
|
|
||||||
b.Property<Instant>("CreatedAt")
|
b.Property<Instant>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("created_at");
|
.HasColumnName("created_at");
|
||||||
@@ -201,42 +422,35 @@ namespace DysonNetwork.Drive.Migrations
|
|||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("deleted_at");
|
.HasColumnName("deleted_at");
|
||||||
|
|
||||||
b.Property<Instant?>("ExpiredAt")
|
|
||||||
.HasColumnType("timestamp with time zone")
|
|
||||||
.HasColumnName("expired_at");
|
|
||||||
|
|
||||||
b.Property<string>("FileId")
|
b.Property<string>("FileId")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(32)
|
.HasMaxLength(32)
|
||||||
.HasColumnType("character varying(32)")
|
.HasColumnType("character varying(32)")
|
||||||
.HasColumnName("file_id");
|
.HasColumnName("file_id");
|
||||||
|
|
||||||
b.Property<string>("ResourceId")
|
b.Property<string>("Path")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(1024)
|
.HasMaxLength(8192)
|
||||||
.HasColumnType("character varying(1024)")
|
.HasColumnType("character varying(8192)")
|
||||||
.HasColumnName("resource_id");
|
.HasColumnName("path");
|
||||||
|
|
||||||
b.Property<Instant>("UpdatedAt")
|
b.Property<Instant>("UpdatedAt")
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("updated_at");
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
b.Property<string>("Usage")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(1024)
|
|
||||||
.HasColumnType("character varying(1024)")
|
|
||||||
.HasColumnName("usage");
|
|
||||||
|
|
||||||
b.HasKey("Id")
|
b.HasKey("Id")
|
||||||
.HasName("pk_file_references");
|
.HasName("pk_file_indexes");
|
||||||
|
|
||||||
b.HasIndex("FileId")
|
b.HasIndex("FileId")
|
||||||
.HasDatabaseName("ix_file_references_file_id");
|
.HasDatabaseName("ix_file_indexes_file_id");
|
||||||
|
|
||||||
b.ToTable("file_references", (string)null);
|
b.HasIndex("Path", "AccountId")
|
||||||
|
.HasDatabaseName("ix_file_indexes_path_account_id");
|
||||||
|
|
||||||
|
b.ToTable("file_indexes", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -295,86 +509,71 @@ namespace DysonNetwork.Drive.Migrations
|
|||||||
b.ToTable("bundles", (string)null);
|
b.ToTable("bundles", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b =>
|
modelBuilder.Entity("DysonNetwork.Drive.Storage.Model.PersistentUploadTask", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.HasBaseType("DysonNetwork.Drive.Storage.Model.PersistentTask");
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
|
b.Property<Guid?>("BundleId")
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("id");
|
.HasColumnName("bundle_id");
|
||||||
|
|
||||||
b.Property<Guid?>("AccountId")
|
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")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("account_id");
|
.HasColumnName("pool_id");
|
||||||
|
|
||||||
b.Property<BillingConfig>("BillingConfig")
|
b.PrimitiveCollection<List<int>>("UploadedChunks")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("integer[]")
|
||||||
.HasColumnName("billing_config");
|
.HasColumnName("uploaded_chunks");
|
||||||
|
|
||||||
b.Property<Instant>("CreatedAt")
|
b.HasDiscriminator().HasValue("PersistentUploadTask");
|
||||||
.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.Drive.Storage.CloudFile", b =>
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileReference", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("DysonNetwork.Drive.Storage.FileBundle", "Bundle")
|
b.HasOne("DysonNetwork.Shared.Models.SnCloudFile", "File")
|
||||||
.WithMany("Files")
|
|
||||||
.HasForeignKey("BundleId")
|
|
||||||
.HasConstraintName("fk_files_bundles_bundle_id");
|
|
||||||
|
|
||||||
b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("PoolId")
|
|
||||||
.HasConstraintName("fk_files_pools_pool_id");
|
|
||||||
|
|
||||||
b.Navigation("Bundle");
|
|
||||||
|
|
||||||
b.Navigation("Pool");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
|
|
||||||
.WithMany("References")
|
.WithMany("References")
|
||||||
.HasForeignKey("FileId")
|
.HasForeignKey("FileId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
@@ -384,12 +583,43 @@ namespace DysonNetwork.Drive.Migrations
|
|||||||
b.Navigation("File");
|
b.Navigation("File");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
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.Navigation("File");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("FileIndexes");
|
||||||
|
|
||||||
b.Navigation("References");
|
b.Navigation("References");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Files");
|
b.Navigation("Files");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ 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.AddAccountService();
|
||||||
|
|
||||||
builder.Services.AddAppFlushHandlers();
|
builder.Services.AddAppFlushHandlers();
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using DysonNetwork.Drive.Storage;
|
||||||
using DysonNetwork.Drive.Storage.Model;
|
using DysonNetwork.Drive.Storage.Model;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using DysonNetwork.Shared.Stream;
|
using DysonNetwork.Shared.Queue;
|
||||||
using FFMpegCore;
|
using FFMpegCore;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NATS.Client.Core;
|
using NATS.Client.Core;
|
||||||
@@ -29,16 +31,15 @@ public class BroadcastEventHandler(
|
|||||||
[".gif", ".apng", ".avif"];
|
[".gif", ".apng", ".avif"];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
var js = nats.CreateJetStreamContext();
|
var js = nats.CreateJetStreamContext();
|
||||||
|
|
||||||
await js.EnsureStreamCreated("account_events", [AccountDeletedEvent.Type]);
|
await js.EnsureStreamCreated("account_events", [AccountDeletedEvent.Type]);
|
||||||
|
|
||||||
var accountEventConsumer = await js.CreateOrUpdateConsumerAsync("account_events",
|
var accountEventConsumer = await js.CreateOrUpdateConsumerAsync("account_events",
|
||||||
new ConsumerConfig("drive_account_deleted_handler"), cancellationToken: stoppingToken);
|
new ConsumerConfig("drive_account_deleted_handler"), cancellationToken: stoppingToken);
|
||||||
|
|
||||||
await js.EnsureStreamCreated("file_events", [FileUploadedEvent.Type]);
|
await js.EnsureStreamCreated("file_events", [FileUploadedEvent.Type]);
|
||||||
var fileUploadedConsumer = await js.CreateOrUpdateConsumerAsync("file_events",
|
var fileUploadedConsumer = await js.CreateOrUpdateConsumerAsync("file_events",
|
||||||
new ConsumerConfig("drive_file_uploaded_handler") { MaxDeliver = 3 }, cancellationToken: stoppingToken);
|
new ConsumerConfig("drive_file_uploaded_handler") { MaxDeliver = 3 }, cancellationToken: stoppingToken);
|
||||||
@@ -53,13 +54,14 @@ public class BroadcastEventHandler(
|
|||||||
{
|
{
|
||||||
await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken))
|
await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken))
|
||||||
{
|
{
|
||||||
var payload = JsonSerializer.Deserialize<FileUploadedEventPayload>(msg.Data, GrpcTypeHelper.SerializerOptions);
|
var payload =
|
||||||
|
JsonSerializer.Deserialize<FileUploadedEventPayload>(msg.Data, GrpcTypeHelper.SerializerOptions);
|
||||||
if (payload == null)
|
if (payload == null)
|
||||||
{
|
{
|
||||||
await msg.AckAsync(cancellationToken: stoppingToken);
|
await msg.AckAsync(cancellationToken: stoppingToken);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ProcessAndUploadInBackgroundAsync(
|
await ProcessAndUploadInBackgroundAsync(
|
||||||
@@ -129,8 +131,8 @@ public class BroadcastEventHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ProcessAndUploadInBackgroundAsync(
|
private async Task ProcessAndUploadInBackgroundAsync(
|
||||||
string fileId,
|
string fileId,
|
||||||
Guid remoteId,
|
Guid remoteId,
|
||||||
string storageId,
|
string storageId,
|
||||||
@@ -142,6 +144,7 @@ public class BroadcastEventHandler(
|
|||||||
using var scope = serviceProvider.CreateScope();
|
using var scope = serviceProvider.CreateScope();
|
||||||
var fs = scope.ServiceProvider.GetRequiredService<FileService>();
|
var fs = scope.ServiceProvider.GetRequiredService<FileService>();
|
||||||
var scopedDb = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
var scopedDb = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||||
|
var persistentTaskService = scope.ServiceProvider.GetRequiredService<PersistentTaskService>();
|
||||||
|
|
||||||
var pool = await fs.GetPoolAsync(remoteId);
|
var pool = await fs.GetPoolAsync(remoteId);
|
||||||
if (pool is null) return;
|
if (pool is null) return;
|
||||||
@@ -155,6 +158,11 @@ public class BroadcastEventHandler(
|
|||||||
|
|
||||||
var fileToUpdate = await scopedDb.Files.AsNoTracking().FirstAsync(f => f.Id == fileId);
|
var fileToUpdate = await scopedDb.Files.AsNoTracking().FirstAsync(f => f.Id == fileId);
|
||||||
|
|
||||||
|
// Find the upload task associated with this file
|
||||||
|
var uploadTask = await scopedDb.Tasks
|
||||||
|
.OfType<PersistentUploadTask>()
|
||||||
|
.FirstOrDefaultAsync(t => t.FileName == fileToUpdate.Name && t.FileSize == fileToUpdate.Size);
|
||||||
|
|
||||||
if (fileToUpdate.IsEncrypted)
|
if (fileToUpdate.IsEncrypted)
|
||||||
{
|
{
|
||||||
uploads.Add((processingFilePath, string.Empty, contentType, false));
|
uploads.Add((processingFilePath, string.Empty, contentType, false));
|
||||||
@@ -293,5 +301,51 @@ public class BroadcastEventHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await fs._PurgeCacheAsync(fileId);
|
await fs._PurgeCacheAsync(fileId);
|
||||||
|
|
||||||
|
// Complete the upload task if found
|
||||||
|
if (uploadTask != null)
|
||||||
|
{
|
||||||
|
await persistentTaskService.MarkTaskCompletedAsync(uploadTask.TaskId, new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
{ "FileId", fileId },
|
||||||
|
{ "FileName", fileToUpdate.Name },
|
||||||
|
{ "FileInfo", fileToUpdate },
|
||||||
|
{ "FileSize", fileToUpdate.Size },
|
||||||
|
{ "MimeType", newMimeType },
|
||||||
|
{ "HasCompression", hasCompression },
|
||||||
|
{ "HasThumbnail", hasThumbnail }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send push notification for large files (>5MB) that took longer to process
|
||||||
|
if (fileToUpdate.Size > 5 * 1024 * 1024) // 5MB threshold
|
||||||
|
await SendLargeFileProcessingCompleteNotificationAsync(uploadTask, fileToUpdate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private async Task SendLargeFileProcessingCompleteNotificationAsync(PersistentUploadTask task, SnCloudFile file)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ringService = serviceProvider.GetRequiredService<RingService.RingServiceClient>();
|
||||||
|
|
||||||
|
var pushNotification = new PushNotification
|
||||||
|
{
|
||||||
|
Topic = "drive.tasks.upload",
|
||||||
|
Title = "File Processing Complete",
|
||||||
|
Subtitle = file.Name,
|
||||||
|
Body = $"Your file '{file.Name}' has finished processing and is now available.",
|
||||||
|
IsSavable = true
|
||||||
|
};
|
||||||
|
|
||||||
|
await ringService.SendPushNotificationToUserAsync(new SendPushNotificationToUserRequest
|
||||||
|
{
|
||||||
|
UserId = task.AccountId.ToString(),
|
||||||
|
Notification = pushNotification
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to send large file processing notification for task {TaskId}", task.TaskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,13 @@ public static class ScheduledJobsConfiguration
|
|||||||
.ForJob(cloudFileUnusedRecyclingJob)
|
.ForJob(cloudFileUnusedRecyclingJob)
|
||||||
.WithIdentity("CloudFileUnusedRecyclingTrigger")
|
.WithIdentity("CloudFileUnusedRecyclingTrigger")
|
||||||
.WithCronSchedule("0 0 0 * * ?"));
|
.WithCronSchedule("0 0 0 * * ?"));
|
||||||
|
|
||||||
|
var persistentTaskCleanupJob = new JobKey("PersistentTaskCleanup");
|
||||||
|
q.AddJob<PersistentTaskCleanupJob>(opts => opts.WithIdentity(persistentTaskCleanupJob));
|
||||||
|
q.AddTrigger(opts => opts
|
||||||
|
.ForJob(persistentTaskCleanupJob)
|
||||||
|
.WithIdentity("PersistentTaskCleanupTrigger")
|
||||||
|
.WithCronSchedule("0 0 2 * * ?")); // Run daily at 2 AM
|
||||||
});
|
});
|
||||||
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
|
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using DysonNetwork.Drive.Index;
|
||||||
using DysonNetwork.Shared.Cache;
|
using DysonNetwork.Shared.Cache;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using NodaTime.Serialization.SystemTextJson;
|
using NodaTime.Serialization.SystemTextJson;
|
||||||
@@ -11,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();
|
||||||
|
|
||||||
@@ -55,11 +54,13 @@ 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<FileIndexService>();
|
||||||
services.AddScoped<Billing.UsageService>();
|
services.AddScoped<Billing.UsageService>();
|
||||||
services.AddScoped<Billing.QuotaService>();
|
services.AddScoped<Billing.QuotaService>();
|
||||||
|
|
||||||
services.AddHostedService<BroadcastEventHandler>();
|
services.AddHostedService<BroadcastEventHandler>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ public class CloudFileUnusedRecyclingJob(
|
|||||||
public async Task Execute(IJobExecutionContext context)
|
public async Task Execute(IJobExecutionContext context)
|
||||||
{
|
{
|
||||||
logger.LogInformation("Cleaning tus cloud files...");
|
logger.LogInformation("Cleaning tus cloud files...");
|
||||||
var storePath = configuration["Tus:StorePath"];
|
var storePath = configuration["Storage:Uploads"];
|
||||||
if (Directory.Exists(storePath))
|
if (Directory.Exists(storePath))
|
||||||
{
|
{
|
||||||
var oneHourAgo = SystemClock.Instance.GetCurrentInstant() - Duration.FromHours(1);
|
var oneHourAgo = SystemClock.Instance.GetCurrentInstant() - Duration.FromHours(1);
|
||||||
@@ -39,6 +39,7 @@ public class CloudFileUnusedRecyclingJob(
|
|||||||
var processedCount = 0;
|
var processedCount = 0;
|
||||||
var markedCount = 0;
|
var markedCount = 0;
|
||||||
var totalFiles = await db.Files
|
var totalFiles = await db.Files
|
||||||
|
.Where(f => f.FileIndexes.Count == 0)
|
||||||
.Where(f => f.PoolId.HasValue && recyclablePools.Contains(f.PoolId.Value))
|
.Where(f => f.PoolId.HasValue && recyclablePools.Contains(f.PoolId.Value))
|
||||||
.Where(f => !f.IsMarkedRecycle)
|
.Where(f => !f.IsMarkedRecycle)
|
||||||
.CountAsync();
|
.CountAsync();
|
||||||
|
|||||||
@@ -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>("Tus:StorePath");
|
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);
|
||||||
|
|
||||||
@@ -126,8 +128,7 @@ public class FileService(
|
|||||||
private async Task<FilePool> ValidateAndGetPoolAsync(string filePool)
|
private async Task<FilePool> ValidateAndGetPoolAsync(string filePool)
|
||||||
{
|
{
|
||||||
var pool = await GetPoolAsync(Guid.Parse(filePool));
|
var pool = await GetPoolAsync(Guid.Parse(filePool));
|
||||||
if (pool is null) throw new InvalidOperationException("Pool not found");
|
return pool ?? throw new InvalidOperationException("Pool not found: " + filePool);
|
||||||
return pool;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<SnFileBundle?> ValidateAndGetBundleAsync(string? fileBundleId, Guid accountId)
|
private async Task<SnFileBundle?> ValidateAndGetBundleAsync(string? fileBundleId, Guid accountId)
|
||||||
@@ -135,12 +136,10 @@ public class FileService(
|
|||||||
if (fileBundleId is null) return null;
|
if (fileBundleId is null) return null;
|
||||||
|
|
||||||
var bundle = await GetBundleAsync(Guid.Parse(fileBundleId), accountId);
|
var bundle = await GetBundleAsync(Guid.Parse(fileBundleId), accountId);
|
||||||
if (bundle is null) throw new InvalidOperationException("Bundle not found");
|
return bundle ?? throw new InvalidOperationException("Bundle not found: " + fileBundleId);
|
||||||
|
|
||||||
return bundle;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Instant? CalculateFinalExpiration(Instant? expiredAt, FilePool pool, SnFileBundle? bundle)
|
private static Instant? CalculateFinalExpiration(Instant? expiredAt, FilePool pool, SnFileBundle? bundle)
|
||||||
{
|
{
|
||||||
var finalExpiredAt = expiredAt;
|
var finalExpiredAt = expiredAt;
|
||||||
|
|
||||||
@@ -234,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(
|
||||||
@@ -474,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)
|
||||||
@@ -663,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)
|
||||||
@@ -714,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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Text.Json;
|
|
||||||
using DysonNetwork.Drive.Billing;
|
using DysonNetwork.Drive.Billing;
|
||||||
|
using DysonNetwork.Drive.Index;
|
||||||
using DysonNetwork.Drive.Storage.Model;
|
using DysonNetwork.Drive.Storage.Model;
|
||||||
using DysonNetwork.Shared.Auth;
|
using DysonNetwork.Shared.Auth;
|
||||||
using DysonNetwork.Shared.Http;
|
using DysonNetwork.Shared.Http;
|
||||||
@@ -10,6 +10,8 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NanoidDotNet;
|
using NanoidDotNet;
|
||||||
|
using NodaTime;
|
||||||
|
using TaskStatus = DysonNetwork.Drive.Storage.Model.TaskStatus;
|
||||||
|
|
||||||
namespace DysonNetwork.Drive.Storage;
|
namespace DysonNetwork.Drive.Storage;
|
||||||
|
|
||||||
@@ -21,7 +23,10 @@ public class FileUploadController(
|
|||||||
FileService fileService,
|
FileService fileService,
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
PermissionService.PermissionServiceClient permission,
|
PermissionService.PermissionServiceClient permission,
|
||||||
QuotaService quotaService
|
QuotaService quotaService,
|
||||||
|
PersistentTaskService persistentTaskService,
|
||||||
|
FileIndexService fileIndexService,
|
||||||
|
ILogger<FileUploadController> logger
|
||||||
)
|
)
|
||||||
: ControllerBase
|
: ControllerBase
|
||||||
{
|
{
|
||||||
@@ -33,8 +38,7 @@ public class FileUploadController(
|
|||||||
[HttpPost("create")]
|
[HttpPost("create")]
|
||||||
public async Task<IActionResult> CreateUploadTask([FromBody] CreateUploadTaskRequest request)
|
public async Task<IActionResult> CreateUploadTask([FromBody] CreateUploadTaskRequest request)
|
||||||
{
|
{
|
||||||
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
if (currentUser is null)
|
|
||||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
var permissionCheck = await ValidateUserPermissions(currentUser);
|
var permissionCheck = await ValidateUserPermissions(currentUser);
|
||||||
@@ -57,10 +61,32 @@ public class FileUploadController(
|
|||||||
|
|
||||||
EnsureTempDirectoryExists();
|
EnsureTempDirectoryExists();
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
// Check if a file with the same hash already exists
|
// Check if a file with the same hash already exists
|
||||||
var existingFile = await db.Files.FirstOrDefaultAsync(f => f.Hash == request.Hash);
|
var existingFile = await db.Files.FirstOrDefaultAsync(f => f.Hash == request.Hash);
|
||||||
if (existingFile != null)
|
if (existingFile != null)
|
||||||
{
|
{
|
||||||
|
// Create the file index if a path is provided, even for existing files
|
||||||
|
if (string.IsNullOrEmpty(request.Path))
|
||||||
|
return Ok(new CreateUploadTaskResponse
|
||||||
|
{
|
||||||
|
FileExists = true,
|
||||||
|
File = existingFile
|
||||||
|
});
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await fileIndexService.CreateAsync(request.Path, existingFile.Id, accountId);
|
||||||
|
logger.LogInformation("Created file index for existing file {FileId} at path {Path}",
|
||||||
|
existingFile.Id, request.Path);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to create file index for existing file {FileId} at path {Path}",
|
||||||
|
existingFile.Id, request.Path);
|
||||||
|
// Don't fail the request if index creation fails, just log it
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(new CreateUploadTaskResponse
|
return Ok(new CreateUploadTaskResponse
|
||||||
{
|
{
|
||||||
FileExists = true,
|
FileExists = true,
|
||||||
@@ -68,13 +94,17 @@ public class FileUploadController(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var (taskId, task) = await CreateUploadTaskInternal(request);
|
var taskId = await Nanoid.GenerateAsync();
|
||||||
|
|
||||||
|
// Create persistent upload task
|
||||||
|
var persistentTask = await persistentTaskService.CreateUploadTaskAsync(taskId, request, accountId);
|
||||||
|
|
||||||
return Ok(new CreateUploadTaskResponse
|
return Ok(new CreateUploadTaskResponse
|
||||||
{
|
{
|
||||||
FileExists = false,
|
FileExists = false,
|
||||||
TaskId = taskId,
|
TaskId = taskId,
|
||||||
ChunkSize = task.ChunkSize,
|
ChunkSize = persistentTask.ChunkSize,
|
||||||
ChunksCount = task.ChunksCount
|
ChunksCount = persistentTask.ChunksCount
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,36 +113,38 @@ 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 ? null :
|
return allowed.HasPermission
|
||||||
new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
|
? null
|
||||||
|
: new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IActionResult?> ValidatePoolAccess(Account currentUser, FilePool pool, CreateUploadTaskRequest request)
|
private Task<IActionResult?> ValidatePoolAccess(Account currentUser, FilePool pool, CreateUploadTaskRequest request)
|
||||||
{
|
{
|
||||||
if (pool.PolicyConfig.RequirePrivilege <= 0) return null;
|
if (pool.PolicyConfig.RequirePrivilege <= 0) return Task.FromResult<IActionResult?>(null);
|
||||||
|
|
||||||
var privilege = currentUser.PerkSubscription is null ? 0 :
|
var privilege = currentUser.PerkSubscription is null
|
||||||
PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier);
|
? 0
|
||||||
|
: PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier);
|
||||||
|
|
||||||
if (privilege < pool.PolicyConfig.RequirePrivilege)
|
if (privilege < pool.PolicyConfig.RequirePrivilege)
|
||||||
{
|
{
|
||||||
return new ObjectResult(ApiError.Unauthorized(
|
return Task.FromResult<IActionResult?>(new ObjectResult(ApiError.Unauthorized(
|
||||||
$"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use pool {pool.Name}, you are tier {privilege}",
|
$"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use pool {pool.Name}, you are tier {privilege}",
|
||||||
forbidden: true))
|
forbidden: true))
|
||||||
{ StatusCode = 403 };
|
{ StatusCode = 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return Task.FromResult<IActionResult?>(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private IActionResult? ValidatePoolPolicy(PolicyConfig policy, CreateUploadTaskRequest request)
|
private static IActionResult? ValidatePoolPolicy(PolicyConfig policy, CreateUploadTaskRequest request)
|
||||||
{
|
{
|
||||||
if (!policy.AllowEncryption && !string.IsNullOrEmpty(request.EncryptPassword))
|
if (!policy.AllowEncryption && !string.IsNullOrEmpty(request.EncryptPassword))
|
||||||
{
|
{
|
||||||
return new ObjectResult(ApiError.Unauthorized("File encryption is not allowed in this pool", true))
|
return new ObjectResult(ApiError.Unauthorized("File encryption is not allowed in this pool", true))
|
||||||
{ StatusCode = 403 };
|
{ StatusCode = 403 };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (policy.AcceptTypes is { Count: > 0 })
|
if (policy.AcceptTypes is { Count: > 0 })
|
||||||
@@ -120,36 +152,35 @@ public class FileUploadController(
|
|||||||
if (string.IsNullOrEmpty(request.ContentType))
|
if (string.IsNullOrEmpty(request.ContentType))
|
||||||
{
|
{
|
||||||
return new ObjectResult(ApiError.Validation(new Dictionary<string, string[]>
|
return new ObjectResult(ApiError.Validation(new Dictionary<string, string[]>
|
||||||
{
|
{
|
||||||
{ "contentType", new[] { "Content type is required by the pool's policy" } }
|
{ "contentType", new[] { "Content type is required by the pool's policy" } }
|
||||||
}))
|
}))
|
||||||
{ StatusCode = 400 };
|
{ StatusCode = 400 };
|
||||||
}
|
}
|
||||||
|
|
||||||
var foundMatch = policy.AcceptTypes.Any(acceptType =>
|
var foundMatch = policy.AcceptTypes.Any(acceptType =>
|
||||||
{
|
{
|
||||||
if (acceptType.EndsWith("/*", StringComparison.OrdinalIgnoreCase))
|
if (!acceptType.EndsWith("/*", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
return acceptType.Equals(request.ContentType, StringComparison.OrdinalIgnoreCase);
|
||||||
var type = acceptType[..^2];
|
var type = acceptType[..^2];
|
||||||
return request.ContentType.StartsWith($"{type}/", StringComparison.OrdinalIgnoreCase);
|
return request.ContentType.StartsWith($"{type}/", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
|
||||||
|
|
||||||
return acceptType.Equals(request.ContentType, StringComparison.OrdinalIgnoreCase);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!foundMatch)
|
if (!foundMatch)
|
||||||
{
|
{
|
||||||
return new ObjectResult(
|
return new ObjectResult(
|
||||||
ApiError.Unauthorized($"Content type {request.ContentType} is not allowed by the pool's policy", true))
|
ApiError.Unauthorized($"Content type {request.ContentType} is not allowed by the pool's policy",
|
||||||
{ StatusCode = 403 };
|
true))
|
||||||
|
{ StatusCode = 403 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (policy.MaxFileSize is not null && request.FileSize > policy.MaxFileSize)
|
if (policy.MaxFileSize is not null && request.FileSize > policy.MaxFileSize)
|
||||||
{
|
{
|
||||||
return new ObjectResult(ApiError.Unauthorized(
|
return new ObjectResult(ApiError.Unauthorized(
|
||||||
$"File size {request.FileSize} is larger than the pool's maximum file size {policy.MaxFileSize}", true))
|
$"File size {request.FileSize} is larger than the pool's maximum file size {policy.MaxFileSize}",
|
||||||
{ StatusCode = 403 };
|
true))
|
||||||
|
{ StatusCode = 403 };
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -166,8 +197,9 @@ public class FileUploadController(
|
|||||||
if (!ok)
|
if (!ok)
|
||||||
{
|
{
|
||||||
return new ObjectResult(
|
return new ObjectResult(
|
||||||
ApiError.Unauthorized($"File size {billableUnit} MiB is exceeded the user's quota {quota} MiB", true))
|
ApiError.Unauthorized($"File size {billableUnit} MiB is exceeded the user's quota {quota} MiB",
|
||||||
{ StatusCode = 403 };
|
true))
|
||||||
|
{ StatusCode = 403 };
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -181,109 +213,123 @@ public class FileUploadController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<(string taskId, UploadTask task)> CreateUploadTaskInternal(CreateUploadTaskRequest request)
|
|
||||||
{
|
|
||||||
var taskId = await Nanoid.GenerateAsync();
|
|
||||||
var taskPath = Path.Combine(_tempPath, taskId);
|
|
||||||
Directory.CreateDirectory(taskPath);
|
|
||||||
|
|
||||||
var chunkSize = request.ChunkSize ?? DefaultChunkSize;
|
|
||||||
var chunksCount = (int)Math.Ceiling((double)request.FileSize / chunkSize);
|
|
||||||
|
|
||||||
var task = new UploadTask
|
|
||||||
{
|
|
||||||
TaskId = taskId,
|
|
||||||
FileName = request.FileName,
|
|
||||||
FileSize = request.FileSize,
|
|
||||||
ContentType = request.ContentType,
|
|
||||||
ChunkSize = chunkSize,
|
|
||||||
ChunksCount = chunksCount,
|
|
||||||
PoolId = request.PoolId.Value,
|
|
||||||
BundleId = request.BundleId,
|
|
||||||
EncryptPassword = request.EncryptPassword,
|
|
||||||
ExpiredAt = request.ExpiredAt,
|
|
||||||
Hash = request.Hash,
|
|
||||||
};
|
|
||||||
|
|
||||||
await System.IO.File.WriteAllTextAsync(Path.Combine(taskPath, "task.json"), JsonSerializer.Serialize(task));
|
|
||||||
return (taskId, task);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class UploadChunkRequest
|
public class UploadChunkRequest
|
||||||
{
|
{
|
||||||
[Required]
|
[Required] public IFormFile Chunk { get; set; } = null!;
|
||||||
public IFormFile Chunk { get; set; } = null!;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("chunk/{taskId}/{chunkIndex}")]
|
[HttpPost("chunk/{taskId}/{chunkIndex:int}")]
|
||||||
[RequestSizeLimit(DefaultChunkSize + 1024 * 1024)] // 6MB to be safe
|
[RequestSizeLimit(DefaultChunkSize + 1024 * 1024)] // 6MB to be safe
|
||||||
[RequestFormLimits(MultipartBodyLengthLimit = DefaultChunkSize + 1024 * 1024)]
|
[RequestFormLimits(MultipartBodyLengthLimit = DefaultChunkSize + 1024 * 1024)]
|
||||||
public async Task<IActionResult> UploadChunk(string taskId, int chunkIndex, [FromForm] UploadChunkRequest request)
|
public async Task<IActionResult> UploadChunk(string taskId, int chunkIndex, [FromForm] UploadChunkRequest request)
|
||||||
{
|
{
|
||||||
var chunk = request.Chunk;
|
var chunk = request.Chunk;
|
||||||
|
|
||||||
|
// Check if chunk is already uploaded (resumable upload)
|
||||||
|
if (await persistentTaskService.IsChunkUploadedAsync(taskId, chunkIndex))
|
||||||
|
{
|
||||||
|
return Ok(new { message = "Chunk already uploaded" });
|
||||||
|
}
|
||||||
|
|
||||||
var taskPath = Path.Combine(_tempPath, taskId);
|
var taskPath = Path.Combine(_tempPath, taskId);
|
||||||
if (!Directory.Exists(taskPath))
|
if (!Directory.Exists(taskPath))
|
||||||
{
|
{
|
||||||
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
|
Directory.CreateDirectory(taskPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
var chunkPath = Path.Combine(taskPath, $"{chunkIndex}.chunk");
|
var chunkPath = Path.Combine(taskPath, $"{chunkIndex}.chunk");
|
||||||
await using var stream = new FileStream(chunkPath, FileMode.Create);
|
await using var stream = new FileStream(chunkPath, FileMode.Create);
|
||||||
await chunk.CopyToAsync(stream);
|
await chunk.CopyToAsync(stream);
|
||||||
|
|
||||||
|
// Update persistent task progress
|
||||||
|
await persistentTaskService.UpdateChunkProgressAsync(taskId, chunkIndex);
|
||||||
|
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("complete/{taskId}")]
|
[HttpPost("complete/{taskId}")]
|
||||||
public async Task<IActionResult> CompleteUpload(string taskId)
|
public async Task<IActionResult> CompleteUpload(string taskId)
|
||||||
{
|
{
|
||||||
var taskPath = Path.Combine(_tempPath, taskId);
|
// Get persistent task
|
||||||
if (!Directory.Exists(taskPath))
|
var persistentTask = await persistentTaskService.GetUploadTaskAsync(taskId);
|
||||||
|
if (persistentTask is null)
|
||||||
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
|
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
|
||||||
|
|
||||||
var taskJsonPath = Path.Combine(taskPath, "task.json");
|
|
||||||
if (!System.IO.File.Exists(taskJsonPath))
|
|
||||||
return new ObjectResult(ApiError.NotFound("Upload task metadata")) { StatusCode = 404 };
|
|
||||||
|
|
||||||
var task = JsonSerializer.Deserialize<UploadTask>(await System.IO.File.ReadAllTextAsync(taskJsonPath));
|
|
||||||
if (task == null)
|
|
||||||
return new ObjectResult(new ApiError { Code = "BAD_REQUEST", Message = "Invalid task metadata.", Status = 400 })
|
|
||||||
{ StatusCode = 400 };
|
|
||||||
|
|
||||||
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||||
if (currentUser is null)
|
if (currentUser is null)
|
||||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
if (persistentTask.AccountId != Guid.Parse(currentUser.Id))
|
||||||
|
return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
|
||||||
|
|
||||||
|
var taskPath = Path.Combine(_tempPath, taskId);
|
||||||
|
if (!Directory.Exists(taskPath))
|
||||||
|
return new ObjectResult(ApiError.NotFound("Upload task directory")) { StatusCode = 404 };
|
||||||
|
|
||||||
var mergedFilePath = Path.Combine(_tempPath, taskId + ".tmp");
|
var mergedFilePath = Path.Combine(_tempPath, taskId + ".tmp");
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await MergeChunks(taskPath, mergedFilePath, task.ChunksCount);
|
await MergeChunks(taskId, taskPath, mergedFilePath, persistentTask.ChunksCount, persistentTaskService);
|
||||||
|
|
||||||
var fileId = await Nanoid.GenerateAsync();
|
var fileId = await Nanoid.GenerateAsync();
|
||||||
var cloudFile = await fileService.ProcessNewFileAsync(
|
var cloudFile = await fileService.ProcessNewFileAsync(
|
||||||
currentUser,
|
currentUser,
|
||||||
fileId,
|
fileId,
|
||||||
task.PoolId.ToString(),
|
persistentTask.PoolId.ToString(),
|
||||||
task.BundleId?.ToString(),
|
persistentTask.BundleId?.ToString(),
|
||||||
mergedFilePath,
|
mergedFilePath,
|
||||||
task.FileName,
|
persistentTask.FileName,
|
||||||
task.ContentType,
|
persistentTask.ContentType,
|
||||||
task.EncryptPassword,
|
persistentTask.EncryptPassword,
|
||||||
task.ExpiredAt
|
persistentTask.ExpiredAt
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Create the file index if a path is provided
|
||||||
|
if (!string.IsNullOrEmpty(persistentTask.Path))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
await fileIndexService.CreateAsync(persistentTask.Path, fileId, accountId);
|
||||||
|
logger.LogInformation("Created file index for file {FileId} at path {Path}", fileId,
|
||||||
|
persistentTask.Path);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to create file index for file {FileId} at path {Path}", fileId,
|
||||||
|
persistentTask.Path);
|
||||||
|
// Don't fail the upload if index creation fails, just log it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the task status to "processing" - background processing is now happening
|
||||||
|
await persistentTaskService.UpdateTaskProgressAsync(taskId, 0.95, "Processing file in background...");
|
||||||
|
|
||||||
|
// Send upload completion notification (a file is uploaded, but processing continues)
|
||||||
|
await persistentTaskService.SendUploadCompletedNotificationAsync(persistentTask, fileId);
|
||||||
|
|
||||||
return Ok(cloudFile);
|
return Ok(cloudFile);
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Log the error and clean up
|
// Log the actual exception for debugging
|
||||||
// (Assuming you have a logger - you might want to inject ILogger)
|
logger.LogError(ex, "Failed to complete upload for task {TaskId}. Error: {ErrorMessage}", taskId,
|
||||||
|
ex.Message);
|
||||||
|
|
||||||
|
// Mark task as failed
|
||||||
|
await persistentTaskService.MarkTaskFailedAsync(taskId);
|
||||||
|
|
||||||
|
// Send failure notification
|
||||||
|
await persistentTaskService.SendUploadFailedNotificationAsync(persistentTask, ex.Message);
|
||||||
|
|
||||||
await CleanupTempFiles(taskPath, mergedFilePath);
|
await CleanupTempFiles(taskPath, mergedFilePath);
|
||||||
|
|
||||||
return new ObjectResult(new ApiError
|
return new ObjectResult(new ApiError
|
||||||
{
|
{
|
||||||
Code = "UPLOAD_FAILED",
|
Code = "UPLOAD_FAILED",
|
||||||
Message = "Failed to complete file upload.",
|
Message = $"Failed to complete file upload: {ex.Message}",
|
||||||
Status = 500
|
Status = 500
|
||||||
}) { StatusCode = 500 };
|
}) { StatusCode = 500 };
|
||||||
}
|
}
|
||||||
@@ -294,24 +340,39 @@ public class FileUploadController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task MergeChunks(string taskPath, string mergedFilePath, int chunksCount)
|
private static async Task MergeChunks(
|
||||||
|
string taskId,
|
||||||
|
string taskPath,
|
||||||
|
string mergedFilePath,
|
||||||
|
int chunksCount,
|
||||||
|
PersistentTaskService persistentTaskService)
|
||||||
{
|
{
|
||||||
await using var mergedStream = new FileStream(mergedFilePath, FileMode.Create);
|
await using var mergedStream = new FileStream(mergedFilePath, FileMode.Create);
|
||||||
|
|
||||||
|
const double baseProgress = 0.8; // Start from 80% (chunk upload is already at 95%)
|
||||||
|
const double remainingProgress = 0.15; // Remaining 15% progress distributed across chunks
|
||||||
|
var progressPerChunk = remainingProgress / chunksCount;
|
||||||
|
|
||||||
for (var i = 0; i < chunksCount; i++)
|
for (var i = 0; i < chunksCount; i++)
|
||||||
{
|
{
|
||||||
var chunkPath = Path.Combine(taskPath, $"{i}.chunk");
|
var chunkPath = Path.Combine(taskPath, i + ".chunk");
|
||||||
if (!System.IO.File.Exists(chunkPath))
|
if (!System.IO.File.Exists(chunkPath))
|
||||||
{
|
throw new InvalidOperationException("Chunk " + i + " is missing.");
|
||||||
throw new InvalidOperationException($"Chunk {i} is missing.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await using var chunkStream = new FileStream(chunkPath, FileMode.Open);
|
await using var chunkStream = new FileStream(chunkPath, FileMode.Open);
|
||||||
await chunkStream.CopyToAsync(mergedStream);
|
await chunkStream.CopyToAsync(mergedStream);
|
||||||
|
|
||||||
|
// Update progress after each chunk is merged
|
||||||
|
var currentProgress = baseProgress + progressPerChunk * (i + 1);
|
||||||
|
await persistentTaskService.UpdateTaskProgressAsync(
|
||||||
|
taskId,
|
||||||
|
currentProgress,
|
||||||
|
"Merging chunks... (" + (i + 1) + "/" + chunksCount + ")"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CleanupTempFiles(string taskPath, string mergedFilePath)
|
private static Task CleanupTempFiles(string taskPath, string mergedFilePath)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -325,5 +386,302 @@ public class FileUploadController(
|
|||||||
{
|
{
|
||||||
// Ignore cleanup errors to avoid masking the original exception
|
// Ignore cleanup errors to avoid masking the original exception
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// New endpoints for resumable uploads
|
||||||
|
|
||||||
|
[HttpGet("tasks")]
|
||||||
|
public async Task<IActionResult> GetMyUploadTasks(
|
||||||
|
[FromQuery] UploadTaskStatus? status = null,
|
||||||
|
[FromQuery] string? sortBy = "lastActivity",
|
||||||
|
[FromQuery] bool sortDescending = true,
|
||||||
|
[FromQuery] int offset = 0,
|
||||||
|
[FromQuery] int limit = 50
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||||
|
if (currentUser is null)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
var tasks = await persistentTaskService.GetUserUploadTasksAsync(accountId, status, sortBy, sortDescending,
|
||||||
|
offset, limit);
|
||||||
|
|
||||||
|
Response.Headers.Append("X-Total", tasks.TotalCount.ToString());
|
||||||
|
|
||||||
|
return Ok(tasks.Items.Select(t => new
|
||||||
|
{
|
||||||
|
t.TaskId,
|
||||||
|
t.FileName,
|
||||||
|
t.FileSize,
|
||||||
|
t.ContentType,
|
||||||
|
t.ChunkSize,
|
||||||
|
t.ChunksCount,
|
||||||
|
t.ChunksUploaded,
|
||||||
|
Progress = t.ChunksCount > 0 ? (double)t.ChunksUploaded / t.ChunksCount * 100 : 0,
|
||||||
|
t.Status,
|
||||||
|
t.LastActivity,
|
||||||
|
t.CreatedAt,
|
||||||
|
t.UpdatedAt,
|
||||||
|
t.UploadedChunks,
|
||||||
|
Pool = new { t.PoolId, Name = "Pool Name" }, // Could be expanded to include pool details
|
||||||
|
Bundle = t.BundleId.HasValue ? new { t.BundleId } : null
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("progress/{taskId}")]
|
||||||
|
public async Task<IActionResult> GetUploadProgress(string taskId)
|
||||||
|
{
|
||||||
|
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||||
|
if (currentUser is null)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var task = await persistentTaskService.GetUploadTaskAsync(taskId);
|
||||||
|
if (task is null)
|
||||||
|
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
if (task.AccountId != Guid.Parse(currentUser.Id))
|
||||||
|
return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
|
||||||
|
|
||||||
|
var progress = await persistentTaskService.GetUploadProgressAsync(taskId);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
task.TaskId,
|
||||||
|
task.FileName,
|
||||||
|
task.FileSize,
|
||||||
|
task.ChunksCount,
|
||||||
|
task.ChunksUploaded,
|
||||||
|
Progress = progress,
|
||||||
|
task.Status,
|
||||||
|
task.LastActivity,
|
||||||
|
task.UploadedChunks
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("resume/{taskId}")]
|
||||||
|
public async Task<IActionResult> ResumeUploadTask(string taskId)
|
||||||
|
{
|
||||||
|
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||||
|
if (currentUser is null)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var task = await persistentTaskService.GetUploadTaskAsync(taskId);
|
||||||
|
if (task is null)
|
||||||
|
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
if (task.AccountId != Guid.Parse(currentUser.Id))
|
||||||
|
return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
|
||||||
|
|
||||||
|
// Ensure temp directory exists
|
||||||
|
var taskPath = Path.Combine(_tempPath, taskId);
|
||||||
|
if (!Directory.Exists(taskPath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(taskPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
task.TaskId,
|
||||||
|
task.FileName,
|
||||||
|
task.FileSize,
|
||||||
|
task.ContentType,
|
||||||
|
task.ChunkSize,
|
||||||
|
task.ChunksCount,
|
||||||
|
task.ChunksUploaded,
|
||||||
|
task.UploadedChunks,
|
||||||
|
Progress = task.ChunksCount > 0 ? (double)task.ChunksUploaded / task.ChunksCount * 100 : 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("task/{taskId}")]
|
||||||
|
public async Task<IActionResult> CancelUploadTask(string taskId)
|
||||||
|
{
|
||||||
|
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||||
|
if (currentUser is null)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var task = await persistentTaskService.GetUploadTaskAsync(taskId);
|
||||||
|
if (task is null)
|
||||||
|
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
if (task.AccountId != Guid.Parse(currentUser.Id))
|
||||||
|
return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
|
||||||
|
|
||||||
|
// Mark as failed (cancelled)
|
||||||
|
await persistentTaskService.MarkTaskFailedAsync(taskId);
|
||||||
|
|
||||||
|
// Clean up temp files
|
||||||
|
var taskPath = Path.Combine(_tempPath, taskId);
|
||||||
|
await CleanupTempFiles(taskPath, string.Empty);
|
||||||
|
|
||||||
|
return Ok(new { message = "Upload task cancelled" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("stats")]
|
||||||
|
public async Task<IActionResult> GetUploadStats()
|
||||||
|
{
|
||||||
|
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||||
|
if (currentUser is null)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
var stats = await persistentTaskService.GetUserUploadStatsAsync(accountId);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
stats.TotalTasks,
|
||||||
|
stats.InProgressTasks,
|
||||||
|
stats.CompletedTasks,
|
||||||
|
stats.FailedTasks,
|
||||||
|
stats.ExpiredTasks,
|
||||||
|
stats.TotalUploadedBytes,
|
||||||
|
stats.AverageProgress,
|
||||||
|
stats.RecentActivity
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("tasks/cleanup")]
|
||||||
|
public async Task<IActionResult> CleanupFailedTasks()
|
||||||
|
{
|
||||||
|
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||||
|
if (currentUser is null)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
var cleanedCount = await persistentTaskService.CleanupUserFailedTasksAsync(accountId);
|
||||||
|
|
||||||
|
return Ok(new { message = $"Cleaned up {cleanedCount} failed tasks" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("tasks/recent")]
|
||||||
|
public async Task<IActionResult> GetRecentTasks([FromQuery] int limit = 10)
|
||||||
|
{
|
||||||
|
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||||
|
if (currentUser is null)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
var tasks = await persistentTaskService.GetRecentUserTasksAsync(accountId, limit);
|
||||||
|
|
||||||
|
return Ok(tasks.Select(t => new
|
||||||
|
{
|
||||||
|
t.TaskId,
|
||||||
|
t.FileName,
|
||||||
|
t.FileSize,
|
||||||
|
t.ContentType,
|
||||||
|
Progress = t.ChunksCount > 0 ? (double)t.ChunksUploaded / t.ChunksCount * 100 : 0,
|
||||||
|
t.Status,
|
||||||
|
t.LastActivity,
|
||||||
|
t.CreatedAt
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("tasks/{taskId}/details")]
|
||||||
|
public async Task<IActionResult> GetTaskDetails(string taskId)
|
||||||
|
{
|
||||||
|
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||||
|
if (currentUser is null)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
|
var task = await persistentTaskService.GetUploadTaskAsync(taskId);
|
||||||
|
if (task is null)
|
||||||
|
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
if (task.AccountId != Guid.Parse(currentUser.Id))
|
||||||
|
return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
|
||||||
|
|
||||||
|
// Get pool information
|
||||||
|
var pool = await fileService.GetPoolAsync(task.PoolId);
|
||||||
|
var bundle = task.BundleId.HasValue
|
||||||
|
? await db.Bundles.FirstOrDefaultAsync(b => b.Id == task.BundleId.Value)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Task = new
|
||||||
|
{
|
||||||
|
task.TaskId,
|
||||||
|
task.FileName,
|
||||||
|
task.FileSize,
|
||||||
|
task.ContentType,
|
||||||
|
task.ChunkSize,
|
||||||
|
task.ChunksCount,
|
||||||
|
task.ChunksUploaded,
|
||||||
|
Progress = task.ChunksCount > 0 ? (double)task.ChunksUploaded / task.ChunksCount * 100 : 0,
|
||||||
|
task.Status,
|
||||||
|
task.LastActivity,
|
||||||
|
task.CreatedAt,
|
||||||
|
task.UpdatedAt,
|
||||||
|
task.ExpiredAt,
|
||||||
|
task.Hash,
|
||||||
|
task.UploadedChunks
|
||||||
|
},
|
||||||
|
Pool = pool != null
|
||||||
|
? new
|
||||||
|
{
|
||||||
|
pool.Id,
|
||||||
|
pool.Name,
|
||||||
|
pool.Description
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
Bundle = bundle != null
|
||||||
|
? new
|
||||||
|
{
|
||||||
|
bundle.Id,
|
||||||
|
bundle.Name,
|
||||||
|
bundle.Description
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
EstimatedTimeRemaining = CalculateEstimatedTime(task),
|
||||||
|
UploadSpeed = CalculateUploadSpeed(task)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? CalculateEstimatedTime(PersistentUploadTask task)
|
||||||
|
{
|
||||||
|
if (task.Status != TaskStatus.InProgress || task.ChunksUploaded == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var elapsed = NodaTime.SystemClock.Instance.GetCurrentInstant() - task.CreatedAt;
|
||||||
|
var elapsedSeconds = elapsed.TotalSeconds;
|
||||||
|
var chunksPerSecond = task.ChunksUploaded / elapsedSeconds;
|
||||||
|
var remainingChunks = task.ChunksCount - task.ChunksUploaded;
|
||||||
|
|
||||||
|
if (chunksPerSecond <= 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var remainingSeconds = remainingChunks / chunksPerSecond;
|
||||||
|
|
||||||
|
return remainingSeconds switch
|
||||||
|
{
|
||||||
|
< 60 => $"{remainingSeconds:F0} seconds",
|
||||||
|
< 3600 => $"{remainingSeconds / 60:F0} minutes",
|
||||||
|
_ => $"{remainingSeconds / 3600:F1} hours"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? CalculateUploadSpeed(PersistentUploadTask task)
|
||||||
|
{
|
||||||
|
if (task.ChunksUploaded == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var elapsed = SystemClock.Instance.GetCurrentInstant() - task.CreatedAt;
|
||||||
|
var elapsedSeconds = elapsed.TotalSeconds;
|
||||||
|
var bytesUploaded = task.ChunksUploaded * task.ChunkSize;
|
||||||
|
var bytesPerSecond = bytesUploaded / elapsedSeconds;
|
||||||
|
|
||||||
|
return bytesPerSecond switch
|
||||||
|
{
|
||||||
|
< 1024 => $"{bytesPerSecond:F0} B/s",
|
||||||
|
< 1024 * 1024 => $"{bytesPerSecond / 1024:F0} KB/s",
|
||||||
|
_ => $"{bytesPerSecond / (1024 * 1024):F1} MB/s"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,42 +1,670 @@
|
|||||||
using DysonNetwork.Shared.Models;
|
using DysonNetwork.Shared.Models;
|
||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using Google.Protobuf.Collections;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace DysonNetwork.Drive.Storage.Model
|
namespace DysonNetwork.Drive.Storage.Model;
|
||||||
|
|
||||||
|
// File Upload Task Parameters
|
||||||
|
public class FileUploadParameters
|
||||||
{
|
{
|
||||||
public class CreateUploadTaskRequest
|
public string FileName { get; set; } = string.Empty;
|
||||||
|
public long FileSize { get; set; }
|
||||||
|
public string ContentType { get; set; } = string.Empty;
|
||||||
|
public long ChunkSize { get; set; } = 5242880L;
|
||||||
|
public int ChunksCount { get; set; }
|
||||||
|
public int ChunksUploaded { get; set; }
|
||||||
|
public Guid PoolId { get; set; }
|
||||||
|
public Guid? BundleId { get; set; }
|
||||||
|
public string? EncryptPassword { get; set; }
|
||||||
|
public string Hash { get; set; } = string.Empty;
|
||||||
|
public List<int> UploadedChunks { get; set; } = [];
|
||||||
|
public string? Path { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// File Move Task Parameters
|
||||||
|
public class FileMoveParameters
|
||||||
|
{
|
||||||
|
public List<string> FileIds { get; set; } = [];
|
||||||
|
public Guid TargetPoolId { get; set; }
|
||||||
|
public Guid? TargetBundleId { get; set; }
|
||||||
|
public int FilesProcessed { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// File Compression Task Parameters
|
||||||
|
public class FileCompressParameters
|
||||||
|
{
|
||||||
|
public List<string> FileIds { get; set; } = [];
|
||||||
|
public string CompressionFormat { get; set; } = "zip";
|
||||||
|
public int CompressionLevel { get; set; } = 6;
|
||||||
|
public string? OutputFileName { get; set; }
|
||||||
|
public int FilesProcessed { get; set; }
|
||||||
|
public string? ResultFileId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk Operation Task Parameters
|
||||||
|
public class BulkOperationParameters
|
||||||
|
{
|
||||||
|
public string OperationType { get; set; } = string.Empty;
|
||||||
|
public List<string> TargetIds { get; set; } = [];
|
||||||
|
public Dictionary<string, object?> OperationParameters { get; set; } = new();
|
||||||
|
public int ItemsProcessed { get; set; }
|
||||||
|
public Dictionary<string, object?>? OperationResults { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage Migration Task Parameters
|
||||||
|
public class StorageMigrationParameters
|
||||||
|
{
|
||||||
|
public Guid SourcePoolId { get; set; }
|
||||||
|
public Guid TargetPoolId { get; set; }
|
||||||
|
public List<string> FileIds { get; set; } = new();
|
||||||
|
public bool PreserveOriginals { get; set; } = true;
|
||||||
|
public long TotalBytesToTransfer { get; set; }
|
||||||
|
public long BytesTransferred { get; set; }
|
||||||
|
public int FilesMigrated { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper class for parameter operations using GrpcTypeHelper
|
||||||
|
public static class ParameterHelper
|
||||||
|
{
|
||||||
|
public static T? Typed<T>(Dictionary<string, object?> parameters)
|
||||||
{
|
{
|
||||||
public string Hash { get; set; } = null!;
|
var rawParams = GrpcTypeHelper.ConvertObjectToByteString(parameters);
|
||||||
public string FileName { get; set; } = null!;
|
return GrpcTypeHelper.ConvertByteStringToObject<T>(rawParams);
|
||||||
public long FileSize { get; set; }
|
|
||||||
public string ContentType { get; set; } = null!;
|
|
||||||
public Guid? PoolId { get; set; } = null!;
|
|
||||||
public Guid? BundleId { get; set; }
|
|
||||||
public string? EncryptPassword { get; set; }
|
|
||||||
public Instant? ExpiredAt { get; set; }
|
|
||||||
public long? ChunkSize { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CreateUploadTaskResponse
|
public static Dictionary<string, object?> Untyped<T>(T parameters)
|
||||||
{
|
{
|
||||||
public bool FileExists { get; set; }
|
var rawParams = GrpcTypeHelper.ConvertObjectToByteString(parameters);
|
||||||
public SnCloudFile? File { get; set; }
|
return GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(rawParams) ?? [];
|
||||||
public string? TaskId { get; set; }
|
|
||||||
public long? ChunkSize { get; set; }
|
|
||||||
public int? ChunksCount { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class UploadTask
|
|
||||||
{
|
|
||||||
public string TaskId { get; set; } = null!;
|
|
||||||
public string FileName { get; set; } = null!;
|
|
||||||
public long FileSize { get; set; }
|
|
||||||
public string ContentType { get; set; } = null!;
|
|
||||||
public long ChunkSize { get; set; }
|
|
||||||
public int ChunksCount { get; set; }
|
|
||||||
public Guid PoolId { get; set; }
|
|
||||||
public Guid? BundleId { get; set; }
|
|
||||||
public string? EncryptPassword { get; set; }
|
|
||||||
public Instant? ExpiredAt { get; set; }
|
|
||||||
public string Hash { get; set; } = null!;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class CreateUploadTaskRequest
|
||||||
|
{
|
||||||
|
public string Hash { get; set; } = null!;
|
||||||
|
public string FileName { get; set; } = null!;
|
||||||
|
public long FileSize { get; set; }
|
||||||
|
public string ContentType { get; set; } = null!;
|
||||||
|
public Guid? PoolId { get; set; } = null!;
|
||||||
|
public Guid? BundleId { get; set; }
|
||||||
|
public string? EncryptPassword { get; set; }
|
||||||
|
public Instant? ExpiredAt { get; set; }
|
||||||
|
public long? ChunkSize { get; set; }
|
||||||
|
public string? Path { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateUploadTaskResponse
|
||||||
|
{
|
||||||
|
public bool FileExists { get; set; }
|
||||||
|
public SnCloudFile? File { get; set; }
|
||||||
|
public string? TaskId { get; set; }
|
||||||
|
public long? ChunkSize { get; set; }
|
||||||
|
public int? ChunksCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class UploadTask
|
||||||
|
{
|
||||||
|
public string TaskId { get; set; } = null!;
|
||||||
|
public string FileName { get; set; } = null!;
|
||||||
|
public long FileSize { get; set; }
|
||||||
|
public string ContentType { get; set; } = null!;
|
||||||
|
public long ChunkSize { get; set; }
|
||||||
|
public int ChunksCount { get; set; }
|
||||||
|
public Guid PoolId { get; set; }
|
||||||
|
public Guid? BundleId { get; set; }
|
||||||
|
public string? EncryptPassword { get; set; }
|
||||||
|
public Instant? ExpiredAt { get; set; }
|
||||||
|
public string Hash { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PersistentTask : ModelBase
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
[MaxLength(64)] public string TaskId { get; set; } = null!;
|
||||||
|
|
||||||
|
[MaxLength(256)] public string Name { get; set; } = null!;
|
||||||
|
|
||||||
|
[MaxLength(1024)] public string? Description { get; set; }
|
||||||
|
|
||||||
|
public TaskType Type { get; set; }
|
||||||
|
|
||||||
|
public TaskStatus Status { get; set; } = TaskStatus.InProgress;
|
||||||
|
|
||||||
|
public Guid AccountId { get; set; }
|
||||||
|
|
||||||
|
// Progress tracking (0-100)
|
||||||
|
public double Progress { get; set; }
|
||||||
|
|
||||||
|
// Task-specific parameters stored as JSON
|
||||||
|
[Column(TypeName = "jsonb")] public Dictionary<string, object?> Parameters { get; set; } = new();
|
||||||
|
|
||||||
|
// Task results/output stored as JSON
|
||||||
|
[Column(TypeName = "jsonb")] public Dictionary<string, object?> Results { get; set; } = new();
|
||||||
|
|
||||||
|
[MaxLength(1024)] public string? ErrorMessage { get; set; }
|
||||||
|
|
||||||
|
public Instant? StartedAt { get; set; }
|
||||||
|
public Instant? CompletedAt { get; set; }
|
||||||
|
public Instant? ExpiredAt { get; set; }
|
||||||
|
|
||||||
|
public Instant LastActivity { get; set; }
|
||||||
|
|
||||||
|
// Priority (higher = more important)
|
||||||
|
public int Priority { get; set; } = 0;
|
||||||
|
|
||||||
|
// Estimated duration in seconds
|
||||||
|
public long? EstimatedDurationSeconds { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward compatibility - UploadTask inherits from PersistentTask
|
||||||
|
public class PersistentUploadTask : PersistentTask
|
||||||
|
{
|
||||||
|
public PersistentUploadTask()
|
||||||
|
{
|
||||||
|
Type = TaskType.FileUpload;
|
||||||
|
Name = "File Upload";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience properties using typed parameters
|
||||||
|
[NotMapped]
|
||||||
|
public FileUploadParameters TypedParameters
|
||||||
|
{
|
||||||
|
get => ParameterHelper.Typed<FileUploadParameters>(Parameters)!;
|
||||||
|
set => Parameters = ParameterHelper.Untyped(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MaxLength(256)]
|
||||||
|
public string FileName
|
||||||
|
{
|
||||||
|
get => TypedParameters.FileName;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.FileName = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public long FileSize
|
||||||
|
{
|
||||||
|
get => TypedParameters.FileSize;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.FileSize = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string ContentType
|
||||||
|
{
|
||||||
|
get => TypedParameters.ContentType;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.ContentType = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public long ChunkSize
|
||||||
|
{
|
||||||
|
get => TypedParameters.ChunkSize;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.ChunkSize = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int ChunksCount
|
||||||
|
{
|
||||||
|
get => TypedParameters.ChunksCount;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.ChunksCount = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int ChunksUploaded
|
||||||
|
{
|
||||||
|
get => TypedParameters.ChunksUploaded;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.ChunksUploaded = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
Progress = ChunksCount > 0 ? (double)value / ChunksCount * 100 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid PoolId
|
||||||
|
{
|
||||||
|
get => TypedParameters.PoolId;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.PoolId = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid? BundleId
|
||||||
|
{
|
||||||
|
get => TypedParameters.BundleId;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.BundleId = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[MaxLength(256)]
|
||||||
|
public string? EncryptPassword
|
||||||
|
{
|
||||||
|
get => TypedParameters.EncryptPassword;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.EncryptPassword = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Hash
|
||||||
|
{
|
||||||
|
get => TypedParameters.Hash;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.Hash = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON array of uploaded chunk indices for resumability
|
||||||
|
public List<int> UploadedChunks
|
||||||
|
{
|
||||||
|
get => TypedParameters.UploadedChunks;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.UploadedChunks = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? Path
|
||||||
|
{
|
||||||
|
get => TypedParameters.Path;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.Path = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TaskType
|
||||||
|
{
|
||||||
|
FileUpload,
|
||||||
|
FileMove,
|
||||||
|
FileCompress,
|
||||||
|
FileDecompress,
|
||||||
|
FileEncrypt,
|
||||||
|
FileDecrypt,
|
||||||
|
BulkOperation,
|
||||||
|
StorageMigration,
|
||||||
|
FileConversion,
|
||||||
|
Custom
|
||||||
|
}
|
||||||
|
|
||||||
|
[Flags]
|
||||||
|
public enum TaskStatus
|
||||||
|
{
|
||||||
|
Pending,
|
||||||
|
InProgress,
|
||||||
|
Paused,
|
||||||
|
Completed,
|
||||||
|
Failed,
|
||||||
|
Cancelled,
|
||||||
|
Expired
|
||||||
|
}
|
||||||
|
|
||||||
|
// File Move Task
|
||||||
|
public class FileMoveTask : PersistentTask
|
||||||
|
{
|
||||||
|
public FileMoveTask()
|
||||||
|
{
|
||||||
|
Type = TaskType.FileMove;
|
||||||
|
Name = "Move Files";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience properties using typed parameters
|
||||||
|
public FileMoveParameters TypedParameters
|
||||||
|
{
|
||||||
|
get => ParameterHelper.Typed<FileMoveParameters>(Parameters)!;
|
||||||
|
set => Parameters = ParameterHelper.Untyped(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<string> FileIds
|
||||||
|
{
|
||||||
|
get => TypedParameters.FileIds;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.FileIds = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid TargetPoolId
|
||||||
|
{
|
||||||
|
get => TypedParameters.TargetPoolId;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.TargetPoolId = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid? TargetBundleId
|
||||||
|
{
|
||||||
|
get => TypedParameters.TargetBundleId;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.TargetBundleId = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int FilesProcessed
|
||||||
|
{
|
||||||
|
get => TypedParameters.FilesProcessed;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.FilesProcessed = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
Progress = FileIds.Count > 0 ? (double)value / FileIds.Count * 100 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// File Compression Task
|
||||||
|
public class FileCompressTask : PersistentTask
|
||||||
|
{
|
||||||
|
public FileCompressTask()
|
||||||
|
{
|
||||||
|
Type = TaskType.FileCompress;
|
||||||
|
Name = "Compress Files";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience properties using typed parameters
|
||||||
|
public FileCompressParameters TypedParameters
|
||||||
|
{
|
||||||
|
get => ParameterHelper.Typed<FileCompressParameters>(Parameters)!;
|
||||||
|
set => Parameters = ParameterHelper.Untyped(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<string> FileIds
|
||||||
|
{
|
||||||
|
get => TypedParameters.FileIds;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.FileIds = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[MaxLength(32)]
|
||||||
|
public string CompressionFormat
|
||||||
|
{
|
||||||
|
get => TypedParameters.CompressionFormat;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.CompressionFormat = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int CompressionLevel
|
||||||
|
{
|
||||||
|
get => TypedParameters.CompressionLevel;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.CompressionLevel = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? OutputFileName
|
||||||
|
{
|
||||||
|
get => TypedParameters.OutputFileName;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.OutputFileName = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int FilesProcessed
|
||||||
|
{
|
||||||
|
get => TypedParameters.FilesProcessed;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.FilesProcessed = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
Progress = FileIds.Count > 0 ? (double)value / FileIds.Count * 100 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? ResultFileId
|
||||||
|
{
|
||||||
|
get => TypedParameters.ResultFileId;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.ResultFileId = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk Operation Task
|
||||||
|
public class BulkOperationTask : PersistentTask
|
||||||
|
{
|
||||||
|
public BulkOperationTask()
|
||||||
|
{
|
||||||
|
Type = TaskType.BulkOperation;
|
||||||
|
Name = "Bulk Operation";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience properties using typed parameters
|
||||||
|
public BulkOperationParameters TypedParameters
|
||||||
|
{
|
||||||
|
get => ParameterHelper.Typed<BulkOperationParameters>(Parameters)!;
|
||||||
|
set => Parameters = ParameterHelper.Untyped(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string OperationType
|
||||||
|
{
|
||||||
|
get => TypedParameters.OperationType;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.OperationType = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<string> TargetIds
|
||||||
|
{
|
||||||
|
get => TypedParameters.TargetIds;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.TargetIds = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Column(TypeName = "jsonb")]
|
||||||
|
public Dictionary<string, object?> OperationParameters
|
||||||
|
{
|
||||||
|
get => TypedParameters.OperationParameters;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.OperationParameters = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int ItemsProcessed
|
||||||
|
{
|
||||||
|
get => TypedParameters.ItemsProcessed;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.ItemsProcessed = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
Progress = TargetIds.Count > 0 ? (double)value / TargetIds.Count * 100 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Column(TypeName = "jsonb")]
|
||||||
|
public Dictionary<string, object?>? OperationResults
|
||||||
|
{
|
||||||
|
get => TypedParameters.OperationResults;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.OperationResults = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage Migration Task
|
||||||
|
public class StorageMigrationTask : PersistentTask
|
||||||
|
{
|
||||||
|
public StorageMigrationTask()
|
||||||
|
{
|
||||||
|
Type = TaskType.StorageMigration;
|
||||||
|
Name = "Storage Migration";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience properties using typed parameters
|
||||||
|
public StorageMigrationParameters TypedParameters
|
||||||
|
{
|
||||||
|
get => ParameterHelper.Typed<StorageMigrationParameters>(Parameters)!;
|
||||||
|
set => Parameters = ParameterHelper.Untyped(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid SourcePoolId
|
||||||
|
{
|
||||||
|
get => TypedParameters.SourcePoolId;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.SourcePoolId = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid TargetPoolId
|
||||||
|
{
|
||||||
|
get => TypedParameters.TargetPoolId;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.TargetPoolId = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<string> FileIds
|
||||||
|
{
|
||||||
|
get => TypedParameters.FileIds;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.FileIds = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool PreserveOriginals
|
||||||
|
{
|
||||||
|
get => TypedParameters.PreserveOriginals;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.PreserveOriginals = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public long TotalBytesToTransfer
|
||||||
|
{
|
||||||
|
get => TypedParameters.TotalBytesToTransfer;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.TotalBytesToTransfer = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public long BytesTransferred
|
||||||
|
{
|
||||||
|
get => TypedParameters.BytesTransferred;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.BytesTransferred = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
Progress = TotalBytesToTransfer > 0 ? (double)value / TotalBytesToTransfer * 100 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int FilesMigrated
|
||||||
|
{
|
||||||
|
get => TypedParameters.FilesMigrated;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var parameters = TypedParameters;
|
||||||
|
parameters.FilesMigrated = value;
|
||||||
|
TypedParameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy enum for backward compatibility
|
||||||
|
public enum UploadTaskStatus
|
||||||
|
{
|
||||||
|
InProgress = TaskStatus.InProgress,
|
||||||
|
Completed = TaskStatus.Completed,
|
||||||
|
Failed = TaskStatus.Failed,
|
||||||
|
Expired = TaskStatus.Expired
|
||||||
|
}
|
||||||
|
|||||||
1143
DysonNetwork.Drive/Storage/PersistentTaskService.cs
Normal file
1143
DysonNetwork.Drive/Storage/PersistentTaskService.cs
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,121 +1,121 @@
|
|||||||
{
|
{
|
||||||
"Debug": true,
|
"Debug": true,
|
||||||
"BaseUrl": "http://localhost:5090",
|
"BaseUrl": "http://localhost:5090",
|
||||||
"GatewayUrl": "http://localhost:5094",
|
"GatewayUrl": "http://localhost:5094",
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
"App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
||||||
},
|
},
|
||||||
"Authentication": {
|
"Authentication": {
|
||||||
"Schemes": {
|
"Schemes": {
|
||||||
"Bearer": {
|
"Bearer": {
|
||||||
"ValidAudiences": [
|
"ValidAudiences": [
|
||||||
"http://localhost:5071",
|
"http://localhost:5071",
|
||||||
"https://localhost:7099"
|
"https://localhost:7099"
|
||||||
],
|
],
|
||||||
"ValidIssuer": "solar-network"
|
"ValidIssuer": "solar-network"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AuthToken": {
|
"AuthToken": {
|
||||||
"PublicKeyPath": "Keys/PublicKey.pem",
|
"PublicKeyPath": "Keys/PublicKey.pem",
|
||||||
"PrivateKeyPath": "Keys/PrivateKey.pem"
|
"PrivateKeyPath": "Keys/PrivateKey.pem"
|
||||||
},
|
},
|
||||||
"Tus": {
|
"Storage": {
|
||||||
"StorePath": "Uploads"
|
"Uploads": "Uploads",
|
||||||
},
|
"PreferredRemote": "c53136a6-9152-4ecb-9f88-43c41438c23e",
|
||||||
"Storage": {
|
"Remote": [
|
||||||
"Uploads": "Uploads",
|
{
|
||||||
"PreferredRemote": "2adceae3-981a-4564-9b8d-5d71a211c873",
|
"Id": "minio",
|
||||||
"Remote": [
|
"Label": "Minio",
|
||||||
{
|
"Region": "auto",
|
||||||
"Id": "minio",
|
"Bucket": "solar-network-development",
|
||||||
"Label": "Minio",
|
"Endpoint": "localhost:9000",
|
||||||
"Region": "auto",
|
"SecretId": "littlesheep",
|
||||||
"Bucket": "solar-network-development",
|
"SecretKey": "password",
|
||||||
"Endpoint": "localhost:9000",
|
"EnabledSigned": true,
|
||||||
"SecretId": "littlesheep",
|
"EnableSsl": false
|
||||||
"SecretKey": "password",
|
},
|
||||||
"EnabledSigned": true,
|
{
|
||||||
"EnableSsl": false
|
"Id": "cloudflare",
|
||||||
},
|
"Label": "Cloudflare R2",
|
||||||
{
|
"Region": "auto",
|
||||||
"Id": "cloudflare",
|
"Bucket": "solar-network",
|
||||||
"Label": "Cloudflare R2",
|
"Endpoint": "0a70a6d1b7128888c823359d0008f4e1.r2.cloudflarestorage.com",
|
||||||
"Region": "auto",
|
"SecretId": "8ff5d06c7b1639829d60bc6838a542e6",
|
||||||
"Bucket": "solar-network",
|
"SecretKey": "fd58158c5201be16d1872c9209d9cf199421dae3c2f9972f94b2305976580d67",
|
||||||
"Endpoint": "0a70a6d1b7128888c823359d0008f4e1.r2.cloudflarestorage.com",
|
"EnableSigned": true,
|
||||||
"SecretId": "8ff5d06c7b1639829d60bc6838a542e6",
|
"EnableSsl": true
|
||||||
"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,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.Gateway/DysonNetwork.Gateway.csproj", "DysonNetwork.Gateway/"]
|
COPY ["DysonNetwork.Gateway/DysonNetwork.Gateway.csproj", "DysonNetwork.Gateway/"]
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
<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="Microsoft.Extensions.ServiceDiscovery.Yarp" Version="9.5.2" />
|
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery.Yarp" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Nerdbank.GitVersioning" Version="3.9.50">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
|
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
9
DysonNetwork.Gateway/Health/GatewayConstant.cs
Normal file
9
DysonNetwork.Gateway/Health/GatewayConstant.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace DysonNetwork.Gateway.Health;
|
||||||
|
|
||||||
|
public abstract class GatewayConstant
|
||||||
|
{
|
||||||
|
public static readonly string[] ServiceNames = ["ring", "pass", "drive", "sphere", "develop", "insight", "zone"];
|
||||||
|
|
||||||
|
// Core services stands with w/o these services the functional of entire app will broke.
|
||||||
|
public static readonly string[] CoreServiceNames = ["ring", "pass", "drive", "sphere"];
|
||||||
|
}
|
||||||
60
DysonNetwork.Gateway/Health/GatewayHealthAggregator.cs
Normal file
60
DysonNetwork.Gateway/Health/GatewayHealthAggregator.cs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Gateway.Health;
|
||||||
|
|
||||||
|
public class GatewayHealthAggregator(IHttpClientFactory httpClientFactory, GatewayReadinessStore store)
|
||||||
|
: BackgroundService
|
||||||
|
{
|
||||||
|
private async Task<ServiceHealthState> CheckService(string serviceName)
|
||||||
|
{
|
||||||
|
var client = httpClientFactory.CreateClient("health");
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Use the service discovery to lookup service
|
||||||
|
// The service defaults give every single service a health endpoint that we can use here
|
||||||
|
using var response = await client.GetAsync($"http://{serviceName}/health");
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return new ServiceHealthState(
|
||||||
|
serviceName,
|
||||||
|
true,
|
||||||
|
now,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ServiceHealthState(
|
||||||
|
serviceName,
|
||||||
|
false,
|
||||||
|
now,
|
||||||
|
$"StatusCode: {(int)response.StatusCode}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new ServiceHealthState(
|
||||||
|
serviceName,
|
||||||
|
false,
|
||||||
|
now,
|
||||||
|
ex.Message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
foreach (var service in GatewayConstant.ServiceNames)
|
||||||
|
{
|
||||||
|
var result = await CheckService(service);
|
||||||
|
store.Update(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
DysonNetwork.Gateway/Health/GatewayReadinessMiddleware.cs
Normal file
35
DysonNetwork.Gateway/Health/GatewayReadinessMiddleware.cs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
namespace DysonNetwork.Gateway.Health;
|
||||||
|
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
public sealed class GatewayReadinessMiddleware(RequestDelegate next)
|
||||||
|
{
|
||||||
|
public async Task InvokeAsync(HttpContext context, GatewayReadinessStore store)
|
||||||
|
{
|
||||||
|
if (context.Request.Path.StartsWithSegments("/health"))
|
||||||
|
{
|
||||||
|
await next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var readiness = store.Current;
|
||||||
|
|
||||||
|
// Only core services participate in readiness gating
|
||||||
|
var notReadyCoreServices = readiness.Services
|
||||||
|
.Where(kv => GatewayConstant.CoreServiceNames.Contains(kv.Key))
|
||||||
|
.Where(kv => !kv.Value.IsHealthy)
|
||||||
|
.Select(kv => kv.Key)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (notReadyCoreServices.Length > 0)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||||
|
var unavailableServices = string.Join(", ", notReadyCoreServices);
|
||||||
|
context.Response.Headers["X-NotReady"] = unavailableServices;
|
||||||
|
await context.Response.WriteAsync("Solar Network is warming up. Try again later please.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await next(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
DysonNetwork.Gateway/Health/GatewayReadinessStore.cs
Normal file
76
DysonNetwork.Gateway/Health/GatewayReadinessStore.cs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Gateway.Health;
|
||||||
|
|
||||||
|
public record ServiceHealthState(
|
||||||
|
string ServiceName,
|
||||||
|
bool IsHealthy,
|
||||||
|
Instant LastChecked,
|
||||||
|
string? Error
|
||||||
|
);
|
||||||
|
|
||||||
|
public record GatewayReadinessState(
|
||||||
|
bool IsReady,
|
||||||
|
IReadOnlyDictionary<string, ServiceHealthState> Services,
|
||||||
|
Instant LastUpdated
|
||||||
|
);
|
||||||
|
|
||||||
|
public class GatewayReadinessStore
|
||||||
|
{
|
||||||
|
private readonly Lock _lock = new();
|
||||||
|
|
||||||
|
private readonly Dictionary<string, ServiceHealthState> _services = new();
|
||||||
|
|
||||||
|
public GatewayReadinessState Current { get; private set; } = new(
|
||||||
|
IsReady: false,
|
||||||
|
Services: new Dictionary<string, ServiceHealthState>(),
|
||||||
|
LastUpdated: SystemClock.Instance.GetCurrentInstant()
|
||||||
|
);
|
||||||
|
|
||||||
|
public IReadOnlyCollection<string> ServiceNames => _services.Keys;
|
||||||
|
|
||||||
|
public GatewayReadinessStore()
|
||||||
|
{
|
||||||
|
InitializeServices(GatewayConstant.ServiceNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeServices(IEnumerable<string> serviceNames)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_services.Clear();
|
||||||
|
|
||||||
|
foreach (var name in serviceNames)
|
||||||
|
{
|
||||||
|
_services[name] = new ServiceHealthState(
|
||||||
|
name,
|
||||||
|
IsHealthy: false,
|
||||||
|
LastChecked: SystemClock.Instance.GetCurrentInstant(),
|
||||||
|
Error: "Not checked yet"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
RecalculateLocked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Update(ServiceHealthState state)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_services[state.ServiceName] = state;
|
||||||
|
RecalculateLocked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RecalculateLocked()
|
||||||
|
{
|
||||||
|
var isReady = _services.Count > 0 && _services.Values.All(s => s.IsHealthy);
|
||||||
|
|
||||||
|
Current = new GatewayReadinessState(
|
||||||
|
IsReady: isReady,
|
||||||
|
Services: new Dictionary<string, ServiceHealthState>(_services),
|
||||||
|
LastUpdated: SystemClock.Instance.GetCurrentInstant()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
DysonNetwork.Gateway/Health/GatewayStatusController.cs
Normal file
14
DysonNetwork.Gateway/Health/GatewayStatusController.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Gateway.Health;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("/health")]
|
||||||
|
public class GatewayStatusController(GatewayReadinessStore readinessStore) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public ActionResult<GatewayReadinessState> GetHealthStatus()
|
||||||
|
{
|
||||||
|
return Ok(readinessStore.Current);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
|
using DysonNetwork.Gateway.Health;
|
||||||
using DysonNetwork.Shared.Http;
|
using DysonNetwork.Shared.Http;
|
||||||
using Yarp.ReverseProxy.Configuration;
|
using Yarp.ReverseProxy.Configuration;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
|
using NodaTime;
|
||||||
|
using NodaTime.Serialization.SystemTextJson;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -9,17 +14,19 @@ builder.AddServiceDefaults();
|
|||||||
|
|
||||||
builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxValue, enableGrpc: false);
|
builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxValue, enableGrpc: false);
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<GatewayReadinessStore>();
|
||||||
|
builder.Services.AddHostedService<GatewayHealthAggregator>();
|
||||||
|
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
options.AddDefaultPolicy(
|
options.AddDefaultPolicy(policy =>
|
||||||
policy =>
|
{
|
||||||
{
|
policy.SetIsOriginAllowed(origin => true)
|
||||||
policy.SetIsOriginAllowed(origin => true)
|
.AllowAnyMethod()
|
||||||
.AllowAnyMethod()
|
.AllowAnyHeader()
|
||||||
.AllowAnyHeader()
|
.AllowCredentials()
|
||||||
.AllowCredentials()
|
.WithExposedHeaders("X-Total", "X-NotReady");
|
||||||
.WithExposedHeaders("X-Total");
|
});
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddRateLimiter(options =>
|
builder.Services.AddRateLimiter(options =>
|
||||||
@@ -40,23 +47,22 @@ builder.Services.AddRateLimiter(options =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
options.OnRejected = async (context, token) =>
|
options.OnRejected = async (context, token) =>
|
||||||
{
|
{
|
||||||
// Log the rejected IP
|
// Log the rejected IP
|
||||||
var logger = context.HttpContext.RequestServices
|
var logger = context.HttpContext.RequestServices
|
||||||
.GetRequiredService<ILoggerFactory>()
|
.GetRequiredService<ILoggerFactory>()
|
||||||
.CreateLogger("RateLimiter");
|
.CreateLogger("RateLimiter");
|
||||||
|
|
||||||
var ip = context.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
var ip = context.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
logger.LogWarning("Rate limit exceeded for IP: {IP}", ip);
|
logger.LogWarning("Rate limit exceeded for IP: {IP}", ip);
|
||||||
|
|
||||||
// Respond to the client
|
// Respond to the client
|
||||||
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
await context.HttpContext.Response.WriteAsync(
|
await context.HttpContext.Response.WriteAsync(
|
||||||
"Rate limit exceeded. Try again later.", token);
|
"Rate limit exceeded. Try again later.", token);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
var serviceNames = new[] { "ring", "pass", "drive", "sphere", "develop", "insight" };
|
|
||||||
|
|
||||||
var specialRoutes = new[]
|
var specialRoutes = new[]
|
||||||
{
|
{
|
||||||
@@ -86,7 +92,7 @@ var specialRoutes = new[]
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var apiRoutes = serviceNames.Select(serviceName =>
|
var apiRoutes = GatewayConstant.ServiceNames.Select(serviceName =>
|
||||||
{
|
{
|
||||||
var apiPath = serviceName switch
|
var apiPath = serviceName switch
|
||||||
{
|
{
|
||||||
@@ -105,7 +111,7 @@ var apiRoutes = serviceNames.Select(serviceName =>
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
var swaggerRoutes = serviceNames.Select(serviceName => new RouteConfig
|
var swaggerRoutes = GatewayConstant.ServiceNames.Select(serviceName => new RouteConfig
|
||||||
{
|
{
|
||||||
RouteId = $"{serviceName}-swagger",
|
RouteId = $"{serviceName}-swagger",
|
||||||
ClusterId = serviceName,
|
ClusterId = serviceName,
|
||||||
@@ -119,7 +125,7 @@ var swaggerRoutes = serviceNames.Select(serviceName => new RouteConfig
|
|||||||
|
|
||||||
var routes = specialRoutes.Concat(apiRoutes).Concat(swaggerRoutes).ToArray();
|
var routes = specialRoutes.Concat(apiRoutes).Concat(swaggerRoutes).ToArray();
|
||||||
|
|
||||||
var clusters = serviceNames.Select(serviceName => new ClusterConfig
|
var clusters = GatewayConstant.ServiceNames.Select(serviceName => new ClusterConfig
|
||||||
{
|
{
|
||||||
ClusterId = serviceName,
|
ClusterId = serviceName,
|
||||||
HealthCheck = new HealthCheckConfig
|
HealthCheck = new HealthCheckConfig
|
||||||
@@ -131,7 +137,7 @@ var clusters = serviceNames.Select(serviceName => new ClusterConfig
|
|||||||
Timeout = TimeSpan.FromSeconds(5),
|
Timeout = TimeSpan.FromSeconds(5),
|
||||||
Path = "/health"
|
Path = "/health"
|
||||||
},
|
},
|
||||||
Passive = new()
|
Passive = new PassiveHealthCheckConfig
|
||||||
{
|
{
|
||||||
Enabled = true
|
Enabled = true
|
||||||
}
|
}
|
||||||
@@ -147,7 +153,14 @@ builder.Services
|
|||||||
.LoadFromMemory(routes, clusters)
|
.LoadFromMemory(routes, clusters)
|
||||||
.AddServiceDiscoveryDestinationResolver();
|
.AddServiceDiscoveryDestinationResolver();
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers().AddJsonOptions(options =>
|
||||||
|
{
|
||||||
|
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
|
||||||
|
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||||
|
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||||
|
|
||||||
|
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
|
||||||
|
});
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
@@ -155,14 +168,16 @@ var forwardedHeadersOptions = new ForwardedHeadersOptions
|
|||||||
{
|
{
|
||||||
ForwardedHeaders = ForwardedHeaders.All
|
ForwardedHeaders = ForwardedHeaders.All
|
||||||
};
|
};
|
||||||
forwardedHeadersOptions.KnownNetworks.Clear();
|
forwardedHeadersOptions.KnownIPNetworks.Clear();
|
||||||
forwardedHeadersOptions.KnownProxies.Clear();
|
forwardedHeadersOptions.KnownProxies.Clear();
|
||||||
app.UseForwardedHeaders(forwardedHeadersOptions);
|
app.UseForwardedHeaders(forwardedHeadersOptions);
|
||||||
|
|
||||||
app.UseCors();
|
app.UseCors();
|
||||||
|
|
||||||
|
app.UseMiddleware<GatewayReadinessMiddleware>();
|
||||||
|
|
||||||
app.MapReverseProxy().RequireRateLimiting("fixed");
|
app.MapReverseProxy().RequireRateLimiting("fixed");
|
||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Data;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace DysonNetwork.Pass;
|
namespace DysonNetwork.Gateway;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("/api/version")]
|
[Route("/version")]
|
||||||
public class VersionController : ControllerBase
|
public class VersionController : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Cache": {
|
||||||
|
"Serializer": "MessagePack"
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"SiteUrl": "http://localhost:3000",
|
||||||
|
"Client": {
|
||||||
|
"SomeSetting": "SomeValue"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"AllowedHosts": "*",
|
|
||||||
"SiteUrl": "http://localhost:3000",
|
|
||||||
"Client": {
|
|
||||||
"SomeSetting": "SomeValue"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,23 +2,21 @@
|
|||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace DysonNetwork.Pass.Migrations
|
namespace DysonNetwork.Insight.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class RemoveNetTopo : Migration
|
public partial class UpdatedFunctionCallModels : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AlterDatabase()
|
|
||||||
.OldAnnotation("Npgsql:PostgresExtension:postgis", ",,");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AlterDatabase()
|
|
||||||
.Annotation("Npgsql:PostgresExtension:postgis", ",,");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,17 +2,17 @@
|
|||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace DysonNetwork.Pass.Migrations
|
namespace DysonNetwork.Insight.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class AddPublicContact : Migration
|
public partial class SharableThought : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<bool>(
|
migrationBuilder.AddColumn<bool>(
|
||||||
name: "is_public",
|
name: "is_public",
|
||||||
table: "account_contacts",
|
table: "thinking_sequences",
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: false);
|
defaultValue: false);
|
||||||
@@ -23,7 +23,7 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
{
|
{
|
||||||
migrationBuilder.DropColumn(
|
migrationBuilder.DropColumn(
|
||||||
name: "is_public",
|
name: "is_public",
|
||||||
table: "account_contacts");
|
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")
|
||||||
|
|||||||
@@ -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,21 +1,27 @@
|
|||||||
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;
|
||||||
@@ -23,10 +29,10 @@ public class ThoughtProvider
|
|||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly ILogger<ThoughtProvider> _logger;
|
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(
|
||||||
@@ -41,118 +47,83 @@ public class ThoughtProvider
|
|||||||
_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 +134,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
|
||||||
{
|
{
|
||||||
@@ -19,9 +19,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,4 +1,5 @@
|
|||||||
/wwwroot/dist/
|
/wwwroot/dist/
|
||||||
**/bin/
|
**/bin/
|
||||||
**/obj/
|
**/obj/
|
||||||
**/node_modules/
|
**/node_modules/
|
||||||
|
Mailart/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using DysonNetwork.Pass.Wallet;
|
|||||||
using DysonNetwork.Shared.Cache;
|
using DysonNetwork.Shared.Cache;
|
||||||
using DysonNetwork.Shared.Models;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using DysonNetwork.Shared.Stream;
|
using DysonNetwork.Shared.Queue;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
using NATS.Client.Core;
|
using NATS.Client.Core;
|
||||||
@@ -137,7 +137,7 @@ public class AccountEventService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cacheMissUserIds.Count != 0)
|
if (cacheMissUserIds.Count == 0) return results;
|
||||||
{
|
{
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
var statusesFromDb = await db.AccountStatuses
|
var statusesFromDb = await db.AccountStatuses
|
||||||
@@ -160,7 +160,7 @@ public class AccountEventService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var usersWithoutStatus = cacheMissUserIds.Except(foundUserIds).ToList();
|
var usersWithoutStatus = cacheMissUserIds.Except(foundUserIds).ToList();
|
||||||
if (usersWithoutStatus.Any())
|
if (usersWithoutStatus.Count == 0) return results;
|
||||||
{
|
{
|
||||||
foreach (var userId in usersWithoutStatus)
|
foreach (var userId in usersWithoutStatus)
|
||||||
{
|
{
|
||||||
@@ -313,46 +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.
|
|
||||||
var sum = 0;
|
|
||||||
var maxLevel = Enum.GetValues<CheckInResultLevel>().Length - 1;
|
|
||||||
for (var i = 0; i < 5; i++)
|
|
||||||
sum += Random.Next(maxLevel);
|
|
||||||
var checkInLevel = (CheckInResultLevel)(sum / 5);
|
|
||||||
|
|
||||||
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
|
||||||
{
|
{
|
||||||
@@ -438,7 +476,8 @@ public class AccountEventService(
|
|||||||
.ToDictionary(g => g.Key, g => g.ToList());
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
var checkInByDate = checkIn
|
var checkInByDate = checkIn
|
||||||
.ToDictionary(c => c.CreatedAt.InUtc().Date);
|
.GroupBy(c => c.CreatedAt.InUtc().Date)
|
||||||
|
.ToDictionary(g => g.Key, g => g.OrderByDescending(c => c.CreatedAt).First());
|
||||||
|
|
||||||
return dates.Select(date =>
|
return dates.Select(date =>
|
||||||
{
|
{
|
||||||
@@ -471,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,11 +1,14 @@
|
|||||||
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.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.Queue;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
using NATS.Client.Core;
|
using NATS.Client.Core;
|
||||||
@@ -22,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,
|
||||||
@@ -52,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;
|
||||||
@@ -79,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,
|
||||||
@@ -86,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
|
||||||
@@ -99,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,
|
||||||
@@ -108,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,
|
||||||
@@ -130,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();
|
||||||
@@ -138,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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -179,10 +199,7 @@ public class AccountService(
|
|||||||
displayName,
|
displayName,
|
||||||
userInfo.Email,
|
userInfo.Email,
|
||||||
null,
|
null,
|
||||||
"en-US",
|
isEmailVerified: userInfo.EmailVerified
|
||||||
"en",
|
|
||||||
userInfo.EmailVerified,
|
|
||||||
userInfo.EmailVerified
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,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)
|
||||||
@@ -350,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(
|
||||||
@@ -447,10 +466,10 @@ public class AccountService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await mailer
|
await mailer
|
||||||
.SendTemplatedEmailAsync<Emails.VerificationEmail, VerificationEmailModel>(
|
.SendTemplatedEmailAsync<FactorCodeEmail, VerificationEmailModel>(
|
||||||
account.Nick,
|
account.Nick,
|
||||||
contact.Content,
|
contact.Content,
|
||||||
emailLocalizer["EmailCodeTitle"],
|
emailLocalizer["CodeEmailTitle"],
|
||||||
new VerificationEmailModel
|
new VerificationEmailModel
|
||||||
{
|
{
|
||||||
Name = account.Name,
|
Name = account.Name,
|
||||||
@@ -506,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)
|
||||||
@@ -527,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.");
|
||||||
@@ -537,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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,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);
|
||||||
@@ -580,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)
|
||||||
@@ -642,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)
|
||||||
{
|
{
|
||||||
|
|||||||
141
DysonNetwork.Pass/Account/FortuneSayingController.cs
Normal file
141
DysonNetwork.Pass/Account/FortuneSayingController.cs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
|
public record FortuneSaying(
|
||||||
|
string Content,
|
||||||
|
string Source,
|
||||||
|
string Language
|
||||||
|
);
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("/api/fortune")]
|
||||||
|
public class FortuneSayingController : ControllerBase
|
||||||
|
{
|
||||||
|
private static readonly FortuneSaying[] Sayings =
|
||||||
|
[
|
||||||
|
// Chinese sayings
|
||||||
|
new("天行健,君子以自强不息。", "周易", "zh"),
|
||||||
|
new("地势坤,君子以厚德载物。", "周易", "zh"),
|
||||||
|
new("天道酬勤。", "谚语", "zh"),
|
||||||
|
new("天时不如地利,地利不如人和。", "孟子", "zh"),
|
||||||
|
new("君子有三畏:畏天命,畏大人,畏圣人之言。", "论语", "zh"),
|
||||||
|
new("己所不欲,勿施于人。", "孔子", "zh"),
|
||||||
|
new("学而时习之,不亦说乎?", "论语", "zh"),
|
||||||
|
new("有朋自远方来,不亦乐乎?", "论语", "zh"),
|
||||||
|
new("人不知而不愠,不亦君子乎?", "论语", "zh"),
|
||||||
|
new("不愤不启,不悱不发。", "论语", "zh"),
|
||||||
|
new("温故而知新,可以为师矣。", "论语", "zh"),
|
||||||
|
new("见贤思齐焉,见不贤而内自省也。", "论语", "zh"),
|
||||||
|
new("君子坦荡荡,小人长戚戚。", "论语", "zh"),
|
||||||
|
new("道不远人。人之为道而远人,不可以为道。", "论语", "zh"),
|
||||||
|
new("巧言令色,鲜矣仁。", "论语", "zh"),
|
||||||
|
new("岁寒,然后知松柏之后凋也。", "孔子", "zh"),
|
||||||
|
new("志于道,据于德,依于仁,游于艺。", "论语", "zh"),
|
||||||
|
new("博学而笃志,切问而近思。", "论语", "zh"),
|
||||||
|
new("知之者不如好之者,好之者不如乐之者。", "论语", "zh"),
|
||||||
|
new("君子欲讷于言而敏于行。", "论语", "zh"),
|
||||||
|
new("君子周而不比,小人比而不周。", "论语", "zh"),
|
||||||
|
new("君子喻于义,小人喻于利。", "论语", "zh"),
|
||||||
|
new("君子怀德,小人怀土。", "论语", "zh"),
|
||||||
|
new("君子矜而不争,群而不党。", "论语", "zh"),
|
||||||
|
new("君子和而不同,小人同而不和。", "论语", "zh"),
|
||||||
|
new("君子泰而不骄,小人骄而不泰。", "论语", "zh"),
|
||||||
|
new("君子谋道不谋食。", "论语", "zh"),
|
||||||
|
new("君子食无求饱,居无求安。", "论语", "zh"),
|
||||||
|
new("君子学以致其道。", "论语", "zh"),
|
||||||
|
new("君子耻其言而过其行。", "论语", "zh"),
|
||||||
|
new("君子敬而无失,与人恭而有礼。", "论语", "zh"),
|
||||||
|
new("君子求诸己,小人求诸人。", "论语", "zh"),
|
||||||
|
new("君子慎独。", "论语", "zh"),
|
||||||
|
new("君子不以言举人,不以人废言。", "论语", "zh"),
|
||||||
|
new("君子不器。", "论语", "zh"),
|
||||||
|
new("君子有终身之忧,无一朝之患。", "论语", "zh"),
|
||||||
|
new("君子固穷,小人穷斯滥矣。", "论语", "zh"),
|
||||||
|
new("君子疾没世而名不称焉。", "论语", "zh"),
|
||||||
|
new("君子而不仁者有矣夫,未有小人而仁者也。", "论语", "zh"),
|
||||||
|
new("君子义以为质,礼以行之,逊以出之,信以成之。", "论语", "zh"),
|
||||||
|
new("君子之德风,小人之德草。", "论语", "zh"),
|
||||||
|
new("君子之过也,如日月之食焉。", "论语", "zh"),
|
||||||
|
new("君子之言,寡而实;小人之言,多而虚。", "论语", "zh"),
|
||||||
|
new("君子之行,静以修身;小人之行,躁以求名。", "论语", "zh"),
|
||||||
|
new("君子之交淡若水,小人之交甘若醴。", "论语", "zh"),
|
||||||
|
new("君子之泽,五世而斩;小人之泽,亦五世而斩。", "孟子", "zh"),
|
||||||
|
new("君子有三乐,而王天下不与存焉。", "孟子", "zh"),
|
||||||
|
new("君子有三戒:少之时,血气未定,戒之在色;及其壮也,血气方刚,戒之在斗;及其老也,血气既衰,戒之在得。", "论语", "zh"),
|
||||||
|
new("君子莫大乎与人为善。", "孟子", "zh"),
|
||||||
|
new("君子远庖厨。", "孟子", "zh"),
|
||||||
|
// English sayings
|
||||||
|
new("The only way to do great work is to love what you do.", "Steve Jobs", "en"),
|
||||||
|
new("Believe you can and you're halfway there.", "Theodore Roosevelt", "en"),
|
||||||
|
new("The future belongs to those who believe in the beauty of their dreams.", "Eleanor Roosevelt", "en"),
|
||||||
|
new("You miss 100% of the shots you don't take.", "Wayne Gretzky", "en"),
|
||||||
|
new("The best way to predict the future is to create it.", "Peter Drucker", "en"),
|
||||||
|
new("Fortune favors the bold.", "Virgil", "en"),
|
||||||
|
new("Luck is what happens when preparation meets opportunity.", "Seneca", "en"),
|
||||||
|
new("The harder you work, the luckier you get.", "Gary Player", "en"),
|
||||||
|
new("Success is not final, failure is not fatal: It is the courage to continue that counts.", "Winston Churchill", "en"),
|
||||||
|
new("The pessimist complains about the wind; the optimist expects it to change; the realist adjusts the sails.", "William Arthur Ward", "en"),
|
||||||
|
new("The road to success is dotted with many tempting parking spaces.", "Will Rogers", "en"),
|
||||||
|
new("Don't watch the clock; do what it does. Keep going.", "Sam Levenson", "en"),
|
||||||
|
new("The only limit to our realization of tomorrow will be our doubts of today.", "Franklin D. Roosevelt", "en"),
|
||||||
|
new("Your time is limited, so don't waste it living someone else's life.", "Steve Jobs", "en"),
|
||||||
|
new("The way to get started is to quit talking and begin doing.", "Walt Disney", "en"),
|
||||||
|
new("If you look at what you have in life, you'll always have more.", "Oprah Winfrey", "en"),
|
||||||
|
new("The best revenge is massive success.", "Frank Sinatra", "en"),
|
||||||
|
new("You must do the things you think you cannot do.", "Eleanor Roosevelt", "en"),
|
||||||
|
new("Keep your face always toward the sunshine—and shadows will fall behind you.", "Walt Whitman", "en"),
|
||||||
|
new("The greatest glory in living lies not in never falling, but in rising every time we fall.", "Nelson Mandela", "en"),
|
||||||
|
new("Life is what happens to you while you're busy making other plans.", "John Lennon", "en"),
|
||||||
|
new("The secret of getting ahead is getting started.", "Mark Twain", "en"),
|
||||||
|
new("Believe in yourself and all that you are.", "Christian D. Larson", "en"),
|
||||||
|
new("The only person you are destined to become is the person you decide to be.", "Ralph Waldo Emerson", "en"),
|
||||||
|
new("Dream big and dare to fail.", "Norman Vaughan", "en"),
|
||||||
|
new("What lies behind us and what lies before us are tiny matters compared to what lies within us.", "Ralph Waldo Emerson", "en"),
|
||||||
|
new("You can't use up creativity. The more you use, the more you have.", "Maya Angelou", "en"),
|
||||||
|
new("The mind is everything. What you think you become.", "Buddha", "en"),
|
||||||
|
new("The best time to plant a tree was 20 years ago. The second best time is now.", "Chinese Proverb", "en"),
|
||||||
|
new("Fall seven times, stand up eight.", "Japanese Proverb", "en"),
|
||||||
|
new("The journey of a thousand miles begins with a single step.", "Lao Tzu", "en"),
|
||||||
|
new("Be not afraid of growing slowly, be afraid only of standing still.", "Chinese Proverb", "en"),
|
||||||
|
new("A bird does not sing because it has an answer. It sings because it has a song.", "Chinese Proverb", "en"),
|
||||||
|
new("Do not dwell in the past, do not dream of the future, concentrate the mind on the present moment.", "Buddha", "en"),
|
||||||
|
new("The best and most beautiful things in the world cannot be seen or even touched - they must be felt with the heart.", "Helen Keller", "en"),
|
||||||
|
new("Keep your eyes on the stars, and your feet on the ground.", "Theodore Roosevelt", "en"),
|
||||||
|
new("The only true wisdom is in knowing you know nothing.", "Socrates", "en"),
|
||||||
|
new("In the middle of every difficulty lies opportunity.", "Albert Einstein", "en"),
|
||||||
|
new("What you get by achieving your goals is not as important as what you become by achieving your goals.", "Zig Ziglar", "en"),
|
||||||
|
new("The purpose of life is a life of purpose.", "Robert Byrne", "en"),
|
||||||
|
new("You become what you believe.", "Oprah Winfrey", "en"),
|
||||||
|
new("The difference between a successful person and others is not a lack of strength, not a lack of knowledge, but rather a lack in will.", "Vince Lombardi", "en"),
|
||||||
|
new("The only way to make sense out of change is to plunge into it, move with it, and join the dance.", "Alan Watts", "en"),
|
||||||
|
new("Your work is going to fill a large part of your life, and the only way to be truly satisfied is to do what you believe is great work.", "Steve Jobs", "en"),
|
||||||
|
new("The man who has confidence in himself gains the confidence of others.", "Hasidic Proverb", "en"),
|
||||||
|
new("Courage is not the absence of fear, but rather the assessment that something else is more important than fear.", "Franklin D. Roosevelt", "en"),
|
||||||
|
new("The best preparation for tomorrow is doing your best today.", "H. Jackson Brown Jr.", "en"),
|
||||||
|
new("Believe in the power of your own voice. The more you use it, the stronger it becomes.", "Unknown", "en"),
|
||||||
|
new("Opportunities don't happen, you create them.", "Chris Grosser", "en")
|
||||||
|
];
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public ActionResult<List<FortuneSaying>> ListFortunes()
|
||||||
|
{
|
||||||
|
return Ok(Sayings);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("random")]
|
||||||
|
public ActionResult<List<FortuneSaying>> GetRandomFortunes([FromQuery] string? language)
|
||||||
|
{
|
||||||
|
var filteredSayings = string.IsNullOrEmpty(language)
|
||||||
|
? Sayings
|
||||||
|
: Sayings.Where(s => s.Language.Equals(language, StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||||
|
|
||||||
|
if (filteredSayings.Length == 0)
|
||||||
|
return NotFound("No fortunes found for the specified language.");
|
||||||
|
|
||||||
|
var random = new Random();
|
||||||
|
var randomSaying = filteredSayings[random.Next(filteredSayings.Length)];
|
||||||
|
|
||||||
|
return Ok(new List<FortuneSaying> { randomSaying });
|
||||||
|
}
|
||||||
|
}
|
||||||
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DysonNetwork.Pass.Emails;
|
|
||||||
using DysonNetwork.Pass.Mailer;
|
using DysonNetwork.Pass.Mailer;
|
||||||
|
using DysonNetwork.Pass.Resources.Emails;
|
||||||
using DysonNetwork.Shared.Cache;
|
using DysonNetwork.Shared.Cache;
|
||||||
using DysonNetwork.Shared.Models;
|
using DysonNetwork.Shared.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -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,
|
||||||
@@ -94,10 +95,10 @@ public class MagicSpellService(
|
|||||||
switch (spell.Type)
|
switch (spell.Type)
|
||||||
{
|
{
|
||||||
case MagicSpellType.AccountActivation:
|
case MagicSpellType.AccountActivation:
|
||||||
await email.SendTemplatedEmailAsync<LandingEmail, LandingEmailModel>(
|
await email.SendTemplatedEmailAsync<RegistrationConfirmEmail, LandingEmailModel>(
|
||||||
contact.Account.Nick,
|
contact.Account.Nick,
|
||||||
contact.Content,
|
contact.Content,
|
||||||
localizer["EmailLandingTitle"],
|
localizer["RegConfirmTitle"],
|
||||||
new LandingEmailModel
|
new LandingEmailModel
|
||||||
{
|
{
|
||||||
Name = contact.Account.Name,
|
Name = contact.Account.Name,
|
||||||
@@ -109,7 +110,7 @@ public class MagicSpellService(
|
|||||||
await email.SendTemplatedEmailAsync<AccountDeletionEmail, AccountDeletionEmailModel>(
|
await email.SendTemplatedEmailAsync<AccountDeletionEmail, AccountDeletionEmailModel>(
|
||||||
contact.Account.Nick,
|
contact.Account.Nick,
|
||||||
contact.Content,
|
contact.Content,
|
||||||
localizer["EmailAccountDeletionTitle"],
|
localizer["AccountDeletionTitle"],
|
||||||
new AccountDeletionEmailModel
|
new AccountDeletionEmailModel
|
||||||
{
|
{
|
||||||
Name = contact.Account.Name,
|
Name = contact.Account.Name,
|
||||||
@@ -121,7 +122,7 @@ public class MagicSpellService(
|
|||||||
await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>(
|
await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>(
|
||||||
contact.Account.Nick,
|
contact.Account.Nick,
|
||||||
contact.Content,
|
contact.Content,
|
||||||
localizer["EmailPasswordResetTitle"],
|
localizer["PasswordResetTitle"],
|
||||||
new PasswordResetEmailModel
|
new PasswordResetEmailModel
|
||||||
{
|
{
|
||||||
Name = contact.Account.Name,
|
Name = contact.Account.Name,
|
||||||
@@ -135,7 +136,7 @@ public class MagicSpellService(
|
|||||||
await email.SendTemplatedEmailAsync<ContactVerificationEmail, ContactVerificationEmailModel>(
|
await email.SendTemplatedEmailAsync<ContactVerificationEmail, ContactVerificationEmailModel>(
|
||||||
contact.Account.Nick,
|
contact.Account.Nick,
|
||||||
contactMethod!,
|
contactMethod!,
|
||||||
localizer["EmailContactVerificationTitle"],
|
localizer["ContractVerificationTitle"],
|
||||||
new ContactVerificationEmailModel
|
new ContactVerificationEmailModel
|
||||||
{
|
{
|
||||||
Name = contact.Account.Name,
|
Name = contact.Account.Name,
|
||||||
@@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public class NotableDay
|
|||||||
public Instant Date { get; set; }
|
public Instant Date { get; set; }
|
||||||
public string? LocalName { get; set; }
|
public string? LocalName { get; set; }
|
||||||
public string? GlobalName { get; set; }
|
public string? GlobalName { get; set; }
|
||||||
|
public string? LocalizableKey { get; set; }
|
||||||
public string? CountryCode { get; set; }
|
public string? CountryCode { get; set; }
|
||||||
public NotableHolidayType[] Holidays { get; set; } = [];
|
public NotableHolidayType[] Holidays { get; set; } = [];
|
||||||
|
|
||||||
|
|||||||
@@ -77,4 +77,52 @@ public class NotableDaysController(NotableDaysService days) : ControllerBase
|
|||||||
}
|
}
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("{regionCode}/current")]
|
||||||
|
public async Task<ActionResult<NotableDay?>> GetCurrentHoliday(string regionCode)
|
||||||
|
{
|
||||||
|
var result = await days.GetCurrentHoliday(regionCode);
|
||||||
|
if (result == null)
|
||||||
|
{
|
||||||
|
return NotFound("No holiday today");
|
||||||
|
}
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("me/current")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<NotableDay?>> GetAccountCurrentHoliday()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var region = currentUser.Region;
|
||||||
|
if (string.IsNullOrWhiteSpace(region)) region = "us";
|
||||||
|
|
||||||
|
var result = await days.GetCurrentHoliday(region);
|
||||||
|
if (result == null)
|
||||||
|
{
|
||||||
|
return NotFound("No holiday today");
|
||||||
|
}
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{regionCode}/recent")]
|
||||||
|
public async Task<ActionResult<List<NotableDay>>> GetRecentNotableDay(string regionCode)
|
||||||
|
{
|
||||||
|
var result = await days.GetCurrentAndNextHoliday(regionCode);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("me/recent")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<List<NotableDay>>> GetAccountRecentNotableDay()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var region = currentUser.Region;
|
||||||
|
if (string.IsNullOrWhiteSpace(region)) region = "us";
|
||||||
|
|
||||||
|
var result = await days.GetCurrentAndNextHoliday(region);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,12 +27,123 @@ public class NotableDaysService(ICacheService cache)
|
|||||||
var holidays = await holidayClient.GetHolidaysAsync(year.Value, regionCode);
|
var holidays = await holidayClient.GetHolidaysAsync(year.Value, regionCode);
|
||||||
var days = holidays?.Select(NotableDay.FromNagerHoliday).ToList() ?? [];
|
var days = holidays?.Select(NotableDay.FromNagerHoliday).ToList() ?? [];
|
||||||
|
|
||||||
|
// Add global holidays that are available for all regions
|
||||||
|
var globalDays = GetGlobalHolidays(year.Value);
|
||||||
|
foreach (var globalDay in globalDays.Where(globalDay =>
|
||||||
|
!days.Any(d => d.Date.Equals(globalDay.Date) && d.GlobalName == globalDay.GlobalName)))
|
||||||
|
{
|
||||||
|
days.Add(globalDay);
|
||||||
|
}
|
||||||
|
|
||||||
// Cache the result for 1 day (holiday data doesn't change frequently)
|
// Cache the result for 1 day (holiday data doesn't change frequently)
|
||||||
await cache.SetAsync(cacheKey, days, TimeSpan.FromDays(1));
|
await cache.SetAsync(cacheKey, days, TimeSpan.FromDays(1));
|
||||||
|
|
||||||
return days;
|
return days;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<NotableDay> GetGlobalHolidays(int year)
|
||||||
|
{
|
||||||
|
var globalDays = new List<NotableDay>();
|
||||||
|
|
||||||
|
// Christmas Day - December 25
|
||||||
|
var christmas = new NotableDay
|
||||||
|
{
|
||||||
|
Date = Instant.FromDateTimeUtc(new DateTime(year, 12, 25, 0, 0, 0, DateTimeKind.Utc)),
|
||||||
|
LocalName = "Christmas",
|
||||||
|
GlobalName = "Christmas",
|
||||||
|
LocalizableKey = "Christmas",
|
||||||
|
CountryCode = null,
|
||||||
|
Holidays = [NotableHolidayType.Public]
|
||||||
|
};
|
||||||
|
globalDays.Add(christmas);
|
||||||
|
|
||||||
|
// New Year's Day - January 1
|
||||||
|
var newYear = new NotableDay
|
||||||
|
{
|
||||||
|
Date = Instant.FromDateTimeUtc(new DateTime(year, 1, 1, 0, 0, 0, DateTimeKind.Utc)),
|
||||||
|
LocalName = "New Year's Day",
|
||||||
|
GlobalName = "New Year's Day",
|
||||||
|
LocalizableKey = "NewYear",
|
||||||
|
CountryCode = null,
|
||||||
|
Holidays = [NotableHolidayType.Public]
|
||||||
|
};
|
||||||
|
globalDays.Add(newYear);
|
||||||
|
|
||||||
|
// Valentine's Day - February 14
|
||||||
|
var valentine = new NotableDay
|
||||||
|
{
|
||||||
|
Date = Instant.FromDateTimeUtc(new DateTime(year, 2, 14, 0, 0, 0, DateTimeKind.Utc)),
|
||||||
|
LocalName = "Valentine's Day",
|
||||||
|
GlobalName = "Valentine's Day",
|
||||||
|
LocalizableKey = "ValentineDay",
|
||||||
|
CountryCode = null,
|
||||||
|
Holidays = [NotableHolidayType.Observance]
|
||||||
|
};
|
||||||
|
globalDays.Add(valentine);
|
||||||
|
|
||||||
|
// April Fools' Day - April 1
|
||||||
|
var aprilFools = new NotableDay
|
||||||
|
{
|
||||||
|
Date = Instant.FromDateTimeUtc(new DateTime(year, 4, 1, 0, 0, 0, DateTimeKind.Utc)),
|
||||||
|
LocalName = "April Fools' Day",
|
||||||
|
GlobalName = "April Fools' Day",
|
||||||
|
LocalizableKey = "AprilFoolsDay",
|
||||||
|
CountryCode = null,
|
||||||
|
Holidays = [NotableHolidayType.Observance]
|
||||||
|
};
|
||||||
|
globalDays.Add(aprilFools);
|
||||||
|
|
||||||
|
// International Workers' Day - May 1
|
||||||
|
var workersDay = new NotableDay
|
||||||
|
{
|
||||||
|
Date = Instant.FromDateTimeUtc(new DateTime(year, 5, 1, 0, 0, 0, DateTimeKind.Utc)),
|
||||||
|
LocalName = "International Workers' Day",
|
||||||
|
GlobalName = "International Workers' Day",
|
||||||
|
LocalizableKey = "WorkersDay",
|
||||||
|
CountryCode = null,
|
||||||
|
Holidays = [NotableHolidayType.Public]
|
||||||
|
};
|
||||||
|
globalDays.Add(workersDay);
|
||||||
|
|
||||||
|
// Children's Day - June 1
|
||||||
|
var childrenDay = new NotableDay
|
||||||
|
{
|
||||||
|
Date = Instant.FromDateTimeUtc(new DateTime(year, 6, 1, 0, 0, 0, DateTimeKind.Utc)),
|
||||||
|
LocalName = "Children's Day",
|
||||||
|
GlobalName = "Children's Day",
|
||||||
|
LocalizableKey = "ChildrenDay",
|
||||||
|
CountryCode = null,
|
||||||
|
Holidays = [NotableHolidayType.Public]
|
||||||
|
};
|
||||||
|
globalDays.Add(childrenDay);
|
||||||
|
|
||||||
|
// World Environment Day - June 5
|
||||||
|
var environmentDay = new NotableDay
|
||||||
|
{
|
||||||
|
Date = Instant.FromDateTimeUtc(new DateTime(year, 6, 5, 0, 0, 0, DateTimeKind.Utc)),
|
||||||
|
LocalName = "World Environment Day",
|
||||||
|
GlobalName = "World Environment Day",
|
||||||
|
LocalizableKey = "EnvironmentDay",
|
||||||
|
CountryCode = null,
|
||||||
|
Holidays = [NotableHolidayType.Observance]
|
||||||
|
};
|
||||||
|
globalDays.Add(environmentDay);
|
||||||
|
|
||||||
|
// Halloween - October 31
|
||||||
|
var halloween = new NotableDay
|
||||||
|
{
|
||||||
|
Date = Instant.FromDateTimeUtc(new DateTime(year, 10, 31, 0, 0, 0, DateTimeKind.Utc)),
|
||||||
|
LocalName = "Halloween",
|
||||||
|
GlobalName = "Halloween",
|
||||||
|
LocalizableKey = "Halloween",
|
||||||
|
CountryCode = null,
|
||||||
|
Holidays = [NotableHolidayType.Observance]
|
||||||
|
};
|
||||||
|
globalDays.Add(halloween);
|
||||||
|
|
||||||
|
return globalDays;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<NotableDay?> GetNextHoliday(string regionCode)
|
public async Task<NotableDay?> GetNextHoliday(string regionCode)
|
||||||
{
|
{
|
||||||
var currentDate = SystemClock.Instance.GetCurrentInstant();
|
var currentDate = SystemClock.Instance.GetCurrentInstant();
|
||||||
@@ -52,4 +163,37 @@ public class NotableDaysService(ICacheService cache)
|
|||||||
|
|
||||||
return nextHoliday;
|
return nextHoliday;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<NotableDay?> GetCurrentHoliday(string regionCode)
|
||||||
|
{
|
||||||
|
var currentDate = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var currentYear = currentDate.InUtc().Year;
|
||||||
|
|
||||||
|
var currentYearHolidays = await GetNotableDays(currentYear, regionCode);
|
||||||
|
|
||||||
|
// Find the holiday that is today
|
||||||
|
var todayHoliday = currentYearHolidays
|
||||||
|
.FirstOrDefault(day => day.Date.InUtc().Date == currentDate.InUtc().Date);
|
||||||
|
|
||||||
|
return todayHoliday;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<NotableDay>> GetCurrentAndNextHoliday(string regionCode)
|
||||||
|
{
|
||||||
|
var result = new List<NotableDay>();
|
||||||
|
|
||||||
|
var current = await GetCurrentHoliday(regionCode);
|
||||||
|
if (current != null)
|
||||||
|
{
|
||||||
|
result.Add(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
var next = await GetNextHoliday(regionCode);
|
||||||
|
if (next != null && (current == null || !next.Date.Equals(current.Date)))
|
||||||
|
{
|
||||||
|
result.Add(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
|
|||||||
var userId = currentUser.Id;
|
var userId = currentUser.Id;
|
||||||
|
|
||||||
var query = db.AccountRelationships.AsQueryable()
|
var query = db.AccountRelationships.AsQueryable()
|
||||||
|
.OrderByDescending(r => r.CreatedAt)
|
||||||
.Where(r => r.RelatedId == userId);
|
.Where(r => r.RelatedId == userId);
|
||||||
var totalCount = await query.CountAsync();
|
var totalCount = await query.CountAsync();
|
||||||
var relationships = await query
|
var relationships = await query
|
||||||
@@ -35,8 +36,9 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
|
|||||||
.Where(r => r.AccountId == userId)
|
.Where(r => r.AccountId == userId)
|
||||||
.ToDictionaryAsync(r => r.RelatedId);
|
.ToDictionaryAsync(r => r.RelatedId);
|
||||||
foreach (var relationship in relationships)
|
foreach (var relationship in relationships)
|
||||||
if (statuses.TryGetValue(relationship.RelatedId, out var status))
|
relationship.Status = statuses.TryGetValue(relationship.AccountId, out var status)
|
||||||
relationship.Status = status.Status;
|
? status.Status
|
||||||
|
: RelationshipStatus.Pending;
|
||||||
|
|
||||||
Response.Headers["X-Total"] = totalCount.ToString();
|
Response.Headers["X-Total"] = totalCount.ToString();
|
||||||
|
|
||||||
|
|||||||
@@ -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,59 @@ 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) return relationships;
|
||||||
|
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
|
||||||
|
var query = db.AccountRelationships
|
||||||
|
.Where(r => r.RelatedId == accountId)
|
||||||
|
.Where(r => r.Status == status)
|
||||||
|
.Where(r => r.ExpiredAt == null || r.ExpiredAt > now)
|
||||||
|
.Select(r => r.AccountId);
|
||||||
|
|
||||||
|
if (status == RelationshipStatus.Friends)
|
||||||
|
{
|
||||||
|
var usersBlockedByMe = db.AccountRelationships
|
||||||
|
.Where(r => r.AccountId == accountId && r.Status == RelationshipStatus.Blocked)
|
||||||
|
.Select(r => r.RelatedId);
|
||||||
|
query = query.Except(usersBlockedByMe);
|
||||||
|
}
|
||||||
|
|
||||||
|
relationships = await query.ToListAsync();
|
||||||
|
|
||||||
|
await cache.SetAsync(cacheKey, relationships, CacheExpiration);
|
||||||
|
|
||||||
|
return relationships;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
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,11 @@ 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!;
|
||||||
|
|
||||||
|
public DbSet<SnRewindPoint> RewindPoints { get; set; }
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
optionsBuilder.UseNpgsql(
|
optionsBuilder.UseNpgsql(
|
||||||
@@ -100,7 +105,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 +148,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 +232,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; }
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user