Compare commits
151 Commits
32e91da0b2
...
refactor/a
Author | SHA1 | Date | |
---|---|---|---|
f6f0703cb3
|
|||
3d47b4e44e
|
|||
71fe2a30e7
|
|||
d8f57161ae
|
|||
3caa79b9a7
|
|||
49beb17925
|
|||
bd8e13f25d
|
|||
1128c9a0ba
|
|||
8dfe201afe
|
|||
c1016e496a
|
|||
091097a858
|
|||
5c97733b3e
|
|||
4ee387ab76
|
|||
19bf17200d
|
|||
be6d97ec85
|
|||
9d282b26f3
|
|||
dbc2c54ab0
|
|||
aa062932cf
|
|||
812dd03e85
|
|||
06d639a114
|
|||
74f51036b1
|
|||
8308325b73
|
|||
fa7010db3d
|
|||
89320fc540
|
|||
5ec8d89563
|
|||
0eeafb5352
|
|||
ab2bdcc7ca
|
|||
c2b49e6642
|
|||
1a89c48790
|
|||
8dddfe77cd
|
|||
8e8b011fdd
|
|||
abd346bb97
|
|||
6386ec8caa
|
|||
ad062828ff
|
|||
92e4988114
|
|||
f9269d7558
|
|||
fa01b7027a
|
|||
eaa3a9c297
|
|||
6cedda9307
|
|||
942ca73f8d
|
|||
da3f58f2ec
|
|||
4a8521d59d
|
|||
d7ad84e199
|
|||
52430c19a5
|
|||
9492b6cac6
|
|||
5f324a2348
|
|||
7452b14817
|
|||
4a27794ccc
|
|||
d2f5ba36ab
|
|||
0117fdf084 | |||
02680d224a | |||
68bfdebcbd | |||
54907eede1 | |||
a21d19c3ef | |||
df732616d5 | |||
79a31ae060 | |||
6eacfcd8f2 | |||
5e328509bd | |||
9c078db564 | |||
ddd109c77c | |||
3ee04d0b24 | |||
7f110313e9 | |||
bc2e87c56f | |||
d7271a2d11 | |||
c57d65db67 | |||
edf3aab173 | |||
352746a141 | |||
216c72ea36 | |||
d0723b366b | |||
fb6721cb1b | |||
9fcb169c94 | |||
572874431d | |||
f595ac8001 | |||
18674e0e1d | |||
da4c4d3a84 | |||
aec01b117d | |||
d299c32e35 | |||
344007af66 | |||
d4de5aeac2 | |||
8ce5ba50f4 | |||
5a44952b27 | |||
c30946daf6 | |||
0221d7b294 | |||
c44b0b64c3 | |||
442ee3bcfd | |||
081815c512 | |||
eab2a388ae | |||
5f7ab49abb | |||
4ff89173b2 | |||
f2052410c7 | |||
83a49be725 | |||
9b205a73fd | |||
d5157eb7e3 | |||
75c92c51db | |||
915054fce0 | |||
63653680ba | |||
84c4df6620 | |||
8c748fd57a | |||
4684550ebf | |||
51db08f374 | |||
9f38a288b9 | |||
75a975049c | |||
f8c35c0350 | |||
d9a5fed77f | |||
7cb14940d9 | |||
953bf5d4de | |||
d9620fd6a4 | |||
541e2dd14c | |||
c7925d98c8 | |||
f759b19bcb | |||
5d7429a416 | |||
fb7e52d6f3 | |||
50e888b075 | |||
76c8bbf307 | |||
8f3825e92c | |||
d1c3610ec8 | |||
4b958a3c31 | |||
1f9021d459 | |||
7ad9deaf70 | |||
c1c17b5f4e | |||
d92220b4bc | |||
4d1972bc99 | |||
83c052ec4e | |||
57a75fe9e6 | |||
379bc37aff | |||
0217fbb13b | |||
4e9943e6a2 | |||
b3cc623168 | |||
3ee5e5367d | |||
85fef30c7f | |||
e8d8dcbb2d | |||
3b679d6134 | |||
ec44b51ab6 | |||
2e52a13c30 | |||
1e8e2e9ea7 | |||
9e8363c004 | |||
56c40ee001 | |||
e3dfccfee3 | |||
d555fcaf17 | |||
2fdefae718 | |||
e78858b7b4 | |||
636b674229 | |||
fc6cee17d7 | |||
7f7b47fb1c | |||
bf181b88ec | |||
c056938b6e | |||
66eadf96b0 | |||
665595b8b4 | |||
29550401fd | |||
1bb0012c40 | |||
2cea391ebf |
3
.aspire/settings.json
Normal file
3
.aspire/settings.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"appHostPath": "../DysonNetwork.Control/DysonNetwork.Control.csproj"
|
||||
}
|
35
.env
Normal file
35
.env
Normal file
@@ -0,0 +1,35 @@
|
||||
# Default container port for ring
|
||||
RING_PORT=8080
|
||||
|
||||
# Default container port for pass
|
||||
PASS_PORT=8080
|
||||
|
||||
# Default container port for drive
|
||||
DRIVE_PORT=8080
|
||||
|
||||
# Default container port for sphere
|
||||
SPHERE_PORT=8080
|
||||
|
||||
# Default container port for develop
|
||||
DEVELOP_PORT=8080
|
||||
|
||||
# Parameter cache-password
|
||||
CACHE_PASSWORD=KS3jSPaU9e
|
||||
|
||||
# Parameter queue-password
|
||||
QUEUE_PASSWORD=8xEECa4ckz
|
||||
|
||||
# Container image name for ring
|
||||
RING_IMAGE=ring:latest
|
||||
|
||||
# Container image name for pass
|
||||
PASS_IMAGE=pass:latest
|
||||
|
||||
# Container image name for drive
|
||||
DRIVE_IMAGE=drive:latest
|
||||
|
||||
# Container image name for sphere
|
||||
SPHERE_IMAGE=sphere:latest
|
||||
|
||||
# Container image name for develop
|
||||
DEVELOP_IMAGE=develop:latest
|
249
.github/workflows/docker-build.yml
vendored
249
.github/workflows/docker-build.yml
vendored
@@ -1,189 +1,60 @@
|
||||
name: Build and Push Microservices
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-sphere:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup NBGV
|
||||
uses: dotnet/nbgv@master
|
||||
id: nbgv
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push DysonNetwork.Sphere Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: DysonNetwork.Sphere/Dockerfile
|
||||
context: .
|
||||
push: true
|
||||
tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-sphere:latest
|
||||
platforms: linux/amd64
|
||||
|
||||
build-pass:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup NBGV
|
||||
uses: dotnet/nbgv@master
|
||||
id: nbgv
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push DysonNetwork.Pass Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: DysonNetwork.Pass/Dockerfile
|
||||
context: .
|
||||
push: true
|
||||
tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-pass:latest
|
||||
platforms: linux/amd64
|
||||
|
||||
build-pusher:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup NBGV
|
||||
uses: dotnet/nbgv@master
|
||||
id: nbgv
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push DysonNetwork.Pusher Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: DysonNetwork.Pusher/Dockerfile
|
||||
context: .
|
||||
push: true
|
||||
tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-pusher:latest
|
||||
platforms: linux/amd64
|
||||
|
||||
build-drive:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup NBGV
|
||||
uses: dotnet/nbgv@master
|
||||
id: nbgv
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push DysonNetwork.Drive Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: DysonNetwork.Drive/Dockerfile
|
||||
context: .
|
||||
push: true
|
||||
tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-drive:latest
|
||||
platforms: linux/amd64
|
||||
|
||||
build-gateway:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup NBGV
|
||||
uses: dotnet/nbgv@master
|
||||
id: nbgv
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push DysonNetwork.Gateway Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: DysonNetwork.Gateway/Dockerfile
|
||||
context: .
|
||||
push: true
|
||||
tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-gateway:latest
|
||||
platforms: linux/amd64
|
||||
|
||||
build-develop:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup NBGV
|
||||
uses: dotnet/nbgv@master
|
||||
id: nbgv
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push DysonNetwork.Develop Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: DysonNetwork.Develop/Dockerfile
|
||||
context: .
|
||||
push: true
|
||||
tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-develop:latest
|
||||
platforms: linux/amd64
|
||||
|
||||
name: Aspire Publish Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v3
|
||||
with:
|
||||
dotnet-version: "9.0.x"
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Aspire CLI
|
||||
run: dotnet tool install -g Aspire.Cli --prerelease
|
||||
|
||||
- name: Build and Publish Aspire Application
|
||||
run: aspire publish --project ./DysonNetwork.Control/DysonNetwork.Control.csproj --output publish
|
||||
|
||||
- name: Tag and Push Images
|
||||
run: |
|
||||
IMAGES=( "sphere" "pass" "ring" "drive" "develop" )
|
||||
|
||||
for image in "${IMAGES[@]}"; do
|
||||
IMAGE_NAME="ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-$image:alpha"
|
||||
SOURCE_IMAGE_NAME="$image:latest" # Aspire's default local image name
|
||||
|
||||
echo "Tagging and pushing $SOURCE_IMAGE_NAME to $IMAGE_NAME..."
|
||||
docker tag $SOURCE_IMAGE_NAME $IMAGE_NAME
|
||||
docker push $IMAGE_NAME
|
||||
done
|
||||
|
||||
- name: Upload Aspire Publish Directory
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: aspire-publish-output
|
||||
path: ./publish/
|
||||
|
||||
- name: Upload Docker Compose file
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: docker-compose-output
|
||||
path: ./publish/docker-compose.yml
|
||||
|
77
DysonNetwork.Control/AppHost.cs
Normal file
77
DysonNetwork.Control/AppHost.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using Aspire.Hosting.Yarp.Transforms;
|
||||
|
||||
var builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
// Database was configured separately in each service.
|
||||
// var database = builder.AddPostgres("database");
|
||||
|
||||
var cache = builder.AddRedis("cache");
|
||||
var queue = builder.AddNats("queue").WithJetStream();
|
||||
|
||||
var ringService = builder.AddProject<Projects.DysonNetwork_Ring>("ring")
|
||||
.WithReference(queue)
|
||||
.WithHttpHealthCheck()
|
||||
.WithEndpoint(5001, 5001, "https", name: "grpc");
|
||||
var passService = builder.AddProject<Projects.DysonNetwork_Pass>("pass")
|
||||
.WithReference(cache)
|
||||
.WithReference(queue)
|
||||
.WithReference(ringService)
|
||||
.WithHttpHealthCheck()
|
||||
.WithEndpoint(5001, 5001, "https", name: "grpc");
|
||||
var driveService = builder.AddProject<Projects.DysonNetwork_Drive>("drive")
|
||||
.WithReference(cache)
|
||||
.WithReference(queue)
|
||||
.WithReference(passService)
|
||||
.WithReference(ringService)
|
||||
.WithHttpHealthCheck()
|
||||
.WithEndpoint(5001, 5001, "https", name: "grpc");
|
||||
var sphereService = builder.AddProject<Projects.DysonNetwork_Sphere>("sphere")
|
||||
.WithReference(cache)
|
||||
.WithReference(queue)
|
||||
.WithReference(passService)
|
||||
.WithReference(ringService)
|
||||
.WithHttpHealthCheck()
|
||||
.WithEndpoint(5001, 5001, "https", name: "grpc");
|
||||
var developService = builder.AddProject<Projects.DysonNetwork_Develop>("develop")
|
||||
.WithReference(cache)
|
||||
.WithReference(passService)
|
||||
.WithReference(ringService)
|
||||
.WithHttpHealthCheck()
|
||||
.WithEndpoint(5001, 5001, "https", name: "grpc");
|
||||
|
||||
// Extra double-ended references
|
||||
ringService.WithReference(passService);
|
||||
|
||||
builder.AddYarp("gateway")
|
||||
.WithHostPort(5000)
|
||||
.WithConfiguration(yarp =>
|
||||
{
|
||||
var ringCluster = yarp.AddCluster(ringService.GetEndpoint("http"));
|
||||
yarp.AddRoute("/ws", ringCluster);
|
||||
yarp.AddRoute("/ring/{**catch-all}", ringCluster)
|
||||
.WithTransformPathRemovePrefix("/ring")
|
||||
.WithTransformPathPrefix("/api");
|
||||
var passCluster = yarp.AddCluster(passService.GetEndpoint("http"));
|
||||
yarp.AddRoute("/.well-known/openid-configuration", passCluster);
|
||||
yarp.AddRoute("/.well-known/jwks", passCluster);
|
||||
yarp.AddRoute("/id/{**catch-all}", passCluster)
|
||||
.WithTransformPathRemovePrefix("/id")
|
||||
.WithTransformPathPrefix("/api");
|
||||
var driveCluster = yarp.AddCluster(driveService.GetEndpoint("http"));
|
||||
yarp.AddRoute("/api/tus", driveCluster);
|
||||
yarp.AddRoute("/drive/{**catch-all}", driveCluster)
|
||||
.WithTransformPathRemovePrefix("/drive")
|
||||
.WithTransformPathPrefix("/api");
|
||||
var sphereCluster = yarp.AddCluster(sphereService.GetEndpoint("http"));
|
||||
yarp.AddRoute("/sphere/{**catch-all}", sphereCluster)
|
||||
.WithTransformPathRemovePrefix("/sphere")
|
||||
.WithTransformPathPrefix("/api");
|
||||
var developCluster = yarp.AddCluster(developService.GetEndpoint("http"));
|
||||
yarp.AddRoute("/develop/{**catch-all}", developCluster)
|
||||
.WithTransformPathRemovePrefix("/develop")
|
||||
.WithTransformPathPrefix("/api");
|
||||
});
|
||||
|
||||
builder.AddDockerComposeEnvironment("docker-compose");
|
||||
|
||||
builder.Build().Run();
|
30
DysonNetwork.Control/DysonNetwork.Control.csproj
Normal file
30
DysonNetwork.Control/DysonNetwork.Control.csproj
Normal file
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<Sdk Name="Aspire.AppHost.Sdk" Version="9.4.2"/>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UserSecretsId>a68b3195-a00d-40c2-b5ed-d675356b7cde</UserSecretsId>
|
||||
<RootNamespace>DysonNetwork.Control</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.4.2"/>
|
||||
<PackageReference Include="Aspire.Hosting.Docker" Version="9.4.2-preview.1.25428.12" />
|
||||
<PackageReference Include="Aspire.Hosting.Nats" Version="9.4.2" />
|
||||
<PackageReference Include="Aspire.Hosting.Redis" Version="9.4.2" />
|
||||
<PackageReference Include="Aspire.Hosting.Yarp" Version="9.4.2-preview.1.25428.12" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DysonNetwork.Develop\DysonNetwork.Develop.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Pass\DysonNetwork.Pass.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Ring\DysonNetwork.Ring.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
29
DysonNetwork.Control/Properties/launchSettings.json
Normal file
29
DysonNetwork.Control/Properties/launchSettings.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:17025;http://localhost:15057",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21175",
|
||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22189"
|
||||
}
|
||||
},
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:15057",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19163",
|
||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20185"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
11
DysonNetwork.Control/appsettings.json
Normal file
11
DysonNetwork.Control/appsettings.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"cache": "localhost:6379"
|
||||
}
|
||||
}
|
@@ -1,4 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Develop.Identity;
|
||||
using DysonNetwork.Develop.Project;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
@@ -11,8 +13,11 @@ public class AppDatabase(
|
||||
{
|
||||
public DbSet<Developer> Developers { get; set; } = null!;
|
||||
|
||||
public DbSet<DevProject> DevProjects { get; set; } = null!;
|
||||
|
||||
public DbSet<CustomApp> CustomApps { get; set; } = null!;
|
||||
public DbSet<CustomAppSecret> CustomAppSecrets { get; set; } = null!;
|
||||
public DbSet<BotAccount> BotAccounts { get; set; } = null!;
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
|
@@ -31,6 +31,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
54
DysonNetwork.Develop/Identity/BotAccount.cs
Normal file
54
DysonNetwork.Develop/Identity/BotAccount.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using DysonNetwork.Develop.Project;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
public class BotAccount : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(1024)] public string Slug { get; set; } = null!;
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public Guid ProjectId { get; set; }
|
||||
public DevProject Project { get; set; } = null!;
|
||||
|
||||
[NotMapped] public AccountReference? Account { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// This developer field is to serve the transparent info for user to know which developer
|
||||
/// published this robot. Not for relationships usage.
|
||||
/// </summary>
|
||||
[NotMapped] public Developer? Developer { get; set; }
|
||||
|
||||
public Shared.Proto.BotAccount ToProtoValue()
|
||||
{
|
||||
var proto = new Shared.Proto.BotAccount
|
||||
{
|
||||
Slug = Slug,
|
||||
IsActive = IsActive,
|
||||
AutomatedId = Id.ToString(),
|
||||
CreatedAt = CreatedAt.ToTimestamp(),
|
||||
UpdatedAt = UpdatedAt.ToTimestamp()
|
||||
};
|
||||
|
||||
return proto;
|
||||
}
|
||||
|
||||
public static BotAccount FromProto(Shared.Proto.BotAccount proto)
|
||||
{
|
||||
var botAccount = new BotAccount
|
||||
{
|
||||
Id = Guid.Parse(proto.AutomatedId),
|
||||
Slug = proto.Slug,
|
||||
IsActive = proto.IsActive,
|
||||
CreatedAt = proto.CreatedAt.ToInstant(),
|
||||
UpdatedAt = proto.UpdatedAt.ToInstant()
|
||||
};
|
||||
|
||||
return botAccount;
|
||||
}
|
||||
}
|
460
DysonNetwork.Develop/Identity/BotAccountController.cs
Normal file
460
DysonNetwork.Develop/Identity/BotAccountController.cs
Normal file
@@ -0,0 +1,460 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Develop.Project;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Grpc.Core;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/developers/{pubName}/projects/{projectId:guid}/bots")]
|
||||
[Authorize]
|
||||
public class BotAccountController(
|
||||
BotAccountService botService,
|
||||
DeveloperService developerService,
|
||||
DevProjectService projectService,
|
||||
ILogger<BotAccountController> logger,
|
||||
AccountClientHelper accounts,
|
||||
BotAccountReceiverService.BotAccountReceiverServiceClient accountsReceiver
|
||||
)
|
||||
: ControllerBase
|
||||
{
|
||||
public class CommonBotRequest
|
||||
{
|
||||
[MaxLength(256)] public string? FirstName { get; set; }
|
||||
[MaxLength(256)] public string? MiddleName { get; set; }
|
||||
[MaxLength(256)] public string? LastName { get; set; }
|
||||
[MaxLength(1024)] public string? Gender { get; set; }
|
||||
[MaxLength(1024)] public string? Pronouns { get; set; }
|
||||
[MaxLength(1024)] public string? TimeZone { get; set; }
|
||||
[MaxLength(1024)] public string? Location { get; set; }
|
||||
[MaxLength(4096)] public string? Bio { get; set; }
|
||||
public Instant? Birthday { get; set; }
|
||||
|
||||
[MaxLength(32)] public string? PictureId { get; set; }
|
||||
[MaxLength(32)] public string? BackgroundId { get; set; }
|
||||
}
|
||||
|
||||
public class BotCreateRequest : CommonBotRequest
|
||||
{
|
||||
[Required]
|
||||
[MinLength(2)]
|
||||
[MaxLength(256)]
|
||||
[RegularExpression(@"^[A-Za-z0-9_-]+$",
|
||||
ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.")
|
||||
]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[Required] [MaxLength(256)] public string Nick { get; set; } = string.Empty;
|
||||
|
||||
[Required] [MaxLength(1024)] public string Slug { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(128)] public string Language { get; set; } = "en-us";
|
||||
}
|
||||
|
||||
public class UpdateBotRequest : CommonBotRequest
|
||||
{
|
||||
[MinLength(2)]
|
||||
[MaxLength(256)]
|
||||
[RegularExpression(@"^[A-Za-z0-9_-]+$",
|
||||
ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.")
|
||||
]
|
||||
public string? Name { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(256)] public string? Nick { get; set; } = string.Empty;
|
||||
|
||||
[Required] [MaxLength(1024)] public string? Slug { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(128)] public string? Language { get; set; }
|
||||
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ListBots(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||
PublisherMemberRole.Viewer))
|
||||
return StatusCode(403, "You must be an viewer of the developer to list bots");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var bots = await botService.GetBotsByProjectAsync(projectId);
|
||||
return Ok(await botService.LoadBotsAccountAsync(bots));
|
||||
}
|
||||
|
||||
[HttpGet("{botId:guid}")]
|
||||
public async Task<IActionResult> GetBot(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid botId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||
PublisherMemberRole.Viewer))
|
||||
return StatusCode(403, "You must be an viewer of the developer to view bot details");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var bot = await botService.GetBotByIdAsync(botId);
|
||||
if (bot is null || bot.ProjectId != projectId)
|
||||
return NotFound("Bot not found");
|
||||
|
||||
return Ok(await botService.LoadBotAccountAsync(bot));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CreateBot(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromBody] BotCreateRequest createRequest
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||
PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to create a bot");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var accountId = Guid.NewGuid();
|
||||
var account = new Account()
|
||||
{
|
||||
Id = accountId.ToString(),
|
||||
Name = createRequest.Name,
|
||||
Nick = createRequest.Nick,
|
||||
Language = createRequest.Language,
|
||||
Profile = new AccountProfile()
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
Bio = createRequest.Bio,
|
||||
Gender = createRequest.Gender,
|
||||
FirstName = createRequest.FirstName,
|
||||
MiddleName = createRequest.MiddleName,
|
||||
LastName = createRequest.LastName,
|
||||
TimeZone = createRequest.TimeZone,
|
||||
Pronouns = createRequest.Pronouns,
|
||||
Location = createRequest.Location,
|
||||
Birthday = createRequest.Birthday?.ToTimestamp(),
|
||||
AccountId = accountId.ToString(),
|
||||
CreatedAt = now.ToTimestamp(),
|
||||
UpdatedAt = now.ToTimestamp()
|
||||
},
|
||||
CreatedAt = now.ToTimestamp(),
|
||||
UpdatedAt = now.ToTimestamp()
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var bot = await botService.CreateBotAsync(
|
||||
project,
|
||||
createRequest.Slug,
|
||||
account,
|
||||
createRequest.PictureId,
|
||||
createRequest.BackgroundId
|
||||
);
|
||||
return Ok(bot);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error creating bot account");
|
||||
return StatusCode(500, "An error occurred while creating the bot account");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPatch("{botId:guid}")]
|
||||
public async Task<IActionResult> UpdateBot(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid botId,
|
||||
[FromBody] UpdateBotRequest request
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||
PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to update a bot");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var bot = await botService.GetBotByIdAsync(botId);
|
||||
if (bot is null || bot.ProjectId != projectId)
|
||||
return NotFound("Bot not found");
|
||||
|
||||
var botAccount = await accounts.GetBotAccount(bot.Id);
|
||||
|
||||
if (request.Name is not null) botAccount.Name = request.Name;
|
||||
if (request.Nick is not null) botAccount.Nick = request.Nick;
|
||||
if (request.Language is not null) botAccount.Language = request.Language;
|
||||
if (request.Bio is not null) botAccount.Profile.Bio = request.Bio;
|
||||
if (request.Gender is not null) botAccount.Profile.Gender = request.Gender;
|
||||
if (request.FirstName is not null) botAccount.Profile.FirstName = request.FirstName;
|
||||
if (request.MiddleName is not null) botAccount.Profile.MiddleName = request.MiddleName;
|
||||
if (request.LastName is not null) botAccount.Profile.LastName = request.LastName;
|
||||
if (request.TimeZone is not null) botAccount.Profile.TimeZone = request.TimeZone;
|
||||
if (request.Pronouns is not null) botAccount.Profile.Pronouns = request.Pronouns;
|
||||
if (request.Location is not null) botAccount.Profile.Location = request.Location;
|
||||
if (request.Birthday is not null) botAccount.Profile.Birthday = request.Birthday?.ToTimestamp();
|
||||
|
||||
if (request.Slug is not null) bot.Slug = request.Slug;
|
||||
if (request.IsActive is not null) bot.IsActive = request.IsActive.Value;
|
||||
|
||||
try
|
||||
{
|
||||
var updatedBot = await botService.UpdateBotAsync(
|
||||
bot,
|
||||
botAccount,
|
||||
request.PictureId,
|
||||
request.BackgroundId
|
||||
);
|
||||
|
||||
return Ok(updatedBot);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error updating bot account {BotId}", botId);
|
||||
return StatusCode(500, "An error occurred while updating the bot account");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{botId:guid}")]
|
||||
public async Task<IActionResult> DeleteBot(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid botId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||
PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to delete a bot");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var bot = await botService.GetBotByIdAsync(botId);
|
||||
if (bot is null || bot.ProjectId != projectId)
|
||||
return NotFound("Bot not found");
|
||||
|
||||
try
|
||||
{
|
||||
await botService.DeleteBotAsync(bot);
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error deleting bot {BotId}", botId);
|
||||
return StatusCode(500, "An error occurred while deleting the bot account");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{botId:guid}/keys")]
|
||||
public async Task<ActionResult<List<ApiKeyReference>>> ListBotKeys(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid botId
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Viewer);
|
||||
if (developer == null) return NotFound("Developer not found");
|
||||
if (project == null) return NotFound("Project not found or you don't have access");
|
||||
if (bot == null) return NotFound("Bot not found");
|
||||
|
||||
var keys = await accountsReceiver.ListApiKeyAsync(new ListApiKeyRequest
|
||||
{
|
||||
AutomatedId = bot.Id.ToString()
|
||||
});
|
||||
var data = keys.Data.Select(ApiKeyReference.FromProtoValue).ToList();
|
||||
|
||||
return Ok(data);
|
||||
}
|
||||
|
||||
[HttpGet("{botId:guid}/keys/{keyId:guid}")]
|
||||
public async Task<ActionResult<ApiKeyReference>> GetBotKey(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid botId,
|
||||
[FromRoute] Guid keyId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Viewer);
|
||||
if (developer == null) return NotFound("Developer not found");
|
||||
if (project == null) return NotFound("Project not found or you don't have access");
|
||||
if (bot == null) return NotFound("Bot not found");
|
||||
|
||||
try
|
||||
{
|
||||
var key = await accountsReceiver.GetApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() });
|
||||
if (key == null) return NotFound("API key not found");
|
||||
return Ok(ApiKeyReference.FromProtoValue(key));
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
|
||||
{
|
||||
return NotFound("API key not found");
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateApiKeyRequest
|
||||
{
|
||||
[Required, MaxLength(1024)]
|
||||
public string Label { get; set; } = null!;
|
||||
}
|
||||
|
||||
[HttpPost("{botId:guid}/keys")]
|
||||
public async Task<ActionResult<ApiKeyReference>> CreateBotKey(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid botId,
|
||||
[FromBody] CreateApiKeyRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor);
|
||||
if (developer == null) return NotFound("Developer not found");
|
||||
if (project == null) return NotFound("Project not found or you don't have access");
|
||||
if (bot == null) return NotFound("Bot not found");
|
||||
|
||||
try
|
||||
{
|
||||
var newKey = new ApiKey
|
||||
{
|
||||
AccountId = bot.Id.ToString(),
|
||||
Label = request.Label
|
||||
};
|
||||
|
||||
var createdKey = await accountsReceiver.CreateApiKeyAsync(newKey);
|
||||
return Ok(ApiKeyReference.FromProtoValue(createdKey));
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument)
|
||||
{
|
||||
return BadRequest(ex.Status.Detail);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{botId:guid}/keys/{keyId:guid}/rotate")]
|
||||
public async Task<ActionResult<ApiKeyReference>> RotateBotKey(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid botId,
|
||||
[FromRoute] Guid keyId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor);
|
||||
if (developer == null) return NotFound("Developer not found");
|
||||
if (project == null) return NotFound("Project not found or you don't have access");
|
||||
if (bot == null) return NotFound("Bot not found");
|
||||
|
||||
try
|
||||
{
|
||||
var rotatedKey = await accountsReceiver.RotateApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() });
|
||||
return Ok(ApiKeyReference.FromProtoValue(rotatedKey));
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
|
||||
{
|
||||
return NotFound("API key not found");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{botId:guid}/keys/{keyId:guid}")]
|
||||
public async Task<IActionResult> DeleteBotKey(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid botId,
|
||||
[FromRoute] Guid keyId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor);
|
||||
if (developer == null) return NotFound("Developer not found");
|
||||
if (project == null) return NotFound("Project not found or you don't have access");
|
||||
if (bot == null) return NotFound("Bot not found");
|
||||
|
||||
try
|
||||
{
|
||||
await accountsReceiver.DeleteApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() });
|
||||
return NoContent();
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
|
||||
{
|
||||
return NotFound("API key not found");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(Developer?, DevProject?, BotAccount?)> ValidateBotAccess(
|
||||
string pubName,
|
||||
Guid projectId,
|
||||
Guid botId,
|
||||
Account currentUser,
|
||||
PublisherMemberRole requiredRole)
|
||||
{
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
if (developer == null) return (null, null, null);
|
||||
|
||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), requiredRole))
|
||||
return (null, null, null);
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project == null) return (developer, null, null);
|
||||
|
||||
var bot = await botService.GetBotByIdAsync(botId);
|
||||
if (bot == null || bot.ProjectId != projectId) return (developer, project, null);
|
||||
|
||||
return (developer, project, bot);
|
||||
}
|
||||
}
|
35
DysonNetwork.Develop/Identity/BotAccountPublicController.cs
Normal file
35
DysonNetwork.Develop/Identity/BotAccountPublicController.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/bots")]
|
||||
public class BotAccountPublicController(BotAccountService botService, DeveloperService developerService) : ControllerBase
|
||||
{
|
||||
[HttpGet("{botId:guid}")]
|
||||
public async Task<ActionResult<BotAccount>> GetBotTransparentInfo([FromRoute] Guid botId)
|
||||
{
|
||||
var bot = await botService.GetBotByIdAsync(botId);
|
||||
if (bot is null) return NotFound("Bot not found");
|
||||
bot = await botService.LoadBotAccountAsync(bot);
|
||||
|
||||
var developer = await developerService.GetDeveloperById(bot!.Project.DeveloperId);
|
||||
if (developer is null) return NotFound("Developer not found");
|
||||
bot.Developer = await developerService.LoadDeveloperPublisher(developer);
|
||||
|
||||
return Ok(bot);
|
||||
}
|
||||
|
||||
[HttpGet("{botId:guid}/developer")]
|
||||
public async Task<ActionResult<Developer>> GetBotDeveloper([FromRoute] Guid botId)
|
||||
{
|
||||
var bot = await botService.GetBotByIdAsync(botId);
|
||||
if (bot is null) return NotFound("Bot not found");
|
||||
|
||||
var developer = await developerService.GetDeveloperById(bot!.Project.DeveloperId);
|
||||
if (developer is null) return NotFound("Developer not found");
|
||||
developer = await developerService.LoadDeveloperPublisher(developer);
|
||||
|
||||
return Ok(developer);
|
||||
}
|
||||
}
|
174
DysonNetwork.Develop/Identity/BotAccountService.cs
Normal file
174
DysonNetwork.Develop/Identity/BotAccountService.cs
Normal file
@@ -0,0 +1,174 @@
|
||||
using DysonNetwork.Develop.Project;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Grpc.Core;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
public class BotAccountService(
|
||||
AppDatabase db,
|
||||
BotAccountReceiverService.BotAccountReceiverServiceClient accountReceiver,
|
||||
AccountClientHelper accounts
|
||||
)
|
||||
{
|
||||
public async Task<BotAccount?> GetBotByIdAsync(Guid id)
|
||||
{
|
||||
return await db.BotAccounts
|
||||
.Include(b => b.Project)
|
||||
.FirstOrDefaultAsync(b => b.Id == id);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<BotAccount>> GetBotsByProjectAsync(Guid projectId)
|
||||
{
|
||||
return await db.BotAccounts
|
||||
.Where(b => b.ProjectId == projectId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<BotAccount> CreateBotAsync(
|
||||
DevProject project,
|
||||
string slug,
|
||||
Account account,
|
||||
string? pictureId,
|
||||
string? backgroundId
|
||||
)
|
||||
{
|
||||
// First, check if a bot with this slug already exists in this project
|
||||
var existingBot = await db.BotAccounts
|
||||
.FirstOrDefaultAsync(b => b.ProjectId == project.Id && b.Slug == slug);
|
||||
|
||||
if (existingBot != null)
|
||||
throw new InvalidOperationException("A bot with this slug already exists in this project.");
|
||||
|
||||
try
|
||||
{
|
||||
var automatedId = Guid.NewGuid();
|
||||
var createRequest = new CreateBotAccountRequest
|
||||
{
|
||||
AutomatedId = automatedId.ToString(),
|
||||
Account = account,
|
||||
PictureId = pictureId,
|
||||
BackgroundId = backgroundId
|
||||
};
|
||||
|
||||
var createResponse = await accountReceiver.CreateBotAccountAsync(createRequest);
|
||||
var botAccount = createResponse.Bot;
|
||||
|
||||
// Then create the local bot account
|
||||
var bot = new BotAccount
|
||||
{
|
||||
Id = automatedId,
|
||||
Slug = slug,
|
||||
ProjectId = project.Id,
|
||||
Project = project,
|
||||
IsActive = botAccount.IsActive,
|
||||
CreatedAt = botAccount.CreatedAt.ToInstant(),
|
||||
UpdatedAt = botAccount.UpdatedAt.ToInstant()
|
||||
};
|
||||
|
||||
db.BotAccounts.Add(bot);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return bot;
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == StatusCode.AlreadyExists)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A bot account with this ID already exists in the authentication service.", ex);
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == StatusCode.InvalidArgument)
|
||||
{
|
||||
throw new ArgumentException($"Invalid bot account data: {ex.Status.Detail}", ex);
|
||||
}
|
||||
catch (RpcException ex)
|
||||
{
|
||||
throw new Exception($"Failed to create bot account: {ex.Status.Detail}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<BotAccount> UpdateBotAsync(
|
||||
BotAccount bot,
|
||||
Account account,
|
||||
string? pictureId,
|
||||
string? backgroundId
|
||||
)
|
||||
{
|
||||
db.Update(bot);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Update the bot account in the Pass service
|
||||
var updateRequest = new UpdateBotAccountRequest
|
||||
{
|
||||
AutomatedId = bot.Id.ToString(),
|
||||
Account = account,
|
||||
PictureId = pictureId,
|
||||
BackgroundId = backgroundId
|
||||
};
|
||||
|
||||
var updateResponse = await accountReceiver.UpdateBotAccountAsync(updateRequest);
|
||||
var updatedBot = updateResponse.Bot;
|
||||
|
||||
// Update local bot account
|
||||
bot.UpdatedAt = updatedBot.UpdatedAt.ToInstant();
|
||||
bot.IsActive = updatedBot.IsActive;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound)
|
||||
{
|
||||
throw new Exception("Bot account not found in the authentication service", ex);
|
||||
}
|
||||
catch (RpcException ex)
|
||||
{
|
||||
throw new Exception($"Failed to update bot account: {ex.Status.Detail}", ex);
|
||||
}
|
||||
|
||||
return bot;
|
||||
}
|
||||
|
||||
public async Task DeleteBotAsync(BotAccount bot)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Delete the bot account from the Pass service
|
||||
var deleteRequest = new DeleteBotAccountRequest
|
||||
{
|
||||
AutomatedId = bot.Id.ToString(),
|
||||
Force = false
|
||||
};
|
||||
|
||||
await accountReceiver.DeleteBotAccountAsync(deleteRequest);
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound)
|
||||
{
|
||||
// Account not found in Pass service, continue with local deletion
|
||||
}
|
||||
|
||||
// Delete the local bot account
|
||||
db.BotAccounts.Remove(bot);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<BotAccount?> LoadBotAccountAsync(BotAccount bot) =>
|
||||
(await LoadBotsAccountAsync([bot])).FirstOrDefault();
|
||||
|
||||
public async Task<List<BotAccount>> LoadBotsAccountAsync(IEnumerable<BotAccount> bots)
|
||||
{
|
||||
bots = bots.ToList();
|
||||
var automatedIds = bots.Select(b => b.Id).ToList();
|
||||
var data = await accounts.GetBotAccountBatch(automatedIds);
|
||||
|
||||
foreach (var bot in bots)
|
||||
{
|
||||
bot.Account = data
|
||||
.Select(AccountReference.FromProtoValue)
|
||||
.FirstOrDefault(e => e.AutomatedId == bot.Id);
|
||||
}
|
||||
|
||||
return bots as List<BotAccount> ?? [];
|
||||
}
|
||||
}
|
@@ -1,6 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Develop.Project;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Google.Protobuf;
|
||||
@@ -31,14 +32,17 @@ public class CustomApp : ModelBase, IIdentifiedResource
|
||||
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
|
||||
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
|
||||
|
||||
[Column(TypeName = "jsonb")] public DysonNetwork.Shared.Data.VerificationMark? Verification { get; set; }
|
||||
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
|
||||
[Column(TypeName = "jsonb")] public CustomAppOauthConfig? OauthConfig { get; set; }
|
||||
[Column(TypeName = "jsonb")] public CustomAppLinks? Links { get; set; }
|
||||
|
||||
[JsonIgnore] public ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>();
|
||||
|
||||
public Guid DeveloperId { get; set; }
|
||||
public Developer Developer { get; set; } = null!;
|
||||
public Guid ProjectId { get; set; }
|
||||
public DevProject Project { get; set; } = null!;
|
||||
|
||||
[NotMapped]
|
||||
public Developer Developer => Project.Developer;
|
||||
|
||||
[NotMapped] public string ResourceIdentifier => "custom-app:" + Id;
|
||||
|
||||
@@ -58,21 +62,26 @@ public class CustomApp : ModelBase, IIdentifiedResource
|
||||
CustomAppStatus.Suspended => Shared.Proto.CustomAppStatus.Suspended,
|
||||
_ => Shared.Proto.CustomAppStatus.Unspecified
|
||||
},
|
||||
Picture = Picture is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Picture)),
|
||||
Background = Background is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Background)),
|
||||
Verification = Verification is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Verification)),
|
||||
Links = Links is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Links)),
|
||||
Picture = Picture?.ToProtoValue(),
|
||||
Background = Background?.ToProtoValue(),
|
||||
Verification = Verification?.ToProtoValue(),
|
||||
Links = Links is null ? null : new DysonNetwork.Shared.Proto.CustomAppLinks
|
||||
{
|
||||
HomePage = Links.HomePage ?? string.Empty,
|
||||
PrivacyPolicy = Links.PrivacyPolicy ?? string.Empty,
|
||||
TermsOfService = Links.TermsOfService ?? string.Empty
|
||||
},
|
||||
OauthConfig = OauthConfig is null ? null : new DysonNetwork.Shared.Proto.CustomAppOauthConfig
|
||||
{
|
||||
ClientUri = OauthConfig.ClientUri ?? string.Empty,
|
||||
RedirectUris = { OauthConfig.RedirectUris ?? Array.Empty<string>() },
|
||||
PostLogoutRedirectUris = { OauthConfig.PostLogoutRedirectUris ?? Array.Empty<string>() },
|
||||
AllowedScopes = { OauthConfig.AllowedScopes ?? Array.Empty<string>() },
|
||||
AllowedGrantTypes = { OauthConfig.AllowedGrantTypes ?? Array.Empty<string>() },
|
||||
RedirectUris = { OauthConfig.RedirectUris ?? [] },
|
||||
PostLogoutRedirectUris = { OauthConfig.PostLogoutRedirectUris ?? [] },
|
||||
AllowedScopes = { OauthConfig.AllowedScopes ?? [] },
|
||||
AllowedGrantTypes = { OauthConfig.AllowedGrantTypes ?? [] },
|
||||
RequirePkce = OauthConfig.RequirePkce,
|
||||
AllowOfflineAccess = OauthConfig.AllowOfflineAccess
|
||||
},
|
||||
DeveloperId = DeveloperId.ToString(),
|
||||
ProjectId = ProjectId.ToString(),
|
||||
CreatedAt = CreatedAt.ToTimestamp(),
|
||||
UpdatedAt = UpdatedAt.ToTimestamp()
|
||||
};
|
||||
@@ -92,13 +101,21 @@ public class CustomApp : ModelBase, IIdentifiedResource
|
||||
Shared.Proto.CustomAppStatus.Suspended => CustomAppStatus.Suspended,
|
||||
_ => CustomAppStatus.Developing
|
||||
};
|
||||
DeveloperId = string.IsNullOrEmpty(p.DeveloperId) ? Guid.Empty : Guid.Parse(p.DeveloperId);
|
||||
ProjectId = string.IsNullOrEmpty(p.ProjectId) ? Guid.Empty : Guid.Parse(p.ProjectId);
|
||||
CreatedAt = p.CreatedAt.ToInstant();
|
||||
UpdatedAt = p.UpdatedAt.ToInstant();
|
||||
if (p.Picture.Length > 0) Picture = System.Text.Json.JsonSerializer.Deserialize<CloudFileReferenceObject>(p.Picture.ToStringUtf8());
|
||||
if (p.Background.Length > 0) Background = System.Text.Json.JsonSerializer.Deserialize<CloudFileReferenceObject>(p.Background.ToStringUtf8());
|
||||
if (p.Verification.Length > 0) Verification = System.Text.Json.JsonSerializer.Deserialize<DysonNetwork.Shared.Data.VerificationMark>(p.Verification.ToStringUtf8());
|
||||
if (p.Links.Length > 0) Links = System.Text.Json.JsonSerializer.Deserialize<CustomAppLinks>(p.Links.ToStringUtf8());
|
||||
if (p.Picture is not null) Picture = CloudFileReferenceObject.FromProtoValue(p.Picture);
|
||||
if (p.Background is not null) Background = CloudFileReferenceObject.FromProtoValue(p.Background);
|
||||
if (p.Verification is not null) Verification = VerificationMark.FromProtoValue(p.Verification);
|
||||
if (p.Links is not null)
|
||||
{
|
||||
Links = new CustomAppLinks
|
||||
{
|
||||
HomePage = string.IsNullOrEmpty(p.Links.HomePage) ? null : p.Links.HomePage,
|
||||
PrivacyPolicy = string.IsNullOrEmpty(p.Links.PrivacyPolicy) ? null : p.Links.PrivacyPolicy,
|
||||
TermsOfService = string.IsNullOrEmpty(p.Links.TermsOfService) ? null : p.Links.TermsOfService
|
||||
};
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
@@ -1,13 +1,16 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Develop.Project;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/developers/{pubName}/apps")]
|
||||
public class CustomAppController(CustomAppService customApps, DeveloperService ds) : ControllerBase
|
||||
[Route("/api/developers/{pubName}/projects/{projectId:guid}/apps")]
|
||||
public class CustomAppController(CustomAppService customApps, DeveloperService ds, DevProjectService projectService)
|
||||
: ControllerBase
|
||||
{
|
||||
public record CustomAppRequest(
|
||||
[MaxLength(1024)] string? Slug,
|
||||
@@ -20,22 +23,62 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
||||
CustomAppOauthConfig? OauthConfig
|
||||
);
|
||||
|
||||
public record CreateSecretRequest(
|
||||
[MaxLength(4096)] string? Description,
|
||||
TimeSpan? ExpiresIn = null,
|
||||
bool IsOidc = false
|
||||
);
|
||||
|
||||
public record SecretResponse(
|
||||
string Id,
|
||||
string? Secret,
|
||||
string? Description,
|
||||
Instant? ExpiresAt,
|
||||
bool IsOidc,
|
||||
Instant CreatedAt,
|
||||
Instant UpdatedAt
|
||||
);
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ListApps([FromRoute] string pubName)
|
||||
[Authorize]
|
||||
public async Task<IActionResult> ListApps([FromRoute] string pubName, [FromRoute] Guid projectId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null) return NotFound();
|
||||
var apps = await customApps.GetAppsByPublisherAsync(developer.Id);
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, PublisherMemberRole.Viewer))
|
||||
return StatusCode(403, "You must be a viewer of the developer to list custom apps");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null) return NotFound();
|
||||
|
||||
var apps = await customApps.GetAppsByProjectAsync(projectId);
|
||||
return Ok(apps);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IActionResult> GetApp([FromRoute] string pubName, Guid id)
|
||||
[HttpGet("{appId:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GetApp([FromRoute] string pubName, [FromRoute] Guid projectId,
|
||||
[FromRoute] Guid appId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null) return NotFound();
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, PublisherMemberRole.Viewer))
|
||||
return StatusCode(403, "You must be a viewer of the developer to list custom apps");
|
||||
|
||||
var app = await customApps.GetAppAsync(id, developerId: developer.Id);
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null) return NotFound();
|
||||
|
||||
var app = await customApps.GetAppAsync(appId, projectId);
|
||||
if (app == null)
|
||||
return NotFound();
|
||||
|
||||
@@ -44,23 +87,39 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
||||
|
||||
[HttpPost]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CreateApp([FromRoute] string pubName, [FromBody] CustomAppRequest request)
|
||||
public async Task<IActionResult> CreateApp(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromBody] CustomAppRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Slug))
|
||||
return BadRequest("Name and slug are required");
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null) return NotFound();
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to create a custom app");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Slug))
|
||||
return BadRequest("Name and slug are required");
|
||||
|
||||
try
|
||||
{
|
||||
var app = await customApps.CreateAppAsync(developer, request);
|
||||
return Ok(app);
|
||||
var app = await customApps.CreateAppAsync(projectId, request);
|
||||
if (app == null)
|
||||
return BadRequest("Failed to create app");
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetApp),
|
||||
new { pubName, projectId, appId = app.Id },
|
||||
app
|
||||
);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
@@ -68,23 +127,30 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}")]
|
||||
[HttpPatch("{appId:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> UpdateApp(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid id,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid appId,
|
||||
[FromBody] CustomAppRequest request
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null) return NotFound();
|
||||
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to update a custom app");
|
||||
|
||||
var app = await customApps.GetAppAsync(id, developerId: developer.Id);
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var app = await customApps.GetAppAsync(appId, projectId);
|
||||
if (app == null)
|
||||
return NotFound();
|
||||
|
||||
@@ -99,28 +165,267 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[HttpDelete("{appId:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> DeleteApp(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid id
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid appId
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null) return NotFound();
|
||||
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to delete a custom app");
|
||||
|
||||
var app = await customApps.GetAppAsync(id, developerId: developer.Id);
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var app = await customApps.GetAppAsync(appId, projectId);
|
||||
if (app == null)
|
||||
return NotFound();
|
||||
|
||||
var result = await customApps.DeleteAppAsync(id);
|
||||
var result = await customApps.DeleteAppAsync(appId);
|
||||
if (!result)
|
||||
return NotFound();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("{appId:guid}/secrets")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> ListSecrets(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid appId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to view app secrets");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var app = await customApps.GetAppAsync(appId, projectId);
|
||||
if (app == null)
|
||||
return NotFound("App not found");
|
||||
|
||||
var secrets = await customApps.GetAppSecretsAsync(appId);
|
||||
return Ok(secrets.Select(s => new SecretResponse(
|
||||
s.Id.ToString(),
|
||||
null,
|
||||
s.Description,
|
||||
s.ExpiredAt,
|
||||
s.IsOidc,
|
||||
s.CreatedAt,
|
||||
s.UpdatedAt
|
||||
)));
|
||||
}
|
||||
|
||||
[HttpPost("{appId:guid}/secrets")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CreateSecret(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid appId,
|
||||
[FromBody] CreateSecretRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to create app secrets");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var app = await customApps.GetAppAsync(appId, projectId);
|
||||
if (app == null)
|
||||
return NotFound("App not found");
|
||||
|
||||
try
|
||||
{
|
||||
var secret = await customApps.CreateAppSecretAsync(new CustomAppSecret
|
||||
{
|
||||
AppId = appId,
|
||||
Description = request.Description,
|
||||
ExpiredAt = request.ExpiresIn.HasValue
|
||||
? NodaTime.SystemClock.Instance.GetCurrentInstant()
|
||||
.Plus(Duration.FromTimeSpan(request.ExpiresIn.Value))
|
||||
: (NodaTime.Instant?)null,
|
||||
IsOidc = request.IsOidc
|
||||
});
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetSecret),
|
||||
new { pubName, projectId, appId, secretId = secret.Id },
|
||||
new SecretResponse(
|
||||
secret.Id.ToString(),
|
||||
secret.Secret,
|
||||
secret.Description,
|
||||
secret.ExpiredAt,
|
||||
secret.IsOidc,
|
||||
secret.CreatedAt,
|
||||
secret.UpdatedAt
|
||||
)
|
||||
);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{appId:guid}/secrets/{secretId:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GetSecret(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid appId,
|
||||
[FromRoute] Guid secretId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to view app secrets");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var app = await customApps.GetAppAsync(appId, projectId);
|
||||
if (app == null)
|
||||
return NotFound("App not found");
|
||||
|
||||
var secret = await customApps.GetAppSecretAsync(secretId, appId);
|
||||
if (secret == null)
|
||||
return NotFound("Secret not found");
|
||||
|
||||
return Ok(new SecretResponse(
|
||||
secret.Id.ToString(),
|
||||
null,
|
||||
secret.Description,
|
||||
secret.ExpiredAt,
|
||||
secret.IsOidc,
|
||||
secret.CreatedAt,
|
||||
secret.UpdatedAt
|
||||
));
|
||||
}
|
||||
|
||||
[HttpDelete("{appId:guid}/secrets/{secretId:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> DeleteSecret(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid appId,
|
||||
[FromRoute] Guid secretId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to delete app secrets");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var app = await customApps.GetAppAsync(appId, projectId);
|
||||
if (app == null)
|
||||
return NotFound("App not found");
|
||||
|
||||
var secret = await customApps.GetAppSecretAsync(secretId, appId);
|
||||
if (secret == null)
|
||||
return NotFound("Secret not found");
|
||||
|
||||
var result = await customApps.DeleteAppSecretAsync(secretId, appId);
|
||||
if (!result)
|
||||
return NotFound("Failed to delete secret");
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("{appId:guid}/secrets/{secretId:guid}/rotate")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> RotateSecret(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid appId,
|
||||
[FromRoute] Guid secretId,
|
||||
[FromBody] CreateSecretRequest? request = null)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to rotate app secrets");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var app = await customApps.GetAppAsync(appId, projectId);
|
||||
if (app == null)
|
||||
return NotFound("App not found");
|
||||
|
||||
try
|
||||
{
|
||||
var secret = await customApps.RotateAppSecretAsync(new CustomAppSecret
|
||||
{
|
||||
Id = secretId,
|
||||
AppId = appId,
|
||||
Description = request?.Description,
|
||||
ExpiredAt = request?.ExpiresIn.HasValue == true
|
||||
? NodaTime.SystemClock.Instance.GetCurrentInstant()
|
||||
.Plus(Duration.FromTimeSpan(request.ExpiresIn.Value))
|
||||
: (NodaTime.Instant?)null,
|
||||
IsOidc = request?.IsOidc ?? false
|
||||
});
|
||||
|
||||
return Ok(new SecretResponse(
|
||||
secret.Id.ToString(),
|
||||
secret.Secret,
|
||||
secret.Description,
|
||||
secret.ExpiredAt,
|
||||
secret.IsOidc,
|
||||
secret.CreatedAt,
|
||||
secret.UpdatedAt
|
||||
));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,6 +1,9 @@
|
||||
using DysonNetwork.Develop.Project;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
@@ -11,10 +14,17 @@ public class CustomAppService(
|
||||
)
|
||||
{
|
||||
public async Task<CustomApp?> CreateAppAsync(
|
||||
Developer pub,
|
||||
Guid projectId,
|
||||
CustomAppController.CustomAppRequest request
|
||||
)
|
||||
{
|
||||
var project = await db.DevProjects
|
||||
.Include(p => p.Developer)
|
||||
.FirstOrDefaultAsync(p => p.Id == projectId);
|
||||
|
||||
if (project == null)
|
||||
return null;
|
||||
|
||||
var app = new CustomApp
|
||||
{
|
||||
Slug = request.Slug!,
|
||||
@@ -23,7 +33,7 @@ public class CustomAppService(
|
||||
Status = request.Status ?? CustomAppStatus.Developing,
|
||||
Links = request.Links,
|
||||
OauthConfig = request.OauthConfig,
|
||||
DeveloperId = pub.Id
|
||||
ProjectId = projectId
|
||||
};
|
||||
|
||||
if (request.PictureId is not null)
|
||||
@@ -74,17 +84,104 @@ public class CustomAppService(
|
||||
return app;
|
||||
}
|
||||
|
||||
public async Task<CustomApp?> GetAppAsync(Guid id, Guid? developerId = null)
|
||||
public async Task<CustomApp?> GetAppAsync(Guid id, Guid? projectId = null)
|
||||
{
|
||||
var query = db.CustomApps.Where(a => a.Id == id).AsQueryable();
|
||||
if (developerId.HasValue)
|
||||
query = query.Where(a => a.DeveloperId == developerId.Value);
|
||||
return await query.FirstOrDefaultAsync();
|
||||
var query = db.CustomApps.AsQueryable();
|
||||
|
||||
if (projectId.HasValue)
|
||||
{
|
||||
query = query.Where(a => a.ProjectId == projectId.Value);
|
||||
}
|
||||
|
||||
return await query.FirstOrDefaultAsync(a => a.Id == id);
|
||||
}
|
||||
|
||||
public async Task<List<CustomApp>> GetAppsByPublisherAsync(Guid publisherId)
|
||||
public async Task<List<CustomAppSecret>> GetAppSecretsAsync(Guid appId)
|
||||
{
|
||||
return await db.CustomApps.Where(a => a.DeveloperId == publisherId).ToListAsync();
|
||||
return await db.CustomAppSecrets
|
||||
.Where(s => s.AppId == appId)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<CustomAppSecret?> GetAppSecretAsync(Guid secretId, Guid appId)
|
||||
{
|
||||
return await db.CustomAppSecrets
|
||||
.FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId);
|
||||
}
|
||||
|
||||
public async Task<CustomAppSecret> CreateAppSecretAsync(CustomAppSecret secret)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(secret.Secret))
|
||||
{
|
||||
// Generate a new random secret if not provided
|
||||
secret.Secret = GenerateRandomSecret();
|
||||
}
|
||||
|
||||
secret.Id = Guid.NewGuid();
|
||||
secret.CreatedAt = NodaTime.SystemClock.Instance.GetCurrentInstant();
|
||||
secret.UpdatedAt = secret.CreatedAt;
|
||||
|
||||
db.CustomAppSecrets.Add(secret);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return secret;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAppSecretAsync(Guid secretId, Guid appId)
|
||||
{
|
||||
var secret = await db.CustomAppSecrets
|
||||
.FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId);
|
||||
|
||||
if (secret == null)
|
||||
return false;
|
||||
|
||||
db.CustomAppSecrets.Remove(secret);
|
||||
await db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<CustomAppSecret> RotateAppSecretAsync(CustomAppSecret secretUpdate)
|
||||
{
|
||||
var existingSecret = await db.CustomAppSecrets
|
||||
.FirstOrDefaultAsync(s => s.Id == secretUpdate.Id && s.AppId == secretUpdate.AppId);
|
||||
|
||||
if (existingSecret == null)
|
||||
throw new InvalidOperationException("Secret not found");
|
||||
|
||||
// Update the existing secret with new values
|
||||
existingSecret.Secret = GenerateRandomSecret();
|
||||
existingSecret.Description = secretUpdate.Description ?? existingSecret.Description;
|
||||
existingSecret.ExpiredAt = secretUpdate.ExpiredAt ?? existingSecret.ExpiredAt;
|
||||
existingSecret.IsOidc = secretUpdate.IsOidc;
|
||||
existingSecret.UpdatedAt = NodaTime.SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return existingSecret;
|
||||
}
|
||||
|
||||
private static string GenerateRandomSecret(int length = 64)
|
||||
{
|
||||
const string valid = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-._~+";
|
||||
var res = new StringBuilder();
|
||||
using (var rng = RandomNumberGenerator.Create())
|
||||
{
|
||||
var uintBuffer = new byte[sizeof(uint)];
|
||||
while (length-- > 0)
|
||||
{
|
||||
rng.GetBytes(uintBuffer);
|
||||
var num = BitConverter.ToUInt32(uintBuffer, 0);
|
||||
res.Append(valid[(int)(num % (uint)valid.Length)]);
|
||||
}
|
||||
}
|
||||
return res.ToString();
|
||||
}
|
||||
|
||||
public async Task<List<CustomApp>> GetAppsByProjectAsync(Guid projectId)
|
||||
{
|
||||
return await db.CustomApps
|
||||
.Where(a => a.ProjectId == projectId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<CustomApp?> UpdateAppAsync(CustomApp app, CustomAppController.CustomAppRequest request)
|
||||
|
@@ -1,4 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Develop.Project;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using VerificationMark = DysonNetwork.Shared.Data.VerificationMark;
|
||||
@@ -10,6 +12,8 @@ public class Developer
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid PublisherId { get; set; }
|
||||
|
||||
[JsonIgnore] public List<DevProject> Projects { get; set; } = [];
|
||||
|
||||
[NotMapped] public PublisherInfo? Publisher { get; set; }
|
||||
}
|
||||
|
||||
|
@@ -33,7 +33,8 @@ public class DeveloperController(
|
||||
|
||||
// Get custom apps count
|
||||
var customAppsCount = await db.CustomApps
|
||||
.Where(a => a.DeveloperId == developer.Id)
|
||||
.Include(a => a.Project)
|
||||
.Where(a => a.Project.DeveloperId == developer.Id)
|
||||
.CountAsync();
|
||||
|
||||
var stats = new DeveloperStats
|
||||
|
@@ -4,7 +4,10 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
public class DeveloperService(AppDatabase db, PublisherService.PublisherServiceClient ps, ILogger<DeveloperService> logger)
|
||||
public class DeveloperService(
|
||||
AppDatabase db,
|
||||
PublisherService.PublisherServiceClient ps,
|
||||
ILogger<DeveloperService> logger)
|
||||
{
|
||||
public async Task<Developer> LoadDeveloperPublisher(Developer developer)
|
||||
{
|
||||
@@ -47,6 +50,11 @@ public class DeveloperService(AppDatabase db, PublisherService.PublisherServiceC
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Developer?> GetDeveloperById(Guid id)
|
||||
{
|
||||
return await db.Developers.FirstOrDefaultAsync(d => d.Id == id);
|
||||
}
|
||||
|
||||
public async Task<bool> IsMemberWithRole(Guid pubId, Guid accountId, PublisherMemberRole role)
|
||||
{
|
||||
try
|
||||
|
270
DysonNetwork.Develop/Migrations/20250818124844_AddDevProject.Designer.cs
generated
Normal file
270
DysonNetwork.Develop/Migrations/20250818124844_AddDevProject.Designer.cs
generated
Normal file
@@ -0,0 +1,270 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DysonNetwork.Develop;
|
||||
using DysonNetwork.Develop.Identity;
|
||||
using DysonNetwork.Shared.Data;
|
||||
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.Develop.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDatabase))]
|
||||
[Migration("20250818124844_AddDevProject")]
|
||||
partial class AddDevProject
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<CloudFileReferenceObject>("Background")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("background");
|
||||
|
||||
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<CustomAppLinks>("Links")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("links");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<CustomAppOauthConfig>("OauthConfig")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("oauth_config");
|
||||
|
||||
b.Property<CloudFileReferenceObject>("Picture")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("picture");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("project_id");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<VerificationMark>("Verification")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("verification");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_custom_apps");
|
||||
|
||||
b.HasIndex("ProjectId")
|
||||
.HasDatabaseName("ix_custom_apps_project_id");
|
||||
|
||||
b.ToTable("custom_apps", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AppId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("app_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<bool>("IsOidc")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_oidc");
|
||||
|
||||
b.Property<string>("Secret")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("secret");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_custom_app_secrets");
|
||||
|
||||
b.HasIndex("AppId")
|
||||
.HasDatabaseName("ix_custom_app_secrets_app_id");
|
||||
|
||||
b.ToTable("custom_app_secrets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("PublisherId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("publisher_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_developers");
|
||||
|
||||
b.ToTable("developers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Guid>("DeveloperId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("developer_id");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
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_dev_projects");
|
||||
|
||||
b.HasIndex("DeveloperId")
|
||||
.HasDatabaseName("ix_dev_projects_developer_id");
|
||||
|
||||
b.ToTable("dev_projects", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_custom_apps_dev_projects_project_id");
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Identity.CustomApp", "App")
|
||||
.WithMany("Secrets")
|
||||
.HasForeignKey("AppId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_custom_app_secrets_custom_apps_app_id");
|
||||
|
||||
b.Navigation("App");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer")
|
||||
.WithMany("Projects")
|
||||
.HasForeignKey("DeveloperId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_dev_projects_developers_developer_id");
|
||||
|
||||
b.Navigation("Developer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.Navigation("Secrets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
|
||||
{
|
||||
b.Navigation("Projects");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,96 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NodaTime;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Develop.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDevProject : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_custom_apps_developers_developer_id",
|
||||
table: "custom_apps");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "developer_id",
|
||||
table: "custom_apps",
|
||||
newName: "project_id");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "ix_custom_apps_developer_id",
|
||||
table: "custom_apps",
|
||||
newName: "ix_custom_apps_project_id");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "dev_projects",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
slug = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
|
||||
developer_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_dev_projects", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_dev_projects_developers_developer_id",
|
||||
column: x => x.developer_id,
|
||||
principalTable: "developers",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_dev_projects_developer_id",
|
||||
table: "dev_projects",
|
||||
column: "developer_id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_custom_apps_dev_projects_project_id",
|
||||
table: "custom_apps",
|
||||
column: "project_id",
|
||||
principalTable: "dev_projects",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_custom_apps_dev_projects_project_id",
|
||||
table: "custom_apps");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "dev_projects");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "project_id",
|
||||
table: "custom_apps",
|
||||
newName: "developer_id");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "ix_custom_apps_project_id",
|
||||
table: "custom_apps",
|
||||
newName: "ix_custom_apps_developer_id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_custom_apps_developers_developer_id",
|
||||
table: "custom_apps",
|
||||
column: "developer_id",
|
||||
principalTable: "developers",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
324
DysonNetwork.Develop/Migrations/20250819163227_AddBotAccount.Designer.cs
generated
Normal file
324
DysonNetwork.Develop/Migrations/20250819163227_AddBotAccount.Designer.cs
generated
Normal file
@@ -0,0 +1,324 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DysonNetwork.Develop;
|
||||
using DysonNetwork.Develop.Identity;
|
||||
using DysonNetwork.Shared.Data;
|
||||
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.Develop.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDatabase))]
|
||||
[Migration("20250819163227_AddBotAccount")]
|
||||
partial class AddBotAccount
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", 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<bool>("IsActive")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_active");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("project_id");
|
||||
|
||||
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_bot_accounts");
|
||||
|
||||
b.HasIndex("ProjectId")
|
||||
.HasDatabaseName("ix_bot_accounts_project_id");
|
||||
|
||||
b.ToTable("bot_accounts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<CloudFileReferenceObject>("Background")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("background");
|
||||
|
||||
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<CustomAppLinks>("Links")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("links");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<CustomAppOauthConfig>("OauthConfig")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("oauth_config");
|
||||
|
||||
b.Property<CloudFileReferenceObject>("Picture")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("picture");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("project_id");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<VerificationMark>("Verification")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("verification");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_custom_apps");
|
||||
|
||||
b.HasIndex("ProjectId")
|
||||
.HasDatabaseName("ix_custom_apps_project_id");
|
||||
|
||||
b.ToTable("custom_apps", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AppId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("app_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<bool>("IsOidc")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_oidc");
|
||||
|
||||
b.Property<string>("Secret")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("secret");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_custom_app_secrets");
|
||||
|
||||
b.HasIndex("AppId")
|
||||
.HasDatabaseName("ix_custom_app_secrets_app_id");
|
||||
|
||||
b.ToTable("custom_app_secrets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("PublisherId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("publisher_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_developers");
|
||||
|
||||
b.ToTable("developers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Guid>("DeveloperId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("developer_id");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
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_dev_projects");
|
||||
|
||||
b.HasIndex("DeveloperId")
|
||||
.HasDatabaseName("ix_dev_projects_developer_id");
|
||||
|
||||
b.ToTable("dev_projects", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_bot_accounts_dev_projects_project_id");
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_custom_apps_dev_projects_project_id");
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Identity.CustomApp", "App")
|
||||
.WithMany("Secrets")
|
||||
.HasForeignKey("AppId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_custom_app_secrets_custom_apps_app_id");
|
||||
|
||||
b.Navigation("App");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer")
|
||||
.WithMany("Projects")
|
||||
.HasForeignKey("DeveloperId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_dev_projects_developers_developer_id");
|
||||
|
||||
b.Navigation("Developer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.Navigation("Secrets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
|
||||
{
|
||||
b.Navigation("Projects");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NodaTime;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Develop.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBotAccount : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "bot_accounts",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
slug = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
is_active = table.Column<bool>(type: "boolean", nullable: false),
|
||||
project_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_bot_accounts", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_bot_accounts_dev_projects_project_id",
|
||||
column: x => x.project_id,
|
||||
principalTable: "dev_projects",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_bot_accounts_project_id",
|
||||
table: "bot_accounts",
|
||||
column: "project_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "bot_accounts");
|
||||
}
|
||||
}
|
||||
}
|
@@ -25,6 +25,48 @@ namespace DysonNetwork.Develop.Migrations
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", 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<bool>("IsActive")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_active");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("project_id");
|
||||
|
||||
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_bot_accounts");
|
||||
|
||||
b.HasIndex("ProjectId")
|
||||
.HasDatabaseName("ix_bot_accounts_project_id");
|
||||
|
||||
b.ToTable("bot_accounts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -49,10 +91,6 @@ namespace DysonNetwork.Develop.Migrations
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Guid>("DeveloperId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("developer_id");
|
||||
|
||||
b.Property<CustomAppLinks>("Links")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("links");
|
||||
@@ -71,6 +109,10 @@ namespace DysonNetwork.Develop.Migrations
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("picture");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("project_id");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
@@ -92,8 +134,8 @@ namespace DysonNetwork.Develop.Migrations
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_custom_apps");
|
||||
|
||||
b.HasIndex("DeveloperId")
|
||||
.HasDatabaseName("ix_custom_apps_developer_id");
|
||||
b.HasIndex("ProjectId")
|
||||
.HasDatabaseName("ix_custom_apps_project_id");
|
||||
|
||||
b.ToTable("custom_apps", (string)null);
|
||||
});
|
||||
@@ -166,16 +208,78 @@ namespace DysonNetwork.Develop.Migrations
|
||||
b.ToTable("developers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer")
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Guid>("DeveloperId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("developer_id");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
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_dev_projects");
|
||||
|
||||
b.HasIndex("DeveloperId")
|
||||
.HasDatabaseName("ix_dev_projects_developer_id");
|
||||
|
||||
b.ToTable("dev_projects", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
|
||||
.WithMany()
|
||||
.HasForeignKey("DeveloperId")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_custom_apps_developers_developer_id");
|
||||
.HasConstraintName("fk_bot_accounts_dev_projects_project_id");
|
||||
|
||||
b.Navigation("Developer");
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_custom_apps_dev_projects_project_id");
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
|
||||
@@ -190,10 +294,27 @@ namespace DysonNetwork.Develop.Migrations
|
||||
b.Navigation("App");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer")
|
||||
.WithMany("Projects")
|
||||
.HasForeignKey("DeveloperId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_dev_projects_developers_developer_id");
|
||||
|
||||
b.Navigation("Developer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.Navigation("Secrets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
|
||||
{
|
||||
b.Navigation("Projects");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
@@ -1,15 +1,16 @@
|
||||
using DysonNetwork.Develop;
|
||||
using DysonNetwork.Shared.Auth;
|
||||
using DysonNetwork.Shared.Http;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using DysonNetwork.Develop.Startup;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
|
||||
builder.ConfigureAppKestrel(builder.Configuration);
|
||||
|
||||
builder.Services.AddRegistryService(builder.Configuration);
|
||||
builder.Services.AddAppServices(builder.Configuration);
|
||||
builder.Services.AddAppAuthentication();
|
||||
builder.Services.AddAppSwagger();
|
||||
@@ -20,6 +21,8 @@ builder.Services.AddDriveService();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapDefaultEndpoints();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||
|
16
DysonNetwork.Develop/Project/DevProject.cs
Normal file
16
DysonNetwork.Develop/Project/DevProject.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Develop.Identity;
|
||||
using DysonNetwork.Shared.Data;
|
||||
|
||||
namespace DysonNetwork.Develop.Project;
|
||||
|
||||
public class DevProject : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(1024)] public string Slug { get; set; } = string.Empty;
|
||||
[MaxLength(1024)] public string Name { get; set; } = string.Empty;
|
||||
[MaxLength(4096)] public string Description { get; set; } = string.Empty;
|
||||
|
||||
public Developer Developer { get; set; } = null!;
|
||||
public Guid DeveloperId { get; set; }
|
||||
}
|
107
DysonNetwork.Develop/Project/DevProjectController.cs
Normal file
107
DysonNetwork.Develop/Project/DevProjectController.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Develop.Identity;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
|
||||
namespace DysonNetwork.Develop.Project;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/developers/{pubName}/projects")]
|
||||
public class DevProjectController(DevProjectService projectService, DeveloperService developerService) : ControllerBase
|
||||
{
|
||||
public record DevProjectRequest(
|
||||
[MaxLength(1024)] string? Slug,
|
||||
[MaxLength(1024)] string? Name,
|
||||
[MaxLength(4096)] string? Description
|
||||
);
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ListProjects([FromRoute] string pubName)
|
||||
{
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
if (developer is null) return NotFound();
|
||||
|
||||
var projects = await projectService.GetProjectsByDeveloperAsync(developer.Id);
|
||||
return Ok(projects);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IActionResult> GetProject([FromRoute] string pubName, Guid id)
|
||||
{
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
if (developer is null) return NotFound();
|
||||
|
||||
var project = await projectService.GetProjectAsync(id, developer.Id);
|
||||
if (project is null) return NotFound();
|
||||
|
||||
return Ok(project);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CreateProject([FromRoute] string pubName, [FromBody] DevProjectRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to create a project");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Slug) || string.IsNullOrWhiteSpace(request.Name))
|
||||
return BadRequest("Slug and Name are required");
|
||||
|
||||
var project = await projectService.CreateProjectAsync(developer, request);
|
||||
return CreatedAtAction(
|
||||
nameof(GetProject),
|
||||
new { pubName, id = project.Id },
|
||||
project
|
||||
);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> UpdateProject(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid id,
|
||||
[FromBody] DevProjectRequest request
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
if (developer is null || developer.Id != accountId)
|
||||
return Forbid();
|
||||
|
||||
var project = await projectService.UpdateProjectAsync(id, developer.Id, request);
|
||||
if (project is null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(project);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> DeleteProject([FromRoute] string pubName, [FromRoute] Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
if (developer is null || developer.Id != accountId)
|
||||
return Forbid();
|
||||
|
||||
var success = await projectService.DeleteProjectAsync(id, developer.Id);
|
||||
if (!success)
|
||||
return NotFound();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
77
DysonNetwork.Develop/Project/DevProjectService.cs
Normal file
77
DysonNetwork.Develop/Project/DevProjectService.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using DysonNetwork.Develop.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
|
||||
namespace DysonNetwork.Develop.Project;
|
||||
|
||||
public class DevProjectService(
|
||||
AppDatabase db,
|
||||
FileReferenceService.FileReferenceServiceClient fileRefs,
|
||||
FileService.FileServiceClient files
|
||||
)
|
||||
{
|
||||
public async Task<DevProject> CreateProjectAsync(
|
||||
Developer developer,
|
||||
DevProjectController.DevProjectRequest request
|
||||
)
|
||||
{
|
||||
var project = new DevProject
|
||||
{
|
||||
Slug = request.Slug!,
|
||||
Name = request.Name!,
|
||||
Description = request.Description ?? string.Empty,
|
||||
DeveloperId = developer.Id
|
||||
};
|
||||
|
||||
db.DevProjects.Add(project);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
public async Task<DevProject?> GetProjectAsync(Guid id, Guid? developerId = null)
|
||||
{
|
||||
var query = db.DevProjects.AsQueryable();
|
||||
|
||||
if (developerId.HasValue)
|
||||
{
|
||||
query = query.Where(p => p.DeveloperId == developerId.Value);
|
||||
}
|
||||
|
||||
return await query.FirstOrDefaultAsync(p => p.Id == id);
|
||||
}
|
||||
|
||||
public async Task<List<DevProject>> GetProjectsByDeveloperAsync(Guid developerId)
|
||||
{
|
||||
return await db.DevProjects
|
||||
.Where(p => p.DeveloperId == developerId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<DevProject?> UpdateProjectAsync(
|
||||
Guid id,
|
||||
Guid developerId,
|
||||
DevProjectController.DevProjectRequest request
|
||||
)
|
||||
{
|
||||
var project = await GetProjectAsync(id, developerId);
|
||||
if (project == null) return null;
|
||||
|
||||
if (request.Slug != null) project.Slug = request.Slug;
|
||||
if (request.Name != null) project.Name = request.Name;
|
||||
if (request.Description != null) project.Description = request.Description;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return project;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteProjectAsync(Guid id, Guid developerId)
|
||||
{
|
||||
var project = await GetProjectAsync(id, developerId);
|
||||
if (project == null) return false;
|
||||
|
||||
db.DevProjects.Remove(project);
|
||||
await db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
}
|
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using DysonNetwork.Develop.Identity;
|
||||
using DysonNetwork.Shared.Auth;
|
||||
using DysonNetwork.Shared.Http;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Prometheus;
|
||||
|
||||
@@ -18,7 +19,7 @@ public static class ApplicationConfiguration
|
||||
|
||||
app.UseRequestLocalization();
|
||||
|
||||
ConfigureForwardedHeaders(app, configuration);
|
||||
app.ConfigureForwardedHeaders(configuration);
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
@@ -30,26 +31,4 @@ public static class ApplicationConfiguration
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static void ConfigureForwardedHeaders(WebApplication app, IConfiguration configuration)
|
||||
{
|
||||
var knownProxiesSection = configuration.GetSection("KnownProxies");
|
||||
var forwardedHeadersOptions = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All };
|
||||
|
||||
if (knownProxiesSection.Exists())
|
||||
{
|
||||
var proxyAddresses = knownProxiesSection.Get<string[]>();
|
||||
if (proxyAddresses != null)
|
||||
foreach (var proxy in proxyAddresses)
|
||||
if (IPAddress.TryParse(proxy, out var ipAddress))
|
||||
forwardedHeadersOptions.KnownProxies.Add(ipAddress);
|
||||
}
|
||||
else
|
||||
{
|
||||
forwardedHeadersOptions.KnownProxies.Add(IPAddress.Any);
|
||||
forwardedHeadersOptions.KnownProxies.Add(IPAddress.IPv6Any);
|
||||
}
|
||||
|
||||
app.UseForwardedHeaders(forwardedHeadersOptions);
|
||||
}
|
||||
}
|
||||
|
@@ -3,7 +3,9 @@ using Microsoft.OpenApi.Models;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.SystemTextJson;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Develop.Identity;
|
||||
using DysonNetwork.Develop.Project;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using StackExchange.Redis;
|
||||
|
||||
@@ -18,19 +20,16 @@ public static class ServiceCollectionExtensions
|
||||
services.AddDbContext<AppDatabase>();
|
||||
services.AddSingleton<IClock>(SystemClock.Instance);
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddSingleton<IConnectionMultiplexer>(_ =>
|
||||
{
|
||||
var connection = configuration.GetConnectionString("FastRetrieve")!;
|
||||
return ConnectionMultiplexer.Connect(connection);
|
||||
});
|
||||
services.AddSingleton<ICacheService, CacheServiceRedis>();
|
||||
|
||||
services.AddHttpClient();
|
||||
|
||||
services.AddControllers().AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
|
||||
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||
|
||||
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
|
||||
});
|
||||
|
||||
@@ -50,6 +49,8 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
services.AddScoped<DeveloperService>();
|
||||
services.AddScoped<CustomAppService>();
|
||||
services.AddScoped<DevProjectService>();
|
||||
services.AddScoped<BotAccountService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
@@ -10,9 +10,7 @@
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"App": "Host=localhost;Port=5432;Database=dyson_network_dev;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
|
||||
"FastRetrieve": "localhost:6379",
|
||||
"Etcd": "etcd.orb.local:2379"
|
||||
"App": "Host=localhost;Port=5432;Database=dyson_network_dev;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
||||
},
|
||||
"KnownProxies": [
|
||||
"127.0.0.1",
|
||||
@@ -23,8 +21,6 @@
|
||||
},
|
||||
"Service": {
|
||||
"Name": "DysonNetwork.Develop",
|
||||
"Url": "https://localhost:7099",
|
||||
"ClientCert": "../Certificates/client.crt",
|
||||
"ClientKey": "../Certificates/client.key"
|
||||
"Url": "https://localhost:7192"
|
||||
}
|
||||
}
|
||||
|
@@ -31,7 +31,6 @@ public class AppDatabase(
|
||||
opt => opt
|
||||
.ConfigureDataSource(optSource => optSource.EnableDynamicJson())
|
||||
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
|
||||
.UseNetTopologySuite()
|
||||
.UseNodaTime()
|
||||
).UseSnakeCaseNamingConvention();
|
||||
|
||||
|
@@ -35,7 +35,6 @@
|
||||
<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.NetTopologySuite" Version="9.0.4" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||
@@ -67,6 +66,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
404
DysonNetwork.Drive/Migrations/20250819164302_RemoveUploadedTo.Designer.cs
generated
Normal file
404
DysonNetwork.Drive/Migrations/20250819164302_RemoveUploadedTo.Designer.cs
generated
Normal file
@@ -0,0 +1,404 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DysonNetwork.Drive;
|
||||
using DysonNetwork.Drive.Storage;
|
||||
using DysonNetwork.Shared.Data;
|
||||
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("20250819164302_RemoveUploadedTo")]
|
||||
partial class RemoveUploadedTo
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
|
||||
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.CloudFile", 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.Drive.Storage.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.Drive.Storage.FileBundle", 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.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.Drive.Storage.CloudFile", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Drive.Storage.FileBundle", "Bundle")
|
||||
.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")
|
||||
.HasForeignKey("FileId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_file_references_files_file_id");
|
||||
|
||||
b.Navigation("File");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||
{
|
||||
b.Navigation("References");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
|
||||
{
|
||||
b.Navigation("Files");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Drive.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RemoveUploadedTo : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "uploaded_to",
|
||||
table: "files");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "uploaded_to",
|
||||
table: "files",
|
||||
type: "character varying(128)",
|
||||
maxLength: 128,
|
||||
nullable: true);
|
||||
}
|
||||
}
|
||||
}
|
403
DysonNetwork.Drive/Migrations/20250907070034_RemoveNetTopo.Designer.cs
generated
Normal file
403
DysonNetwork.Drive/Migrations/20250907070034_RemoveNetTopo.Designer.cs
generated
Normal file
@@ -0,0 +1,403 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DysonNetwork.Drive;
|
||||
using DysonNetwork.Drive.Storage;
|
||||
using DysonNetwork.Shared.Data;
|
||||
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("20250907070034_RemoveNetTopo")]
|
||||
partial class RemoveNetTopo
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.7")
|
||||
.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.CloudFile", 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.Drive.Storage.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.Drive.Storage.FileBundle", 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.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.Drive.Storage.CloudFile", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Drive.Storage.FileBundle", "Bundle")
|
||||
.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")
|
||||
.HasForeignKey("FileId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_file_references_files_file_id");
|
||||
|
||||
b.Navigation("File");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||
{
|
||||
b.Navigation("References");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
|
||||
{
|
||||
b.Navigation("Files");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,24 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Drive.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RemoveNetTopo : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterDatabase()
|
||||
.OldAnnotation("Npgsql:PostgresExtension:postgis", ",,");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterDatabase()
|
||||
.Annotation("Npgsql:PostgresExtension:postgis", ",,");
|
||||
}
|
||||
}
|
||||
}
|
@@ -24,7 +24,6 @@ namespace DysonNetwork.Drive.Migrations
|
||||
.HasAnnotation("ProductVersion", "9.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b =>
|
||||
@@ -172,11 +171,6 @@ namespace DysonNetwork.Drive.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("uploaded_at");
|
||||
|
||||
b.Property<string>("UploadedTo")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("uploaded_to");
|
||||
|
||||
b.Property<Dictionary<string, object>>("UserMeta")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("user_meta");
|
||||
@@ -382,7 +376,7 @@ namespace DysonNetwork.Drive.Migrations
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
|
||||
.WithMany()
|
||||
.WithMany("References")
|
||||
.HasForeignKey("FileId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
@@ -391,6 +385,11 @@ namespace DysonNetwork.Drive.Migrations
|
||||
b.Navigation("File");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||
{
|
||||
b.Navigation("References");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
|
||||
{
|
||||
b.Navigation("Files");
|
||||
|
@@ -10,11 +10,13 @@ using tusdotnet.Stores;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
|
||||
// Configure Kestrel and server options
|
||||
builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxValue);
|
||||
|
||||
// Add application services
|
||||
builder.Services.AddRegistryService(builder.Configuration);
|
||||
|
||||
builder.Services.AddAppServices(builder.Configuration);
|
||||
builder.Services.AddAppRateLimiting();
|
||||
builder.Services.AddAppAuthentication();
|
||||
@@ -37,6 +39,8 @@ builder.Services.AddTransient<IPageDataProvider, VersionPageData>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapDefaultEndpoints();
|
||||
|
||||
// Run database migrations
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
@@ -49,8 +53,6 @@ var tusDiskStore = app.Services.GetRequiredService<TusDiskStore>();
|
||||
// Configure application middleware pipeline
|
||||
app.ConfigureAppMiddleware(tusDiskStore, builder.Environment.ContentRootPath);
|
||||
|
||||
app.MapGatewayProxy();
|
||||
|
||||
app.MapPages(Path.Combine(app.Environment.WebRootPath, "dist", "index.html"));
|
||||
|
||||
// Configure gRPC
|
||||
|
72
DysonNetwork.Drive/Startup/BroadcastEventHandler.cs
Normal file
72
DysonNetwork.Drive/Startup/BroadcastEventHandler.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Drive.Storage;
|
||||
using DysonNetwork.Shared.Stream;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Client.JetStream.Models;
|
||||
using NATS.Net;
|
||||
|
||||
namespace DysonNetwork.Drive.Startup;
|
||||
|
||||
public class BroadcastEventHandler(
|
||||
INatsConnection nats,
|
||||
ILogger<BroadcastEventHandler> logger,
|
||||
IServiceProvider serviceProvider
|
||||
) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var js = nats.CreateJetStreamContext();
|
||||
|
||||
await js.EnsureStreamCreated("account_events", [AccountDeletedEvent.Type]);
|
||||
|
||||
var consumer = await js.CreateOrUpdateConsumerAsync("account_events",
|
||||
new ConsumerConfig("drive_account_deleted_handler"), cancellationToken: stoppingToken);
|
||||
|
||||
await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
var evt = JsonSerializer.Deserialize<AccountDeletedEvent>(msg.Data);
|
||||
if (evt == null)
|
||||
{
|
||||
await msg.AckAsync(cancellationToken: stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.LogInformation("Account deleted: {AccountId}", evt.AccountId);
|
||||
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var fs = scope.ServiceProvider.GetRequiredService<FileService>();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken: stoppingToken);
|
||||
try
|
||||
{
|
||||
var files = await db.Files
|
||||
.Where(p => p.AccountId == evt.AccountId)
|
||||
.ToListAsync(cancellationToken: stoppingToken);
|
||||
|
||||
await fs.DeleteFileDataBatchAsync(files);
|
||||
await db.Files
|
||||
.Where(p => p.AccountId == evt.AccountId)
|
||||
.ExecuteDeleteAsync(cancellationToken: stoppingToken);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken: stoppingToken);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken: stoppingToken);
|
||||
throw;
|
||||
}
|
||||
|
||||
await msg.AckAsync(cancellationToken: stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error processing AccountDeleted");
|
||||
await msg.NakAsync(cancellationToken: stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.RateLimiting;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
@@ -16,11 +17,6 @@ public static class ServiceCollectionExtensions
|
||||
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddDbContext<AppDatabase>(); // Assuming you'll have an AppDatabase
|
||||
services.AddSingleton<IConnectionMultiplexer>(_ =>
|
||||
{
|
||||
var connection = configuration.GetConnectionString("FastRetrieve")!;
|
||||
return ConnectionMultiplexer.Connect(connection);
|
||||
});
|
||||
services.AddSingleton<IClock>(SystemClock.Instance);
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddSingleton<ICacheService, CacheServiceRedis>(); // Uncomment if you have CacheServiceRedis
|
||||
@@ -40,6 +36,7 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
services.AddControllers().AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
|
||||
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||
|
||||
@@ -140,6 +137,8 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<Storage.FileReferenceService>();
|
||||
services.AddScoped<Billing.UsageService>();
|
||||
services.AddScoped<Billing.QuotaService>();
|
||||
|
||||
services.AddHostedService<BroadcastEventHandler>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
@@ -33,10 +33,6 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
|
||||
[JsonIgnore] public FileBundle? Bundle { get; set; }
|
||||
public Guid? BundleId { get; set; }
|
||||
|
||||
[Obsolete("Deprecated, use PoolId instead. For database migration only.")]
|
||||
[MaxLength(128)]
|
||||
public string? UploadedTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The field is set to true if the recycling job plans to delete the file.
|
||||
/// Due to the unstable of the recycling job, this doesn't really delete the file until a human verifies it.
|
||||
@@ -60,6 +56,8 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
|
||||
[NotMapped]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? FastUploadLink { get; set; }
|
||||
|
||||
public ICollection<CloudFileReference> References { get; set; } = new List<CloudFileReference>();
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
|
||||
|
@@ -190,10 +190,8 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
|
||||
.Where(r => r.ResourceId == resourceId && r.Usage == usage)
|
||||
.ToListAsync();
|
||||
|
||||
if (!references.Any())
|
||||
{
|
||||
if (references.Count == 0)
|
||||
return 0;
|
||||
}
|
||||
|
||||
var fileIds = references.Select(r => r.FileId).Distinct().ToList();
|
||||
|
||||
@@ -207,6 +205,28 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
public async Task<int> DeleteResourceReferencesBatchAsync(IEnumerable<string> resourceIds, string? usage = null)
|
||||
{
|
||||
var references = await db.FileReferences
|
||||
.Where(r => resourceIds.Contains(r.ResourceId))
|
||||
.If(usage != null, q => q.Where(q => q.Usage == usage))
|
||||
.ToListAsync();
|
||||
|
||||
if (references.Count == 0)
|
||||
return 0;
|
||||
|
||||
var fileIds = references.Select(r => r.FileId).Distinct().ToList();
|
||||
|
||||
db.FileReferences.RemoveRange(references);
|
||||
var deletedCount = await db.SaveChangesAsync();
|
||||
|
||||
// Purge caches
|
||||
var tasks = fileIds.Select(fileService._PurgeCacheAsync).ToList();
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a specific file reference
|
||||
|
@@ -85,7 +85,7 @@ namespace DysonNetwork.Drive.Storage
|
||||
public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferences(
|
||||
DeleteResourceReferencesRequest request, ServerCallContext context)
|
||||
{
|
||||
var deletedCount = 0;
|
||||
int deletedCount;
|
||||
if (request.Usage is null)
|
||||
deletedCount = await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId);
|
||||
else
|
||||
@@ -93,6 +93,18 @@ namespace DysonNetwork.Drive.Storage
|
||||
await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId, request.Usage!);
|
||||
return new DeleteResourceReferencesResponse { DeletedCount = deletedCount };
|
||||
}
|
||||
|
||||
public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferencesBatch(DeleteResourceReferencesBatchRequest request, ServerCallContext context)
|
||||
{
|
||||
var resourceIds = request.ResourceIds.ToList();
|
||||
int deletedCount;
|
||||
if (request.Usage is null)
|
||||
deletedCount = await fileReferenceService.DeleteResourceReferencesBatchAsync(resourceIds);
|
||||
else
|
||||
deletedCount =
|
||||
await fileReferenceService.DeleteResourceReferencesBatchAsync(resourceIds, request.Usage!);
|
||||
return new DeleteResourceReferencesResponse { DeletedCount = deletedCount };
|
||||
}
|
||||
|
||||
public override async Task<DeleteReferenceResponse> DeleteReference(DeleteReferenceRequest request,
|
||||
ServerCallContext context)
|
||||
|
@@ -102,6 +102,7 @@ public class FileService(
|
||||
|
||||
private static readonly string[] AnimatedImageTypes =
|
||||
["image/gif", "image/apng", "image/avif"];
|
||||
|
||||
private static readonly string[] AnimatedImageExtensions =
|
||||
[".gif", ".apng", ".avif"];
|
||||
|
||||
@@ -278,15 +279,15 @@ public class FileService(
|
||||
s.Rotation
|
||||
}).Where(s => double.IsNormal(s.AvgFrameRate)).ToList(),
|
||||
["audio_streams"] = mediaInfo.AudioStreams.Select(s => new
|
||||
{
|
||||
s.BitRate,
|
||||
s.Channels,
|
||||
s.ChannelLayout,
|
||||
s.CodecName,
|
||||
s.Duration,
|
||||
s.Language,
|
||||
s.SampleRateHz
|
||||
})
|
||||
{
|
||||
s.BitRate,
|
||||
s.Channels,
|
||||
s.ChannelLayout,
|
||||
s.CodecName,
|
||||
s.Duration,
|
||||
s.Language,
|
||||
s.SampleRateHz
|
||||
})
|
||||
.ToList(),
|
||||
};
|
||||
if (mediaInfo.PrimaryVideoStream is not null)
|
||||
@@ -336,7 +337,14 @@ public class FileService(
|
||||
if (!pool.PolicyConfig.NoOptimization)
|
||||
switch (contentType.Split('/')[0])
|
||||
{
|
||||
case "image" when !AnimatedImageTypes.Contains(contentType) && !AnimatedImageExtensions.Contains(fileExtension):
|
||||
case "image":
|
||||
if (AnimatedImageTypes.Contains(contentType) || AnimatedImageExtensions.Contains(fileExtension))
|
||||
{
|
||||
logger.LogInformation("Skip optimize file {FileId} due to it is animated...", fileId);
|
||||
uploads.Add((originalFilePath, string.Empty, contentType, false));
|
||||
break;
|
||||
}
|
||||
|
||||
newMimeType = "image/webp";
|
||||
using (var vipsImage = Image.NewFromFile(originalFilePath))
|
||||
{
|
||||
@@ -643,7 +651,44 @@ public class FileService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<FileBundle?> GetBundleAsync(Guid id, Guid accountId)
|
||||
/// <summary>
|
||||
/// The most efficent way to delete file data (stored files) in batch.
|
||||
/// But this DO NOT check the storage id, so use with caution!
|
||||
/// </summary>
|
||||
/// <param name="files">Files to delete</param>
|
||||
/// <exception cref="InvalidOperationException">Something went wrong</exception>
|
||||
public async Task DeleteFileDataBatchAsync(List<CloudFile> files)
|
||||
{
|
||||
files = files.Where(f => f.PoolId.HasValue).ToList();
|
||||
|
||||
foreach (var fileGroup in files.GroupBy(f => f.PoolId!.Value))
|
||||
{
|
||||
// If any other file with the same storage ID is referenced, don't delete the actual file data
|
||||
var dest = await GetRemoteStorageConfig(fileGroup.Key);
|
||||
if (dest is null)
|
||||
throw new InvalidOperationException($"No remote storage configured for pool {fileGroup.Key}");
|
||||
var client = CreateMinioClient(dest);
|
||||
if (client is null)
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to configure client for remote destination '{fileGroup.Key}'"
|
||||
);
|
||||
|
||||
List<string> objectsToDelete = [];
|
||||
|
||||
foreach (var file in fileGroup)
|
||||
{
|
||||
objectsToDelete.Add(file.StorageId ?? file.Id);
|
||||
if (file.HasCompression) objectsToDelete.Add(file.StorageId ?? file.Id + ".compressed");
|
||||
if (file.HasThumbnail) objectsToDelete.Add(file.StorageId ?? file.Id + ".thumbnail");
|
||||
}
|
||||
|
||||
await client.RemoveObjectsAsync(
|
||||
new RemoveObjectsArgs().WithBucket(dest.Bucket).WithObjects(objectsToDelete)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<FileBundle?> GetBundleAsync(Guid id, Guid accountId)
|
||||
{
|
||||
var bundle = await db.Bundles
|
||||
.Where(e => e.Id == id)
|
||||
@@ -880,4 +925,4 @@ file class UpdatableCloudFile(CloudFile file)
|
||||
.SetProperty(f => f.UserMeta, userMeta!)
|
||||
.SetProperty(f => f.IsMarkedRecycle, IsMarkedRecycle);
|
||||
}
|
||||
}
|
||||
}
|
@@ -10,9 +10,7 @@
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
|
||||
"FastRetrieve": "localhost:6379",
|
||||
"Etcd": "etcd.orb.local:2379"
|
||||
"App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
||||
},
|
||||
"Authentication": {
|
||||
"Schemes": {
|
||||
@@ -130,8 +128,6 @@
|
||||
],
|
||||
"Service": {
|
||||
"Name": "DysonNetwork.Drive",
|
||||
"Url": "https://localhost:7092",
|
||||
"ClientCert": "../Certificates/client.crt",
|
||||
"ClientKey": "../Certificates/client.key"
|
||||
"Url": "https://localhost:7092"
|
||||
}
|
||||
}
|
||||
|
@@ -1,78 +0,0 @@
|
||||
using System.Text;
|
||||
using dotnet_etcd.interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Yarp.ReverseProxy.Configuration;
|
||||
|
||||
namespace DysonNetwork.Gateway.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("/.well-known")]
|
||||
public class WellKnownController(
|
||||
IConfiguration configuration,
|
||||
IProxyConfigProvider proxyConfigProvider,
|
||||
IEtcdClient etcdClient)
|
||||
: ControllerBase
|
||||
{
|
||||
[HttpGet("domains")]
|
||||
public IActionResult GetDomainMappings()
|
||||
{
|
||||
var domainMappings = configuration.GetSection("DomainMappings").GetChildren()
|
||||
.ToDictionary(x => x.Key, x => x.Value);
|
||||
return Ok(domainMappings);
|
||||
}
|
||||
|
||||
[HttpGet("services")]
|
||||
public IActionResult GetServices()
|
||||
{
|
||||
var local = configuration.GetValue<bool>("LocalMode");
|
||||
var response = etcdClient.GetRange("/services/");
|
||||
var kvs = response.Kvs;
|
||||
|
||||
var serviceMap = kvs.ToDictionary(
|
||||
kv => Encoding.UTF8.GetString(kv.Key.ToByteArray()).Replace("/services/", ""),
|
||||
kv => Encoding.UTF8.GetString(kv.Value.ToByteArray())
|
||||
);
|
||||
|
||||
if (local) return Ok(serviceMap);
|
||||
|
||||
var domainMappings = configuration.GetSection("DomainMappings").GetChildren()
|
||||
.ToDictionary(x => x.Key, x => x.Value);
|
||||
foreach (var (key, _) in serviceMap.ToList())
|
||||
{
|
||||
if (!domainMappings.TryGetValue(key, out var domain)) continue;
|
||||
if (domain is not null)
|
||||
serviceMap[key] = "https://" + domain;
|
||||
}
|
||||
|
||||
return Ok(serviceMap);
|
||||
}
|
||||
|
||||
[HttpGet("routes")]
|
||||
public IActionResult GetProxyRules()
|
||||
{
|
||||
var config = proxyConfigProvider.GetConfig();
|
||||
var rules = config.Routes.Select(r => new
|
||||
{
|
||||
r.RouteId,
|
||||
r.ClusterId,
|
||||
Match = new
|
||||
{
|
||||
r.Match.Path,
|
||||
Hosts = r.Match.Hosts != null ? string.Join(", ", r.Match.Hosts) : null
|
||||
},
|
||||
Transforms = r.Transforms?.Select(t => t.Select(kv => $"{kv.Key}: {kv.Value}").ToList())
|
||||
}).ToList();
|
||||
|
||||
var clusters = config.Clusters.Select(c => new
|
||||
{
|
||||
c.ClusterId,
|
||||
Destinations = c.Destinations?.Select(d => new
|
||||
{
|
||||
d.Key,
|
||||
d.Value.Address
|
||||
}).ToList()
|
||||
}).ToList();
|
||||
|
||||
return Ok(new { Rules = rules, Clusters = clusters });
|
||||
}
|
||||
}
|
@@ -1,23 +0,0 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["DysonNetwork.Gateway/DysonNetwork.Gateway.csproj", "DysonNetwork.Gateway/"]
|
||||
RUN dotnet restore "DysonNetwork.Gateway/DysonNetwork.Gateway.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/DysonNetwork.Gateway"
|
||||
RUN dotnet build "./DysonNetwork.Gateway.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "./DysonNetwork.Gateway.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "DysonNetwork.Gateway.dll"]
|
@@ -1,23 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="dotnet-etcd" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" />
|
||||
<PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
@@ -1,38 +0,0 @@
|
||||
using DysonNetwork.Gateway.Startup;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Host.UseContentRoot(Directory.GetCurrentDirectory());
|
||||
builder.WebHost.ConfigureKestrel(options =>
|
||||
{
|
||||
options.Limits.MaxRequestBodySize = long.MaxValue;
|
||||
options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2);
|
||||
options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddGateway(builder.Configuration);
|
||||
builder.Services.AddControllers();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseForwardedHeaders(new ForwardedHeadersOptions
|
||||
{
|
||||
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
|
||||
});
|
||||
|
||||
app.UseRequestTimeouts();
|
||||
app.UseCors(opts =>
|
||||
opts.SetIsOriginAllowed(_ => true)
|
||||
.WithExposedHeaders("*")
|
||||
.WithHeaders("*")
|
||||
.AllowCredentials()
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
);
|
||||
|
||||
app.MapControllers();
|
||||
app.MapReverseProxy();
|
||||
|
||||
app.Run();
|
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5094",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7034;http://0.0.0.0:5094",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,239 +0,0 @@
|
||||
using System.Text;
|
||||
using dotnet_etcd.interfaces;
|
||||
using Yarp.ReverseProxy.Configuration;
|
||||
using Yarp.ReverseProxy.Forwarder;
|
||||
|
||||
namespace DysonNetwork.Gateway;
|
||||
|
||||
public class RegistryProxyConfigProvider : IProxyConfigProvider, IDisposable
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly IEtcdClient _etcdClient;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<RegistryProxyConfigProvider> _logger;
|
||||
private readonly CancellationTokenSource _watchCts = new();
|
||||
private CancellationTokenSource _cts;
|
||||
private IProxyConfig _config;
|
||||
|
||||
public RegistryProxyConfigProvider(
|
||||
IEtcdClient etcdClient,
|
||||
IConfiguration configuration,
|
||||
ILogger<RegistryProxyConfigProvider> logger
|
||||
)
|
||||
{
|
||||
_etcdClient = etcdClient;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
_cts = new CancellationTokenSource();
|
||||
_config = LoadConfig();
|
||||
|
||||
// Watch for changes in etcd
|
||||
_etcdClient.WatchRange("/services/", _ =>
|
||||
{
|
||||
_logger.LogInformation("Etcd configuration changed. Reloading proxy config.");
|
||||
ReloadConfig();
|
||||
}, cancellationToken: _watchCts.Token);
|
||||
}
|
||||
|
||||
public IProxyConfig GetConfig() => _config;
|
||||
|
||||
private void ReloadConfig()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var oldCts = _cts;
|
||||
_cts = new CancellationTokenSource();
|
||||
_config = LoadConfig();
|
||||
oldCts.Cancel();
|
||||
oldCts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private IProxyConfig LoadConfig()
|
||||
{
|
||||
_logger.LogInformation("Generating new proxy config.");
|
||||
var response = _etcdClient.GetRange("/services/");
|
||||
var kvs = response.Kvs;
|
||||
|
||||
var serviceMap = kvs.ToDictionary(
|
||||
kv => Encoding.UTF8.GetString(kv.Key.ToByteArray()).Replace("/services/", ""),
|
||||
kv => Encoding.UTF8.GetString(kv.Value.ToByteArray())
|
||||
);
|
||||
|
||||
var clusters = new List<ClusterConfig>();
|
||||
var routes = new List<RouteConfig>();
|
||||
|
||||
var domainMappings = _configuration.GetSection("DomainMappings").GetChildren()
|
||||
.ToDictionary(x => x.Key, x => x.Value);
|
||||
|
||||
var pathAliases = _configuration.GetSection("PathAliases").GetChildren()
|
||||
.ToDictionary(x => x.Key, x => x.Value);
|
||||
|
||||
var directRoutes = _configuration.GetSection("DirectRoutes").Get<List<DirectRouteConfig>>() ??
|
||||
[];
|
||||
|
||||
_logger.LogInformation("Indexing {ServiceCount} services from Etcd.", kvs.Count);
|
||||
|
||||
var gatewayServiceName = _configuration["Service:Name"];
|
||||
|
||||
// Add direct routes
|
||||
foreach (var directRoute in directRoutes)
|
||||
{
|
||||
if (serviceMap.TryGetValue(directRoute.Service, out var serviceUrl))
|
||||
{
|
||||
var existingCluster = clusters.FirstOrDefault(c => c.ClusterId == directRoute.Service);
|
||||
if (existingCluster is null)
|
||||
{
|
||||
var cluster = new ClusterConfig
|
||||
{
|
||||
ClusterId = directRoute.Service,
|
||||
Destinations = new Dictionary<string, DestinationConfig>
|
||||
{
|
||||
{ "destination1", new DestinationConfig { Address = serviceUrl } }
|
||||
},
|
||||
};
|
||||
clusters.Add(cluster);
|
||||
}
|
||||
|
||||
var route = new RouteConfig
|
||||
{
|
||||
RouteId = $"direct-{directRoute.Service}-{directRoute.Path.Replace("/", "-")}",
|
||||
ClusterId = directRoute.Service,
|
||||
Match = new RouteMatch { Path = directRoute.Path },
|
||||
};
|
||||
routes.Add(route);
|
||||
_logger.LogInformation(" Added Direct Route: {Path} -> {Service}", directRoute.Path,
|
||||
directRoute.Service);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(" Direct route service {Service} not found in Etcd.", directRoute.Service);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var serviceName in serviceMap.Keys)
|
||||
{
|
||||
if (serviceName == gatewayServiceName)
|
||||
{
|
||||
_logger.LogInformation("Skipping gateway service: {ServiceName}", serviceName);
|
||||
continue;
|
||||
}
|
||||
|
||||
var serviceUrl = serviceMap[serviceName];
|
||||
|
||||
// Determine the path alias
|
||||
string? pathAlias;
|
||||
pathAlias = pathAliases.TryGetValue(serviceName, out var alias)
|
||||
? alias
|
||||
: serviceName.Split('.').Last().ToLowerInvariant();
|
||||
|
||||
_logger.LogInformation(" Service: {ServiceName}, URL: {ServiceUrl}, Path Alias: {PathAlias}", serviceName,
|
||||
serviceUrl, pathAlias);
|
||||
|
||||
// Check if the cluster already exists
|
||||
var existingCluster = clusters.FirstOrDefault(c => c.ClusterId == serviceName);
|
||||
if (existingCluster == null)
|
||||
{
|
||||
var cluster = new ClusterConfig
|
||||
{
|
||||
ClusterId = serviceName,
|
||||
Destinations = new Dictionary<string, DestinationConfig>
|
||||
{
|
||||
{ "destination1", new DestinationConfig { Address = serviceUrl } }
|
||||
}
|
||||
};
|
||||
clusters.Add(cluster);
|
||||
_logger.LogInformation(" Added Cluster: {ServiceName}", serviceName);
|
||||
}
|
||||
else if (existingCluster.Destinations is not null)
|
||||
{
|
||||
// Create a new cluster with merged destinations
|
||||
var newDestinations = new Dictionary<string, DestinationConfig>(existingCluster.Destinations)
|
||||
{
|
||||
{
|
||||
$"destination{existingCluster.Destinations.Count + 1}",
|
||||
new DestinationConfig { Address = serviceUrl }
|
||||
}
|
||||
};
|
||||
|
||||
var mergedCluster = new ClusterConfig
|
||||
{
|
||||
ClusterId = serviceName,
|
||||
Destinations = newDestinations
|
||||
};
|
||||
|
||||
// Replace the existing cluster with the merged one
|
||||
var index = clusters.IndexOf(existingCluster);
|
||||
clusters[index] = mergedCluster;
|
||||
|
||||
_logger.LogInformation(" Updated Cluster {ServiceName} with {DestinationCount} destinations",
|
||||
serviceName, mergedCluster.Destinations.Count);
|
||||
}
|
||||
|
||||
// Host-based routing
|
||||
if (domainMappings.TryGetValue(serviceName, out var domain))
|
||||
{
|
||||
var hostRoute = new RouteConfig
|
||||
{
|
||||
RouteId = $"{serviceName}-host",
|
||||
ClusterId = serviceName,
|
||||
Match = new RouteMatch
|
||||
{
|
||||
Hosts = [domain],
|
||||
Path = "/{**catch-all}"
|
||||
}
|
||||
};
|
||||
routes.Add(hostRoute);
|
||||
_logger.LogInformation(" Added Host-based Route: {Host}", domain);
|
||||
}
|
||||
|
||||
// Path-based routing
|
||||
var pathRoute = new RouteConfig
|
||||
{
|
||||
RouteId = $"{serviceName}-path",
|
||||
ClusterId = serviceName,
|
||||
Match = new RouteMatch { Path = $"/{pathAlias}/{{**catch-all}}" },
|
||||
Transforms = new List<Dictionary<string, string>>
|
||||
{
|
||||
new() { { "PathRemovePrefix", $"/{pathAlias}" } },
|
||||
new() { { "PathPrefix", "/api" } }
|
||||
},
|
||||
Timeout = TimeSpan.FromSeconds(5)
|
||||
};
|
||||
routes.Add(pathRoute);
|
||||
_logger.LogInformation(" Added Path-based Route: {Path}", pathRoute.Match.Path);
|
||||
}
|
||||
|
||||
return new CustomProxyConfig(
|
||||
routes,
|
||||
clusters,
|
||||
new Microsoft.Extensions.Primitives.CancellationChangeToken(_cts.Token)
|
||||
);
|
||||
}
|
||||
|
||||
private class CustomProxyConfig(
|
||||
IReadOnlyList<RouteConfig> routes,
|
||||
IReadOnlyList<ClusterConfig> clusters,
|
||||
Microsoft.Extensions.Primitives.IChangeToken changeToken
|
||||
)
|
||||
: IProxyConfig
|
||||
{
|
||||
public IReadOnlyList<RouteConfig> Routes { get; } = routes;
|
||||
public IReadOnlyList<ClusterConfig> Clusters { get; } = clusters;
|
||||
public Microsoft.Extensions.Primitives.IChangeToken ChangeToken { get; } = changeToken;
|
||||
}
|
||||
|
||||
public record DirectRouteConfig
|
||||
{
|
||||
public required string Path { get; set; }
|
||||
public required string Service { get; set; }
|
||||
}
|
||||
|
||||
public virtual void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
_watchCts.Cancel();
|
||||
_watchCts.Dispose();
|
||||
}
|
||||
}
|
@@ -1,30 +0,0 @@
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Yarp.ReverseProxy.Configuration;
|
||||
|
||||
namespace DysonNetwork.Gateway.Startup;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddGateway(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddRequestTimeouts();
|
||||
|
||||
services
|
||||
.AddReverseProxy()
|
||||
.ConfigureHttpClient((context, handler) =>
|
||||
{
|
||||
var caCert = X509CertificateLoader.LoadCertificateFromFile(configuration["CaCert"]!);
|
||||
handler.SslOptions = new SslClientAuthenticationOptions
|
||||
{
|
||||
RemoteCertificateValidationCallback = (sender, cert, chain, errors) => true
|
||||
};
|
||||
});
|
||||
|
||||
services.AddRegistryService(configuration, addForwarder: false);
|
||||
services.AddSingleton<IProxyConfigProvider, RegistryProxyConfigProvider>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
@@ -1,20 +0,0 @@
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DysonNetwork.Gateway;
|
||||
|
||||
[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,49 +0,0 @@
|
||||
{
|
||||
"LocalMode": true,
|
||||
"CaCert": "../Certificates/ca.crt",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"Etcd": "etcd.orb.local:2379"
|
||||
},
|
||||
"Etcd": {
|
||||
"Insecure": true
|
||||
},
|
||||
"Service": {
|
||||
"Name": "DysonNetwork.Gateway",
|
||||
"Url": "https://localhost:7034"
|
||||
},
|
||||
"DomainMappings": {
|
||||
"DysonNetwork.Pass": "id.solsynth.dev",
|
||||
"DysonNetwork.Drive": "drive.solsynth.dev",
|
||||
"DysonNetwork.Pusher": "push.solsynth.dev",
|
||||
"DysonNetwork.Sphere": "sphere.solsynth.dev"
|
||||
},
|
||||
"PathAliases": {
|
||||
"DysonNetwork.Pass": "id",
|
||||
"DysonNetwork.Drive": "drive"
|
||||
},
|
||||
"DirectRoutes": [
|
||||
{
|
||||
"Path": "/ws",
|
||||
"Service": "DysonNetwork.Pusher"
|
||||
},
|
||||
{
|
||||
"Path": "/api/tus",
|
||||
"Service": "DysonNetwork.Drive"
|
||||
},
|
||||
{
|
||||
"Path": "/.well-known/openid-configuration",
|
||||
"Service": "DysonNetwork.Pass"
|
||||
},
|
||||
{
|
||||
"Path": "/.well-known/jwks",
|
||||
"Service": "DysonNetwork.Pass"
|
||||
}
|
||||
]
|
||||
}
|
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"publicReleaseRefSpec": ["^refs/heads/main$"],
|
||||
"cloudBuild": {
|
||||
"setVersionVariables": true
|
||||
}
|
||||
}
|
@@ -18,9 +18,13 @@ public class Account : ModelBase
|
||||
[MaxLength(256)] public string Name { get; set; } = string.Empty;
|
||||
[MaxLength(256)] public string Nick { get; set; } = string.Empty;
|
||||
[MaxLength(32)] public string Language { get; set; } = string.Empty;
|
||||
[MaxLength(32)] public string Region { get; set; } = string.Empty;
|
||||
public Instant? ActivatedAt { get; set; }
|
||||
public bool IsSuperuser { get; set; } = false;
|
||||
|
||||
// The ID is the BotAccount ID in the DysonNetwork.Develop
|
||||
public Guid? AutomatedId { get; set; }
|
||||
|
||||
public AccountProfile Profile { get; set; } = null!;
|
||||
public ICollection<AccountContact> Contacts { get; set; } = new List<AccountContact>();
|
||||
public ICollection<AccountBadge> Badges { get; set; } = new List<AccountBadge>();
|
||||
@@ -43,12 +47,14 @@ public class Account : ModelBase
|
||||
Name = Name,
|
||||
Nick = Nick,
|
||||
Language = Language,
|
||||
Region = Region,
|
||||
ActivatedAt = ActivatedAt?.ToTimestamp(),
|
||||
IsSuperuser = IsSuperuser,
|
||||
Profile = Profile.ToProtoValue(),
|
||||
PerkSubscription = PerkSubscription?.ToProtoValue(),
|
||||
CreatedAt = CreatedAt.ToTimestamp(),
|
||||
UpdatedAt = UpdatedAt.ToTimestamp()
|
||||
UpdatedAt = UpdatedAt.ToTimestamp(),
|
||||
AutomatedId = AutomatedId?.ToString()
|
||||
};
|
||||
|
||||
// Add contacts
|
||||
@@ -71,6 +77,7 @@ public class Account : ModelBase
|
||||
Name = proto.Name,
|
||||
Nick = proto.Nick,
|
||||
Language = proto.Language,
|
||||
Region = proto.Region,
|
||||
ActivatedAt = proto.ActivatedAt?.ToInstant(),
|
||||
IsSuperuser = proto.IsSuperuser,
|
||||
PerkSubscription = proto.PerkSubscription is not null
|
||||
@@ -78,10 +85,10 @@ public class Account : ModelBase
|
||||
: null,
|
||||
CreatedAt = proto.CreatedAt.ToInstant(),
|
||||
UpdatedAt = proto.UpdatedAt.ToInstant(),
|
||||
AutomatedId = proto.AutomatedId is not null ? Guid.Parse(proto.AutomatedId) : null,
|
||||
Profile = AccountProfile.FromProtoValue(proto.Profile)
|
||||
};
|
||||
|
||||
account.Profile = AccountProfile.FromProtoValue(proto.Profile);
|
||||
|
||||
foreach (var contactProto in proto.Contacts)
|
||||
account.Contacts.Add(AccountContact.FromProtoValue(contactProto));
|
||||
|
||||
@@ -116,7 +123,7 @@ public abstract class Leveling
|
||||
|
||||
public class AccountProfile : ModelBase, IIdentifiedResource
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(256)] public string? FirstName { get; set; }
|
||||
[MaxLength(256)] public string? MiddleName { get; set; }
|
||||
[MaxLength(256)] public string? LastName { get; set; }
|
||||
@@ -132,9 +139,20 @@ public class AccountProfile : ModelBase, IIdentifiedResource
|
||||
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
|
||||
[Column(TypeName = "jsonb")] public BadgeReferenceObject? ActiveBadge { get; set; }
|
||||
|
||||
public int Experience { get; set; } = 0;
|
||||
public int Experience { get; set; }
|
||||
[NotMapped] public int Level => Leveling.ExperiencePerLevel.Count(xp => Experience >= xp) - 1;
|
||||
|
||||
public double SocialCredits { get; set; } = 100;
|
||||
|
||||
[NotMapped]
|
||||
public int SocialCreditsLevel => SocialCredits switch
|
||||
{
|
||||
< 100 => -1,
|
||||
> 100 and < 200 => 0,
|
||||
< 200 => 1,
|
||||
_ => 2
|
||||
};
|
||||
|
||||
[NotMapped]
|
||||
public double LevelingProgress => Level >= Leveling.ExperiencePerLevel.Count - 1
|
||||
? 100
|
||||
@@ -165,6 +183,8 @@ public class AccountProfile : ModelBase, IIdentifiedResource
|
||||
Experience = Experience,
|
||||
Level = Level,
|
||||
LevelingProgress = LevelingProgress,
|
||||
SocialCredits = SocialCredits,
|
||||
SocialCreditsLevel = SocialCreditsLevel,
|
||||
Picture = Picture?.ToProtoValue(),
|
||||
Background = Background?.ToProtoValue(),
|
||||
AccountId = AccountId.ToString(),
|
||||
@@ -195,6 +215,7 @@ public class AccountProfile : ModelBase, IIdentifiedResource
|
||||
Verification = proto.Verification is null ? null : VerificationMark.FromProtoValue(proto.Verification),
|
||||
ActiveBadge = proto.ActiveBadge is null ? null : BadgeReferenceObject.FromProtoValue(proto.ActiveBadge),
|
||||
Experience = proto.Experience,
|
||||
SocialCredits = proto.SocialCredits,
|
||||
Picture = proto.Picture is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Picture),
|
||||
Background = proto.Background is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Background),
|
||||
AccountId = Guid.Parse(proto.AccountId),
|
||||
|
@@ -1,7 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Pass.Auth;
|
||||
using DysonNetwork.Pass.Credit;
|
||||
using DysonNetwork.Pass.Wallet;
|
||||
using DysonNetwork.Shared.Error;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
@@ -15,7 +17,9 @@ public class AccountController(
|
||||
AuthService auth,
|
||||
AccountService accounts,
|
||||
SubscriptionService subscriptions,
|
||||
AccountEventService events
|
||||
AccountEventService events,
|
||||
SocialCreditService socialCreditService,
|
||||
GeoIpService geo
|
||||
) : ControllerBase
|
||||
{
|
||||
[HttpGet("{name}")]
|
||||
@@ -30,10 +34,10 @@ public class AccountController(
|
||||
.Where(a => a.Name == name)
|
||||
.FirstOrDefaultAsync();
|
||||
if (account is null) return NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier));
|
||||
|
||||
|
||||
var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id);
|
||||
account.PerkSubscription = perk?.ToReference();
|
||||
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
@@ -46,7 +50,28 @@ public class AccountController(
|
||||
.Include(e => e.Badges)
|
||||
.Where(a => a.Name == name)
|
||||
.FirstOrDefaultAsync();
|
||||
return account is null ? NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier)) : account.Badges.ToList();
|
||||
return account is null
|
||||
? NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier))
|
||||
: account.Badges.ToList();
|
||||
}
|
||||
|
||||
[HttpGet("{name}/credits")]
|
||||
[ProducesResponseType<double>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<double>> GetSocialCredits(string name)
|
||||
{
|
||||
var account = await db.Accounts
|
||||
.Where(a => a.Name == name)
|
||||
.Select(a => new { a.Id })
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (account is null)
|
||||
{
|
||||
return NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier));
|
||||
}
|
||||
|
||||
var credits = await socialCreditService.GetSocialCredit(account.Id);
|
||||
return credits;
|
||||
}
|
||||
|
||||
public class AccountCreateRequest
|
||||
@@ -72,7 +97,7 @@ public class AccountController(
|
||||
[MaxLength(128)]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(128)] public string Language { get; set; } = "en-us";
|
||||
[MaxLength(32)] public string Language { get; set; } = "en-us";
|
||||
|
||||
[Required] public string CaptchaToken { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -88,6 +113,10 @@ public class AccountController(
|
||||
[nameof(request.CaptchaToken)] = ["Invalid captcha token."]
|
||||
}, traceId: HttpContext.TraceIdentifier));
|
||||
|
||||
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
if (ip is null) return BadRequest(ApiError.NotFound(request.Name, traceId: HttpContext.TraceIdentifier));
|
||||
var region = geo.GetFromIp(ip)?.Country.IsoCode ?? "us";
|
||||
|
||||
try
|
||||
{
|
||||
var account = await accounts.CreateAccount(
|
||||
@@ -95,7 +124,8 @@ public class AccountController(
|
||||
request.Nick,
|
||||
request.Email,
|
||||
request.Password,
|
||||
request.Language
|
||||
request.Language,
|
||||
region
|
||||
);
|
||||
return Ok(account);
|
||||
}
|
||||
@@ -161,7 +191,9 @@ public class AccountController(
|
||||
public StatusAttitude Attitude { get; set; }
|
||||
public bool IsInvisible { get; set; }
|
||||
public bool IsNotDisturb { get; set; }
|
||||
public bool IsAutomated { get; set; } = false;
|
||||
[MaxLength(1024)] public string? Label { get; set; }
|
||||
[MaxLength(4096)] public string? AppIdentifier { get; set; }
|
||||
public Instant? ClearedAt { get; set; }
|
||||
}
|
||||
|
||||
|
@@ -24,11 +24,13 @@ public class AccountCurrentController(
|
||||
AccountEventService events,
|
||||
AuthService auth,
|
||||
FileService.FileServiceClient files,
|
||||
FileReferenceService.FileReferenceServiceClient fileRefs
|
||||
FileReferenceService.FileReferenceServiceClient fileRefs,
|
||||
Credit.SocialCreditService creditService
|
||||
) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ApiError>(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult<Account>> GetCurrentIdentity()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
@@ -50,6 +52,7 @@ public class AccountCurrentController(
|
||||
{
|
||||
[MaxLength(256)] public string? Nick { get; set; }
|
||||
[MaxLength(32)] public string? Language { get; set; }
|
||||
[MaxLength(32)] public string? Region { get; set; }
|
||||
}
|
||||
|
||||
[HttpPatch]
|
||||
@@ -61,6 +64,7 @@ public class AccountCurrentController(
|
||||
|
||||
if (request.Nick is not null) account.Nick = request.Nick;
|
||||
if (request.Language is not null) account.Language = request.Language;
|
||||
if (request.Region is not null) account.Region = request.Region;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
await accounts.PurgeAccountCache(currentUser);
|
||||
@@ -193,6 +197,8 @@ public class AccountCurrentController(
|
||||
public async Task<ActionResult<Status>> UpdateStatus([FromBody] AccountController.StatusRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
if (request is { IsAutomated: true, AppIdentifier: not null })
|
||||
return BadRequest("Automated status cannot be updated.");
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var status = await db.AccountStatuses
|
||||
@@ -201,11 +207,15 @@ public class AccountCurrentController(
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
if (status is null) return NotFound(ApiError.NotFound("status", traceId: HttpContext.TraceIdentifier));
|
||||
if (status.IsAutomated && request.AppIdentifier is null)
|
||||
return BadRequest("Automated status cannot be updated.");
|
||||
|
||||
status.Attitude = request.Attitude;
|
||||
status.IsInvisible = request.IsInvisible;
|
||||
status.IsNotDisturb = request.IsNotDisturb;
|
||||
status.IsAutomated = request.IsAutomated;
|
||||
status.Label = request.Label;
|
||||
status.AppIdentifier = request.AppIdentifier;
|
||||
status.ClearedAt = request.ClearedAt;
|
||||
|
||||
db.Update(status);
|
||||
@@ -221,13 +231,44 @@ public class AccountCurrentController(
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
if (request is { IsAutomated: true, AppIdentifier: not null })
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var existingStatus = await db.AccountStatuses
|
||||
.Where(s => s.AccountId == currentUser.Id)
|
||||
.Where(s => s.ClearedAt == null || s.ClearedAt > now)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
if (existingStatus is not null && existingStatus.IsAutomated)
|
||||
if (existingStatus.IsAutomated && request.AppIdentifier == existingStatus.AppIdentifier)
|
||||
{
|
||||
existingStatus.Attitude = request.Attitude;
|
||||
existingStatus.IsInvisible = request.IsInvisible;
|
||||
existingStatus.IsNotDisturb = request.IsNotDisturb;
|
||||
existingStatus.Label = request.Label;
|
||||
db.Update(existingStatus);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(existingStatus);
|
||||
}
|
||||
else
|
||||
{
|
||||
existingStatus.ClearedAt = now;
|
||||
db.Update(existingStatus);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
else if (existingStatus is not null)
|
||||
return Ok(existingStatus); // Do not override manually set status with automated ones
|
||||
}
|
||||
|
||||
var status = new Status
|
||||
{
|
||||
AccountId = currentUser.Id,
|
||||
Attitude = request.Attitude,
|
||||
IsInvisible = request.IsInvisible,
|
||||
IsNotDisturb = request.IsNotDisturb,
|
||||
IsAutomated = request.IsAutomated,
|
||||
Label = request.Label,
|
||||
AppIdentifier = request.AppIdentifier,
|
||||
ClearedAt = request.ClearedAt
|
||||
};
|
||||
|
||||
@@ -235,15 +276,21 @@ public class AccountCurrentController(
|
||||
}
|
||||
|
||||
[HttpDelete("statuses")]
|
||||
public async Task<ActionResult> DeleteStatus()
|
||||
public async Task<ActionResult> DeleteStatus([FromQuery] string? app)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var status = await db.AccountStatuses
|
||||
var queryable = db.AccountStatuses
|
||||
.Where(s => s.AccountId == currentUser.Id)
|
||||
.Where(s => s.ClearedAt == null || s.ClearedAt > now)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.AsQueryable();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(app))
|
||||
queryable = queryable.Where(s => s.IsAutomated && s.AppIdentifier == app);
|
||||
|
||||
var status = await queryable
|
||||
.FirstOrDefaultAsync();
|
||||
if (status is null) return NotFound();
|
||||
|
||||
@@ -268,7 +315,9 @@ public class AccountCurrentController(
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
return result is null ? NotFound(ApiError.NotFound("check-in", traceId: HttpContext.TraceIdentifier)) : Ok(result);
|
||||
return result is null
|
||||
? NotFound(ApiError.NotFound("check-in", traceId: HttpContext.TraceIdentifier))
|
||||
: Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("check-in")]
|
||||
@@ -323,10 +372,11 @@ public class AccountCurrentController(
|
||||
TraceId = HttpContext.TraceIdentifier
|
||||
}
|
||||
),
|
||||
true when !await auth.ValidateCaptcha(captchaToken!) => BadRequest(ApiError.Validation(new Dictionary<string, string[]>
|
||||
{
|
||||
["captchaToken"] = new[] { "Invalid captcha token." }
|
||||
}, traceId: HttpContext.TraceIdentifier)),
|
||||
true when !await auth.ValidateCaptcha(captchaToken!) => BadRequest(ApiError.Validation(
|
||||
new Dictionary<string, string[]>
|
||||
{
|
||||
["captchaToken"] = new[] { "Invalid captcha token." }
|
||||
}, traceId: HttpContext.TraceIdentifier)),
|
||||
_ => await events.CheckInDaily(currentUser, backdated)
|
||||
};
|
||||
}
|
||||
@@ -823,4 +873,60 @@ public class AccountCurrentController(
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("leveling")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<ExperienceRecord>> GetLevelingHistory(
|
||||
[FromQuery] int take = 20,
|
||||
[FromQuery] int offset = 0
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
var queryable = db.ExperienceRecords
|
||||
.Where(r => r.AccountId == currentUser.Id)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.AsQueryable();
|
||||
|
||||
var totalCount = await queryable.CountAsync();
|
||||
Response.Headers["X-Total"] = totalCount.ToString();
|
||||
|
||||
var records = await queryable
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(records);
|
||||
}
|
||||
|
||||
[HttpGet("credits")]
|
||||
public async Task<ActionResult<bool>> GetSocialCredit()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
var credit = await creditService.GetSocialCredit(currentUser.Id);
|
||||
return Ok(credit);
|
||||
}
|
||||
|
||||
[HttpGet("credits/history")]
|
||||
public async Task<ActionResult<SocialCreditRecord>> GetCreditHistory(
|
||||
[FromQuery] int take = 20,
|
||||
[FromQuery] int offset = 0
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
var queryable = db.SocialCreditRecords
|
||||
.Where(r => r.AccountId == currentUser.Id)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.AsQueryable();
|
||||
|
||||
var totalCount = await queryable.CountAsync();
|
||||
Response.Headers["X-Total"] = totalCount.ToString();
|
||||
|
||||
var records = await queryable
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(records);
|
||||
}
|
||||
}
|
@@ -14,8 +14,9 @@ public class AccountEventService(
|
||||
Wallet.PaymentService payment,
|
||||
ICacheService cache,
|
||||
IStringLocalizer<Localization.AccountEventResource> localizer,
|
||||
PusherService.PusherServiceClient pusher,
|
||||
SubscriptionService subscriptions
|
||||
RingService.RingServiceClient pusher,
|
||||
SubscriptionService subscriptions,
|
||||
Pass.Leveling.ExperienceService experienceService
|
||||
)
|
||||
{
|
||||
private static readonly Random Random = new();
|
||||
@@ -327,13 +328,15 @@ public class AccountEventService(
|
||||
result.RewardPoints = null;
|
||||
}
|
||||
|
||||
await db.AccountProfiles
|
||||
.Where(p => p.AccountId == user.Id)
|
||||
.ExecuteUpdateAsync(s =>
|
||||
s.SetProperty(b => b.Experience, b => b.Experience + result.RewardExperience)
|
||||
);
|
||||
db.AccountCheckInResults.Add(result);
|
||||
await db.SaveChangesAsync(); // Don't forget to save changes to the database
|
||||
await db.SaveChangesAsync(); // Remember to save changes to the database
|
||||
if (result.RewardExperience is not null)
|
||||
await experienceService.AddRecord(
|
||||
"check-in",
|
||||
$"Check-in reward on {now:yyyy/MM/dd}",
|
||||
result.RewardExperience.Value,
|
||||
user.Id
|
||||
);
|
||||
|
||||
// The lock will be automatically released by the await using statement
|
||||
return result;
|
||||
|
@@ -1,14 +1,20 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Pass.Auth;
|
||||
using DysonNetwork.Pass.Auth.OpenId;
|
||||
using DysonNetwork.Pass.Email;
|
||||
using DysonNetwork.Pass.Localization;
|
||||
using DysonNetwork.Pass.Permission;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Stream;
|
||||
using EFCore.BulkExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Client.JetStream;
|
||||
using NATS.Net;
|
||||
using NodaTime;
|
||||
using OtpNet;
|
||||
using AuthService = DysonNetwork.Pass.Auth.AuthService;
|
||||
@@ -18,12 +24,16 @@ namespace DysonNetwork.Pass.Account;
|
||||
public class AccountService(
|
||||
AppDatabase db,
|
||||
MagicSpellService spells,
|
||||
FileService.FileServiceClient files,
|
||||
FileReferenceService.FileReferenceServiceClient fileRefs,
|
||||
AccountUsernameService uname,
|
||||
EmailService mailer,
|
||||
PusherService.PusherServiceClient pusher,
|
||||
RingService.RingServiceClient pusher,
|
||||
IStringLocalizer<NotificationResource> localizer,
|
||||
IStringLocalizer<EmailResource> emailLocalizer,
|
||||
ICacheService cache,
|
||||
ILogger<AccountService> logger
|
||||
ILogger<AccountService> logger,
|
||||
INatsConnection nats
|
||||
)
|
||||
{
|
||||
public static void SetCultureInfo(Account account)
|
||||
@@ -80,6 +90,7 @@ public class AccountService(
|
||||
string email,
|
||||
string? password,
|
||||
string language = "en-US",
|
||||
string region = "en",
|
||||
bool isEmailVerified = false,
|
||||
bool isActivated = false
|
||||
)
|
||||
@@ -99,6 +110,7 @@ public class AccountService(
|
||||
Name = name,
|
||||
Nick = nick,
|
||||
Language = language,
|
||||
Region = region,
|
||||
Contacts = new List<AccountContact>
|
||||
{
|
||||
new()
|
||||
@@ -173,11 +185,66 @@ public class AccountService(
|
||||
userInfo.Email,
|
||||
null,
|
||||
"en-US",
|
||||
"en",
|
||||
userInfo.EmailVerified,
|
||||
userInfo.EmailVerified
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<Account> CreateBotAccount(Account account, Guid automatedId, string? pictureId,
|
||||
string? backgroundId)
|
||||
{
|
||||
var dupeAutomateCount = await db.Accounts.Where(a => a.AutomatedId == automatedId).CountAsync();
|
||||
if (dupeAutomateCount > 0)
|
||||
throw new InvalidOperationException("Automated ID has already been used.");
|
||||
|
||||
var dupeNameCount = await db.Accounts.Where(a => a.Name == account.Name).CountAsync();
|
||||
if (dupeNameCount > 0)
|
||||
throw new InvalidOperationException("Account name has already been taken.");
|
||||
|
||||
account.AutomatedId = automatedId;
|
||||
account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
account.IsSuperuser = false;
|
||||
|
||||
if (!string.IsNullOrEmpty(pictureId))
|
||||
{
|
||||
var file = await files.GetFileAsync(new GetFileRequest { Id = pictureId });
|
||||
await fileRefs.CreateReferenceAsync(
|
||||
new CreateReferenceRequest
|
||||
{
|
||||
ResourceId = account.Profile.ResourceIdentifier,
|
||||
FileId = pictureId,
|
||||
Usage = "profile.picture"
|
||||
}
|
||||
);
|
||||
account.Profile.Picture = CloudFileReferenceObject.FromProtoValue(file);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(backgroundId))
|
||||
{
|
||||
var file = await files.GetFileAsync(new GetFileRequest { Id = backgroundId });
|
||||
await fileRefs.CreateReferenceAsync(
|
||||
new CreateReferenceRequest
|
||||
{
|
||||
ResourceId = account.Profile.ResourceIdentifier,
|
||||
FileId = backgroundId,
|
||||
Usage = "profile.background"
|
||||
}
|
||||
);
|
||||
account.Profile.Background = CloudFileReferenceObject.FromProtoValue(file);
|
||||
}
|
||||
|
||||
db.Accounts.Add(account);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
public async Task<Account?> GetBotAccount(Guid automatedId)
|
||||
{
|
||||
return await db.Accounts.FirstOrDefaultAsync(a => a.AutomatedId == automatedId);
|
||||
}
|
||||
|
||||
public async Task RequestAccountDeletion(Account account)
|
||||
{
|
||||
var spell = await spells.CreateMagicSpell(
|
||||
@@ -372,12 +439,14 @@ public class AccountService(
|
||||
.Where(c => c.Type == AccountContactType.Email)
|
||||
.Where(c => c.VerifiedAt != null)
|
||||
.Where(c => c.IsPrimary)
|
||||
.Where(c => c.AccountId == account.Id)
|
||||
.Include(c => c.Account)
|
||||
.FirstOrDefaultAsync();
|
||||
if (contact is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Unable to send factor code to #{FactorId} with, due to no contact method was found..."
|
||||
"Unable to send factor code to #{FactorId} with, due to no contact method was found...",
|
||||
factor.Id
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -386,7 +455,7 @@ public class AccountService(
|
||||
.SendTemplatedEmailAsync<Pages.Emails.VerificationEmail, VerificationEmailModel>(
|
||||
account.Nick,
|
||||
contact.Content,
|
||||
localizer["VerificationEmail"],
|
||||
emailLocalizer["VerificationEmail"],
|
||||
new VerificationEmailModel
|
||||
{
|
||||
Name = account.Name,
|
||||
@@ -440,7 +509,7 @@ public class AccountService(
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<bool> IsDeviceActive(Guid id)
|
||||
private async Task<bool> IsDeviceActive(Guid id)
|
||||
{
|
||||
return await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
@@ -449,8 +518,7 @@ public class AccountService(
|
||||
|
||||
public async Task<AuthClient> UpdateDeviceName(Account account, string deviceId, string label)
|
||||
{
|
||||
var device = await db.AuthClients.FirstOrDefaultAsync(
|
||||
c => c.DeviceId == deviceId && c.AccountId == account.Id
|
||||
var device = await db.AuthClients.FirstOrDefaultAsync(c => c.DeviceId == deviceId && c.AccountId == account.Id
|
||||
);
|
||||
if (device is null) throw new InvalidOperationException("Device was not found.");
|
||||
|
||||
@@ -470,54 +538,48 @@ public class AccountService(
|
||||
.FirstOrDefaultAsync();
|
||||
if (session is null) throw new InvalidOperationException("Session was not found.");
|
||||
|
||||
var sessions = await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId)
|
||||
.ToListAsync();
|
||||
// The current session should be included in the sessions' list
|
||||
db.AuthSessions.Remove(session);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (session.Challenge.ClientId.HasValue)
|
||||
{
|
||||
if (!await IsDeviceActive(session.Challenge.ClientId.Value))
|
||||
await pusher.UnsubscribePushNotificationsAsync(new UnsubscribePushNotificationsRequest()
|
||||
{ DeviceId = session.Challenge.Client!.DeviceId }
|
||||
{ DeviceId = session.Challenge.Client!.DeviceId }
|
||||
);
|
||||
}
|
||||
|
||||
// The current session should be included in the sessions' list
|
||||
await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.Where(s => s.Challenge.DeviceId == session.Challenge.DeviceId)
|
||||
.ExecuteDeleteAsync();
|
||||
logger.LogInformation("Deleted session #{SessionId}", session.Id);
|
||||
|
||||
foreach (var item in sessions)
|
||||
await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{item.Id}");
|
||||
await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{session.Id}");
|
||||
}
|
||||
|
||||
public async Task DeleteDevice(Account account, string deviceId)
|
||||
{
|
||||
var device = await db.AuthClients.FirstOrDefaultAsync(
|
||||
c => c.DeviceId == deviceId && c.AccountId == account.Id
|
||||
var device = await db.AuthClients.FirstOrDefaultAsync(c => c.DeviceId == deviceId && c.AccountId == account.Id
|
||||
);
|
||||
if (device is null)
|
||||
throw new InvalidOperationException("Device not found.");
|
||||
|
||||
await pusher.UnsubscribePushNotificationsAsync(
|
||||
new UnsubscribePushNotificationsRequest() { DeviceId = device.DeviceId }
|
||||
new UnsubscribePushNotificationsRequest { DeviceId = device.DeviceId }
|
||||
);
|
||||
|
||||
db.AuthClients.Remove(device);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var sessions = await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.Where(s => s.Challenge.ClientId == device.Id)
|
||||
.Where(s => s.Challenge.ClientId == device.Id && s.AccountId == account.Id)
|
||||
.ToListAsync();
|
||||
|
||||
// The current session should be included in the sessions' list
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.Where(s => s.Challenge.DeviceId == device.DeviceId)
|
||||
.ExecuteDeleteAsync();
|
||||
.Where(s => s.Challenge.ClientId == device.Id)
|
||||
.ExecuteUpdateAsync(p => p.SetProperty(s => s.DeletedAt, s => now));
|
||||
|
||||
db.AuthClients.Remove(device);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
foreach (var item in sessions)
|
||||
await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{item.Id}");
|
||||
@@ -672,21 +734,23 @@ public class AccountService(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The maintenance method for server administrator.
|
||||
/// To check every user has an account profile and to create them if it isn't having one.
|
||||
/// </summary>
|
||||
public async Task EnsureAccountProfileCreated()
|
||||
public async Task DeleteAccount(Account account)
|
||||
{
|
||||
var accountsId = await db.Accounts.Select(a => a.Id).ToListAsync();
|
||||
var existingId = await db.AccountProfiles.Select(p => p.AccountId).ToListAsync();
|
||||
var missingId = accountsId.Except(existingId).ToList();
|
||||
await db.AuthSessions
|
||||
.Where(s => s.AccountId == account.Id)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
if (missingId.Count != 0)
|
||||
{
|
||||
var newProfiles = missingId.Select(id => new AccountProfile { Id = Guid.NewGuid(), AccountId = id })
|
||||
.ToList();
|
||||
await db.BulkInsertAsync(newProfiles);
|
||||
}
|
||||
db.Accounts.Remove(account);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var js = nats.CreateJetStreamContext();
|
||||
await js.PublishAsync(
|
||||
AccountDeletedEvent.Type,
|
||||
GrpcTypeHelper.ConvertObjectToByteString(new AccountDeletedEvent
|
||||
{
|
||||
AccountId = account.Id,
|
||||
DeletedAt = SystemClock.Instance.GetCurrentInstant()
|
||||
}).ToByteArray()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@@ -42,6 +42,26 @@ public class AccountServiceGrpc(
|
||||
return account.ToProtoValue();
|
||||
}
|
||||
|
||||
public override async Task<Shared.Proto.Account> GetBotAccount(GetBotAccountRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
if (!Guid.TryParse(request.AutomatedId, out var automatedId))
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid automated ID format"));
|
||||
|
||||
var account = await _db.Accounts
|
||||
.AsNoTracking()
|
||||
.Include(a => a.Profile)
|
||||
.FirstOrDefaultAsync(a => a.AutomatedId == automatedId);
|
||||
|
||||
if (account == null)
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Account with automated ID {request.AutomatedId} not found"));
|
||||
|
||||
var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id);
|
||||
account.PerkSubscription = perk?.ToReference();
|
||||
|
||||
return account.ToProtoValue();
|
||||
}
|
||||
|
||||
public override async Task<GetAccountBatchResponse> GetAccountBatch(GetAccountBatchRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
@@ -56,7 +76,35 @@ public class AccountServiceGrpc(
|
||||
.Where(a => accountIds.Contains(a.Id))
|
||||
.Include(a => a.Profile)
|
||||
.ToListAsync();
|
||||
|
||||
|
||||
var perks = await subscriptions.GetPerkSubscriptionsAsync(
|
||||
accounts.Select(x => x.Id).ToList()
|
||||
);
|
||||
foreach (var account in accounts)
|
||||
if (perks.TryGetValue(account.Id, out var perk))
|
||||
account.PerkSubscription = perk?.ToReference();
|
||||
|
||||
var response = new GetAccountBatchResponse();
|
||||
response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue()));
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
public override async Task<GetAccountBatchResponse> GetBotAccountBatch(GetBotAccountBatchRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
var automatedIds = request.AutomatedId
|
||||
.Select(id => Guid.TryParse(id, out var automatedId) ? automatedId : (Guid?)null)
|
||||
.Where(id => id.HasValue)
|
||||
.Select(id => id!.Value)
|
||||
.ToList();
|
||||
|
||||
var accounts = await _db.Accounts
|
||||
.AsNoTracking()
|
||||
.Where(a => a.AutomatedId != null && automatedIds.Contains(a.AutomatedId.Value))
|
||||
.Include(a => a.Profile)
|
||||
.ToListAsync();
|
||||
|
||||
var perks = await subscriptions.GetPerkSubscriptionsAsync(
|
||||
accounts.Select(x => x.Id).ToList()
|
||||
);
|
||||
@@ -76,7 +124,8 @@ public class AccountServiceGrpc(
|
||||
return status.ToProtoValue();
|
||||
}
|
||||
|
||||
public override async Task<GetAccountStatusBatchResponse> GetAccountStatusBatch(GetAccountBatchRequest request, ServerCallContext context)
|
||||
public override async Task<GetAccountStatusBatchResponse> GetAccountStatusBatch(GetAccountBatchRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
var accountIds = request.Id
|
||||
.Select(id => Guid.TryParse(id, out var accountId) ? accountId : (Guid?)null)
|
||||
@@ -98,14 +147,14 @@ public class AccountServiceGrpc(
|
||||
.Where(a => accountNames.Contains(a.Name))
|
||||
.Include(a => a.Profile)
|
||||
.ToListAsync();
|
||||
|
||||
|
||||
var perks = await subscriptions.GetPerkSubscriptionsAsync(
|
||||
accounts.Select(x => x.Id).ToList()
|
||||
);
|
||||
foreach (var account in accounts)
|
||||
if (perks.TryGetValue(account.Id, out var perk))
|
||||
account.PerkSubscription = perk?.ToReference();
|
||||
|
||||
|
||||
var response = new GetAccountBatchResponse();
|
||||
response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue()));
|
||||
return response;
|
||||
|
@@ -1,6 +1,8 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
using Point = NetTopologySuite.Geometries.Point;
|
||||
@@ -14,7 +16,7 @@ public class ActionLog : ModelBase
|
||||
[Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
|
||||
[MaxLength(512)] public string? UserAgent { get; set; }
|
||||
[MaxLength(128)] public string? IpAddress { get; set; }
|
||||
public Point? Location { get; set; }
|
||||
[Column(TypeName = "jsonb")] public GeoPoint? Location { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
public Account Account { get; set; } = null!;
|
||||
|
218
DysonNetwork.Pass/Account/BotAccountReceiverGrpc.cs
Normal file
218
DysonNetwork.Pass/Account/BotAccountReceiverGrpc.cs
Normal file
@@ -0,0 +1,218 @@
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Grpc.Core;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
using ApiKey = DysonNetwork.Shared.Proto.ApiKey;
|
||||
using AuthService = DysonNetwork.Pass.Auth.AuthService;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
public class BotAccountReceiverGrpc(
|
||||
AppDatabase db,
|
||||
AccountService accounts,
|
||||
FileService.FileServiceClient files,
|
||||
FileReferenceService.FileReferenceServiceClient fileRefs,
|
||||
AuthService authService
|
||||
)
|
||||
: BotAccountReceiverService.BotAccountReceiverServiceBase
|
||||
{
|
||||
public override async Task<CreateBotAccountResponse> CreateBotAccount(
|
||||
CreateBotAccountRequest request,
|
||||
ServerCallContext context
|
||||
)
|
||||
{
|
||||
var account = Account.FromProtoValue(request.Account);
|
||||
account = await accounts.CreateBotAccount(
|
||||
account,
|
||||
Guid.Parse(request.AutomatedId),
|
||||
request.PictureId,
|
||||
request.BackgroundId
|
||||
);
|
||||
|
||||
return new CreateBotAccountResponse
|
||||
{
|
||||
Bot = new BotAccount
|
||||
{
|
||||
Account = account.ToProtoValue(),
|
||||
AutomatedId = account.Id.ToString(),
|
||||
CreatedAt = account.CreatedAt.ToTimestamp(),
|
||||
UpdatedAt = account.UpdatedAt.ToTimestamp(),
|
||||
IsActive = true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<UpdateBotAccountResponse> UpdateBotAccount(
|
||||
UpdateBotAccountRequest request,
|
||||
ServerCallContext context
|
||||
)
|
||||
{
|
||||
var account = Account.FromProtoValue(request.Account);
|
||||
|
||||
if (request.PictureId is not null)
|
||||
{
|
||||
var file = await files.GetFileAsync(new GetFileRequest { Id = request.PictureId });
|
||||
if (account.Profile.Picture is not null)
|
||||
await fileRefs.DeleteResourceReferencesAsync(
|
||||
new DeleteResourceReferencesRequest { ResourceId = account.Profile.ResourceIdentifier }
|
||||
);
|
||||
await fileRefs.CreateReferenceAsync(
|
||||
new CreateReferenceRequest
|
||||
{
|
||||
ResourceId = account.Profile.ResourceIdentifier,
|
||||
FileId = request.PictureId,
|
||||
Usage = "profile.picture"
|
||||
}
|
||||
);
|
||||
account.Profile.Picture = CloudFileReferenceObject.FromProtoValue(file);
|
||||
}
|
||||
|
||||
if (request.BackgroundId is not null)
|
||||
{
|
||||
var file = await files.GetFileAsync(new GetFileRequest { Id = request.BackgroundId });
|
||||
if (account.Profile.Background is not null)
|
||||
await fileRefs.DeleteResourceReferencesAsync(
|
||||
new DeleteResourceReferencesRequest { ResourceId = account.Profile.ResourceIdentifier }
|
||||
);
|
||||
await fileRefs.CreateReferenceAsync(
|
||||
new CreateReferenceRequest
|
||||
{
|
||||
ResourceId = account.Profile.ResourceIdentifier,
|
||||
FileId = request.BackgroundId,
|
||||
Usage = "profile.background"
|
||||
}
|
||||
);
|
||||
account.Profile.Background = CloudFileReferenceObject.FromProtoValue(file);
|
||||
}
|
||||
|
||||
db.Accounts.Update(account);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return new UpdateBotAccountResponse
|
||||
{
|
||||
Bot = new BotAccount
|
||||
{
|
||||
Account = account.ToProtoValue(),
|
||||
AutomatedId = account.Id.ToString(),
|
||||
CreatedAt = account.CreatedAt.ToTimestamp(),
|
||||
UpdatedAt = account.UpdatedAt.ToTimestamp(),
|
||||
IsActive = true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<DeleteBotAccountResponse> DeleteBotAccount(
|
||||
DeleteBotAccountRequest request,
|
||||
ServerCallContext context
|
||||
)
|
||||
{
|
||||
var automatedId = Guid.Parse(request.AutomatedId);
|
||||
var account = await accounts.GetBotAccount(automatedId);
|
||||
if (account is null)
|
||||
throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.NotFound, "Account not found"));
|
||||
|
||||
await accounts.DeleteAccount(account);
|
||||
|
||||
return new DeleteBotAccountResponse();
|
||||
}
|
||||
|
||||
public override async Task<ApiKey> GetApiKey(GetApiKeyRequest request, ServerCallContext context)
|
||||
{
|
||||
var keyId = Guid.Parse(request.Id);
|
||||
var key = await db.ApiKeys
|
||||
.Include(k => k.Account)
|
||||
.FirstOrDefaultAsync(k => k.Id == keyId);
|
||||
|
||||
if (key == null)
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found"));
|
||||
|
||||
return key.ToProtoValue();
|
||||
}
|
||||
|
||||
public override async Task<GetApiKeyBatchResponse> ListApiKey(ListApiKeyRequest request, ServerCallContext context)
|
||||
{
|
||||
var automatedId = Guid.Parse(request.AutomatedId);
|
||||
var account = await accounts.GetBotAccount(automatedId);
|
||||
if (account == null)
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found"));
|
||||
|
||||
var keys = await db.ApiKeys
|
||||
.Where(k => k.AccountId == account.Id)
|
||||
.Select(k => k.ToProtoValue())
|
||||
.ToListAsync();
|
||||
|
||||
var response = new GetApiKeyBatchResponse();
|
||||
response.Data.AddRange(keys);
|
||||
return response;
|
||||
}
|
||||
|
||||
public override async Task<ApiKey> CreateApiKey(ApiKey request, ServerCallContext context)
|
||||
{
|
||||
var accountId = Guid.Parse(request.AccountId);
|
||||
var account = await accounts.GetBotAccount(accountId);
|
||||
if (account == null)
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found"));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Label))
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Label is required"));
|
||||
|
||||
var key = await authService.CreateApiKey(account.Id, request.Label, null);
|
||||
key.Key = await authService.IssueApiKeyToken(key);
|
||||
|
||||
return key.ToProtoValue();
|
||||
}
|
||||
|
||||
public override async Task<ApiKey> UpdateApiKey(ApiKey request, ServerCallContext context)
|
||||
{
|
||||
var keyId = Guid.Parse(request.Id);
|
||||
var accountId = Guid.Parse(request.AccountId);
|
||||
|
||||
var key = await db.ApiKeys
|
||||
.Include(k => k.Session)
|
||||
.Where(k => k.Id == keyId && k.AccountId == accountId)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (key == null)
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found"));
|
||||
|
||||
// Only update the label if provided
|
||||
if (string.IsNullOrWhiteSpace(request.Label)) return key.ToProtoValue();
|
||||
key.Label = request.Label;
|
||||
db.ApiKeys.Update(key);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return key.ToProtoValue();
|
||||
}
|
||||
|
||||
public override async Task<ApiKey> RotateApiKey(GetApiKeyRequest request, ServerCallContext context)
|
||||
{
|
||||
var keyId = Guid.Parse(request.Id);
|
||||
var key = await db.ApiKeys
|
||||
.Include(k => k.Session)
|
||||
.FirstOrDefaultAsync(k => k.Id == keyId);
|
||||
|
||||
if (key == null)
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found"));
|
||||
|
||||
key = await authService.RotateApiKeyToken(key);
|
||||
key.Key = await authService.IssueApiKeyToken(key);
|
||||
|
||||
return key.ToProtoValue();
|
||||
}
|
||||
|
||||
public override async Task<DeleteApiKeyResponse> DeleteApiKey(GetApiKeyRequest request, ServerCallContext context)
|
||||
{
|
||||
var keyId = Guid.Parse(request.Id);
|
||||
var key = await db.ApiKeys
|
||||
.Include(k => k.Session)
|
||||
.FirstOrDefaultAsync(k => k.Id == keyId);
|
||||
|
||||
if (key == null)
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found"));
|
||||
|
||||
await authService.RevokeApiKeyToken(key);
|
||||
|
||||
return new DeleteApiKeyResponse { Success = true };
|
||||
}
|
||||
}
|
@@ -23,6 +23,12 @@ public class Status : ModelBase
|
||||
public bool IsNotDisturb { get; set; }
|
||||
[MaxLength(1024)] public string? Label { get; set; }
|
||||
public Instant? ClearedAt { get; set; }
|
||||
[MaxLength(4096)] public string? AppIdentifier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates this status is created based on running process or rich presence
|
||||
/// </summary>
|
||||
public bool IsAutomated { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
public Account Account { get; set; } = null!;
|
||||
|
@@ -3,6 +3,7 @@ using System.Text.Json;
|
||||
using DysonNetwork.Pass.Email;
|
||||
using DysonNetwork.Pass.Pages.Emails;
|
||||
using DysonNetwork.Pass.Permission;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using NodaTime;
|
||||
@@ -15,7 +16,8 @@ public class MagicSpellService(
|
||||
IConfiguration configuration,
|
||||
ILogger<MagicSpellService> logger,
|
||||
IStringLocalizer<EmailResource> localizer,
|
||||
EmailService email
|
||||
EmailService email,
|
||||
ICacheService cache
|
||||
)
|
||||
{
|
||||
public async Task<MagicSpell> CreateMagicSpell(
|
||||
@@ -35,11 +37,8 @@ public class MagicSpellService(
|
||||
.Where(s => s.Type == type)
|
||||
.Where(s => s.ExpiresAt == null || s.ExpiresAt > now)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (existingSpell != null)
|
||||
{
|
||||
throw new InvalidOperationException($"Account already has an active magic spell of type {type}");
|
||||
}
|
||||
if (existingSpell is not null)
|
||||
return existingSpell;
|
||||
}
|
||||
|
||||
var spellWord = _GenerateRandomString(128);
|
||||
@@ -59,8 +58,18 @@ public class MagicSpellService(
|
||||
return spell;
|
||||
}
|
||||
|
||||
private const string SpellNotifyCacheKeyPrefix = "spells:notify:";
|
||||
|
||||
public async Task NotifyMagicSpell(MagicSpell spell, bool bypassVerify = false)
|
||||
{
|
||||
var cacheKey = SpellNotifyCacheKeyPrefix + spell.Id;
|
||||
var (found, _) = await cache.GetAsyncWithStatus<bool?>(cacheKey);
|
||||
if (found)
|
||||
{
|
||||
logger.LogInformation("Skip sending magic spell {SpellId} due to already sent.", spell.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
var contact = await db.AccountContacts
|
||||
.Where(c => c.Account.Id == spell.AccountId)
|
||||
.Where(c => c.Type == AccountContactType.Email)
|
||||
@@ -112,7 +121,7 @@ public class MagicSpellService(
|
||||
await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>(
|
||||
contact.Account.Nick,
|
||||
contact.Content,
|
||||
localizer["EmailAccountDeletionTitle"],
|
||||
localizer["EmailPasswordResetTitle"],
|
||||
new PasswordResetEmailModel
|
||||
{
|
||||
Name = contact.Account.Name,
|
||||
@@ -138,6 +147,8 @@ public class MagicSpellService(
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
|
||||
await cache.SetAsync(cacheKey, true, TimeSpan.FromMinutes(5));
|
||||
}
|
||||
catch (Exception err)
|
||||
{
|
||||
|
53
DysonNetwork.Pass/Account/NotableDay.cs
Normal file
53
DysonNetwork.Pass/Account/NotableDay.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using Nager.Holiday;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
/// <summary>
|
||||
/// Reference from Nager.Holiday
|
||||
/// </summary>
|
||||
public enum NotableHolidayType
|
||||
{
|
||||
/// <summary>Public holiday</summary>
|
||||
Public,
|
||||
/// <summary>Bank holiday, banks and offices are closed</summary>
|
||||
Bank,
|
||||
/// <summary>School holiday, schools are closed</summary>
|
||||
School,
|
||||
/// <summary>Authorities are closed</summary>
|
||||
Authorities,
|
||||
/// <summary>Majority of people take a day off</summary>
|
||||
Optional,
|
||||
/// <summary>Optional festivity, no paid day off</summary>
|
||||
Observance,
|
||||
}
|
||||
|
||||
|
||||
public class NotableDay
|
||||
{
|
||||
public Instant Date { get; set; }
|
||||
public string? LocalName { get; set; }
|
||||
public string? GlobalName { get; set; }
|
||||
public string? CountryCode { get; set; }
|
||||
public NotableHolidayType[] Holidays { get; set; } = [];
|
||||
|
||||
public static NotableDay FromNagerHoliday(PublicHoliday holiday)
|
||||
{
|
||||
return new NotableDay()
|
||||
{
|
||||
Date = Instant.FromDateTimeUtc(holiday.Date.ToUniversalTime()),
|
||||
LocalName = holiday.LocalName,
|
||||
GlobalName = holiday.Name,
|
||||
CountryCode = holiday.CountryCode,
|
||||
Holidays = holiday.Types?.Select(x => x switch
|
||||
{
|
||||
PublicHolidayType.Public => NotableHolidayType.Public,
|
||||
PublicHolidayType.Bank => NotableHolidayType.Bank,
|
||||
PublicHolidayType.School => NotableHolidayType.School,
|
||||
PublicHolidayType.Authorities => NotableHolidayType.Authorities,
|
||||
PublicHolidayType.Optional => NotableHolidayType.Optional,
|
||||
_ => NotableHolidayType.Observance
|
||||
}).ToArray() ?? [],
|
||||
};
|
||||
}
|
||||
}
|
79
DysonNetwork.Pass/Account/NotableDaysController.cs
Normal file
79
DysonNetwork.Pass/Account/NotableDaysController.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/notable")]
|
||||
public class NotableDaysController(NotableDaysService days) : ControllerBase
|
||||
{
|
||||
[HttpGet("{regionCode}/{year:int}")]
|
||||
public async Task<ActionResult<List<NotableDay>>> GetRegionDays(string regionCode, int year)
|
||||
{
|
||||
var result = await days.GetNotableDays(year, regionCode);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{regionCode}")]
|
||||
public async Task<ActionResult<List<NotableDay>>> GetRegionDaysCurrentYear(string regionCode)
|
||||
{
|
||||
var currentYear = DateTime.Now.Year;
|
||||
var result = await days.GetNotableDays(currentYear, regionCode);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("me/{year:int}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<NotableDay>>> GetAccountNotableDays(int year)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
var region = currentUser.Region;
|
||||
if (string.IsNullOrWhiteSpace(region)) region = "us";
|
||||
|
||||
var result = await days.GetNotableDays(year, region);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<NotableDay>>> GetAccountNotableDaysCurrentYear()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
var currentYear = DateTime.Now.Year;
|
||||
var region = currentUser.Region;
|
||||
if (string.IsNullOrWhiteSpace(region)) region = "us";
|
||||
|
||||
var result = await days.GetNotableDays(currentYear, region);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{regionCode}/next")]
|
||||
public async Task<ActionResult<NotableDay?>> GetNextHoliday(string regionCode)
|
||||
{
|
||||
var result = await days.GetNextHoliday(regionCode);
|
||||
if (result == null)
|
||||
{
|
||||
return NotFound("No upcoming holidays found");
|
||||
}
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("me/next")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<NotableDay?>> GetAccountNextHoliday()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
var region = currentUser.Region;
|
||||
if (string.IsNullOrWhiteSpace(region)) region = "us";
|
||||
|
||||
var result = await days.GetNextHoliday(region);
|
||||
if (result == null)
|
||||
{
|
||||
return NotFound("No upcoming holidays found");
|
||||
}
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
55
DysonNetwork.Pass/Account/NotableDaysService.cs
Normal file
55
DysonNetwork.Pass/Account/NotableDaysService.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using Nager.Holiday;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
public class NotableDaysService(ICacheService cache)
|
||||
{
|
||||
private const string NotableDaysCacheKeyPrefix = "notable:";
|
||||
|
||||
public async Task<List<NotableDay>> GetNotableDays(int? year, string regionCode)
|
||||
{
|
||||
year ??= DateTime.UtcNow.Year;
|
||||
|
||||
// Generate cache key using year and region code
|
||||
var cacheKey = $"{NotableDaysCacheKeyPrefix}:{year}:{regionCode}";
|
||||
|
||||
// Try to get from cache first
|
||||
var (found, cachedDays) = await cache.GetAsyncWithStatus<List<NotableDay>>(cacheKey);
|
||||
if (found && cachedDays != null)
|
||||
{
|
||||
return cachedDays;
|
||||
}
|
||||
|
||||
// If not in cache, fetch from API
|
||||
using var holidayClient = new HolidayClient();
|
||||
var holidays = await holidayClient.GetHolidaysAsync(year.Value, regionCode);
|
||||
var days = holidays?.Select(NotableDay.FromNagerHoliday).ToList() ?? [];
|
||||
|
||||
// Cache the result for 1 day (holiday data doesn't change frequently)
|
||||
await cache.SetAsync(cacheKey, days, TimeSpan.FromDays(1));
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
public async Task<NotableDay?> GetNextHoliday(string regionCode)
|
||||
{
|
||||
var currentDate = SystemClock.Instance.GetCurrentInstant();
|
||||
var currentYear = currentDate.InUtc().Year;
|
||||
|
||||
// Get holidays for current year and next year to cover all possibilities
|
||||
var currentYearHolidays = await GetNotableDays(currentYear, regionCode);
|
||||
var nextYearHolidays = await GetNotableDays(currentYear + 1, regionCode);
|
||||
|
||||
var allHolidays = currentYearHolidays.Concat(nextYearHolidays);
|
||||
|
||||
// Find the first holiday that is today or in the future
|
||||
var nextHoliday = allHolidays
|
||||
.Where(day => day.Date >= currentDate)
|
||||
.OrderBy(day => day.Date)
|
||||
.FirstOrDefault();
|
||||
|
||||
return nextHoliday;
|
||||
}
|
||||
}
|
@@ -1,14 +1,22 @@
|
||||
using DysonNetwork.Pass.Localization;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
public class RelationshipService(AppDatabase db, ICacheService cache)
|
||||
public class RelationshipService(
|
||||
AppDatabase db,
|
||||
ICacheService cache,
|
||||
RingService.RingServiceClient pusher,
|
||||
IStringLocalizer<NotificationResource> localizer
|
||||
)
|
||||
{
|
||||
private const string UserFriendsCacheKeyPrefix = "accounts:friends:";
|
||||
private const string UserBlockedCacheKeyPrefix = "accounts:blocked:";
|
||||
|
||||
|
||||
public async Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId)
|
||||
{
|
||||
var count = await db.AccountRelationships
|
||||
@@ -51,7 +59,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
||||
|
||||
db.AccountRelationships.Add(relationship);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
|
||||
await PurgeRelationshipCache(sender.Id, target.Id);
|
||||
|
||||
return relationship;
|
||||
@@ -63,16 +71,16 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
||||
return await UpdateRelationship(sender.Id, target.Id, RelationshipStatus.Blocked);
|
||||
return await CreateRelationship(sender, target, RelationshipStatus.Blocked);
|
||||
}
|
||||
|
||||
|
||||
public async Task<Relationship> UnblockAccount(Account sender, Account target)
|
||||
{
|
||||
var relationship = await GetRelationship(sender.Id, target.Id, RelationshipStatus.Blocked);
|
||||
if (relationship is null) throw new ArgumentException("There is no relationship between you and the user.");
|
||||
db.Remove(relationship);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
|
||||
await PurgeRelationshipCache(sender.Id, target.Id);
|
||||
|
||||
|
||||
return relationship;
|
||||
}
|
||||
|
||||
@@ -92,21 +100,34 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
||||
db.AccountRelationships.Add(relationship);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await pusher.SendPushNotificationToUserAsync(new SendPushNotificationToUserRequest
|
||||
{
|
||||
UserId = target.Id.ToString(),
|
||||
Notification = new PushNotification
|
||||
{
|
||||
Topic = "relationships.friends.request",
|
||||
Title = localizer["FriendRequestTitle", sender.Nick],
|
||||
Body = localizer["FriendRequestBody"],
|
||||
ActionUri = "/account/relationships",
|
||||
IsSavable = true
|
||||
}
|
||||
});
|
||||
|
||||
return relationship;
|
||||
}
|
||||
|
||||
|
||||
public async Task DeleteFriendRequest(Guid accountId, Guid relatedId)
|
||||
{
|
||||
var relationship = await GetRelationship(accountId, relatedId, RelationshipStatus.Pending);
|
||||
if (relationship is null) throw new ArgumentException("Friend request was not found.");
|
||||
|
||||
|
||||
await db.AccountRelationships
|
||||
.Where(r => r.AccountId == accountId && r.RelatedId == relatedId && r.Status == RelationshipStatus.Pending)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
|
||||
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
|
||||
}
|
||||
|
||||
|
||||
public async Task<Relationship> AcceptFriendRelationship(
|
||||
Relationship relationship,
|
||||
RelationshipStatus status = RelationshipStatus.Friends
|
||||
@@ -146,9 +167,9 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
||||
relationship.Status = status;
|
||||
db.Update(relationship);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
|
||||
await PurgeRelationshipCache(accountId, relatedId);
|
||||
|
||||
|
||||
return relationship;
|
||||
}
|
||||
|
||||
@@ -161,7 +182,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
||||
{
|
||||
var cacheKey = $"{UserFriendsCacheKeyPrefix}{accountId}";
|
||||
var friends = await cache.GetAsync<List<Guid>>(cacheKey);
|
||||
|
||||
|
||||
if (friends == null)
|
||||
{
|
||||
friends = await db.AccountRelationships
|
||||
@@ -169,23 +190,23 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
||||
.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(Account account)
|
||||
{
|
||||
return await ListAccountBlocked(account.Id);
|
||||
}
|
||||
|
||||
|
||||
public async Task<List<Guid>> ListAccountBlocked(Guid accountId)
|
||||
{
|
||||
var cacheKey = $"{UserBlockedCacheKeyPrefix}{accountId}";
|
||||
var blocked = await cache.GetAsync<List<Guid>>(cacheKey);
|
||||
|
||||
|
||||
if (blocked == null)
|
||||
{
|
||||
blocked = await db.AccountRelationships
|
||||
@@ -193,7 +214,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
||||
.Where(r => r.Status == RelationshipStatus.Blocked)
|
||||
.Select(r => r.AccountId)
|
||||
.ToListAsync();
|
||||
|
||||
|
||||
await cache.SetAsync(cacheKey, blocked, TimeSpan.FromHours(1));
|
||||
}
|
||||
|
||||
@@ -206,7 +227,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
||||
var relationship = await GetRelationship(accountId, relatedId, status);
|
||||
return relationship is not null;
|
||||
}
|
||||
|
||||
|
||||
private async Task PurgeRelationshipCache(Guid accountId, Guid relatedId)
|
||||
{
|
||||
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}");
|
||||
|
@@ -1,7 +1,11 @@
|
||||
using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Pass.Account;
|
||||
using DysonNetwork.Pass.Auth;
|
||||
using DysonNetwork.Pass.Credit;
|
||||
using DysonNetwork.Pass.Leveling;
|
||||
using DysonNetwork.Pass.Permission;
|
||||
using DysonNetwork.Pass.Wallet;
|
||||
using DysonNetwork.Shared.Data;
|
||||
@@ -38,6 +42,7 @@ public class AppDatabase(
|
||||
public DbSet<AuthSession> AuthSessions { get; set; } = null!;
|
||||
public DbSet<AuthChallenge> AuthChallenges { get; set; } = null!;
|
||||
public DbSet<AuthClient> AuthClients { get; set; } = null!;
|
||||
public DbSet<ApiKey> ApiKeys { get; set; } = null!;
|
||||
|
||||
public DbSet<Wallet.Wallet> Wallets { get; set; } = null!;
|
||||
public DbSet<WalletPocket> WalletPockets { get; set; } = null!;
|
||||
@@ -48,14 +53,22 @@ public class AppDatabase(
|
||||
|
||||
public DbSet<Punishment> Punishments { get; set; } = null!;
|
||||
|
||||
public DbSet<SocialCreditRecord> SocialCreditRecords { get; set; } = null!;
|
||||
public DbSet<ExperienceRecord> ExperienceRecords { get; set; } = null!;
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
optionsBuilder.UseNpgsql(
|
||||
configuration.GetConnectionString("App"),
|
||||
opt => opt
|
||||
.ConfigureDataSource(optSource => optSource.EnableDynamicJson())
|
||||
.ConfigureDataSource(optSource => optSource
|
||||
.EnableDynamicJson()
|
||||
.ConfigureJsonOptions(new JsonSerializerOptions()
|
||||
{
|
||||
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
|
||||
})
|
||||
)
|
||||
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
|
||||
.UseNetTopologySuite()
|
||||
.UseNodaTime()
|
||||
).UseSnakeCaseNamingConvention();
|
||||
|
||||
@@ -270,4 +283,4 @@ public static class OptionalQueryExtensions
|
||||
{
|
||||
return condition ? transform(source) : source;
|
||||
}
|
||||
}
|
||||
}
|
50
DysonNetwork.Pass/Auth/ApiKey.cs
Normal file
50
DysonNetwork.Pass/Auth/ApiKey.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
|
||||
namespace DysonNetwork.Pass.Auth;
|
||||
|
||||
public class ApiKey : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(1024)] public string Label { get; set; } = null!;
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
public Account.Account Account { get; set; } = null!;
|
||||
public Guid SessionId { get; set; }
|
||||
public AuthSession Session { get; set; } = null!;
|
||||
|
||||
[NotMapped]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Key { get; set; }
|
||||
|
||||
public DysonNetwork.Shared.Proto.ApiKey ToProtoValue()
|
||||
{
|
||||
return new DysonNetwork.Shared.Proto.ApiKey
|
||||
{
|
||||
Id = Id.ToString(),
|
||||
Label = Label,
|
||||
AccountId = AccountId.ToString(),
|
||||
SessionId = SessionId.ToString(),
|
||||
Key = Key,
|
||||
CreatedAt = CreatedAt.ToTimestamp(),
|
||||
UpdatedAt = UpdatedAt.ToTimestamp()
|
||||
};
|
||||
}
|
||||
|
||||
public static ApiKey FromProtoValue(DysonNetwork.Shared.Proto.ApiKey proto)
|
||||
{
|
||||
return new ApiKey
|
||||
{
|
||||
Id = Guid.Parse(proto.Id),
|
||||
AccountId = Guid.Parse(proto.AccountId),
|
||||
SessionId = Guid.Parse(proto.SessionId),
|
||||
Label = proto.Label,
|
||||
Key = proto.Key,
|
||||
CreatedAt = proto.CreatedAt.ToInstant(),
|
||||
UpdatedAt = proto.UpdatedAt.ToInstant()
|
||||
};
|
||||
}
|
||||
}
|
90
DysonNetwork.Pass/Auth/ApiKeyController.cs
Normal file
90
DysonNetwork.Pass/Auth/ApiKeyController.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Auth;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/auth/keys")]
|
||||
public class ApiKeyController(AppDatabase db, AuthService auth) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GetKeys([FromQuery] int offset = 0, [FromQuery] int take = 20)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
var query = db.ApiKeys
|
||||
.Where(e => e.AccountId == currentUser.Id)
|
||||
.AsQueryable();
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
Response.Headers["X-Total"] = totalCount.ToString();
|
||||
|
||||
var keys = await query
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(keys);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GetKey(Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
var key = await db.ApiKeys
|
||||
.Where(e => e.AccountId == currentUser.Id)
|
||||
.Where(e => e.Id == id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (key == null) return NotFound();
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
public class ApiKeyRequest
|
||||
{
|
||||
[MaxLength(1024)] public string? Label { get; set; }
|
||||
public Instant? ExpiredAt { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CreateKey([FromBody] ApiKeyRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Label))
|
||||
return BadRequest("Label is required");
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
var key = await auth.CreateApiKey(currentUser.Id, request.Label, request.ExpiredAt);
|
||||
key.Key = await auth.IssueApiKeyToken(key);
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/rotate")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> RotateKey(Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
var key = await auth.GetApiKey(id, currentUser.Id);
|
||||
if(key is null) return NotFound();
|
||||
key = await auth.RotateApiKeyToken(key);
|
||||
key.Key = await auth.IssueApiKeyToken(key);
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> DeleteKey(Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
var key = await auth.GetApiKey(id, currentUser.Id);
|
||||
if(key is null) return NotFound();
|
||||
await auth.RevokeApiKeyToken(key);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
@@ -49,7 +49,10 @@ public class DysonTokenAuthHandler(
|
||||
|
||||
try
|
||||
{
|
||||
var (valid, session, message) = await token.AuthenticateTokenAsync(tokenInfo.Token);
|
||||
// Get client IP address
|
||||
var ipAddress = Context.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
var (valid, session, message) = await token.AuthenticateTokenAsync(tokenInfo.Token, ipAddress);
|
||||
if (!valid || session is null)
|
||||
return AuthenticateResult.Fail(message ?? "Authentication failed.");
|
||||
|
||||
@@ -67,7 +70,7 @@ public class DysonTokenAuthHandler(
|
||||
};
|
||||
|
||||
// Add scopes as claims
|
||||
session.Challenge.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope)));
|
||||
session.Challenge?.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope)));
|
||||
|
||||
// Add superuser claim if applicable
|
||||
if (session.Account.IsSuperuser)
|
||||
|
@@ -22,7 +22,7 @@ public class AuthController(
|
||||
AuthService auth,
|
||||
GeoIpService geo,
|
||||
ActionLogService als,
|
||||
PusherService.PusherServiceClient pusher,
|
||||
RingService.RingServiceClient pusher,
|
||||
IConfiguration configuration,
|
||||
IStringLocalizer<NotificationResource> localizer
|
||||
) : ControllerBase
|
||||
@@ -51,7 +51,11 @@ public class AuthController(
|
||||
.Where(e => e.Type == PunishmentType.BlockLogin || e.Type == PunishmentType.DisableAccount)
|
||||
.Where(e => e.ExpiredAt == null || now < e.ExpiredAt)
|
||||
.FirstOrDefaultAsync();
|
||||
if (punishment is not null) return StatusCode(423, punishment);
|
||||
if (punishment is not null)
|
||||
return StatusCode(
|
||||
423,
|
||||
$"Your account has been suspended. Reason: {punishment.Reason}. Expired at: {punishment.ExpiredAt?.ToString() ?? "never"}"
|
||||
);
|
||||
|
||||
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
|
||||
|
@@ -1,5 +1,6 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Pass.Account;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -52,7 +53,7 @@ public class AuthService(
|
||||
riskScore += 1;
|
||||
else
|
||||
{
|
||||
if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge.IpAddress) &&
|
||||
if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge?.IpAddress) &&
|
||||
!lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase))
|
||||
riskScore += 1;
|
||||
}
|
||||
@@ -137,6 +138,7 @@ public class AuthService(
|
||||
|
||||
var jsonOpts = new JsonSerializerOptions
|
||||
{
|
||||
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
};
|
||||
@@ -211,8 +213,7 @@ public class AuthService(
|
||||
var session = new AuthSession
|
||||
{
|
||||
LastGrantedAt = now,
|
||||
// Never expire server-side
|
||||
ExpiredAt = null,
|
||||
ExpiredAt = now.Plus(Duration.FromDays(7)),
|
||||
AccountId = challenge.AccountId,
|
||||
ChallengeId = challenge.Id
|
||||
};
|
||||
@@ -318,6 +319,87 @@ public class AuthService(
|
||||
return factor.VerifyPassword(pinCode);
|
||||
}
|
||||
|
||||
public async Task<ApiKey?> GetApiKey(Guid id, Guid? accountId = null)
|
||||
{
|
||||
var key = await db.ApiKeys
|
||||
.Include(e => e.Session)
|
||||
.Where(e => e.Id == id)
|
||||
.If(accountId.HasValue, q => q.Where(e => e.AccountId == accountId!.Value))
|
||||
.FirstOrDefaultAsync();
|
||||
return key;
|
||||
}
|
||||
|
||||
public async Task<ApiKey> CreateApiKey(Guid accountId, string label, Instant? expiredAt = null)
|
||||
{
|
||||
var key = new ApiKey
|
||||
{
|
||||
AccountId = accountId,
|
||||
Label = label,
|
||||
Session = new AuthSession
|
||||
{
|
||||
AccountId = accountId,
|
||||
ExpiredAt = expiredAt
|
||||
},
|
||||
};
|
||||
|
||||
db.ApiKeys.Add(key);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
public async Task<string> IssueApiKeyToken(ApiKey key)
|
||||
{
|
||||
key.Session.LastGrantedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
db.Update(key.Session);
|
||||
await db.SaveChangesAsync();
|
||||
var tk = CreateToken(key.Session);
|
||||
return tk;
|
||||
}
|
||||
|
||||
public async Task RevokeApiKeyToken(ApiKey key)
|
||||
{
|
||||
db.Remove(key);
|
||||
db.Remove(key.Session);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<ApiKey> RotateApiKeyToken(ApiKey key)
|
||||
{
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
var oldSessionId = key.SessionId;
|
||||
|
||||
// Create new session
|
||||
var newSession = new AuthSession
|
||||
{
|
||||
AccountId = key.AccountId,
|
||||
ExpiredAt = key.Session?.ExpiredAt
|
||||
};
|
||||
|
||||
db.AuthSessions.Add(newSession);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Update ApiKey to point to new session
|
||||
key.SessionId = newSession.Id;
|
||||
key.Session = newSession;
|
||||
db.ApiKeys.Update(key);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Delete old session
|
||||
await db.AuthSessions.Where(s => s.Id == oldSessionId).ExecuteDeleteAsync();
|
||||
|
||||
await transaction.CommitAsync();
|
||||
return key;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods for Base64Url encoding/decoding
|
||||
private static string Base64UrlEncode(byte[] data)
|
||||
{
|
||||
@@ -329,7 +411,7 @@ public class AuthService(
|
||||
|
||||
private static byte[] Base64UrlDecode(string base64Url)
|
||||
{
|
||||
string padded = base64Url
|
||||
var padded = base64Url
|
||||
.Replace('-', '+')
|
||||
.Replace('_', '/');
|
||||
|
||||
|
@@ -1,9 +1,5 @@
|
||||
using DysonNetwork.Pass.Wallet;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Grpc.Core;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Auth;
|
||||
|
||||
@@ -18,7 +14,7 @@ public class AuthServiceGrpc(
|
||||
ServerCallContext context
|
||||
)
|
||||
{
|
||||
var (valid, session, message) = await token.AuthenticateTokenAsync(request.Token);
|
||||
var (valid, session, message) = await token.AuthenticateTokenAsync(request.Token, request.IpAddress);
|
||||
if (!valid || session is null)
|
||||
return new AuthenticateResponse { Valid = false, Message = message ?? "Authentication failed." };
|
||||
|
||||
|
@@ -2,7 +2,7 @@ using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
using Point = NetTopologySuite.Geometries.Point;
|
||||
@@ -12,26 +12,28 @@ namespace DysonNetwork.Pass.Auth;
|
||||
public class AuthSession : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(1024)] public string? Label { get; set; }
|
||||
public Instant? LastGrantedAt { get; set; }
|
||||
public Instant? ExpiredAt { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
||||
public Guid ChallengeId { get; set; }
|
||||
public AuthChallenge Challenge { get; set; } = null!;
|
||||
|
||||
// When the challenge is null, indicates the session is for an API Key
|
||||
public Guid? ChallengeId { get; set; }
|
||||
public AuthChallenge? Challenge { get; set; } = null!;
|
||||
|
||||
// Indicates the session is for an OIDC connection
|
||||
public Guid? AppId { get; set; }
|
||||
|
||||
public Shared.Proto.AuthSession ToProtoValue() => new()
|
||||
{
|
||||
Id = Id.ToString(),
|
||||
Label = Label,
|
||||
LastGrantedAt = LastGrantedAt?.ToTimestamp(),
|
||||
ExpiredAt = ExpiredAt?.ToTimestamp(),
|
||||
AccountId = AccountId.ToString(),
|
||||
Account = Account.ToProtoValue(),
|
||||
ChallengeId = ChallengeId.ToString(),
|
||||
Challenge = Challenge.ToProtoValue(),
|
||||
Challenge = Challenge?.ToProtoValue(),
|
||||
AppId = AppId?.ToString()
|
||||
};
|
||||
}
|
||||
@@ -68,8 +70,7 @@ public class AuthChallenge : ModelBase
|
||||
[MaxLength(128)] public string? IpAddress { get; set; }
|
||||
[MaxLength(512)] public string? UserAgent { get; set; }
|
||||
[MaxLength(1024)] public string? Nonce { get; set; }
|
||||
[MaxLength(1024)] public string? DeviceId { get; set; } = string.Empty;
|
||||
public Point? Location { get; set; }
|
||||
[Column(TypeName = "jsonb")] public GeoPoint? Location { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
||||
@@ -129,4 +130,4 @@ public class AuthClientWithChallenge : AuthClient
|
||||
AccountId = client.AccountId,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
@@ -5,8 +5,10 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Web;
|
||||
using DysonNetwork.Pass.Account;
|
||||
using DysonNetwork.Pass.Auth.OidcProvider.Options;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using NodaTime;
|
||||
@@ -19,10 +21,199 @@ public class OidcProviderController(
|
||||
AppDatabase db,
|
||||
OidcProviderService oidcService,
|
||||
IConfiguration configuration,
|
||||
IOptions<OidcProviderOptions> options
|
||||
)
|
||||
: ControllerBase
|
||||
IOptions<OidcProviderOptions> options,
|
||||
ILogger<OidcProviderController> logger
|
||||
) : ControllerBase
|
||||
{
|
||||
[HttpGet("authorize")]
|
||||
[Produces("application/json")]
|
||||
public async Task<IActionResult> Authorize(
|
||||
[FromQuery(Name = "client_id")] string clientId,
|
||||
[FromQuery(Name = "response_type")] string responseType,
|
||||
[FromQuery(Name = "redirect_uri")] string? redirectUri = null,
|
||||
[FromQuery] string? scope = null,
|
||||
[FromQuery] string? state = null,
|
||||
[FromQuery(Name = "response_mode")] string? responseMode = null,
|
||||
[FromQuery] string? nonce = null,
|
||||
[FromQuery] string? display = null,
|
||||
[FromQuery] string? prompt = null,
|
||||
[FromQuery(Name = "code_challenge")] string? codeChallenge = null,
|
||||
[FromQuery(Name = "code_challenge_method")]
|
||||
string? codeChallengeMethod = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(clientId))
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "invalid_request",
|
||||
ErrorDescription = "client_id is required"
|
||||
});
|
||||
}
|
||||
|
||||
var client = await oidcService.FindClientBySlugAsync(clientId);
|
||||
if (client == null)
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "unauthorized_client",
|
||||
ErrorDescription = "Client not found"
|
||||
});
|
||||
}
|
||||
|
||||
// Validate response_type
|
||||
if (string.IsNullOrEmpty(responseType))
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "invalid_request",
|
||||
ErrorDescription = "response_type is required"
|
||||
});
|
||||
}
|
||||
|
||||
// Check if the client is allowed to use the requested response type
|
||||
var allowedResponseTypes = new[] { "code", "token", "id_token" };
|
||||
var requestedResponseTypes = responseType.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
if (requestedResponseTypes.Any(rt => !allowedResponseTypes.Contains(rt)))
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "unsupported_response_type",
|
||||
ErrorDescription = "The requested response type is not supported"
|
||||
});
|
||||
}
|
||||
|
||||
// Validate redirect_uri if provided
|
||||
if (!string.IsNullOrEmpty(redirectUri) &&
|
||||
!await oidcService.ValidateRedirectUriAsync(Guid.Parse(client.Id), redirectUri))
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "invalid_request",
|
||||
ErrorDescription = "Invalid redirect_uri"
|
||||
});
|
||||
}
|
||||
|
||||
// Return client information
|
||||
var clientInfo = new ClientInfoResponse
|
||||
{
|
||||
ClientId = Guid.Parse(client.Id),
|
||||
Picture = client.Picture is not null ? CloudFileReferenceObject.FromProtoValue(client.Picture) : null,
|
||||
Background = client.Background is not null
|
||||
? CloudFileReferenceObject.FromProtoValue(client.Background)
|
||||
: null,
|
||||
ClientName = client.Name,
|
||||
HomeUri = client.Links.HomePage,
|
||||
PolicyUri = client.Links.PrivacyPolicy,
|
||||
TermsOfServiceUri = client.Links.TermsOfService,
|
||||
ResponseTypes = responseType,
|
||||
Scopes = scope?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? [],
|
||||
State = state,
|
||||
Nonce = nonce,
|
||||
CodeChallenge = codeChallenge,
|
||||
CodeChallengeMethod = codeChallengeMethod
|
||||
};
|
||||
|
||||
return Ok(clientInfo);
|
||||
}
|
||||
|
||||
[HttpPost("authorize")]
|
||||
[Consumes("application/x-www-form-urlencoded")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> HandleAuthorizationResponse(
|
||||
[FromForm(Name = "authorize")] string? authorize,
|
||||
[FromForm(Name = "client_id")] string clientId,
|
||||
[FromForm(Name = "redirect_uri")] string? redirectUri = null,
|
||||
[FromForm] string? scope = null,
|
||||
[FromForm] string? state = null,
|
||||
[FromForm] string? nonce = null,
|
||||
[FromForm(Name = "code_challenge")] string? codeChallenge = null,
|
||||
[FromForm(Name = "code_challenge_method")]
|
||||
string? codeChallengeMethod = null)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account account)
|
||||
return Unauthorized();
|
||||
|
||||
// Find the client
|
||||
var client = await oidcService.FindClientBySlugAsync(clientId);
|
||||
if (client == null)
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "unauthorized_client",
|
||||
ErrorDescription = "Client not found"
|
||||
});
|
||||
}
|
||||
|
||||
// If user denied the request
|
||||
if (string.IsNullOrEmpty(authorize) || !bool.TryParse(authorize, out var isAuthorized) || !isAuthorized)
|
||||
{
|
||||
var errorUri = new UriBuilder(redirectUri ?? client.Links?.HomePage ?? "https://example.com");
|
||||
var queryParams = HttpUtility.ParseQueryString(errorUri.Query);
|
||||
queryParams["error"] = "access_denied";
|
||||
queryParams["error_description"] = "The user denied the authorization request";
|
||||
if (!string.IsNullOrEmpty(state)) queryParams["state"] = state;
|
||||
|
||||
errorUri.Query = queryParams.ToString();
|
||||
return Ok(new { redirectUri = errorUri.Uri.ToString() });
|
||||
}
|
||||
|
||||
// Validate redirect_uri if provided
|
||||
if (!string.IsNullOrEmpty(redirectUri) &&
|
||||
!await oidcService.ValidateRedirectUriAsync(Guid.Parse(client!.Id), redirectUri))
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "invalid_request",
|
||||
ErrorDescription = "Invalid redirect_uri"
|
||||
});
|
||||
}
|
||||
|
||||
// Default to client's first redirect URI if not provided
|
||||
redirectUri ??= client.OauthConfig?.RedirectUris?.FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(redirectUri))
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "invalid_request",
|
||||
ErrorDescription = "No valid redirect_uri available"
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Generate authorization code and create session
|
||||
var authorizationCode = await oidcService.GenerateAuthorizationCodeAsync(
|
||||
Guid.Parse(client.Id),
|
||||
account.Id,
|
||||
redirectUri,
|
||||
scope?.Split(' ') ?? [],
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
nonce
|
||||
);
|
||||
|
||||
// Build the redirect URI with the authorization code
|
||||
var redirectBuilder = new UriBuilder(redirectUri);
|
||||
var queryParams = HttpUtility.ParseQueryString(redirectBuilder.Query);
|
||||
queryParams["code"] = authorizationCode;
|
||||
if (!string.IsNullOrEmpty(state)) queryParams["state"] = state;
|
||||
|
||||
redirectBuilder.Query = queryParams.ToString();
|
||||
|
||||
return Ok(new { redirectUri = redirectBuilder.Uri.ToString() });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error processing authorization request");
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse
|
||||
{
|
||||
Error = "server_error",
|
||||
ErrorDescription = "An error occurred while processing your request"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("token")]
|
||||
[Consumes("application/x-www-form-urlencoded")]
|
||||
public async Task<IActionResult> Token([FromForm] TokenRequest request)
|
||||
@@ -35,74 +226,74 @@ public class OidcProviderController(
|
||||
case "authorization_code" when request.Code == null:
|
||||
return BadRequest("Authorization code is required");
|
||||
case "authorization_code":
|
||||
{
|
||||
var client = await oidcService.FindClientByIdAsync(request.ClientId.Value);
|
||||
if (client == null ||
|
||||
!await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret))
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
var client = await oidcService.FindClientBySlugAsync(request.ClientId);
|
||||
if (client == null ||
|
||||
!await oidcService.ValidateClientCredentialsAsync(Guid.Parse(client.Id), request.ClientSecret))
|
||||
return BadRequest(new ErrorResponse
|
||||
{ Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
|
||||
|
||||
// Generate tokens
|
||||
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
||||
clientId: request.ClientId.Value,
|
||||
authorizationCode: request.Code!,
|
||||
redirectUri: request.RedirectUri,
|
||||
codeVerifier: request.CodeVerifier
|
||||
);
|
||||
// Generate tokens
|
||||
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
||||
clientId: Guid.Parse(client.Id),
|
||||
authorizationCode: request.Code!,
|
||||
redirectUri: request.RedirectUri,
|
||||
codeVerifier: request.CodeVerifier
|
||||
);
|
||||
|
||||
return Ok(tokenResponse);
|
||||
}
|
||||
return Ok(tokenResponse);
|
||||
}
|
||||
case "refresh_token" when string.IsNullOrEmpty(request.RefreshToken):
|
||||
return BadRequest(new ErrorResponse
|
||||
{ Error = "invalid_request", ErrorDescription = "Refresh token is required" });
|
||||
{ Error = "invalid_request", ErrorDescription = "Refresh token is required" });
|
||||
case "refresh_token":
|
||||
{
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
// Decode the base64 refresh token to get the session ID
|
||||
var sessionIdBytes = Convert.FromBase64String(request.RefreshToken);
|
||||
var sessionId = new Guid(sessionIdBytes);
|
||||
// Decode the base64 refresh token to get the session ID
|
||||
var sessionIdBytes = Convert.FromBase64String(request.RefreshToken);
|
||||
var sessionId = new Guid(sessionIdBytes);
|
||||
|
||||
// Find the session and related data
|
||||
var session = await oidcService.FindSessionByIdAsync(sessionId);
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
if (session?.AppId is null || session.ExpiredAt < now)
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "invalid_grant",
|
||||
ErrorDescription = "Invalid or expired refresh token"
|
||||
});
|
||||
}
|
||||
|
||||
// Get the client
|
||||
var client = await oidcService.FindClientByIdAsync(session.AppId.Value);
|
||||
if (client == null)
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "invalid_client",
|
||||
ErrorDescription = "Client not found"
|
||||
});
|
||||
}
|
||||
|
||||
// Generate new tokens
|
||||
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
||||
clientId: session.AppId!.Value,
|
||||
sessionId: session.Id
|
||||
);
|
||||
|
||||
return Ok(tokenResponse);
|
||||
}
|
||||
catch (FormatException)
|
||||
// Find the session and related data
|
||||
var session = await oidcService.FindSessionByIdAsync(sessionId);
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
if (session?.AppId is null || session.ExpiredAt < now)
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "invalid_grant",
|
||||
ErrorDescription = "Invalid refresh token format"
|
||||
ErrorDescription = "Invalid or expired refresh token"
|
||||
});
|
||||
}
|
||||
|
||||
// Get the client
|
||||
var client = await oidcService.FindClientByIdAsync(session.AppId.Value);
|
||||
if (client == null)
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "invalid_client",
|
||||
ErrorDescription = "Client not found"
|
||||
});
|
||||
}
|
||||
|
||||
// Generate new tokens
|
||||
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
||||
clientId: session.AppId!.Value,
|
||||
sessionId: session.Id
|
||||
);
|
||||
|
||||
return Ok(tokenResponse);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "invalid_grant",
|
||||
ErrorDescription = "Invalid refresh token format"
|
||||
});
|
||||
}
|
||||
}
|
||||
default:
|
||||
return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" });
|
||||
}
|
||||
@@ -116,7 +307,7 @@ public class OidcProviderController(
|
||||
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
|
||||
|
||||
// Get requested scopes from the token
|
||||
var scopes = currentSession.Challenge.Scopes;
|
||||
var scopes = currentSession.Challenge?.Scopes ?? [];
|
||||
|
||||
var userInfo = new Dictionary<string, object>
|
||||
{
|
||||
@@ -150,10 +341,10 @@ public class OidcProviderController(
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
issuer = issuer,
|
||||
issuer,
|
||||
authorization_endpoint = $"{baseUrl}/auth/authorize",
|
||||
token_endpoint = $"{baseUrl}/auth/open/token",
|
||||
userinfo_endpoint = $"{baseUrl}/auth/open/userinfo",
|
||||
token_endpoint = $"{baseUrl}/api/auth/open/token",
|
||||
userinfo_endpoint = $"{baseUrl}/api/auth/open/userinfo",
|
||||
jwks_uri = $"{baseUrl}/.well-known/jwks",
|
||||
scopes_supported = new[] { "openid", "profile", "email" },
|
||||
response_types_supported = new[]
|
||||
@@ -220,7 +411,7 @@ public class TokenRequest
|
||||
|
||||
[JsonPropertyName("client_id")]
|
||||
[FromForm(Name = "client_id")]
|
||||
public Guid? ClientId { get; set; }
|
||||
public string? ClientId { get; set; }
|
||||
|
||||
[JsonPropertyName("client_secret")]
|
||||
[FromForm(Name = "client_secret")]
|
||||
@@ -237,4 +428,4 @@ public class TokenRequest
|
||||
[JsonPropertyName("code_verifier")]
|
||||
[FromForm(Name = "code_verifier")]
|
||||
public string? CodeVerifier { get; set; }
|
||||
}
|
||||
}
|
@@ -0,0 +1,21 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Shared.Data;
|
||||
|
||||
namespace DysonNetwork.Pass.Auth.OidcProvider.Responses;
|
||||
|
||||
public class ClientInfoResponse
|
||||
{
|
||||
public Guid ClientId { get; set; }
|
||||
public CloudFileReferenceObject? Picture { get; set; }
|
||||
public CloudFileReferenceObject? Background { get; set; }
|
||||
public string? ClientName { get; set; }
|
||||
public string? HomeUri { get; set; }
|
||||
public string? PolicyUri { get; set; }
|
||||
public string? TermsOfServiceUri { get; set; }
|
||||
public string? ResponseTypes { get; set; }
|
||||
public string[]? Scopes { get; set; }
|
||||
public string? State { get; set; }
|
||||
public string? Nonce { get; set; }
|
||||
public string? CodeChallenge { get; set; }
|
||||
public string? CodeChallengeMethod { get; set; }
|
||||
}
|
@@ -20,7 +20,6 @@ public class TokenResponse
|
||||
[JsonPropertyName("scope")]
|
||||
public string? Scope { get; set; }
|
||||
|
||||
|
||||
[JsonPropertyName("id_token")]
|
||||
public string? IdToken { get; set; }
|
||||
}
|
||||
|
@@ -11,6 +11,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using NodaTime;
|
||||
using AccountContactType = DysonNetwork.Pass.Account.AccountContactType;
|
||||
|
||||
namespace DysonNetwork.Pass.Auth.OidcProvider.Services;
|
||||
|
||||
@@ -31,15 +32,31 @@ public class OidcProviderService(
|
||||
return resp.App ?? null;
|
||||
}
|
||||
|
||||
public async Task<AuthSession?> FindValidSessionAsync(Guid accountId, Guid clientId)
|
||||
public async Task<CustomApp?> FindClientBySlugAsync(string slug)
|
||||
{
|
||||
var resp = await customApps.GetCustomAppAsync(new GetCustomAppRequest { Slug = slug });
|
||||
return resp.App ?? null;
|
||||
}
|
||||
|
||||
public async Task<AuthSession?> FindValidSessionAsync(Guid accountId, Guid clientId, bool withAccount = false)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
return await db.AuthSessions
|
||||
var queryable = db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.AsQueryable();
|
||||
if (withAccount)
|
||||
queryable = queryable
|
||||
.Include(s => s.Account)
|
||||
.ThenInclude(a => a.Profile)
|
||||
.Include(a => a.Account.Contacts)
|
||||
.AsQueryable();
|
||||
|
||||
return await queryable
|
||||
.Where(s => s.AccountId == accountId &&
|
||||
s.AppId == clientId &&
|
||||
(s.ExpiredAt == null || s.ExpiredAt > now) &&
|
||||
s.Challenge != null &&
|
||||
s.Challenge.Type == ChallengeType.OAuth)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
@@ -56,6 +73,149 @@ public class OidcProviderService(
|
||||
return resp.Valid;
|
||||
}
|
||||
|
||||
public async Task<bool> ValidateRedirectUriAsync(Guid clientId, string redirectUri)
|
||||
{
|
||||
if (string.IsNullOrEmpty(redirectUri))
|
||||
return false;
|
||||
|
||||
|
||||
var client = await FindClientByIdAsync(clientId);
|
||||
if (client?.Status != CustomAppStatus.Production)
|
||||
return true;
|
||||
|
||||
if (client?.OauthConfig?.RedirectUris == null)
|
||||
return false;
|
||||
|
||||
// Check if the redirect URI matches any of the allowed URIs
|
||||
// For exact match
|
||||
if (client.OauthConfig.RedirectUris.Contains(redirectUri))
|
||||
return true;
|
||||
|
||||
// Check for wildcard matches (e.g., https://*.example.com/*)
|
||||
foreach (var allowedUri in client.OauthConfig.RedirectUris)
|
||||
{
|
||||
if (string.IsNullOrEmpty(allowedUri))
|
||||
continue;
|
||||
|
||||
// Handle wildcard in domain
|
||||
if (allowedUri.Contains("*.") && allowedUri.StartsWith("http"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var allowedUriObj = new Uri(allowedUri);
|
||||
var redirectUriObj = new Uri(redirectUri);
|
||||
|
||||
if (allowedUriObj.Scheme != redirectUriObj.Scheme ||
|
||||
allowedUriObj.Port != redirectUriObj.Port)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the domain matches the wildcard pattern
|
||||
var allowedDomain = allowedUriObj.Host;
|
||||
var redirectDomain = redirectUriObj.Host;
|
||||
|
||||
if (allowedDomain.StartsWith("*."))
|
||||
{
|
||||
var baseDomain = allowedDomain[2..]; // Remove the "*." prefix
|
||||
if (redirectDomain == baseDomain || redirectDomain.EndsWith($".{baseDomain}"))
|
||||
{
|
||||
// Check path
|
||||
var allowedPath = allowedUriObj.AbsolutePath.TrimEnd('/');
|
||||
var redirectPath = redirectUriObj.AbsolutePath.TrimEnd('/');
|
||||
|
||||
if (string.IsNullOrEmpty(allowedPath) ||
|
||||
redirectPath.StartsWith(allowedPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (UriFormatException)
|
||||
{
|
||||
// Invalid URI format in allowed URIs, skip
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private string GenerateIdToken(
|
||||
CustomApp client,
|
||||
AuthSession session,
|
||||
string? nonce = null,
|
||||
IEnumerable<string>? scopes = null
|
||||
)
|
||||
{
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var clock = SystemClock.Instance;
|
||||
var now = clock.GetCurrentInstant();
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Iss, _options.IssuerUri),
|
||||
new(JwtRegisteredClaimNames.Sub, session.AccountId.ToString()),
|
||||
new(JwtRegisteredClaimNames.Aud, client.Slug),
|
||||
new(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
|
||||
new(JwtRegisteredClaimNames.Exp,
|
||||
now.Plus(Duration.FromSeconds(_options.AccessTokenLifetime.TotalSeconds)).ToUnixTimeSeconds()
|
||||
.ToString(), ClaimValueTypes.Integer64),
|
||||
new(JwtRegisteredClaimNames.AuthTime, session.CreatedAt.ToUnixTimeSeconds().ToString(),
|
||||
ClaimValueTypes.Integer64),
|
||||
};
|
||||
|
||||
// Add nonce if provided (required for implicit and hybrid flows)
|
||||
if (!string.IsNullOrEmpty(nonce))
|
||||
{
|
||||
claims.Add(new Claim("nonce", nonce));
|
||||
}
|
||||
|
||||
// Add email claim if email scope is requested
|
||||
var scopesList = scopes?.ToList() ?? [];
|
||||
if (scopesList.Contains("email"))
|
||||
{
|
||||
var contact = session.Account.Contacts.FirstOrDefault(c => c.Type == AccountContactType.Email);
|
||||
if (contact is not null)
|
||||
{
|
||||
claims.Add(new Claim(JwtRegisteredClaimNames.Email, contact.Content));
|
||||
claims.Add(new Claim("email_verified", contact.VerifiedAt is not null ? "true" : "false",
|
||||
ClaimValueTypes.Boolean));
|
||||
}
|
||||
}
|
||||
|
||||
// Add profile claims if profile scope is requested
|
||||
if (scopes != null && scopesList.Contains("profile"))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(session.Account.Name))
|
||||
claims.Add(new Claim("preferred_username", session.Account.Name));
|
||||
if (!string.IsNullOrEmpty(session.Account.Nick))
|
||||
claims.Add(new Claim("name", session.Account.Nick));
|
||||
if (!string.IsNullOrEmpty(session.Account.Profile.FirstName))
|
||||
claims.Add(new Claim("given_name", session.Account.Profile.FirstName));
|
||||
if (!string.IsNullOrEmpty(session.Account.Profile.LastName))
|
||||
claims.Add(new Claim("family_name", session.Account.Profile.LastName));
|
||||
}
|
||||
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity(claims),
|
||||
Issuer = _options.IssuerUri,
|
||||
Audience = client.Id.ToString(),
|
||||
Expires = now.Plus(Duration.FromSeconds(_options.AccessTokenLifetime.TotalSeconds)).ToDateTimeUtc(),
|
||||
NotBefore = now.ToDateTimeUtc(),
|
||||
SigningCredentials = new SigningCredentials(
|
||||
new RsaSecurityKey(_options.GetRsaPrivateKey()),
|
||||
SecurityAlgorithms.RsaSha256
|
||||
)
|
||||
};
|
||||
|
||||
var token = tokenHandler.CreateToken(tokenDescriptor);
|
||||
return tokenHandler.WriteToken(token);
|
||||
}
|
||||
|
||||
public async Task<TokenResponse> GenerateTokenResponseAsync(
|
||||
Guid clientId,
|
||||
string? authorizationCode = null,
|
||||
@@ -71,24 +231,43 @@ public class OidcProviderService(
|
||||
AuthSession session;
|
||||
var clock = SystemClock.Instance;
|
||||
var now = clock.GetCurrentInstant();
|
||||
|
||||
string? nonce = null;
|
||||
List<string>? scopes = null;
|
||||
|
||||
if (authorizationCode != null)
|
||||
{
|
||||
// Authorization code flow
|
||||
var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier);
|
||||
if (authCode is null) throw new InvalidOperationException("Invalid authorization code");
|
||||
var account = await db.Accounts.Where(a => a.Id == authCode.AccountId).FirstOrDefaultAsync();
|
||||
if (account is null) throw new InvalidOperationException("Account was not found");
|
||||
if (authCode == null)
|
||||
throw new InvalidOperationException("Invalid authorization code");
|
||||
|
||||
// Load the session for the user
|
||||
var existingSession = await FindValidSessionAsync(authCode.AccountId, clientId, withAccount: true);
|
||||
|
||||
if (existingSession is null)
|
||||
{
|
||||
var account = await db.Accounts
|
||||
.Where(a => a.Id == authCode.AccountId)
|
||||
.Include(a => a.Profile)
|
||||
.Include(a => a.Contacts)
|
||||
.FirstOrDefaultAsync();
|
||||
if (account is null) throw new InvalidOperationException("Account not found");
|
||||
session = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant(), clientId);
|
||||
session.Account = account;
|
||||
}
|
||||
else
|
||||
{
|
||||
session = existingSession;
|
||||
}
|
||||
|
||||
session = await auth.CreateSessionForOidcAsync(account, now, clientId);
|
||||
scopes = authCode.Scopes;
|
||||
nonce = authCode.Nonce;
|
||||
}
|
||||
else if (sessionId.HasValue)
|
||||
{
|
||||
// Refresh token flow
|
||||
session = await FindSessionByIdAsync(sessionId.Value) ??
|
||||
throw new InvalidOperationException("Invalid session");
|
||||
throw new InvalidOperationException("Session not found");
|
||||
|
||||
// Verify the session is still valid
|
||||
if (session.ExpiredAt < now)
|
||||
@@ -102,13 +281,15 @@ public class OidcProviderService(
|
||||
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
|
||||
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
|
||||
|
||||
// Generate an access token
|
||||
// Generate tokens
|
||||
var accessToken = GenerateJwtToken(client, session, expiresAt, scopes);
|
||||
var idToken = GenerateIdToken(client, session, nonce, scopes);
|
||||
var refreshToken = GenerateRefreshToken(session);
|
||||
|
||||
return new TokenResponse
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
IdToken = idToken,
|
||||
ExpiresIn = expiresIn,
|
||||
TokenType = "Bearer",
|
||||
RefreshToken = refreshToken,
|
||||
@@ -134,11 +315,10 @@ public class OidcProviderService(
|
||||
new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(),
|
||||
ClaimValueTypes.Integer64),
|
||||
new Claim("client_id", client.Id)
|
||||
]),
|
||||
Expires = expiresAt.ToDateTimeUtc(),
|
||||
Issuer = _options.IssuerUri,
|
||||
Audience = client.Id
|
||||
Audience = client.Slug
|
||||
};
|
||||
|
||||
// Try to use RSA signing if keys are available, fall back to HMAC
|
||||
@@ -204,51 +384,6 @@ public class OidcProviderService(
|
||||
return Convert.ToBase64String(session.Id.ToByteArray());
|
||||
}
|
||||
|
||||
private static bool VerifyHashedSecret(string secret, string hashedSecret)
|
||||
{
|
||||
// In a real implementation, you'd use a proper password hashing algorithm like PBKDF2, bcrypt, or Argon2
|
||||
// For now, we'll do a simple comparison, but you should replace this with proper hashing
|
||||
return string.Equals(secret, hashedSecret, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public async Task<string> GenerateAuthorizationCodeForReuseSessionAsync(
|
||||
AuthSession session,
|
||||
Guid clientId,
|
||||
string redirectUri,
|
||||
IEnumerable<string> scopes,
|
||||
string? codeChallenge = null,
|
||||
string? codeChallengeMethod = null,
|
||||
string? nonce = null)
|
||||
{
|
||||
var clock = SystemClock.Instance;
|
||||
var now = clock.GetCurrentInstant();
|
||||
var code = Guid.NewGuid().ToString("N");
|
||||
|
||||
// Update the session's last activity time
|
||||
await db.AuthSessions.Where(s => s.Id == session.Id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(s => s.LastGrantedAt, now));
|
||||
|
||||
// Create the authorization code info
|
||||
var authCodeInfo = new AuthorizationCodeInfo
|
||||
{
|
||||
ClientId = clientId,
|
||||
AccountId = session.AccountId,
|
||||
RedirectUri = redirectUri,
|
||||
Scopes = scopes.ToList(),
|
||||
CodeChallenge = codeChallenge,
|
||||
CodeChallengeMethod = codeChallengeMethod,
|
||||
Nonce = nonce,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
// Store the code with its metadata in the cache
|
||||
var cacheKey = $"auth:code:{code}";
|
||||
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
|
||||
|
||||
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, session.AccountId);
|
||||
return code;
|
||||
}
|
||||
|
||||
public async Task<string> GenerateAuthorizationCodeAsync(
|
||||
Guid clientId,
|
||||
Guid userId,
|
||||
@@ -278,7 +413,7 @@ public class OidcProviderService(
|
||||
};
|
||||
|
||||
// Store the code with its metadata in the cache
|
||||
var cacheKey = $"auth:code:{code}";
|
||||
var cacheKey = $"auth:oidc-code:{code}";
|
||||
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
|
||||
|
||||
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId);
|
||||
@@ -292,7 +427,7 @@ public class OidcProviderService(
|
||||
string? codeVerifier = null
|
||||
)
|
||||
{
|
||||
var cacheKey = $"auth:code:{code}";
|
||||
var cacheKey = $"auth:oidc-code:{code}";
|
||||
var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey);
|
||||
|
||||
if (!found || authCode == null)
|
||||
|
@@ -340,7 +340,7 @@ public class ConnectionController(
|
||||
|
||||
var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant());
|
||||
var loginToken = auth.CreateToken(loginSession);
|
||||
return Redirect($"/auth/token?token={loginToken}");
|
||||
return Redirect($"/auth/callback?token={loginToken}");
|
||||
}
|
||||
|
||||
private static async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request)
|
||||
|
@@ -84,6 +84,7 @@ public class OidcState
|
||||
{
|
||||
return JsonSerializer.Serialize(this, new JsonSerializerOptions
|
||||
{
|
||||
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
});
|
||||
|
@@ -1,3 +1,4 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using DysonNetwork.Pass.Wallet;
|
||||
@@ -22,8 +23,9 @@ public class TokenAuthService(
|
||||
/// then cache and return.
|
||||
/// </summary>
|
||||
/// <param name="token">Incoming token string</param>
|
||||
/// <param name="ipAddress">Client IP address, for logging purposes</param>
|
||||
/// <returns>(Valid, Session, Message)</returns>
|
||||
public async Task<(bool Valid, AuthSession? Session, string? Message)> AuthenticateTokenAsync(string token)
|
||||
public async Task<(bool Valid, AuthSession? Session, string? Message)> AuthenticateTokenAsync(string token, string? ipAddress = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -32,6 +34,11 @@ public class TokenAuthService(
|
||||
logger.LogWarning("AuthenticateTokenAsync: no token provided");
|
||||
return (false, null, "No token provided.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(ipAddress))
|
||||
{
|
||||
logger.LogDebug("AuthenticateTokenAsync: client IP: {IpAddress}", ipAddress);
|
||||
}
|
||||
|
||||
// token fingerprint for correlation
|
||||
var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
||||
@@ -70,7 +77,7 @@ public class TokenAuthService(
|
||||
"AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})",
|
||||
sessionId,
|
||||
session.AccountId,
|
||||
session.Challenge.Scopes.Count,
|
||||
session.Challenge?.Scopes.Count,
|
||||
session.ExpiredAt
|
||||
);
|
||||
return (true, session, null);
|
||||
@@ -103,11 +110,11 @@ public class TokenAuthService(
|
||||
"AuthenticateTokenAsync: DB session loaded (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId}, appId={AppId}, scopes={ScopeCount}, ip={Ip}, uaLen={UaLen})",
|
||||
sessionId,
|
||||
session.AccountId,
|
||||
session.Challenge.ClientId,
|
||||
session.Challenge?.ClientId,
|
||||
session.AppId,
|
||||
session.Challenge.Scopes.Count,
|
||||
session.Challenge.IpAddress,
|
||||
(session.Challenge.UserAgent ?? string.Empty).Length
|
||||
session.Challenge?.Scopes.Count,
|
||||
session.Challenge?.IpAddress,
|
||||
(session.Challenge?.UserAgent ?? string.Empty).Length
|
||||
);
|
||||
|
||||
logger.LogDebug("AuthenticateTokenAsync: enriching account with subscription (accountId={AccountId})", session.AccountId);
|
||||
@@ -136,7 +143,7 @@ public class TokenAuthService(
|
||||
"AuthenticateTokenAsync: success via DB (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId})",
|
||||
sessionId,
|
||||
session.AccountId,
|
||||
session.Challenge.ClientId
|
||||
session.Challenge?.ClientId
|
||||
);
|
||||
return (true, session, null);
|
||||
}
|
||||
|
@@ -60,6 +60,12 @@ const router = createRouter({
|
||||
name: 'authCallback',
|
||||
component: () => import('../views/callback.vue'),
|
||||
},
|
||||
{
|
||||
path: '/auth/authorize',
|
||||
name: 'authAuthorize',
|
||||
component: () => import('../views/authorize.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/:notFound(.*)',
|
||||
name: 'errorNotFound',
|
||||
|
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-center h-full p-4">
|
||||
<n-card class="w-full max-w-md" title="Authorize Application">
|
||||
<n-spin :show="isLoading">
|
||||
<div v-if="error" class="mb-4">
|
||||
<n-alert type="error" :title="error" closable @close="error = null" />
|
||||
</div>
|
||||
|
||||
<!-- App Info Section -->
|
||||
<div v-if="clientInfo" class="mb-6">
|
||||
<div class="flex items-center">
|
||||
<n-avatar
|
||||
v-if="clientInfo.picture"
|
||||
:src="clientInfo.picture.url"
|
||||
:alt="clientInfo.client_name"
|
||||
size="large"
|
||||
class="mr-3"
|
||||
/>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">
|
||||
{{ clientInfo.client_name || 'Unknown Application' }}
|
||||
</h2>
|
||||
<span v-if="isNewApp">wants to access your Solar Network account</span>
|
||||
<span v-else>wants to access your account</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Requested Permissions -->
|
||||
<n-card size="small" class="mt-4">
|
||||
<h3 class="font-medium mb-2">
|
||||
This will allow {{ clientInfo.client_name || 'the app' }} to:
|
||||
</h3>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="scope in requestedScopes" :key="scope" class="flex items-start">
|
||||
<n-icon :component="CheckBoxFilled" class="mt-1 mr-2" />
|
||||
<span>{{ scope }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</n-card>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex gap-3 mt-4">
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="isAuthorizing"
|
||||
@click="handleAuthorize"
|
||||
class="flex-grow-1 w-1/2"
|
||||
>
|
||||
Authorize
|
||||
</n-button>
|
||||
<n-button
|
||||
type="tertiary"
|
||||
:disabled="isAuthorizing"
|
||||
@click="handleDeny"
|
||||
class="flex-grow-1 w-1/2"
|
||||
>
|
||||
Deny
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-xs text-gray-500 text-center">
|
||||
By authorizing, you agree to the
|
||||
<n-button text type="primary" size="tiny" @click="openTerms" class="px-1">
|
||||
Terms of Service
|
||||
</n-button>
|
||||
and
|
||||
<n-button text type="primary" size="tiny" @click="openPrivacy" class="px-1">
|
||||
Privacy Policy
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { NCard, NButton, NSpin, NAlert, NAvatar, NIcon } from 'naive-ui'
|
||||
import { CheckBoxFilled } from '@vicons/material'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// State
|
||||
const isLoading = ref(true)
|
||||
const isAuthorizing = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const clientInfo = ref<{
|
||||
client_name?: string
|
||||
home_uri?: string
|
||||
picture?: { url: string }
|
||||
terms_of_service_uri?: string
|
||||
privacy_policy_uri?: string
|
||||
scopes?: string[]
|
||||
} | null>(null)
|
||||
const isNewApp = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const requestedScopes = computed(() => {
|
||||
return clientInfo.value?.scopes || []
|
||||
})
|
||||
|
||||
// Methods
|
||||
async function fetchClientInfo() {
|
||||
try {
|
||||
const response = await fetch(`/api/auth/open/authorize?${window.location.search.slice(1)}`)
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error_description || 'Failed to load authorization request')
|
||||
}
|
||||
clientInfo.value = await response.json()
|
||||
checkIfNewApp()
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'An error occurred while loading the authorization request'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function checkIfNewApp() {
|
||||
// In a real app, you might want to check if this is the first time authorizing this app
|
||||
// For now, we'll just set it to false
|
||||
isNewApp.value = false
|
||||
}
|
||||
|
||||
async function handleAuthorize() {
|
||||
isAuthorizing.value = true
|
||||
try {
|
||||
// In a real implementation, you would submit the authorization
|
||||
const response = await fetch('/api/auth/open/authorize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
...route.query,
|
||||
authorize: 'true',
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error_description || 'Authorization failed')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (data.redirect_uri) {
|
||||
window.open(data.redirect_uri, '_self')
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'An error occurred during authorization'
|
||||
} finally {
|
||||
isAuthorizing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeny() {
|
||||
// Redirect back to the client with an error
|
||||
// Ensure redirect_uri is always a string (not an array)
|
||||
const redirectUriStr = Array.isArray(route.query.redirect_uri)
|
||||
? route.query.redirect_uri[0] || clientInfo.value?.home_uri || '/'
|
||||
: route.query.redirect_uri || clientInfo.value?.home_uri || '/'
|
||||
const redirectUri = new URL(redirectUriStr)
|
||||
// Ensure state is always a string (not an array)
|
||||
const state = Array.isArray(route.query.state)
|
||||
? route.query.state[0] || ''
|
||||
: route.query.state || ''
|
||||
const params = new URLSearchParams({
|
||||
error: 'access_denied',
|
||||
error_description: 'The user denied the authorization request',
|
||||
state: state,
|
||||
})
|
||||
window.open(`${redirectUri}?${params}`, "_self")
|
||||
}
|
||||
|
||||
function openTerms() {
|
||||
window.open(clientInfo.value?.terms_of_service_uri || '#', "_blank")
|
||||
}
|
||||
|
||||
function openPrivacy() {
|
||||
window.open(clientInfo.value?.privacy_policy_uri || '#', "_blank")
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
fetchClientInfo()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Add any custom styles here */
|
||||
</style>
|
||||
|
34
DysonNetwork.Pass/Credit/SocialCreditRecord.cs
Normal file
34
DysonNetwork.Pass/Credit/SocialCreditRecord.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
|
||||
namespace DysonNetwork.Pass.Credit;
|
||||
|
||||
public class SocialCreditRecord : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
[MaxLength(1024)] public string ReasonType { get; set; } = string.Empty;
|
||||
[MaxLength(1024)] public string Reason { get; set; } = string.Empty;
|
||||
public double Delta { get; set; }
|
||||
public Instant? ExpiredAt { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
public Account.Account Account { get; set; } = null!;
|
||||
|
||||
public Shared.Proto.SocialCreditRecord ToProto()
|
||||
{
|
||||
var proto = new Shared.Proto.SocialCreditRecord
|
||||
{
|
||||
Id = Id.ToString(),
|
||||
ReasonType = ReasonType,
|
||||
Reason = Reason,
|
||||
Delta = Delta,
|
||||
AccountId = AccountId.ToString(),
|
||||
CreatedAt = CreatedAt.ToTimestamp(),
|
||||
UpdatedAt = UpdatedAt.ToTimestamp()
|
||||
};
|
||||
|
||||
return proto;
|
||||
}
|
||||
}
|
46
DysonNetwork.Pass/Credit/SocialCreditService.cs
Normal file
46
DysonNetwork.Pass/Credit/SocialCreditService.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Pass.Credit;
|
||||
|
||||
public class SocialCreditService(AppDatabase db, ICacheService cache)
|
||||
{
|
||||
private const string CacheKeyPrefix = "account:credits:";
|
||||
|
||||
public async Task<SocialCreditRecord> AddRecord(string reasonType, string reason, double delta, Guid accountId)
|
||||
{
|
||||
var record = new SocialCreditRecord
|
||||
{
|
||||
ReasonType = reasonType,
|
||||
Reason = reason,
|
||||
Delta = delta,
|
||||
AccountId = accountId,
|
||||
};
|
||||
db.SocialCreditRecords.Add(record);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await db.AccountProfiles
|
||||
.Where(p => p.AccountId == accountId)
|
||||
.ExecuteUpdateAsync(p => p.SetProperty(v => v.SocialCredits, v => v.SocialCredits + record.Delta));
|
||||
|
||||
await cache.RemoveAsync($"{CacheKeyPrefix}{accountId}");
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
private const double BaseSocialCredit = 100;
|
||||
|
||||
public async Task<double> GetSocialCredit(Guid accountId)
|
||||
{
|
||||
var cached = await cache.GetAsync<double?>($"{CacheKeyPrefix}{accountId}");
|
||||
if (cached.HasValue) return cached.Value;
|
||||
|
||||
var records = await db.SocialCreditRecords
|
||||
.Where(x => x.AccountId == accountId)
|
||||
.SumAsync(x => x.Delta);
|
||||
records += BaseSocialCredit;
|
||||
|
||||
await cache.SetAsync($"{CacheKeyPrefix}{accountId}", records);
|
||||
return records;
|
||||
}
|
||||
}
|
27
DysonNetwork.Pass/Credit/SocialCreditServiceGrpc.cs
Normal file
27
DysonNetwork.Pass/Credit/SocialCreditServiceGrpc.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Grpc.Core;
|
||||
|
||||
namespace DysonNetwork.Pass.Credit;
|
||||
|
||||
public class SocialCreditServiceGrpc(SocialCreditService creditService) : Shared.Proto.SocialCreditService.SocialCreditServiceBase
|
||||
{
|
||||
public override async Task<Shared.Proto.SocialCreditRecord> AddRecord(AddSocialCreditRecordRequest request, ServerCallContext context)
|
||||
{
|
||||
var accountId = Guid.Parse(request.AccountId);
|
||||
var record = await creditService.AddRecord(
|
||||
request.ReasonType,
|
||||
request.Reason,
|
||||
request.Delta,
|
||||
accountId);
|
||||
|
||||
return record.ToProto();
|
||||
}
|
||||
|
||||
public override async Task<SocialCreditResponse> GetSocialCredit(GetSocialCreditRequest request, ServerCallContext context)
|
||||
{
|
||||
var accountId = Guid.Parse(request.AccountId);
|
||||
var amount = await creditService.GetSocialCredit(accountId);
|
||||
|
||||
return new SocialCreditResponse { Amount = amount };
|
||||
}
|
||||
}
|
@@ -13,6 +13,7 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Nager.Holiday" Version="1.0.1" />
|
||||
<PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
@@ -23,7 +24,6 @@
|
||||
<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.NetTopologySuite" Version="9.0.4"/>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4"/>
|
||||
<PackageReference Include="OpenGraph-Net" Version="4.0.1" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/>
|
||||
@@ -49,6 +49,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -136,19 +137,12 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AdditionalFiles Include="Pages\Emails\AccountDeletionEmail.razor"/>
|
||||
<AdditionalFiles Include="Pages\Emails\ContactVerificationEmail.razor"/>
|
||||
<AdditionalFiles Include="Pages\Emails\EmailLayout.razor"/>
|
||||
<AdditionalFiles Include="Pages\Emails\LandingEmail.razor"/>
|
||||
<AdditionalFiles Include="Pages\Emails\PasswordResetEmail.razor"/>
|
||||
<AdditionalFiles Include="Pages\Emails\VerificationEmail.razor"/>
|
||||
<AdditionalFiles Include="Resources\Localization\AccountEventResource.zh-hans.resx"/>
|
||||
<AdditionalFiles Include="Resources\Localization\EmailResource.resx"/>
|
||||
<AdditionalFiles Include="Resources\Localization\EmailResource.zh-hans.resx"/>
|
||||
<AdditionalFiles Include="Resources\Localization\NotificationResource.resx"/>
|
||||
<AdditionalFiles Include="Resources\Localization\NotificationResource.zh-hans.resx"/>
|
||||
<AdditionalFiles Include="Resources\Localization\SharedResource.resx"/>
|
||||
<AdditionalFiles Include="Resources\Localization\SharedResource.zh-hans.resx"/>
|
||||
<AdditionalFiles Include="Pages\Emails\AccountDeletionEmail.razor" />
|
||||
<AdditionalFiles Include="Pages\Emails\ContactVerificationEmail.razor" />
|
||||
<AdditionalFiles Include="Pages\Emails\EmailLayout.razor" />
|
||||
<AdditionalFiles Include="Pages\Emails\LandingEmail.razor" />
|
||||
<AdditionalFiles Include="Pages\Emails\PasswordResetEmail.razor" />
|
||||
<AdditionalFiles Include="Pages\Emails\VerificationEmail.razor" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
@@ -1,12 +1,10 @@
|
||||
using dotnet_etcd;
|
||||
using dotnet_etcd.interfaces;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DysonNetwork.Pass.Email;
|
||||
|
||||
public class EmailService(
|
||||
PusherService.PusherServiceClient pusher,
|
||||
RingService.RingServiceClient pusher,
|
||||
RazorViewRenderer viewRenderer,
|
||||
ILogger<EmailService> logger
|
||||
)
|
||||
|
@@ -18,7 +18,7 @@ public class LastActiveFlushHandler(IServiceProvider srp, ILogger<LastActiveFlus
|
||||
public async Task FlushAsync(IReadOnlyList<LastActiveInfo> items)
|
||||
{
|
||||
logger.LogInformation("Flushing {Count} LastActiveInfo items...", items.Count);
|
||||
|
||||
|
||||
using var scope = srp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||
|
||||
@@ -38,13 +38,22 @@ public class LastActiveFlushHandler(IServiceProvider srp, ILogger<LastActiveFlus
|
||||
.ToDictionary(g => g.Key, g => g.Last().SeenAt);
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
|
||||
var updatingSessions = sessionMap.Select(x => x.Key).ToList();
|
||||
var sessionUpdates = await db.AuthSessions
|
||||
.Where(s => updatingSessions.Contains(s.Id))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.LastGrantedAt, now));
|
||||
.ExecuteUpdateAsync(s =>
|
||||
s.SetProperty(x => x.LastGrantedAt, now)
|
||||
);
|
||||
logger.LogInformation("Updated {Count} auth sessions according to LastActiveInfo", sessionUpdates);
|
||||
|
||||
var newExpiration = now.Plus(Duration.FromDays(7));
|
||||
var keepAliveSessionUpdates = await db.AuthSessions
|
||||
.Where(s => updatingSessions.Contains(s.Id) && s.ExpiredAt != null)
|
||||
.ExecuteUpdateAsync(s =>
|
||||
s.SetProperty(x => x.ExpiredAt, newExpiration)
|
||||
);
|
||||
logger.LogInformation("Updated {Count} auth sessions' duration according to LastActiveInfo", sessionUpdates);
|
||||
|
||||
var updatingAccounts = accountMap.Select(x => x.Key).ToList();
|
||||
var profileUpdates = await db.AccountProfiles
|
||||
.Where(a => updatingAccounts.Contains(a.AccountId))
|
||||
@@ -53,7 +62,8 @@ public class LastActiveFlushHandler(IServiceProvider srp, ILogger<LastActiveFlus
|
||||
}
|
||||
}
|
||||
|
||||
public class LastActiveFlushJob(FlushBufferService fbs, LastActiveFlushHandler hdl, ILogger<LastActiveFlushJob> logger) : IJob
|
||||
public class LastActiveFlushJob(FlushBufferService fbs, LastActiveFlushHandler hdl, ILogger<LastActiveFlushJob> logger)
|
||||
: IJob
|
||||
{
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
@@ -62,7 +72,8 @@ public class LastActiveFlushJob(FlushBufferService fbs, LastActiveFlushHandler h
|
||||
logger.LogInformation("Running LastActiveInfo flush job...");
|
||||
await fbs.FlushAsync(hdl);
|
||||
logger.LogInformation("Completed LastActiveInfo flush job...");
|
||||
} catch (Exception ex)
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error running LastActiveInfo job...");
|
||||
}
|
||||
|
39
DysonNetwork.Pass/IpCheckController.cs
Normal file
39
DysonNetwork.Pass/IpCheckController.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DysonNetwork.Pass;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/ip-check")]
|
||||
public class IpCheckController : ControllerBase
|
||||
{
|
||||
public class IpCheckResponse
|
||||
{
|
||||
public string? RemoteIp { get; set; }
|
||||
public string? XForwardedFor { get; set; }
|
||||
public string? XForwardedProto { get; set; }
|
||||
public string? XForwardedHost { get; set; }
|
||||
public string? XRealIp { get; set; }
|
||||
public string? Headers { get; set; }
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public ActionResult<IpCheckResponse> GetIpCheck()
|
||||
{
|
||||
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
var xForwardedFor = Request.Headers["X-Forwarded-For"].FirstOrDefault();
|
||||
var xForwardedProto = Request.Headers["X-Forwarded-Proto"].FirstOrDefault();
|
||||
var xForwardedHost = Request.Headers["X-Forwarded-Host"].FirstOrDefault();
|
||||
var realIp = Request.Headers["X-Real-IP"].FirstOrDefault();
|
||||
|
||||
return Ok(new IpCheckResponse
|
||||
{
|
||||
RemoteIp = ip,
|
||||
XForwardedFor = xForwardedFor,
|
||||
XForwardedProto = xForwardedProto,
|
||||
XForwardedHost = xForwardedHost,
|
||||
XRealIp = realIp,
|
||||
Headers = string.Join('\n', Request.Headers.Select(h => $"{h.Key}: {h.Value}")),
|
||||
});
|
||||
}
|
||||
}
|
34
DysonNetwork.Pass/Leveling/ExperienceRecord.cs
Normal file
34
DysonNetwork.Pass/Leveling/ExperienceRecord.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
|
||||
namespace DysonNetwork.Pass.Leveling;
|
||||
|
||||
public class ExperienceRecord : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(1024)] public string ReasonType { get; set; } = string.Empty;
|
||||
[MaxLength(1024)] public string Reason { get; set; } = string.Empty;
|
||||
public long Delta { get; set; }
|
||||
public double BonusMultiplier { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
public Account.Account Account { get; set; } = null!;
|
||||
|
||||
public Shared.Proto.ExperienceRecord ToProto()
|
||||
{
|
||||
var proto = new Shared.Proto.ExperienceRecord
|
||||
{
|
||||
Id = Id.ToString(),
|
||||
ReasonType = ReasonType,
|
||||
Reason = Reason,
|
||||
Delta = Delta,
|
||||
BonusMultiplier = BonusMultiplier,
|
||||
AccountId = AccountId.ToString(),
|
||||
CreatedAt = CreatedAt.ToTimestamp(),
|
||||
UpdatedAt = UpdatedAt.ToTimestamp()
|
||||
};
|
||||
|
||||
return proto;
|
||||
}
|
||||
}
|
42
DysonNetwork.Pass/Leveling/ExperienceService.cs
Normal file
42
DysonNetwork.Pass/Leveling/ExperienceService.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using DysonNetwork.Pass.Wallet;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Pass.Leveling;
|
||||
|
||||
public class ExperienceService(AppDatabase db, SubscriptionService subscriptions, ICacheService cache)
|
||||
{
|
||||
public async Task<ExperienceRecord> AddRecord(string reasonType, string reason, long delta, Guid accountId)
|
||||
{
|
||||
var record = new ExperienceRecord
|
||||
{
|
||||
ReasonType = reasonType,
|
||||
Reason = reason,
|
||||
Delta = delta,
|
||||
AccountId = accountId,
|
||||
};
|
||||
|
||||
var perkSubscription = await subscriptions.GetPerkSubscriptionAsync(accountId);
|
||||
if (perkSubscription is not null)
|
||||
{
|
||||
record.BonusMultiplier = perkSubscription.Identifier switch
|
||||
{
|
||||
SubscriptionType.Stellar => 1.5,
|
||||
SubscriptionType.Nova => 2,
|
||||
SubscriptionType.Supernova => 2,
|
||||
_ => 1
|
||||
};
|
||||
if (record.Delta >= 0)
|
||||
record.Delta = (long)Math.Floor(record.Delta * record.BonusMultiplier);
|
||||
}
|
||||
|
||||
db.ExperienceRecords.Add(record);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await db.AccountProfiles
|
||||
.Where(p => p.AccountId == accountId)
|
||||
.ExecuteUpdateAsync(p => p.SetProperty(v => v.Experience, v => v.Experience + record.Delta));
|
||||
|
||||
return record;
|
||||
}
|
||||
}
|
19
DysonNetwork.Pass/Leveling/ExperienceServiceGrpc.cs
Normal file
19
DysonNetwork.Pass/Leveling/ExperienceServiceGrpc.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Grpc.Core;
|
||||
|
||||
namespace DysonNetwork.Pass.Leveling;
|
||||
|
||||
public class ExperienceServiceGrpc(ExperienceService experienceService) : Shared.Proto.ExperienceService.ExperienceServiceBase
|
||||
{
|
||||
public override async Task<Shared.Proto.ExperienceRecord> AddRecord(AddExperienceRecordRequest request, ServerCallContext context)
|
||||
{
|
||||
var accountId = Guid.Parse(request.AccountId);
|
||||
var record = await experienceService.AddRecord(
|
||||
request.ReasonType,
|
||||
request.Reason,
|
||||
request.Delta,
|
||||
accountId);
|
||||
|
||||
return record.ToProto();
|
||||
}
|
||||
}
|
1821
DysonNetwork.Pass/Migrations/20250818125540_RemoveChallengeOldDeviceId.Designer.cs
generated
Normal file
1821
DysonNetwork.Pass/Migrations/20250818125540_RemoveChallengeOldDeviceId.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Pass.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RemoveChallengeOldDeviceId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "device_id",
|
||||
table: "auth_challenges");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "device_id",
|
||||
table: "auth_challenges",
|
||||
type: "character varying(1024)",
|
||||
maxLength: 1024,
|
||||
nullable: true);
|
||||
}
|
||||
}
|
||||
}
|
1825
DysonNetwork.Pass/Migrations/20250819162856_AddBotAccount.Designer.cs
generated
Normal file
1825
DysonNetwork.Pass/Migrations/20250819162856_AddBotAccount.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user