Compare commits
166 Commits
c860f10cf9
...
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 | |||
| 32e91da0b2 | |||
| 69b56b9658 | |||
| 83e3d77f79 | |||
| 38a8eecd50 | |||
| bd77137714 | |||
| 201126e5d0 | |||
| d4a2e5ef5b | |||
| 2761abf405 | |||
| add16ffdad | |||
| b49cd1c382 | |||
| aa9ae5c11e | |||
| 8e8965eb3d | |||
| a0fe8fd0f0 | |||
| 855031a4fe | |||
| adc2b20aeb |
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
|
name: Aspire Publish Workflow
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-sphere:
|
publish:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
- name: Setup .NET
|
||||||
- name: Setup NBGV
|
uses: actions/setup-dotnet@v3
|
||||||
uses: dotnet/nbgv@master
|
with:
|
||||||
id: nbgv
|
dotnet-version: "9.0.x"
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
- name: Log in to GitHub Container Registry
|
||||||
- name: Log in to GitHub Container Registry
|
uses: docker/login-action@v3
|
||||||
uses: docker/login-action@v3
|
with:
|
||||||
with:
|
registry: ghcr.io
|
||||||
registry: ghcr.io
|
username: ${{ github.actor }}
|
||||||
username: ${{ github.actor }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- name: Build and push DysonNetwork.Sphere Docker image
|
- name: Install Aspire CLI
|
||||||
uses: docker/build-push-action@v6
|
run: dotnet tool install -g Aspire.Cli --prerelease
|
||||||
with:
|
|
||||||
file: DysonNetwork.Sphere/Dockerfile
|
- name: Build and Publish Aspire Application
|
||||||
context: .
|
run: aspire publish --project ./DysonNetwork.Control/DysonNetwork.Control.csproj --output publish
|
||||||
push: true
|
|
||||||
tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-sphere:latest
|
- name: Tag and Push Images
|
||||||
platforms: linux/amd64
|
run: |
|
||||||
|
IMAGES=( "sphere" "pass" "ring" "drive" "develop" )
|
||||||
build-pass:
|
|
||||||
runs-on: ubuntu-latest
|
for image in "${IMAGES[@]}"; do
|
||||||
permissions:
|
IMAGE_NAME="ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-$image:alpha"
|
||||||
contents: read
|
SOURCE_IMAGE_NAME="$image:latest" # Aspire's default local image name
|
||||||
packages: write
|
|
||||||
steps:
|
echo "Tagging and pushing $SOURCE_IMAGE_NAME to $IMAGE_NAME..."
|
||||||
- name: Checkout repository
|
docker tag $SOURCE_IMAGE_NAME $IMAGE_NAME
|
||||||
uses: actions/checkout@v3
|
docker push $IMAGE_NAME
|
||||||
with:
|
done
|
||||||
fetch-depth: 0
|
|
||||||
- name: Setup NBGV
|
- name: Upload Aspire Publish Directory
|
||||||
uses: dotnet/nbgv@master
|
uses: actions/upload-artifact@v3
|
||||||
id: nbgv
|
with:
|
||||||
- name: Set up Docker Buildx
|
name: aspire-publish-output
|
||||||
uses: docker/setup-buildx-action@v3
|
path: ./publish/
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
- name: Upload Docker Compose file
|
||||||
with:
|
uses: actions/upload-artifact@v3
|
||||||
registry: ghcr.io
|
with:
|
||||||
username: ${{ github.actor }}
|
name: docker-compose-output
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
path: ./publish/docker-compose.yml
|
||||||
- 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
|
|
||||||
|
|
||||||
|
|||||||
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.Identity;
|
||||||
|
using DysonNetwork.Develop.Project;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Design;
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
|
||||||
@@ -11,8 +13,11 @@ public class AppDatabase(
|
|||||||
{
|
{
|
||||||
public DbSet<Developer> Developers { get; set; } = null!;
|
public DbSet<Developer> Developers { get; set; } = null!;
|
||||||
|
|
||||||
|
public DbSet<DevProject> DevProjects { get; set; } = null!;
|
||||||
|
|
||||||
public DbSet<CustomApp> CustomApps { get; set; } = null!;
|
public DbSet<CustomApp> CustomApps { get; set; } = null!;
|
||||||
public DbSet<CustomAppSecret> CustomAppSecrets { get; set; } = null!;
|
public DbSet<CustomAppSecret> CustomAppSecrets { get; set; } = null!;
|
||||||
|
public DbSet<BotAccount> BotAccounts { get; set; } = null!;
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
|
||||||
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
||||||
</ItemGroup>
|
</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;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using DysonNetwork.Develop.Project;
|
||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Data;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Google.Protobuf;
|
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? Picture { get; set; }
|
||||||
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { 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 CustomAppOauthConfig? OauthConfig { get; set; }
|
||||||
[Column(TypeName = "jsonb")] public CustomAppLinks? Links { get; set; }
|
[Column(TypeName = "jsonb")] public CustomAppLinks? Links { get; set; }
|
||||||
|
|
||||||
[JsonIgnore] public ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>();
|
[JsonIgnore] public ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>();
|
||||||
|
|
||||||
public Guid DeveloperId { get; set; }
|
public Guid ProjectId { get; set; }
|
||||||
public Developer Developer { get; set; } = null!;
|
public DevProject Project { get; set; } = null!;
|
||||||
|
|
||||||
|
[NotMapped]
|
||||||
|
public Developer Developer => Project.Developer;
|
||||||
|
|
||||||
[NotMapped] public string ResourceIdentifier => "custom-app:" + Id;
|
[NotMapped] public string ResourceIdentifier => "custom-app:" + Id;
|
||||||
|
|
||||||
@@ -58,21 +62,26 @@ public class CustomApp : ModelBase, IIdentifiedResource
|
|||||||
CustomAppStatus.Suspended => Shared.Proto.CustomAppStatus.Suspended,
|
CustomAppStatus.Suspended => Shared.Proto.CustomAppStatus.Suspended,
|
||||||
_ => Shared.Proto.CustomAppStatus.Unspecified
|
_ => Shared.Proto.CustomAppStatus.Unspecified
|
||||||
},
|
},
|
||||||
Picture = Picture is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Picture)),
|
Picture = Picture?.ToProtoValue(),
|
||||||
Background = Background is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Background)),
|
Background = Background?.ToProtoValue(),
|
||||||
Verification = Verification is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Verification)),
|
Verification = Verification?.ToProtoValue(),
|
||||||
Links = Links is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Links)),
|
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
|
OauthConfig = OauthConfig is null ? null : new DysonNetwork.Shared.Proto.CustomAppOauthConfig
|
||||||
{
|
{
|
||||||
ClientUri = OauthConfig.ClientUri ?? string.Empty,
|
ClientUri = OauthConfig.ClientUri ?? string.Empty,
|
||||||
RedirectUris = { OauthConfig.RedirectUris ?? Array.Empty<string>() },
|
RedirectUris = { OauthConfig.RedirectUris ?? [] },
|
||||||
PostLogoutRedirectUris = { OauthConfig.PostLogoutRedirectUris ?? Array.Empty<string>() },
|
PostLogoutRedirectUris = { OauthConfig.PostLogoutRedirectUris ?? [] },
|
||||||
AllowedScopes = { OauthConfig.AllowedScopes ?? Array.Empty<string>() },
|
AllowedScopes = { OauthConfig.AllowedScopes ?? [] },
|
||||||
AllowedGrantTypes = { OauthConfig.AllowedGrantTypes ?? Array.Empty<string>() },
|
AllowedGrantTypes = { OauthConfig.AllowedGrantTypes ?? [] },
|
||||||
RequirePkce = OauthConfig.RequirePkce,
|
RequirePkce = OauthConfig.RequirePkce,
|
||||||
AllowOfflineAccess = OauthConfig.AllowOfflineAccess
|
AllowOfflineAccess = OauthConfig.AllowOfflineAccess
|
||||||
},
|
},
|
||||||
DeveloperId = DeveloperId.ToString(),
|
ProjectId = ProjectId.ToString(),
|
||||||
CreatedAt = CreatedAt.ToTimestamp(),
|
CreatedAt = CreatedAt.ToTimestamp(),
|
||||||
UpdatedAt = UpdatedAt.ToTimestamp()
|
UpdatedAt = UpdatedAt.ToTimestamp()
|
||||||
};
|
};
|
||||||
@@ -92,13 +101,21 @@ public class CustomApp : ModelBase, IIdentifiedResource
|
|||||||
Shared.Proto.CustomAppStatus.Suspended => CustomAppStatus.Suspended,
|
Shared.Proto.CustomAppStatus.Suspended => CustomAppStatus.Suspended,
|
||||||
_ => CustomAppStatus.Developing
|
_ => 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();
|
CreatedAt = p.CreatedAt.ToInstant();
|
||||||
UpdatedAt = p.UpdatedAt.ToInstant();
|
UpdatedAt = p.UpdatedAt.ToInstant();
|
||||||
if (p.Picture.Length > 0) Picture = System.Text.Json.JsonSerializer.Deserialize<CloudFileReferenceObject>(p.Picture.ToStringUtf8());
|
if (p.Picture is not null) Picture = CloudFileReferenceObject.FromProtoValue(p.Picture);
|
||||||
if (p.Background.Length > 0) Background = System.Text.Json.JsonSerializer.Deserialize<CloudFileReferenceObject>(p.Background.ToStringUtf8());
|
if (p.Background is not null) Background = CloudFileReferenceObject.FromProtoValue(p.Background);
|
||||||
if (p.Verification.Length > 0) Verification = System.Text.Json.JsonSerializer.Deserialize<DysonNetwork.Shared.Data.VerificationMark>(p.Verification.ToStringUtf8());
|
if (p.Verification is not null) Verification = VerificationMark.FromProtoValue(p.Verification);
|
||||||
if (p.Links.Length > 0) Links = System.Text.Json.JsonSerializer.Deserialize<CustomAppLinks>(p.Links.ToStringUtf8());
|
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;
|
return this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using DysonNetwork.Develop.Project;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
namespace DysonNetwork.Develop.Identity;
|
namespace DysonNetwork.Develop.Identity;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("/api/developers/{pubName}/apps")]
|
[Route("/api/developers/{pubName}/projects/{projectId:guid}/apps")]
|
||||||
public class CustomAppController(CustomAppService customApps, DeveloperService ds) : ControllerBase
|
public class CustomAppController(CustomAppService customApps, DeveloperService ds, DevProjectService projectService)
|
||||||
|
: ControllerBase
|
||||||
{
|
{
|
||||||
public record CustomAppRequest(
|
public record CustomAppRequest(
|
||||||
[MaxLength(1024)] string? Slug,
|
[MaxLength(1024)] string? Slug,
|
||||||
@@ -20,22 +23,62 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
|||||||
CustomAppOauthConfig? OauthConfig
|
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]
|
[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);
|
var developer = await ds.GetDeveloperByName(pubName);
|
||||||
if (developer is null) return NotFound();
|
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);
|
return Ok(apps);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{appId:guid}")]
|
||||||
public async Task<IActionResult> GetApp([FromRoute] string pubName, Guid id)
|
[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);
|
var developer = await ds.GetDeveloperByName(pubName);
|
||||||
if (developer is null) return NotFound();
|
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)
|
if (app == null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
@@ -44,23 +87,39 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
|||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize]
|
[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 (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");
|
|
||||||
|
|
||||||
var developer = await ds.GetDeveloperByName(pubName);
|
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))
|
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");
|
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
|
try
|
||||||
{
|
{
|
||||||
var app = await customApps.CreateAppAsync(developer, request);
|
var app = await customApps.CreateAppAsync(projectId, request);
|
||||||
return Ok(app);
|
if (app == null)
|
||||||
|
return BadRequest("Failed to create app");
|
||||||
|
|
||||||
|
return CreatedAtAction(
|
||||||
|
nameof(GetApp),
|
||||||
|
new { pubName, projectId, appId = app.Id },
|
||||||
|
app
|
||||||
|
);
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex)
|
catch (InvalidOperationException ex)
|
||||||
{
|
{
|
||||||
@@ -68,23 +127,30 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch("{id:guid}")]
|
[HttpPatch("{appId:guid}")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> UpdateApp(
|
public async Task<IActionResult> UpdateApp(
|
||||||
[FromRoute] string pubName,
|
[FromRoute] string pubName,
|
||||||
[FromRoute] Guid id,
|
[FromRoute] Guid projectId,
|
||||||
|
[FromRoute] Guid appId,
|
||||||
[FromBody] CustomAppRequest request
|
[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);
|
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))
|
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");
|
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)
|
if (app == null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
@@ -99,28 +165,267 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{appId:guid}")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> DeleteApp(
|
public async Task<IActionResult> DeleteApp(
|
||||||
[FromRoute] string pubName,
|
[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);
|
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))
|
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");
|
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)
|
if (app == null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
var result = await customApps.DeleteAppAsync(id);
|
var result = await customApps.DeleteAppAsync(appId);
|
||||||
if (!result)
|
if (!result)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
return NoContent();
|
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.Data;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace DysonNetwork.Develop.Identity;
|
namespace DysonNetwork.Develop.Identity;
|
||||||
|
|
||||||
@@ -11,10 +14,17 @@ public class CustomAppService(
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
public async Task<CustomApp?> CreateAppAsync(
|
public async Task<CustomApp?> CreateAppAsync(
|
||||||
Developer pub,
|
Guid projectId,
|
||||||
CustomAppController.CustomAppRequest request
|
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
|
var app = new CustomApp
|
||||||
{
|
{
|
||||||
Slug = request.Slug!,
|
Slug = request.Slug!,
|
||||||
@@ -23,7 +33,7 @@ public class CustomAppService(
|
|||||||
Status = request.Status ?? CustomAppStatus.Developing,
|
Status = request.Status ?? CustomAppStatus.Developing,
|
||||||
Links = request.Links,
|
Links = request.Links,
|
||||||
OauthConfig = request.OauthConfig,
|
OauthConfig = request.OauthConfig,
|
||||||
DeveloperId = pub.Id
|
ProjectId = projectId
|
||||||
};
|
};
|
||||||
|
|
||||||
if (request.PictureId is not null)
|
if (request.PictureId is not null)
|
||||||
@@ -74,17 +84,104 @@ public class CustomAppService(
|
|||||||
return app;
|
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();
|
var query = db.CustomApps.AsQueryable();
|
||||||
if (developerId.HasValue)
|
|
||||||
query = query.Where(a => a.DeveloperId == developerId.Value);
|
if (projectId.HasValue)
|
||||||
return await query.FirstOrDefaultAsync();
|
{
|
||||||
|
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)
|
public async Task<CustomApp?> UpdateAppAsync(CustomApp app, CustomAppController.CustomAppRequest request)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using DysonNetwork.Develop.Project;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Data;
|
||||||
using VerificationMark = DysonNetwork.Shared.Data.VerificationMark;
|
using VerificationMark = DysonNetwork.Shared.Data.VerificationMark;
|
||||||
@@ -10,6 +12,8 @@ public class Developer
|
|||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
public Guid PublisherId { get; set; }
|
public Guid PublisherId { get; set; }
|
||||||
|
|
||||||
|
[JsonIgnore] public List<DevProject> Projects { get; set; } = [];
|
||||||
|
|
||||||
[NotMapped] public PublisherInfo? Publisher { get; set; }
|
[NotMapped] public PublisherInfo? Publisher { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ public class DeveloperController(
|
|||||||
|
|
||||||
// Get custom apps count
|
// Get custom apps count
|
||||||
var customAppsCount = await db.CustomApps
|
var customAppsCount = await db.CustomApps
|
||||||
.Where(a => a.DeveloperId == developer.Id)
|
.Include(a => a.Project)
|
||||||
|
.Where(a => a.Project.DeveloperId == developer.Id)
|
||||||
.CountAsync();
|
.CountAsync();
|
||||||
|
|
||||||
var stats = new DeveloperStats
|
var stats = new DeveloperStats
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace DysonNetwork.Develop.Identity;
|
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)
|
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)
|
public async Task<bool> IsMemberWithRole(Guid pubId, Guid accountId, PublisherMemberRole role)
|
||||||
{
|
{
|
||||||
try
|
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);
|
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 =>
|
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -49,10 +91,6 @@ namespace DysonNetwork.Develop.Migrations
|
|||||||
.HasColumnType("character varying(4096)")
|
.HasColumnType("character varying(4096)")
|
||||||
.HasColumnName("description");
|
.HasColumnName("description");
|
||||||
|
|
||||||
b.Property<Guid>("DeveloperId")
|
|
||||||
.HasColumnType("uuid")
|
|
||||||
.HasColumnName("developer_id");
|
|
||||||
|
|
||||||
b.Property<CustomAppLinks>("Links")
|
b.Property<CustomAppLinks>("Links")
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("links");
|
.HasColumnName("links");
|
||||||
@@ -71,6 +109,10 @@ namespace DysonNetwork.Develop.Migrations
|
|||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("picture");
|
.HasColumnName("picture");
|
||||||
|
|
||||||
|
b.Property<Guid>("ProjectId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("project_id");
|
||||||
|
|
||||||
b.Property<string>("Slug")
|
b.Property<string>("Slug")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(1024)
|
.HasMaxLength(1024)
|
||||||
@@ -92,8 +134,8 @@ namespace DysonNetwork.Develop.Migrations
|
|||||||
b.HasKey("Id")
|
b.HasKey("Id")
|
||||||
.HasName("pk_custom_apps");
|
.HasName("pk_custom_apps");
|
||||||
|
|
||||||
b.HasIndex("DeveloperId")
|
b.HasIndex("ProjectId")
|
||||||
.HasDatabaseName("ix_custom_apps_developer_id");
|
.HasDatabaseName("ix_custom_apps_project_id");
|
||||||
|
|
||||||
b.ToTable("custom_apps", (string)null);
|
b.ToTable("custom_apps", (string)null);
|
||||||
});
|
});
|
||||||
@@ -166,16 +208,78 @@ namespace DysonNetwork.Develop.Migrations
|
|||||||
b.ToTable("developers", (string)null);
|
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()
|
.WithMany()
|
||||||
.HasForeignKey("DeveloperId")
|
.HasForeignKey("ProjectId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired()
|
.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 =>
|
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
|
||||||
@@ -190,10 +294,27 @@ namespace DysonNetwork.Develop.Migrations
|
|||||||
b.Navigation("App");
|
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 =>
|
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Secrets");
|
b.Navigation("Secrets");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Projects");
|
||||||
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
using DysonNetwork.Develop;
|
using DysonNetwork.Develop;
|
||||||
using DysonNetwork.Shared.Auth;
|
using DysonNetwork.Shared.Auth;
|
||||||
using DysonNetwork.Shared.Http;
|
using DysonNetwork.Shared.Http;
|
||||||
using DysonNetwork.Shared.Registry;
|
|
||||||
using DysonNetwork.Develop.Startup;
|
using DysonNetwork.Develop.Startup;
|
||||||
|
using DysonNetwork.Shared.Registry;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.AddServiceDefaults();
|
||||||
|
|
||||||
builder.ConfigureAppKestrel(builder.Configuration);
|
builder.ConfigureAppKestrel(builder.Configuration);
|
||||||
|
|
||||||
builder.Services.AddRegistryService(builder.Configuration);
|
|
||||||
builder.Services.AddAppServices(builder.Configuration);
|
builder.Services.AddAppServices(builder.Configuration);
|
||||||
builder.Services.AddAppAuthentication();
|
builder.Services.AddAppAuthentication();
|
||||||
builder.Services.AddAppSwagger();
|
builder.Services.AddAppSwagger();
|
||||||
@@ -20,6 +21,8 @@ builder.Services.AddDriveService();
|
|||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.MapDefaultEndpoints();
|
||||||
|
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
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 System.Net;
|
||||||
using DysonNetwork.Develop.Identity;
|
using DysonNetwork.Develop.Identity;
|
||||||
using DysonNetwork.Shared.Auth;
|
using DysonNetwork.Shared.Auth;
|
||||||
|
using DysonNetwork.Shared.Http;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Prometheus;
|
using Prometheus;
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ public static class ApplicationConfiguration
|
|||||||
|
|
||||||
app.UseRequestLocalization();
|
app.UseRequestLocalization();
|
||||||
|
|
||||||
ConfigureForwardedHeaders(app, configuration);
|
app.ConfigureForwardedHeaders(configuration);
|
||||||
|
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
@@ -30,26 +31,4 @@ public static class ApplicationConfiguration
|
|||||||
|
|
||||||
return app;
|
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;
|
||||||
using NodaTime.Serialization.SystemTextJson;
|
using NodaTime.Serialization.SystemTextJson;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using DysonNetwork.Develop.Identity;
|
using DysonNetwork.Develop.Identity;
|
||||||
|
using DysonNetwork.Develop.Project;
|
||||||
using DysonNetwork.Shared.Cache;
|
using DysonNetwork.Shared.Cache;
|
||||||
using StackExchange.Redis;
|
using StackExchange.Redis;
|
||||||
|
|
||||||
@@ -18,19 +20,16 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddDbContext<AppDatabase>();
|
services.AddDbContext<AppDatabase>();
|
||||||
services.AddSingleton<IClock>(SystemClock.Instance);
|
services.AddSingleton<IClock>(SystemClock.Instance);
|
||||||
services.AddHttpContextAccessor();
|
services.AddHttpContextAccessor();
|
||||||
services.AddSingleton<IConnectionMultiplexer>(_ =>
|
|
||||||
{
|
|
||||||
var connection = configuration.GetConnectionString("FastRetrieve")!;
|
|
||||||
return ConnectionMultiplexer.Connect(connection);
|
|
||||||
});
|
|
||||||
services.AddSingleton<ICacheService, CacheServiceRedis>();
|
services.AddSingleton<ICacheService, CacheServiceRedis>();
|
||||||
|
|
||||||
services.AddHttpClient();
|
services.AddHttpClient();
|
||||||
|
|
||||||
services.AddControllers().AddJsonOptions(options =>
|
services.AddControllers().AddJsonOptions(options =>
|
||||||
{
|
{
|
||||||
|
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
|
||||||
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
|
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||||
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
|
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||||
|
|
||||||
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
|
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,6 +49,8 @@ public static class ServiceCollectionExtensions
|
|||||||
|
|
||||||
services.AddScoped<DeveloperService>();
|
services.AddScoped<DeveloperService>();
|
||||||
services.AddScoped<CustomAppService>();
|
services.AddScoped<CustomAppService>();
|
||||||
|
services.AddScoped<DevProjectService>();
|
||||||
|
services.AddScoped<BotAccountService>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,7 @@
|
|||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"ConnectionStrings": {
|
"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",
|
"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"
|
|
||||||
},
|
},
|
||||||
"KnownProxies": [
|
"KnownProxies": [
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
@@ -23,8 +21,6 @@
|
|||||||
},
|
},
|
||||||
"Service": {
|
"Service": {
|
||||||
"Name": "DysonNetwork.Develop",
|
"Name": "DysonNetwork.Develop",
|
||||||
"Url": "https://localhost:7099",
|
"Url": "https://localhost:7192"
|
||||||
"ClientCert": "../Certificates/client.crt",
|
|
||||||
"ClientKey": "../Certificates/client.key"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ public class AppDatabase(
|
|||||||
opt => opt
|
opt => opt
|
||||||
.ConfigureDataSource(optSource => optSource.EnableDynamicJson())
|
.ConfigureDataSource(optSource => optSource.EnableDynamicJson())
|
||||||
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
|
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
|
||||||
.UseNetTopologySuite()
|
|
||||||
.UseNodaTime()
|
.UseNodaTime()
|
||||||
).UseSnakeCaseNamingConvention();
|
).UseSnakeCaseNamingConvention();
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@
|
|||||||
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
|
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
|
<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="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
|
||||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
|
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||||
@@ -67,6 +66,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
|
||||||
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
||||||
</ItemGroup>
|
</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("ProductVersion", "9.0.7")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b =>
|
modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b =>
|
||||||
@@ -172,11 +171,6 @@ namespace DysonNetwork.Drive.Migrations
|
|||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("uploaded_at");
|
.HasColumnName("uploaded_at");
|
||||||
|
|
||||||
b.Property<string>("UploadedTo")
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)")
|
|
||||||
.HasColumnName("uploaded_to");
|
|
||||||
|
|
||||||
b.Property<Dictionary<string, object>>("UserMeta")
|
b.Property<Dictionary<string, object>>("UserMeta")
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("user_meta");
|
.HasColumnName("user_meta");
|
||||||
@@ -382,7 +376,7 @@ namespace DysonNetwork.Drive.Migrations
|
|||||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
|
b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
|
||||||
.WithMany()
|
.WithMany("References")
|
||||||
.HasForeignKey("FileId")
|
.HasForeignKey("FileId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
@@ -391,6 +385,11 @@ namespace DysonNetwork.Drive.Migrations
|
|||||||
b.Navigation("File");
|
b.Navigation("File");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("References");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
|
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Files");
|
b.Navigation("Files");
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ using tusdotnet.Stores;
|
|||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.AddServiceDefaults();
|
||||||
|
|
||||||
// Configure Kestrel and server options
|
// Configure Kestrel and server options
|
||||||
builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxValue);
|
builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxValue);
|
||||||
|
|
||||||
// Add application services
|
// Add application services
|
||||||
builder.Services.AddRegistryService(builder.Configuration);
|
|
||||||
builder.Services.AddAppServices(builder.Configuration);
|
builder.Services.AddAppServices(builder.Configuration);
|
||||||
builder.Services.AddAppRateLimiting();
|
builder.Services.AddAppRateLimiting();
|
||||||
builder.Services.AddAppAuthentication();
|
builder.Services.AddAppAuthentication();
|
||||||
@@ -37,6 +39,8 @@ builder.Services.AddTransient<IPageDataProvider, VersionPageData>();
|
|||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.MapDefaultEndpoints();
|
||||||
|
|
||||||
// Run database migrations
|
// Run database migrations
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
@@ -49,8 +53,6 @@ var tusDiskStore = app.Services.GetRequiredService<TusDiskStore>();
|
|||||||
// Configure application middleware pipeline
|
// Configure application middleware pipeline
|
||||||
app.ConfigureAppMiddleware(tusDiskStore, builder.Environment.ContentRootPath);
|
app.ConfigureAppMiddleware(tusDiskStore, builder.Environment.ContentRootPath);
|
||||||
|
|
||||||
app.MapGatewayProxy();
|
|
||||||
|
|
||||||
app.MapPages(Path.Combine(app.Environment.WebRootPath, "dist", "index.html"));
|
app.MapPages(Path.Combine(app.Environment.WebRootPath, "dist", "index.html"));
|
||||||
|
|
||||||
// Configure gRPC
|
// 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;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
using DysonNetwork.Shared.Cache;
|
using DysonNetwork.Shared.Cache;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
@@ -16,11 +17,6 @@ public static class ServiceCollectionExtensions
|
|||||||
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
|
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services.AddDbContext<AppDatabase>(); // Assuming you'll have an AppDatabase
|
services.AddDbContext<AppDatabase>(); // Assuming you'll have an AppDatabase
|
||||||
services.AddSingleton<IConnectionMultiplexer>(_ =>
|
|
||||||
{
|
|
||||||
var connection = configuration.GetConnectionString("FastRetrieve")!;
|
|
||||||
return ConnectionMultiplexer.Connect(connection);
|
|
||||||
});
|
|
||||||
services.AddSingleton<IClock>(SystemClock.Instance);
|
services.AddSingleton<IClock>(SystemClock.Instance);
|
||||||
services.AddHttpContextAccessor();
|
services.AddHttpContextAccessor();
|
||||||
services.AddSingleton<ICacheService, CacheServiceRedis>(); // Uncomment if you have CacheServiceRedis
|
services.AddSingleton<ICacheService, CacheServiceRedis>(); // Uncomment if you have CacheServiceRedis
|
||||||
@@ -40,6 +36,7 @@ public static class ServiceCollectionExtensions
|
|||||||
|
|
||||||
services.AddControllers().AddJsonOptions(options =>
|
services.AddControllers().AddJsonOptions(options =>
|
||||||
{
|
{
|
||||||
|
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
|
||||||
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
|
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||||
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
|
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||||
|
|
||||||
@@ -140,6 +137,8 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<Storage.FileReferenceService>();
|
services.AddScoped<Storage.FileReferenceService>();
|
||||||
services.AddScoped<Billing.UsageService>();
|
services.AddScoped<Billing.UsageService>();
|
||||||
services.AddScoped<Billing.QuotaService>();
|
services.AddScoped<Billing.QuotaService>();
|
||||||
|
|
||||||
|
services.AddHostedService<BroadcastEventHandler>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,10 +33,6 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
|
|||||||
[JsonIgnore] public FileBundle? Bundle { get; set; }
|
[JsonIgnore] public FileBundle? Bundle { get; set; }
|
||||||
public Guid? BundleId { get; set; }
|
public Guid? BundleId { get; set; }
|
||||||
|
|
||||||
[Obsolete("Deprecated, use PoolId instead. For database migration only.")]
|
|
||||||
[MaxLength(128)]
|
|
||||||
public string? UploadedTo { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The field is set to true if the recycling job plans to delete the file.
|
/// 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.
|
/// 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]
|
[NotMapped]
|
||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public string? FastUploadLink { get; set; }
|
public string? FastUploadLink { get; set; }
|
||||||
|
|
||||||
|
public ICollection<CloudFileReference> References { get; set; } = new List<CloudFileReference>();
|
||||||
|
|
||||||
public Guid AccountId { get; set; }
|
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)
|
.Where(r => r.ResourceId == resourceId && r.Usage == usage)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
if (!references.Any())
|
if (references.Count == 0)
|
||||||
{
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
|
||||||
|
|
||||||
var fileIds = references.Select(r => r.FileId).Distinct().ToList();
|
var fileIds = references.Select(r => r.FileId).Distinct().ToList();
|
||||||
|
|
||||||
@@ -207,6 +205,28 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
|
|||||||
|
|
||||||
return deletedCount;
|
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>
|
/// <summary>
|
||||||
/// Deletes a specific file reference
|
/// Deletes a specific file reference
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ namespace DysonNetwork.Drive.Storage
|
|||||||
public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferences(
|
public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferences(
|
||||||
DeleteResourceReferencesRequest request, ServerCallContext context)
|
DeleteResourceReferencesRequest request, ServerCallContext context)
|
||||||
{
|
{
|
||||||
var deletedCount = 0;
|
int deletedCount;
|
||||||
if (request.Usage is null)
|
if (request.Usage is null)
|
||||||
deletedCount = await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId);
|
deletedCount = await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId);
|
||||||
else
|
else
|
||||||
@@ -93,6 +93,18 @@ namespace DysonNetwork.Drive.Storage
|
|||||||
await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId, request.Usage!);
|
await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId, request.Usage!);
|
||||||
return new DeleteResourceReferencesResponse { DeletedCount = deletedCount };
|
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,
|
public override async Task<DeleteReferenceResponse> DeleteReference(DeleteReferenceRequest request,
|
||||||
ServerCallContext context)
|
ServerCallContext context)
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ public class FileService(
|
|||||||
|
|
||||||
private static readonly string[] AnimatedImageTypes =
|
private static readonly string[] AnimatedImageTypes =
|
||||||
["image/gif", "image/apng", "image/avif"];
|
["image/gif", "image/apng", "image/avif"];
|
||||||
|
|
||||||
private static readonly string[] AnimatedImageExtensions =
|
private static readonly string[] AnimatedImageExtensions =
|
||||||
[".gif", ".apng", ".avif"];
|
[".gif", ".apng", ".avif"];
|
||||||
|
|
||||||
@@ -278,15 +279,15 @@ public class FileService(
|
|||||||
s.Rotation
|
s.Rotation
|
||||||
}).Where(s => double.IsNormal(s.AvgFrameRate)).ToList(),
|
}).Where(s => double.IsNormal(s.AvgFrameRate)).ToList(),
|
||||||
["audio_streams"] = mediaInfo.AudioStreams.Select(s => new
|
["audio_streams"] = mediaInfo.AudioStreams.Select(s => new
|
||||||
{
|
{
|
||||||
s.BitRate,
|
s.BitRate,
|
||||||
s.Channels,
|
s.Channels,
|
||||||
s.ChannelLayout,
|
s.ChannelLayout,
|
||||||
s.CodecName,
|
s.CodecName,
|
||||||
s.Duration,
|
s.Duration,
|
||||||
s.Language,
|
s.Language,
|
||||||
s.SampleRateHz
|
s.SampleRateHz
|
||||||
})
|
})
|
||||||
.ToList(),
|
.ToList(),
|
||||||
};
|
};
|
||||||
if (mediaInfo.PrimaryVideoStream is not null)
|
if (mediaInfo.PrimaryVideoStream is not null)
|
||||||
@@ -336,7 +337,14 @@ public class FileService(
|
|||||||
if (!pool.PolicyConfig.NoOptimization)
|
if (!pool.PolicyConfig.NoOptimization)
|
||||||
switch (contentType.Split('/')[0])
|
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";
|
newMimeType = "image/webp";
|
||||||
using (var vipsImage = Image.NewFromFile(originalFilePath))
|
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
|
var bundle = await db.Bundles
|
||||||
.Where(e => e.Id == id)
|
.Where(e => e.Id == id)
|
||||||
@@ -880,4 +925,4 @@ file class UpdatableCloudFile(CloudFile file)
|
|||||||
.SetProperty(f => f.UserMeta, userMeta!)
|
.SetProperty(f => f.UserMeta, userMeta!)
|
||||||
.SetProperty(f => f.IsMarkedRecycle, IsMarkedRecycle);
|
.SetProperty(f => f.IsMarkedRecycle, IsMarkedRecycle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,9 +10,7 @@
|
|||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
|
"App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
||||||
"FastRetrieve": "localhost:6379",
|
|
||||||
"Etcd": "etcd.orb.local:2379"
|
|
||||||
},
|
},
|
||||||
"Authentication": {
|
"Authentication": {
|
||||||
"Schemes": {
|
"Schemes": {
|
||||||
@@ -130,8 +128,6 @@
|
|||||||
],
|
],
|
||||||
"Service": {
|
"Service": {
|
||||||
"Name": "DysonNetwork.Drive",
|
"Name": "DysonNetwork.Drive",
|
||||||
"Url": "https://localhost:7092",
|
"Url": "https://localhost:7092"
|
||||||
"ClientCert": "../Certificates/client.crt",
|
|
||||||
"ClientKey": "../Certificates/client.key"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,32 +0,0 @@
|
|||||||
using DysonNetwork.Gateway.Startup;
|
|
||||||
|
|
||||||
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.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 Name { get; set; } = string.Empty;
|
||||||
[MaxLength(256)] public string Nick { 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 Language { get; set; } = string.Empty;
|
||||||
|
[MaxLength(32)] public string Region { get; set; } = string.Empty;
|
||||||
public Instant? ActivatedAt { get; set; }
|
public Instant? ActivatedAt { get; set; }
|
||||||
public bool IsSuperuser { get; set; } = false;
|
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 AccountProfile Profile { get; set; } = null!;
|
||||||
public ICollection<AccountContact> Contacts { get; set; } = new List<AccountContact>();
|
public ICollection<AccountContact> Contacts { get; set; } = new List<AccountContact>();
|
||||||
public ICollection<AccountBadge> Badges { get; set; } = new List<AccountBadge>();
|
public ICollection<AccountBadge> Badges { get; set; } = new List<AccountBadge>();
|
||||||
@@ -43,12 +47,14 @@ public class Account : ModelBase
|
|||||||
Name = Name,
|
Name = Name,
|
||||||
Nick = Nick,
|
Nick = Nick,
|
||||||
Language = Language,
|
Language = Language,
|
||||||
|
Region = Region,
|
||||||
ActivatedAt = ActivatedAt?.ToTimestamp(),
|
ActivatedAt = ActivatedAt?.ToTimestamp(),
|
||||||
IsSuperuser = IsSuperuser,
|
IsSuperuser = IsSuperuser,
|
||||||
Profile = Profile.ToProtoValue(),
|
Profile = Profile.ToProtoValue(),
|
||||||
PerkSubscription = PerkSubscription?.ToProtoValue(),
|
PerkSubscription = PerkSubscription?.ToProtoValue(),
|
||||||
CreatedAt = CreatedAt.ToTimestamp(),
|
CreatedAt = CreatedAt.ToTimestamp(),
|
||||||
UpdatedAt = UpdatedAt.ToTimestamp()
|
UpdatedAt = UpdatedAt.ToTimestamp(),
|
||||||
|
AutomatedId = AutomatedId?.ToString()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add contacts
|
// Add contacts
|
||||||
@@ -71,6 +77,7 @@ public class Account : ModelBase
|
|||||||
Name = proto.Name,
|
Name = proto.Name,
|
||||||
Nick = proto.Nick,
|
Nick = proto.Nick,
|
||||||
Language = proto.Language,
|
Language = proto.Language,
|
||||||
|
Region = proto.Region,
|
||||||
ActivatedAt = proto.ActivatedAt?.ToInstant(),
|
ActivatedAt = proto.ActivatedAt?.ToInstant(),
|
||||||
IsSuperuser = proto.IsSuperuser,
|
IsSuperuser = proto.IsSuperuser,
|
||||||
PerkSubscription = proto.PerkSubscription is not null
|
PerkSubscription = proto.PerkSubscription is not null
|
||||||
@@ -78,10 +85,10 @@ public class Account : ModelBase
|
|||||||
: null,
|
: null,
|
||||||
CreatedAt = proto.CreatedAt.ToInstant(),
|
CreatedAt = proto.CreatedAt.ToInstant(),
|
||||||
UpdatedAt = proto.UpdatedAt.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)
|
foreach (var contactProto in proto.Contacts)
|
||||||
account.Contacts.Add(AccountContact.FromProtoValue(contactProto));
|
account.Contacts.Add(AccountContact.FromProtoValue(contactProto));
|
||||||
|
|
||||||
@@ -116,7 +123,7 @@ public abstract class Leveling
|
|||||||
|
|
||||||
public class AccountProfile : ModelBase, IIdentifiedResource
|
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? FirstName { get; set; }
|
||||||
[MaxLength(256)] public string? MiddleName { get; set; }
|
[MaxLength(256)] public string? MiddleName { get; set; }
|
||||||
[MaxLength(256)] public string? LastName { 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 VerificationMark? Verification { get; set; }
|
||||||
[Column(TypeName = "jsonb")] public BadgeReferenceObject? ActiveBadge { 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;
|
[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]
|
[NotMapped]
|
||||||
public double LevelingProgress => Level >= Leveling.ExperiencePerLevel.Count - 1
|
public double LevelingProgress => Level >= Leveling.ExperiencePerLevel.Count - 1
|
||||||
? 100
|
? 100
|
||||||
@@ -165,6 +183,8 @@ public class AccountProfile : ModelBase, IIdentifiedResource
|
|||||||
Experience = Experience,
|
Experience = Experience,
|
||||||
Level = Level,
|
Level = Level,
|
||||||
LevelingProgress = LevelingProgress,
|
LevelingProgress = LevelingProgress,
|
||||||
|
SocialCredits = SocialCredits,
|
||||||
|
SocialCreditsLevel = SocialCreditsLevel,
|
||||||
Picture = Picture?.ToProtoValue(),
|
Picture = Picture?.ToProtoValue(),
|
||||||
Background = Background?.ToProtoValue(),
|
Background = Background?.ToProtoValue(),
|
||||||
AccountId = AccountId.ToString(),
|
AccountId = AccountId.ToString(),
|
||||||
@@ -195,6 +215,7 @@ public class AccountProfile : ModelBase, IIdentifiedResource
|
|||||||
Verification = proto.Verification is null ? null : VerificationMark.FromProtoValue(proto.Verification),
|
Verification = proto.Verification is null ? null : VerificationMark.FromProtoValue(proto.Verification),
|
||||||
ActiveBadge = proto.ActiveBadge is null ? null : BadgeReferenceObject.FromProtoValue(proto.ActiveBadge),
|
ActiveBadge = proto.ActiveBadge is null ? null : BadgeReferenceObject.FromProtoValue(proto.ActiveBadge),
|
||||||
Experience = proto.Experience,
|
Experience = proto.Experience,
|
||||||
|
SocialCredits = proto.SocialCredits,
|
||||||
Picture = proto.Picture is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Picture),
|
Picture = proto.Picture is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Picture),
|
||||||
Background = proto.Background is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Background),
|
Background = proto.Background is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Background),
|
||||||
AccountId = Guid.Parse(proto.AccountId),
|
AccountId = Guid.Parse(proto.AccountId),
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using DysonNetwork.Pass.Auth;
|
using DysonNetwork.Pass.Auth;
|
||||||
|
using DysonNetwork.Pass.Credit;
|
||||||
using DysonNetwork.Pass.Wallet;
|
using DysonNetwork.Pass.Wallet;
|
||||||
|
using DysonNetwork.Shared.Error;
|
||||||
|
using DysonNetwork.Shared.GeoIp;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
@@ -14,7 +17,9 @@ public class AccountController(
|
|||||||
AuthService auth,
|
AuthService auth,
|
||||||
AccountService accounts,
|
AccountService accounts,
|
||||||
SubscriptionService subscriptions,
|
SubscriptionService subscriptions,
|
||||||
AccountEventService events
|
AccountEventService events,
|
||||||
|
SocialCreditService socialCreditService,
|
||||||
|
GeoIpService geo
|
||||||
) : ControllerBase
|
) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("{name}")]
|
[HttpGet("{name}")]
|
||||||
@@ -28,11 +33,11 @@ public class AccountController(
|
|||||||
.Include(e => e.Contacts.Where(c => c.IsPublic))
|
.Include(e => e.Contacts.Where(c => c.IsPublic))
|
||||||
.Where(a => a.Name == name)
|
.Where(a => a.Name == name)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (account is null) return new NotFoundResult();
|
if (account is null) return NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier));
|
||||||
|
|
||||||
var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id);
|
var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id);
|
||||||
account.PerkSubscription = perk?.ToReference();
|
account.PerkSubscription = perk?.ToReference();
|
||||||
|
|
||||||
return account;
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +50,28 @@ public class AccountController(
|
|||||||
.Include(e => e.Badges)
|
.Include(e => e.Badges)
|
||||||
.Where(a => a.Name == name)
|
.Where(a => a.Name == name)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
return account is null ? NotFound() : 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
|
public class AccountCreateRequest
|
||||||
@@ -71,7 +97,7 @@ public class AccountController(
|
|||||||
[MaxLength(128)]
|
[MaxLength(128)]
|
||||||
public string Password { get; set; } = string.Empty;
|
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;
|
[Required] public string CaptchaToken { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
@@ -81,7 +107,15 @@ public class AccountController(
|
|||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
public async Task<ActionResult<Account>> CreateAccount([FromBody] AccountCreateRequest request)
|
public async Task<ActionResult<Account>> CreateAccount([FromBody] AccountCreateRequest request)
|
||||||
{
|
{
|
||||||
if (!await auth.ValidateCaptcha(request.CaptchaToken)) return BadRequest("Invalid captcha token.");
|
if (!await auth.ValidateCaptcha(request.CaptchaToken))
|
||||||
|
return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
[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
|
try
|
||||||
{
|
{
|
||||||
@@ -90,13 +124,21 @@ public class AccountController(
|
|||||||
request.Nick,
|
request.Nick,
|
||||||
request.Email,
|
request.Email,
|
||||||
request.Password,
|
request.Password,
|
||||||
request.Language
|
request.Language,
|
||||||
|
region
|
||||||
);
|
);
|
||||||
return Ok(account);
|
return Ok(account);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return BadRequest(ex.Message);
|
return BadRequest(new ApiError
|
||||||
|
{
|
||||||
|
Code = "BAD_REQUEST",
|
||||||
|
Message = "Failed to create account.",
|
||||||
|
Detail = ex.Message,
|
||||||
|
Status = 400,
|
||||||
|
TraceId = HttpContext.TraceIdentifier
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,10 +151,22 @@ public class AccountController(
|
|||||||
[HttpPost("recovery/password")]
|
[HttpPost("recovery/password")]
|
||||||
public async Task<ActionResult> RequestResetPassword([FromBody] RecoveryPasswordRequest request)
|
public async Task<ActionResult> RequestResetPassword([FromBody] RecoveryPasswordRequest request)
|
||||||
{
|
{
|
||||||
if (!await auth.ValidateCaptcha(request.CaptchaToken)) return BadRequest("Invalid captcha token.");
|
if (!await auth.ValidateCaptcha(request.CaptchaToken))
|
||||||
|
return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
[nameof(request.CaptchaToken)] = new[] { "Invalid captcha token." }
|
||||||
|
}, traceId: HttpContext.TraceIdentifier));
|
||||||
|
|
||||||
var account = await accounts.LookupAccount(request.Account);
|
var account = await accounts.LookupAccount(request.Account);
|
||||||
if (account is null) return BadRequest("Unable to find the account.");
|
if (account is null)
|
||||||
|
return BadRequest(new ApiError
|
||||||
|
{
|
||||||
|
Code = "NOT_FOUND",
|
||||||
|
Message = "Unable to find the account.",
|
||||||
|
Detail = request.Account,
|
||||||
|
Status = 400,
|
||||||
|
TraceId = HttpContext.TraceIdentifier
|
||||||
|
});
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -120,7 +174,13 @@ public class AccountController(
|
|||||||
}
|
}
|
||||||
catch (InvalidOperationException)
|
catch (InvalidOperationException)
|
||||||
{
|
{
|
||||||
return BadRequest("You already requested password reset within 24 hours.");
|
return BadRequest(new ApiError
|
||||||
|
{
|
||||||
|
Code = "TOO_MANY_REQUESTS",
|
||||||
|
Message = "You already requested password reset within 24 hours.",
|
||||||
|
Status = 400,
|
||||||
|
TraceId = HttpContext.TraceIdentifier
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok();
|
return Ok();
|
||||||
@@ -131,7 +191,9 @@ public class AccountController(
|
|||||||
public StatusAttitude Attitude { get; set; }
|
public StatusAttitude Attitude { get; set; }
|
||||||
public bool IsInvisible { get; set; }
|
public bool IsInvisible { get; set; }
|
||||||
public bool IsNotDisturb { get; set; }
|
public bool IsNotDisturb { get; set; }
|
||||||
|
public bool IsAutomated { get; set; } = false;
|
||||||
[MaxLength(1024)] public string? Label { get; set; }
|
[MaxLength(1024)] public string? Label { get; set; }
|
||||||
|
[MaxLength(4096)] public string? AppIdentifier { get; set; }
|
||||||
public Instant? ClearedAt { get; set; }
|
public Instant? ClearedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +201,15 @@ public class AccountController(
|
|||||||
public async Task<ActionResult<Status>> GetOtherStatus(string name)
|
public async Task<ActionResult<Status>> GetOtherStatus(string name)
|
||||||
{
|
{
|
||||||
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name);
|
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name);
|
||||||
if (account is null) return BadRequest();
|
if (account is null)
|
||||||
|
return BadRequest(new ApiError
|
||||||
|
{
|
||||||
|
Code = "NOT_FOUND",
|
||||||
|
Message = "Account not found.",
|
||||||
|
Detail = name,
|
||||||
|
Status = 400,
|
||||||
|
TraceId = HttpContext.TraceIdentifier
|
||||||
|
});
|
||||||
var status = await events.GetStatus(account.Id);
|
var status = await events.GetStatus(account.Id);
|
||||||
status.IsInvisible = false; // Keep the invisible field not available for other users
|
status.IsInvisible = false; // Keep the invisible field not available for other users
|
||||||
return Ok(status);
|
return Ok(status);
|
||||||
@@ -156,11 +226,27 @@ public class AccountController(
|
|||||||
month ??= currentDate.Month;
|
month ??= currentDate.Month;
|
||||||
year ??= currentDate.Year;
|
year ??= currentDate.Year;
|
||||||
|
|
||||||
if (month is < 1 or > 12) return BadRequest("Invalid month.");
|
if (month is < 1 or > 12)
|
||||||
if (year < 1) return BadRequest("Invalid year.");
|
return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
[nameof(month)] = new[] { "Month must be between 1 and 12." }
|
||||||
|
}, traceId: HttpContext.TraceIdentifier));
|
||||||
|
if (year < 1)
|
||||||
|
return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
[nameof(year)] = new[] { "Year must be a positive integer." }
|
||||||
|
}, traceId: HttpContext.TraceIdentifier));
|
||||||
|
|
||||||
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name);
|
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name);
|
||||||
if (account is null) return BadRequest();
|
if (account is null)
|
||||||
|
return BadRequest(new ApiError
|
||||||
|
{
|
||||||
|
Code = "not_found",
|
||||||
|
Message = "Account not found.",
|
||||||
|
Detail = name,
|
||||||
|
Status = 400,
|
||||||
|
TraceId = HttpContext.TraceIdentifier
|
||||||
|
});
|
||||||
|
|
||||||
var calendar = await events.GetEventCalendar(account, month.Value, year.Value, replaceInvisible: true);
|
var calendar = await events.GetEventCalendar(account, month.Value, year.Value, replaceInvisible: true);
|
||||||
return Ok(calendar);
|
return Ok(calendar);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using DysonNetwork.Pass.Auth;
|
|||||||
using DysonNetwork.Pass.Permission;
|
using DysonNetwork.Pass.Permission;
|
||||||
using DysonNetwork.Pass.Wallet;
|
using DysonNetwork.Pass.Wallet;
|
||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Data;
|
||||||
|
using DysonNetwork.Shared.Error;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -23,11 +24,13 @@ public class AccountCurrentController(
|
|||||||
AccountEventService events,
|
AccountEventService events,
|
||||||
AuthService auth,
|
AuthService auth,
|
||||||
FileService.FileServiceClient files,
|
FileService.FileServiceClient files,
|
||||||
FileReferenceService.FileReferenceServiceClient fileRefs
|
FileReferenceService.FileReferenceServiceClient fileRefs,
|
||||||
|
Credit.SocialCreditService creditService
|
||||||
) : ControllerBase
|
) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
|
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ApiError>(StatusCodes.Status401Unauthorized)]
|
||||||
public async Task<ActionResult<Account>> GetCurrentIdentity()
|
public async Task<ActionResult<Account>> GetCurrentIdentity()
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
@@ -49,6 +52,7 @@ public class AccountCurrentController(
|
|||||||
{
|
{
|
||||||
[MaxLength(256)] public string? Nick { get; set; }
|
[MaxLength(256)] public string? Nick { get; set; }
|
||||||
[MaxLength(32)] public string? Language { get; set; }
|
[MaxLength(32)] public string? Language { get; set; }
|
||||||
|
[MaxLength(32)] public string? Region { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch]
|
[HttpPatch]
|
||||||
@@ -60,6 +64,7 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
if (request.Nick is not null) account.Nick = request.Nick;
|
if (request.Nick is not null) account.Nick = request.Nick;
|
||||||
if (request.Language is not null) account.Language = request.Language;
|
if (request.Language is not null) account.Language = request.Language;
|
||||||
|
if (request.Region is not null) account.Region = request.Region;
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
await accounts.PurgeAccountCache(currentUser);
|
await accounts.PurgeAccountCache(currentUser);
|
||||||
@@ -92,7 +97,14 @@ public class AccountCurrentController(
|
|||||||
var profile = await db.AccountProfiles
|
var profile = await db.AccountProfiles
|
||||||
.Where(p => p.Account.Id == userId)
|
.Where(p => p.Account.Id == userId)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (profile is null) return BadRequest("Unable to get your account.");
|
if (profile is null)
|
||||||
|
return BadRequest(new ApiError
|
||||||
|
{
|
||||||
|
Code = "NOT_FOUND",
|
||||||
|
Message = "Unable to get your account.",
|
||||||
|
Status = 400,
|
||||||
|
TraceId = HttpContext.TraceIdentifier
|
||||||
|
});
|
||||||
|
|
||||||
if (request.FirstName is not null) profile.FirstName = request.FirstName;
|
if (request.FirstName is not null) profile.FirstName = request.FirstName;
|
||||||
if (request.MiddleName is not null) profile.MiddleName = request.MiddleName;
|
if (request.MiddleName is not null) profile.MiddleName = request.MiddleName;
|
||||||
@@ -160,7 +172,13 @@ public class AccountCurrentController(
|
|||||||
}
|
}
|
||||||
catch (InvalidOperationException)
|
catch (InvalidOperationException)
|
||||||
{
|
{
|
||||||
return BadRequest("You already requested account deletion within 24 hours.");
|
return BadRequest(new ApiError
|
||||||
|
{
|
||||||
|
Code = "TOO_MANY_REQUESTS",
|
||||||
|
Message = "You already requested account deletion within 24 hours.",
|
||||||
|
Status = 400,
|
||||||
|
TraceId = HttpContext.TraceIdentifier
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok();
|
return Ok();
|
||||||
@@ -179,6 +197,8 @@ public class AccountCurrentController(
|
|||||||
public async Task<ActionResult<Status>> UpdateStatus([FromBody] AccountController.StatusRequest request)
|
public async Task<ActionResult<Status>> UpdateStatus([FromBody] AccountController.StatusRequest request)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
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 now = SystemClock.Instance.GetCurrentInstant();
|
||||||
var status = await db.AccountStatuses
|
var status = await db.AccountStatuses
|
||||||
@@ -186,12 +206,16 @@ public class AccountCurrentController(
|
|||||||
.Where(e => e.ClearedAt == null || e.ClearedAt > now)
|
.Where(e => e.ClearedAt == null || e.ClearedAt > now)
|
||||||
.OrderByDescending(e => e.CreatedAt)
|
.OrderByDescending(e => e.CreatedAt)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (status is null) return NotFound();
|
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.Attitude = request.Attitude;
|
||||||
status.IsInvisible = request.IsInvisible;
|
status.IsInvisible = request.IsInvisible;
|
||||||
status.IsNotDisturb = request.IsNotDisturb;
|
status.IsNotDisturb = request.IsNotDisturb;
|
||||||
|
status.IsAutomated = request.IsAutomated;
|
||||||
status.Label = request.Label;
|
status.Label = request.Label;
|
||||||
|
status.AppIdentifier = request.AppIdentifier;
|
||||||
status.ClearedAt = request.ClearedAt;
|
status.ClearedAt = request.ClearedAt;
|
||||||
|
|
||||||
db.Update(status);
|
db.Update(status);
|
||||||
@@ -207,13 +231,44 @@ public class AccountCurrentController(
|
|||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
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
|
var status = new Status
|
||||||
{
|
{
|
||||||
AccountId = currentUser.Id,
|
AccountId = currentUser.Id,
|
||||||
Attitude = request.Attitude,
|
Attitude = request.Attitude,
|
||||||
IsInvisible = request.IsInvisible,
|
IsInvisible = request.IsInvisible,
|
||||||
IsNotDisturb = request.IsNotDisturb,
|
IsNotDisturb = request.IsNotDisturb,
|
||||||
|
IsAutomated = request.IsAutomated,
|
||||||
Label = request.Label,
|
Label = request.Label,
|
||||||
|
AppIdentifier = request.AppIdentifier,
|
||||||
ClearedAt = request.ClearedAt
|
ClearedAt = request.ClearedAt
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -221,15 +276,21 @@ public class AccountCurrentController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("statuses")]
|
[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();
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
var status = await db.AccountStatuses
|
var queryable = db.AccountStatuses
|
||||||
.Where(s => s.AccountId == currentUser.Id)
|
.Where(s => s.AccountId == currentUser.Id)
|
||||||
.Where(s => s.ClearedAt == null || s.ClearedAt > now)
|
.Where(s => s.ClearedAt == null || s.ClearedAt > now)
|
||||||
.OrderByDescending(s => s.CreatedAt)
|
.OrderByDescending(s => s.CreatedAt)
|
||||||
|
.AsQueryable();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(app))
|
||||||
|
queryable = queryable.Where(s => s.IsAutomated && s.AppIdentifier == app);
|
||||||
|
|
||||||
|
var status = await queryable
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (status is null) return NotFound();
|
if (status is null) return NotFound();
|
||||||
|
|
||||||
@@ -254,7 +315,9 @@ public class AccountCurrentController(
|
|||||||
.OrderByDescending(x => x.CreatedAt)
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
return result is null ? NotFound() : Ok(result);
|
return result is null
|
||||||
|
? NotFound(ApiError.NotFound("check-in", traceId: HttpContext.TraceIdentifier))
|
||||||
|
: Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("check-in")]
|
[HttpPost("check-in")]
|
||||||
@@ -269,15 +332,30 @@ public class AccountCurrentController(
|
|||||||
{
|
{
|
||||||
var isAvailable = await events.CheckInDailyIsAvailable(currentUser);
|
var isAvailable = await events.CheckInDailyIsAvailable(currentUser);
|
||||||
if (!isAvailable)
|
if (!isAvailable)
|
||||||
return BadRequest("Check-in is not available for today.");
|
return BadRequest(new ApiError
|
||||||
|
{
|
||||||
|
Code = "BAD_REQUEST",
|
||||||
|
Message = "Check-in is not available for today.",
|
||||||
|
Status = 400,
|
||||||
|
TraceId = HttpContext.TraceIdentifier
|
||||||
|
});
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (currentUser.PerkSubscription is null)
|
if (currentUser.PerkSubscription is null)
|
||||||
return StatusCode(403, "You need to have a subscription to check-in backdated.");
|
return StatusCode(403, ApiError.Unauthorized(
|
||||||
|
message: "You need to have a subscription to check-in backdated.",
|
||||||
|
forbidden: true,
|
||||||
|
traceId: HttpContext.TraceIdentifier));
|
||||||
var isAvailable = await events.CheckInBackdatedIsAvailable(currentUser, backdated.Value);
|
var isAvailable = await events.CheckInBackdatedIsAvailable(currentUser, backdated.Value);
|
||||||
if (!isAvailable)
|
if (!isAvailable)
|
||||||
return BadRequest("Check-in is not available for this date.");
|
return BadRequest(new ApiError
|
||||||
|
{
|
||||||
|
Code = "BAD_REQUEST",
|
||||||
|
Message = "Check-in is not available for this date.",
|
||||||
|
Status = 400,
|
||||||
|
TraceId = HttpContext.TraceIdentifier
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -286,15 +364,32 @@ public class AccountCurrentController(
|
|||||||
return needsCaptcha switch
|
return needsCaptcha switch
|
||||||
{
|
{
|
||||||
true when string.IsNullOrWhiteSpace(captchaToken) => StatusCode(423,
|
true when string.IsNullOrWhiteSpace(captchaToken) => StatusCode(423,
|
||||||
"Captcha is required for this check-in."
|
new ApiError
|
||||||
|
{
|
||||||
|
Code = "CAPTCHA_REQUIRED",
|
||||||
|
Message = "Captcha is required for this check-in.",
|
||||||
|
Status = 423,
|
||||||
|
TraceId = HttpContext.TraceIdentifier
|
||||||
|
}
|
||||||
),
|
),
|
||||||
true when !await auth.ValidateCaptcha(captchaToken!) => BadRequest("Invalid captcha token."),
|
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)
|
_ => await events.CheckInDaily(currentUser, backdated)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex)
|
catch (InvalidOperationException ex)
|
||||||
{
|
{
|
||||||
return BadRequest(ex.Message);
|
return BadRequest(new ApiError
|
||||||
|
{
|
||||||
|
Code = "BAD_REQUEST",
|
||||||
|
Message = "Check-in failed.",
|
||||||
|
Detail = ex.Message,
|
||||||
|
Status = 400,
|
||||||
|
TraceId = HttpContext.TraceIdentifier
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,8 +403,16 @@ public class AccountCurrentController(
|
|||||||
month ??= currentDate.Month;
|
month ??= currentDate.Month;
|
||||||
year ??= currentDate.Year;
|
year ??= currentDate.Year;
|
||||||
|
|
||||||
if (month is < 1 or > 12) return BadRequest("Invalid month.");
|
if (month is < 1 or > 12)
|
||||||
if (year < 1) return BadRequest("Invalid year.");
|
return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
[nameof(month)] = new[] { "Month must be between 1 and 12." }
|
||||||
|
}, traceId: HttpContext.TraceIdentifier));
|
||||||
|
if (year < 1)
|
||||||
|
return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
[nameof(year)] = new[] { "Year must be a positive integer." }
|
||||||
|
}, traceId: HttpContext.TraceIdentifier));
|
||||||
|
|
||||||
var calendar = await events.GetEventCalendar(currentUser, month.Value, year.Value);
|
var calendar = await events.GetEventCalendar(currentUser, month.Value, year.Value);
|
||||||
return Ok(calendar);
|
return Ok(calendar);
|
||||||
@@ -365,7 +468,13 @@ public class AccountCurrentController(
|
|||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
if (await accounts.CheckAuthFactorExists(currentUser, request.Type))
|
if (await accounts.CheckAuthFactorExists(currentUser, request.Type))
|
||||||
return BadRequest($"Auth factor with type {request.Type} is already exists.");
|
return BadRequest(new ApiError
|
||||||
|
{
|
||||||
|
Code = "ALREADY_EXISTS",
|
||||||
|
Message = $"Auth factor with type {request.Type} already exists.",
|
||||||
|
Status = 400,
|
||||||
|
TraceId = HttpContext.TraceIdentifier
|
||||||
|
});
|
||||||
|
|
||||||
var factor = await accounts.CreateAuthFactor(currentUser, request.Type, request.Secret);
|
var factor = await accounts.CreateAuthFactor(currentUser, request.Type, request.Secret);
|
||||||
return Ok(factor);
|
return Ok(factor);
|
||||||
@@ -380,7 +489,7 @@ public class AccountCurrentController(
|
|||||||
var factor = await db.AccountAuthFactors
|
var factor = await db.AccountAuthFactors
|
||||||
.Where(f => f.AccountId == currentUser.Id && f.Id == id)
|
.Where(f => f.AccountId == currentUser.Id && f.Id == id)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (factor is null) return NotFound();
|
if (factor is null) return NotFound(ApiError.NotFound(id.ToString(), traceId: HttpContext.TraceIdentifier));
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -389,7 +498,14 @@ public class AccountCurrentController(
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return BadRequest(ex.Message);
|
return BadRequest(new ApiError
|
||||||
|
{
|
||||||
|
Code = "BAD_REQUEST",
|
||||||
|
Message = "Failed to enable auth factor.",
|
||||||
|
Detail = ex.Message,
|
||||||
|
Status = 400,
|
||||||
|
TraceId = HttpContext.TraceIdentifier
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,4 +873,60 @@ public class AccountCurrentController(
|
|||||||
return BadRequest(ex.Message);
|
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,
|
Wallet.PaymentService payment,
|
||||||
ICacheService cache,
|
ICacheService cache,
|
||||||
IStringLocalizer<Localization.AccountEventResource> localizer,
|
IStringLocalizer<Localization.AccountEventResource> localizer,
|
||||||
PusherService.PusherServiceClient pusher,
|
RingService.RingServiceClient pusher,
|
||||||
SubscriptionService subscriptions
|
SubscriptionService subscriptions,
|
||||||
|
Pass.Leveling.ExperienceService experienceService
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
private static readonly Random Random = new();
|
private static readonly Random Random = new();
|
||||||
@@ -327,13 +328,15 @@ public class AccountEventService(
|
|||||||
result.RewardPoints = null;
|
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);
|
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
|
// The lock will be automatically released by the await using statement
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -1,28 +1,39 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
using DysonNetwork.Pass.Auth;
|
using DysonNetwork.Pass.Auth;
|
||||||
using DysonNetwork.Pass.Auth.OpenId;
|
using DysonNetwork.Pass.Auth.OpenId;
|
||||||
using DysonNetwork.Pass.Email;
|
using DysonNetwork.Pass.Email;
|
||||||
using DysonNetwork.Pass.Localization;
|
using DysonNetwork.Pass.Localization;
|
||||||
using DysonNetwork.Pass.Permission;
|
using DysonNetwork.Pass.Permission;
|
||||||
using DysonNetwork.Shared.Cache;
|
using DysonNetwork.Shared.Cache;
|
||||||
|
using DysonNetwork.Shared.Data;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using DysonNetwork.Shared.Stream;
|
||||||
using EFCore.BulkExtensions;
|
using EFCore.BulkExtensions;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
|
using NATS.Client.Core;
|
||||||
|
using NATS.Client.JetStream;
|
||||||
|
using NATS.Net;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using OtpNet;
|
using OtpNet;
|
||||||
|
using AuthService = DysonNetwork.Pass.Auth.AuthService;
|
||||||
|
|
||||||
namespace DysonNetwork.Pass.Account;
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
public class AccountService(
|
public class AccountService(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
MagicSpellService spells,
|
MagicSpellService spells,
|
||||||
|
FileService.FileServiceClient files,
|
||||||
|
FileReferenceService.FileReferenceServiceClient fileRefs,
|
||||||
AccountUsernameService uname,
|
AccountUsernameService uname,
|
||||||
EmailService mailer,
|
EmailService mailer,
|
||||||
PusherService.PusherServiceClient pusher,
|
RingService.RingServiceClient pusher,
|
||||||
IStringLocalizer<NotificationResource> localizer,
|
IStringLocalizer<NotificationResource> localizer,
|
||||||
|
IStringLocalizer<EmailResource> emailLocalizer,
|
||||||
ICacheService cache,
|
ICacheService cache,
|
||||||
ILogger<AccountService> logger
|
ILogger<AccountService> logger,
|
||||||
|
INatsConnection nats
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
public static void SetCultureInfo(Account account)
|
public static void SetCultureInfo(Account account)
|
||||||
@@ -79,6 +90,7 @@ public class AccountService(
|
|||||||
string email,
|
string email,
|
||||||
string? password,
|
string? password,
|
||||||
string language = "en-US",
|
string language = "en-US",
|
||||||
|
string region = "en",
|
||||||
bool isEmailVerified = false,
|
bool isEmailVerified = false,
|
||||||
bool isActivated = false
|
bool isActivated = false
|
||||||
)
|
)
|
||||||
@@ -98,6 +110,7 @@ public class AccountService(
|
|||||||
Name = name,
|
Name = name,
|
||||||
Nick = nick,
|
Nick = nick,
|
||||||
Language = language,
|
Language = language,
|
||||||
|
Region = region,
|
||||||
Contacts = new List<AccountContact>
|
Contacts = new List<AccountContact>
|
||||||
{
|
{
|
||||||
new()
|
new()
|
||||||
@@ -172,11 +185,66 @@ public class AccountService(
|
|||||||
userInfo.Email,
|
userInfo.Email,
|
||||||
null,
|
null,
|
||||||
"en-US",
|
"en-US",
|
||||||
|
"en",
|
||||||
userInfo.EmailVerified,
|
userInfo.EmailVerified,
|
||||||
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)
|
public async Task RequestAccountDeletion(Account account)
|
||||||
{
|
{
|
||||||
var spell = await spells.CreateMagicSpell(
|
var spell = await spells.CreateMagicSpell(
|
||||||
@@ -335,12 +403,10 @@ public class AccountService(
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Send the auth factor verification code to users, for factors like in-app code and email.
|
/// Send the auth factor verification code to users, for factors like in-app code and email.
|
||||||
/// Sometimes it requires a hint, like a part of the user's email address to ensure the user is who own the account.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="account">The owner of the auth factor</param>
|
/// <param name="account">The owner of the auth factor</param>
|
||||||
/// <param name="factor">The auth factor needed to send code</param>
|
/// <param name="factor">The auth factor needed to send code</param>
|
||||||
/// <param name="hint">The part of the contact method for verification</param>
|
public async Task SendFactorCode(Account account, AccountAuthFactor factor)
|
||||||
public async Task SendFactorCode(Account account, AccountAuthFactor factor, string? hint = null)
|
|
||||||
{
|
{
|
||||||
var code = new Random().Next(100000, 999999).ToString("000000");
|
var code = new Random().Next(100000, 999999).ToString("000000");
|
||||||
|
|
||||||
@@ -369,30 +435,18 @@ public class AccountService(
|
|||||||
if (await _GetFactorCode(factor) is not null)
|
if (await _GetFactorCode(factor) is not null)
|
||||||
throw new InvalidOperationException("A factor code has been sent and in active duration.");
|
throw new InvalidOperationException("A factor code has been sent and in active duration.");
|
||||||
|
|
||||||
ArgumentNullException.ThrowIfNull(hint);
|
|
||||||
hint = hint.Replace("@", "").Replace(".", "").Replace("+", "").Replace("%", "");
|
|
||||||
if (string.IsNullOrWhiteSpace(hint))
|
|
||||||
{
|
|
||||||
logger.LogWarning(
|
|
||||||
"Unable to send factor code to #{FactorId} with hint {Hint}, due to invalid hint...",
|
|
||||||
factor.Id,
|
|
||||||
hint
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var contact = await db.AccountContacts
|
var contact = await db.AccountContacts
|
||||||
.Where(c => c.Type == AccountContactType.Email)
|
.Where(c => c.Type == AccountContactType.Email)
|
||||||
.Where(c => c.VerifiedAt != null)
|
.Where(c => c.VerifiedAt != null)
|
||||||
.Where(c => EF.Functions.ILike(c.Content, $"%{hint}%"))
|
.Where(c => c.IsPrimary)
|
||||||
|
.Where(c => c.AccountId == account.Id)
|
||||||
.Include(c => c.Account)
|
.Include(c => c.Account)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (contact is null)
|
if (contact is null)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"Unable to send factor code to #{FactorId} with hint {Hint}, due to no contact method found according to hint...",
|
"Unable to send factor code to #{FactorId} with, due to no contact method was found...",
|
||||||
factor.Id,
|
factor.Id
|
||||||
hint
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -401,7 +455,7 @@ public class AccountService(
|
|||||||
.SendTemplatedEmailAsync<Pages.Emails.VerificationEmail, VerificationEmailModel>(
|
.SendTemplatedEmailAsync<Pages.Emails.VerificationEmail, VerificationEmailModel>(
|
||||||
account.Nick,
|
account.Nick,
|
||||||
contact.Content,
|
contact.Content,
|
||||||
localizer["VerificationEmail"],
|
emailLocalizer["VerificationEmail"],
|
||||||
new VerificationEmailModel
|
new VerificationEmailModel
|
||||||
{
|
{
|
||||||
Name = account.Name,
|
Name = account.Name,
|
||||||
@@ -455,7 +509,7 @@ public class AccountService(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> IsDeviceActive(Guid id)
|
private async Task<bool> IsDeviceActive(Guid id)
|
||||||
{
|
{
|
||||||
return await db.AuthSessions
|
return await db.AuthSessions
|
||||||
.Include(s => s.Challenge)
|
.Include(s => s.Challenge)
|
||||||
@@ -464,8 +518,7 @@ public class AccountService(
|
|||||||
|
|
||||||
public async Task<AuthClient> UpdateDeviceName(Account account, string deviceId, string label)
|
public async Task<AuthClient> UpdateDeviceName(Account account, string deviceId, string label)
|
||||||
{
|
{
|
||||||
var device = await db.AuthClients.FirstOrDefaultAsync(
|
var device = await db.AuthClients.FirstOrDefaultAsync(c => c.DeviceId == deviceId && c.AccountId == account.Id
|
||||||
c => c.DeviceId == deviceId && c.AccountId == account.Id
|
|
||||||
);
|
);
|
||||||
if (device is null) throw new InvalidOperationException("Device was not found.");
|
if (device is null) throw new InvalidOperationException("Device was not found.");
|
||||||
|
|
||||||
@@ -485,57 +538,51 @@ public class AccountService(
|
|||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (session is null) throw new InvalidOperationException("Session was not found.");
|
if (session is null) throw new InvalidOperationException("Session was not found.");
|
||||||
|
|
||||||
var sessions = await db.AuthSessions
|
// The current session should be included in the sessions' list
|
||||||
.Include(s => s.Challenge)
|
db.AuthSessions.Remove(session);
|
||||||
.Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId)
|
await db.SaveChangesAsync();
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
if (session.Challenge.ClientId.HasValue)
|
if (session.Challenge.ClientId.HasValue)
|
||||||
{
|
{
|
||||||
if (!await IsDeviceActive(session.Challenge.ClientId.Value))
|
if (!await IsDeviceActive(session.Challenge.ClientId.Value))
|
||||||
await pusher.UnsubscribePushNotificationsAsync(new UnsubscribePushNotificationsRequest()
|
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
|
logger.LogInformation("Deleted session #{SessionId}", session.Id);
|
||||||
await db.AuthSessions
|
|
||||||
.Include(s => s.Challenge)
|
|
||||||
.Where(s => s.Challenge.DeviceId == session.Challenge.DeviceId)
|
|
||||||
.ExecuteDeleteAsync();
|
|
||||||
|
|
||||||
foreach (var item in sessions)
|
await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{session.Id}");
|
||||||
await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteDevice(Account account, string deviceId)
|
public async Task DeleteDevice(Account account, string deviceId)
|
||||||
{
|
{
|
||||||
var device = await db.AuthClients.FirstOrDefaultAsync(
|
var device = await db.AuthClients.FirstOrDefaultAsync(c => c.DeviceId == deviceId && c.AccountId == account.Id
|
||||||
c => c.DeviceId == deviceId && c.AccountId == account.Id
|
|
||||||
);
|
);
|
||||||
if (device is null)
|
if (device is null)
|
||||||
throw new InvalidOperationException("Device not found.");
|
throw new InvalidOperationException("Device not found.");
|
||||||
|
|
||||||
await pusher.UnsubscribePushNotificationsAsync(
|
await pusher.UnsubscribePushNotificationsAsync(
|
||||||
new UnsubscribePushNotificationsRequest() { DeviceId = device.DeviceId }
|
new UnsubscribePushNotificationsRequest { DeviceId = device.DeviceId }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
var sessions = await db.AuthSessions
|
||||||
|
.Include(s => s.Challenge)
|
||||||
|
.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.ClientId == device.Id)
|
||||||
|
.ExecuteUpdateAsync(p => p.SetProperty(s => s.DeletedAt, s => now));
|
||||||
|
|
||||||
db.AuthClients.Remove(device);
|
db.AuthClients.Remove(device);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var sessions = await db.AuthSessions
|
|
||||||
.Include(s => s.Challenge)
|
|
||||||
.Where(s => s.Challenge.ClientId == device.Id)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
// The current session should be included in the sessions' list
|
|
||||||
await db.AuthSessions
|
|
||||||
.Include(s => s.Challenge)
|
|
||||||
.Where(s => s.Challenge.DeviceId == device.DeviceId)
|
|
||||||
.ExecuteDeleteAsync();
|
|
||||||
|
|
||||||
foreach (var item in sessions)
|
foreach (var item in sessions)
|
||||||
await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}");
|
await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{item.Id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AccountContact> CreateContactMethod(Account account, AccountContactType type, string content)
|
public async Task<AccountContact> CreateContactMethod(Account account, AccountContactType type, string content)
|
||||||
@@ -687,21 +734,23 @@ public class AccountService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public async Task DeleteAccount(Account account)
|
||||||
/// 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()
|
|
||||||
{
|
{
|
||||||
var accountsId = await db.Accounts.Select(a => a.Id).ToListAsync();
|
await db.AuthSessions
|
||||||
var existingId = await db.AccountProfiles.Select(p => p.AccountId).ToListAsync();
|
.Where(s => s.AccountId == account.Id)
|
||||||
var missingId = accountsId.Except(existingId).ToList();
|
.ExecuteDeleteAsync();
|
||||||
|
|
||||||
if (missingId.Count != 0)
|
db.Accounts.Remove(account);
|
||||||
{
|
await db.SaveChangesAsync();
|
||||||
var newProfiles = missingId.Select(id => new AccountProfile { Id = Guid.NewGuid(), AccountId = id })
|
|
||||||
.ToList();
|
var js = nats.CreateJetStreamContext();
|
||||||
await db.BulkInsertAsync(newProfiles);
|
await js.PublishAsync(
|
||||||
}
|
AccountDeletedEvent.Type,
|
||||||
|
GrpcTypeHelper.ConvertObjectToByteString(new AccountDeletedEvent
|
||||||
|
{
|
||||||
|
AccountId = account.Id,
|
||||||
|
DeletedAt = SystemClock.Instance.GetCurrentInstant()
|
||||||
|
}).ToByteArray()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ namespace DysonNetwork.Pass.Account;
|
|||||||
|
|
||||||
public class AccountServiceGrpc(
|
public class AccountServiceGrpc(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
|
AccountEventService accountEvents,
|
||||||
RelationshipService relationships,
|
RelationshipService relationships,
|
||||||
SubscriptionService subscriptions,
|
SubscriptionService subscriptions,
|
||||||
IClock clock,
|
IClock clock,
|
||||||
@@ -41,6 +42,26 @@ public class AccountServiceGrpc(
|
|||||||
return account.ToProtoValue();
|
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,
|
public override async Task<GetAccountBatchResponse> GetAccountBatch(GetAccountBatchRequest request,
|
||||||
ServerCallContext context)
|
ServerCallContext context)
|
||||||
{
|
{
|
||||||
@@ -55,7 +76,35 @@ public class AccountServiceGrpc(
|
|||||||
.Where(a => accountIds.Contains(a.Id))
|
.Where(a => accountIds.Contains(a.Id))
|
||||||
.Include(a => a.Profile)
|
.Include(a => a.Profile)
|
||||||
.ToListAsync();
|
.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(
|
var perks = await subscriptions.GetPerkSubscriptionsAsync(
|
||||||
accounts.Select(x => x.Id).ToList()
|
accounts.Select(x => x.Id).ToList()
|
||||||
);
|
);
|
||||||
@@ -68,6 +117,27 @@ public class AccountServiceGrpc(
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override async Task<AccountStatus> GetAccountStatus(GetAccountRequest request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
var accountId = Guid.Parse(request.Id);
|
||||||
|
var status = await accountEvents.GetStatus(accountId);
|
||||||
|
return status.ToProtoValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
.Where(id => id.HasValue)
|
||||||
|
.Select(id => id!.Value)
|
||||||
|
.ToList();
|
||||||
|
var statuses = await accountEvents.GetStatuses(accountIds);
|
||||||
|
var response = new GetAccountStatusBatchResponse();
|
||||||
|
response.Statuses.AddRange(statuses.Select(s => s.Value.ToProtoValue()));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
public override async Task<GetAccountBatchResponse> LookupAccountBatch(LookupAccountBatchRequest request,
|
public override async Task<GetAccountBatchResponse> LookupAccountBatch(LookupAccountBatchRequest request,
|
||||||
ServerCallContext context)
|
ServerCallContext context)
|
||||||
{
|
{
|
||||||
@@ -77,14 +147,14 @@ public class AccountServiceGrpc(
|
|||||||
.Where(a => accountNames.Contains(a.Name))
|
.Where(a => accountNames.Contains(a.Name))
|
||||||
.Include(a => a.Profile)
|
.Include(a => a.Profile)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var perks = await subscriptions.GetPerkSubscriptionsAsync(
|
var perks = await subscriptions.GetPerkSubscriptionsAsync(
|
||||||
accounts.Select(x => x.Id).ToList()
|
accounts.Select(x => x.Id).ToList()
|
||||||
);
|
);
|
||||||
foreach (var account in accounts)
|
foreach (var account in accounts)
|
||||||
if (perks.TryGetValue(account.Id, out var perk))
|
if (perks.TryGetValue(account.Id, out var perk))
|
||||||
account.PerkSubscription = perk?.ToReference();
|
account.PerkSubscription = perk?.ToReference();
|
||||||
|
|
||||||
var response = new GetAccountBatchResponse();
|
var response = new GetAccountBatchResponse();
|
||||||
response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue()));
|
response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue()));
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Data;
|
||||||
|
using DysonNetwork.Shared.GeoIp;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using NodaTime.Serialization.Protobuf;
|
using NodaTime.Serialization.Protobuf;
|
||||||
using Point = NetTopologySuite.Geometries.Point;
|
using Point = NetTopologySuite.Geometries.Point;
|
||||||
@@ -14,7 +16,7 @@ public class ActionLog : ModelBase
|
|||||||
[Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
|
[Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
|
||||||
[MaxLength(512)] public string? UserAgent { get; set; }
|
[MaxLength(512)] public string? UserAgent { get; set; }
|
||||||
[MaxLength(128)] public string? IpAddress { 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 Guid AccountId { get; set; }
|
||||||
public Account Account { get; set; } = null!;
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Data;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
using NodaTime.Serialization.Protobuf;
|
||||||
|
|
||||||
namespace DysonNetwork.Pass.Account;
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
@@ -22,9 +23,63 @@ public class Status : ModelBase
|
|||||||
public bool IsNotDisturb { get; set; }
|
public bool IsNotDisturb { get; set; }
|
||||||
[MaxLength(1024)] public string? Label { get; set; }
|
[MaxLength(1024)] public string? Label { get; set; }
|
||||||
public Instant? ClearedAt { 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 Guid AccountId { get; set; }
|
||||||
public Account Account { get; set; } = null!;
|
public Account Account { get; set; } = null!;
|
||||||
|
|
||||||
|
public Shared.Proto.AccountStatus ToProtoValue()
|
||||||
|
{
|
||||||
|
var proto = new Shared.Proto.AccountStatus
|
||||||
|
{
|
||||||
|
Id = Id.ToString(),
|
||||||
|
Attitude = Attitude switch
|
||||||
|
{
|
||||||
|
StatusAttitude.Positive => Shared.Proto.StatusAttitude.Positive,
|
||||||
|
StatusAttitude.Negative => Shared.Proto.StatusAttitude.Negative,
|
||||||
|
StatusAttitude.Neutral => Shared.Proto.StatusAttitude.Neutral,
|
||||||
|
_ => Shared.Proto.StatusAttitude.Unspecified
|
||||||
|
},
|
||||||
|
IsOnline = IsOnline,
|
||||||
|
IsCustomized = IsCustomized,
|
||||||
|
IsInvisible = IsInvisible,
|
||||||
|
IsNotDisturb = IsNotDisturb,
|
||||||
|
Label = Label ?? string.Empty,
|
||||||
|
ClearedAt = ClearedAt?.ToTimestamp(),
|
||||||
|
AccountId = AccountId.ToString()
|
||||||
|
};
|
||||||
|
|
||||||
|
return proto;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Status FromProtoValue(Shared.Proto.AccountStatus proto)
|
||||||
|
{
|
||||||
|
var status = new Status
|
||||||
|
{
|
||||||
|
Id = Guid.Parse(proto.Id),
|
||||||
|
Attitude = proto.Attitude switch
|
||||||
|
{
|
||||||
|
Shared.Proto.StatusAttitude.Positive => StatusAttitude.Positive,
|
||||||
|
Shared.Proto.StatusAttitude.Negative => StatusAttitude.Negative,
|
||||||
|
Shared.Proto.StatusAttitude.Neutral => StatusAttitude.Neutral,
|
||||||
|
_ => StatusAttitude.Neutral
|
||||||
|
},
|
||||||
|
IsOnline = proto.IsOnline,
|
||||||
|
IsCustomized = proto.IsCustomized,
|
||||||
|
IsInvisible = proto.IsInvisible,
|
||||||
|
IsNotDisturb = proto.IsNotDisturb,
|
||||||
|
Label = proto.Label,
|
||||||
|
ClearedAt = proto.ClearedAt?.ToInstant(),
|
||||||
|
AccountId = Guid.Parse(proto.AccountId)
|
||||||
|
};
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum CheckInResultLevel
|
public enum CheckInResultLevel
|
||||||
@@ -43,10 +98,10 @@ public class CheckInResult : ModelBase
|
|||||||
public decimal? RewardPoints { get; set; }
|
public decimal? RewardPoints { get; set; }
|
||||||
public int? RewardExperience { get; set; }
|
public int? RewardExperience { get; set; }
|
||||||
[Column(TypeName = "jsonb")] public ICollection<FortuneTip> Tips { get; set; } = new List<FortuneTip>();
|
[Column(TypeName = "jsonb")] public ICollection<FortuneTip> Tips { get; set; } = new List<FortuneTip>();
|
||||||
|
|
||||||
public Guid AccountId { get; set; }
|
public Guid AccountId { get; set; }
|
||||||
public Account Account { get; set; } = null!;
|
public Account Account { get; set; } = null!;
|
||||||
|
|
||||||
public Instant? BackdatedFrom { get; set; }
|
public Instant? BackdatedFrom { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,4 +120,4 @@ public class DailyEventResponse
|
|||||||
public Instant Date { get; set; }
|
public Instant Date { get; set; }
|
||||||
public CheckInResult? CheckInResult { get; set; }
|
public CheckInResult? CheckInResult { get; set; }
|
||||||
public ICollection<Status> Statuses { get; set; } = new List<Status>();
|
public ICollection<Status> Statuses { get; set; } = new List<Status>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Text.Json;
|
|||||||
using DysonNetwork.Pass.Email;
|
using DysonNetwork.Pass.Email;
|
||||||
using DysonNetwork.Pass.Pages.Emails;
|
using DysonNetwork.Pass.Pages.Emails;
|
||||||
using DysonNetwork.Pass.Permission;
|
using DysonNetwork.Pass.Permission;
|
||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
@@ -15,7 +16,8 @@ public class MagicSpellService(
|
|||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
ILogger<MagicSpellService> logger,
|
ILogger<MagicSpellService> logger,
|
||||||
IStringLocalizer<EmailResource> localizer,
|
IStringLocalizer<EmailResource> localizer,
|
||||||
EmailService email
|
EmailService email,
|
||||||
|
ICacheService cache
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
public async Task<MagicSpell> CreateMagicSpell(
|
public async Task<MagicSpell> CreateMagicSpell(
|
||||||
@@ -35,11 +37,8 @@ public class MagicSpellService(
|
|||||||
.Where(s => s.Type == type)
|
.Where(s => s.Type == type)
|
||||||
.Where(s => s.ExpiresAt == null || s.ExpiresAt > now)
|
.Where(s => s.ExpiresAt == null || s.ExpiresAt > now)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
if (existingSpell is not null)
|
||||||
if (existingSpell != null)
|
return existingSpell;
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Account already has an active magic spell of type {type}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var spellWord = _GenerateRandomString(128);
|
var spellWord = _GenerateRandomString(128);
|
||||||
@@ -59,8 +58,18 @@ public class MagicSpellService(
|
|||||||
return spell;
|
return spell;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const string SpellNotifyCacheKeyPrefix = "spells:notify:";
|
||||||
|
|
||||||
public async Task NotifyMagicSpell(MagicSpell spell, bool bypassVerify = false)
|
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
|
var contact = await db.AccountContacts
|
||||||
.Where(c => c.Account.Id == spell.AccountId)
|
.Where(c => c.Account.Id == spell.AccountId)
|
||||||
.Where(c => c.Type == AccountContactType.Email)
|
.Where(c => c.Type == AccountContactType.Email)
|
||||||
@@ -112,7 +121,7 @@ public class MagicSpellService(
|
|||||||
await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>(
|
await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>(
|
||||||
contact.Account.Nick,
|
contact.Account.Nick,
|
||||||
contact.Content,
|
contact.Content,
|
||||||
localizer["EmailAccountDeletionTitle"],
|
localizer["EmailPasswordResetTitle"],
|
||||||
new PasswordResetEmailModel
|
new PasswordResetEmailModel
|
||||||
{
|
{
|
||||||
Name = contact.Account.Name,
|
Name = contact.Account.Name,
|
||||||
@@ -138,6 +147,8 @@ public class MagicSpellService(
|
|||||||
default:
|
default:
|
||||||
throw new ArgumentOutOfRangeException();
|
throw new ArgumentOutOfRangeException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await cache.SetAsync(cacheKey, true, TimeSpan.FromMinutes(5));
|
||||||
}
|
}
|
||||||
catch (Exception err)
|
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.Cache;
|
||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Localization;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
namespace DysonNetwork.Pass.Account;
|
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 UserFriendsCacheKeyPrefix = "accounts:friends:";
|
||||||
private const string UserBlockedCacheKeyPrefix = "accounts:blocked:";
|
private const string UserBlockedCacheKeyPrefix = "accounts:blocked:";
|
||||||
|
|
||||||
public async Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId)
|
public async Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId)
|
||||||
{
|
{
|
||||||
var count = await db.AccountRelationships
|
var count = await db.AccountRelationships
|
||||||
@@ -51,7 +59,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
|||||||
|
|
||||||
db.AccountRelationships.Add(relationship);
|
db.AccountRelationships.Add(relationship);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
await PurgeRelationshipCache(sender.Id, target.Id);
|
await PurgeRelationshipCache(sender.Id, target.Id);
|
||||||
|
|
||||||
return relationship;
|
return relationship;
|
||||||
@@ -63,16 +71,16 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
|||||||
return await UpdateRelationship(sender.Id, target.Id, RelationshipStatus.Blocked);
|
return await UpdateRelationship(sender.Id, target.Id, RelationshipStatus.Blocked);
|
||||||
return await CreateRelationship(sender, target, RelationshipStatus.Blocked);
|
return await CreateRelationship(sender, target, RelationshipStatus.Blocked);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Relationship> UnblockAccount(Account sender, Account target)
|
public async Task<Relationship> UnblockAccount(Account sender, Account target)
|
||||||
{
|
{
|
||||||
var relationship = await GetRelationship(sender.Id, target.Id, RelationshipStatus.Blocked);
|
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.");
|
if (relationship is null) throw new ArgumentException("There is no relationship between you and the user.");
|
||||||
db.Remove(relationship);
|
db.Remove(relationship);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
await PurgeRelationshipCache(sender.Id, target.Id);
|
await PurgeRelationshipCache(sender.Id, target.Id);
|
||||||
|
|
||||||
return relationship;
|
return relationship;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,21 +100,34 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
|||||||
db.AccountRelationships.Add(relationship);
|
db.AccountRelationships.Add(relationship);
|
||||||
await db.SaveChangesAsync();
|
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;
|
return relationship;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteFriendRequest(Guid accountId, Guid relatedId)
|
public async Task DeleteFriendRequest(Guid accountId, Guid relatedId)
|
||||||
{
|
{
|
||||||
var relationship = await GetRelationship(accountId, relatedId, RelationshipStatus.Pending);
|
var relationship = await GetRelationship(accountId, relatedId, RelationshipStatus.Pending);
|
||||||
if (relationship is null) throw new ArgumentException("Friend request was not found.");
|
if (relationship is null) throw new ArgumentException("Friend request was not found.");
|
||||||
|
|
||||||
await db.AccountRelationships
|
await db.AccountRelationships
|
||||||
.Where(r => r.AccountId == accountId && r.RelatedId == relatedId && r.Status == RelationshipStatus.Pending)
|
.Where(r => r.AccountId == accountId && r.RelatedId == relatedId && r.Status == RelationshipStatus.Pending)
|
||||||
.ExecuteDeleteAsync();
|
.ExecuteDeleteAsync();
|
||||||
|
|
||||||
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
|
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Relationship> AcceptFriendRelationship(
|
public async Task<Relationship> AcceptFriendRelationship(
|
||||||
Relationship relationship,
|
Relationship relationship,
|
||||||
RelationshipStatus status = RelationshipStatus.Friends
|
RelationshipStatus status = RelationshipStatus.Friends
|
||||||
@@ -146,9 +167,9 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
|||||||
relationship.Status = status;
|
relationship.Status = status;
|
||||||
db.Update(relationship);
|
db.Update(relationship);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
await PurgeRelationshipCache(accountId, relatedId);
|
await PurgeRelationshipCache(accountId, relatedId);
|
||||||
|
|
||||||
return relationship;
|
return relationship;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +182,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
|||||||
{
|
{
|
||||||
var cacheKey = $"{UserFriendsCacheKeyPrefix}{accountId}";
|
var cacheKey = $"{UserFriendsCacheKeyPrefix}{accountId}";
|
||||||
var friends = await cache.GetAsync<List<Guid>>(cacheKey);
|
var friends = await cache.GetAsync<List<Guid>>(cacheKey);
|
||||||
|
|
||||||
if (friends == null)
|
if (friends == null)
|
||||||
{
|
{
|
||||||
friends = await db.AccountRelationships
|
friends = await db.AccountRelationships
|
||||||
@@ -169,23 +190,23 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
|||||||
.Where(r => r.Status == RelationshipStatus.Friends)
|
.Where(r => r.Status == RelationshipStatus.Friends)
|
||||||
.Select(r => r.AccountId)
|
.Select(r => r.AccountId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
await cache.SetAsync(cacheKey, friends, TimeSpan.FromHours(1));
|
await cache.SetAsync(cacheKey, friends, TimeSpan.FromHours(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
return friends ?? [];
|
return friends ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<Guid>> ListAccountBlocked(Account account)
|
public async Task<List<Guid>> ListAccountBlocked(Account account)
|
||||||
{
|
{
|
||||||
return await ListAccountBlocked(account.Id);
|
return await ListAccountBlocked(account.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<Guid>> ListAccountBlocked(Guid accountId)
|
public async Task<List<Guid>> ListAccountBlocked(Guid accountId)
|
||||||
{
|
{
|
||||||
var cacheKey = $"{UserBlockedCacheKeyPrefix}{accountId}";
|
var cacheKey = $"{UserBlockedCacheKeyPrefix}{accountId}";
|
||||||
var blocked = await cache.GetAsync<List<Guid>>(cacheKey);
|
var blocked = await cache.GetAsync<List<Guid>>(cacheKey);
|
||||||
|
|
||||||
if (blocked == null)
|
if (blocked == null)
|
||||||
{
|
{
|
||||||
blocked = await db.AccountRelationships
|
blocked = await db.AccountRelationships
|
||||||
@@ -193,7 +214,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
|||||||
.Where(r => r.Status == RelationshipStatus.Blocked)
|
.Where(r => r.Status == RelationshipStatus.Blocked)
|
||||||
.Select(r => r.AccountId)
|
.Select(r => r.AccountId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
await cache.SetAsync(cacheKey, blocked, TimeSpan.FromHours(1));
|
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);
|
var relationship = await GetRelationship(accountId, relatedId, status);
|
||||||
return relationship is not null;
|
return relationship is not null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task PurgeRelationshipCache(Guid accountId, Guid relatedId)
|
private async Task PurgeRelationshipCache(Guid accountId, Guid relatedId)
|
||||||
{
|
{
|
||||||
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}");
|
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}");
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using DysonNetwork.Pass.Account;
|
using DysonNetwork.Pass.Account;
|
||||||
using DysonNetwork.Pass.Auth;
|
using DysonNetwork.Pass.Auth;
|
||||||
|
using DysonNetwork.Pass.Credit;
|
||||||
|
using DysonNetwork.Pass.Leveling;
|
||||||
using DysonNetwork.Pass.Permission;
|
using DysonNetwork.Pass.Permission;
|
||||||
using DysonNetwork.Pass.Wallet;
|
using DysonNetwork.Pass.Wallet;
|
||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Data;
|
||||||
@@ -38,6 +42,7 @@ public class AppDatabase(
|
|||||||
public DbSet<AuthSession> AuthSessions { get; set; } = null!;
|
public DbSet<AuthSession> AuthSessions { get; set; } = null!;
|
||||||
public DbSet<AuthChallenge> AuthChallenges { get; set; } = null!;
|
public DbSet<AuthChallenge> AuthChallenges { get; set; } = null!;
|
||||||
public DbSet<AuthClient> AuthClients { 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<Wallet.Wallet> Wallets { get; set; } = null!;
|
||||||
public DbSet<WalletPocket> WalletPockets { 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<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)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
optionsBuilder.UseNpgsql(
|
optionsBuilder.UseNpgsql(
|
||||||
configuration.GetConnectionString("App"),
|
configuration.GetConnectionString("App"),
|
||||||
opt => opt
|
opt => opt
|
||||||
.ConfigureDataSource(optSource => optSource.EnableDynamicJson())
|
.ConfigureDataSource(optSource => optSource
|
||||||
|
.EnableDynamicJson()
|
||||||
|
.ConfigureJsonOptions(new JsonSerializerOptions()
|
||||||
|
{
|
||||||
|
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
|
||||||
|
})
|
||||||
|
)
|
||||||
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
|
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
|
||||||
.UseNetTopologySuite()
|
|
||||||
.UseNodaTime()
|
.UseNodaTime()
|
||||||
).UseSnakeCaseNamingConvention();
|
).UseSnakeCaseNamingConvention();
|
||||||
|
|
||||||
@@ -270,4 +283,4 @@ public static class OptionalQueryExtensions
|
|||||||
{
|
{
|
||||||
return condition ? transform(source) : source;
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,8 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
using DysonNetwork.Pass.Account;
|
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using DysonNetwork.Pass.Auth.OidcProvider.Services;
|
|
||||||
using DysonNetwork.Pass.Handlers;
|
using DysonNetwork.Pass.Handlers;
|
||||||
using DysonNetwork.Pass.Wallet;
|
|
||||||
using DysonNetwork.Shared.Cache;
|
using DysonNetwork.Shared.Cache;
|
||||||
using SystemClock = NodaTime.SystemClock;
|
using SystemClock = NodaTime.SystemClock;
|
||||||
|
|
||||||
@@ -38,19 +33,13 @@ public class DysonTokenAuthOptions : AuthenticationSchemeOptions;
|
|||||||
|
|
||||||
public class DysonTokenAuthHandler(
|
public class DysonTokenAuthHandler(
|
||||||
IOptionsMonitor<DysonTokenAuthOptions> options,
|
IOptionsMonitor<DysonTokenAuthOptions> options,
|
||||||
IConfiguration configuration,
|
|
||||||
ILoggerFactory logger,
|
ILoggerFactory logger,
|
||||||
UrlEncoder encoder,
|
UrlEncoder encoder,
|
||||||
AppDatabase database,
|
TokenAuthService token,
|
||||||
OidcProviderService oidc,
|
|
||||||
SubscriptionService subscriptions,
|
|
||||||
ICacheService cache,
|
|
||||||
FlushBufferService fbs
|
FlushBufferService fbs
|
||||||
)
|
)
|
||||||
: AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder)
|
: AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder)
|
||||||
{
|
{
|
||||||
public const string AuthCachePrefix = "auth:";
|
|
||||||
|
|
||||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
{
|
{
|
||||||
var tokenInfo = _ExtractToken(Request);
|
var tokenInfo = _ExtractToken(Request);
|
||||||
@@ -60,48 +49,12 @@ public class DysonTokenAuthHandler(
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
// Get client IP address
|
||||||
|
var ipAddress = Context.Connection.RemoteIpAddress?.ToString();
|
||||||
// Validate token and extract session ID
|
|
||||||
if (!ValidateToken(tokenInfo.Token, out var sessionId))
|
var (valid, session, message) = await token.AuthenticateTokenAsync(tokenInfo.Token, ipAddress);
|
||||||
return AuthenticateResult.Fail("Invalid token.");
|
if (!valid || session is null)
|
||||||
|
return AuthenticateResult.Fail(message ?? "Authentication failed.");
|
||||||
// Try to get session from cache first
|
|
||||||
var session = await cache.GetAsync<AuthSession>($"{AuthCachePrefix}{sessionId}");
|
|
||||||
|
|
||||||
// If not in cache, load from database
|
|
||||||
if (session is null)
|
|
||||||
{
|
|
||||||
session = await database.AuthSessions
|
|
||||||
.Where(e => e.Id == sessionId)
|
|
||||||
.Include(e => e.Challenge)
|
|
||||||
.ThenInclude(e => e.Client)
|
|
||||||
.Include(e => e.Account)
|
|
||||||
.ThenInclude(e => e.Profile)
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
|
|
||||||
if (session is not null)
|
|
||||||
{
|
|
||||||
var perk = await subscriptions.GetPerkSubscriptionAsync(session.AccountId);
|
|
||||||
session.Account.PerkSubscription = perk?.ToReference();
|
|
||||||
|
|
||||||
// Store in cache for future requests
|
|
||||||
await cache.SetWithGroupsAsync(
|
|
||||||
$"auth:{sessionId}",
|
|
||||||
session,
|
|
||||||
[$"{AccountService.AccountCachePrefix}{session.Account.Id}"],
|
|
||||||
TimeSpan.FromHours(1)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the session exists
|
|
||||||
if (session == null)
|
|
||||||
return AuthenticateResult.Fail("Session not found.");
|
|
||||||
|
|
||||||
// Check if the session is expired
|
|
||||||
if (session.ExpiredAt.HasValue && session.ExpiredAt.Value < now)
|
|
||||||
return AuthenticateResult.Fail("Session expired.");
|
|
||||||
|
|
||||||
// Store user and session in the HttpContext.Items for easy access in controllers
|
// Store user and session in the HttpContext.Items for easy access in controllers
|
||||||
Context.Items["CurrentUser"] = session.Account;
|
Context.Items["CurrentUser"] = session.Account;
|
||||||
@@ -117,7 +70,7 @@ public class DysonTokenAuthHandler(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Add scopes as claims
|
// Add scopes as claims
|
||||||
session.Challenge.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope)));
|
session.Challenge?.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope)));
|
||||||
|
|
||||||
// Add superuser claim if applicable
|
// Add superuser claim if applicable
|
||||||
if (session.Account.IsSuperuser)
|
if (session.Account.IsSuperuser)
|
||||||
@@ -145,77 +98,6 @@ public class DysonTokenAuthHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool ValidateToken(string token, out Guid sessionId)
|
|
||||||
{
|
|
||||||
sessionId = Guid.Empty;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var parts = token.Split('.');
|
|
||||||
|
|
||||||
switch (parts.Length)
|
|
||||||
{
|
|
||||||
// Handle JWT tokens (3 parts)
|
|
||||||
case 3:
|
|
||||||
{
|
|
||||||
var (isValid, jwtResult) = oidc.ValidateToken(token);
|
|
||||||
if (!isValid) return false;
|
|
||||||
var jti = jwtResult?.Claims.FirstOrDefault(c => c.Type == "jti")?.Value;
|
|
||||||
if (jti is null) return false;
|
|
||||||
|
|
||||||
return Guid.TryParse(jti, out sessionId);
|
|
||||||
}
|
|
||||||
// Handle compact tokens (2 parts)
|
|
||||||
case 2:
|
|
||||||
// Original compact token validation logic
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Decode the payload
|
|
||||||
var payloadBytes = Base64UrlDecode(parts[0]);
|
|
||||||
|
|
||||||
// Extract session ID
|
|
||||||
sessionId = new Guid(payloadBytes);
|
|
||||||
|
|
||||||
// Load public key for verification
|
|
||||||
var publicKeyPem = File.ReadAllText(configuration["AuthToken:PublicKeyPath"]!);
|
|
||||||
using var rsa = RSA.Create();
|
|
||||||
rsa.ImportFromPem(publicKeyPem);
|
|
||||||
|
|
||||||
// Verify signature
|
|
||||||
var signature = Base64UrlDecode(parts[1]);
|
|
||||||
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256,
|
|
||||||
RSASignaturePadding.Pkcs1);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogWarning(ex, "Token validation failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] Base64UrlDecode(string base64Url)
|
|
||||||
{
|
|
||||||
var padded = base64Url
|
|
||||||
.Replace('-', '+')
|
|
||||||
.Replace('_', '/');
|
|
||||||
|
|
||||||
switch (padded.Length % 4)
|
|
||||||
{
|
|
||||||
case 2: padded += "=="; break;
|
|
||||||
case 3: padded += "="; break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Convert.FromBase64String(padded);
|
|
||||||
}
|
|
||||||
|
|
||||||
private TokenInfo? _ExtractToken(HttpRequest request)
|
private TokenInfo? _ExtractToken(HttpRequest request)
|
||||||
{
|
{
|
||||||
// Check for token in query parameters
|
// Check for token in query parameters
|
||||||
|
|||||||
8
DysonNetwork.Pass/Auth/AuthCacheConstants.cs
Normal file
8
DysonNetwork.Pass/Auth/AuthCacheConstants.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Auth;
|
||||||
|
|
||||||
|
public static class AuthCacheConstants
|
||||||
|
{
|
||||||
|
public const string Prefix = "auth:";
|
||||||
|
}
|
||||||
@@ -3,8 +3,14 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using NodaTime;
|
using NodaTime;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using DysonNetwork.Pass.Account;
|
using DysonNetwork.Pass.Account;
|
||||||
|
using DysonNetwork.Pass.Localization;
|
||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Data;
|
||||||
using DysonNetwork.Shared.GeoIp;
|
using DysonNetwork.Shared.GeoIp;
|
||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using Microsoft.Extensions.Localization;
|
||||||
|
using AccountAuthFactor = DysonNetwork.Pass.Account.AccountAuthFactor;
|
||||||
|
using AccountService = DysonNetwork.Pass.Account.AccountService;
|
||||||
|
using ActionLogService = DysonNetwork.Pass.Account.ActionLogService;
|
||||||
|
|
||||||
namespace DysonNetwork.Pass.Auth;
|
namespace DysonNetwork.Pass.Auth;
|
||||||
|
|
||||||
@@ -16,7 +22,9 @@ public class AuthController(
|
|||||||
AuthService auth,
|
AuthService auth,
|
||||||
GeoIpService geo,
|
GeoIpService geo,
|
||||||
ActionLogService als,
|
ActionLogService als,
|
||||||
IConfiguration configuration
|
RingService.RingServiceClient pusher,
|
||||||
|
IConfiguration configuration,
|
||||||
|
IStringLocalizer<NotificationResource> localizer
|
||||||
) : ControllerBase
|
) : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly string _cookieDomain = configuration["AuthToken:CookieDomain"]!;
|
private readonly string _cookieDomain = configuration["AuthToken:CookieDomain"]!;
|
||||||
@@ -24,8 +32,8 @@ public class AuthController(
|
|||||||
public class ChallengeRequest
|
public class ChallengeRequest
|
||||||
{
|
{
|
||||||
[Required] public ClientPlatform Platform { get; set; }
|
[Required] public ClientPlatform Platform { get; set; }
|
||||||
[Required][MaxLength(256)] public string Account { get; set; } = null!;
|
[Required] [MaxLength(256)] public string Account { get; set; } = null!;
|
||||||
[Required][MaxLength(512)] public string DeviceId { get; set; } = null!;
|
[Required] [MaxLength(512)] public string DeviceId { get; set; } = null!;
|
||||||
[MaxLength(1024)] public string? DeviceName { get; set; }
|
[MaxLength(1024)] public string? DeviceName { get; set; }
|
||||||
public List<string> Audiences { get; set; } = new();
|
public List<string> Audiences { get; set; } = new();
|
||||||
public List<string> Scopes { get; set; } = new();
|
public List<string> Scopes { get; set; } = new();
|
||||||
@@ -43,14 +51,19 @@ public class AuthController(
|
|||||||
.Where(e => e.Type == PunishmentType.BlockLogin || e.Type == PunishmentType.DisableAccount)
|
.Where(e => e.Type == PunishmentType.BlockLogin || e.Type == PunishmentType.DisableAccount)
|
||||||
.Where(e => e.ExpiredAt == null || now < e.ExpiredAt)
|
.Where(e => e.ExpiredAt == null || now < e.ExpiredAt)
|
||||||
.FirstOrDefaultAsync();
|
.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 ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
|
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
|
||||||
|
|
||||||
request.DeviceName ??= userAgent;
|
request.DeviceName ??= userAgent;
|
||||||
|
|
||||||
var device = await auth.GetOrCreateDeviceAsync(account.Id, request.DeviceId, request.DeviceName, request.Platform);
|
var device =
|
||||||
|
await auth.GetOrCreateDeviceAsync(account.Id, request.DeviceId, request.DeviceName, request.Platform);
|
||||||
|
|
||||||
// Trying to pick up challenges from the same IP address and user agent
|
// Trying to pick up challenges from the same IP address and user agent
|
||||||
var existingChallenge = await db.AuthChallenges
|
var existingChallenge = await db.AuthChallenges
|
||||||
@@ -64,7 +77,8 @@ public class AuthController(
|
|||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (existingChallenge is not null)
|
if (existingChallenge is not null)
|
||||||
{
|
{
|
||||||
var existingSession = await db.AuthSessions.Where(e => e.ChallengeId == existingChallenge.Id).FirstOrDefaultAsync();
|
var existingSession = await db.AuthSessions.Where(e => e.ChallengeId == existingChallenge.Id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
if (existingSession is null) return existingChallenge;
|
if (existingSession is null) return existingChallenge;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,8 +134,7 @@ public class AuthController(
|
|||||||
[HttpPost("challenge/{id:guid}/factors/{factorId:guid}")]
|
[HttpPost("challenge/{id:guid}/factors/{factorId:guid}")]
|
||||||
public async Task<ActionResult> RequestFactorCode(
|
public async Task<ActionResult> RequestFactorCode(
|
||||||
[FromRoute] Guid id,
|
[FromRoute] Guid id,
|
||||||
[FromRoute] Guid factorId,
|
[FromRoute] Guid factorId
|
||||||
[FromBody] string? hint
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var challenge = await db.AuthChallenges
|
var challenge = await db.AuthChallenges
|
||||||
@@ -135,7 +148,7 @@ public class AuthController(
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await accounts.SendFactorCode(challenge.Account, factor, hint);
|
await accounts.SendFactorCode(challenge.Account, factor);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -157,18 +170,29 @@ public class AuthController(
|
|||||||
[FromBody] PerformChallengeRequest request
|
[FromBody] PerformChallengeRequest request
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var challenge = await db.AuthChallenges.Include(e => e.Account).FirstOrDefaultAsync(e => e.Id == id);
|
var challenge = await db.AuthChallenges
|
||||||
|
.Include(e => e.Account)
|
||||||
|
.Include(authChallenge => authChallenge.Client)
|
||||||
|
.FirstOrDefaultAsync(e => e.Id == id);
|
||||||
if (challenge is null) return NotFound("Auth challenge was not found.");
|
if (challenge is null) return NotFound("Auth challenge was not found.");
|
||||||
|
|
||||||
var factor = await db.AccountAuthFactors.FindAsync(request.FactorId);
|
var factor = await db.AccountAuthFactors
|
||||||
|
.Where(f => f.Id == request.FactorId)
|
||||||
|
.Where(f => f.AccountId == challenge.AccountId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
if (factor is null) return NotFound("Auth factor was not found.");
|
if (factor is null) return NotFound("Auth factor was not found.");
|
||||||
if (factor.EnabledAt is null) return BadRequest("Auth factor is not enabled.");
|
if (factor.EnabledAt is null) return BadRequest("Auth factor is not enabled.");
|
||||||
if (factor.Trustworthy <= 0) return BadRequest("Auth factor is not trustworthy.");
|
if (factor.Trustworthy <= 0) return BadRequest("Auth factor is not trustworthy.");
|
||||||
|
|
||||||
if (challenge.StepRemain == 0) return challenge;
|
if (challenge.StepRemain == 0) return challenge;
|
||||||
if (challenge.ExpiredAt.HasValue && challenge.ExpiredAt.Value < Instant.FromDateTimeUtc(DateTime.UtcNow))
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
if (challenge.ExpiredAt.HasValue && now > challenge.ExpiredAt.Value)
|
||||||
return BadRequest();
|
return BadRequest();
|
||||||
|
|
||||||
|
// prevent reusing the same factor in one challenge
|
||||||
|
if (challenge.BlacklistFactors.Contains(factor.Id))
|
||||||
|
return BadRequest("Auth factor already used.");
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (await accounts.VerifyFactorCode(factor, request.Password))
|
if (await accounts.VerifyFactorCode(factor, request.Password))
|
||||||
@@ -207,6 +231,19 @@ public class AuthController(
|
|||||||
|
|
||||||
if (challenge.StepRemain == 0)
|
if (challenge.StepRemain == 0)
|
||||||
{
|
{
|
||||||
|
AccountService.SetCultureInfo(challenge.Account);
|
||||||
|
await pusher.SendPushNotificationToUserAsync(new SendPushNotificationToUserRequest
|
||||||
|
{
|
||||||
|
Notification = new PushNotification()
|
||||||
|
{
|
||||||
|
Topic = "auth.login",
|
||||||
|
Title = localizer["NewLoginTitle"],
|
||||||
|
Body = localizer["NewLoginBody", challenge.Client?.DeviceName ?? "unknown",
|
||||||
|
challenge.IpAddress ?? "unknown"],
|
||||||
|
IsSavable = true
|
||||||
|
},
|
||||||
|
UserId = challenge.AccountId.ToString()
|
||||||
|
});
|
||||||
als.CreateActionLogFromRequest(ActionLogType.NewLogin,
|
als.CreateActionLogFromRequest(ActionLogType.NewLogin,
|
||||||
new Dictionary<string, object>
|
new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
@@ -247,37 +284,15 @@ public class AuthController(
|
|||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (challenge is null)
|
if (challenge is null)
|
||||||
return BadRequest("Authorization code not found or expired.");
|
return BadRequest("Authorization code not found or expired.");
|
||||||
if (challenge.StepRemain != 0)
|
try
|
||||||
return BadRequest("Challenge not yet completed.");
|
|
||||||
|
|
||||||
var session = await db.AuthSessions
|
|
||||||
.Where(e => e.Challenge == challenge)
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
if (session is not null)
|
|
||||||
return BadRequest("Session already exists for this challenge.");
|
|
||||||
|
|
||||||
session = new AuthSession
|
|
||||||
{
|
{
|
||||||
LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow),
|
var tk = await auth.CreateSessionAndIssueToken(challenge);
|
||||||
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)),
|
return Ok(new TokenExchangeResponse { Token = tk });
|
||||||
Account = challenge.Account,
|
}
|
||||||
Challenge = challenge,
|
catch (ArgumentException ex)
|
||||||
};
|
|
||||||
|
|
||||||
db.AuthSessions.Add(session);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
|
||||||
var tk = auth.CreateToken(session);
|
|
||||||
Response.Cookies.Append(AuthConstants.CookieTokenName, tk, new CookieOptions
|
|
||||||
{
|
{
|
||||||
HttpOnly = true,
|
return BadRequest(ex.Message);
|
||||||
Secure = true,
|
}
|
||||||
SameSite = SameSiteMode.Lax,
|
|
||||||
Domain = _cookieDomain,
|
|
||||||
Expires = DateTime.UtcNow.AddDays(30)
|
|
||||||
});
|
|
||||||
|
|
||||||
return Ok(new TokenExchangeResponse { Token = tk });
|
|
||||||
default:
|
default:
|
||||||
// Since we no longer need the refresh token
|
// Since we no longer need the refresh token
|
||||||
// This case is blank for now, thinking to mock it if the OIDC standard requires it
|
// This case is blank for now, thinking to mock it if the OIDC standard requires it
|
||||||
@@ -304,4 +319,4 @@ public class AuthController(
|
|||||||
});
|
});
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using DysonNetwork.Pass.Account;
|
using DysonNetwork.Pass.Account;
|
||||||
using DysonNetwork.Shared.Cache;
|
using DysonNetwork.Shared.Cache;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -17,6 +18,7 @@ public class AuthService(
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
private HttpContext HttpContext => httpContextAccessor.HttpContext!;
|
private HttpContext HttpContext => httpContextAccessor.HttpContext!;
|
||||||
|
public const string AuthCachePrefix = "auth:";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Detect the risk of the current request to login
|
/// Detect the risk of the current request to login
|
||||||
@@ -51,7 +53,7 @@ public class AuthService(
|
|||||||
riskScore += 1;
|
riskScore += 1;
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge.IpAddress) &&
|
if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge?.IpAddress) &&
|
||||||
!lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase))
|
!lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase))
|
||||||
riskScore += 1;
|
riskScore += 1;
|
||||||
}
|
}
|
||||||
@@ -136,6 +138,7 @@ public class AuthService(
|
|||||||
|
|
||||||
var jsonOpts = new JsonSerializerOptions
|
var jsonOpts = new JsonSerializerOptions
|
||||||
{
|
{
|
||||||
|
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||||
DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower
|
DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||||
};
|
};
|
||||||
@@ -189,6 +192,52 @@ public class AuthService(
|
|||||||
return CreateCompactToken(session.Id, rsa);
|
return CreateCompactToken(session.Id, rsa);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a session for a completed challenge, persist it, issue a token, and set the auth cookie.
|
||||||
|
/// Keeps behavior identical to previous controller implementation.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="challenge">Completed challenge</param>
|
||||||
|
/// <returns>Signed compact token</returns>
|
||||||
|
/// <exception cref="ArgumentException">If challenge not completed or session already exists</exception>
|
||||||
|
public async Task<string> CreateSessionAndIssueToken(AuthChallenge challenge)
|
||||||
|
{
|
||||||
|
if (challenge.StepRemain != 0)
|
||||||
|
throw new ArgumentException("Challenge not yet completed.");
|
||||||
|
|
||||||
|
var hasSession = await db.AuthSessions
|
||||||
|
.AnyAsync(e => e.ChallengeId == challenge.Id);
|
||||||
|
if (hasSession)
|
||||||
|
throw new ArgumentException("Session already exists for this challenge.");
|
||||||
|
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var session = new AuthSession
|
||||||
|
{
|
||||||
|
LastGrantedAt = now,
|
||||||
|
ExpiredAt = now.Plus(Duration.FromDays(7)),
|
||||||
|
AccountId = challenge.AccountId,
|
||||||
|
ChallengeId = challenge.Id
|
||||||
|
};
|
||||||
|
|
||||||
|
db.AuthSessions.Add(session);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var tk = CreateToken(session);
|
||||||
|
|
||||||
|
// Set cookie using HttpContext
|
||||||
|
var cookieDomain = config["AuthToken:CookieDomain"]!;
|
||||||
|
HttpContext.Response.Cookies.Append(AuthConstants.CookieTokenName, tk, new CookieOptions
|
||||||
|
{
|
||||||
|
HttpOnly = true,
|
||||||
|
Secure = true,
|
||||||
|
SameSite = SameSiteMode.Lax,
|
||||||
|
Domain = cookieDomain,
|
||||||
|
// Effectively never expire client-side (20 years)
|
||||||
|
Expires = DateTime.UtcNow.AddYears(20)
|
||||||
|
});
|
||||||
|
|
||||||
|
return tk;
|
||||||
|
}
|
||||||
|
|
||||||
private string CreateCompactToken(Guid sessionId, RSA rsa)
|
private string CreateCompactToken(Guid sessionId, RSA rsa)
|
||||||
{
|
{
|
||||||
// Create the payload: just the session ID
|
// Create the payload: just the session ID
|
||||||
@@ -270,76 +319,82 @@ public class AuthService(
|
|||||||
return factor.VerifyPassword(pinCode);
|
return factor.VerifyPassword(pinCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool ValidateToken(string token, out Guid sessionId)
|
public async Task<ApiKey?> GetApiKey(Guid id, Guid? accountId = null)
|
||||||
{
|
{
|
||||||
sessionId = Guid.Empty;
|
var key = await db.ApiKeys
|
||||||
|
.Include(e => e.Session)
|
||||||
try
|
.Where(e => e.Id == id)
|
||||||
{
|
.If(accountId.HasValue, q => q.Where(e => e.AccountId == accountId!.Value))
|
||||||
// Split the token
|
.FirstOrDefaultAsync();
|
||||||
var parts = token.Split('.');
|
return key;
|
||||||
if (parts.Length != 2)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// Decode the payload
|
|
||||||
var payloadBytes = Base64UrlDecode(parts[0]);
|
|
||||||
|
|
||||||
// Extract session ID
|
|
||||||
sessionId = new Guid(payloadBytes);
|
|
||||||
|
|
||||||
// Load public key for verification
|
|
||||||
var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!);
|
|
||||||
using var rsa = RSA.Create();
|
|
||||||
rsa.ImportFromPem(publicKeyPem);
|
|
||||||
|
|
||||||
// Verify signature
|
|
||||||
var signature = Base64UrlDecode(parts[1]);
|
|
||||||
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task MigrateDeviceIdToClient()
|
public async Task<ApiKey> CreateApiKey(Guid accountId, string label, Instant? expiredAt = null)
|
||||||
{
|
{
|
||||||
logger.LogInformation("Migrating device IDs to clients...");
|
var key = new ApiKey
|
||||||
|
{
|
||||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
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
|
try
|
||||||
{
|
{
|
||||||
var challenges = await db.AuthChallenges
|
var oldSessionId = key.SessionId;
|
||||||
.Where(c => c.DeviceId != null && c.ClientId == null)
|
|
||||||
.ToListAsync();
|
|
||||||
var clients = challenges.GroupBy(c => c.DeviceId)
|
|
||||||
.Select(c => new AuthClient
|
|
||||||
{
|
|
||||||
DeviceId = c.Key!,
|
|
||||||
AccountId = c.First().AccountId,
|
|
||||||
DeviceName = c.First().UserAgent ?? string.Empty,
|
|
||||||
Platform = ClientPlatform.Unidentified
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
await db.AuthClients.AddRangeAsync(clients);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
|
||||||
var clientsMap = clients.ToDictionary(c => c.DeviceId, c => c.Id);
|
// Create new session
|
||||||
foreach (var challenge in challenges.Where(challenge => challenge.ClientId == null && challenge.DeviceId != null))
|
var newSession = new AuthSession
|
||||||
{
|
{
|
||||||
if (clientsMap.TryGetValue(challenge.DeviceId!, out var clientId))
|
AccountId = key.AccountId,
|
||||||
challenge.ClientId = clientId;
|
ExpiredAt = key.Session?.ExpiredAt
|
||||||
db.AuthChallenges.Update(challenge);
|
};
|
||||||
}
|
|
||||||
|
|
||||||
|
db.AuthSessions.Add(newSession);
|
||||||
await db.SaveChangesAsync();
|
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();
|
await transaction.CommitAsync();
|
||||||
logger.LogInformation("Migrated {Count} device IDs to clients", challenges.Count);
|
return key;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
logger.LogError("Failed to migrate device IDs to clients");
|
|
||||||
await transaction.RollbackAsync();
|
await transaction.RollbackAsync();
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
@@ -356,7 +411,7 @@ public class AuthService(
|
|||||||
|
|
||||||
private static byte[] Base64UrlDecode(string base64Url)
|
private static byte[] Base64UrlDecode(string base64Url)
|
||||||
{
|
{
|
||||||
string padded = base64Url
|
var padded = base64Url
|
||||||
.Replace('-', '+')
|
.Replace('-', '+')
|
||||||
.Replace('_', '/');
|
.Replace('_', '/');
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
using DysonNetwork.Pass.Wallet;
|
|
||||||
using DysonNetwork.Shared.Cache;
|
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using NodaTime;
|
|
||||||
|
|
||||||
namespace DysonNetwork.Pass.Auth;
|
namespace DysonNetwork.Pass.Auth;
|
||||||
|
|
||||||
public class AuthServiceGrpc(
|
public class AuthServiceGrpc(
|
||||||
AuthService authService,
|
TokenAuthService token,
|
||||||
SubscriptionService subscriptions,
|
AuthService auth
|
||||||
ICacheService cache,
|
|
||||||
AppDatabase db
|
|
||||||
)
|
)
|
||||||
: Shared.Proto.AuthService.AuthServiceBase
|
: Shared.Proto.AuthService.AuthServiceBase
|
||||||
{
|
{
|
||||||
@@ -20,35 +14,9 @@ public class AuthServiceGrpc(
|
|||||||
ServerCallContext context
|
ServerCallContext context
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (!authService.ValidateToken(request.Token, out var sessionId))
|
var (valid, session, message) = await token.AuthenticateTokenAsync(request.Token, request.IpAddress);
|
||||||
return new AuthenticateResponse { Valid = false, Message = "Invalid token." };
|
if (!valid || session is null)
|
||||||
|
return new AuthenticateResponse { Valid = false, Message = message ?? "Authentication failed." };
|
||||||
var session = await cache.GetAsync<AuthSession>($"{DysonTokenAuthHandler.AuthCachePrefix}{sessionId}");
|
|
||||||
if (session is not null)
|
|
||||||
return new AuthenticateResponse { Valid = true, Session = session.ToProtoValue() };
|
|
||||||
|
|
||||||
session = await db.AuthSessions
|
|
||||||
.AsNoTracking()
|
|
||||||
.Include(e => e.Challenge)
|
|
||||||
.ThenInclude(e => e.Client)
|
|
||||||
.Include(e => e.Account)
|
|
||||||
.ThenInclude(e => e.Profile)
|
|
||||||
.FirstOrDefaultAsync(s => s.Id == sessionId);
|
|
||||||
if (session == null)
|
|
||||||
return new AuthenticateResponse { Valid = false, Message = "Session was not found." };
|
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
|
||||||
if (session.ExpiredAt.HasValue && session.ExpiredAt < now)
|
|
||||||
return new AuthenticateResponse { Valid = false, Message = "Session has been expired." };
|
|
||||||
|
|
||||||
var perk = await subscriptions.GetPerkSubscriptionAsync(session.AccountId);
|
|
||||||
session.Account.PerkSubscription = perk?.ToReference();
|
|
||||||
|
|
||||||
await cache.SetWithGroupsAsync(
|
|
||||||
$"auth:{sessionId}",
|
|
||||||
session,
|
|
||||||
[$"{Account.AccountService.AccountCachePrefix}{session.Account.Id}"],
|
|
||||||
TimeSpan.FromHours(1)
|
|
||||||
);
|
|
||||||
|
|
||||||
return new AuthenticateResponse { Valid = true, Session = session.ToProtoValue() };
|
return new AuthenticateResponse { Valid = true, Session = session.ToProtoValue() };
|
||||||
}
|
}
|
||||||
@@ -56,13 +24,13 @@ public class AuthServiceGrpc(
|
|||||||
public override async Task<ValidateResponse> ValidatePin(ValidatePinRequest request, ServerCallContext context)
|
public override async Task<ValidateResponse> ValidatePin(ValidatePinRequest request, ServerCallContext context)
|
||||||
{
|
{
|
||||||
var accountId = Guid.Parse(request.AccountId);
|
var accountId = Guid.Parse(request.AccountId);
|
||||||
var valid = await authService.ValidatePinCode(accountId, request.Pin);
|
var valid = await auth.ValidatePinCode(accountId, request.Pin);
|
||||||
return new ValidateResponse { Valid = valid };
|
return new ValidateResponse { Valid = valid };
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<ValidateResponse> ValidateCaptcha(ValidateCaptchaRequest request, ServerCallContext context)
|
public override async Task<ValidateResponse> ValidateCaptcha(ValidateCaptchaRequest request, ServerCallContext context)
|
||||||
{
|
{
|
||||||
var valid = await authService.ValidateCaptcha(request.Token);
|
var valid = await auth.ValidateCaptcha(request.Token);
|
||||||
return new ValidateResponse { Valid = valid };
|
return new ValidateResponse { Valid = valid };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using DysonNetwork.Shared.GeoIp;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using NodaTime.Serialization.Protobuf;
|
using NodaTime.Serialization.Protobuf;
|
||||||
using Point = NetTopologySuite.Geometries.Point;
|
using Point = NetTopologySuite.Geometries.Point;
|
||||||
@@ -12,26 +12,28 @@ namespace DysonNetwork.Pass.Auth;
|
|||||||
public class AuthSession : ModelBase
|
public class AuthSession : ModelBase
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
[MaxLength(1024)] public string? Label { get; set; }
|
|
||||||
public Instant? LastGrantedAt { get; set; }
|
public Instant? LastGrantedAt { get; set; }
|
||||||
public Instant? ExpiredAt { get; set; }
|
public Instant? ExpiredAt { get; set; }
|
||||||
|
|
||||||
public Guid AccountId { get; set; }
|
public Guid AccountId { get; set; }
|
||||||
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
[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 Guid? AppId { get; set; }
|
||||||
|
|
||||||
public Shared.Proto.AuthSession ToProtoValue() => new()
|
public Shared.Proto.AuthSession ToProtoValue() => new()
|
||||||
{
|
{
|
||||||
Id = Id.ToString(),
|
Id = Id.ToString(),
|
||||||
Label = Label,
|
|
||||||
LastGrantedAt = LastGrantedAt?.ToTimestamp(),
|
LastGrantedAt = LastGrantedAt?.ToTimestamp(),
|
||||||
ExpiredAt = ExpiredAt?.ToTimestamp(),
|
ExpiredAt = ExpiredAt?.ToTimestamp(),
|
||||||
AccountId = AccountId.ToString(),
|
AccountId = AccountId.ToString(),
|
||||||
Account = Account.ToProtoValue(),
|
Account = Account.ToProtoValue(),
|
||||||
ChallengeId = ChallengeId.ToString(),
|
ChallengeId = ChallengeId.ToString(),
|
||||||
Challenge = Challenge.ToProtoValue(),
|
Challenge = Challenge?.ToProtoValue(),
|
||||||
AppId = AppId?.ToString()
|
AppId = AppId?.ToString()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -68,8 +70,7 @@ public class AuthChallenge : ModelBase
|
|||||||
[MaxLength(128)] public string? IpAddress { get; set; }
|
[MaxLength(128)] public string? IpAddress { get; set; }
|
||||||
[MaxLength(512)] public string? UserAgent { get; set; }
|
[MaxLength(512)] public string? UserAgent { get; set; }
|
||||||
[MaxLength(1024)] public string? Nonce { get; set; }
|
[MaxLength(1024)] public string? Nonce { get; set; }
|
||||||
[MaxLength(1024)] public string? DeviceId { get; set; } = string.Empty;
|
[Column(TypeName = "jsonb")] public GeoPoint? Location { get; set; }
|
||||||
public Point? Location { get; set; }
|
|
||||||
|
|
||||||
public Guid AccountId { get; set; }
|
public Guid AccountId { get; set; }
|
||||||
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
||||||
@@ -129,4 +130,4 @@ public class AuthClientWithChallenge : AuthClient
|
|||||||
AccountId = client.AccountId,
|
AccountId = client.AccountId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,10 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Web;
|
||||||
using DysonNetwork.Pass.Account;
|
using DysonNetwork.Pass.Account;
|
||||||
using DysonNetwork.Pass.Auth.OidcProvider.Options;
|
using DysonNetwork.Pass.Auth.OidcProvider.Options;
|
||||||
|
using DysonNetwork.Shared.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
@@ -19,10 +21,199 @@ public class OidcProviderController(
|
|||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
OidcProviderService oidcService,
|
OidcProviderService oidcService,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
IOptions<OidcProviderOptions> options
|
IOptions<OidcProviderOptions> options,
|
||||||
)
|
ILogger<OidcProviderController> logger
|
||||||
: ControllerBase
|
) : 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")]
|
[HttpPost("token")]
|
||||||
[Consumes("application/x-www-form-urlencoded")]
|
[Consumes("application/x-www-form-urlencoded")]
|
||||||
public async Task<IActionResult> Token([FromForm] TokenRequest request)
|
public async Task<IActionResult> Token([FromForm] TokenRequest request)
|
||||||
@@ -35,74 +226,74 @@ public class OidcProviderController(
|
|||||||
case "authorization_code" when request.Code == null:
|
case "authorization_code" when request.Code == null:
|
||||||
return BadRequest("Authorization code is required");
|
return BadRequest("Authorization code is required");
|
||||||
case "authorization_code":
|
case "authorization_code":
|
||||||
{
|
{
|
||||||
var client = await oidcService.FindClientByIdAsync(request.ClientId.Value);
|
var client = await oidcService.FindClientBySlugAsync(request.ClientId);
|
||||||
if (client == null ||
|
if (client == null ||
|
||||||
!await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret))
|
!await oidcService.ValidateClientCredentialsAsync(Guid.Parse(client.Id), request.ClientSecret))
|
||||||
return BadRequest(new ErrorResponse
|
return BadRequest(new ErrorResponse
|
||||||
{ Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
|
{ Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
|
||||||
|
|
||||||
// Generate tokens
|
// Generate tokens
|
||||||
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
||||||
clientId: request.ClientId.Value,
|
clientId: Guid.Parse(client.Id),
|
||||||
authorizationCode: request.Code!,
|
authorizationCode: request.Code!,
|
||||||
redirectUri: request.RedirectUri,
|
redirectUri: request.RedirectUri,
|
||||||
codeVerifier: request.CodeVerifier
|
codeVerifier: request.CodeVerifier
|
||||||
);
|
);
|
||||||
|
|
||||||
return Ok(tokenResponse);
|
return Ok(tokenResponse);
|
||||||
}
|
}
|
||||||
case "refresh_token" when string.IsNullOrEmpty(request.RefreshToken):
|
case "refresh_token" when string.IsNullOrEmpty(request.RefreshToken):
|
||||||
return BadRequest(new ErrorResponse
|
return BadRequest(new ErrorResponse
|
||||||
{ Error = "invalid_request", ErrorDescription = "Refresh token is required" });
|
{ Error = "invalid_request", ErrorDescription = "Refresh token is required" });
|
||||||
case "refresh_token":
|
case "refresh_token":
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
try
|
// Decode the base64 refresh token to get the session ID
|
||||||
{
|
var sessionIdBytes = Convert.FromBase64String(request.RefreshToken);
|
||||||
// Decode the base64 refresh token to get the session ID
|
var sessionId = new Guid(sessionIdBytes);
|
||||||
var sessionIdBytes = Convert.FromBase64String(request.RefreshToken);
|
|
||||||
var sessionId = new Guid(sessionIdBytes);
|
|
||||||
|
|
||||||
// Find the session and related data
|
// Find the session and related data
|
||||||
var session = await oidcService.FindSessionByIdAsync(sessionId);
|
var session = await oidcService.FindSessionByIdAsync(sessionId);
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
if (session?.AppId is null || session.ExpiredAt < now)
|
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)
|
|
||||||
{
|
{
|
||||||
return BadRequest(new ErrorResponse
|
return BadRequest(new ErrorResponse
|
||||||
{
|
{
|
||||||
Error = "invalid_grant",
|
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:
|
default:
|
||||||
return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" });
|
return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" });
|
||||||
}
|
}
|
||||||
@@ -116,7 +307,7 @@ public class OidcProviderController(
|
|||||||
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
|
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
|
||||||
|
|
||||||
// Get requested scopes from the token
|
// Get requested scopes from the token
|
||||||
var scopes = currentSession.Challenge.Scopes;
|
var scopes = currentSession.Challenge?.Scopes ?? [];
|
||||||
|
|
||||||
var userInfo = new Dictionary<string, object>
|
var userInfo = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
@@ -150,10 +341,10 @@ public class OidcProviderController(
|
|||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
issuer = issuer,
|
issuer,
|
||||||
authorization_endpoint = $"{baseUrl}/auth/authorize",
|
authorization_endpoint = $"{baseUrl}/auth/authorize",
|
||||||
token_endpoint = $"{baseUrl}/auth/open/token",
|
token_endpoint = $"{baseUrl}/api/auth/open/token",
|
||||||
userinfo_endpoint = $"{baseUrl}/auth/open/userinfo",
|
userinfo_endpoint = $"{baseUrl}/api/auth/open/userinfo",
|
||||||
jwks_uri = $"{baseUrl}/.well-known/jwks",
|
jwks_uri = $"{baseUrl}/.well-known/jwks",
|
||||||
scopes_supported = new[] { "openid", "profile", "email" },
|
scopes_supported = new[] { "openid", "profile", "email" },
|
||||||
response_types_supported = new[]
|
response_types_supported = new[]
|
||||||
@@ -220,7 +411,7 @@ public class TokenRequest
|
|||||||
|
|
||||||
[JsonPropertyName("client_id")]
|
[JsonPropertyName("client_id")]
|
||||||
[FromForm(Name = "client_id")]
|
[FromForm(Name = "client_id")]
|
||||||
public Guid? ClientId { get; set; }
|
public string? ClientId { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("client_secret")]
|
[JsonPropertyName("client_secret")]
|
||||||
[FromForm(Name = "client_secret")]
|
[FromForm(Name = "client_secret")]
|
||||||
@@ -237,4 +428,4 @@ public class TokenRequest
|
|||||||
[JsonPropertyName("code_verifier")]
|
[JsonPropertyName("code_verifier")]
|
||||||
[FromForm(Name = "code_verifier")]
|
[FromForm(Name = "code_verifier")]
|
||||||
public string? CodeVerifier { get; set; }
|
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")]
|
[JsonPropertyName("scope")]
|
||||||
public string? Scope { get; set; }
|
public string? Scope { get; set; }
|
||||||
|
|
||||||
|
|
||||||
[JsonPropertyName("id_token")]
|
[JsonPropertyName("id_token")]
|
||||||
public string? IdToken { get; set; }
|
public string? IdToken { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
using AccountContactType = DysonNetwork.Pass.Account.AccountContactType;
|
||||||
|
|
||||||
namespace DysonNetwork.Pass.Auth.OidcProvider.Services;
|
namespace DysonNetwork.Pass.Auth.OidcProvider.Services;
|
||||||
|
|
||||||
@@ -31,15 +32,31 @@ public class OidcProviderService(
|
|||||||
return resp.App ?? null;
|
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();
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
|
||||||
return await db.AuthSessions
|
var queryable = db.AuthSessions
|
||||||
.Include(s => s.Challenge)
|
.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 &&
|
.Where(s => s.AccountId == accountId &&
|
||||||
s.AppId == clientId &&
|
s.AppId == clientId &&
|
||||||
(s.ExpiredAt == null || s.ExpiredAt > now) &&
|
(s.ExpiredAt == null || s.ExpiredAt > now) &&
|
||||||
|
s.Challenge != null &&
|
||||||
s.Challenge.Type == ChallengeType.OAuth)
|
s.Challenge.Type == ChallengeType.OAuth)
|
||||||
.OrderByDescending(s => s.CreatedAt)
|
.OrderByDescending(s => s.CreatedAt)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
@@ -56,6 +73,149 @@ public class OidcProviderService(
|
|||||||
return resp.Valid;
|
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(
|
public async Task<TokenResponse> GenerateTokenResponseAsync(
|
||||||
Guid clientId,
|
Guid clientId,
|
||||||
string? authorizationCode = null,
|
string? authorizationCode = null,
|
||||||
@@ -71,24 +231,43 @@ public class OidcProviderService(
|
|||||||
AuthSession session;
|
AuthSession session;
|
||||||
var clock = SystemClock.Instance;
|
var clock = SystemClock.Instance;
|
||||||
var now = clock.GetCurrentInstant();
|
var now = clock.GetCurrentInstant();
|
||||||
|
string? nonce = null;
|
||||||
List<string>? scopes = null;
|
List<string>? scopes = null;
|
||||||
|
|
||||||
if (authorizationCode != null)
|
if (authorizationCode != null)
|
||||||
{
|
{
|
||||||
// Authorization code flow
|
// Authorization code flow
|
||||||
var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier);
|
var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier);
|
||||||
if (authCode is null) throw new InvalidOperationException("Invalid authorization code");
|
if (authCode == null)
|
||||||
var account = await db.Accounts.Where(a => a.Id == authCode.AccountId).FirstOrDefaultAsync();
|
throw new InvalidOperationException("Invalid authorization code");
|
||||||
if (account is null) throw new InvalidOperationException("Account was not found");
|
|
||||||
|
// 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;
|
scopes = authCode.Scopes;
|
||||||
|
nonce = authCode.Nonce;
|
||||||
}
|
}
|
||||||
else if (sessionId.HasValue)
|
else if (sessionId.HasValue)
|
||||||
{
|
{
|
||||||
// Refresh token flow
|
// Refresh token flow
|
||||||
session = await FindSessionByIdAsync(sessionId.Value) ??
|
session = await FindSessionByIdAsync(sessionId.Value) ??
|
||||||
throw new InvalidOperationException("Invalid session");
|
throw new InvalidOperationException("Session not found");
|
||||||
|
|
||||||
// Verify the session is still valid
|
// Verify the session is still valid
|
||||||
if (session.ExpiredAt < now)
|
if (session.ExpiredAt < now)
|
||||||
@@ -102,13 +281,15 @@ public class OidcProviderService(
|
|||||||
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
|
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
|
||||||
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
|
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
|
||||||
|
|
||||||
// Generate an access token
|
// Generate tokens
|
||||||
var accessToken = GenerateJwtToken(client, session, expiresAt, scopes);
|
var accessToken = GenerateJwtToken(client, session, expiresAt, scopes);
|
||||||
|
var idToken = GenerateIdToken(client, session, nonce, scopes);
|
||||||
var refreshToken = GenerateRefreshToken(session);
|
var refreshToken = GenerateRefreshToken(session);
|
||||||
|
|
||||||
return new TokenResponse
|
return new TokenResponse
|
||||||
{
|
{
|
||||||
AccessToken = accessToken,
|
AccessToken = accessToken,
|
||||||
|
IdToken = idToken,
|
||||||
ExpiresIn = expiresIn,
|
ExpiresIn = expiresIn,
|
||||||
TokenType = "Bearer",
|
TokenType = "Bearer",
|
||||||
RefreshToken = refreshToken,
|
RefreshToken = refreshToken,
|
||||||
@@ -134,11 +315,10 @@ public class OidcProviderService(
|
|||||||
new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()),
|
new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()),
|
||||||
new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(),
|
new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(),
|
||||||
ClaimValueTypes.Integer64),
|
ClaimValueTypes.Integer64),
|
||||||
new Claim("client_id", client.Id)
|
|
||||||
]),
|
]),
|
||||||
Expires = expiresAt.ToDateTimeUtc(),
|
Expires = expiresAt.ToDateTimeUtc(),
|
||||||
Issuer = _options.IssuerUri,
|
Issuer = _options.IssuerUri,
|
||||||
Audience = client.Id
|
Audience = client.Slug
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try to use RSA signing if keys are available, fall back to HMAC
|
// 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());
|
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(
|
public async Task<string> GenerateAuthorizationCodeAsync(
|
||||||
Guid clientId,
|
Guid clientId,
|
||||||
Guid userId,
|
Guid userId,
|
||||||
@@ -278,7 +413,7 @@ public class OidcProviderService(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Store the code with its metadata in the cache
|
// 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);
|
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
|
||||||
|
|
||||||
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId);
|
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId);
|
||||||
@@ -292,7 +427,7 @@ public class OidcProviderService(
|
|||||||
string? codeVerifier = null
|
string? codeVerifier = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var cacheKey = $"auth:code:{code}";
|
var cacheKey = $"auth:oidc-code:{code}";
|
||||||
var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey);
|
var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey);
|
||||||
|
|
||||||
if (!found || authCode == null)
|
if (!found || authCode == null)
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ public class ConnectionController(
|
|||||||
|
|
||||||
var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant());
|
var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant());
|
||||||
var loginToken = auth.CreateToken(loginSession);
|
var loginToken = auth.CreateToken(loginSession);
|
||||||
return Redirect($"/auth/token?token={loginToken}");
|
return Redirect($"/auth/callback?token={loginToken}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request)
|
private static async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request)
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ public class OidcState
|
|||||||
{
|
{
|
||||||
return JsonSerializer.Serialize(this, new JsonSerializerOptions
|
return JsonSerializer.Serialize(this, new JsonSerializerOptions
|
||||||
{
|
{
|
||||||
|
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||||
});
|
});
|
||||||
|
|||||||
213
DysonNetwork.Pass/Auth/TokenAuthService.cs
Normal file
213
DysonNetwork.Pass/Auth/TokenAuthService.cs
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using DysonNetwork.Pass.Wallet;
|
||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Auth;
|
||||||
|
|
||||||
|
public class TokenAuthService(
|
||||||
|
AppDatabase db,
|
||||||
|
IConfiguration config,
|
||||||
|
ICacheService cache,
|
||||||
|
ILogger<TokenAuthService> logger,
|
||||||
|
OidcProvider.Services.OidcProviderService oidc,
|
||||||
|
SubscriptionService subscriptions
|
||||||
|
)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Universal authenticate method: validate token (JWT or compact),
|
||||||
|
/// load session from cache/DB, check expiry, enrich with subscription,
|
||||||
|
/// 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, string? ipAddress = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
{
|
||||||
|
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)));
|
||||||
|
var tokenFp = tokenHash[..8];
|
||||||
|
|
||||||
|
var partsLen = token.Split('.').Length;
|
||||||
|
var format = partsLen switch
|
||||||
|
{
|
||||||
|
3 => "JWT",
|
||||||
|
2 => "Compact",
|
||||||
|
_ => "Unknown"
|
||||||
|
};
|
||||||
|
logger.LogDebug("AuthenticateTokenAsync: token format detected: {Format} (fp={TokenFp})", format, tokenFp);
|
||||||
|
|
||||||
|
if (!ValidateToken(token, out var sessionId))
|
||||||
|
{
|
||||||
|
logger.LogWarning("AuthenticateTokenAsync: token validation failed (format={Format}, fp={TokenFp})", format, tokenFp);
|
||||||
|
return (false, null, "Invalid token.");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogDebug("AuthenticateTokenAsync: token validated, sessionId={SessionId} (fp={TokenFp})", sessionId, tokenFp);
|
||||||
|
|
||||||
|
// Try cache first
|
||||||
|
var cacheKey = $"{AuthCacheConstants.Prefix}{sessionId}";
|
||||||
|
var session = await cache.GetAsync<AuthSession>(cacheKey);
|
||||||
|
if (session is not null)
|
||||||
|
{
|
||||||
|
logger.LogDebug("AuthenticateTokenAsync: cache hit for {CacheKey}", cacheKey);
|
||||||
|
var nowHit = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
if (session.ExpiredAt.HasValue && session.ExpiredAt < nowHit)
|
||||||
|
{
|
||||||
|
logger.LogWarning("AuthenticateTokenAsync: cached session expired (sessionId={SessionId})", sessionId);
|
||||||
|
return (false, null, "Session has been expired.");
|
||||||
|
}
|
||||||
|
logger.LogInformation(
|
||||||
|
"AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})",
|
||||||
|
sessionId,
|
||||||
|
session.AccountId,
|
||||||
|
session.Challenge?.Scopes.Count,
|
||||||
|
session.ExpiredAt
|
||||||
|
);
|
||||||
|
return (true, session, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogDebug("AuthenticateTokenAsync: cache miss for {CacheKey}, loading from DB", cacheKey);
|
||||||
|
|
||||||
|
session = await db.AuthSessions
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(e => e.Challenge)
|
||||||
|
.ThenInclude(e => e.Client)
|
||||||
|
.Include(e => e.Account)
|
||||||
|
.ThenInclude(e => e.Profile)
|
||||||
|
.FirstOrDefaultAsync(s => s.Id == sessionId);
|
||||||
|
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("AuthenticateTokenAsync: session not found (sessionId={SessionId})", sessionId);
|
||||||
|
return (false, null, "Session was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
if (session.ExpiredAt.HasValue && session.ExpiredAt < now)
|
||||||
|
{
|
||||||
|
logger.LogWarning("AuthenticateTokenAsync: session expired (sessionId={SessionId}, expiredAt={ExpiredAt}, now={Now})", sessionId, session.ExpiredAt, now);
|
||||||
|
return (false, null, "Session has been expired.");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"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.AppId,
|
||||||
|
session.Challenge?.Scopes.Count,
|
||||||
|
session.Challenge?.IpAddress,
|
||||||
|
(session.Challenge?.UserAgent ?? string.Empty).Length
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.LogDebug("AuthenticateTokenAsync: enriching account with subscription (accountId={AccountId})", session.AccountId);
|
||||||
|
var perk = await subscriptions.GetPerkSubscriptionAsync(session.AccountId);
|
||||||
|
session.Account.PerkSubscription = perk?.ToReference();
|
||||||
|
logger.LogInformation(
|
||||||
|
"AuthenticateTokenAsync: subscription attached (accountId={AccountId}, hasPerk={HasPerk}, identifier={Identifier}, status={Status}, available={Available})",
|
||||||
|
session.AccountId,
|
||||||
|
perk is not null,
|
||||||
|
perk?.Identifier,
|
||||||
|
perk?.Status,
|
||||||
|
perk?.IsAvailable
|
||||||
|
);
|
||||||
|
|
||||||
|
await cache.SetWithGroupsAsync(
|
||||||
|
cacheKey,
|
||||||
|
session,
|
||||||
|
[$"{AuthCacheConstants.Prefix}{session.Account.Id}"],
|
||||||
|
TimeSpan.FromHours(1)
|
||||||
|
);
|
||||||
|
logger.LogDebug("AuthenticateTokenAsync: cached session with key {CacheKey} (groups=[{GroupKey}])",
|
||||||
|
cacheKey,
|
||||||
|
$"{AuthCacheConstants.Prefix}{session.Account.Id}");
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"AuthenticateTokenAsync: success via DB (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId})",
|
||||||
|
sessionId,
|
||||||
|
session.AccountId,
|
||||||
|
session.Challenge?.ClientId
|
||||||
|
);
|
||||||
|
return (true, session, null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "AuthenticateTokenAsync: unexpected error");
|
||||||
|
return (false, null, "Authentication error.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ValidateToken(string token, out Guid sessionId)
|
||||||
|
{
|
||||||
|
sessionId = Guid.Empty;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parts = token.Split('.');
|
||||||
|
|
||||||
|
switch (parts.Length)
|
||||||
|
{
|
||||||
|
case 3:
|
||||||
|
{
|
||||||
|
// JWT via OIDC
|
||||||
|
var (isValid, jwtResult) = oidc.ValidateToken(token);
|
||||||
|
if (!isValid) return false;
|
||||||
|
var jti = jwtResult?.Claims.FirstOrDefault(c => c.Type == "jti")?.Value;
|
||||||
|
if (jti is null) return false;
|
||||||
|
return Guid.TryParse(jti, out sessionId);
|
||||||
|
}
|
||||||
|
case 2:
|
||||||
|
{
|
||||||
|
// Compact token
|
||||||
|
var payloadBytes = Base64UrlDecode(parts[0]);
|
||||||
|
sessionId = new Guid(payloadBytes);
|
||||||
|
|
||||||
|
var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!);
|
||||||
|
using var rsa = RSA.Create();
|
||||||
|
rsa.ImportFromPem(publicKeyPem);
|
||||||
|
|
||||||
|
var signature = Base64UrlDecode(parts[1]);
|
||||||
|
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Base64UrlDecode(string base64Url)
|
||||||
|
{
|
||||||
|
var padded = base64Url
|
||||||
|
.Replace('-', '+')
|
||||||
|
.Replace('_', '/');
|
||||||
|
|
||||||
|
switch (padded.Length % 4)
|
||||||
|
{
|
||||||
|
case 2: padded += "=="; break;
|
||||||
|
case 3: padded += "="; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Convert.FromBase64String(padded);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,12 @@ const router = createRouter({
|
|||||||
name: 'authCallback',
|
name: 'authCallback',
|
||||||
component: () => import('../views/callback.vue'),
|
component: () => import('../views/callback.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/auth/authorize',
|
||||||
|
name: 'authAuthorize',
|
||||||
|
component: () => import('../views/authorize.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/:notFound(.*)',
|
path: '/:notFound(.*)',
|
||||||
name: 'errorNotFound',
|
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>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="Nager.Holiday" Version="1.0.1" />
|
||||||
<PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115">
|
<PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
@@ -23,7 +24,6 @@
|
|||||||
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/>
|
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/>
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/>
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/>
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0"/>
|
<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="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4"/>
|
||||||
<PackageReference Include="OpenGraph-Net" Version="4.0.1" />
|
<PackageReference Include="OpenGraph-Net" Version="4.0.1" />
|
||||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/>
|
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/>
|
||||||
@@ -49,6 +49,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
|
||||||
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj"/>
|
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
@@ -136,19 +137,12 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<AdditionalFiles Include="Pages\Emails\AccountDeletionEmail.razor"/>
|
<AdditionalFiles Include="Pages\Emails\AccountDeletionEmail.razor" />
|
||||||
<AdditionalFiles Include="Pages\Emails\ContactVerificationEmail.razor"/>
|
<AdditionalFiles Include="Pages\Emails\ContactVerificationEmail.razor" />
|
||||||
<AdditionalFiles Include="Pages\Emails\EmailLayout.razor"/>
|
<AdditionalFiles Include="Pages\Emails\EmailLayout.razor" />
|
||||||
<AdditionalFiles Include="Pages\Emails\LandingEmail.razor"/>
|
<AdditionalFiles Include="Pages\Emails\LandingEmail.razor" />
|
||||||
<AdditionalFiles Include="Pages\Emails\PasswordResetEmail.razor"/>
|
<AdditionalFiles Include="Pages\Emails\PasswordResetEmail.razor" />
|
||||||
<AdditionalFiles Include="Pages\Emails\VerificationEmail.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"/>
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
using dotnet_etcd;
|
|
||||||
using dotnet_etcd.interfaces;
|
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
namespace DysonNetwork.Pass.Email;
|
namespace DysonNetwork.Pass.Email;
|
||||||
|
|
||||||
public class EmailService(
|
public class EmailService(
|
||||||
PusherService.PusherServiceClient pusher,
|
RingService.RingServiceClient pusher,
|
||||||
RazorViewRenderer viewRenderer,
|
RazorViewRenderer viewRenderer,
|
||||||
ILogger<EmailService> logger
|
ILogger<EmailService> logger
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public class LastActiveFlushHandler(IServiceProvider srp, ILogger<LastActiveFlus
|
|||||||
public async Task FlushAsync(IReadOnlyList<LastActiveInfo> items)
|
public async Task FlushAsync(IReadOnlyList<LastActiveInfo> items)
|
||||||
{
|
{
|
||||||
logger.LogInformation("Flushing {Count} LastActiveInfo items...", items.Count);
|
logger.LogInformation("Flushing {Count} LastActiveInfo items...", items.Count);
|
||||||
|
|
||||||
using var scope = srp.CreateScope();
|
using var scope = srp.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
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);
|
.ToDictionary(g => g.Key, g => g.Last().SeenAt);
|
||||||
|
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
|
||||||
var updatingSessions = sessionMap.Select(x => x.Key).ToList();
|
var updatingSessions = sessionMap.Select(x => x.Key).ToList();
|
||||||
var sessionUpdates = await db.AuthSessions
|
var sessionUpdates = await db.AuthSessions
|
||||||
.Where(s => updatingSessions.Contains(s.Id))
|
.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);
|
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 updatingAccounts = accountMap.Select(x => x.Key).ToList();
|
||||||
var profileUpdates = await db.AccountProfiles
|
var profileUpdates = await db.AccountProfiles
|
||||||
.Where(a => updatingAccounts.Contains(a.AccountId))
|
.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)
|
public async Task Execute(IJobExecutionContext context)
|
||||||
{
|
{
|
||||||
@@ -62,7 +72,8 @@ public class LastActiveFlushJob(FlushBufferService fbs, LastActiveFlushHandler h
|
|||||||
logger.LogInformation("Running LastActiveInfo flush job...");
|
logger.LogInformation("Running LastActiveInfo flush job...");
|
||||||
await fbs.FlushAsync(hdl);
|
await fbs.FlushAsync(hdl);
|
||||||
logger.LogInformation("Completed LastActiveInfo flush job...");
|
logger.LogInformation("Completed LastActiveInfo flush job...");
|
||||||
} catch (Exception ex)
|
}
|
||||||
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Error running LastActiveInfo job...");
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user