Compare commits
	
		
			165 Commits
		
	
	
		
			adc2b20aeb
			...
			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 | 
							
								
								
									
										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 | ||||
							
								
								
									
										233
									
								
								.github/workflows/docker-build.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										233
									
								
								.github/workflows/docker-build.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,189 +1,60 @@ | ||||
|  name: Build and Push Microservices | ||||
| name: Aspire Publish Workflow | ||||
|  | ||||
|  on: | ||||
|    push: | ||||
|      branches: | ||||
|        - master | ||||
|    workflow_dispatch: | ||||
| on: | ||||
|   push: | ||||
|     branches: | ||||
|       - master | ||||
|   workflow_dispatch: | ||||
|  | ||||
|  jobs: | ||||
|    build-sphere: | ||||
|      runs-on: ubuntu-latest | ||||
|      permissions: | ||||
|        contents: read | ||||
|        packages: write | ||||
|      steps: | ||||
|        - name: Checkout repository | ||||
|          uses: actions/checkout@v3 | ||||
|          with: | ||||
|            fetch-depth: 0 | ||||
|        - name: Setup NBGV | ||||
|          uses: dotnet/nbgv@master | ||||
|          id: nbgv | ||||
|        - name: Set up Docker Buildx | ||||
|          uses: docker/setup-buildx-action@v3 | ||||
|        - name: Log in to GitHub Container Registry | ||||
|          uses: docker/login-action@v3 | ||||
|          with: | ||||
|            registry: ghcr.io | ||||
|            username: ${{ github.actor }} | ||||
|            password: ${{ secrets.GITHUB_TOKEN }} | ||||
|        - name: Build and push DysonNetwork.Sphere Docker image | ||||
|          uses: docker/build-push-action@v6 | ||||
|          with: | ||||
|            file: DysonNetwork.Sphere/Dockerfile | ||||
|            context: . | ||||
|            push: true | ||||
|            tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-sphere:latest | ||||
|            platforms: linux/amd64 | ||||
| jobs: | ||||
|   publish: | ||||
|     runs-on: ubuntu-latest | ||||
|     permissions: | ||||
|       contents: read | ||||
|       packages: write | ||||
|     steps: | ||||
|       - name: Checkout repository | ||||
|         uses: actions/checkout@v3 | ||||
|  | ||||
|    build-pass: | ||||
|      runs-on: ubuntu-latest | ||||
|      permissions: | ||||
|        contents: read | ||||
|        packages: write | ||||
|      steps: | ||||
|        - name: Checkout repository | ||||
|          uses: actions/checkout@v3 | ||||
|          with: | ||||
|            fetch-depth: 0 | ||||
|        - name: Setup NBGV | ||||
|          uses: dotnet/nbgv@master | ||||
|          id: nbgv | ||||
|        - name: Set up Docker Buildx | ||||
|          uses: docker/setup-buildx-action@v3 | ||||
|        - name: Log in to GitHub Container Registry | ||||
|          uses: docker/login-action@v3 | ||||
|          with: | ||||
|            registry: ghcr.io | ||||
|            username: ${{ github.actor }} | ||||
|            password: ${{ secrets.GITHUB_TOKEN }} | ||||
|        - name: Build and push DysonNetwork.Pass Docker image | ||||
|          uses: docker/build-push-action@v6 | ||||
|          with: | ||||
|            file: DysonNetwork.Pass/Dockerfile | ||||
|            context: . | ||||
|            push: true | ||||
|            tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-pass:latest | ||||
|            platforms: linux/amd64 | ||||
|       - name: Setup .NET | ||||
|         uses: actions/setup-dotnet@v3 | ||||
|         with: | ||||
|           dotnet-version: "9.0.x" | ||||
|  | ||||
|    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 | ||||
|       - name: Log in to GitHub Container Registry | ||||
|         uses: docker/login-action@v3 | ||||
|         with: | ||||
|           registry: ghcr.io | ||||
|           username: ${{ github.actor }} | ||||
|           password: ${{ secrets.GITHUB_TOKEN }} | ||||
|  | ||||
|    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 | ||||
|       - name: Install Aspire CLI | ||||
|         run: dotnet tool install -g Aspire.Cli --prerelease | ||||
|  | ||||
|    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 | ||||
|       - name: Build and Publish Aspire Application | ||||
|         run: aspire publish --project ./DysonNetwork.Control/DysonNetwork.Control.csproj --output publish | ||||
|  | ||||
|    build-develop: | ||||
|      runs-on: ubuntu-latest | ||||
|      permissions: | ||||
|        contents: read | ||||
|        packages: write | ||||
|      steps: | ||||
|        - name: Checkout repository | ||||
|          uses: actions/checkout@v3 | ||||
|          with: | ||||
|            fetch-depth: 0 | ||||
|        - name: Setup NBGV | ||||
|          uses: dotnet/nbgv@master | ||||
|          id: nbgv | ||||
|        - name: Set up Docker Buildx | ||||
|          uses: docker/setup-buildx-action@v3 | ||||
|        - name: Log in to GitHub Container Registry | ||||
|          uses: docker/login-action@v3 | ||||
|          with: | ||||
|            registry: ghcr.io | ||||
|            username: ${{ github.actor }} | ||||
|            password: ${{ secrets.GITHUB_TOKEN }} | ||||
|        - name: Build and push DysonNetwork.Develop Docker image | ||||
|          uses: docker/build-push-action@v6 | ||||
|          with: | ||||
|            file: DysonNetwork.Develop/Dockerfile | ||||
|            context: . | ||||
|            push: true | ||||
|            tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-develop:latest | ||||
|            platforms: linux/amd64 | ||||
|       - name: Tag and Push Images | ||||
|         run: | | ||||
|           IMAGES=( "sphere" "pass" "ring" "drive" "develop" ) | ||||
|  | ||||
|           for image in "${IMAGES[@]}"; do | ||||
|             IMAGE_NAME="ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-$image:alpha" | ||||
|             SOURCE_IMAGE_NAME="$image:latest" # Aspire's default local image name | ||||
|  | ||||
|             echo "Tagging and pushing $SOURCE_IMAGE_NAME to $IMAGE_NAME..." | ||||
|             docker tag $SOURCE_IMAGE_NAME $IMAGE_NAME | ||||
|             docker push $IMAGE_NAME | ||||
|           done | ||||
|  | ||||
|       - name: Upload Aspire Publish Directory | ||||
|         uses: actions/upload-artifact@v3 | ||||
|         with: | ||||
|           name: aspire-publish-output | ||||
|           path: ./publish/ | ||||
|  | ||||
|       - name: Upload Docker Compose file | ||||
|         uses: actions/upload-artifact@v3 | ||||
|         with: | ||||
|           name: docker-compose-output | ||||
|           path: ./publish/docker-compose.yml | ||||
|   | ||||
							
								
								
									
										77
									
								
								DysonNetwork.Control/AppHost.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								DysonNetwork.Control/AppHost.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,77 @@ | ||||
| using Aspire.Hosting.Yarp.Transforms; | ||||
|  | ||||
| var builder = DistributedApplication.CreateBuilder(args); | ||||
|  | ||||
| // Database was configured separately in each service. | ||||
| // var database = builder.AddPostgres("database"); | ||||
|  | ||||
| var cache = builder.AddRedis("cache"); | ||||
| var queue = builder.AddNats("queue").WithJetStream(); | ||||
|  | ||||
| var ringService = builder.AddProject<Projects.DysonNetwork_Ring>("ring") | ||||
|     .WithReference(queue) | ||||
|     .WithHttpHealthCheck() | ||||
|     .WithEndpoint(5001, 5001, "https", name: "grpc"); | ||||
| var passService = builder.AddProject<Projects.DysonNetwork_Pass>("pass") | ||||
|     .WithReference(cache) | ||||
|     .WithReference(queue) | ||||
|     .WithReference(ringService) | ||||
|     .WithHttpHealthCheck() | ||||
|     .WithEndpoint(5001, 5001, "https", name: "grpc"); | ||||
| var driveService = builder.AddProject<Projects.DysonNetwork_Drive>("drive") | ||||
|     .WithReference(cache) | ||||
|     .WithReference(queue) | ||||
|     .WithReference(passService) | ||||
|     .WithReference(ringService) | ||||
|     .WithHttpHealthCheck() | ||||
|     .WithEndpoint(5001, 5001, "https", name: "grpc"); | ||||
| var sphereService = builder.AddProject<Projects.DysonNetwork_Sphere>("sphere") | ||||
|     .WithReference(cache) | ||||
|     .WithReference(queue) | ||||
|     .WithReference(passService) | ||||
|     .WithReference(ringService) | ||||
|     .WithHttpHealthCheck() | ||||
|     .WithEndpoint(5001, 5001, "https", name: "grpc"); | ||||
| var developService = builder.AddProject<Projects.DysonNetwork_Develop>("develop") | ||||
|     .WithReference(cache) | ||||
|     .WithReference(passService) | ||||
|     .WithReference(ringService) | ||||
|     .WithHttpHealthCheck() | ||||
|     .WithEndpoint(5001, 5001, "https", name: "grpc"); | ||||
|  | ||||
| // Extra double-ended references | ||||
| ringService.WithReference(passService); | ||||
|  | ||||
| builder.AddYarp("gateway") | ||||
|     .WithHostPort(5000) | ||||
|     .WithConfiguration(yarp => | ||||
|     { | ||||
|         var ringCluster = yarp.AddCluster(ringService.GetEndpoint("http")); | ||||
|         yarp.AddRoute("/ws", ringCluster); | ||||
|         yarp.AddRoute("/ring/{**catch-all}", ringCluster) | ||||
|             .WithTransformPathRemovePrefix("/ring") | ||||
|             .WithTransformPathPrefix("/api"); | ||||
|         var passCluster = yarp.AddCluster(passService.GetEndpoint("http")); | ||||
|         yarp.AddRoute("/.well-known/openid-configuration", passCluster); | ||||
|         yarp.AddRoute("/.well-known/jwks", passCluster); | ||||
|         yarp.AddRoute("/id/{**catch-all}", passCluster) | ||||
|             .WithTransformPathRemovePrefix("/id") | ||||
|             .WithTransformPathPrefix("/api"); | ||||
|         var driveCluster = yarp.AddCluster(driveService.GetEndpoint("http")); | ||||
|         yarp.AddRoute("/api/tus", driveCluster); | ||||
|         yarp.AddRoute("/drive/{**catch-all}", driveCluster) | ||||
|             .WithTransformPathRemovePrefix("/drive") | ||||
|             .WithTransformPathPrefix("/api"); | ||||
|         var sphereCluster = yarp.AddCluster(sphereService.GetEndpoint("http")); | ||||
|         yarp.AddRoute("/sphere/{**catch-all}", sphereCluster) | ||||
|             .WithTransformPathRemovePrefix("/sphere") | ||||
|             .WithTransformPathPrefix("/api"); | ||||
|         var developCluster = yarp.AddCluster(developService.GetEndpoint("http")); | ||||
|         yarp.AddRoute("/develop/{**catch-all}", developCluster) | ||||
|             .WithTransformPathRemovePrefix("/develop") | ||||
|             .WithTransformPathPrefix("/api"); | ||||
|     }); | ||||
|  | ||||
| builder.AddDockerComposeEnvironment("docker-compose"); | ||||
|  | ||||
| builder.Build().Run(); | ||||
							
								
								
									
										30
									
								
								DysonNetwork.Control/DysonNetwork.Control.csproj
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								DysonNetwork.Control/DysonNetwork.Control.csproj
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|  | ||||
|     <Sdk Name="Aspire.AppHost.Sdk" Version="9.4.2"/> | ||||
|  | ||||
|     <PropertyGroup> | ||||
|         <OutputType>Exe</OutputType> | ||||
|         <TargetFramework>net9.0</TargetFramework> | ||||
|         <ImplicitUsings>enable</ImplicitUsings> | ||||
|         <Nullable>enable</Nullable> | ||||
|         <UserSecretsId>a68b3195-a00d-40c2-b5ed-d675356b7cde</UserSecretsId> | ||||
|         <RootNamespace>DysonNetwork.Control</RootNamespace> | ||||
|     </PropertyGroup> | ||||
|  | ||||
|     <ItemGroup> | ||||
|         <PackageReference Include="Aspire.Hosting.AppHost" Version="9.4.2"/> | ||||
|         <PackageReference Include="Aspire.Hosting.Docker" Version="9.4.2-preview.1.25428.12" /> | ||||
|         <PackageReference Include="Aspire.Hosting.Nats" Version="9.4.2" /> | ||||
|         <PackageReference Include="Aspire.Hosting.Redis" Version="9.4.2" /> | ||||
|         <PackageReference Include="Aspire.Hosting.Yarp" Version="9.4.2-preview.1.25428.12" /> | ||||
|     </ItemGroup> | ||||
|  | ||||
|     <ItemGroup> | ||||
|       <ProjectReference Include="..\DysonNetwork.Develop\DysonNetwork.Develop.csproj" /> | ||||
|       <ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" /> | ||||
|       <ProjectReference Include="..\DysonNetwork.Pass\DysonNetwork.Pass.csproj" /> | ||||
|       <ProjectReference Include="..\DysonNetwork.Ring\DysonNetwork.Ring.csproj" /> | ||||
|       <ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" /> | ||||
|     </ItemGroup> | ||||
|  | ||||
| </Project> | ||||
							
								
								
									
										29
									
								
								DysonNetwork.Control/Properties/launchSettings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								DysonNetwork.Control/Properties/launchSettings.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| { | ||||
|   "$schema": "https://json.schemastore.org/launchsettings.json", | ||||
|   "profiles": { | ||||
|     "https": { | ||||
|       "commandName": "Project", | ||||
|       "dotnetRunMessages": true, | ||||
|       "launchBrowser": true, | ||||
|       "applicationUrl": "https://localhost:17025;http://localhost:15057", | ||||
|       "environmentVariables": { | ||||
|         "ASPNETCORE_ENVIRONMENT": "Development", | ||||
|         "DOTNET_ENVIRONMENT": "Development", | ||||
|         "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21175", | ||||
|         "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22189" | ||||
|       } | ||||
|     }, | ||||
|     "http": { | ||||
|       "commandName": "Project", | ||||
|       "dotnetRunMessages": true, | ||||
|       "launchBrowser": true, | ||||
|       "applicationUrl": "http://localhost:15057", | ||||
|       "environmentVariables": { | ||||
|         "ASPNETCORE_ENVIRONMENT": "Development", | ||||
|         "DOTNET_ENVIRONMENT": "Development", | ||||
|         "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19163", | ||||
|         "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20185" | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										11
									
								
								DysonNetwork.Control/appsettings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								DysonNetwork.Control/appsettings.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| { | ||||
|   "Logging": { | ||||
|     "LogLevel": { | ||||
|       "Default": "Information", | ||||
|       "Microsoft.AspNetCore": "Warning" | ||||
|     } | ||||
|   }, | ||||
|   "ConnectionStrings": { | ||||
|     "cache": "localhost:6379" | ||||
|   } | ||||
| } | ||||
| @@ -1,4 +1,6 @@ | ||||
| using System.Text.Json; | ||||
| using DysonNetwork.Develop.Identity; | ||||
| using DysonNetwork.Develop.Project; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.EntityFrameworkCore.Design; | ||||
|  | ||||
| @@ -11,8 +13,11 @@ public class AppDatabase( | ||||
| { | ||||
|     public DbSet<Developer> Developers { get; set; } = null!; | ||||
|  | ||||
|     public DbSet<DevProject> DevProjects { get; set; } = null!; | ||||
|      | ||||
|     public DbSet<CustomApp> CustomApps { get; set; } = null!; | ||||
|     public DbSet<CustomAppSecret> CustomAppSecrets { get; set; } = null!; | ||||
|     public DbSet<BotAccount> BotAccounts { get; set; } = null!; | ||||
|  | ||||
|     protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) | ||||
|     { | ||||
|   | ||||
| @@ -31,6 +31,7 @@ | ||||
|     </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" /> | ||||
|     <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> | ||||
|   </ItemGroup> | ||||
|   | ||||
|   | ||||
							
								
								
									
										54
									
								
								DysonNetwork.Develop/Identity/BotAccount.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								DysonNetwork.Develop/Identity/BotAccount.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,54 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using System.ComponentModel.DataAnnotations.Schema; | ||||
| using DysonNetwork.Develop.Project; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using NodaTime.Serialization.Protobuf; | ||||
|  | ||||
| namespace DysonNetwork.Develop.Identity; | ||||
|  | ||||
| public class BotAccount : ModelBase | ||||
| { | ||||
|     public Guid Id { get; set; } = Guid.NewGuid(); | ||||
|     [MaxLength(1024)] public string Slug { get; set; } = null!; | ||||
|  | ||||
|     public bool IsActive { get; set; } = true; | ||||
|  | ||||
|     public Guid ProjectId { get; set; } | ||||
|     public DevProject Project { get; set; } = null!; | ||||
|      | ||||
|     [NotMapped] public AccountReference? Account { get; set; } | ||||
|      | ||||
|     /// <summary> | ||||
|     /// This developer field is to serve the transparent info for user to know which developer | ||||
|     /// published this robot. Not for relationships usage. | ||||
|     /// </summary> | ||||
|     [NotMapped] public Developer? Developer { get; set; } | ||||
|  | ||||
|     public Shared.Proto.BotAccount ToProtoValue() | ||||
|     { | ||||
|         var proto = new Shared.Proto.BotAccount | ||||
|         { | ||||
|             Slug = Slug, | ||||
|             IsActive = IsActive, | ||||
|             AutomatedId = Id.ToString(), | ||||
|             CreatedAt = CreatedAt.ToTimestamp(), | ||||
|             UpdatedAt = UpdatedAt.ToTimestamp() | ||||
|         }; | ||||
|  | ||||
|         return proto; | ||||
|     } | ||||
|  | ||||
|     public static BotAccount FromProto(Shared.Proto.BotAccount proto) | ||||
|     { | ||||
|         var botAccount = new BotAccount | ||||
|         { | ||||
|             Id = Guid.Parse(proto.AutomatedId), | ||||
|             Slug = proto.Slug, | ||||
|             IsActive = proto.IsActive, | ||||
|             CreatedAt = proto.CreatedAt.ToInstant(), | ||||
|             UpdatedAt = proto.UpdatedAt.ToInstant() | ||||
|         }; | ||||
|  | ||||
|         return botAccount; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										460
									
								
								DysonNetwork.Develop/Identity/BotAccountController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										460
									
								
								DysonNetwork.Develop/Identity/BotAccountController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,460 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using DysonNetwork.Develop.Project; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using DysonNetwork.Shared.Registry; | ||||
| using Grpc.Core; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using NodaTime; | ||||
| using NodaTime.Serialization.Protobuf; | ||||
|  | ||||
| namespace DysonNetwork.Develop.Identity; | ||||
|  | ||||
| [ApiController] | ||||
| [Route("/api/developers/{pubName}/projects/{projectId:guid}/bots")] | ||||
| [Authorize] | ||||
| public class BotAccountController( | ||||
|     BotAccountService botService, | ||||
|     DeveloperService developerService, | ||||
|     DevProjectService projectService, | ||||
|     ILogger<BotAccountController> logger, | ||||
|     AccountClientHelper accounts, | ||||
|     BotAccountReceiverService.BotAccountReceiverServiceClient accountsReceiver | ||||
| ) | ||||
|     : ControllerBase | ||||
| { | ||||
|     public class CommonBotRequest | ||||
|     { | ||||
|         [MaxLength(256)] public string? FirstName { get; set; } | ||||
|         [MaxLength(256)] public string? MiddleName { get; set; } | ||||
|         [MaxLength(256)] public string? LastName { get; set; } | ||||
|         [MaxLength(1024)] public string? Gender { get; set; } | ||||
|         [MaxLength(1024)] public string? Pronouns { get; set; } | ||||
|         [MaxLength(1024)] public string? TimeZone { get; set; } | ||||
|         [MaxLength(1024)] public string? Location { get; set; } | ||||
|         [MaxLength(4096)] public string? Bio { get; set; } | ||||
|         public Instant? Birthday { get; set; } | ||||
|  | ||||
|         [MaxLength(32)] public string? PictureId { get; set; } | ||||
|         [MaxLength(32)] public string? BackgroundId { get; set; } | ||||
|     } | ||||
|  | ||||
|     public class BotCreateRequest : CommonBotRequest | ||||
|     { | ||||
|         [Required] | ||||
|         [MinLength(2)] | ||||
|         [MaxLength(256)] | ||||
|         [RegularExpression(@"^[A-Za-z0-9_-]+$", | ||||
|             ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.") | ||||
|         ] | ||||
|         public string Name { get; set; } = string.Empty; | ||||
|  | ||||
|         [Required] [MaxLength(256)] public string Nick { get; set; } = string.Empty; | ||||
|  | ||||
|         [Required] [MaxLength(1024)] public string Slug { get; set; } = string.Empty; | ||||
|  | ||||
|         [MaxLength(128)] public string Language { get; set; } = "en-us"; | ||||
|     } | ||||
|  | ||||
|     public class UpdateBotRequest : CommonBotRequest | ||||
|     { | ||||
|         [MinLength(2)] | ||||
|         [MaxLength(256)] | ||||
|         [RegularExpression(@"^[A-Za-z0-9_-]+$", | ||||
|             ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.") | ||||
|         ] | ||||
|         public string? Name { get; set; } = string.Empty; | ||||
|  | ||||
|         [MaxLength(256)] public string? Nick { get; set; } = string.Empty; | ||||
|  | ||||
|         [Required] [MaxLength(1024)] public string? Slug { get; set; } = string.Empty; | ||||
|  | ||||
|         [MaxLength(128)] public string? Language { get; set; } | ||||
|  | ||||
|         public bool? IsActive { get; set; } | ||||
|     } | ||||
|  | ||||
|     [HttpGet] | ||||
|     public async Task<IActionResult> ListBots( | ||||
|         [FromRoute] string pubName, | ||||
|         [FromRoute] Guid projectId) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||
|             return Unauthorized(); | ||||
|  | ||||
|         var developer = await developerService.GetDeveloperByName(pubName); | ||||
|         if (developer is null) | ||||
|             return NotFound("Developer not found"); | ||||
|  | ||||
|         if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), | ||||
|                 PublisherMemberRole.Viewer)) | ||||
|             return StatusCode(403, "You must be an viewer of the developer to list bots"); | ||||
|  | ||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||
|         if (project is null) | ||||
|             return NotFound("Project not found or you don't have access"); | ||||
|  | ||||
|         var bots = await botService.GetBotsByProjectAsync(projectId); | ||||
|         return Ok(await botService.LoadBotsAccountAsync(bots)); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("{botId:guid}")] | ||||
|     public async Task<IActionResult> GetBot( | ||||
|         [FromRoute] string pubName, | ||||
|         [FromRoute] Guid projectId, | ||||
|         [FromRoute] Guid botId) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||
|             return Unauthorized(); | ||||
|  | ||||
|         var developer = await developerService.GetDeveloperByName(pubName); | ||||
|         if (developer is null) | ||||
|             return NotFound("Developer not found"); | ||||
|  | ||||
|         if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), | ||||
|                 PublisherMemberRole.Viewer)) | ||||
|             return StatusCode(403, "You must be an viewer of the developer to view bot details"); | ||||
|  | ||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||
|         if (project is null) | ||||
|             return NotFound("Project not found or you don't have access"); | ||||
|  | ||||
|         var bot = await botService.GetBotByIdAsync(botId); | ||||
|         if (bot is null || bot.ProjectId != projectId) | ||||
|             return NotFound("Bot not found"); | ||||
|  | ||||
|         return Ok(await botService.LoadBotAccountAsync(bot)); | ||||
|     } | ||||
|  | ||||
|     [HttpPost] | ||||
|     public async Task<IActionResult> CreateBot( | ||||
|         [FromRoute] string pubName, | ||||
|         [FromRoute] Guid projectId, | ||||
|         [FromBody] BotCreateRequest createRequest | ||||
|     ) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||
|             return Unauthorized(); | ||||
|  | ||||
|         var developer = await developerService.GetDeveloperByName(pubName); | ||||
|         if (developer is null) | ||||
|             return NotFound("Developer not found"); | ||||
|  | ||||
|         if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), | ||||
|                 PublisherMemberRole.Editor)) | ||||
|             return StatusCode(403, "You must be an editor of the developer to create a bot"); | ||||
|  | ||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||
|         if (project is null) | ||||
|             return NotFound("Project not found or you don't have access"); | ||||
|  | ||||
|         var now = SystemClock.Instance.GetCurrentInstant(); | ||||
|         var accountId = Guid.NewGuid(); | ||||
|         var account = new Account() | ||||
|         { | ||||
|             Id = accountId.ToString(), | ||||
|             Name = createRequest.Name, | ||||
|             Nick = createRequest.Nick, | ||||
|             Language = createRequest.Language, | ||||
|             Profile = new AccountProfile() | ||||
|             { | ||||
|                 Id = Guid.NewGuid().ToString(), | ||||
|                 Bio = createRequest.Bio, | ||||
|                 Gender = createRequest.Gender, | ||||
|                 FirstName = createRequest.FirstName, | ||||
|                 MiddleName = createRequest.MiddleName, | ||||
|                 LastName = createRequest.LastName, | ||||
|                 TimeZone = createRequest.TimeZone, | ||||
|                 Pronouns = createRequest.Pronouns, | ||||
|                 Location = createRequest.Location, | ||||
|                 Birthday = createRequest.Birthday?.ToTimestamp(), | ||||
|                 AccountId = accountId.ToString(), | ||||
|                 CreatedAt = now.ToTimestamp(), | ||||
|                 UpdatedAt = now.ToTimestamp() | ||||
|             }, | ||||
|             CreatedAt = now.ToTimestamp(), | ||||
|             UpdatedAt = now.ToTimestamp() | ||||
|         }; | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var bot = await botService.CreateBotAsync( | ||||
|                 project, | ||||
|                 createRequest.Slug, | ||||
|                 account, | ||||
|                 createRequest.PictureId, | ||||
|                 createRequest.BackgroundId | ||||
|             ); | ||||
|             return Ok(bot); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             logger.LogError(ex, "Error creating bot account"); | ||||
|             return StatusCode(500, "An error occurred while creating the bot account"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [HttpPatch("{botId:guid}")] | ||||
|     public async Task<IActionResult> UpdateBot( | ||||
|         [FromRoute] string pubName, | ||||
|         [FromRoute] Guid projectId, | ||||
|         [FromRoute] Guid botId, | ||||
|         [FromBody] UpdateBotRequest request | ||||
|     ) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||
|             return Unauthorized(); | ||||
|  | ||||
|         var developer = await developerService.GetDeveloperByName(pubName); | ||||
|         if (developer is null) | ||||
|             return NotFound("Developer not found"); | ||||
|  | ||||
|         if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), | ||||
|                 PublisherMemberRole.Editor)) | ||||
|             return StatusCode(403, "You must be an editor of the developer to update a bot"); | ||||
|  | ||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||
|         if (project is null) | ||||
|             return NotFound("Project not found or you don't have access"); | ||||
|  | ||||
|         var bot = await botService.GetBotByIdAsync(botId); | ||||
|         if (bot is null || bot.ProjectId != projectId) | ||||
|             return NotFound("Bot not found"); | ||||
|  | ||||
|         var botAccount = await accounts.GetBotAccount(bot.Id); | ||||
|  | ||||
|         if (request.Name is not null) botAccount.Name = request.Name; | ||||
|         if (request.Nick is not null) botAccount.Nick = request.Nick; | ||||
|         if (request.Language is not null) botAccount.Language = request.Language; | ||||
|         if (request.Bio is not null) botAccount.Profile.Bio = request.Bio; | ||||
|         if (request.Gender is not null) botAccount.Profile.Gender = request.Gender; | ||||
|         if (request.FirstName is not null) botAccount.Profile.FirstName = request.FirstName; | ||||
|         if (request.MiddleName is not null) botAccount.Profile.MiddleName = request.MiddleName; | ||||
|         if (request.LastName is not null) botAccount.Profile.LastName = request.LastName; | ||||
|         if (request.TimeZone is not null) botAccount.Profile.TimeZone = request.TimeZone; | ||||
|         if (request.Pronouns is not null) botAccount.Profile.Pronouns = request.Pronouns; | ||||
|         if (request.Location is not null) botAccount.Profile.Location = request.Location; | ||||
|         if (request.Birthday is not null) botAccount.Profile.Birthday = request.Birthday?.ToTimestamp(); | ||||
|  | ||||
|         if (request.Slug is not null) bot.Slug = request.Slug; | ||||
|         if (request.IsActive is not null) bot.IsActive = request.IsActive.Value; | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var updatedBot = await botService.UpdateBotAsync( | ||||
|                 bot, | ||||
|                 botAccount, | ||||
|                 request.PictureId, | ||||
|                 request.BackgroundId | ||||
|             ); | ||||
|  | ||||
|             return Ok(updatedBot); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             logger.LogError(ex, "Error updating bot account {BotId}", botId); | ||||
|             return StatusCode(500, "An error occurred while updating the bot account"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [HttpDelete("{botId:guid}")] | ||||
|     public async Task<IActionResult> DeleteBot( | ||||
|         [FromRoute] string pubName, | ||||
|         [FromRoute] Guid projectId, | ||||
|         [FromRoute] Guid botId) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||
|             return Unauthorized(); | ||||
|  | ||||
|         var developer = await developerService.GetDeveloperByName(pubName); | ||||
|         if (developer is null) | ||||
|             return NotFound("Developer not found"); | ||||
|  | ||||
|         if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), | ||||
|                 PublisherMemberRole.Editor)) | ||||
|             return StatusCode(403, "You must be an editor of the developer to delete a bot"); | ||||
|  | ||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||
|         if (project is null) | ||||
|             return NotFound("Project not found or you don't have access"); | ||||
|  | ||||
|         var bot = await botService.GetBotByIdAsync(botId); | ||||
|         if (bot is null || bot.ProjectId != projectId) | ||||
|             return NotFound("Bot not found"); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             await botService.DeleteBotAsync(bot); | ||||
|             return NoContent(); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             logger.LogError(ex, "Error deleting bot {BotId}", botId); | ||||
|             return StatusCode(500, "An error occurred while deleting the bot account"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [HttpGet("{botId:guid}/keys")] | ||||
|     public async Task<ActionResult<List<ApiKeyReference>>> ListBotKeys( | ||||
|         [FromRoute] string pubName, | ||||
|         [FromRoute] Guid projectId, | ||||
|         [FromRoute] Guid botId | ||||
|     ) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||
|             return Unauthorized(); | ||||
|  | ||||
|         var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Viewer); | ||||
|         if (developer == null) return NotFound("Developer not found"); | ||||
|         if (project == null) return NotFound("Project not found or you don't have access"); | ||||
|         if (bot == null) return NotFound("Bot not found"); | ||||
|  | ||||
|         var keys = await accountsReceiver.ListApiKeyAsync(new ListApiKeyRequest | ||||
|         { | ||||
|             AutomatedId = bot.Id.ToString() | ||||
|         }); | ||||
|         var data = keys.Data.Select(ApiKeyReference.FromProtoValue).ToList(); | ||||
|  | ||||
|         return Ok(data); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("{botId:guid}/keys/{keyId:guid}")] | ||||
|     public async Task<ActionResult<ApiKeyReference>> GetBotKey( | ||||
|         [FromRoute] string pubName, | ||||
|         [FromRoute] Guid projectId, | ||||
|         [FromRoute] Guid botId, | ||||
|         [FromRoute] Guid keyId) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||
|             return Unauthorized(); | ||||
|  | ||||
|         var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Viewer); | ||||
|         if (developer == null) return NotFound("Developer not found"); | ||||
|         if (project == null) return NotFound("Project not found or you don't have access"); | ||||
|         if (bot == null) return NotFound("Bot not found"); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var key = await accountsReceiver.GetApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() }); | ||||
|             if (key == null) return NotFound("API key not found"); | ||||
|             return Ok(ApiKeyReference.FromProtoValue(key)); | ||||
|         } | ||||
|         catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) | ||||
|         { | ||||
|             return NotFound("API key not found"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public class CreateApiKeyRequest | ||||
|     { | ||||
|         [Required, MaxLength(1024)] | ||||
|         public string Label { get; set; } = null!; | ||||
|     } | ||||
|  | ||||
|     [HttpPost("{botId:guid}/keys")] | ||||
|     public async Task<ActionResult<ApiKeyReference>> CreateBotKey( | ||||
|         [FromRoute] string pubName, | ||||
|         [FromRoute] Guid projectId, | ||||
|         [FromRoute] Guid botId, | ||||
|         [FromBody] CreateApiKeyRequest request) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||
|             return Unauthorized(); | ||||
|  | ||||
|         var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor); | ||||
|         if (developer == null) return NotFound("Developer not found"); | ||||
|         if (project == null) return NotFound("Project not found or you don't have access"); | ||||
|         if (bot == null) return NotFound("Bot not found"); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var newKey = new ApiKey | ||||
|             { | ||||
|                 AccountId = bot.Id.ToString(), | ||||
|                 Label = request.Label | ||||
|             }; | ||||
|              | ||||
|             var createdKey = await accountsReceiver.CreateApiKeyAsync(newKey); | ||||
|             return Ok(ApiKeyReference.FromProtoValue(createdKey)); | ||||
|         } | ||||
|         catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument) | ||||
|         { | ||||
|             return BadRequest(ex.Status.Detail); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [HttpPost("{botId:guid}/keys/{keyId:guid}/rotate")] | ||||
|     public async Task<ActionResult<ApiKeyReference>> RotateBotKey( | ||||
|         [FromRoute] string pubName, | ||||
|         [FromRoute] Guid projectId, | ||||
|         [FromRoute] Guid botId, | ||||
|         [FromRoute] Guid keyId) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||
|             return Unauthorized(); | ||||
|  | ||||
|         var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor); | ||||
|         if (developer == null) return NotFound("Developer not found"); | ||||
|         if (project == null) return NotFound("Project not found or you don't have access"); | ||||
|         if (bot == null) return NotFound("Bot not found"); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var rotatedKey = await accountsReceiver.RotateApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() }); | ||||
|             return Ok(ApiKeyReference.FromProtoValue(rotatedKey)); | ||||
|         } | ||||
|         catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) | ||||
|         { | ||||
|             return NotFound("API key not found"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [HttpDelete("{botId:guid}/keys/{keyId:guid}")] | ||||
|     public async Task<IActionResult> DeleteBotKey( | ||||
|         [FromRoute] string pubName, | ||||
|         [FromRoute] Guid projectId, | ||||
|         [FromRoute] Guid botId, | ||||
|         [FromRoute] Guid keyId) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||
|             return Unauthorized(); | ||||
|  | ||||
|         var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor); | ||||
|         if (developer == null) return NotFound("Developer not found"); | ||||
|         if (project == null) return NotFound("Project not found or you don't have access"); | ||||
|         if (bot == null) return NotFound("Bot not found"); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             await accountsReceiver.DeleteApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() }); | ||||
|             return NoContent(); | ||||
|         } | ||||
|         catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) | ||||
|         { | ||||
|             return NotFound("API key not found"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private async Task<(Developer?, DevProject?, BotAccount?)> ValidateBotAccess( | ||||
|         string pubName, | ||||
|         Guid projectId, | ||||
|         Guid botId, | ||||
|         Account currentUser, | ||||
|         PublisherMemberRole requiredRole) | ||||
|     { | ||||
|         var developer = await developerService.GetDeveloperByName(pubName); | ||||
|         if (developer == null) return (null, null, null); | ||||
|  | ||||
|         if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), requiredRole)) | ||||
|             return (null, null, null); | ||||
|  | ||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||
|         if (project == null) return (developer, null, null); | ||||
|  | ||||
|         var bot = await botService.GetBotByIdAsync(botId); | ||||
|         if (bot == null || bot.ProjectId != projectId) return (developer, project, null); | ||||
|  | ||||
|         return (developer, project, bot); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										35
									
								
								DysonNetwork.Develop/Identity/BotAccountPublicController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								DysonNetwork.Develop/Identity/BotAccountPublicController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
|  | ||||
| namespace DysonNetwork.Develop.Identity; | ||||
|  | ||||
| [ApiController] | ||||
| [Route("api/bots")] | ||||
| public class BotAccountPublicController(BotAccountService botService, DeveloperService developerService) : ControllerBase | ||||
| { | ||||
|     [HttpGet("{botId:guid}")] | ||||
|     public async Task<ActionResult<BotAccount>> GetBotTransparentInfo([FromRoute] Guid botId) | ||||
|     { | ||||
|         var bot = await botService.GetBotByIdAsync(botId); | ||||
|         if (bot is null) return NotFound("Bot not found"); | ||||
|         bot = await botService.LoadBotAccountAsync(bot); | ||||
|  | ||||
|         var developer = await developerService.GetDeveloperById(bot!.Project.DeveloperId); | ||||
|         if (developer is null) return NotFound("Developer not found"); | ||||
|         bot.Developer = await developerService.LoadDeveloperPublisher(developer); | ||||
|  | ||||
|         return Ok(bot); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("{botId:guid}/developer")] | ||||
|     public async Task<ActionResult<Developer>> GetBotDeveloper([FromRoute] Guid botId) | ||||
|     { | ||||
|         var bot = await botService.GetBotByIdAsync(botId); | ||||
|         if (bot is null) return NotFound("Bot not found"); | ||||
|          | ||||
|         var developer = await developerService.GetDeveloperById(bot!.Project.DeveloperId); | ||||
|         if (developer is null) return NotFound("Developer not found"); | ||||
|         developer = await developerService.LoadDeveloperPublisher(developer); | ||||
|  | ||||
|         return Ok(developer); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										174
									
								
								DysonNetwork.Develop/Identity/BotAccountService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										174
									
								
								DysonNetwork.Develop/Identity/BotAccountService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,174 @@ | ||||
| using DysonNetwork.Develop.Project; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using DysonNetwork.Shared.Registry; | ||||
| using Grpc.Core; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime.Serialization.Protobuf; | ||||
|  | ||||
| namespace DysonNetwork.Develop.Identity; | ||||
|  | ||||
| public class BotAccountService( | ||||
|     AppDatabase db, | ||||
|     BotAccountReceiverService.BotAccountReceiverServiceClient accountReceiver, | ||||
|     AccountClientHelper accounts | ||||
| ) | ||||
| { | ||||
|     public async Task<BotAccount?> GetBotByIdAsync(Guid id) | ||||
|     { | ||||
|         return await db.BotAccounts | ||||
|             .Include(b => b.Project) | ||||
|             .FirstOrDefaultAsync(b => b.Id == id); | ||||
|     } | ||||
|  | ||||
|     public async Task<IEnumerable<BotAccount>> GetBotsByProjectAsync(Guid projectId) | ||||
|     { | ||||
|         return await db.BotAccounts | ||||
|             .Where(b => b.ProjectId == projectId) | ||||
|             .ToListAsync(); | ||||
|     } | ||||
|  | ||||
|     public async Task<BotAccount> CreateBotAsync( | ||||
|         DevProject project, | ||||
|         string slug, | ||||
|         Account account, | ||||
|         string? pictureId, | ||||
|         string? backgroundId | ||||
|     ) | ||||
|     { | ||||
|         // First, check if a bot with this slug already exists in this project | ||||
|         var existingBot = await db.BotAccounts | ||||
|             .FirstOrDefaultAsync(b => b.ProjectId == project.Id && b.Slug == slug); | ||||
|  | ||||
|         if (existingBot != null) | ||||
|             throw new InvalidOperationException("A bot with this slug already exists in this project."); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var automatedId = Guid.NewGuid(); | ||||
|             var createRequest = new CreateBotAccountRequest | ||||
|             { | ||||
|                 AutomatedId = automatedId.ToString(), | ||||
|                 Account = account, | ||||
|                 PictureId = pictureId, | ||||
|                 BackgroundId = backgroundId | ||||
|             }; | ||||
|  | ||||
|             var createResponse = await accountReceiver.CreateBotAccountAsync(createRequest); | ||||
|             var botAccount = createResponse.Bot; | ||||
|  | ||||
|             // Then create the local bot account | ||||
|             var bot = new BotAccount | ||||
|             { | ||||
|                 Id = automatedId, | ||||
|                 Slug = slug, | ||||
|                 ProjectId = project.Id, | ||||
|                 Project = project, | ||||
|                 IsActive = botAccount.IsActive, | ||||
|                 CreatedAt = botAccount.CreatedAt.ToInstant(), | ||||
|                 UpdatedAt = botAccount.UpdatedAt.ToInstant() | ||||
|             }; | ||||
|  | ||||
|             db.BotAccounts.Add(bot); | ||||
|             await db.SaveChangesAsync(); | ||||
|  | ||||
|             return bot; | ||||
|         } | ||||
|         catch (RpcException ex) when (ex.StatusCode == StatusCode.AlreadyExists) | ||||
|         { | ||||
|             throw new InvalidOperationException( | ||||
|                 "A bot account with this ID already exists in the authentication service.", ex); | ||||
|         } | ||||
|         catch (RpcException ex) when (ex.StatusCode == StatusCode.InvalidArgument) | ||||
|         { | ||||
|             throw new ArgumentException($"Invalid bot account data: {ex.Status.Detail}", ex); | ||||
|         } | ||||
|         catch (RpcException ex) | ||||
|         { | ||||
|             throw new Exception($"Failed to create bot account: {ex.Status.Detail}", ex); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public async Task<BotAccount> UpdateBotAsync( | ||||
|         BotAccount bot, | ||||
|         Account account, | ||||
|         string? pictureId, | ||||
|         string? backgroundId | ||||
|     ) | ||||
|     { | ||||
|         db.Update(bot); | ||||
|         await db.SaveChangesAsync(); | ||||
|          | ||||
|         try | ||||
|         { | ||||
|             // Update the bot account in the Pass service | ||||
|             var updateRequest = new UpdateBotAccountRequest | ||||
|             { | ||||
|                 AutomatedId = bot.Id.ToString(), | ||||
|                 Account = account, | ||||
|                 PictureId = pictureId, | ||||
|                 BackgroundId = backgroundId | ||||
|             }; | ||||
|  | ||||
|             var updateResponse = await accountReceiver.UpdateBotAccountAsync(updateRequest); | ||||
|             var updatedBot = updateResponse.Bot; | ||||
|  | ||||
|             // Update local bot account | ||||
|             bot.UpdatedAt = updatedBot.UpdatedAt.ToInstant(); | ||||
|             bot.IsActive = updatedBot.IsActive; | ||||
|             await db.SaveChangesAsync(); | ||||
|         } | ||||
|         catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound) | ||||
|         { | ||||
|             throw new Exception("Bot account not found in the authentication service", ex); | ||||
|         } | ||||
|         catch (RpcException ex) | ||||
|         { | ||||
|             throw new Exception($"Failed to update bot account: {ex.Status.Detail}", ex); | ||||
|         } | ||||
|  | ||||
|         return bot; | ||||
|     } | ||||
|  | ||||
|     public async Task DeleteBotAsync(BotAccount bot) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             // Delete the bot account from the Pass service | ||||
|             var deleteRequest = new DeleteBotAccountRequest | ||||
|             { | ||||
|                 AutomatedId = bot.Id.ToString(), | ||||
|                 Force = false | ||||
|             }; | ||||
|  | ||||
|             await accountReceiver.DeleteBotAccountAsync(deleteRequest); | ||||
|         } | ||||
|         catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound) | ||||
|         { | ||||
|             // Account not found in Pass service, continue with local deletion | ||||
|         } | ||||
|  | ||||
|         // Delete the local bot account | ||||
|         db.BotAccounts.Remove(bot); | ||||
|         await db.SaveChangesAsync(); | ||||
|     } | ||||
|  | ||||
|     public async Task<BotAccount?> LoadBotAccountAsync(BotAccount bot) => | ||||
|         (await LoadBotsAccountAsync([bot])).FirstOrDefault(); | ||||
|  | ||||
|     public async Task<List<BotAccount>> LoadBotsAccountAsync(IEnumerable<BotAccount> bots) | ||||
|     { | ||||
|         bots = bots.ToList(); | ||||
|         var automatedIds = bots.Select(b => b.Id).ToList(); | ||||
|         var data = await accounts.GetBotAccountBatch(automatedIds); | ||||
|  | ||||
|         foreach (var bot in bots) | ||||
|         { | ||||
|             bot.Account = data | ||||
|                 .Select(AccountReference.FromProtoValue) | ||||
|                 .FirstOrDefault(e => e.AutomatedId == bot.Id); | ||||
|         } | ||||
|  | ||||
|         return bots as List<BotAccount> ?? []; | ||||
|     } | ||||
| } | ||||
| @@ -1,6 +1,7 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using System.ComponentModel.DataAnnotations.Schema; | ||||
| using System.Text.Json.Serialization; | ||||
| using DysonNetwork.Develop.Project; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using Google.Protobuf; | ||||
| @@ -31,14 +32,17 @@ public class CustomApp : ModelBase, IIdentifiedResource | ||||
|     [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; } | ||||
|     [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; } | ||||
|  | ||||
|     [Column(TypeName = "jsonb")] public DysonNetwork.Shared.Data.VerificationMark? Verification { get; set; } | ||||
|     [Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; } | ||||
|     [Column(TypeName = "jsonb")] public CustomAppOauthConfig? OauthConfig { get; set; } | ||||
|     [Column(TypeName = "jsonb")] public CustomAppLinks? Links { get; set; } | ||||
|  | ||||
|     [JsonIgnore] public ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>(); | ||||
|  | ||||
|     public Guid DeveloperId { get; set; } | ||||
|     public Developer Developer { get; set; } = null!; | ||||
|     public Guid ProjectId { get; set; } | ||||
|     public DevProject Project { get; set; } = null!; | ||||
|      | ||||
|     [NotMapped] | ||||
|     public Developer Developer => Project.Developer; | ||||
|  | ||||
|     [NotMapped] public string ResourceIdentifier => "custom-app:" + Id; | ||||
|  | ||||
| @@ -58,21 +62,26 @@ public class CustomApp : ModelBase, IIdentifiedResource | ||||
|                 CustomAppStatus.Suspended => Shared.Proto.CustomAppStatus.Suspended, | ||||
|                 _ => Shared.Proto.CustomAppStatus.Unspecified | ||||
|             }, | ||||
|             Picture = Picture is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Picture)), | ||||
|             Background = Background is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Background)), | ||||
|             Verification = Verification is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Verification)), | ||||
|             Links = Links is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Links)), | ||||
|             Picture = Picture?.ToProtoValue(), | ||||
|             Background = Background?.ToProtoValue(), | ||||
|             Verification = Verification?.ToProtoValue(), | ||||
|             Links = Links is null ? null : new DysonNetwork.Shared.Proto.CustomAppLinks | ||||
|             { | ||||
|                 HomePage = Links.HomePage ?? string.Empty, | ||||
|                 PrivacyPolicy = Links.PrivacyPolicy ?? string.Empty, | ||||
|                 TermsOfService = Links.TermsOfService ?? string.Empty | ||||
|             }, | ||||
|             OauthConfig = OauthConfig is null ? null : new DysonNetwork.Shared.Proto.CustomAppOauthConfig | ||||
|             { | ||||
|                 ClientUri = OauthConfig.ClientUri ?? string.Empty, | ||||
|                 RedirectUris = { OauthConfig.RedirectUris ?? Array.Empty<string>() }, | ||||
|                 PostLogoutRedirectUris = { OauthConfig.PostLogoutRedirectUris ?? Array.Empty<string>() }, | ||||
|                 AllowedScopes = { OauthConfig.AllowedScopes ?? Array.Empty<string>() }, | ||||
|                 AllowedGrantTypes = { OauthConfig.AllowedGrantTypes ?? Array.Empty<string>() }, | ||||
|                 RedirectUris = { OauthConfig.RedirectUris ?? [] }, | ||||
|                 PostLogoutRedirectUris = { OauthConfig.PostLogoutRedirectUris ?? [] }, | ||||
|                 AllowedScopes = { OauthConfig.AllowedScopes ?? [] }, | ||||
|                 AllowedGrantTypes = { OauthConfig.AllowedGrantTypes ?? [] }, | ||||
|                 RequirePkce = OauthConfig.RequirePkce, | ||||
|                 AllowOfflineAccess = OauthConfig.AllowOfflineAccess | ||||
|             }, | ||||
|             DeveloperId = DeveloperId.ToString(), | ||||
|             ProjectId = ProjectId.ToString(), | ||||
|             CreatedAt = CreatedAt.ToTimestamp(), | ||||
|             UpdatedAt = UpdatedAt.ToTimestamp() | ||||
|         }; | ||||
| @@ -92,13 +101,21 @@ public class CustomApp : ModelBase, IIdentifiedResource | ||||
|             Shared.Proto.CustomAppStatus.Suspended => CustomAppStatus.Suspended, | ||||
|             _ => CustomAppStatus.Developing | ||||
|         }; | ||||
|         DeveloperId = string.IsNullOrEmpty(p.DeveloperId) ? Guid.Empty : Guid.Parse(p.DeveloperId); | ||||
|         ProjectId = string.IsNullOrEmpty(p.ProjectId) ? Guid.Empty : Guid.Parse(p.ProjectId); | ||||
|         CreatedAt = p.CreatedAt.ToInstant(); | ||||
|         UpdatedAt = p.UpdatedAt.ToInstant(); | ||||
|         if (p.Picture.Length > 0) Picture = System.Text.Json.JsonSerializer.Deserialize<CloudFileReferenceObject>(p.Picture.ToStringUtf8()); | ||||
|         if (p.Background.Length > 0) Background = System.Text.Json.JsonSerializer.Deserialize<CloudFileReferenceObject>(p.Background.ToStringUtf8()); | ||||
|         if (p.Verification.Length > 0) Verification = System.Text.Json.JsonSerializer.Deserialize<DysonNetwork.Shared.Data.VerificationMark>(p.Verification.ToStringUtf8()); | ||||
|         if (p.Links.Length > 0) Links = System.Text.Json.JsonSerializer.Deserialize<CustomAppLinks>(p.Links.ToStringUtf8()); | ||||
|         if (p.Picture is not null) Picture = CloudFileReferenceObject.FromProtoValue(p.Picture); | ||||
|         if (p.Background is not null) Background = CloudFileReferenceObject.FromProtoValue(p.Background); | ||||
|         if (p.Verification is not null) Verification = VerificationMark.FromProtoValue(p.Verification); | ||||
|         if (p.Links is not null) | ||||
|         { | ||||
|             Links = new CustomAppLinks | ||||
|             { | ||||
|                 HomePage = string.IsNullOrEmpty(p.Links.HomePage) ? null : p.Links.HomePage, | ||||
|                 PrivacyPolicy = string.IsNullOrEmpty(p.Links.PrivacyPolicy) ? null : p.Links.PrivacyPolicy, | ||||
|                 TermsOfService = string.IsNullOrEmpty(p.Links.TermsOfService) ? null : p.Links.TermsOfService | ||||
|             }; | ||||
|         } | ||||
|         return this; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,13 +1,16 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using DysonNetwork.Develop.Project; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Develop.Identity; | ||||
|  | ||||
| [ApiController] | ||||
| [Route("/api/developers/{pubName}/apps")] | ||||
| public class CustomAppController(CustomAppService customApps, DeveloperService ds) : ControllerBase | ||||
| [Route("/api/developers/{pubName}/projects/{projectId:guid}/apps")] | ||||
| public class CustomAppController(CustomAppService customApps, DeveloperService ds, DevProjectService projectService) | ||||
|     : ControllerBase | ||||
| { | ||||
|     public record CustomAppRequest( | ||||
|         [MaxLength(1024)] string? Slug, | ||||
| @@ -20,22 +23,62 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d | ||||
|         CustomAppOauthConfig? OauthConfig | ||||
|     ); | ||||
|  | ||||
|     public record CreateSecretRequest( | ||||
|         [MaxLength(4096)] string? Description, | ||||
|         TimeSpan? ExpiresIn = null, | ||||
|         bool IsOidc = false | ||||
|     ); | ||||
|  | ||||
|     public record SecretResponse( | ||||
|         string Id, | ||||
|         string? Secret, | ||||
|         string? Description, | ||||
|         Instant? ExpiresAt, | ||||
|         bool IsOidc, | ||||
|         Instant CreatedAt, | ||||
|         Instant UpdatedAt | ||||
|     ); | ||||
|  | ||||
|     [HttpGet] | ||||
|     public async Task<IActionResult> ListApps([FromRoute] string pubName) | ||||
|     [Authorize] | ||||
|     public async Task<IActionResult> ListApps([FromRoute] string pubName, [FromRoute] Guid projectId) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||
|             return Unauthorized(); | ||||
|  | ||||
|         var developer = await ds.GetDeveloperByName(pubName); | ||||
|         if (developer is null) return NotFound(); | ||||
|         var apps = await customApps.GetAppsByPublisherAsync(developer.Id); | ||||
|  | ||||
|         var accountId = Guid.Parse(currentUser.Id); | ||||
|         if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, PublisherMemberRole.Viewer)) | ||||
|             return StatusCode(403, "You must be a viewer of the developer to list custom apps"); | ||||
|  | ||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||
|         if (project is null) return NotFound(); | ||||
|  | ||||
|         var apps = await customApps.GetAppsByProjectAsync(projectId); | ||||
|         return Ok(apps); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("{id:guid}")] | ||||
|     public async Task<IActionResult> GetApp([FromRoute] string pubName, Guid id) | ||||
|     [HttpGet("{appId:guid}")] | ||||
|     [Authorize] | ||||
|     public async Task<IActionResult> GetApp([FromRoute] string pubName, [FromRoute] Guid projectId, | ||||
|         [FromRoute] Guid appId) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||
|             return Unauthorized(); | ||||
|          | ||||
|         var developer = await ds.GetDeveloperByName(pubName); | ||||
|         if (developer is null) return NotFound(); | ||||
|          | ||||
|         var app = await customApps.GetAppAsync(id, developerId: 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 app = await customApps.GetAppAsync(appId, projectId); | ||||
|         if (app == null) | ||||
|             return NotFound(); | ||||
|  | ||||
| @@ -44,23 +87,39 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d | ||||
|  | ||||
|     [HttpPost] | ||||
|     [Authorize] | ||||
|     public async Task<IActionResult> CreateApp([FromRoute] string pubName, [FromBody] CustomAppRequest request) | ||||
|     public async Task<IActionResult> CreateApp( | ||||
|         [FromRoute] string pubName, | ||||
|         [FromRoute] Guid projectId, | ||||
|         [FromBody] CustomAppRequest request) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Slug)) | ||||
|             return BadRequest("Name and slug are required"); | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||
|             return Unauthorized(); | ||||
|  | ||||
|         var developer = await ds.GetDeveloperByName(pubName); | ||||
|         if (developer is null) return NotFound(); | ||||
|         if (developer is null) | ||||
|             return NotFound("Developer not found"); | ||||
|  | ||||
|         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) | ||||
|             return StatusCode(403, "You must be an editor of the developer to create a custom app"); | ||||
|  | ||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||
|         if (project is null) | ||||
|             return NotFound("Project not found or you don't have access"); | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Slug)) | ||||
|             return BadRequest("Name and slug are required"); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var app = await customApps.CreateAppAsync(developer, request); | ||||
|             return Ok(app); | ||||
|             var app = await customApps.CreateAppAsync(projectId, request); | ||||
|             if (app == null) | ||||
|                 return BadRequest("Failed to create app"); | ||||
|  | ||||
|             return CreatedAtAction( | ||||
|                 nameof(GetApp), | ||||
|                 new { pubName, projectId, appId = app.Id }, | ||||
|                 app | ||||
|             ); | ||||
|         } | ||||
|         catch (InvalidOperationException ex) | ||||
|         { | ||||
| @@ -68,23 +127,30 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [HttpPatch("{id:guid}")] | ||||
|     [HttpPatch("{appId:guid}")] | ||||
|     [Authorize] | ||||
|     public async Task<IActionResult> UpdateApp( | ||||
|         [FromRoute] string pubName, | ||||
|         [FromRoute] Guid id, | ||||
|         [FromRoute] Guid projectId, | ||||
|         [FromRoute] Guid appId, | ||||
|         [FromBody] CustomAppRequest request | ||||
|     ) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||
|             return Unauthorized(); | ||||
|  | ||||
|         var developer = await ds.GetDeveloperByName(pubName); | ||||
|         if (developer is null) return NotFound(); | ||||
|         if (developer is null) | ||||
|             return NotFound("Developer not found"); | ||||
|  | ||||
|         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) | ||||
|             return StatusCode(403, "You must be an editor of the developer to update a custom app"); | ||||
|  | ||||
|         var app = await customApps.GetAppAsync(id, developerId: developer.Id); | ||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||
|         if (project is null) | ||||
|             return NotFound("Project not found or you don't have access"); | ||||
|  | ||||
|         var app = await customApps.GetAppAsync(appId, projectId); | ||||
|         if (app == null) | ||||
|             return NotFound(); | ||||
|  | ||||
| @@ -99,28 +165,267 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [HttpDelete("{id:guid}")] | ||||
|     [HttpDelete("{appId:guid}")] | ||||
|     [Authorize] | ||||
|     public async Task<IActionResult> DeleteApp( | ||||
|         [FromRoute] string pubName, | ||||
|         [FromRoute] Guid id | ||||
|         [FromRoute] Guid projectId, | ||||
|         [FromRoute] Guid appId | ||||
|     ) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||
|             return Unauthorized(); | ||||
|  | ||||
|         var developer = await ds.GetDeveloperByName(pubName); | ||||
|         if (developer is null) return NotFound(); | ||||
|         if (developer is null) | ||||
|             return NotFound("Developer not found"); | ||||
|  | ||||
|         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) | ||||
|             return StatusCode(403, "You must be an editor of the developer to delete a custom app"); | ||||
|  | ||||
|         var app = await customApps.GetAppAsync(id, developerId: developer.Id); | ||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||
|         if (project is null) | ||||
|             return NotFound("Project not found or you don't have access"); | ||||
|  | ||||
|         var app = await customApps.GetAppAsync(appId, projectId); | ||||
|         if (app == null) | ||||
|             return NotFound(); | ||||
|  | ||||
|         var result = await customApps.DeleteAppAsync(id); | ||||
|         var result = await customApps.DeleteAppAsync(appId); | ||||
|         if (!result) | ||||
|             return NotFound(); | ||||
|  | ||||
|         return NoContent(); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("{appId:guid}/secrets")] | ||||
|     [Authorize] | ||||
|     public async Task<IActionResult> ListSecrets( | ||||
|         [FromRoute] string pubName, | ||||
|         [FromRoute] Guid projectId, | ||||
|         [FromRoute] Guid appId) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||
|             return Unauthorized(); | ||||
|  | ||||
|         var developer = await ds.GetDeveloperByName(pubName); | ||||
|         if (developer is null) | ||||
|             return NotFound("Developer not found"); | ||||
|  | ||||
|         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) | ||||
|             return StatusCode(403, "You must be an editor of the developer to view app secrets"); | ||||
|  | ||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||
|         if (project is null) | ||||
|             return NotFound("Project not found or you don't have access"); | ||||
|  | ||||
|         var app = await customApps.GetAppAsync(appId, projectId); | ||||
|         if (app == null) | ||||
|             return NotFound("App not found"); | ||||
|  | ||||
|         var secrets = await customApps.GetAppSecretsAsync(appId); | ||||
|         return Ok(secrets.Select(s => new SecretResponse( | ||||
|             s.Id.ToString(), | ||||
|             null, | ||||
|             s.Description, | ||||
|             s.ExpiredAt, | ||||
|             s.IsOidc, | ||||
|             s.CreatedAt, | ||||
|             s.UpdatedAt | ||||
|         ))); | ||||
|     } | ||||
|  | ||||
|     [HttpPost("{appId:guid}/secrets")] | ||||
|     [Authorize] | ||||
|     public async Task<IActionResult> CreateSecret( | ||||
|         [FromRoute] string pubName, | ||||
|         [FromRoute] Guid projectId, | ||||
|         [FromRoute] Guid appId, | ||||
|         [FromBody] CreateSecretRequest request) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||
|             return Unauthorized(); | ||||
|  | ||||
|         var developer = await ds.GetDeveloperByName(pubName); | ||||
|         if (developer is null) | ||||
|             return NotFound("Developer not found"); | ||||
|  | ||||
|         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) | ||||
|             return StatusCode(403, "You must be an editor of the developer to create app secrets"); | ||||
|  | ||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||
|         if (project is null) | ||||
|             return NotFound("Project not found or you don't have access"); | ||||
|  | ||||
|         var app = await customApps.GetAppAsync(appId, projectId); | ||||
|         if (app == null) | ||||
|             return NotFound("App not found"); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var secret = await customApps.CreateAppSecretAsync(new CustomAppSecret | ||||
|             { | ||||
|                 AppId = appId, | ||||
|                 Description = request.Description, | ||||
|                 ExpiredAt = request.ExpiresIn.HasValue | ||||
|                     ? NodaTime.SystemClock.Instance.GetCurrentInstant() | ||||
|                         .Plus(Duration.FromTimeSpan(request.ExpiresIn.Value)) | ||||
|                     : (NodaTime.Instant?)null, | ||||
|                 IsOidc = request.IsOidc | ||||
|             }); | ||||
|  | ||||
|             return CreatedAtAction( | ||||
|                 nameof(GetSecret), | ||||
|                 new { pubName, projectId, appId, secretId = secret.Id }, | ||||
|                 new SecretResponse( | ||||
|                     secret.Id.ToString(), | ||||
|                     secret.Secret, | ||||
|                     secret.Description, | ||||
|                     secret.ExpiredAt, | ||||
|                     secret.IsOidc, | ||||
|                     secret.CreatedAt, | ||||
|                     secret.UpdatedAt | ||||
|                 ) | ||||
|             ); | ||||
|         } | ||||
|         catch (InvalidOperationException ex) | ||||
|         { | ||||
|             return BadRequest(ex.Message); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [HttpGet("{appId:guid}/secrets/{secretId:guid}")] | ||||
|     [Authorize] | ||||
|     public async Task<IActionResult> GetSecret( | ||||
|         [FromRoute] string pubName, | ||||
|         [FromRoute] Guid projectId, | ||||
|         [FromRoute] Guid appId, | ||||
|         [FromRoute] Guid secretId) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||
|             return Unauthorized(); | ||||
|  | ||||
|         var developer = await ds.GetDeveloperByName(pubName); | ||||
|         if (developer is null) | ||||
|             return NotFound("Developer not found"); | ||||
|  | ||||
|         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) | ||||
|             return StatusCode(403, "You must be an editor of the developer to view app secrets"); | ||||
|  | ||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||
|         if (project is null) | ||||
|             return NotFound("Project not found or you don't have access"); | ||||
|  | ||||
|         var app = await customApps.GetAppAsync(appId, projectId); | ||||
|         if (app == null) | ||||
|             return NotFound("App not found"); | ||||
|  | ||||
|         var secret = await customApps.GetAppSecretAsync(secretId, appId); | ||||
|         if (secret == null) | ||||
|             return NotFound("Secret not found"); | ||||
|  | ||||
|         return Ok(new SecretResponse( | ||||
|             secret.Id.ToString(), | ||||
|             null, | ||||
|             secret.Description, | ||||
|             secret.ExpiredAt, | ||||
|             secret.IsOidc, | ||||
|             secret.CreatedAt, | ||||
|             secret.UpdatedAt | ||||
|         )); | ||||
|     } | ||||
|  | ||||
|     [HttpDelete("{appId:guid}/secrets/{secretId:guid}")] | ||||
|     [Authorize] | ||||
|     public async Task<IActionResult> DeleteSecret( | ||||
|         [FromRoute] string pubName, | ||||
|         [FromRoute] Guid projectId, | ||||
|         [FromRoute] Guid appId, | ||||
|         [FromRoute] Guid secretId) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||
|             return Unauthorized(); | ||||
|  | ||||
|         var developer = await ds.GetDeveloperByName(pubName); | ||||
|         if (developer is null) | ||||
|             return NotFound("Developer not found"); | ||||
|  | ||||
|         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) | ||||
|             return StatusCode(403, "You must be an editor of the developer to delete app secrets"); | ||||
|  | ||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||
|         if (project is null) | ||||
|             return NotFound("Project not found or you don't have access"); | ||||
|  | ||||
|         var app = await customApps.GetAppAsync(appId, projectId); | ||||
|         if (app == null) | ||||
|             return NotFound("App not found"); | ||||
|  | ||||
|         var secret = await customApps.GetAppSecretAsync(secretId, appId); | ||||
|         if (secret == null) | ||||
|             return NotFound("Secret not found"); | ||||
|  | ||||
|         var result = await customApps.DeleteAppSecretAsync(secretId, appId); | ||||
|         if (!result) | ||||
|             return NotFound("Failed to delete secret"); | ||||
|  | ||||
|         return NoContent(); | ||||
|     } | ||||
|  | ||||
|     [HttpPost("{appId:guid}/secrets/{secretId:guid}/rotate")] | ||||
|     [Authorize] | ||||
|     public async Task<IActionResult> RotateSecret( | ||||
|         [FromRoute] string pubName, | ||||
|         [FromRoute] Guid projectId, | ||||
|         [FromRoute] Guid appId, | ||||
|         [FromRoute] Guid secretId, | ||||
|         [FromBody] CreateSecretRequest? request = null) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||
|             return Unauthorized(); | ||||
|  | ||||
|         var developer = await ds.GetDeveloperByName(pubName); | ||||
|         if (developer is null) | ||||
|             return NotFound("Developer not found"); | ||||
|  | ||||
|         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) | ||||
|             return StatusCode(403, "You must be an editor of the developer to rotate app secrets"); | ||||
|  | ||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||
|         if (project is null) | ||||
|             return NotFound("Project not found or you don't have access"); | ||||
|  | ||||
|         var app = await customApps.GetAppAsync(appId, projectId); | ||||
|         if (app == null) | ||||
|             return NotFound("App not found"); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var secret = await customApps.RotateAppSecretAsync(new CustomAppSecret | ||||
|             { | ||||
|                 Id = secretId, | ||||
|                 AppId = appId, | ||||
|                 Description = request?.Description, | ||||
|                 ExpiredAt = request?.ExpiresIn.HasValue == true | ||||
|                     ? NodaTime.SystemClock.Instance.GetCurrentInstant() | ||||
|                         .Plus(Duration.FromTimeSpan(request.ExpiresIn.Value)) | ||||
|                     : (NodaTime.Instant?)null, | ||||
|                 IsOidc = request?.IsOidc ?? false | ||||
|             }); | ||||
|  | ||||
|             return Ok(new SecretResponse( | ||||
|                 secret.Id.ToString(), | ||||
|                 secret.Secret, | ||||
|                 secret.Description, | ||||
|                 secret.ExpiredAt, | ||||
|                 secret.IsOidc, | ||||
|                 secret.CreatedAt, | ||||
|                 secret.UpdatedAt | ||||
|             )); | ||||
|         } | ||||
|         catch (InvalidOperationException ex) | ||||
|         { | ||||
|             return BadRequest(ex.Message); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,6 +1,9 @@ | ||||
| using DysonNetwork.Develop.Project; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
|  | ||||
| namespace DysonNetwork.Develop.Identity; | ||||
|  | ||||
| @@ -11,10 +14,17 @@ public class CustomAppService( | ||||
| ) | ||||
| { | ||||
|     public async Task<CustomApp?> CreateAppAsync( | ||||
|         Developer pub, | ||||
|         Guid projectId, | ||||
|         CustomAppController.CustomAppRequest request | ||||
|     ) | ||||
|     { | ||||
|         var project = await db.DevProjects | ||||
|             .Include(p => p.Developer) | ||||
|             .FirstOrDefaultAsync(p => p.Id == projectId); | ||||
|              | ||||
|         if (project == null) | ||||
|             return null; | ||||
|              | ||||
|         var app = new CustomApp | ||||
|         { | ||||
|             Slug = request.Slug!, | ||||
| @@ -23,7 +33,7 @@ public class CustomAppService( | ||||
|             Status = request.Status ?? CustomAppStatus.Developing, | ||||
|             Links = request.Links, | ||||
|             OauthConfig = request.OauthConfig, | ||||
|             DeveloperId = pub.Id | ||||
|             ProjectId = projectId | ||||
|         }; | ||||
|  | ||||
|         if (request.PictureId is not null) | ||||
| @@ -74,17 +84,104 @@ public class CustomAppService( | ||||
|         return app; | ||||
|     } | ||||
|  | ||||
|     public async Task<CustomApp?> GetAppAsync(Guid id, Guid? developerId = null) | ||||
|     public async Task<CustomApp?> GetAppAsync(Guid id, Guid? projectId = null) | ||||
|     { | ||||
|         var query = db.CustomApps.Where(a => a.Id == id).AsQueryable(); | ||||
|         if (developerId.HasValue) | ||||
|             query = query.Where(a => a.DeveloperId == developerId.Value); | ||||
|         return await query.FirstOrDefaultAsync(); | ||||
|         var query = db.CustomApps.AsQueryable(); | ||||
|          | ||||
|         if (projectId.HasValue) | ||||
|         { | ||||
|             query = query.Where(a => a.ProjectId == projectId.Value); | ||||
|         } | ||||
|  | ||||
|         return await query.FirstOrDefaultAsync(a => a.Id == id); | ||||
|     } | ||||
|  | ||||
|     public async Task<List<CustomApp>> GetAppsByPublisherAsync(Guid publisherId) | ||||
|     public async Task<List<CustomAppSecret>> GetAppSecretsAsync(Guid appId) | ||||
|     { | ||||
|         return await db.CustomApps.Where(a => a.DeveloperId == publisherId).ToListAsync(); | ||||
|         return await db.CustomAppSecrets | ||||
|             .Where(s => s.AppId == appId) | ||||
|             .OrderByDescending(s => s.CreatedAt) | ||||
|             .ToListAsync(); | ||||
|     } | ||||
|  | ||||
|     public async Task<CustomAppSecret?> GetAppSecretAsync(Guid secretId, Guid appId) | ||||
|     { | ||||
|         return await db.CustomAppSecrets | ||||
|             .FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId); | ||||
|     } | ||||
|  | ||||
|     public async Task<CustomAppSecret> CreateAppSecretAsync(CustomAppSecret secret) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(secret.Secret)) | ||||
|         { | ||||
|             // Generate a new random secret if not provided | ||||
|             secret.Secret = GenerateRandomSecret(); | ||||
|         } | ||||
|  | ||||
|         secret.Id = Guid.NewGuid(); | ||||
|         secret.CreatedAt = NodaTime.SystemClock.Instance.GetCurrentInstant(); | ||||
|         secret.UpdatedAt = secret.CreatedAt; | ||||
|  | ||||
|         db.CustomAppSecrets.Add(secret); | ||||
|         await db.SaveChangesAsync(); | ||||
|  | ||||
|         return secret; | ||||
|     } | ||||
|  | ||||
|     public async Task<bool> DeleteAppSecretAsync(Guid secretId, Guid appId) | ||||
|     { | ||||
|         var secret = await db.CustomAppSecrets | ||||
|             .FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId); | ||||
|  | ||||
|         if (secret == null) | ||||
|             return false; | ||||
|  | ||||
|         db.CustomAppSecrets.Remove(secret); | ||||
|         await db.SaveChangesAsync(); | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     public async Task<CustomAppSecret> RotateAppSecretAsync(CustomAppSecret secretUpdate) | ||||
|     { | ||||
|         var existingSecret = await db.CustomAppSecrets | ||||
|             .FirstOrDefaultAsync(s => s.Id == secretUpdate.Id && s.AppId == secretUpdate.AppId); | ||||
|  | ||||
|         if (existingSecret == null) | ||||
|             throw new InvalidOperationException("Secret not found"); | ||||
|  | ||||
|         // Update the existing secret with new values | ||||
|         existingSecret.Secret = GenerateRandomSecret(); | ||||
|         existingSecret.Description = secretUpdate.Description ?? existingSecret.Description; | ||||
|         existingSecret.ExpiredAt = secretUpdate.ExpiredAt ?? existingSecret.ExpiredAt; | ||||
|         existingSecret.IsOidc = secretUpdate.IsOidc; | ||||
|         existingSecret.UpdatedAt = NodaTime.SystemClock.Instance.GetCurrentInstant(); | ||||
|  | ||||
|         await db.SaveChangesAsync(); | ||||
|         return existingSecret; | ||||
|     } | ||||
|  | ||||
|     private static string GenerateRandomSecret(int length = 64) | ||||
|     { | ||||
|         const string valid = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-._~+"; | ||||
|         var res = new StringBuilder(); | ||||
|         using (var rng = RandomNumberGenerator.Create()) | ||||
|         { | ||||
|             var uintBuffer = new byte[sizeof(uint)]; | ||||
|             while (length-- > 0) | ||||
|             { | ||||
|                 rng.GetBytes(uintBuffer); | ||||
|                 var num = BitConverter.ToUInt32(uintBuffer, 0); | ||||
|                 res.Append(valid[(int)(num % (uint)valid.Length)]); | ||||
|             } | ||||
|         } | ||||
|         return res.ToString(); | ||||
|     } | ||||
|  | ||||
|     public async Task<List<CustomApp>> GetAppsByProjectAsync(Guid projectId) | ||||
|     { | ||||
|         return await db.CustomApps | ||||
|             .Where(a => a.ProjectId == projectId) | ||||
|             .ToListAsync(); | ||||
|     } | ||||
|  | ||||
|     public async Task<CustomApp?> UpdateAppAsync(CustomApp app, CustomAppController.CustomAppRequest request) | ||||
|   | ||||
| @@ -1,4 +1,6 @@ | ||||
| using System.ComponentModel.DataAnnotations.Schema; | ||||
| using System.Text.Json.Serialization; | ||||
| using DysonNetwork.Develop.Project; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using VerificationMark = DysonNetwork.Shared.Data.VerificationMark; | ||||
| @@ -10,6 +12,8 @@ public class Developer | ||||
|     public Guid Id { get; set; } = Guid.NewGuid(); | ||||
|     public Guid PublisherId { get; set; } | ||||
|      | ||||
|     [JsonIgnore] public List<DevProject> Projects { get; set; } = []; | ||||
|      | ||||
|     [NotMapped] public PublisherInfo? Publisher { get; set; } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -33,7 +33,8 @@ public class DeveloperController( | ||||
|  | ||||
|         // Get custom apps count | ||||
|         var customAppsCount = await db.CustomApps | ||||
|             .Where(a => a.DeveloperId == developer.Id) | ||||
|             .Include(a => a.Project) | ||||
|             .Where(a => a.Project.DeveloperId == developer.Id) | ||||
|             .CountAsync(); | ||||
|  | ||||
|         var stats = new DeveloperStats | ||||
|   | ||||
| @@ -4,7 +4,10 @@ using Microsoft.EntityFrameworkCore; | ||||
|  | ||||
| namespace DysonNetwork.Develop.Identity; | ||||
|  | ||||
| public class DeveloperService(AppDatabase db, PublisherService.PublisherServiceClient ps, ILogger<DeveloperService> logger) | ||||
| public class DeveloperService( | ||||
|     AppDatabase db, | ||||
|     PublisherService.PublisherServiceClient ps, | ||||
|     ILogger<DeveloperService> logger) | ||||
| { | ||||
|     public async Task<Developer> LoadDeveloperPublisher(Developer developer) | ||||
|     { | ||||
| @@ -47,6 +50,11 @@ public class DeveloperService(AppDatabase db, PublisherService.PublisherServiceC | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public async Task<Developer?> GetDeveloperById(Guid id) | ||||
|     { | ||||
|         return await db.Developers.FirstOrDefaultAsync(d => d.Id == id); | ||||
|     } | ||||
|  | ||||
|     public async Task<bool> IsMemberWithRole(Guid pubId, Guid accountId, PublisherMemberRole role) | ||||
|     { | ||||
|         try | ||||
|   | ||||
							
								
								
									
										270
									
								
								DysonNetwork.Develop/Migrations/20250818124844_AddDevProject.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										270
									
								
								DysonNetwork.Develop/Migrations/20250818124844_AddDevProject.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,270 @@ | ||||
| // <auto-generated /> | ||||
| using System; | ||||
| using DysonNetwork.Develop; | ||||
| using DysonNetwork.Develop.Identity; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | ||||
| using Microsoft.EntityFrameworkCore.Migrations; | ||||
| using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||
| using NodaTime; | ||||
| using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||
|  | ||||
| #nullable disable | ||||
|  | ||||
| namespace DysonNetwork.Develop.Migrations | ||||
| { | ||||
|     [DbContext(typeof(AppDatabase))] | ||||
|     [Migration("20250818124844_AddDevProject")] | ||||
|     partial class AddDevProject | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         protected override void BuildTargetModel(ModelBuilder modelBuilder) | ||||
|         { | ||||
| #pragma warning disable 612, 618 | ||||
|             modelBuilder | ||||
|                 .HasAnnotation("ProductVersion", "9.0.7") | ||||
|                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||
|  | ||||
|             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => | ||||
|                 { | ||||
|                     b.Property<Guid>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<CloudFileReferenceObject>("Background") | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("background"); | ||||
|  | ||||
|                     b.Property<Instant>("CreatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("created_at"); | ||||
|  | ||||
|                     b.Property<Instant?>("DeletedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("deleted_at"); | ||||
|  | ||||
|                     b.Property<string>("Description") | ||||
|                         .HasMaxLength(4096) | ||||
|                         .HasColumnType("character varying(4096)") | ||||
|                         .HasColumnName("description"); | ||||
|  | ||||
|                     b.Property<CustomAppLinks>("Links") | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("links"); | ||||
|  | ||||
|                     b.Property<string>("Name") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("name"); | ||||
|  | ||||
|                     b.Property<CustomAppOauthConfig>("OauthConfig") | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("oauth_config"); | ||||
|  | ||||
|                     b.Property<CloudFileReferenceObject>("Picture") | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("picture"); | ||||
|  | ||||
|                     b.Property<Guid>("ProjectId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("project_id"); | ||||
|  | ||||
|                     b.Property<string>("Slug") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("slug"); | ||||
|  | ||||
|                     b.Property<int>("Status") | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("status"); | ||||
|  | ||||
|                     b.Property<Instant>("UpdatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("updated_at"); | ||||
|  | ||||
|                     b.Property<VerificationMark>("Verification") | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("verification"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_custom_apps"); | ||||
|  | ||||
|                     b.HasIndex("ProjectId") | ||||
|                         .HasDatabaseName("ix_custom_apps_project_id"); | ||||
|  | ||||
|                     b.ToTable("custom_apps", (string)null); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b => | ||||
|                 { | ||||
|                     b.Property<Guid>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<Guid>("AppId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("app_id"); | ||||
|  | ||||
|                     b.Property<Instant>("CreatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("created_at"); | ||||
|  | ||||
|                     b.Property<Instant?>("DeletedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("deleted_at"); | ||||
|  | ||||
|                     b.Property<string>("Description") | ||||
|                         .HasMaxLength(4096) | ||||
|                         .HasColumnType("character varying(4096)") | ||||
|                         .HasColumnName("description"); | ||||
|  | ||||
|                     b.Property<Instant?>("ExpiredAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("expired_at"); | ||||
|  | ||||
|                     b.Property<bool>("IsOidc") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("is_oidc"); | ||||
|  | ||||
|                     b.Property<string>("Secret") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("secret"); | ||||
|  | ||||
|                     b.Property<Instant>("UpdatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("updated_at"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_custom_app_secrets"); | ||||
|  | ||||
|                     b.HasIndex("AppId") | ||||
|                         .HasDatabaseName("ix_custom_app_secrets_app_id"); | ||||
|  | ||||
|                     b.ToTable("custom_app_secrets", (string)null); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b => | ||||
|                 { | ||||
|                     b.Property<Guid>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<Guid>("PublisherId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("publisher_id"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_developers"); | ||||
|  | ||||
|                     b.ToTable("developers", (string)null); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b => | ||||
|                 { | ||||
|                     b.Property<Guid>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<Instant>("CreatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("created_at"); | ||||
|  | ||||
|                     b.Property<Instant?>("DeletedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("deleted_at"); | ||||
|  | ||||
|                     b.Property<string>("Description") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(4096) | ||||
|                         .HasColumnType("character varying(4096)") | ||||
|                         .HasColumnName("description"); | ||||
|  | ||||
|                     b.Property<Guid>("DeveloperId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("developer_id"); | ||||
|  | ||||
|                     b.Property<string>("Name") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("name"); | ||||
|  | ||||
|                     b.Property<string>("Slug") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("slug"); | ||||
|  | ||||
|                     b.Property<Instant>("UpdatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("updated_at"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_dev_projects"); | ||||
|  | ||||
|                     b.HasIndex("DeveloperId") | ||||
|                         .HasDatabaseName("ix_dev_projects_developer_id"); | ||||
|  | ||||
|                     b.ToTable("dev_projects", (string)null); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => | ||||
|                 { | ||||
|                     b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("ProjectId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_custom_apps_dev_projects_project_id"); | ||||
|  | ||||
|                     b.Navigation("Project"); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b => | ||||
|                 { | ||||
|                     b.HasOne("DysonNetwork.Develop.Identity.CustomApp", "App") | ||||
|                         .WithMany("Secrets") | ||||
|                         .HasForeignKey("AppId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_custom_app_secrets_custom_apps_app_id"); | ||||
|  | ||||
|                     b.Navigation("App"); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b => | ||||
|                 { | ||||
|                     b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer") | ||||
|                         .WithMany("Projects") | ||||
|                         .HasForeignKey("DeveloperId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_dev_projects_developers_developer_id"); | ||||
|  | ||||
|                     b.Navigation("Developer"); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => | ||||
|                 { | ||||
|                     b.Navigation("Secrets"); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b => | ||||
|                 { | ||||
|                     b.Navigation("Projects"); | ||||
|                 }); | ||||
| #pragma warning restore 612, 618 | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,96 @@ | ||||
| using System; | ||||
| using Microsoft.EntityFrameworkCore.Migrations; | ||||
| using NodaTime; | ||||
|  | ||||
| #nullable disable | ||||
|  | ||||
| namespace DysonNetwork.Develop.Migrations | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     public partial class AddDevProject : Migration | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Up(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.DropForeignKey( | ||||
|                 name: "fk_custom_apps_developers_developer_id", | ||||
|                 table: "custom_apps"); | ||||
|  | ||||
|             migrationBuilder.RenameColumn( | ||||
|                 name: "developer_id", | ||||
|                 table: "custom_apps", | ||||
|                 newName: "project_id"); | ||||
|  | ||||
|             migrationBuilder.RenameIndex( | ||||
|                 name: "ix_custom_apps_developer_id", | ||||
|                 table: "custom_apps", | ||||
|                 newName: "ix_custom_apps_project_id"); | ||||
|  | ||||
|             migrationBuilder.CreateTable( | ||||
|                 name: "dev_projects", | ||||
|                 columns: table => new | ||||
|                 { | ||||
|                     id = table.Column<Guid>(type: "uuid", nullable: false), | ||||
|                     slug = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false), | ||||
|                     name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false), | ||||
|                     description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false), | ||||
|                     developer_id = table.Column<Guid>(type: "uuid", nullable: false), | ||||
|                     created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), | ||||
|                     updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), | ||||
|                     deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true) | ||||
|                 }, | ||||
|                 constraints: table => | ||||
|                 { | ||||
|                     table.PrimaryKey("pk_dev_projects", x => x.id); | ||||
|                     table.ForeignKey( | ||||
|                         name: "fk_dev_projects_developers_developer_id", | ||||
|                         column: x => x.developer_id, | ||||
|                         principalTable: "developers", | ||||
|                         principalColumn: "id", | ||||
|                         onDelete: ReferentialAction.Cascade); | ||||
|                 }); | ||||
|  | ||||
|             migrationBuilder.CreateIndex( | ||||
|                 name: "ix_dev_projects_developer_id", | ||||
|                 table: "dev_projects", | ||||
|                 column: "developer_id"); | ||||
|  | ||||
|             migrationBuilder.AddForeignKey( | ||||
|                 name: "fk_custom_apps_dev_projects_project_id", | ||||
|                 table: "custom_apps", | ||||
|                 column: "project_id", | ||||
|                 principalTable: "dev_projects", | ||||
|                 principalColumn: "id", | ||||
|                 onDelete: ReferentialAction.Cascade); | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Down(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.DropForeignKey( | ||||
|                 name: "fk_custom_apps_dev_projects_project_id", | ||||
|                 table: "custom_apps"); | ||||
|  | ||||
|             migrationBuilder.DropTable( | ||||
|                 name: "dev_projects"); | ||||
|  | ||||
|             migrationBuilder.RenameColumn( | ||||
|                 name: "project_id", | ||||
|                 table: "custom_apps", | ||||
|                 newName: "developer_id"); | ||||
|  | ||||
|             migrationBuilder.RenameIndex( | ||||
|                 name: "ix_custom_apps_project_id", | ||||
|                 table: "custom_apps", | ||||
|                 newName: "ix_custom_apps_developer_id"); | ||||
|  | ||||
|             migrationBuilder.AddForeignKey( | ||||
|                 name: "fk_custom_apps_developers_developer_id", | ||||
|                 table: "custom_apps", | ||||
|                 column: "developer_id", | ||||
|                 principalTable: "developers", | ||||
|                 principalColumn: "id", | ||||
|                 onDelete: ReferentialAction.Cascade); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										324
									
								
								DysonNetwork.Develop/Migrations/20250819163227_AddBotAccount.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										324
									
								
								DysonNetwork.Develop/Migrations/20250819163227_AddBotAccount.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,324 @@ | ||||
| // <auto-generated /> | ||||
| using System; | ||||
| using DysonNetwork.Develop; | ||||
| using DysonNetwork.Develop.Identity; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | ||||
| using Microsoft.EntityFrameworkCore.Migrations; | ||||
| using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||
| using NodaTime; | ||||
| using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||
|  | ||||
| #nullable disable | ||||
|  | ||||
| namespace DysonNetwork.Develop.Migrations | ||||
| { | ||||
|     [DbContext(typeof(AppDatabase))] | ||||
|     [Migration("20250819163227_AddBotAccount")] | ||||
|     partial class AddBotAccount | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         protected override void BuildTargetModel(ModelBuilder modelBuilder) | ||||
|         { | ||||
| #pragma warning disable 612, 618 | ||||
|             modelBuilder | ||||
|                 .HasAnnotation("ProductVersion", "9.0.7") | ||||
|                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||
|  | ||||
|             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b => | ||||
|                 { | ||||
|                     b.Property<Guid>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<Instant>("CreatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("created_at"); | ||||
|  | ||||
|                     b.Property<Instant?>("DeletedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("deleted_at"); | ||||
|  | ||||
|                     b.Property<bool>("IsActive") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("is_active"); | ||||
|  | ||||
|                     b.Property<Guid>("ProjectId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("project_id"); | ||||
|  | ||||
|                     b.Property<string>("Slug") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("slug"); | ||||
|  | ||||
|                     b.Property<Instant>("UpdatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("updated_at"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_bot_accounts"); | ||||
|  | ||||
|                     b.HasIndex("ProjectId") | ||||
|                         .HasDatabaseName("ix_bot_accounts_project_id"); | ||||
|  | ||||
|                     b.ToTable("bot_accounts", (string)null); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => | ||||
|                 { | ||||
|                     b.Property<Guid>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<CloudFileReferenceObject>("Background") | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("background"); | ||||
|  | ||||
|                     b.Property<Instant>("CreatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("created_at"); | ||||
|  | ||||
|                     b.Property<Instant?>("DeletedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("deleted_at"); | ||||
|  | ||||
|                     b.Property<string>("Description") | ||||
|                         .HasMaxLength(4096) | ||||
|                         .HasColumnType("character varying(4096)") | ||||
|                         .HasColumnName("description"); | ||||
|  | ||||
|                     b.Property<CustomAppLinks>("Links") | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("links"); | ||||
|  | ||||
|                     b.Property<string>("Name") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("name"); | ||||
|  | ||||
|                     b.Property<CustomAppOauthConfig>("OauthConfig") | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("oauth_config"); | ||||
|  | ||||
|                     b.Property<CloudFileReferenceObject>("Picture") | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("picture"); | ||||
|  | ||||
|                     b.Property<Guid>("ProjectId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("project_id"); | ||||
|  | ||||
|                     b.Property<string>("Slug") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("slug"); | ||||
|  | ||||
|                     b.Property<int>("Status") | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("status"); | ||||
|  | ||||
|                     b.Property<Instant>("UpdatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("updated_at"); | ||||
|  | ||||
|                     b.Property<VerificationMark>("Verification") | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("verification"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_custom_apps"); | ||||
|  | ||||
|                     b.HasIndex("ProjectId") | ||||
|                         .HasDatabaseName("ix_custom_apps_project_id"); | ||||
|  | ||||
|                     b.ToTable("custom_apps", (string)null); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b => | ||||
|                 { | ||||
|                     b.Property<Guid>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<Guid>("AppId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("app_id"); | ||||
|  | ||||
|                     b.Property<Instant>("CreatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("created_at"); | ||||
|  | ||||
|                     b.Property<Instant?>("DeletedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("deleted_at"); | ||||
|  | ||||
|                     b.Property<string>("Description") | ||||
|                         .HasMaxLength(4096) | ||||
|                         .HasColumnType("character varying(4096)") | ||||
|                         .HasColumnName("description"); | ||||
|  | ||||
|                     b.Property<Instant?>("ExpiredAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("expired_at"); | ||||
|  | ||||
|                     b.Property<bool>("IsOidc") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("is_oidc"); | ||||
|  | ||||
|                     b.Property<string>("Secret") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("secret"); | ||||
|  | ||||
|                     b.Property<Instant>("UpdatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("updated_at"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_custom_app_secrets"); | ||||
|  | ||||
|                     b.HasIndex("AppId") | ||||
|                         .HasDatabaseName("ix_custom_app_secrets_app_id"); | ||||
|  | ||||
|                     b.ToTable("custom_app_secrets", (string)null); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b => | ||||
|                 { | ||||
|                     b.Property<Guid>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<Guid>("PublisherId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("publisher_id"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_developers"); | ||||
|  | ||||
|                     b.ToTable("developers", (string)null); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b => | ||||
|                 { | ||||
|                     b.Property<Guid>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<Instant>("CreatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("created_at"); | ||||
|  | ||||
|                     b.Property<Instant?>("DeletedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("deleted_at"); | ||||
|  | ||||
|                     b.Property<string>("Description") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(4096) | ||||
|                         .HasColumnType("character varying(4096)") | ||||
|                         .HasColumnName("description"); | ||||
|  | ||||
|                     b.Property<Guid>("DeveloperId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("developer_id"); | ||||
|  | ||||
|                     b.Property<string>("Name") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("name"); | ||||
|  | ||||
|                     b.Property<string>("Slug") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("slug"); | ||||
|  | ||||
|                     b.Property<Instant>("UpdatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("updated_at"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_dev_projects"); | ||||
|  | ||||
|                     b.HasIndex("DeveloperId") | ||||
|                         .HasDatabaseName("ix_dev_projects_developer_id"); | ||||
|  | ||||
|                     b.ToTable("dev_projects", (string)null); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b => | ||||
|                 { | ||||
|                     b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("ProjectId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_bot_accounts_dev_projects_project_id"); | ||||
|  | ||||
|                     b.Navigation("Project"); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => | ||||
|                 { | ||||
|                     b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("ProjectId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_custom_apps_dev_projects_project_id"); | ||||
|  | ||||
|                     b.Navigation("Project"); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b => | ||||
|                 { | ||||
|                     b.HasOne("DysonNetwork.Develop.Identity.CustomApp", "App") | ||||
|                         .WithMany("Secrets") | ||||
|                         .HasForeignKey("AppId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_custom_app_secrets_custom_apps_app_id"); | ||||
|  | ||||
|                     b.Navigation("App"); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b => | ||||
|                 { | ||||
|                     b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer") | ||||
|                         .WithMany("Projects") | ||||
|                         .HasForeignKey("DeveloperId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_dev_projects_developers_developer_id"); | ||||
|  | ||||
|                     b.Navigation("Developer"); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => | ||||
|                 { | ||||
|                     b.Navigation("Secrets"); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b => | ||||
|                 { | ||||
|                     b.Navigation("Projects"); | ||||
|                 }); | ||||
| #pragma warning restore 612, 618 | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,51 @@ | ||||
| using System; | ||||
| using Microsoft.EntityFrameworkCore.Migrations; | ||||
| using NodaTime; | ||||
|  | ||||
| #nullable disable | ||||
|  | ||||
| namespace DysonNetwork.Develop.Migrations | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     public partial class AddBotAccount : Migration | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Up(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.CreateTable( | ||||
|                 name: "bot_accounts", | ||||
|                 columns: table => new | ||||
|                 { | ||||
|                     id = table.Column<Guid>(type: "uuid", nullable: false), | ||||
|                     slug = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false), | ||||
|                     is_active = table.Column<bool>(type: "boolean", nullable: false), | ||||
|                     project_id = table.Column<Guid>(type: "uuid", nullable: false), | ||||
|                     created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), | ||||
|                     updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), | ||||
|                     deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true) | ||||
|                 }, | ||||
|                 constraints: table => | ||||
|                 { | ||||
|                     table.PrimaryKey("pk_bot_accounts", x => x.id); | ||||
|                     table.ForeignKey( | ||||
|                         name: "fk_bot_accounts_dev_projects_project_id", | ||||
|                         column: x => x.project_id, | ||||
|                         principalTable: "dev_projects", | ||||
|                         principalColumn: "id", | ||||
|                         onDelete: ReferentialAction.Cascade); | ||||
|                 }); | ||||
|  | ||||
|             migrationBuilder.CreateIndex( | ||||
|                 name: "ix_bot_accounts_project_id", | ||||
|                 table: "bot_accounts", | ||||
|                 column: "project_id"); | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Down(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.DropTable( | ||||
|                 name: "bot_accounts"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -25,6 +25,48 @@ namespace DysonNetwork.Develop.Migrations | ||||
|  | ||||
|             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b => | ||||
|                 { | ||||
|                     b.Property<Guid>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<Instant>("CreatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("created_at"); | ||||
|  | ||||
|                     b.Property<Instant?>("DeletedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("deleted_at"); | ||||
|  | ||||
|                     b.Property<bool>("IsActive") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("is_active"); | ||||
|  | ||||
|                     b.Property<Guid>("ProjectId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("project_id"); | ||||
|  | ||||
|                     b.Property<string>("Slug") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("slug"); | ||||
|  | ||||
|                     b.Property<Instant>("UpdatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("updated_at"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_bot_accounts"); | ||||
|  | ||||
|                     b.HasIndex("ProjectId") | ||||
|                         .HasDatabaseName("ix_bot_accounts_project_id"); | ||||
|  | ||||
|                     b.ToTable("bot_accounts", (string)null); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => | ||||
|                 { | ||||
|                     b.Property<Guid>("Id") | ||||
| @@ -49,10 +91,6 @@ namespace DysonNetwork.Develop.Migrations | ||||
|                         .HasColumnType("character varying(4096)") | ||||
|                         .HasColumnName("description"); | ||||
|  | ||||
|                     b.Property<Guid>("DeveloperId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("developer_id"); | ||||
|  | ||||
|                     b.Property<CustomAppLinks>("Links") | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("links"); | ||||
| @@ -71,6 +109,10 @@ namespace DysonNetwork.Develop.Migrations | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("picture"); | ||||
|  | ||||
|                     b.Property<Guid>("ProjectId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("project_id"); | ||||
|  | ||||
|                     b.Property<string>("Slug") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
| @@ -92,8 +134,8 @@ namespace DysonNetwork.Develop.Migrations | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_custom_apps"); | ||||
|  | ||||
|                     b.HasIndex("DeveloperId") | ||||
|                         .HasDatabaseName("ix_custom_apps_developer_id"); | ||||
|                     b.HasIndex("ProjectId") | ||||
|                         .HasDatabaseName("ix_custom_apps_project_id"); | ||||
|  | ||||
|                     b.ToTable("custom_apps", (string)null); | ||||
|                 }); | ||||
| @@ -166,16 +208,78 @@ namespace DysonNetwork.Develop.Migrations | ||||
|                     b.ToTable("developers", (string)null); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b => | ||||
|                 { | ||||
|                     b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer") | ||||
|                     b.Property<Guid>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<Instant>("CreatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("created_at"); | ||||
|  | ||||
|                     b.Property<Instant?>("DeletedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("deleted_at"); | ||||
|  | ||||
|                     b.Property<string>("Description") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(4096) | ||||
|                         .HasColumnType("character varying(4096)") | ||||
|                         .HasColumnName("description"); | ||||
|  | ||||
|                     b.Property<Guid>("DeveloperId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("developer_id"); | ||||
|  | ||||
|                     b.Property<string>("Name") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("name"); | ||||
|  | ||||
|                     b.Property<string>("Slug") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("slug"); | ||||
|  | ||||
|                     b.Property<Instant>("UpdatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("updated_at"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_dev_projects"); | ||||
|  | ||||
|                     b.HasIndex("DeveloperId") | ||||
|                         .HasDatabaseName("ix_dev_projects_developer_id"); | ||||
|  | ||||
|                     b.ToTable("dev_projects", (string)null); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b => | ||||
|                 { | ||||
|                     b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("DeveloperId") | ||||
|                         .HasForeignKey("ProjectId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_custom_apps_developers_developer_id"); | ||||
|                         .HasConstraintName("fk_bot_accounts_dev_projects_project_id"); | ||||
|  | ||||
|                     b.Navigation("Developer"); | ||||
|                     b.Navigation("Project"); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => | ||||
|                 { | ||||
|                     b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("ProjectId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_custom_apps_dev_projects_project_id"); | ||||
|  | ||||
|                     b.Navigation("Project"); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b => | ||||
| @@ -190,10 +294,27 @@ namespace DysonNetwork.Develop.Migrations | ||||
|                     b.Navigation("App"); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b => | ||||
|                 { | ||||
|                     b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer") | ||||
|                         .WithMany("Projects") | ||||
|                         .HasForeignKey("DeveloperId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_dev_projects_developers_developer_id"); | ||||
|  | ||||
|                     b.Navigation("Developer"); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => | ||||
|                 { | ||||
|                     b.Navigation("Secrets"); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b => | ||||
|                 { | ||||
|                     b.Navigation("Projects"); | ||||
|                 }); | ||||
| #pragma warning restore 612, 618 | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -1,15 +1,16 @@ | ||||
| using DysonNetwork.Develop; | ||||
| using DysonNetwork.Shared.Auth; | ||||
| using DysonNetwork.Shared.Http; | ||||
| using DysonNetwork.Shared.Registry; | ||||
| using DysonNetwork.Develop.Startup; | ||||
| using DysonNetwork.Shared.Registry; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
|  | ||||
| var builder = WebApplication.CreateBuilder(args); | ||||
|  | ||||
| builder.AddServiceDefaults(); | ||||
|  | ||||
| builder.ConfigureAppKestrel(builder.Configuration); | ||||
|  | ||||
| builder.Services.AddRegistryService(builder.Configuration); | ||||
| builder.Services.AddAppServices(builder.Configuration); | ||||
| builder.Services.AddAppAuthentication(); | ||||
| builder.Services.AddAppSwagger(); | ||||
| @@ -20,6 +21,8 @@ builder.Services.AddDriveService(); | ||||
|  | ||||
| var app = builder.Build(); | ||||
|  | ||||
| app.MapDefaultEndpoints(); | ||||
|  | ||||
| using (var scope = app.Services.CreateScope()) | ||||
| { | ||||
|     var db = scope.ServiceProvider.GetRequiredService<AppDatabase>(); | ||||
|   | ||||
							
								
								
									
										16
									
								
								DysonNetwork.Develop/Project/DevProject.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								DysonNetwork.Develop/Project/DevProject.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using DysonNetwork.Develop.Identity; | ||||
| using DysonNetwork.Shared.Data; | ||||
|  | ||||
| namespace DysonNetwork.Develop.Project; | ||||
|  | ||||
| public class DevProject : ModelBase | ||||
| { | ||||
|     public Guid Id { get; set; } = Guid.NewGuid(); | ||||
|     [MaxLength(1024)] public string Slug { get; set; } = string.Empty; | ||||
|     [MaxLength(1024)] public string Name { get; set; } = string.Empty; | ||||
|     [MaxLength(4096)] public string Description { get; set; } = string.Empty; | ||||
|      | ||||
|     public Developer Developer { get; set; } = null!; | ||||
|     public Guid DeveloperId { get; set; } | ||||
| } | ||||
							
								
								
									
										107
									
								
								DysonNetwork.Develop/Project/DevProjectController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								DysonNetwork.Develop/Project/DevProjectController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,107 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using DysonNetwork.Develop.Identity; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using DysonNetwork.Shared.Proto; | ||||
|  | ||||
| namespace DysonNetwork.Develop.Project; | ||||
|  | ||||
| [ApiController] | ||||
| [Route("/api/developers/{pubName}/projects")] | ||||
| public class DevProjectController(DevProjectService projectService, DeveloperService developerService) : ControllerBase | ||||
| { | ||||
|     public record DevProjectRequest( | ||||
|         [MaxLength(1024)] string? Slug, | ||||
|         [MaxLength(1024)] string? Name, | ||||
|         [MaxLength(4096)] string? Description | ||||
|     ); | ||||
|  | ||||
|     [HttpGet] | ||||
|     public async Task<IActionResult> ListProjects([FromRoute] string pubName) | ||||
|     { | ||||
|         var developer = await developerService.GetDeveloperByName(pubName); | ||||
|         if (developer is null) return NotFound(); | ||||
|          | ||||
|         var projects = await projectService.GetProjectsByDeveloperAsync(developer.Id); | ||||
|         return Ok(projects); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("{id:guid}")] | ||||
|     public async Task<IActionResult> GetProject([FromRoute] string pubName, Guid id) | ||||
|     { | ||||
|         var developer = await developerService.GetDeveloperByName(pubName); | ||||
|         if (developer is null) return NotFound(); | ||||
|  | ||||
|         var project = await projectService.GetProjectAsync(id, developer.Id); | ||||
|         if (project is null) return NotFound(); | ||||
|  | ||||
|         return Ok(project); | ||||
|     } | ||||
|  | ||||
|     [HttpPost] | ||||
|     [Authorize] | ||||
|     public async Task<IActionResult> CreateProject([FromRoute] string pubName, [FromBody] DevProjectRequest request) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser)  | ||||
|             return Unauthorized(); | ||||
|  | ||||
|         var developer = await developerService.GetDeveloperByName(pubName); | ||||
|         if (developer is null) | ||||
|             return NotFound("Developer not found"); | ||||
|              | ||||
|         if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) | ||||
|             return StatusCode(403, "You must be an editor of the developer to create a project"); | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(request.Slug) || string.IsNullOrWhiteSpace(request.Name)) | ||||
|             return BadRequest("Slug and Name are required"); | ||||
|  | ||||
|         var project = await projectService.CreateProjectAsync(developer, request); | ||||
|         return CreatedAtAction( | ||||
|             nameof(GetProject),  | ||||
|             new { pubName, id = project.Id }, | ||||
|             project | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     [HttpPut("{id:guid}")] | ||||
|     [Authorize] | ||||
|     public async Task<IActionResult> UpdateProject( | ||||
|         [FromRoute] string pubName,  | ||||
|         [FromRoute] Guid id, | ||||
|         [FromBody] DevProjectRequest request | ||||
|     ) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser)  | ||||
|             return Unauthorized(); | ||||
|  | ||||
|         var developer = await developerService.GetDeveloperByName(pubName); | ||||
|         var accountId = Guid.Parse(currentUser.Id); | ||||
|         if (developer is null || developer.Id != accountId) | ||||
|             return Forbid(); | ||||
|  | ||||
|         var project = await projectService.UpdateProjectAsync(id, developer.Id, request); | ||||
|         if (project is null) | ||||
|             return NotFound(); | ||||
|  | ||||
|         return Ok(project); | ||||
|     } | ||||
|  | ||||
|     [HttpDelete("{id:guid}")] | ||||
|     [Authorize] | ||||
|     public async Task<IActionResult> DeleteProject([FromRoute] string pubName, [FromRoute] Guid id) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser)  | ||||
|             return Unauthorized(); | ||||
|  | ||||
|         var developer = await developerService.GetDeveloperByName(pubName); | ||||
|         var accountId = Guid.Parse(currentUser.Id); | ||||
|         if (developer is null || developer.Id != accountId) | ||||
|             return Forbid(); | ||||
|  | ||||
|         var success = await projectService.DeleteProjectAsync(id, developer.Id); | ||||
|         if (!success) | ||||
|             return NotFound(); | ||||
|  | ||||
|         return NoContent(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										77
									
								
								DysonNetwork.Develop/Project/DevProjectService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								DysonNetwork.Develop/Project/DevProjectService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,77 @@ | ||||
| using DysonNetwork.Develop.Identity; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using DysonNetwork.Shared.Proto; | ||||
|  | ||||
| namespace DysonNetwork.Develop.Project; | ||||
|  | ||||
| public class DevProjectService( | ||||
|     AppDatabase db, | ||||
|     FileReferenceService.FileReferenceServiceClient fileRefs, | ||||
|     FileService.FileServiceClient files | ||||
| ) | ||||
| { | ||||
|     public async Task<DevProject> CreateProjectAsync( | ||||
|         Developer developer, | ||||
|         DevProjectController.DevProjectRequest request | ||||
|     ) | ||||
|     { | ||||
|         var project = new DevProject | ||||
|         { | ||||
|             Slug = request.Slug!, | ||||
|             Name = request.Name!, | ||||
|             Description = request.Description ?? string.Empty, | ||||
|             DeveloperId = developer.Id | ||||
|         }; | ||||
|  | ||||
|         db.DevProjects.Add(project); | ||||
|         await db.SaveChangesAsync(); | ||||
|          | ||||
|         return project; | ||||
|     } | ||||
|  | ||||
|     public async Task<DevProject?> GetProjectAsync(Guid id, Guid? developerId = null) | ||||
|     { | ||||
|         var query = db.DevProjects.AsQueryable(); | ||||
|          | ||||
|         if (developerId.HasValue) | ||||
|         { | ||||
|             query = query.Where(p => p.DeveloperId == developerId.Value); | ||||
|         } | ||||
|  | ||||
|         return await query.FirstOrDefaultAsync(p => p.Id == id); | ||||
|     } | ||||
|  | ||||
|     public async Task<List<DevProject>> GetProjectsByDeveloperAsync(Guid developerId) | ||||
|     { | ||||
|         return await db.DevProjects | ||||
|             .Where(p => p.DeveloperId == developerId) | ||||
|             .ToListAsync(); | ||||
|     } | ||||
|  | ||||
|     public async Task<DevProject?> UpdateProjectAsync( | ||||
|         Guid id, | ||||
|         Guid developerId, | ||||
|         DevProjectController.DevProjectRequest request | ||||
|     ) | ||||
|     { | ||||
|         var project = await GetProjectAsync(id, developerId); | ||||
|         if (project == null) return null; | ||||
|  | ||||
|         if (request.Slug != null) project.Slug = request.Slug; | ||||
|         if (request.Name != null) project.Name = request.Name; | ||||
|         if (request.Description != null) project.Description = request.Description; | ||||
|  | ||||
|         await db.SaveChangesAsync(); | ||||
|         return project; | ||||
|     } | ||||
|  | ||||
|     public async Task<bool> DeleteProjectAsync(Guid id, Guid developerId) | ||||
|     { | ||||
|         var project = await GetProjectAsync(id, developerId); | ||||
|         if (project == null) return false; | ||||
|  | ||||
|         db.DevProjects.Remove(project); | ||||
|         await db.SaveChangesAsync(); | ||||
|         return true; | ||||
|     } | ||||
| } | ||||
| @@ -1,6 +1,7 @@ | ||||
| using System.Net; | ||||
| using DysonNetwork.Develop.Identity; | ||||
| using DysonNetwork.Shared.Auth; | ||||
| using DysonNetwork.Shared.Http; | ||||
| using Microsoft.AspNetCore.HttpOverrides; | ||||
| using Prometheus; | ||||
|  | ||||
| @@ -18,7 +19,7 @@ public static class ApplicationConfiguration | ||||
|          | ||||
|         app.UseRequestLocalization(); | ||||
|  | ||||
|         ConfigureForwardedHeaders(app, configuration); | ||||
|         app.ConfigureForwardedHeaders(configuration); | ||||
|  | ||||
|         app.UseAuthentication(); | ||||
|         app.UseAuthorization(); | ||||
| @@ -30,26 +31,4 @@ public static class ApplicationConfiguration | ||||
|  | ||||
|         return app; | ||||
|     } | ||||
|  | ||||
|     private static void ConfigureForwardedHeaders(WebApplication app, IConfiguration configuration) | ||||
|     { | ||||
|         var knownProxiesSection = configuration.GetSection("KnownProxies"); | ||||
|         var forwardedHeadersOptions = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All }; | ||||
|  | ||||
|         if (knownProxiesSection.Exists()) | ||||
|         { | ||||
|             var proxyAddresses = knownProxiesSection.Get<string[]>(); | ||||
|             if (proxyAddresses != null) | ||||
|                 foreach (var proxy in proxyAddresses) | ||||
|                     if (IPAddress.TryParse(proxy, out var ipAddress)) | ||||
|                         forwardedHeadersOptions.KnownProxies.Add(ipAddress); | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             forwardedHeadersOptions.KnownProxies.Add(IPAddress.Any); | ||||
|             forwardedHeadersOptions.KnownProxies.Add(IPAddress.IPv6Any); | ||||
|         } | ||||
|  | ||||
|         app.UseForwardedHeaders(forwardedHeadersOptions); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -3,7 +3,9 @@ using Microsoft.OpenApi.Models; | ||||
| using NodaTime; | ||||
| using NodaTime.Serialization.SystemTextJson; | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
| using DysonNetwork.Develop.Identity; | ||||
| using DysonNetwork.Develop.Project; | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using StackExchange.Redis; | ||||
|  | ||||
| @@ -18,19 +20,16 @@ public static class ServiceCollectionExtensions | ||||
|         services.AddDbContext<AppDatabase>(); | ||||
|         services.AddSingleton<IClock>(SystemClock.Instance); | ||||
|         services.AddHttpContextAccessor(); | ||||
|         services.AddSingleton<IConnectionMultiplexer>(_ => | ||||
|         { | ||||
|             var connection = configuration.GetConnectionString("FastRetrieve")!; | ||||
|             return ConnectionMultiplexer.Connect(connection); | ||||
|         }); | ||||
|         services.AddSingleton<ICacheService, CacheServiceRedis>(); | ||||
|  | ||||
|         services.AddHttpClient(); | ||||
|  | ||||
|         services.AddControllers().AddJsonOptions(options => | ||||
|         { | ||||
|             options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals; | ||||
|             options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; | ||||
|             options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower; | ||||
|              | ||||
|             options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); | ||||
|         }); | ||||
|  | ||||
| @@ -50,6 +49,8 @@ public static class ServiceCollectionExtensions | ||||
|  | ||||
|         services.AddScoped<DeveloperService>(); | ||||
|         services.AddScoped<CustomAppService>(); | ||||
|         services.AddScoped<DevProjectService>(); | ||||
|         services.AddScoped<BotAccountService>(); | ||||
|  | ||||
|         return services; | ||||
|     } | ||||
|   | ||||
| @@ -10,9 +10,7 @@ | ||||
|   }, | ||||
|   "AllowedHosts": "*", | ||||
|   "ConnectionStrings": { | ||||
|     "App": "Host=localhost;Port=5432;Database=dyson_network_dev;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60", | ||||
|     "FastRetrieve": "localhost:6379", | ||||
|     "Etcd": "etcd.orb.local:2379" | ||||
|     "App": "Host=localhost;Port=5432;Database=dyson_network_dev;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60" | ||||
|   }, | ||||
|   "KnownProxies": [ | ||||
|     "127.0.0.1", | ||||
| @@ -23,8 +21,6 @@ | ||||
|   }, | ||||
|   "Service": { | ||||
|     "Name": "DysonNetwork.Develop", | ||||
|     "Url": "https://localhost:7099", | ||||
|     "ClientCert": "../Certificates/client.crt", | ||||
|     "ClientKey": "../Certificates/client.key" | ||||
|     "Url": "https://localhost:7192" | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -31,7 +31,6 @@ public class AppDatabase( | ||||
|             opt => opt | ||||
|                 .ConfigureDataSource(optSource => optSource.EnableDynamicJson()) | ||||
|                 .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) | ||||
|                 .UseNetTopologySuite() | ||||
|                 .UseNodaTime() | ||||
|         ).UseSnakeCaseNamingConvention(); | ||||
|  | ||||
|   | ||||
| @@ -35,7 +35,6 @@ | ||||
|         <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" /> | ||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> | ||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" /> | ||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="9.0.4" /> | ||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" /> | ||||
|         <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" /> | ||||
|         <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" /> | ||||
| @@ -67,6 +66,7 @@ | ||||
|     </ItemGroup> | ||||
|  | ||||
|     <ItemGroup> | ||||
|       <ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" /> | ||||
|       <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> | ||||
|     </ItemGroup> | ||||
|  | ||||
|   | ||||
							
								
								
									
										404
									
								
								DysonNetwork.Drive/Migrations/20250819164302_RemoveUploadedTo.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										404
									
								
								DysonNetwork.Drive/Migrations/20250819164302_RemoveUploadedTo.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,404 @@ | ||||
| // <auto-generated /> | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using DysonNetwork.Drive; | ||||
| using DysonNetwork.Drive.Storage; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | ||||
| using Microsoft.EntityFrameworkCore.Migrations; | ||||
| using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||
| using NodaTime; | ||||
| using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||
|  | ||||
| #nullable disable | ||||
|  | ||||
| namespace DysonNetwork.Drive.Migrations | ||||
| { | ||||
|     [DbContext(typeof(AppDatabase))] | ||||
|     [Migration("20250819164302_RemoveUploadedTo")] | ||||
|     partial class RemoveUploadedTo | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         protected override void BuildTargetModel(ModelBuilder modelBuilder) | ||||
|         { | ||||
| #pragma warning disable 612, 618 | ||||
|             modelBuilder | ||||
|                 .HasAnnotation("ProductVersion", "9.0.7") | ||||
|                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||
|  | ||||
|             NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); | ||||
|             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b => | ||||
|                 { | ||||
|                     b.Property<Guid>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<Guid>("AccountId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("account_id"); | ||||
|  | ||||
|                     b.Property<Instant>("CreatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("created_at"); | ||||
|  | ||||
|                     b.Property<Instant?>("DeletedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("deleted_at"); | ||||
|  | ||||
|                     b.Property<string>("Description") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("description"); | ||||
|  | ||||
|                     b.Property<Instant?>("ExpiredAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("expired_at"); | ||||
|  | ||||
|                     b.Property<string>("Name") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("name"); | ||||
|  | ||||
|                     b.Property<long>("Quota") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("quota"); | ||||
|  | ||||
|                     b.Property<Instant>("UpdatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("updated_at"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_quota_records"); | ||||
|  | ||||
|                     b.ToTable("quota_records", (string)null); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||
|                 { | ||||
|                     b.Property<string>("Id") | ||||
|                         .HasMaxLength(32) | ||||
|                         .HasColumnType("character varying(32)") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<Guid>("AccountId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("account_id"); | ||||
|  | ||||
|                     b.Property<Guid?>("BundleId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("bundle_id"); | ||||
|  | ||||
|                     b.Property<Instant>("CreatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("created_at"); | ||||
|  | ||||
|                     b.Property<Instant?>("DeletedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("deleted_at"); | ||||
|  | ||||
|                     b.Property<string>("Description") | ||||
|                         .HasMaxLength(4096) | ||||
|                         .HasColumnType("character varying(4096)") | ||||
|                         .HasColumnName("description"); | ||||
|  | ||||
|                     b.Property<Instant?>("ExpiredAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("expired_at"); | ||||
|  | ||||
|                     b.Property<Dictionary<string, object>>("FileMeta") | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("file_meta"); | ||||
|  | ||||
|                     b.Property<bool>("HasCompression") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("has_compression"); | ||||
|  | ||||
|                     b.Property<bool>("HasThumbnail") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("has_thumbnail"); | ||||
|  | ||||
|                     b.Property<string>("Hash") | ||||
|                         .HasMaxLength(256) | ||||
|                         .HasColumnType("character varying(256)") | ||||
|                         .HasColumnName("hash"); | ||||
|  | ||||
|                     b.Property<bool>("IsEncrypted") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("is_encrypted"); | ||||
|  | ||||
|                     b.Property<bool>("IsMarkedRecycle") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("is_marked_recycle"); | ||||
|  | ||||
|                     b.Property<string>("MimeType") | ||||
|                         .HasMaxLength(256) | ||||
|                         .HasColumnType("character varying(256)") | ||||
|                         .HasColumnName("mime_type"); | ||||
|  | ||||
|                     b.Property<string>("Name") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("name"); | ||||
|  | ||||
|                     b.Property<Guid?>("PoolId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("pool_id"); | ||||
|  | ||||
|                     b.Property<List<ContentSensitiveMark>>("SensitiveMarks") | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("sensitive_marks"); | ||||
|  | ||||
|                     b.Property<long>("Size") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("size"); | ||||
|  | ||||
|                     b.Property<string>("StorageId") | ||||
|                         .HasMaxLength(32) | ||||
|                         .HasColumnType("character varying(32)") | ||||
|                         .HasColumnName("storage_id"); | ||||
|  | ||||
|                     b.Property<string>("StorageUrl") | ||||
|                         .HasMaxLength(4096) | ||||
|                         .HasColumnType("character varying(4096)") | ||||
|                         .HasColumnName("storage_url"); | ||||
|  | ||||
|                     b.Property<Instant>("UpdatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("updated_at"); | ||||
|  | ||||
|                     b.Property<Instant?>("UploadedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("uploaded_at"); | ||||
|  | ||||
|                     b.Property<Dictionary<string, object>>("UserMeta") | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("user_meta"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_files"); | ||||
|  | ||||
|                     b.HasIndex("BundleId") | ||||
|                         .HasDatabaseName("ix_files_bundle_id"); | ||||
|  | ||||
|                     b.HasIndex("PoolId") | ||||
|                         .HasDatabaseName("ix_files_pool_id"); | ||||
|  | ||||
|                     b.ToTable("files", (string)null); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||
|                 { | ||||
|                     b.Property<Guid>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<Instant>("CreatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("created_at"); | ||||
|  | ||||
|                     b.Property<Instant?>("DeletedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("deleted_at"); | ||||
|  | ||||
|                     b.Property<Instant?>("ExpiredAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("expired_at"); | ||||
|  | ||||
|                     b.Property<string>("FileId") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(32) | ||||
|                         .HasColumnType("character varying(32)") | ||||
|                         .HasColumnName("file_id"); | ||||
|  | ||||
|                     b.Property<string>("ResourceId") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("resource_id"); | ||||
|  | ||||
|                     b.Property<Instant>("UpdatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("updated_at"); | ||||
|  | ||||
|                     b.Property<string>("Usage") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("usage"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_file_references"); | ||||
|  | ||||
|                     b.HasIndex("FileId") | ||||
|                         .HasDatabaseName("ix_file_references_file_id"); | ||||
|  | ||||
|                     b.ToTable("file_references", (string)null); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b => | ||||
|                 { | ||||
|                     b.Property<Guid>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<Guid>("AccountId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("account_id"); | ||||
|  | ||||
|                     b.Property<Instant>("CreatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("created_at"); | ||||
|  | ||||
|                     b.Property<Instant?>("DeletedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("deleted_at"); | ||||
|  | ||||
|                     b.Property<string>("Description") | ||||
|                         .HasMaxLength(8192) | ||||
|                         .HasColumnType("character varying(8192)") | ||||
|                         .HasColumnName("description"); | ||||
|  | ||||
|                     b.Property<Instant?>("ExpiredAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("expired_at"); | ||||
|  | ||||
|                     b.Property<string>("Name") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("name"); | ||||
|  | ||||
|                     b.Property<string>("Passcode") | ||||
|                         .HasMaxLength(256) | ||||
|                         .HasColumnType("character varying(256)") | ||||
|                         .HasColumnName("passcode"); | ||||
|  | ||||
|                     b.Property<string>("Slug") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("slug"); | ||||
|  | ||||
|                     b.Property<Instant>("UpdatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("updated_at"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_bundles"); | ||||
|  | ||||
|                     b.HasIndex("Slug") | ||||
|                         .IsUnique() | ||||
|                         .HasDatabaseName("ix_bundles_slug"); | ||||
|  | ||||
|                     b.ToTable("bundles", (string)null); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b => | ||||
|                 { | ||||
|                     b.Property<Guid>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<Guid?>("AccountId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("account_id"); | ||||
|  | ||||
|                     b.Property<BillingConfig>("BillingConfig") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("billing_config"); | ||||
|  | ||||
|                     b.Property<Instant>("CreatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("created_at"); | ||||
|  | ||||
|                     b.Property<Instant?>("DeletedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("deleted_at"); | ||||
|  | ||||
|                     b.Property<string>("Description") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(8192) | ||||
|                         .HasColumnType("character varying(8192)") | ||||
|                         .HasColumnName("description"); | ||||
|  | ||||
|                     b.Property<bool>("IsHidden") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("is_hidden"); | ||||
|  | ||||
|                     b.Property<string>("Name") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("name"); | ||||
|  | ||||
|                     b.Property<PolicyConfig>("PolicyConfig") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("policy_config"); | ||||
|  | ||||
|                     b.Property<RemoteStorageConfig>("StorageConfig") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("storage_config"); | ||||
|  | ||||
|                     b.Property<Instant>("UpdatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("updated_at"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_pools"); | ||||
|  | ||||
|                     b.ToTable("pools", (string)null); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||
|                 { | ||||
|                     b.HasOne("DysonNetwork.Drive.Storage.FileBundle", "Bundle") | ||||
|                         .WithMany("Files") | ||||
|                         .HasForeignKey("BundleId") | ||||
|                         .HasConstraintName("fk_files_bundles_bundle_id"); | ||||
|  | ||||
|                     b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("PoolId") | ||||
|                         .HasConstraintName("fk_files_pools_pool_id"); | ||||
|  | ||||
|                     b.Navigation("Bundle"); | ||||
|  | ||||
|                     b.Navigation("Pool"); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||
|                 { | ||||
|                     b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File") | ||||
|                         .WithMany("References") | ||||
|                         .HasForeignKey("FileId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_file_references_files_file_id"); | ||||
|  | ||||
|                     b.Navigation("File"); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||
|                 { | ||||
|                     b.Navigation("References"); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b => | ||||
|                 { | ||||
|                     b.Navigation("Files"); | ||||
|                 }); | ||||
| #pragma warning restore 612, 618 | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,29 @@ | ||||
| using Microsoft.EntityFrameworkCore.Migrations; | ||||
|  | ||||
| #nullable disable | ||||
|  | ||||
| namespace DysonNetwork.Drive.Migrations | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     public partial class RemoveUploadedTo : Migration | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Up(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.DropColumn( | ||||
|                 name: "uploaded_to", | ||||
|                 table: "files"); | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Down(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.AddColumn<string>( | ||||
|                 name: "uploaded_to", | ||||
|                 table: "files", | ||||
|                 type: "character varying(128)", | ||||
|                 maxLength: 128, | ||||
|                 nullable: true); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										403
									
								
								DysonNetwork.Drive/Migrations/20250907070034_RemoveNetTopo.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										403
									
								
								DysonNetwork.Drive/Migrations/20250907070034_RemoveNetTopo.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,403 @@ | ||||
| // <auto-generated /> | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using DysonNetwork.Drive; | ||||
| using DysonNetwork.Drive.Storage; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | ||||
| using Microsoft.EntityFrameworkCore.Migrations; | ||||
| using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||
| using NodaTime; | ||||
| using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||
|  | ||||
| #nullable disable | ||||
|  | ||||
| namespace DysonNetwork.Drive.Migrations | ||||
| { | ||||
|     [DbContext(typeof(AppDatabase))] | ||||
|     [Migration("20250907070034_RemoveNetTopo")] | ||||
|     partial class RemoveNetTopo | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         protected override void BuildTargetModel(ModelBuilder modelBuilder) | ||||
|         { | ||||
| #pragma warning disable 612, 618 | ||||
|             modelBuilder | ||||
|                 .HasAnnotation("ProductVersion", "9.0.7") | ||||
|                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||
|  | ||||
|             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b => | ||||
|                 { | ||||
|                     b.Property<Guid>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<Guid>("AccountId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("account_id"); | ||||
|  | ||||
|                     b.Property<Instant>("CreatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("created_at"); | ||||
|  | ||||
|                     b.Property<Instant?>("DeletedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("deleted_at"); | ||||
|  | ||||
|                     b.Property<string>("Description") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("description"); | ||||
|  | ||||
|                     b.Property<Instant?>("ExpiredAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("expired_at"); | ||||
|  | ||||
|                     b.Property<string>("Name") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("name"); | ||||
|  | ||||
|                     b.Property<long>("Quota") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("quota"); | ||||
|  | ||||
|                     b.Property<Instant>("UpdatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("updated_at"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_quota_records"); | ||||
|  | ||||
|                     b.ToTable("quota_records", (string)null); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||
|                 { | ||||
|                     b.Property<string>("Id") | ||||
|                         .HasMaxLength(32) | ||||
|                         .HasColumnType("character varying(32)") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<Guid>("AccountId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("account_id"); | ||||
|  | ||||
|                     b.Property<Guid?>("BundleId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("bundle_id"); | ||||
|  | ||||
|                     b.Property<Instant>("CreatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("created_at"); | ||||
|  | ||||
|                     b.Property<Instant?>("DeletedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("deleted_at"); | ||||
|  | ||||
|                     b.Property<string>("Description") | ||||
|                         .HasMaxLength(4096) | ||||
|                         .HasColumnType("character varying(4096)") | ||||
|                         .HasColumnName("description"); | ||||
|  | ||||
|                     b.Property<Instant?>("ExpiredAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("expired_at"); | ||||
|  | ||||
|                     b.Property<Dictionary<string, object>>("FileMeta") | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("file_meta"); | ||||
|  | ||||
|                     b.Property<bool>("HasCompression") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("has_compression"); | ||||
|  | ||||
|                     b.Property<bool>("HasThumbnail") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("has_thumbnail"); | ||||
|  | ||||
|                     b.Property<string>("Hash") | ||||
|                         .HasMaxLength(256) | ||||
|                         .HasColumnType("character varying(256)") | ||||
|                         .HasColumnName("hash"); | ||||
|  | ||||
|                     b.Property<bool>("IsEncrypted") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("is_encrypted"); | ||||
|  | ||||
|                     b.Property<bool>("IsMarkedRecycle") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("is_marked_recycle"); | ||||
|  | ||||
|                     b.Property<string>("MimeType") | ||||
|                         .HasMaxLength(256) | ||||
|                         .HasColumnType("character varying(256)") | ||||
|                         .HasColumnName("mime_type"); | ||||
|  | ||||
|                     b.Property<string>("Name") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("name"); | ||||
|  | ||||
|                     b.Property<Guid?>("PoolId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("pool_id"); | ||||
|  | ||||
|                     b.Property<List<ContentSensitiveMark>>("SensitiveMarks") | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("sensitive_marks"); | ||||
|  | ||||
|                     b.Property<long>("Size") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("size"); | ||||
|  | ||||
|                     b.Property<string>("StorageId") | ||||
|                         .HasMaxLength(32) | ||||
|                         .HasColumnType("character varying(32)") | ||||
|                         .HasColumnName("storage_id"); | ||||
|  | ||||
|                     b.Property<string>("StorageUrl") | ||||
|                         .HasMaxLength(4096) | ||||
|                         .HasColumnType("character varying(4096)") | ||||
|                         .HasColumnName("storage_url"); | ||||
|  | ||||
|                     b.Property<Instant>("UpdatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("updated_at"); | ||||
|  | ||||
|                     b.Property<Instant?>("UploadedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("uploaded_at"); | ||||
|  | ||||
|                     b.Property<Dictionary<string, object>>("UserMeta") | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("user_meta"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_files"); | ||||
|  | ||||
|                     b.HasIndex("BundleId") | ||||
|                         .HasDatabaseName("ix_files_bundle_id"); | ||||
|  | ||||
|                     b.HasIndex("PoolId") | ||||
|                         .HasDatabaseName("ix_files_pool_id"); | ||||
|  | ||||
|                     b.ToTable("files", (string)null); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||
|                 { | ||||
|                     b.Property<Guid>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<Instant>("CreatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("created_at"); | ||||
|  | ||||
|                     b.Property<Instant?>("DeletedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("deleted_at"); | ||||
|  | ||||
|                     b.Property<Instant?>("ExpiredAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("expired_at"); | ||||
|  | ||||
|                     b.Property<string>("FileId") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(32) | ||||
|                         .HasColumnType("character varying(32)") | ||||
|                         .HasColumnName("file_id"); | ||||
|  | ||||
|                     b.Property<string>("ResourceId") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("resource_id"); | ||||
|  | ||||
|                     b.Property<Instant>("UpdatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("updated_at"); | ||||
|  | ||||
|                     b.Property<string>("Usage") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("usage"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_file_references"); | ||||
|  | ||||
|                     b.HasIndex("FileId") | ||||
|                         .HasDatabaseName("ix_file_references_file_id"); | ||||
|  | ||||
|                     b.ToTable("file_references", (string)null); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b => | ||||
|                 { | ||||
|                     b.Property<Guid>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<Guid>("AccountId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("account_id"); | ||||
|  | ||||
|                     b.Property<Instant>("CreatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("created_at"); | ||||
|  | ||||
|                     b.Property<Instant?>("DeletedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("deleted_at"); | ||||
|  | ||||
|                     b.Property<string>("Description") | ||||
|                         .HasMaxLength(8192) | ||||
|                         .HasColumnType("character varying(8192)") | ||||
|                         .HasColumnName("description"); | ||||
|  | ||||
|                     b.Property<Instant?>("ExpiredAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("expired_at"); | ||||
|  | ||||
|                     b.Property<string>("Name") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("name"); | ||||
|  | ||||
|                     b.Property<string>("Passcode") | ||||
|                         .HasMaxLength(256) | ||||
|                         .HasColumnType("character varying(256)") | ||||
|                         .HasColumnName("passcode"); | ||||
|  | ||||
|                     b.Property<string>("Slug") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("slug"); | ||||
|  | ||||
|                     b.Property<Instant>("UpdatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("updated_at"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_bundles"); | ||||
|  | ||||
|                     b.HasIndex("Slug") | ||||
|                         .IsUnique() | ||||
|                         .HasDatabaseName("ix_bundles_slug"); | ||||
|  | ||||
|                     b.ToTable("bundles", (string)null); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b => | ||||
|                 { | ||||
|                     b.Property<Guid>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<Guid?>("AccountId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("account_id"); | ||||
|  | ||||
|                     b.Property<BillingConfig>("BillingConfig") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("billing_config"); | ||||
|  | ||||
|                     b.Property<Instant>("CreatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("created_at"); | ||||
|  | ||||
|                     b.Property<Instant?>("DeletedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("deleted_at"); | ||||
|  | ||||
|                     b.Property<string>("Description") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(8192) | ||||
|                         .HasColumnType("character varying(8192)") | ||||
|                         .HasColumnName("description"); | ||||
|  | ||||
|                     b.Property<bool>("IsHidden") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("is_hidden"); | ||||
|  | ||||
|                     b.Property<string>("Name") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("name"); | ||||
|  | ||||
|                     b.Property<PolicyConfig>("PolicyConfig") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("policy_config"); | ||||
|  | ||||
|                     b.Property<RemoteStorageConfig>("StorageConfig") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("storage_config"); | ||||
|  | ||||
|                     b.Property<Instant>("UpdatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("updated_at"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_pools"); | ||||
|  | ||||
|                     b.ToTable("pools", (string)null); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||
|                 { | ||||
|                     b.HasOne("DysonNetwork.Drive.Storage.FileBundle", "Bundle") | ||||
|                         .WithMany("Files") | ||||
|                         .HasForeignKey("BundleId") | ||||
|                         .HasConstraintName("fk_files_bundles_bundle_id"); | ||||
|  | ||||
|                     b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("PoolId") | ||||
|                         .HasConstraintName("fk_files_pools_pool_id"); | ||||
|  | ||||
|                     b.Navigation("Bundle"); | ||||
|  | ||||
|                     b.Navigation("Pool"); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||
|                 { | ||||
|                     b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File") | ||||
|                         .WithMany("References") | ||||
|                         .HasForeignKey("FileId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_file_references_files_file_id"); | ||||
|  | ||||
|                     b.Navigation("File"); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||
|                 { | ||||
|                     b.Navigation("References"); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b => | ||||
|                 { | ||||
|                     b.Navigation("Files"); | ||||
|                 }); | ||||
| #pragma warning restore 612, 618 | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,24 @@ | ||||
| using Microsoft.EntityFrameworkCore.Migrations; | ||||
|  | ||||
| #nullable disable | ||||
|  | ||||
| namespace DysonNetwork.Drive.Migrations | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     public partial class RemoveNetTopo : Migration | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Up(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.AlterDatabase() | ||||
|                 .OldAnnotation("Npgsql:PostgresExtension:postgis", ",,"); | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Down(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.AlterDatabase() | ||||
|                 .Annotation("Npgsql:PostgresExtension:postgis", ",,"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -24,7 +24,6 @@ namespace DysonNetwork.Drive.Migrations | ||||
|                 .HasAnnotation("ProductVersion", "9.0.7") | ||||
|                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||
|  | ||||
|             NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); | ||||
|             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b => | ||||
| @@ -172,11 +171,6 @@ namespace DysonNetwork.Drive.Migrations | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("uploaded_at"); | ||||
|  | ||||
|                     b.Property<string>("UploadedTo") | ||||
|                         .HasMaxLength(128) | ||||
|                         .HasColumnType("character varying(128)") | ||||
|                         .HasColumnName("uploaded_to"); | ||||
|  | ||||
|                     b.Property<Dictionary<string, object>>("UserMeta") | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("user_meta"); | ||||
| @@ -382,7 +376,7 @@ namespace DysonNetwork.Drive.Migrations | ||||
|             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||
|                 { | ||||
|                     b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File") | ||||
|                         .WithMany() | ||||
|                         .WithMany("References") | ||||
|                         .HasForeignKey("FileId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
| @@ -391,6 +385,11 @@ namespace DysonNetwork.Drive.Migrations | ||||
|                     b.Navigation("File"); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||
|                 { | ||||
|                     b.Navigation("References"); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b => | ||||
|                 { | ||||
|                     b.Navigation("Files"); | ||||
|   | ||||
| @@ -10,11 +10,13 @@ using tusdotnet.Stores; | ||||
|  | ||||
| var builder = WebApplication.CreateBuilder(args); | ||||
|  | ||||
| builder.AddServiceDefaults(); | ||||
|  | ||||
| // Configure Kestrel and server options | ||||
| builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxValue); | ||||
|  | ||||
| // Add application services | ||||
| builder.Services.AddRegistryService(builder.Configuration); | ||||
|  | ||||
| builder.Services.AddAppServices(builder.Configuration); | ||||
| builder.Services.AddAppRateLimiting(); | ||||
| builder.Services.AddAppAuthentication(); | ||||
| @@ -37,6 +39,8 @@ builder.Services.AddTransient<IPageDataProvider, VersionPageData>(); | ||||
|  | ||||
| var app = builder.Build(); | ||||
|  | ||||
| app.MapDefaultEndpoints(); | ||||
|  | ||||
| // Run database migrations | ||||
| using (var scope = app.Services.CreateScope()) | ||||
| { | ||||
| @@ -49,8 +53,6 @@ var tusDiskStore = app.Services.GetRequiredService<TusDiskStore>(); | ||||
| // Configure application middleware pipeline | ||||
| app.ConfigureAppMiddleware(tusDiskStore, builder.Environment.ContentRootPath); | ||||
|  | ||||
| app.MapGatewayProxy(); | ||||
|  | ||||
| app.MapPages(Path.Combine(app.Environment.WebRootPath, "dist", "index.html")); | ||||
|  | ||||
| // Configure gRPC | ||||
|   | ||||
							
								
								
									
										72
									
								
								DysonNetwork.Drive/Startup/BroadcastEventHandler.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								DysonNetwork.Drive/Startup/BroadcastEventHandler.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,72 @@ | ||||
| using System.Text.Json; | ||||
| using DysonNetwork.Drive.Storage; | ||||
| using DysonNetwork.Shared.Stream; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NATS.Client.Core; | ||||
| using NATS.Client.JetStream.Models; | ||||
| using NATS.Net; | ||||
|  | ||||
| namespace DysonNetwork.Drive.Startup; | ||||
|  | ||||
| public class BroadcastEventHandler( | ||||
|     INatsConnection nats, | ||||
|     ILogger<BroadcastEventHandler> logger, | ||||
|     IServiceProvider serviceProvider | ||||
| ) : BackgroundService | ||||
| { | ||||
|     protected override async Task ExecuteAsync(CancellationToken stoppingToken) | ||||
|     { | ||||
|         var js = nats.CreateJetStreamContext(); | ||||
|  | ||||
|         await js.EnsureStreamCreated("account_events", [AccountDeletedEvent.Type]); | ||||
|          | ||||
|         var consumer = await js.CreateOrUpdateConsumerAsync("account_events", | ||||
|             new ConsumerConfig("drive_account_deleted_handler"), cancellationToken: stoppingToken); | ||||
|  | ||||
|         await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken)) | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 var evt = JsonSerializer.Deserialize<AccountDeletedEvent>(msg.Data); | ||||
|                 if (evt == null) | ||||
|                 { | ||||
|                     await msg.AckAsync(cancellationToken: stoppingToken); | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 logger.LogInformation("Account deleted: {AccountId}", evt.AccountId); | ||||
|  | ||||
|                 using var scope = serviceProvider.CreateScope(); | ||||
|                 var fs = scope.ServiceProvider.GetRequiredService<FileService>(); | ||||
|                 var db = scope.ServiceProvider.GetRequiredService<AppDatabase>(); | ||||
|  | ||||
|                 await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken: stoppingToken); | ||||
|                 try | ||||
|                 { | ||||
|                     var files = await db.Files | ||||
|                         .Where(p => p.AccountId == evt.AccountId) | ||||
|                         .ToListAsync(cancellationToken: stoppingToken); | ||||
|  | ||||
|                     await fs.DeleteFileDataBatchAsync(files); | ||||
|                     await db.Files | ||||
|                         .Where(p => p.AccountId == evt.AccountId) | ||||
|                         .ExecuteDeleteAsync(cancellationToken: stoppingToken); | ||||
|  | ||||
|                     await transaction.CommitAsync(cancellationToken: stoppingToken); | ||||
|                 } | ||||
|                 catch (Exception) | ||||
|                 { | ||||
|                     await transaction.RollbackAsync(cancellationToken: stoppingToken); | ||||
|                     throw; | ||||
|                 } | ||||
|  | ||||
|                 await msg.AckAsync(cancellationToken: stoppingToken); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 logger.LogError(ex, "Error processing AccountDeleted"); | ||||
|                 await msg.NakAsync(cancellationToken: stoppingToken); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,4 +1,5 @@ | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
| using System.Threading.RateLimiting; | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using Microsoft.AspNetCore.RateLimiting; | ||||
| @@ -16,11 +17,6 @@ public static class ServiceCollectionExtensions | ||||
|     public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration) | ||||
|     { | ||||
|         services.AddDbContext<AppDatabase>(); // Assuming you'll have an AppDatabase | ||||
|         services.AddSingleton<IConnectionMultiplexer>(_ => | ||||
|         { | ||||
|             var connection = configuration.GetConnectionString("FastRetrieve")!; | ||||
|             return ConnectionMultiplexer.Connect(connection); | ||||
|         }); | ||||
|         services.AddSingleton<IClock>(SystemClock.Instance); | ||||
|         services.AddHttpContextAccessor(); | ||||
|         services.AddSingleton<ICacheService, CacheServiceRedis>(); // Uncomment if you have CacheServiceRedis | ||||
| @@ -40,6 +36,7 @@ public static class ServiceCollectionExtensions | ||||
|  | ||||
|         services.AddControllers().AddJsonOptions(options => | ||||
|         { | ||||
|             options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals; | ||||
|             options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; | ||||
|             options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower; | ||||
|  | ||||
| @@ -141,6 +138,8 @@ public static class ServiceCollectionExtensions | ||||
|         services.AddScoped<Billing.UsageService>(); | ||||
|         services.AddScoped<Billing.QuotaService>(); | ||||
|  | ||||
|         services.AddHostedService<BroadcastEventHandler>(); | ||||
|          | ||||
|         return services; | ||||
|     } | ||||
| } | ||||
| @@ -33,10 +33,6 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource | ||||
|     [JsonIgnore] public FileBundle? Bundle { get; set; } | ||||
|     public Guid? BundleId { get; set; } | ||||
|  | ||||
|     [Obsolete("Deprecated, use PoolId instead. For database migration only.")] | ||||
|     [MaxLength(128)] | ||||
|     public string? UploadedTo { get; set; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// The field is set to true if the recycling job plans to delete the file. | ||||
|     /// Due to the unstable of the recycling job, this doesn't really delete the file until a human verifies it. | ||||
| @@ -61,6 +57,8 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource | ||||
|     [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
|     public string? FastUploadLink { get; set; } | ||||
|      | ||||
|     public ICollection<CloudFileReference> References { get; set; } = new List<CloudFileReference>(); | ||||
|  | ||||
|     public Guid AccountId { get; set; } | ||||
|  | ||||
|     public CloudFileReferenceObject ToReferenceObject() | ||||
|   | ||||
| @@ -190,10 +190,8 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach | ||||
|             .Where(r => r.ResourceId == resourceId && r.Usage == usage) | ||||
|             .ToListAsync(); | ||||
|  | ||||
|         if (!references.Any()) | ||||
|         { | ||||
|         if (references.Count == 0) | ||||
|             return 0; | ||||
|         } | ||||
|  | ||||
|         var fileIds = references.Select(r => r.FileId).Distinct().ToList(); | ||||
|  | ||||
| @@ -208,6 +206,28 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach | ||||
|         return deletedCount; | ||||
|     } | ||||
|      | ||||
|     public async Task<int> DeleteResourceReferencesBatchAsync(IEnumerable<string> resourceIds, string? usage = null) | ||||
|     { | ||||
|         var references = await db.FileReferences | ||||
|             .Where(r => resourceIds.Contains(r.ResourceId)) | ||||
|             .If(usage != null, q => q.Where(q => q.Usage == usage)) | ||||
|             .ToListAsync(); | ||||
|  | ||||
|         if (references.Count == 0) | ||||
|             return 0; | ||||
|  | ||||
|         var fileIds = references.Select(r => r.FileId).Distinct().ToList(); | ||||
|  | ||||
|         db.FileReferences.RemoveRange(references); | ||||
|         var deletedCount = await db.SaveChangesAsync(); | ||||
|  | ||||
|         // Purge caches | ||||
|         var tasks = fileIds.Select(fileService._PurgeCacheAsync).ToList(); | ||||
|         await Task.WhenAll(tasks); | ||||
|  | ||||
|         return deletedCount; | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Deletes a specific file reference | ||||
|     /// </summary> | ||||
|   | ||||
| @@ -85,7 +85,7 @@ namespace DysonNetwork.Drive.Storage | ||||
|         public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferences( | ||||
|             DeleteResourceReferencesRequest request, ServerCallContext context) | ||||
|         { | ||||
|             var deletedCount = 0; | ||||
|             int deletedCount; | ||||
|             if (request.Usage is null) | ||||
|                 deletedCount = await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId); | ||||
|             else | ||||
| @@ -94,6 +94,18 @@ namespace DysonNetwork.Drive.Storage | ||||
|             return new DeleteResourceReferencesResponse { DeletedCount = deletedCount }; | ||||
|         } | ||||
|          | ||||
|         public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferencesBatch(DeleteResourceReferencesBatchRequest request, ServerCallContext context) | ||||
|         { | ||||
|             var resourceIds = request.ResourceIds.ToList(); | ||||
|             int deletedCount; | ||||
|             if (request.Usage is null) | ||||
|                 deletedCount = await fileReferenceService.DeleteResourceReferencesBatchAsync(resourceIds); | ||||
|             else | ||||
|                 deletedCount = | ||||
|                     await fileReferenceService.DeleteResourceReferencesBatchAsync(resourceIds, request.Usage!); | ||||
|             return new DeleteResourceReferencesResponse { DeletedCount = deletedCount }; | ||||
|         } | ||||
|  | ||||
|         public override async Task<DeleteReferenceResponse> DeleteReference(DeleteReferenceRequest request, | ||||
|             ServerCallContext context) | ||||
|         { | ||||
|   | ||||
| @@ -102,6 +102,7 @@ public class FileService( | ||||
|  | ||||
|     private static readonly string[] AnimatedImageTypes = | ||||
|         ["image/gif", "image/apng", "image/avif"]; | ||||
|  | ||||
|     private static readonly string[] AnimatedImageExtensions = | ||||
|         [".gif", ".apng", ".avif"]; | ||||
|  | ||||
| @@ -278,15 +279,15 @@ public class FileService( | ||||
|                             s.Rotation | ||||
|                         }).Where(s => double.IsNormal(s.AvgFrameRate)).ToList(), | ||||
|                         ["audio_streams"] = mediaInfo.AudioStreams.Select(s => new | ||||
|                         { | ||||
|                             s.BitRate, | ||||
|                             s.Channels, | ||||
|                             s.ChannelLayout, | ||||
|                             s.CodecName, | ||||
|                             s.Duration, | ||||
|                             s.Language, | ||||
|                             s.SampleRateHz | ||||
|                         }) | ||||
|                             { | ||||
|                                 s.BitRate, | ||||
|                                 s.Channels, | ||||
|                                 s.ChannelLayout, | ||||
|                                 s.CodecName, | ||||
|                                 s.Duration, | ||||
|                                 s.Language, | ||||
|                                 s.SampleRateHz | ||||
|                             }) | ||||
|                             .ToList(), | ||||
|                     }; | ||||
|                     if (mediaInfo.PrimaryVideoStream is not null) | ||||
| @@ -336,7 +337,14 @@ public class FileService( | ||||
|             if (!pool.PolicyConfig.NoOptimization) | ||||
|                 switch (contentType.Split('/')[0]) | ||||
|                 { | ||||
|                     case "image" when !AnimatedImageTypes.Contains(contentType) && !AnimatedImageExtensions.Contains(fileExtension): | ||||
|                     case "image": | ||||
|                         if (AnimatedImageTypes.Contains(contentType) || AnimatedImageExtensions.Contains(fileExtension)) | ||||
|                         { | ||||
|                             logger.LogInformation("Skip optimize file {FileId} due to it is animated...", fileId); | ||||
|                             uploads.Add((originalFilePath, string.Empty, contentType, false)); | ||||
|                             break; | ||||
|                         } | ||||
|  | ||||
|                         newMimeType = "image/webp"; | ||||
|                         using (var vipsImage = Image.NewFromFile(originalFilePath)) | ||||
|                         { | ||||
| @@ -643,7 +651,44 @@ public class FileService( | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public async Task<FileBundle?> GetBundleAsync(Guid id, Guid accountId) | ||||
|     /// <summary> | ||||
|     /// The most efficent way to delete file data (stored files) in batch. | ||||
|     /// But this DO NOT check the storage id, so use with caution! | ||||
|     /// </summary> | ||||
|     /// <param name="files">Files to delete</param> | ||||
|     /// <exception cref="InvalidOperationException">Something went wrong</exception> | ||||
|     public async Task DeleteFileDataBatchAsync(List<CloudFile> files) | ||||
|     { | ||||
|         files = files.Where(f => f.PoolId.HasValue).ToList(); | ||||
|  | ||||
|         foreach (var fileGroup in files.GroupBy(f => f.PoolId!.Value)) | ||||
|         { | ||||
|             // If any other file with the same storage ID is referenced, don't delete the actual file data | ||||
|             var dest = await GetRemoteStorageConfig(fileGroup.Key); | ||||
|             if (dest is null) | ||||
|                 throw new InvalidOperationException($"No remote storage configured for pool {fileGroup.Key}"); | ||||
|             var client = CreateMinioClient(dest); | ||||
|             if (client is null) | ||||
|                 throw new InvalidOperationException( | ||||
|                     $"Failed to configure client for remote destination '{fileGroup.Key}'" | ||||
|                 ); | ||||
|  | ||||
|             List<string> objectsToDelete = []; | ||||
|  | ||||
|             foreach (var file in fileGroup) | ||||
|             { | ||||
|                 objectsToDelete.Add(file.StorageId ?? file.Id); | ||||
|                 if (file.HasCompression) objectsToDelete.Add(file.StorageId ?? file.Id + ".compressed"); | ||||
|                 if (file.HasThumbnail) objectsToDelete.Add(file.StorageId ?? file.Id + ".thumbnail"); | ||||
|             } | ||||
|  | ||||
|             await client.RemoveObjectsAsync( | ||||
|                 new RemoveObjectsArgs().WithBucket(dest.Bucket).WithObjects(objectsToDelete) | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private async Task<FileBundle?> GetBundleAsync(Guid id, Guid accountId) | ||||
|     { | ||||
|         var bundle = await db.Bundles | ||||
|             .Where(e => e.Id == id) | ||||
|   | ||||
| @@ -10,9 +10,7 @@ | ||||
|   }, | ||||
|   "AllowedHosts": "*", | ||||
|   "ConnectionStrings": { | ||||
|     "App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60", | ||||
|     "FastRetrieve": "localhost:6379", | ||||
|     "Etcd": "etcd.orb.local:2379" | ||||
|     "App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60" | ||||
|   }, | ||||
|   "Authentication": { | ||||
|     "Schemes": { | ||||
| @@ -130,8 +128,6 @@ | ||||
|   ], | ||||
|   "Service": { | ||||
|     "Name": "DysonNetwork.Drive", | ||||
|     "Url": "https://localhost:7092", | ||||
|     "ClientCert": "../Certificates/client.crt", | ||||
|     "ClientKey": "../Certificates/client.key" | ||||
|     "Url": "https://localhost:7092" | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,78 +0,0 @@ | ||||
| using System.Text; | ||||
| using dotnet_etcd.interfaces; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Yarp.ReverseProxy.Configuration; | ||||
|  | ||||
| namespace DysonNetwork.Gateway.Controllers; | ||||
|  | ||||
| [ApiController] | ||||
| [Route("/.well-known")] | ||||
| public class WellKnownController( | ||||
|     IConfiguration configuration, | ||||
|     IProxyConfigProvider proxyConfigProvider, | ||||
|     IEtcdClient etcdClient) | ||||
|     : ControllerBase | ||||
| { | ||||
|     [HttpGet("domains")] | ||||
|     public IActionResult GetDomainMappings() | ||||
|     { | ||||
|         var domainMappings = configuration.GetSection("DomainMappings").GetChildren() | ||||
|             .ToDictionary(x => x.Key, x => x.Value); | ||||
|         return Ok(domainMappings); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("services")] | ||||
|     public IActionResult GetServices() | ||||
|     { | ||||
|         var local = configuration.GetValue<bool>("LocalMode"); | ||||
|         var response = etcdClient.GetRange("/services/"); | ||||
|         var kvs = response.Kvs; | ||||
|  | ||||
|         var serviceMap = kvs.ToDictionary( | ||||
|             kv => Encoding.UTF8.GetString(kv.Key.ToByteArray()).Replace("/services/", ""), | ||||
|             kv => Encoding.UTF8.GetString(kv.Value.ToByteArray()) | ||||
|         ); | ||||
|  | ||||
|         if (local) return Ok(serviceMap); | ||||
|          | ||||
|         var domainMappings = configuration.GetSection("DomainMappings").GetChildren() | ||||
|             .ToDictionary(x => x.Key, x => x.Value); | ||||
|         foreach (var (key, _) in serviceMap.ToList()) | ||||
|         { | ||||
|             if (!domainMappings.TryGetValue(key, out var domain)) continue; | ||||
|             if (domain is not null) | ||||
|                 serviceMap[key] = "https://" + domain; | ||||
|         } | ||||
|  | ||||
|         return Ok(serviceMap); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("routes")] | ||||
|     public IActionResult GetProxyRules() | ||||
|     { | ||||
|         var config = proxyConfigProvider.GetConfig(); | ||||
|         var rules = config.Routes.Select(r => new | ||||
|         { | ||||
|             r.RouteId, | ||||
|             r.ClusterId, | ||||
|             Match = new | ||||
|             { | ||||
|                 r.Match.Path, | ||||
|                 Hosts = r.Match.Hosts != null ? string.Join(", ", r.Match.Hosts) : null | ||||
|             }, | ||||
|             Transforms = r.Transforms?.Select(t => t.Select(kv => $"{kv.Key}: {kv.Value}").ToList()) | ||||
|         }).ToList(); | ||||
|  | ||||
|         var clusters = config.Clusters.Select(c => new | ||||
|         { | ||||
|             c.ClusterId, | ||||
|             Destinations = c.Destinations?.Select(d => new | ||||
|             { | ||||
|                 d.Key, | ||||
|                 d.Value.Address | ||||
|             }).ToList() | ||||
|         }).ToList(); | ||||
|  | ||||
|         return Ok(new { Rules = rules, Clusters = clusters }); | ||||
|     } | ||||
| } | ||||
| @@ -1,23 +0,0 @@ | ||||
| FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base | ||||
| USER $APP_UID | ||||
| WORKDIR /app | ||||
| EXPOSE 8080 | ||||
| EXPOSE 8081 | ||||
|  | ||||
| FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build | ||||
| ARG BUILD_CONFIGURATION=Release | ||||
| WORKDIR /src | ||||
| COPY ["DysonNetwork.Gateway/DysonNetwork.Gateway.csproj", "DysonNetwork.Gateway/"] | ||||
| RUN dotnet restore "DysonNetwork.Gateway/DysonNetwork.Gateway.csproj" | ||||
| COPY . . | ||||
| WORKDIR "/src/DysonNetwork.Gateway" | ||||
| RUN dotnet build "./DysonNetwork.Gateway.csproj" -c $BUILD_CONFIGURATION -o /app/build | ||||
|  | ||||
| FROM build AS publish | ||||
| ARG BUILD_CONFIGURATION=Release | ||||
| RUN dotnet publish "./DysonNetwork.Gateway.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false | ||||
|  | ||||
| FROM base AS final | ||||
| WORKDIR /app | ||||
| COPY --from=publish /app/publish . | ||||
| ENTRYPOINT ["dotnet", "DysonNetwork.Gateway.dll"] | ||||
| @@ -1,23 +0,0 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk.Web"> | ||||
|  | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net9.0</TargetFramework> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|   </PropertyGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="dotnet-etcd" Version="8.0.1" /> | ||||
|     <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" /> | ||||
|     <PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115"> | ||||
|       <PrivateAssets>all</PrivateAssets> | ||||
|       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||
|     </PackageReference> | ||||
|     <PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
| </Project> | ||||
| @@ -1,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 Nick { get; set; } = string.Empty; | ||||
|     [MaxLength(32)] public string Language { get; set; } = string.Empty; | ||||
|     [MaxLength(32)] public string Region { get; set; } = string.Empty; | ||||
|     public Instant? ActivatedAt { get; set; } | ||||
|     public bool IsSuperuser { get; set; } = false; | ||||
|  | ||||
|     // The ID is the BotAccount ID in the DysonNetwork.Develop | ||||
|     public Guid? AutomatedId { get; set; } | ||||
|  | ||||
|     public AccountProfile Profile { get; set; } = null!; | ||||
|     public ICollection<AccountContact> Contacts { get; set; } = new List<AccountContact>(); | ||||
|     public ICollection<AccountBadge> Badges { get; set; } = new List<AccountBadge>(); | ||||
| @@ -43,12 +47,14 @@ public class Account : ModelBase | ||||
|             Name = Name, | ||||
|             Nick = Nick, | ||||
|             Language = Language, | ||||
|             Region = Region, | ||||
|             ActivatedAt = ActivatedAt?.ToTimestamp(), | ||||
|             IsSuperuser = IsSuperuser, | ||||
|             Profile = Profile.ToProtoValue(), | ||||
|             PerkSubscription = PerkSubscription?.ToProtoValue(), | ||||
|             CreatedAt = CreatedAt.ToTimestamp(), | ||||
|             UpdatedAt = UpdatedAt.ToTimestamp() | ||||
|             UpdatedAt = UpdatedAt.ToTimestamp(), | ||||
|             AutomatedId = AutomatedId?.ToString() | ||||
|         }; | ||||
|  | ||||
|         // Add contacts | ||||
| @@ -71,6 +77,7 @@ public class Account : ModelBase | ||||
|             Name = proto.Name, | ||||
|             Nick = proto.Nick, | ||||
|             Language = proto.Language, | ||||
|             Region = proto.Region, | ||||
|             ActivatedAt = proto.ActivatedAt?.ToInstant(), | ||||
|             IsSuperuser = proto.IsSuperuser, | ||||
|             PerkSubscription = proto.PerkSubscription is not null | ||||
| @@ -78,10 +85,10 @@ public class Account : ModelBase | ||||
|                 : null, | ||||
|             CreatedAt = proto.CreatedAt.ToInstant(), | ||||
|             UpdatedAt = proto.UpdatedAt.ToInstant(), | ||||
|             AutomatedId = proto.AutomatedId is not null ? Guid.Parse(proto.AutomatedId) : null, | ||||
|             Profile = AccountProfile.FromProtoValue(proto.Profile) | ||||
|         }; | ||||
|  | ||||
|         account.Profile = AccountProfile.FromProtoValue(proto.Profile); | ||||
|  | ||||
|         foreach (var contactProto in proto.Contacts) | ||||
|             account.Contacts.Add(AccountContact.FromProtoValue(contactProto)); | ||||
|  | ||||
| @@ -116,7 +123,7 @@ public abstract class Leveling | ||||
|  | ||||
| public class AccountProfile : ModelBase, IIdentifiedResource | ||||
| { | ||||
|     public Guid Id { get; set; } | ||||
|     public Guid Id { get; set; } = Guid.NewGuid(); | ||||
|     [MaxLength(256)] public string? FirstName { get; set; } | ||||
|     [MaxLength(256)] public string? MiddleName { get; set; } | ||||
|     [MaxLength(256)] public string? LastName { get; set; } | ||||
| @@ -132,9 +139,20 @@ public class AccountProfile : ModelBase, IIdentifiedResource | ||||
|     [Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; } | ||||
|     [Column(TypeName = "jsonb")] public BadgeReferenceObject? ActiveBadge { get; set; } | ||||
|  | ||||
|     public int Experience { get; set; } = 0; | ||||
|     public int Experience { get; set; } | ||||
|     [NotMapped] public int Level => Leveling.ExperiencePerLevel.Count(xp => Experience >= xp) - 1; | ||||
|  | ||||
|     public double SocialCredits { get; set; } = 100; | ||||
|  | ||||
|     [NotMapped] | ||||
|     public int SocialCreditsLevel => SocialCredits switch | ||||
|     { | ||||
|         < 100 => -1, | ||||
|         > 100 and < 200 => 0, | ||||
|         < 200 => 1, | ||||
|         _ => 2 | ||||
|     }; | ||||
|  | ||||
|     [NotMapped] | ||||
|     public double LevelingProgress => Level >= Leveling.ExperiencePerLevel.Count - 1 | ||||
|         ? 100 | ||||
| @@ -165,6 +183,8 @@ public class AccountProfile : ModelBase, IIdentifiedResource | ||||
|             Experience = Experience, | ||||
|             Level = Level, | ||||
|             LevelingProgress = LevelingProgress, | ||||
|             SocialCredits = SocialCredits, | ||||
|             SocialCreditsLevel = SocialCreditsLevel, | ||||
|             Picture = Picture?.ToProtoValue(), | ||||
|             Background = Background?.ToProtoValue(), | ||||
|             AccountId = AccountId.ToString(), | ||||
| @@ -195,6 +215,7 @@ public class AccountProfile : ModelBase, IIdentifiedResource | ||||
|             Verification = proto.Verification is null ? null : VerificationMark.FromProtoValue(proto.Verification), | ||||
|             ActiveBadge = proto.ActiveBadge is null ? null : BadgeReferenceObject.FromProtoValue(proto.ActiveBadge), | ||||
|             Experience = proto.Experience, | ||||
|             SocialCredits = proto.SocialCredits, | ||||
|             Picture = proto.Picture is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Picture), | ||||
|             Background = proto.Background is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Background), | ||||
|             AccountId = Guid.Parse(proto.AccountId), | ||||
|   | ||||
| @@ -1,6 +1,9 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using DysonNetwork.Pass.Auth; | ||||
| using DysonNetwork.Pass.Credit; | ||||
| using DysonNetwork.Pass.Wallet; | ||||
| using DysonNetwork.Shared.Error; | ||||
| using DysonNetwork.Shared.GeoIp; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime; | ||||
| @@ -14,7 +17,9 @@ public class AccountController( | ||||
|     AuthService auth, | ||||
|     AccountService accounts, | ||||
|     SubscriptionService subscriptions, | ||||
|     AccountEventService events | ||||
|     AccountEventService events, | ||||
|     SocialCreditService socialCreditService, | ||||
|     GeoIpService geo | ||||
| ) : ControllerBase | ||||
| { | ||||
|     [HttpGet("{name}")] | ||||
| @@ -28,7 +33,7 @@ public class AccountController( | ||||
|             .Include(e => e.Contacts.Where(c => c.IsPublic)) | ||||
|             .Where(a => a.Name == name) | ||||
|             .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); | ||||
|         account.PerkSubscription = perk?.ToReference(); | ||||
| @@ -45,7 +50,28 @@ public class AccountController( | ||||
|             .Include(e => e.Badges) | ||||
|             .Where(a => a.Name == name) | ||||
|             .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 | ||||
| @@ -71,7 +97,7 @@ public class AccountController( | ||||
|         [MaxLength(128)] | ||||
|         public string Password { get; set; } = string.Empty; | ||||
|  | ||||
|         [MaxLength(128)] public string Language { get; set; } = "en-us"; | ||||
|         [MaxLength(32)] public string Language { get; set; } = "en-us"; | ||||
|  | ||||
|         [Required] public string CaptchaToken { get; set; } = string.Empty; | ||||
|     } | ||||
| @@ -81,7 +107,15 @@ public class AccountController( | ||||
|     [ProducesResponseType(StatusCodes.Status400BadRequest)] | ||||
|     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 | ||||
|         { | ||||
| @@ -90,13 +124,21 @@ public class AccountController( | ||||
|                 request.Nick, | ||||
|                 request.Email, | ||||
|                 request.Password, | ||||
|                 request.Language | ||||
|                 request.Language, | ||||
|                 region | ||||
|             ); | ||||
|             return Ok(account); | ||||
|         } | ||||
|         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")] | ||||
|     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); | ||||
|         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 | ||||
|         { | ||||
| @@ -120,7 +174,13 @@ public class AccountController( | ||||
|         } | ||||
|         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(); | ||||
| @@ -131,7 +191,9 @@ public class AccountController( | ||||
|         public StatusAttitude Attitude { get; set; } | ||||
|         public bool IsInvisible { get; set; } | ||||
|         public bool IsNotDisturb { get; set; } | ||||
|         public bool IsAutomated { get; set; } = false; | ||||
|         [MaxLength(1024)] public string? Label { get; set; } | ||||
|         [MaxLength(4096)] public string? AppIdentifier { get; set; } | ||||
|         public Instant? ClearedAt { get; set; } | ||||
|     } | ||||
|  | ||||
| @@ -139,7 +201,15 @@ public class AccountController( | ||||
|     public async Task<ActionResult<Status>> GetOtherStatus(string 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); | ||||
|         status.IsInvisible = false; // Keep the invisible field not available for other users | ||||
|         return Ok(status); | ||||
| @@ -156,11 +226,27 @@ public class AccountController( | ||||
|         month ??= currentDate.Month; | ||||
|         year ??= currentDate.Year; | ||||
|  | ||||
|         if (month is < 1 or > 12) return BadRequest("Invalid month."); | ||||
|         if (year < 1) return BadRequest("Invalid year."); | ||||
|         if (month is < 1 or > 12) | ||||
|             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); | ||||
|         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); | ||||
|         return Ok(calendar); | ||||
|   | ||||
| @@ -3,6 +3,7 @@ using DysonNetwork.Pass.Auth; | ||||
| using DysonNetwork.Pass.Permission; | ||||
| using DysonNetwork.Pass.Wallet; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using DysonNetwork.Shared.Error; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| @@ -23,11 +24,13 @@ public class AccountCurrentController( | ||||
|     AccountEventService events, | ||||
|     AuthService auth, | ||||
|     FileService.FileServiceClient files, | ||||
|     FileReferenceService.FileReferenceServiceClient fileRefs | ||||
|     FileReferenceService.FileReferenceServiceClient fileRefs, | ||||
|     Credit.SocialCreditService creditService | ||||
| ) : ControllerBase | ||||
| { | ||||
|     [HttpGet] | ||||
|     [ProducesResponseType<Account>(StatusCodes.Status200OK)] | ||||
|     [ProducesResponseType<ApiError>(StatusCodes.Status401Unauthorized)] | ||||
|     public async Task<ActionResult<Account>> GetCurrentIdentity() | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||
| @@ -49,6 +52,7 @@ public class AccountCurrentController( | ||||
|     { | ||||
|         [MaxLength(256)] public string? Nick { get; set; } | ||||
|         [MaxLength(32)] public string? Language { get; set; } | ||||
|         [MaxLength(32)] public string? Region { get; set; } | ||||
|     } | ||||
|  | ||||
|     [HttpPatch] | ||||
| @@ -60,6 +64,7 @@ public class AccountCurrentController( | ||||
|  | ||||
|         if (request.Nick is not null) account.Nick = request.Nick; | ||||
|         if (request.Language is not null) account.Language = request.Language; | ||||
|         if (request.Region is not null) account.Region = request.Region; | ||||
|  | ||||
|         await db.SaveChangesAsync(); | ||||
|         await accounts.PurgeAccountCache(currentUser); | ||||
| @@ -92,7 +97,14 @@ public class AccountCurrentController( | ||||
|         var profile = await db.AccountProfiles | ||||
|             .Where(p => p.Account.Id == userId) | ||||
|             .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.MiddleName is not null) profile.MiddleName = request.MiddleName; | ||||
| @@ -160,7 +172,13 @@ public class AccountCurrentController( | ||||
|         } | ||||
|         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(); | ||||
| @@ -179,6 +197,8 @@ public class AccountCurrentController( | ||||
|     public async Task<ActionResult<Status>> UpdateStatus([FromBody] AccountController.StatusRequest request) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||
|         if (request is { IsAutomated: true, AppIdentifier: not null }) | ||||
|             return BadRequest("Automated status cannot be updated."); | ||||
|  | ||||
|         var now = SystemClock.Instance.GetCurrentInstant(); | ||||
|         var status = await db.AccountStatuses | ||||
| @@ -186,12 +206,16 @@ public class AccountCurrentController( | ||||
|             .Where(e => e.ClearedAt == null || e.ClearedAt > now) | ||||
|             .OrderByDescending(e => e.CreatedAt) | ||||
|             .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.IsInvisible = request.IsInvisible; | ||||
|         status.IsNotDisturb = request.IsNotDisturb; | ||||
|         status.IsAutomated = request.IsAutomated; | ||||
|         status.Label = request.Label; | ||||
|         status.AppIdentifier = request.AppIdentifier; | ||||
|         status.ClearedAt = request.ClearedAt; | ||||
|  | ||||
|         db.Update(status); | ||||
| @@ -207,13 +231,44 @@ public class AccountCurrentController( | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||
|  | ||||
|         if (request is { IsAutomated: true, AppIdentifier: not null }) | ||||
|         { | ||||
|             var now = SystemClock.Instance.GetCurrentInstant(); | ||||
|             var existingStatus = await db.AccountStatuses | ||||
|                 .Where(s => s.AccountId == currentUser.Id) | ||||
|                 .Where(s => s.ClearedAt == null || s.ClearedAt > now) | ||||
|                 .OrderByDescending(s => s.CreatedAt) | ||||
|                 .FirstOrDefaultAsync(); | ||||
|             if (existingStatus is not null && existingStatus.IsAutomated) | ||||
|                 if (existingStatus.IsAutomated && request.AppIdentifier == existingStatus.AppIdentifier) | ||||
|                 { | ||||
|                     existingStatus.Attitude = request.Attitude; | ||||
|                     existingStatus.IsInvisible = request.IsInvisible; | ||||
|                     existingStatus.IsNotDisturb = request.IsNotDisturb; | ||||
|                     existingStatus.Label = request.Label; | ||||
|                     db.Update(existingStatus); | ||||
|                     await db.SaveChangesAsync(); | ||||
|                     return Ok(existingStatus); | ||||
|                 } | ||||
|                 else | ||||
|                 { | ||||
|                     existingStatus.ClearedAt = now; | ||||
|                     db.Update(existingStatus); | ||||
|                     await db.SaveChangesAsync(); | ||||
|                 } | ||||
|             else if (existingStatus is not null) | ||||
|                 return Ok(existingStatus); // Do not override manually set status with automated ones | ||||
|         } | ||||
|  | ||||
|         var status = new Status | ||||
|         { | ||||
|             AccountId = currentUser.Id, | ||||
|             Attitude = request.Attitude, | ||||
|             IsInvisible = request.IsInvisible, | ||||
|             IsNotDisturb = request.IsNotDisturb, | ||||
|             IsAutomated = request.IsAutomated, | ||||
|             Label = request.Label, | ||||
|             AppIdentifier = request.AppIdentifier, | ||||
|             ClearedAt = request.ClearedAt | ||||
|         }; | ||||
|  | ||||
| @@ -221,15 +276,21 @@ public class AccountCurrentController( | ||||
|     } | ||||
|  | ||||
|     [HttpDelete("statuses")] | ||||
|     public async Task<ActionResult> DeleteStatus() | ||||
|     public async Task<ActionResult> DeleteStatus([FromQuery] string? app) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||
|  | ||||
|         var now = SystemClock.Instance.GetCurrentInstant(); | ||||
|         var status = await db.AccountStatuses | ||||
|         var queryable = db.AccountStatuses | ||||
|             .Where(s => s.AccountId == currentUser.Id) | ||||
|             .Where(s => s.ClearedAt == null || s.ClearedAt > now) | ||||
|             .OrderByDescending(s => s.CreatedAt) | ||||
|             .AsQueryable(); | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(app)) | ||||
|             queryable = queryable.Where(s => s.IsAutomated && s.AppIdentifier == app); | ||||
|  | ||||
|         var status = await queryable | ||||
|             .FirstOrDefaultAsync(); | ||||
|         if (status is null) return NotFound(); | ||||
|  | ||||
| @@ -254,7 +315,9 @@ public class AccountCurrentController( | ||||
|             .OrderByDescending(x => x.CreatedAt) | ||||
|             .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")] | ||||
| @@ -269,15 +332,30 @@ public class AccountCurrentController( | ||||
|         { | ||||
|             var isAvailable = await events.CheckInDailyIsAvailable(currentUser); | ||||
|             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 | ||||
|         { | ||||
|             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); | ||||
|             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 | ||||
| @@ -286,15 +364,32 @@ public class AccountCurrentController( | ||||
|             return needsCaptcha switch | ||||
|             { | ||||
|                 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) | ||||
|             }; | ||||
|         } | ||||
|         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; | ||||
|         year ??= currentDate.Year; | ||||
|  | ||||
|         if (month is < 1 or > 12) return BadRequest("Invalid month."); | ||||
|         if (year < 1) return BadRequest("Invalid year."); | ||||
|         if (month is < 1 or > 12) | ||||
|             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); | ||||
|         return Ok(calendar); | ||||
| @@ -365,7 +468,13 @@ public class AccountCurrentController( | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||
|         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); | ||||
|         return Ok(factor); | ||||
| @@ -380,7 +489,7 @@ public class AccountCurrentController( | ||||
|         var factor = await db.AccountAuthFactors | ||||
|             .Where(f => f.AccountId == currentUser.Id && f.Id == id) | ||||
|             .FirstOrDefaultAsync(); | ||||
|         if (factor is null) return NotFound(); | ||||
|         if (factor is null) return NotFound(ApiError.NotFound(id.ToString(), traceId: HttpContext.TraceIdentifier)); | ||||
|  | ||||
|         try | ||||
|         { | ||||
| @@ -389,7 +498,14 @@ public class AccountCurrentController( | ||||
|         } | ||||
|         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); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [HttpGet("leveling")] | ||||
|     [Authorize] | ||||
|     public async Task<ActionResult<ExperienceRecord>> GetLevelingHistory( | ||||
|         [FromQuery] int take = 20, | ||||
|         [FromQuery] int offset = 0 | ||||
|     ) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||
|  | ||||
|         var queryable = db.ExperienceRecords | ||||
|             .Where(r => r.AccountId == currentUser.Id) | ||||
|             .OrderByDescending(r => r.CreatedAt) | ||||
|             .AsQueryable(); | ||||
|  | ||||
|         var totalCount = await queryable.CountAsync(); | ||||
|         Response.Headers["X-Total"] = totalCount.ToString(); | ||||
|  | ||||
|         var records = await queryable | ||||
|             .Skip(offset) | ||||
|             .Take(take) | ||||
|             .ToListAsync(); | ||||
|         return Ok(records); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("credits")] | ||||
|     public async Task<ActionResult<bool>> GetSocialCredit() | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||
|  | ||||
|         var credit = await creditService.GetSocialCredit(currentUser.Id); | ||||
|         return Ok(credit); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("credits/history")] | ||||
|     public async Task<ActionResult<SocialCreditRecord>> GetCreditHistory( | ||||
|         [FromQuery] int take = 20, | ||||
|         [FromQuery] int offset = 0 | ||||
|     ) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||
|  | ||||
|         var queryable = db.SocialCreditRecords | ||||
|             .Where(r => r.AccountId == currentUser.Id) | ||||
|             .OrderByDescending(r => r.CreatedAt) | ||||
|             .AsQueryable(); | ||||
|  | ||||
|         var totalCount = await queryable.CountAsync(); | ||||
|         Response.Headers["X-Total"] = totalCount.ToString(); | ||||
|  | ||||
|         var records = await queryable | ||||
|             .Skip(offset) | ||||
|             .Take(take) | ||||
|             .ToListAsync(); | ||||
|         return Ok(records); | ||||
|     } | ||||
| } | ||||
| @@ -14,8 +14,9 @@ public class AccountEventService( | ||||
|     Wallet.PaymentService payment, | ||||
|     ICacheService cache, | ||||
|     IStringLocalizer<Localization.AccountEventResource> localizer, | ||||
|     PusherService.PusherServiceClient pusher, | ||||
|     SubscriptionService subscriptions | ||||
|     RingService.RingServiceClient pusher, | ||||
|     SubscriptionService subscriptions, | ||||
|     Pass.Leveling.ExperienceService experienceService | ||||
| ) | ||||
| { | ||||
|     private static readonly Random Random = new(); | ||||
| @@ -327,13 +328,15 @@ public class AccountEventService( | ||||
|             result.RewardPoints = null; | ||||
|         } | ||||
|  | ||||
|         await db.AccountProfiles | ||||
|             .Where(p => p.AccountId == user.Id) | ||||
|             .ExecuteUpdateAsync(s => | ||||
|                 s.SetProperty(b => b.Experience, b => b.Experience + result.RewardExperience) | ||||
|             ); | ||||
|         db.AccountCheckInResults.Add(result); | ||||
|         await db.SaveChangesAsync(); // Don't forget to save changes to the database | ||||
|         await db.SaveChangesAsync(); // Remember to save changes to the database | ||||
|         if (result.RewardExperience is not null) | ||||
|             await experienceService.AddRecord( | ||||
|                 "check-in", | ||||
|                 $"Check-in reward on {now:yyyy/MM/dd}", | ||||
|                 result.RewardExperience.Value, | ||||
|                 user.Id | ||||
|             ); | ||||
|  | ||||
|         // The lock will be automatically released by the await using statement | ||||
|         return result; | ||||
|   | ||||
| @@ -1,28 +1,39 @@ | ||||
| using System.Globalization; | ||||
| using System.Text.Json; | ||||
| using DysonNetwork.Pass.Auth; | ||||
| using DysonNetwork.Pass.Auth.OpenId; | ||||
| using DysonNetwork.Pass.Email; | ||||
| using DysonNetwork.Pass.Localization; | ||||
| using DysonNetwork.Pass.Permission; | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using DysonNetwork.Shared.Stream; | ||||
| using EFCore.BulkExtensions; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.Extensions.Localization; | ||||
| using NATS.Client.Core; | ||||
| using NATS.Client.JetStream; | ||||
| using NATS.Net; | ||||
| using NodaTime; | ||||
| using OtpNet; | ||||
| using AuthService = DysonNetwork.Pass.Auth.AuthService; | ||||
|  | ||||
| namespace DysonNetwork.Pass.Account; | ||||
|  | ||||
| public class AccountService( | ||||
|     AppDatabase db, | ||||
|     MagicSpellService spells, | ||||
|     FileService.FileServiceClient files, | ||||
|     FileReferenceService.FileReferenceServiceClient fileRefs, | ||||
|     AccountUsernameService uname, | ||||
|     EmailService mailer, | ||||
|     PusherService.PusherServiceClient pusher, | ||||
|     RingService.RingServiceClient pusher, | ||||
|     IStringLocalizer<NotificationResource> localizer, | ||||
|     IStringLocalizer<EmailResource> emailLocalizer, | ||||
|     ICacheService cache, | ||||
|     ILogger<AccountService> logger | ||||
|     ILogger<AccountService> logger, | ||||
|     INatsConnection nats | ||||
| ) | ||||
| { | ||||
|     public static void SetCultureInfo(Account account) | ||||
| @@ -79,6 +90,7 @@ public class AccountService( | ||||
|         string email, | ||||
|         string? password, | ||||
|         string language = "en-US", | ||||
|         string region = "en", | ||||
|         bool isEmailVerified = false, | ||||
|         bool isActivated = false | ||||
|     ) | ||||
| @@ -98,6 +110,7 @@ public class AccountService( | ||||
|             Name = name, | ||||
|             Nick = nick, | ||||
|             Language = language, | ||||
|             Region = region, | ||||
|             Contacts = new List<AccountContact> | ||||
|             { | ||||
|                 new() | ||||
| @@ -172,11 +185,66 @@ public class AccountService( | ||||
|             userInfo.Email, | ||||
|             null, | ||||
|             "en-US", | ||||
|             "en", | ||||
|             userInfo.EmailVerified, | ||||
|             userInfo.EmailVerified | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     public async Task<Account> CreateBotAccount(Account account, Guid automatedId, string? pictureId, | ||||
|         string? backgroundId) | ||||
|     { | ||||
|         var dupeAutomateCount = await db.Accounts.Where(a => a.AutomatedId == automatedId).CountAsync(); | ||||
|         if (dupeAutomateCount > 0) | ||||
|             throw new InvalidOperationException("Automated ID has already been used."); | ||||
|  | ||||
|         var dupeNameCount = await db.Accounts.Where(a => a.Name == account.Name).CountAsync(); | ||||
|         if (dupeNameCount > 0) | ||||
|             throw new InvalidOperationException("Account name has already been taken."); | ||||
|  | ||||
|         account.AutomatedId = automatedId; | ||||
|         account.ActivatedAt = SystemClock.Instance.GetCurrentInstant(); | ||||
|         account.IsSuperuser = false; | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(pictureId)) | ||||
|         { | ||||
|             var file = await files.GetFileAsync(new GetFileRequest { Id = pictureId }); | ||||
|             await fileRefs.CreateReferenceAsync( | ||||
|                 new CreateReferenceRequest | ||||
|                 { | ||||
|                     ResourceId = account.Profile.ResourceIdentifier, | ||||
|                     FileId = pictureId, | ||||
|                     Usage = "profile.picture" | ||||
|                 } | ||||
|             ); | ||||
|             account.Profile.Picture = CloudFileReferenceObject.FromProtoValue(file); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(backgroundId)) | ||||
|         { | ||||
|             var file = await files.GetFileAsync(new GetFileRequest { Id = backgroundId }); | ||||
|             await fileRefs.CreateReferenceAsync( | ||||
|                 new CreateReferenceRequest | ||||
|                 { | ||||
|                     ResourceId = account.Profile.ResourceIdentifier, | ||||
|                     FileId = backgroundId, | ||||
|                     Usage = "profile.background" | ||||
|                 } | ||||
|             ); | ||||
|             account.Profile.Background = CloudFileReferenceObject.FromProtoValue(file); | ||||
|         } | ||||
|  | ||||
|         db.Accounts.Add(account); | ||||
|         await db.SaveChangesAsync(); | ||||
|  | ||||
|         return account; | ||||
|     } | ||||
|  | ||||
|     public async Task<Account?> GetBotAccount(Guid automatedId) | ||||
|     { | ||||
|         return await db.Accounts.FirstOrDefaultAsync(a => a.AutomatedId == automatedId); | ||||
|     } | ||||
|  | ||||
|     public async Task RequestAccountDeletion(Account account) | ||||
|     { | ||||
|         var spell = await spells.CreateMagicSpell( | ||||
| @@ -335,12 +403,10 @@ public class AccountService( | ||||
|  | ||||
|     /// <summary> | ||||
|     /// 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> | ||||
|     /// <param name="account">The owner of the auth factor</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, string? hint = null) | ||||
|     public async Task SendFactorCode(Account account, AccountAuthFactor factor) | ||||
|     { | ||||
|         var code = new Random().Next(100000, 999999).ToString("000000"); | ||||
|  | ||||
| @@ -369,30 +435,18 @@ public class AccountService( | ||||
|                 if (await _GetFactorCode(factor) is not null) | ||||
|                     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 | ||||
|                     .Where(c => c.Type == AccountContactType.Email) | ||||
|                     .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) | ||||
|                     .FirstOrDefaultAsync(); | ||||
|                 if (contact is null) | ||||
|                 { | ||||
|                     logger.LogWarning( | ||||
|                         "Unable to send factor code to #{FactorId} with hint {Hint}, due to no contact method found according to hint...", | ||||
|                         factor.Id, | ||||
|                         hint | ||||
|                         "Unable to send factor code to #{FactorId} with, due to no contact method was found...", | ||||
|                         factor.Id | ||||
|                     ); | ||||
|                     return; | ||||
|                 } | ||||
| @@ -401,7 +455,7 @@ public class AccountService( | ||||
|                     .SendTemplatedEmailAsync<Pages.Emails.VerificationEmail, VerificationEmailModel>( | ||||
|                         account.Nick, | ||||
|                         contact.Content, | ||||
|                         localizer["VerificationEmail"], | ||||
|                         emailLocalizer["VerificationEmail"], | ||||
|                         new VerificationEmailModel | ||||
|                         { | ||||
|                             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 | ||||
|             .Include(s => s.Challenge) | ||||
| @@ -464,8 +518,7 @@ public class AccountService( | ||||
|  | ||||
|     public async Task<AuthClient> UpdateDeviceName(Account account, string deviceId, string label) | ||||
|     { | ||||
|         var device = await db.AuthClients.FirstOrDefaultAsync( | ||||
|             c => c.DeviceId == deviceId && c.AccountId == account.Id | ||||
|         var device = await db.AuthClients.FirstOrDefaultAsync(c => c.DeviceId == deviceId && c.AccountId == account.Id | ||||
|         ); | ||||
|         if (device is null) throw new InvalidOperationException("Device was not found."); | ||||
|  | ||||
| @@ -485,57 +538,51 @@ public class AccountService( | ||||
|             .FirstOrDefaultAsync(); | ||||
|         if (session is null) throw new InvalidOperationException("Session was not found."); | ||||
|  | ||||
|         var sessions = await db.AuthSessions | ||||
|             .Include(s => s.Challenge) | ||||
|             .Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId) | ||||
|             .ToListAsync(); | ||||
|         // The current session should be included in the sessions' list | ||||
|         db.AuthSessions.Remove(session); | ||||
|         await db.SaveChangesAsync(); | ||||
|  | ||||
|         if (session.Challenge.ClientId.HasValue) | ||||
|         { | ||||
|             if (!await IsDeviceActive(session.Challenge.ClientId.Value)) | ||||
|                 await pusher.UnsubscribePushNotificationsAsync(new UnsubscribePushNotificationsRequest() | ||||
|                 { DeviceId = session.Challenge.Client!.DeviceId } | ||||
|                     { DeviceId = session.Challenge.Client!.DeviceId } | ||||
|                 ); | ||||
|         } | ||||
|  | ||||
|         // The current session should be included in the sessions' list | ||||
|         await db.AuthSessions | ||||
|             .Include(s => s.Challenge) | ||||
|             .Where(s => s.Challenge.DeviceId == session.Challenge.DeviceId) | ||||
|             .ExecuteDeleteAsync(); | ||||
|         logger.LogInformation("Deleted session #{SessionId}", session.Id); | ||||
|  | ||||
|         foreach (var item in sessions) | ||||
|             await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}"); | ||||
|         await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{session.Id}"); | ||||
|     } | ||||
|  | ||||
|     public async Task DeleteDevice(Account account, string deviceId) | ||||
|     { | ||||
|         var device = await db.AuthClients.FirstOrDefaultAsync( | ||||
|             c => c.DeviceId == deviceId && c.AccountId == account.Id | ||||
|         var device = await db.AuthClients.FirstOrDefaultAsync(c => c.DeviceId == deviceId && c.AccountId == account.Id | ||||
|         ); | ||||
|         if (device is null) | ||||
|             throw new InvalidOperationException("Device not found."); | ||||
|  | ||||
|         await pusher.UnsubscribePushNotificationsAsync( | ||||
|             new UnsubscribePushNotificationsRequest() { DeviceId = device.DeviceId } | ||||
|             new UnsubscribePushNotificationsRequest { DeviceId = device.DeviceId } | ||||
|         ); | ||||
|  | ||||
|         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); | ||||
|         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) | ||||
|             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) | ||||
| @@ -687,21 +734,23 @@ public class AccountService( | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// The maintenance method for server administrator. | ||||
|     /// To check every user has an account profile and to create them if it isn't having one. | ||||
|     /// </summary> | ||||
|     public async Task EnsureAccountProfileCreated() | ||||
|     public async Task DeleteAccount(Account account) | ||||
|     { | ||||
|         var accountsId = await db.Accounts.Select(a => a.Id).ToListAsync(); | ||||
|         var existingId = await db.AccountProfiles.Select(p => p.AccountId).ToListAsync(); | ||||
|         var missingId = accountsId.Except(existingId).ToList(); | ||||
|         await db.AuthSessions | ||||
|             .Where(s => s.AccountId == account.Id) | ||||
|             .ExecuteDeleteAsync(); | ||||
|  | ||||
|         if (missingId.Count != 0) | ||||
|         { | ||||
|             var newProfiles = missingId.Select(id => new AccountProfile { Id = Guid.NewGuid(), AccountId = id }) | ||||
|                 .ToList(); | ||||
|             await db.BulkInsertAsync(newProfiles); | ||||
|         } | ||||
|         db.Accounts.Remove(account); | ||||
|         await db.SaveChangesAsync(); | ||||
|  | ||||
|         var js = nats.CreateJetStreamContext(); | ||||
|         await js.PublishAsync( | ||||
|             AccountDeletedEvent.Type, | ||||
|             GrpcTypeHelper.ConvertObjectToByteString(new AccountDeletedEvent | ||||
|             { | ||||
|                 AccountId = account.Id, | ||||
|                 DeletedAt = SystemClock.Instance.GetCurrentInstant() | ||||
|             }).ToByteArray() | ||||
|         ); | ||||
|     } | ||||
| } | ||||
| @@ -9,6 +9,7 @@ namespace DysonNetwork.Pass.Account; | ||||
|  | ||||
| public class AccountServiceGrpc( | ||||
|     AppDatabase db, | ||||
|     AccountEventService accountEvents, | ||||
|     RelationshipService relationships, | ||||
|     SubscriptionService subscriptions, | ||||
|     IClock clock, | ||||
| @@ -41,6 +42,26 @@ public class AccountServiceGrpc( | ||||
|         return account.ToProtoValue(); | ||||
|     } | ||||
|  | ||||
|     public override async Task<Shared.Proto.Account> GetBotAccount(GetBotAccountRequest request, | ||||
|         ServerCallContext context) | ||||
|     { | ||||
|         if (!Guid.TryParse(request.AutomatedId, out var automatedId)) | ||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid automated ID format")); | ||||
|  | ||||
|         var account = await _db.Accounts | ||||
|             .AsNoTracking() | ||||
|             .Include(a => a.Profile) | ||||
|             .FirstOrDefaultAsync(a => a.AutomatedId == automatedId); | ||||
|  | ||||
|         if (account == null) | ||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Account with automated ID {request.AutomatedId} not found")); | ||||
|  | ||||
|         var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id); | ||||
|         account.PerkSubscription = perk?.ToReference(); | ||||
|  | ||||
|         return account.ToProtoValue(); | ||||
|     } | ||||
|  | ||||
|     public override async Task<GetAccountBatchResponse> GetAccountBatch(GetAccountBatchRequest request, | ||||
|         ServerCallContext context) | ||||
|     { | ||||
| @@ -68,6 +89,55 @@ public class AccountServiceGrpc( | ||||
|         return response; | ||||
|     } | ||||
|      | ||||
|      | ||||
|     public override async Task<GetAccountBatchResponse> GetBotAccountBatch(GetBotAccountBatchRequest request, | ||||
|         ServerCallContext context) | ||||
|     { | ||||
|         var automatedIds = request.AutomatedId | ||||
|             .Select(id => Guid.TryParse(id, out var automatedId) ? automatedId : (Guid?)null) | ||||
|             .Where(id => id.HasValue) | ||||
|             .Select(id => id!.Value) | ||||
|             .ToList(); | ||||
|  | ||||
|         var accounts = await _db.Accounts | ||||
|             .AsNoTracking() | ||||
|             .Where(a => a.AutomatedId != null && automatedIds.Contains(a.AutomatedId.Value)) | ||||
|             .Include(a => a.Profile) | ||||
|             .ToListAsync(); | ||||
|  | ||||
|         var perks = await subscriptions.GetPerkSubscriptionsAsync( | ||||
|             accounts.Select(x => x.Id).ToList() | ||||
|         ); | ||||
|         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<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, | ||||
|         ServerCallContext context) | ||||
|     { | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using System.ComponentModel.DataAnnotations.Schema; | ||||
| using System.Text.Json.Serialization; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using DysonNetwork.Shared.GeoIp; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using NodaTime.Serialization.Protobuf; | ||||
| using Point = NetTopologySuite.Geometries.Point; | ||||
| @@ -14,7 +16,7 @@ public class ActionLog : ModelBase | ||||
|     [Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new(); | ||||
|     [MaxLength(512)] public string? UserAgent { get; set; } | ||||
|     [MaxLength(128)] public string? IpAddress { get; set; } | ||||
|     public Point? Location { get; set; } | ||||
|     [Column(TypeName = "jsonb")] public GeoPoint? Location { get; set; } | ||||
|  | ||||
|     public Guid AccountId { get; set; } | ||||
|     public Account Account { get; set; } = null!; | ||||
|   | ||||
							
								
								
									
										218
									
								
								DysonNetwork.Pass/Account/BotAccountReceiverGrpc.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										218
									
								
								DysonNetwork.Pass/Account/BotAccountReceiverGrpc.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,218 @@ | ||||
| using DysonNetwork.Shared.Data; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using Grpc.Core; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime.Serialization.Protobuf; | ||||
| using ApiKey = DysonNetwork.Shared.Proto.ApiKey; | ||||
| using AuthService = DysonNetwork.Pass.Auth.AuthService; | ||||
|  | ||||
| namespace DysonNetwork.Pass.Account; | ||||
|  | ||||
| public class BotAccountReceiverGrpc( | ||||
|     AppDatabase db, | ||||
|     AccountService accounts, | ||||
|     FileService.FileServiceClient files, | ||||
|     FileReferenceService.FileReferenceServiceClient fileRefs, | ||||
|     AuthService authService | ||||
| ) | ||||
|     : BotAccountReceiverService.BotAccountReceiverServiceBase | ||||
| { | ||||
|     public override async Task<CreateBotAccountResponse> CreateBotAccount( | ||||
|         CreateBotAccountRequest request, | ||||
|         ServerCallContext context | ||||
|     ) | ||||
|     { | ||||
|         var account = Account.FromProtoValue(request.Account); | ||||
|         account = await accounts.CreateBotAccount( | ||||
|             account, | ||||
|             Guid.Parse(request.AutomatedId), | ||||
|             request.PictureId, | ||||
|             request.BackgroundId | ||||
|         ); | ||||
|  | ||||
|         return new CreateBotAccountResponse | ||||
|         { | ||||
|             Bot = new BotAccount | ||||
|             { | ||||
|                 Account = account.ToProtoValue(), | ||||
|                 AutomatedId = account.Id.ToString(), | ||||
|                 CreatedAt = account.CreatedAt.ToTimestamp(), | ||||
|                 UpdatedAt = account.UpdatedAt.ToTimestamp(), | ||||
|                 IsActive = true | ||||
|             } | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     public override async Task<UpdateBotAccountResponse> UpdateBotAccount( | ||||
|         UpdateBotAccountRequest request, | ||||
|         ServerCallContext context | ||||
|     ) | ||||
|     { | ||||
|         var account = Account.FromProtoValue(request.Account); | ||||
|  | ||||
|         if (request.PictureId is not null) | ||||
|         { | ||||
|             var file = await files.GetFileAsync(new GetFileRequest { Id = request.PictureId }); | ||||
|             if (account.Profile.Picture is not null) | ||||
|                 await fileRefs.DeleteResourceReferencesAsync( | ||||
|                     new DeleteResourceReferencesRequest { ResourceId = account.Profile.ResourceIdentifier } | ||||
|                 ); | ||||
|             await fileRefs.CreateReferenceAsync( | ||||
|                 new CreateReferenceRequest | ||||
|                 { | ||||
|                     ResourceId = account.Profile.ResourceIdentifier, | ||||
|                     FileId = request.PictureId, | ||||
|                     Usage = "profile.picture" | ||||
|                 } | ||||
|             ); | ||||
|             account.Profile.Picture = CloudFileReferenceObject.FromProtoValue(file); | ||||
|         } | ||||
|  | ||||
|         if (request.BackgroundId is not null) | ||||
|         { | ||||
|             var file = await files.GetFileAsync(new GetFileRequest { Id = request.BackgroundId }); | ||||
|             if (account.Profile.Background is not null) | ||||
|                 await fileRefs.DeleteResourceReferencesAsync( | ||||
|                     new DeleteResourceReferencesRequest { ResourceId = account.Profile.ResourceIdentifier } | ||||
|                 ); | ||||
|             await fileRefs.CreateReferenceAsync( | ||||
|                 new CreateReferenceRequest | ||||
|                 { | ||||
|                     ResourceId = account.Profile.ResourceIdentifier, | ||||
|                     FileId = request.BackgroundId, | ||||
|                     Usage = "profile.background" | ||||
|                 } | ||||
|             ); | ||||
|             account.Profile.Background = CloudFileReferenceObject.FromProtoValue(file); | ||||
|         } | ||||
|  | ||||
|         db.Accounts.Update(account); | ||||
|         await db.SaveChangesAsync(); | ||||
|  | ||||
|         return new UpdateBotAccountResponse | ||||
|         { | ||||
|             Bot = new BotAccount | ||||
|             { | ||||
|                 Account = account.ToProtoValue(), | ||||
|                 AutomatedId = account.Id.ToString(), | ||||
|                 CreatedAt = account.CreatedAt.ToTimestamp(), | ||||
|                 UpdatedAt = account.UpdatedAt.ToTimestamp(), | ||||
|                 IsActive = true | ||||
|             } | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     public override async Task<DeleteBotAccountResponse> DeleteBotAccount( | ||||
|         DeleteBotAccountRequest request, | ||||
|         ServerCallContext context | ||||
|     ) | ||||
|     { | ||||
|         var automatedId = Guid.Parse(request.AutomatedId); | ||||
|         var account = await accounts.GetBotAccount(automatedId); | ||||
|         if (account is null) | ||||
|             throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.NotFound, "Account not found")); | ||||
|  | ||||
|         await accounts.DeleteAccount(account); | ||||
|  | ||||
|         return new DeleteBotAccountResponse(); | ||||
|     } | ||||
|  | ||||
|     public override async Task<ApiKey> GetApiKey(GetApiKeyRequest request, ServerCallContext context) | ||||
|     { | ||||
|         var keyId = Guid.Parse(request.Id); | ||||
|         var key = await db.ApiKeys | ||||
|             .Include(k => k.Account) | ||||
|             .FirstOrDefaultAsync(k => k.Id == keyId); | ||||
|  | ||||
|         if (key == null) | ||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found")); | ||||
|  | ||||
|         return key.ToProtoValue(); | ||||
|     } | ||||
|  | ||||
|     public override async Task<GetApiKeyBatchResponse> ListApiKey(ListApiKeyRequest request, ServerCallContext context) | ||||
|     { | ||||
|         var automatedId = Guid.Parse(request.AutomatedId); | ||||
|         var account = await accounts.GetBotAccount(automatedId); | ||||
|         if (account == null) | ||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found")); | ||||
|  | ||||
|         var keys = await db.ApiKeys | ||||
|             .Where(k => k.AccountId == account.Id) | ||||
|             .Select(k => k.ToProtoValue()) | ||||
|             .ToListAsync(); | ||||
|  | ||||
|         var response = new GetApiKeyBatchResponse(); | ||||
|         response.Data.AddRange(keys); | ||||
|         return response; | ||||
|     } | ||||
|  | ||||
|     public override async Task<ApiKey> CreateApiKey(ApiKey request, ServerCallContext context) | ||||
|     { | ||||
|         var accountId = Guid.Parse(request.AccountId); | ||||
|         var account = await accounts.GetBotAccount(accountId); | ||||
|         if (account == null) | ||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found")); | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(request.Label)) | ||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Label is required")); | ||||
|  | ||||
|         var key = await authService.CreateApiKey(account.Id, request.Label, null); | ||||
|         key.Key = await authService.IssueApiKeyToken(key); | ||||
|          | ||||
|         return key.ToProtoValue(); | ||||
|     } | ||||
|  | ||||
|     public override async Task<ApiKey> UpdateApiKey(ApiKey request, ServerCallContext context) | ||||
|     { | ||||
|         var keyId = Guid.Parse(request.Id); | ||||
|         var accountId = Guid.Parse(request.AccountId); | ||||
|          | ||||
|         var key = await db.ApiKeys | ||||
|             .Include(k => k.Session) | ||||
|             .Where(k => k.Id == keyId && k.AccountId == accountId) | ||||
|             .FirstOrDefaultAsync(); | ||||
|              | ||||
|         if (key == null) | ||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found")); | ||||
|  | ||||
|         // Only update the label if provided | ||||
|         if (string.IsNullOrWhiteSpace(request.Label)) return key.ToProtoValue(); | ||||
|         key.Label = request.Label; | ||||
|         db.ApiKeys.Update(key); | ||||
|         await db.SaveChangesAsync(); | ||||
|  | ||||
|         return key.ToProtoValue(); | ||||
|     } | ||||
|  | ||||
|     public override async Task<ApiKey> RotateApiKey(GetApiKeyRequest request, ServerCallContext context) | ||||
|     { | ||||
|         var keyId = Guid.Parse(request.Id); | ||||
|         var key = await db.ApiKeys | ||||
|             .Include(k => k.Session) | ||||
|             .FirstOrDefaultAsync(k => k.Id == keyId); | ||||
|              | ||||
|         if (key == null) | ||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found")); | ||||
|  | ||||
|         key = await authService.RotateApiKeyToken(key); | ||||
|         key.Key = await authService.IssueApiKeyToken(key); | ||||
|          | ||||
|         return key.ToProtoValue(); | ||||
|     } | ||||
|  | ||||
|     public override async Task<DeleteApiKeyResponse> DeleteApiKey(GetApiKeyRequest request, ServerCallContext context) | ||||
|     { | ||||
|         var keyId = Guid.Parse(request.Id); | ||||
|         var key = await db.ApiKeys | ||||
|             .Include(k => k.Session) | ||||
|             .FirstOrDefaultAsync(k => k.Id == keyId); | ||||
|              | ||||
|         if (key == null) | ||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found")); | ||||
|  | ||||
|         await authService.RevokeApiKeyToken(key); | ||||
|          | ||||
|         return new DeleteApiKeyResponse { Success = true }; | ||||
|     } | ||||
| } | ||||
| @@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations; | ||||
| using System.ComponentModel.DataAnnotations.Schema; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using NodaTime; | ||||
| using NodaTime.Serialization.Protobuf; | ||||
|  | ||||
| namespace DysonNetwork.Pass.Account; | ||||
|  | ||||
| @@ -22,9 +23,63 @@ public class Status : ModelBase | ||||
|     public bool IsNotDisturb { get; set; } | ||||
|     [MaxLength(1024)] public string? Label { get; set; } | ||||
|     public Instant? ClearedAt { get; set; } | ||||
|     [MaxLength(4096)] public string? AppIdentifier { get; set; } | ||||
|      | ||||
|     /// <summary> | ||||
|     /// Indicates this status is created based on running process or rich presence | ||||
|     /// </summary> | ||||
|     public bool IsAutomated { get; set; } | ||||
|  | ||||
|     public Guid AccountId { get; set; } | ||||
|     public Account Account { get; set; } = null!; | ||||
|      | ||||
|     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 | ||||
|   | ||||
| @@ -3,6 +3,7 @@ using System.Text.Json; | ||||
| using DysonNetwork.Pass.Email; | ||||
| using DysonNetwork.Pass.Pages.Emails; | ||||
| using DysonNetwork.Pass.Permission; | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.Extensions.Localization; | ||||
| using NodaTime; | ||||
| @@ -15,7 +16,8 @@ public class MagicSpellService( | ||||
|     IConfiguration configuration, | ||||
|     ILogger<MagicSpellService> logger, | ||||
|     IStringLocalizer<EmailResource> localizer, | ||||
|     EmailService email | ||||
|     EmailService email, | ||||
|     ICacheService cache | ||||
| ) | ||||
| { | ||||
|     public async Task<MagicSpell> CreateMagicSpell( | ||||
| @@ -35,11 +37,8 @@ public class MagicSpellService( | ||||
|                 .Where(s => s.Type == type) | ||||
|                 .Where(s => s.ExpiresAt == null || s.ExpiresAt > now) | ||||
|                 .FirstOrDefaultAsync(); | ||||
|  | ||||
|             if (existingSpell != null) | ||||
|             { | ||||
|                 throw new InvalidOperationException($"Account already has an active magic spell of type {type}"); | ||||
|             } | ||||
|             if (existingSpell is not null) | ||||
|                 return existingSpell; | ||||
|         } | ||||
|  | ||||
|         var spellWord = _GenerateRandomString(128); | ||||
| @@ -59,8 +58,18 @@ public class MagicSpellService( | ||||
|         return spell; | ||||
|     } | ||||
|  | ||||
|     private const string SpellNotifyCacheKeyPrefix = "spells:notify:"; | ||||
|  | ||||
|     public async Task NotifyMagicSpell(MagicSpell spell, bool bypassVerify = false) | ||||
|     { | ||||
|         var cacheKey = SpellNotifyCacheKeyPrefix + spell.Id; | ||||
|         var (found, _) = await cache.GetAsyncWithStatus<bool?>(cacheKey); | ||||
|         if (found) | ||||
|         { | ||||
|             logger.LogInformation("Skip sending magic spell {SpellId} due to already sent.", spell.Id); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var contact = await db.AccountContacts | ||||
|             .Where(c => c.Account.Id == spell.AccountId) | ||||
|             .Where(c => c.Type == AccountContactType.Email) | ||||
| @@ -112,7 +121,7 @@ public class MagicSpellService( | ||||
|                     await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>( | ||||
|                         contact.Account.Nick, | ||||
|                         contact.Content, | ||||
|                         localizer["EmailAccountDeletionTitle"], | ||||
|                         localizer["EmailPasswordResetTitle"], | ||||
|                         new PasswordResetEmailModel | ||||
|                         { | ||||
|                             Name = contact.Account.Name, | ||||
| @@ -138,6 +147,8 @@ public class MagicSpellService( | ||||
|                 default: | ||||
|                     throw new ArgumentOutOfRangeException(); | ||||
|             } | ||||
|  | ||||
|             await cache.SetAsync(cacheKey, true, TimeSpan.FromMinutes(5)); | ||||
|         } | ||||
|         catch (Exception err) | ||||
|         { | ||||
|   | ||||
							
								
								
									
										53
									
								
								DysonNetwork.Pass/Account/NotableDay.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								DysonNetwork.Pass/Account/NotableDay.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | ||||
| using Nager.Holiday; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Pass.Account; | ||||
|  | ||||
| /// <summary> | ||||
| /// Reference from Nager.Holiday | ||||
| /// </summary> | ||||
| public enum NotableHolidayType | ||||
| { | ||||
|     /// <summary>Public holiday</summary> | ||||
|     Public, | ||||
|     /// <summary>Bank holiday, banks and offices are closed</summary> | ||||
|     Bank, | ||||
|     /// <summary>School holiday, schools are closed</summary> | ||||
|     School, | ||||
|     /// <summary>Authorities are closed</summary> | ||||
|     Authorities, | ||||
|     /// <summary>Majority of people take a day off</summary> | ||||
|     Optional, | ||||
|     /// <summary>Optional festivity, no paid day off</summary> | ||||
|     Observance, | ||||
| } | ||||
|  | ||||
|  | ||||
| public class NotableDay | ||||
| { | ||||
|     public Instant Date { get; set; } | ||||
|     public string? LocalName { get; set; } | ||||
|     public string? GlobalName { get; set; } | ||||
|     public string? CountryCode { get; set; } | ||||
|     public NotableHolidayType[] Holidays { get; set; } = []; | ||||
|  | ||||
|     public static NotableDay FromNagerHoliday(PublicHoliday holiday) | ||||
|     { | ||||
|         return new NotableDay() | ||||
|         { | ||||
|             Date = Instant.FromDateTimeUtc(holiday.Date.ToUniversalTime()), | ||||
|             LocalName = holiday.LocalName, | ||||
|             GlobalName = holiday.Name, | ||||
|             CountryCode = holiday.CountryCode, | ||||
|             Holidays = holiday.Types?.Select(x => x switch | ||||
|             { | ||||
|                 PublicHolidayType.Public => NotableHolidayType.Public, | ||||
|                 PublicHolidayType.Bank => NotableHolidayType.Bank, | ||||
|                 PublicHolidayType.School => NotableHolidayType.School, | ||||
|                 PublicHolidayType.Authorities => NotableHolidayType.Authorities, | ||||
|                 PublicHolidayType.Optional => NotableHolidayType.Optional, | ||||
|                 _ => NotableHolidayType.Observance | ||||
|             }).ToArray() ?? [], | ||||
|         }; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										79
									
								
								DysonNetwork.Pass/Account/NotableDaysController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								DysonNetwork.Pass/Account/NotableDaysController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,79 @@ | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
|  | ||||
| namespace DysonNetwork.Pass.Account; | ||||
|  | ||||
| [ApiController] | ||||
| [Route("/api/notable")] | ||||
| public class NotableDaysController(NotableDaysService days) : ControllerBase | ||||
| { | ||||
|     [HttpGet("{regionCode}/{year:int}")] | ||||
|     public async Task<ActionResult<List<NotableDay>>> GetRegionDays(string regionCode, int year) | ||||
|     { | ||||
|         var result = await days.GetNotableDays(year, regionCode); | ||||
|         return Ok(result); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("{regionCode}")] | ||||
|     public async Task<ActionResult<List<NotableDay>>> GetRegionDaysCurrentYear(string regionCode) | ||||
|     { | ||||
|         var currentYear = DateTime.Now.Year; | ||||
|         var result = await days.GetNotableDays(currentYear, regionCode); | ||||
|         return Ok(result); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("me/{year:int}")] | ||||
|     [Authorize] | ||||
|     public async Task<ActionResult<List<NotableDay>>> GetAccountNotableDays(int year) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||
|  | ||||
|         var region = currentUser.Region; | ||||
|         if (string.IsNullOrWhiteSpace(region)) region = "us"; | ||||
|  | ||||
|         var result = await days.GetNotableDays(year, region); | ||||
|         return Ok(result); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("me")] | ||||
|     [Authorize] | ||||
|     public async Task<ActionResult<List<NotableDay>>> GetAccountNotableDaysCurrentYear() | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||
|  | ||||
|         var currentYear = DateTime.Now.Year; | ||||
|         var region = currentUser.Region; | ||||
|         if (string.IsNullOrWhiteSpace(region)) region = "us"; | ||||
|  | ||||
|         var result = await days.GetNotableDays(currentYear, region); | ||||
|         return Ok(result); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("{regionCode}/next")] | ||||
|     public async Task<ActionResult<NotableDay?>> GetNextHoliday(string regionCode) | ||||
|     { | ||||
|         var result = await days.GetNextHoliday(regionCode); | ||||
|         if (result == null) | ||||
|         { | ||||
|             return NotFound("No upcoming holidays found"); | ||||
|         } | ||||
|         return Ok(result); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("me/next")] | ||||
|     [Authorize] | ||||
|     public async Task<ActionResult<NotableDay?>> GetAccountNextHoliday() | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||
|  | ||||
|         var region = currentUser.Region; | ||||
|         if (string.IsNullOrWhiteSpace(region)) region = "us"; | ||||
|  | ||||
|         var result = await days.GetNextHoliday(region); | ||||
|         if (result == null) | ||||
|         { | ||||
|             return NotFound("No upcoming holidays found"); | ||||
|         } | ||||
|         return Ok(result); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										55
									
								
								DysonNetwork.Pass/Account/NotableDaysService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								DysonNetwork.Pass/Account/NotableDaysService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using Nager.Holiday; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Pass.Account; | ||||
|  | ||||
| public class NotableDaysService(ICacheService cache) | ||||
| { | ||||
|     private const string NotableDaysCacheKeyPrefix = "notable:"; | ||||
|  | ||||
|     public async Task<List<NotableDay>> GetNotableDays(int? year, string regionCode) | ||||
|     { | ||||
|         year ??= DateTime.UtcNow.Year; | ||||
|  | ||||
|         // Generate cache key using year and region code | ||||
|         var cacheKey = $"{NotableDaysCacheKeyPrefix}:{year}:{regionCode}"; | ||||
|  | ||||
|         // Try to get from cache first | ||||
|         var (found, cachedDays) = await cache.GetAsyncWithStatus<List<NotableDay>>(cacheKey); | ||||
|         if (found && cachedDays != null) | ||||
|         { | ||||
|             return cachedDays; | ||||
|         } | ||||
|  | ||||
|         // If not in cache, fetch from API | ||||
|         using var holidayClient = new HolidayClient(); | ||||
|         var holidays = await holidayClient.GetHolidaysAsync(year.Value, regionCode); | ||||
|         var days = holidays?.Select(NotableDay.FromNagerHoliday).ToList() ?? []; | ||||
|  | ||||
|         // Cache the result for 1 day (holiday data doesn't change frequently) | ||||
|         await cache.SetAsync(cacheKey, days, TimeSpan.FromDays(1)); | ||||
|  | ||||
|         return days; | ||||
|     } | ||||
|  | ||||
|     public async Task<NotableDay?> GetNextHoliday(string regionCode) | ||||
|     { | ||||
|         var currentDate = SystemClock.Instance.GetCurrentInstant(); | ||||
|         var currentYear = currentDate.InUtc().Year; | ||||
|  | ||||
|         // Get holidays for current year and next year to cover all possibilities | ||||
|         var currentYearHolidays = await GetNotableDays(currentYear, regionCode); | ||||
|         var nextYearHolidays = await GetNotableDays(currentYear + 1, regionCode); | ||||
|  | ||||
|         var allHolidays = currentYearHolidays.Concat(nextYearHolidays); | ||||
|  | ||||
|         // Find the first holiday that is today or in the future | ||||
|         var nextHoliday = allHolidays | ||||
|             .Where(day => day.Date >= currentDate) | ||||
|             .OrderBy(day => day.Date) | ||||
|             .FirstOrDefault(); | ||||
|  | ||||
|         return nextHoliday; | ||||
|     } | ||||
| } | ||||
| @@ -1,10 +1,18 @@ | ||||
| using DysonNetwork.Pass.Localization; | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.Extensions.Localization; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Pass.Account; | ||||
|  | ||||
| public class RelationshipService(AppDatabase db, ICacheService cache) | ||||
| public class RelationshipService( | ||||
|     AppDatabase db, | ||||
|     ICacheService cache, | ||||
|     RingService.RingServiceClient pusher, | ||||
|     IStringLocalizer<NotificationResource> localizer | ||||
| ) | ||||
| { | ||||
|     private const string UserFriendsCacheKeyPrefix = "accounts:friends:"; | ||||
|     private const string UserBlockedCacheKeyPrefix = "accounts:blocked:"; | ||||
| @@ -92,6 +100,19 @@ public class RelationshipService(AppDatabase db, ICacheService cache) | ||||
|         db.AccountRelationships.Add(relationship); | ||||
|         await db.SaveChangesAsync(); | ||||
|  | ||||
|         await pusher.SendPushNotificationToUserAsync(new SendPushNotificationToUserRequest | ||||
|         { | ||||
|             UserId = target.Id.ToString(), | ||||
|             Notification = new PushNotification | ||||
|             { | ||||
|                 Topic = "relationships.friends.request", | ||||
|                 Title = localizer["FriendRequestTitle", sender.Nick], | ||||
|                 Body = localizer["FriendRequestBody"], | ||||
|                 ActionUri = "/account/relationships", | ||||
|                 IsSavable = true | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         return relationship; | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,11 @@ | ||||
| using System.Linq.Expressions; | ||||
| using System.Reflection; | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
| using DysonNetwork.Pass.Account; | ||||
| using DysonNetwork.Pass.Auth; | ||||
| using DysonNetwork.Pass.Credit; | ||||
| using DysonNetwork.Pass.Leveling; | ||||
| using DysonNetwork.Pass.Permission; | ||||
| using DysonNetwork.Pass.Wallet; | ||||
| using DysonNetwork.Shared.Data; | ||||
| @@ -38,6 +42,7 @@ public class AppDatabase( | ||||
|     public DbSet<AuthSession> AuthSessions { get; set; } = null!; | ||||
|     public DbSet<AuthChallenge> AuthChallenges { get; set; } = null!; | ||||
|     public DbSet<AuthClient> AuthClients { get; set; } = null!; | ||||
|     public DbSet<ApiKey> ApiKeys { get; set; } = null!; | ||||
|  | ||||
|     public DbSet<Wallet.Wallet> Wallets { get; set; } = null!; | ||||
|     public DbSet<WalletPocket> WalletPockets { get; set; } = null!; | ||||
| @@ -48,14 +53,22 @@ public class AppDatabase( | ||||
|  | ||||
|     public DbSet<Punishment> Punishments { get; set; } = null!; | ||||
|  | ||||
|     public DbSet<SocialCreditRecord> SocialCreditRecords { get; set; } = null!; | ||||
|     public DbSet<ExperienceRecord> ExperienceRecords { get; set; } = null!; | ||||
|  | ||||
|     protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) | ||||
|     { | ||||
|         optionsBuilder.UseNpgsql( | ||||
|             configuration.GetConnectionString("App"), | ||||
|             opt => opt | ||||
|                 .ConfigureDataSource(optSource => optSource.EnableDynamicJson()) | ||||
|                 .ConfigureDataSource(optSource => optSource | ||||
|                     .EnableDynamicJson() | ||||
|                     .ConfigureJsonOptions(new JsonSerializerOptions() | ||||
|                     { | ||||
|                         NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, | ||||
|                     }) | ||||
|                 ) | ||||
|                 .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) | ||||
|                 .UseNetTopologySuite() | ||||
|                 .UseNodaTime() | ||||
|         ).UseSnakeCaseNamingConvention(); | ||||
|  | ||||
|   | ||||
							
								
								
									
										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.Cryptography; | ||||
| using System.Text.Encodings.Web; | ||||
| using DysonNetwork.Pass.Account; | ||||
| using Microsoft.AspNetCore.Authentication; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.Extensions.Options; | ||||
| using DysonNetwork.Pass.Auth.OidcProvider.Services; | ||||
| using DysonNetwork.Pass.Handlers; | ||||
| using DysonNetwork.Pass.Wallet; | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using SystemClock = NodaTime.SystemClock; | ||||
|  | ||||
| @@ -38,19 +33,13 @@ public class DysonTokenAuthOptions : AuthenticationSchemeOptions; | ||||
|  | ||||
| public class DysonTokenAuthHandler( | ||||
|     IOptionsMonitor<DysonTokenAuthOptions> options, | ||||
|     IConfiguration configuration, | ||||
|     ILoggerFactory logger, | ||||
|     UrlEncoder encoder, | ||||
|     AppDatabase database, | ||||
|     OidcProviderService oidc, | ||||
|     SubscriptionService subscriptions, | ||||
|     ICacheService cache, | ||||
|     TokenAuthService token, | ||||
|     FlushBufferService fbs | ||||
| ) | ||||
|     : AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder) | ||||
| { | ||||
|     public const string AuthCachePrefix = "auth:"; | ||||
|  | ||||
|     protected override async Task<AuthenticateResult> HandleAuthenticateAsync() | ||||
|     { | ||||
|         var tokenInfo = _ExtractToken(Request); | ||||
| @@ -60,48 +49,12 @@ public class DysonTokenAuthHandler( | ||||
|  | ||||
|         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)) | ||||
|                 return AuthenticateResult.Fail("Invalid token."); | ||||
|  | ||||
|             // 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."); | ||||
|             var (valid, session, message) = await token.AuthenticateTokenAsync(tokenInfo.Token, ipAddress); | ||||
|             if (!valid || session is null) | ||||
|                 return AuthenticateResult.Fail(message ?? "Authentication failed."); | ||||
|  | ||||
|             // Store user and session in the HttpContext.Items for easy access in controllers | ||||
|             Context.Items["CurrentUser"] = session.Account; | ||||
| @@ -117,7 +70,7 @@ public class DysonTokenAuthHandler( | ||||
|             }; | ||||
|  | ||||
|             // Add scopes as claims | ||||
|             session.Challenge.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope))); | ||||
|             session.Challenge?.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope))); | ||||
|  | ||||
|             // Add superuser claim if applicable | ||||
|             if (session.Account.IsSuperuser) | ||||
| @@ -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) | ||||
|     { | ||||
|         // 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 Microsoft.EntityFrameworkCore; | ||||
| using DysonNetwork.Pass.Account; | ||||
| using DysonNetwork.Pass.Localization; | ||||
| using DysonNetwork.Shared.Data; | ||||
| 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; | ||||
|  | ||||
| @@ -16,7 +22,9 @@ public class AuthController( | ||||
|     AuthService auth, | ||||
|     GeoIpService geo, | ||||
|     ActionLogService als, | ||||
|     IConfiguration configuration | ||||
|     RingService.RingServiceClient pusher, | ||||
|     IConfiguration configuration, | ||||
|     IStringLocalizer<NotificationResource> localizer | ||||
| ) : ControllerBase | ||||
| { | ||||
|     private readonly string _cookieDomain = configuration["AuthToken:CookieDomain"]!; | ||||
| @@ -24,8 +32,8 @@ public class AuthController( | ||||
|     public class ChallengeRequest | ||||
|     { | ||||
|         [Required] public ClientPlatform Platform { get; set; } | ||||
|         [Required][MaxLength(256)] public string Account { get; set; } = null!; | ||||
|         [Required][MaxLength(512)] public string DeviceId { get; set; } = null!; | ||||
|         [Required] [MaxLength(256)] public string Account { get; set; } = null!; | ||||
|         [Required] [MaxLength(512)] public string DeviceId { get; set; } = null!; | ||||
|         [MaxLength(1024)] public string? DeviceName { get; set; } | ||||
|         public List<string> Audiences { 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.ExpiredAt == null || now < e.ExpiredAt) | ||||
|             .FirstOrDefaultAsync(); | ||||
|         if (punishment is not null) return StatusCode(423, punishment); | ||||
|         if (punishment is not null) | ||||
|             return StatusCode( | ||||
|                 423, | ||||
|                 $"Your account has been suspended. Reason: {punishment.Reason}. Expired at: {punishment.ExpiredAt?.ToString() ?? "never"}" | ||||
|             ); | ||||
|  | ||||
|         var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); | ||||
|         var userAgent = HttpContext.Request.Headers.UserAgent.ToString(); | ||||
|  | ||||
|         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 | ||||
|         var existingChallenge = await db.AuthChallenges | ||||
| @@ -64,7 +77,8 @@ public class AuthController( | ||||
|             .FirstOrDefaultAsync(); | ||||
|         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; | ||||
|         } | ||||
|  | ||||
| @@ -120,8 +134,7 @@ public class AuthController( | ||||
|     [HttpPost("challenge/{id:guid}/factors/{factorId:guid}")] | ||||
|     public async Task<ActionResult> RequestFactorCode( | ||||
|         [FromRoute] Guid id, | ||||
|         [FromRoute] Guid factorId, | ||||
|         [FromBody] string? hint | ||||
|         [FromRoute] Guid factorId | ||||
|     ) | ||||
|     { | ||||
|         var challenge = await db.AuthChallenges | ||||
| @@ -135,7 +148,7 @@ public class AuthController( | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             await accounts.SendFactorCode(challenge.Account, factor, hint); | ||||
|             await accounts.SendFactorCode(challenge.Account, factor); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
| @@ -157,18 +170,29 @@ public class AuthController( | ||||
|         [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."); | ||||
|  | ||||
|         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.EnabledAt is null) return BadRequest("Auth factor is not enabled."); | ||||
|         if (factor.Trustworthy <= 0) return BadRequest("Auth factor is not trustworthy."); | ||||
|  | ||||
|         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(); | ||||
|  | ||||
|         // prevent reusing the same factor in one challenge | ||||
|         if (challenge.BlacklistFactors.Contains(factor.Id)) | ||||
|             return BadRequest("Auth factor already used."); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             if (await accounts.VerifyFactorCode(factor, request.Password)) | ||||
| @@ -207,6 +231,19 @@ public class AuthController( | ||||
|  | ||||
|         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, | ||||
|                 new Dictionary<string, object> | ||||
|                 { | ||||
| @@ -247,37 +284,15 @@ public class AuthController( | ||||
|                     .FirstOrDefaultAsync(); | ||||
|                 if (challenge is null) | ||||
|                     return BadRequest("Authorization code not found or expired."); | ||||
|                 if (challenge.StepRemain != 0) | ||||
|                     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 | ||||
|                 try | ||||
|                 { | ||||
|                     LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow), | ||||
|                     ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)), | ||||
|                     Account = challenge.Account, | ||||
|                     Challenge = challenge, | ||||
|                 }; | ||||
|  | ||||
|                 db.AuthSessions.Add(session); | ||||
|                 await db.SaveChangesAsync(); | ||||
|  | ||||
|                 var tk = auth.CreateToken(session); | ||||
|                 Response.Cookies.Append(AuthConstants.CookieTokenName, tk, new CookieOptions | ||||
|                     var tk = await auth.CreateSessionAndIssueToken(challenge); | ||||
|                     return Ok(new TokenExchangeResponse { Token = tk }); | ||||
|                 } | ||||
|                 catch (ArgumentException ex) | ||||
|                 { | ||||
|                     HttpOnly = true, | ||||
|                     Secure = true, | ||||
|                     SameSite = SameSiteMode.Lax, | ||||
|                     Domain = _cookieDomain, | ||||
|                     Expires = DateTime.UtcNow.AddDays(30) | ||||
|                 }); | ||||
|  | ||||
|                 return Ok(new TokenExchangeResponse { Token = tk }); | ||||
|                     return BadRequest(ex.Message); | ||||
|                 } | ||||
|             default: | ||||
|                 // Since we no longer need the refresh token | ||||
|                 // This case is blank for now, thinking to mock it if the OIDC standard requires it | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| using System.Security.Cryptography; | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
| using DysonNetwork.Pass.Account; | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| @@ -17,6 +18,7 @@ public class AuthService( | ||||
| ) | ||||
| { | ||||
|     private HttpContext HttpContext => httpContextAccessor.HttpContext!; | ||||
|     public const string AuthCachePrefix = "auth:"; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Detect the risk of the current request to login | ||||
| @@ -51,7 +53,7 @@ public class AuthService( | ||||
|             riskScore += 1; | ||||
|         else | ||||
|         { | ||||
|             if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge.IpAddress) && | ||||
|             if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge?.IpAddress) && | ||||
|                 !lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase)) | ||||
|                 riskScore += 1; | ||||
|         } | ||||
| @@ -136,6 +138,7 @@ public class AuthService( | ||||
|  | ||||
|         var jsonOpts = new JsonSerializerOptions | ||||
|         { | ||||
|             NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, | ||||
|             PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, | ||||
|             DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower | ||||
|         }; | ||||
| @@ -189,6 +192,52 @@ public class AuthService( | ||||
|         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) | ||||
|     { | ||||
|         // Create the payload: just the session ID | ||||
| @@ -270,76 +319,82 @@ public class AuthService( | ||||
|         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; | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             // Split the token | ||||
|             var parts = token.Split('.'); | ||||
|             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; | ||||
|         } | ||||
|         var key = await db.ApiKeys | ||||
|             .Include(e => e.Session) | ||||
|             .Where(e => e.Id == id) | ||||
|             .If(accountId.HasValue, q => q.Where(e => e.AccountId == accountId!.Value)) | ||||
|             .FirstOrDefaultAsync(); | ||||
|         return key; | ||||
|     } | ||||
|  | ||||
|     public async Task MigrateDeviceIdToClient() | ||||
|     public async Task<ApiKey> CreateApiKey(Guid accountId, string label, Instant? expiredAt = null) | ||||
|     { | ||||
|         logger.LogInformation("Migrating device IDs to clients..."); | ||||
|         var key = new ApiKey | ||||
|         { | ||||
|             AccountId = accountId, | ||||
|             Label = label, | ||||
|             Session = new AuthSession | ||||
|             { | ||||
|                 AccountId = accountId, | ||||
|                 ExpiredAt = expiredAt | ||||
|             }, | ||||
|         }; | ||||
|  | ||||
|         db.ApiKeys.Add(key); | ||||
|         await db.SaveChangesAsync(); | ||||
|  | ||||
|         return key; | ||||
|     } | ||||
|  | ||||
|     public async Task<string> IssueApiKeyToken(ApiKey key) | ||||
|     { | ||||
|         key.Session.LastGrantedAt = SystemClock.Instance.GetCurrentInstant(); | ||||
|         db.Update(key.Session); | ||||
|         await db.SaveChangesAsync(); | ||||
|         var tk = CreateToken(key.Session); | ||||
|         return tk; | ||||
|     } | ||||
|  | ||||
|     public async Task RevokeApiKeyToken(ApiKey key) | ||||
|     { | ||||
|         db.Remove(key); | ||||
|         db.Remove(key.Session); | ||||
|         await db.SaveChangesAsync(); | ||||
|     } | ||||
|  | ||||
|     public async Task<ApiKey> RotateApiKeyToken(ApiKey key) | ||||
|     { | ||||
|         await using var transaction = await db.Database.BeginTransactionAsync(); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var challenges = await db.AuthChallenges | ||||
|                 .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 oldSessionId = key.SessionId; | ||||
|  | ||||
|             var clientsMap = clients.ToDictionary(c => c.DeviceId, c => c.Id); | ||||
|             foreach (var challenge in challenges.Where(challenge => challenge.ClientId == null && challenge.DeviceId != null)) | ||||
|             // Create new session | ||||
|             var newSession = new AuthSession | ||||
|             { | ||||
|                 if (clientsMap.TryGetValue(challenge.DeviceId!, out var clientId)) | ||||
|                     challenge.ClientId = clientId; | ||||
|                 db.AuthChallenges.Update(challenge); | ||||
|             } | ||||
|                 AccountId = key.AccountId, | ||||
|                 ExpiredAt = key.Session?.ExpiredAt | ||||
|             }; | ||||
|  | ||||
|             db.AuthSessions.Add(newSession); | ||||
|             await db.SaveChangesAsync(); | ||||
|  | ||||
|             // Update ApiKey to point to new session | ||||
|             key.SessionId = newSession.Id; | ||||
|             key.Session = newSession; | ||||
|             db.ApiKeys.Update(key); | ||||
|             await db.SaveChangesAsync(); | ||||
|  | ||||
|             // Delete old session | ||||
|             await db.AuthSessions.Where(s => s.Id == oldSessionId).ExecuteDeleteAsync(); | ||||
|  | ||||
|             await transaction.CommitAsync(); | ||||
|             logger.LogInformation("Migrated {Count} device IDs to clients", challenges.Count); | ||||
|             return key; | ||||
|         } | ||||
|         catch | ||||
|         { | ||||
|             logger.LogError("Failed to migrate device IDs to clients"); | ||||
|             await transaction.RollbackAsync(); | ||||
|             throw; | ||||
|         } | ||||
| @@ -356,7 +411,7 @@ public class AuthService( | ||||
|  | ||||
|     private static byte[] Base64UrlDecode(string base64Url) | ||||
|     { | ||||
|         string padded = base64Url | ||||
|         var padded = base64Url | ||||
|             .Replace('-', '+') | ||||
|             .Replace('_', '/'); | ||||
|  | ||||
|   | ||||
| @@ -1,17 +1,11 @@ | ||||
| using DysonNetwork.Pass.Wallet; | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using Grpc.Core; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Pass.Auth; | ||||
|  | ||||
| public class AuthServiceGrpc( | ||||
|     AuthService authService, | ||||
|     SubscriptionService subscriptions, | ||||
|     ICacheService cache, | ||||
|     AppDatabase db | ||||
|     TokenAuthService token, | ||||
|     AuthService auth | ||||
| ) | ||||
|     : Shared.Proto.AuthService.AuthServiceBase | ||||
| { | ||||
| @@ -20,35 +14,9 @@ public class AuthServiceGrpc( | ||||
|         ServerCallContext context | ||||
|     ) | ||||
|     { | ||||
|         if (!authService.ValidateToken(request.Token, out var sessionId)) | ||||
|             return new AuthenticateResponse { Valid = false, Message = "Invalid token." }; | ||||
|  | ||||
|         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) | ||||
|         ); | ||||
|         var (valid, session, message) = await token.AuthenticateTokenAsync(request.Token, request.IpAddress); | ||||
|         if (!valid || session is null) | ||||
|             return new AuthenticateResponse { Valid = false, Message = message ?? "Authentication failed." }; | ||||
|  | ||||
|         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) | ||||
|     { | ||||
|         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 }; | ||||
|     } | ||||
|      | ||||
|     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 }; | ||||
|     } | ||||
| } | ||||
| @@ -2,7 +2,7 @@ using System.ComponentModel.DataAnnotations; | ||||
| using System.ComponentModel.DataAnnotations.Schema; | ||||
| using System.Text.Json.Serialization; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using DysonNetwork.Shared.GeoIp; | ||||
| using NodaTime; | ||||
| using NodaTime.Serialization.Protobuf; | ||||
| using Point = NetTopologySuite.Geometries.Point; | ||||
| @@ -12,26 +12,28 @@ namespace DysonNetwork.Pass.Auth; | ||||
| public class AuthSession : ModelBase | ||||
| { | ||||
|     public Guid Id { get; set; } = Guid.NewGuid(); | ||||
|     [MaxLength(1024)] public string? Label { get; set; } | ||||
|     public Instant? LastGrantedAt { get; set; } | ||||
|     public Instant? ExpiredAt { get; set; } | ||||
|  | ||||
|     public Guid AccountId { get; set; } | ||||
|     [JsonIgnore] public Account.Account Account { get; set; } = null!; | ||||
|     public Guid ChallengeId { get; set; } | ||||
|     public AuthChallenge Challenge { get; set; } = null!; | ||||
|  | ||||
|     // When the challenge is null, indicates the session is for an API Key | ||||
|     public Guid? ChallengeId { get; set; } | ||||
|     public AuthChallenge? Challenge { get; set; } = null!; | ||||
|  | ||||
|     // Indicates the session is for an OIDC connection | ||||
|     public Guid? AppId { get; set; } | ||||
|  | ||||
|     public Shared.Proto.AuthSession ToProtoValue() => new() | ||||
|     { | ||||
|         Id = Id.ToString(), | ||||
|         Label = Label, | ||||
|         LastGrantedAt = LastGrantedAt?.ToTimestamp(), | ||||
|         ExpiredAt = ExpiredAt?.ToTimestamp(), | ||||
|         AccountId = AccountId.ToString(), | ||||
|         Account = Account.ToProtoValue(), | ||||
|         ChallengeId = ChallengeId.ToString(), | ||||
|         Challenge = Challenge.ToProtoValue(), | ||||
|         Challenge = Challenge?.ToProtoValue(), | ||||
|         AppId = AppId?.ToString() | ||||
|     }; | ||||
| } | ||||
| @@ -68,8 +70,7 @@ public class AuthChallenge : ModelBase | ||||
|     [MaxLength(128)] public string? IpAddress { get; set; } | ||||
|     [MaxLength(512)] public string? UserAgent { get; set; } | ||||
|     [MaxLength(1024)] public string? Nonce { get; set; } | ||||
|     [MaxLength(1024)] public string? DeviceId { get; set; } = string.Empty; | ||||
|     public Point? Location { get; set; } | ||||
|     [Column(TypeName = "jsonb")] public GeoPoint? Location { get; set; } | ||||
|  | ||||
|     public Guid AccountId { get; set; } | ||||
|     [JsonIgnore] public Account.Account Account { get; set; } = null!; | ||||
|   | ||||
| @@ -5,8 +5,10 @@ using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.Extensions.Options; | ||||
| using System.Text.Json.Serialization; | ||||
| using System.Web; | ||||
| using DysonNetwork.Pass.Account; | ||||
| using DysonNetwork.Pass.Auth.OidcProvider.Options; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.IdentityModel.Tokens; | ||||
| using NodaTime; | ||||
| @@ -19,10 +21,199 @@ public class OidcProviderController( | ||||
|     AppDatabase db, | ||||
|     OidcProviderService oidcService, | ||||
|     IConfiguration configuration, | ||||
|     IOptions<OidcProviderOptions> options | ||||
| ) | ||||
|     : ControllerBase | ||||
|     IOptions<OidcProviderOptions> options, | ||||
|     ILogger<OidcProviderController> logger | ||||
| ) : ControllerBase | ||||
| { | ||||
|     [HttpGet("authorize")] | ||||
|     [Produces("application/json")] | ||||
|     public async Task<IActionResult> Authorize( | ||||
|         [FromQuery(Name = "client_id")] string clientId, | ||||
|         [FromQuery(Name = "response_type")] string responseType, | ||||
|         [FromQuery(Name = "redirect_uri")] string? redirectUri = null, | ||||
|         [FromQuery] string? scope = null, | ||||
|         [FromQuery] string? state = null, | ||||
|         [FromQuery(Name = "response_mode")] string? responseMode = null, | ||||
|         [FromQuery] string? nonce = null, | ||||
|         [FromQuery] string? display = null, | ||||
|         [FromQuery] string? prompt = null, | ||||
|         [FromQuery(Name = "code_challenge")] string? codeChallenge = null, | ||||
|         [FromQuery(Name = "code_challenge_method")] | ||||
|         string? codeChallengeMethod = null) | ||||
|     { | ||||
|         if (string.IsNullOrEmpty(clientId)) | ||||
|         { | ||||
|             return BadRequest(new ErrorResponse | ||||
|             { | ||||
|                 Error = "invalid_request", | ||||
|                 ErrorDescription = "client_id is required" | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         var client = await oidcService.FindClientBySlugAsync(clientId); | ||||
|         if (client == null) | ||||
|         { | ||||
|             return BadRequest(new ErrorResponse | ||||
|             { | ||||
|                 Error = "unauthorized_client", | ||||
|                 ErrorDescription = "Client not found" | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         // Validate response_type | ||||
|         if (string.IsNullOrEmpty(responseType)) | ||||
|         { | ||||
|             return BadRequest(new ErrorResponse | ||||
|             { | ||||
|                 Error = "invalid_request", | ||||
|                 ErrorDescription = "response_type is required" | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         // Check if the client is allowed to use the requested response type | ||||
|         var allowedResponseTypes = new[] { "code", "token", "id_token" }; | ||||
|         var requestedResponseTypes = responseType.Split(' ', StringSplitOptions.RemoveEmptyEntries); | ||||
|  | ||||
|         if (requestedResponseTypes.Any(rt => !allowedResponseTypes.Contains(rt))) | ||||
|         { | ||||
|             return BadRequest(new ErrorResponse | ||||
|             { | ||||
|                 Error = "unsupported_response_type", | ||||
|                 ErrorDescription = "The requested response type is not supported" | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         // Validate redirect_uri if provided | ||||
|         if (!string.IsNullOrEmpty(redirectUri) && | ||||
|             !await oidcService.ValidateRedirectUriAsync(Guid.Parse(client.Id), redirectUri)) | ||||
|         { | ||||
|             return BadRequest(new ErrorResponse | ||||
|             { | ||||
|                 Error = "invalid_request", | ||||
|                 ErrorDescription = "Invalid redirect_uri" | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         // Return client information | ||||
|         var clientInfo = new ClientInfoResponse | ||||
|         { | ||||
|             ClientId = Guid.Parse(client.Id), | ||||
|             Picture = client.Picture is not null ? CloudFileReferenceObject.FromProtoValue(client.Picture) : null, | ||||
|             Background = client.Background is not null | ||||
|                 ? CloudFileReferenceObject.FromProtoValue(client.Background) | ||||
|                 : null, | ||||
|             ClientName = client.Name, | ||||
|             HomeUri = client.Links.HomePage, | ||||
|             PolicyUri = client.Links.PrivacyPolicy, | ||||
|             TermsOfServiceUri = client.Links.TermsOfService, | ||||
|             ResponseTypes = responseType, | ||||
|             Scopes = scope?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? [], | ||||
|             State = state, | ||||
|             Nonce = nonce, | ||||
|             CodeChallenge = codeChallenge, | ||||
|             CodeChallengeMethod = codeChallengeMethod | ||||
|         }; | ||||
|  | ||||
|         return Ok(clientInfo); | ||||
|     } | ||||
|  | ||||
|     [HttpPost("authorize")] | ||||
|     [Consumes("application/x-www-form-urlencoded")] | ||||
|     [Authorize] | ||||
|     public async Task<IActionResult> HandleAuthorizationResponse( | ||||
|         [FromForm(Name = "authorize")] string? authorize, | ||||
|         [FromForm(Name = "client_id")] string clientId, | ||||
|         [FromForm(Name = "redirect_uri")] string? redirectUri = null, | ||||
|         [FromForm] string? scope = null, | ||||
|         [FromForm] string? state = null, | ||||
|         [FromForm] string? nonce = null, | ||||
|         [FromForm(Name = "code_challenge")] string? codeChallenge = null, | ||||
|         [FromForm(Name = "code_challenge_method")] | ||||
|         string? codeChallengeMethod = null) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account.Account account) | ||||
|             return Unauthorized(); | ||||
|  | ||||
|         // Find the client | ||||
|         var client = await oidcService.FindClientBySlugAsync(clientId); | ||||
|         if (client == null) | ||||
|         { | ||||
|             return BadRequest(new ErrorResponse | ||||
|             { | ||||
|                 Error = "unauthorized_client", | ||||
|                 ErrorDescription = "Client not found" | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         // If user denied the request | ||||
|         if (string.IsNullOrEmpty(authorize) || !bool.TryParse(authorize, out var isAuthorized) || !isAuthorized) | ||||
|         { | ||||
|             var errorUri = new UriBuilder(redirectUri ?? client.Links?.HomePage ?? "https://example.com"); | ||||
|             var queryParams = HttpUtility.ParseQueryString(errorUri.Query); | ||||
|             queryParams["error"] = "access_denied"; | ||||
|             queryParams["error_description"] = "The user denied the authorization request"; | ||||
|             if (!string.IsNullOrEmpty(state)) queryParams["state"] = state; | ||||
|  | ||||
|             errorUri.Query = queryParams.ToString(); | ||||
|             return Ok(new { redirectUri = errorUri.Uri.ToString() }); | ||||
|         } | ||||
|  | ||||
|         // Validate redirect_uri if provided | ||||
|         if (!string.IsNullOrEmpty(redirectUri) && | ||||
|             !await oidcService.ValidateRedirectUriAsync(Guid.Parse(client!.Id), redirectUri)) | ||||
|         { | ||||
|             return BadRequest(new ErrorResponse | ||||
|             { | ||||
|                 Error = "invalid_request", | ||||
|                 ErrorDescription = "Invalid redirect_uri" | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         // Default to client's first redirect URI if not provided | ||||
|         redirectUri ??= client.OauthConfig?.RedirectUris?.FirstOrDefault(); | ||||
|         if (string.IsNullOrEmpty(redirectUri)) | ||||
|         { | ||||
|             return BadRequest(new ErrorResponse | ||||
|             { | ||||
|                 Error = "invalid_request", | ||||
|                 ErrorDescription = "No valid redirect_uri available" | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             // Generate authorization code and create session | ||||
|             var authorizationCode = await oidcService.GenerateAuthorizationCodeAsync( | ||||
|                 Guid.Parse(client.Id), | ||||
|                 account.Id, | ||||
|                 redirectUri, | ||||
|                 scope?.Split(' ') ?? [], | ||||
|                 codeChallenge, | ||||
|                 codeChallengeMethod, | ||||
|                 nonce | ||||
|             ); | ||||
|  | ||||
|             // Build the redirect URI with the authorization code | ||||
|             var redirectBuilder = new UriBuilder(redirectUri); | ||||
|             var queryParams = HttpUtility.ParseQueryString(redirectBuilder.Query); | ||||
|             queryParams["code"] = authorizationCode; | ||||
|             if (!string.IsNullOrEmpty(state)) queryParams["state"] = state; | ||||
|  | ||||
|             redirectBuilder.Query = queryParams.ToString(); | ||||
|  | ||||
|             return Ok(new { redirectUri = redirectBuilder.Uri.ToString() }); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             logger.LogError(ex, "Error processing authorization request"); | ||||
|             return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse | ||||
|             { | ||||
|                 Error = "server_error", | ||||
|                 ErrorDescription = "An error occurred while processing your request" | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [HttpPost("token")] | ||||
|     [Consumes("application/x-www-form-urlencoded")] | ||||
|     public async Task<IActionResult> Token([FromForm] TokenRequest request) | ||||
| @@ -35,74 +226,74 @@ public class OidcProviderController( | ||||
|             case "authorization_code" when request.Code == null: | ||||
|                 return BadRequest("Authorization code is required"); | ||||
|             case "authorization_code": | ||||
|                 { | ||||
|                     var client = await oidcService.FindClientByIdAsync(request.ClientId.Value); | ||||
|                     if (client == null || | ||||
|                         !await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret)) | ||||
|                         return BadRequest(new ErrorResponse | ||||
|             { | ||||
|                 var client = await oidcService.FindClientBySlugAsync(request.ClientId); | ||||
|                 if (client == null || | ||||
|                     !await oidcService.ValidateClientCredentialsAsync(Guid.Parse(client.Id), request.ClientSecret)) | ||||
|                     return BadRequest(new ErrorResponse | ||||
|                         { Error = "invalid_client", ErrorDescription = "Invalid client credentials" }); | ||||
|  | ||||
|                     // Generate tokens | ||||
|                     var tokenResponse = await oidcService.GenerateTokenResponseAsync( | ||||
|                         clientId: request.ClientId.Value, | ||||
|                         authorizationCode: request.Code!, | ||||
|                         redirectUri: request.RedirectUri, | ||||
|                         codeVerifier: request.CodeVerifier | ||||
|                     ); | ||||
|                 // Generate tokens | ||||
|                 var tokenResponse = await oidcService.GenerateTokenResponseAsync( | ||||
|                     clientId: Guid.Parse(client.Id), | ||||
|                     authorizationCode: request.Code!, | ||||
|                     redirectUri: request.RedirectUri, | ||||
|                     codeVerifier: request.CodeVerifier | ||||
|                 ); | ||||
|  | ||||
|                     return Ok(tokenResponse); | ||||
|                 } | ||||
|                 return Ok(tokenResponse); | ||||
|             } | ||||
|             case "refresh_token" when string.IsNullOrEmpty(request.RefreshToken): | ||||
|                 return BadRequest(new ErrorResponse | ||||
|                 { Error = "invalid_request", ErrorDescription = "Refresh token is required" }); | ||||
|                     { Error = "invalid_request", ErrorDescription = "Refresh token is required" }); | ||||
|             case "refresh_token": | ||||
|             { | ||||
|                 try | ||||
|                 { | ||||
|                     try | ||||
|                     { | ||||
|                         // Decode the base64 refresh token to get the session ID | ||||
|                         var sessionIdBytes = Convert.FromBase64String(request.RefreshToken); | ||||
|                         var sessionId = new Guid(sessionIdBytes); | ||||
|                     // Decode the base64 refresh token to get the session ID | ||||
|                     var sessionIdBytes = Convert.FromBase64String(request.RefreshToken); | ||||
|                     var sessionId = new Guid(sessionIdBytes); | ||||
|  | ||||
|                         // Find the session and related data | ||||
|                         var session = await oidcService.FindSessionByIdAsync(sessionId); | ||||
|                         var now = SystemClock.Instance.GetCurrentInstant(); | ||||
|                         if (session?.AppId is null || session.ExpiredAt < now) | ||||
|                         { | ||||
|                             return BadRequest(new ErrorResponse | ||||
|                             { | ||||
|                                 Error = "invalid_grant", | ||||
|                                 ErrorDescription = "Invalid or expired refresh token" | ||||
|                             }); | ||||
|                         } | ||||
|  | ||||
|                         // Get the client | ||||
|                         var client = await oidcService.FindClientByIdAsync(session.AppId.Value); | ||||
|                         if (client == null) | ||||
|                         { | ||||
|                             return BadRequest(new ErrorResponse | ||||
|                             { | ||||
|                                 Error = "invalid_client", | ||||
|                                 ErrorDescription = "Client not found" | ||||
|                             }); | ||||
|                         } | ||||
|  | ||||
|                         // Generate new tokens | ||||
|                         var tokenResponse = await oidcService.GenerateTokenResponseAsync( | ||||
|                             clientId: session.AppId!.Value, | ||||
|                             sessionId: session.Id | ||||
|                         ); | ||||
|  | ||||
|                         return Ok(tokenResponse); | ||||
|                     } | ||||
|                     catch (FormatException) | ||||
|                     // Find the session and related data | ||||
|                     var session = await oidcService.FindSessionByIdAsync(sessionId); | ||||
|                     var now = SystemClock.Instance.GetCurrentInstant(); | ||||
|                     if (session?.AppId is null || session.ExpiredAt < now) | ||||
|                     { | ||||
|                         return BadRequest(new ErrorResponse | ||||
|                         { | ||||
|                             Error = "invalid_grant", | ||||
|                             ErrorDescription = "Invalid refresh token format" | ||||
|                             ErrorDescription = "Invalid or expired refresh token" | ||||
|                         }); | ||||
|                     } | ||||
|  | ||||
|                     // Get the client | ||||
|                     var client = await oidcService.FindClientByIdAsync(session.AppId.Value); | ||||
|                     if (client == null) | ||||
|                     { | ||||
|                         return BadRequest(new ErrorResponse | ||||
|                         { | ||||
|                             Error = "invalid_client", | ||||
|                             ErrorDescription = "Client not found" | ||||
|                         }); | ||||
|                     } | ||||
|  | ||||
|                     // Generate new tokens | ||||
|                     var tokenResponse = await oidcService.GenerateTokenResponseAsync( | ||||
|                         clientId: session.AppId!.Value, | ||||
|                         sessionId: session.Id | ||||
|                     ); | ||||
|  | ||||
|                     return Ok(tokenResponse); | ||||
|                 } | ||||
|                 catch (FormatException) | ||||
|                 { | ||||
|                     return BadRequest(new ErrorResponse | ||||
|                     { | ||||
|                         Error = "invalid_grant", | ||||
|                         ErrorDescription = "Invalid refresh token format" | ||||
|                     }); | ||||
|                 } | ||||
|             } | ||||
|             default: | ||||
|                 return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" }); | ||||
|         } | ||||
| @@ -116,7 +307,7 @@ public class OidcProviderController( | ||||
|             HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized(); | ||||
|  | ||||
|         // Get requested scopes from the token | ||||
|         var scopes = currentSession.Challenge.Scopes; | ||||
|         var scopes = currentSession.Challenge?.Scopes ?? []; | ||||
|  | ||||
|         var userInfo = new Dictionary<string, object> | ||||
|         { | ||||
| @@ -150,10 +341,10 @@ public class OidcProviderController( | ||||
|  | ||||
|         return Ok(new | ||||
|         { | ||||
|             issuer = issuer, | ||||
|             issuer, | ||||
|             authorization_endpoint = $"{baseUrl}/auth/authorize", | ||||
|             token_endpoint = $"{baseUrl}/auth/open/token", | ||||
|             userinfo_endpoint = $"{baseUrl}/auth/open/userinfo", | ||||
|             token_endpoint = $"{baseUrl}/api/auth/open/token", | ||||
|             userinfo_endpoint = $"{baseUrl}/api/auth/open/userinfo", | ||||
|             jwks_uri = $"{baseUrl}/.well-known/jwks", | ||||
|             scopes_supported = new[] { "openid", "profile", "email" }, | ||||
|             response_types_supported = new[] | ||||
| @@ -220,7 +411,7 @@ public class TokenRequest | ||||
|  | ||||
|     [JsonPropertyName("client_id")] | ||||
|     [FromForm(Name = "client_id")] | ||||
|     public Guid? ClientId { get; set; } | ||||
|     public string? ClientId { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("client_secret")] | ||||
|     [FromForm(Name = "client_secret")] | ||||
|   | ||||
| @@ -0,0 +1,21 @@ | ||||
| using System.Text.Json.Serialization; | ||||
| using DysonNetwork.Shared.Data; | ||||
|  | ||||
| namespace DysonNetwork.Pass.Auth.OidcProvider.Responses; | ||||
|  | ||||
| public class ClientInfoResponse | ||||
| { | ||||
|     public Guid ClientId { get; set; } | ||||
|     public CloudFileReferenceObject? Picture { get; set; } | ||||
|     public CloudFileReferenceObject? Background { get; set; } | ||||
|     public string? ClientName { get; set; } | ||||
|     public string? HomeUri { get; set; } | ||||
|     public string? PolicyUri { get; set; } | ||||
|     public string? TermsOfServiceUri { get; set; } | ||||
|     public string? ResponseTypes { get; set; } | ||||
|     public string[]? Scopes { get; set; } | ||||
|     public string? State { get; set; } | ||||
|     public string? Nonce { get; set; } | ||||
|     public string? CodeChallenge { get; set; } | ||||
|     public string? CodeChallengeMethod { get; set; } | ||||
| } | ||||
| @@ -20,7 +20,6 @@ public class TokenResponse | ||||
|     [JsonPropertyName("scope")] | ||||
|     public string? Scope { get; set; } | ||||
|  | ||||
|  | ||||
|     [JsonPropertyName("id_token")] | ||||
|     public string? IdToken { get; set; } | ||||
| } | ||||
|   | ||||
| @@ -11,6 +11,7 @@ using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.Extensions.Options; | ||||
| using Microsoft.IdentityModel.Tokens; | ||||
| using NodaTime; | ||||
| using AccountContactType = DysonNetwork.Pass.Account.AccountContactType; | ||||
|  | ||||
| namespace DysonNetwork.Pass.Auth.OidcProvider.Services; | ||||
|  | ||||
| @@ -31,15 +32,31 @@ public class OidcProviderService( | ||||
|         return resp.App ?? null; | ||||
|     } | ||||
|  | ||||
|     public async Task<AuthSession?> FindValidSessionAsync(Guid accountId, Guid clientId) | ||||
|     public async Task<CustomApp?> FindClientBySlugAsync(string slug) | ||||
|     { | ||||
|         var resp = await customApps.GetCustomAppAsync(new GetCustomAppRequest { Slug = slug }); | ||||
|         return resp.App ?? null; | ||||
|     } | ||||
|  | ||||
|     public async Task<AuthSession?> FindValidSessionAsync(Guid accountId, Guid clientId, bool withAccount = false) | ||||
|     { | ||||
|         var now = SystemClock.Instance.GetCurrentInstant(); | ||||
|  | ||||
|         return await db.AuthSessions | ||||
|         var queryable = db.AuthSessions | ||||
|             .Include(s => s.Challenge) | ||||
|             .AsQueryable(); | ||||
|         if (withAccount) | ||||
|             queryable = queryable | ||||
|                 .Include(s => s.Account) | ||||
|                 .ThenInclude(a => a.Profile) | ||||
|                 .Include(a => a.Account.Contacts) | ||||
|                 .AsQueryable(); | ||||
|  | ||||
|         return await queryable | ||||
|             .Where(s => s.AccountId == accountId && | ||||
|                         s.AppId == clientId && | ||||
|                         (s.ExpiredAt == null || s.ExpiredAt > now) && | ||||
|                         s.Challenge != null && | ||||
|                         s.Challenge.Type == ChallengeType.OAuth) | ||||
|             .OrderByDescending(s => s.CreatedAt) | ||||
|             .FirstOrDefaultAsync(); | ||||
| @@ -56,6 +73,149 @@ public class OidcProviderService( | ||||
|         return resp.Valid; | ||||
|     } | ||||
|  | ||||
|     public async Task<bool> ValidateRedirectUriAsync(Guid clientId, string redirectUri) | ||||
|     { | ||||
|         if (string.IsNullOrEmpty(redirectUri)) | ||||
|             return false; | ||||
|  | ||||
|  | ||||
|         var client = await FindClientByIdAsync(clientId); | ||||
|         if (client?.Status != CustomAppStatus.Production) | ||||
|             return true; | ||||
|  | ||||
|         if (client?.OauthConfig?.RedirectUris == null) | ||||
|             return false; | ||||
|  | ||||
|         // Check if the redirect URI matches any of the allowed URIs | ||||
|         // For exact match | ||||
|         if (client.OauthConfig.RedirectUris.Contains(redirectUri)) | ||||
|             return true; | ||||
|  | ||||
|         // Check for wildcard matches (e.g., https://*.example.com/*) | ||||
|         foreach (var allowedUri in client.OauthConfig.RedirectUris) | ||||
|         { | ||||
|             if (string.IsNullOrEmpty(allowedUri)) | ||||
|                 continue; | ||||
|  | ||||
|             // Handle wildcard in domain | ||||
|             if (allowedUri.Contains("*.") && allowedUri.StartsWith("http")) | ||||
|             { | ||||
|                 try | ||||
|                 { | ||||
|                     var allowedUriObj = new Uri(allowedUri); | ||||
|                     var redirectUriObj = new Uri(redirectUri); | ||||
|  | ||||
|                     if (allowedUriObj.Scheme != redirectUriObj.Scheme || | ||||
|                         allowedUriObj.Port != redirectUriObj.Port) | ||||
|                     { | ||||
|                         continue; | ||||
|                     } | ||||
|  | ||||
|                     // Check if the domain matches the wildcard pattern | ||||
|                     var allowedDomain = allowedUriObj.Host; | ||||
|                     var redirectDomain = redirectUriObj.Host; | ||||
|  | ||||
|                     if (allowedDomain.StartsWith("*.")) | ||||
|                     { | ||||
|                         var baseDomain = allowedDomain[2..]; // Remove the "*." prefix | ||||
|                         if (redirectDomain == baseDomain || redirectDomain.EndsWith($".{baseDomain}")) | ||||
|                         { | ||||
|                             // Check path | ||||
|                             var allowedPath = allowedUriObj.AbsolutePath.TrimEnd('/'); | ||||
|                             var redirectPath = redirectUriObj.AbsolutePath.TrimEnd('/'); | ||||
|  | ||||
|                             if (string.IsNullOrEmpty(allowedPath) || | ||||
|                                 redirectPath.StartsWith(allowedPath, StringComparison.OrdinalIgnoreCase)) | ||||
|                             { | ||||
|                                 return true; | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|                 catch (UriFormatException) | ||||
|                 { | ||||
|                     // Invalid URI format in allowed URIs, skip | ||||
|                     continue; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     private string GenerateIdToken( | ||||
|         CustomApp client, | ||||
|         AuthSession session, | ||||
|         string? nonce = null, | ||||
|         IEnumerable<string>? scopes = null | ||||
|     ) | ||||
|     { | ||||
|         var tokenHandler = new JwtSecurityTokenHandler(); | ||||
|         var clock = SystemClock.Instance; | ||||
|         var now = clock.GetCurrentInstant(); | ||||
|  | ||||
|         var claims = new List<Claim> | ||||
|         { | ||||
|             new(JwtRegisteredClaimNames.Iss, _options.IssuerUri), | ||||
|             new(JwtRegisteredClaimNames.Sub, session.AccountId.ToString()), | ||||
|             new(JwtRegisteredClaimNames.Aud, client.Slug), | ||||
|             new(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64), | ||||
|             new(JwtRegisteredClaimNames.Exp, | ||||
|                 now.Plus(Duration.FromSeconds(_options.AccessTokenLifetime.TotalSeconds)).ToUnixTimeSeconds() | ||||
|                     .ToString(), ClaimValueTypes.Integer64), | ||||
|             new(JwtRegisteredClaimNames.AuthTime, session.CreatedAt.ToUnixTimeSeconds().ToString(), | ||||
|                 ClaimValueTypes.Integer64), | ||||
|         }; | ||||
|  | ||||
|         // Add nonce if provided (required for implicit and hybrid flows) | ||||
|         if (!string.IsNullOrEmpty(nonce)) | ||||
|         { | ||||
|             claims.Add(new Claim("nonce", nonce)); | ||||
|         } | ||||
|  | ||||
|         // Add email claim if email scope is requested | ||||
|         var scopesList = scopes?.ToList() ?? []; | ||||
|         if (scopesList.Contains("email")) | ||||
|         { | ||||
|             var contact = session.Account.Contacts.FirstOrDefault(c => c.Type == AccountContactType.Email); | ||||
|             if (contact is not null) | ||||
|             { | ||||
|                 claims.Add(new Claim(JwtRegisteredClaimNames.Email, contact.Content)); | ||||
|                 claims.Add(new Claim("email_verified", contact.VerifiedAt is not null ? "true" : "false", | ||||
|                     ClaimValueTypes.Boolean)); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Add profile claims if profile scope is requested | ||||
|         if (scopes != null && scopesList.Contains("profile")) | ||||
|         { | ||||
|             if (!string.IsNullOrEmpty(session.Account.Name)) | ||||
|                 claims.Add(new Claim("preferred_username", session.Account.Name)); | ||||
|             if (!string.IsNullOrEmpty(session.Account.Nick)) | ||||
|                 claims.Add(new Claim("name", session.Account.Nick)); | ||||
|             if (!string.IsNullOrEmpty(session.Account.Profile.FirstName)) | ||||
|                 claims.Add(new Claim("given_name", session.Account.Profile.FirstName)); | ||||
|             if (!string.IsNullOrEmpty(session.Account.Profile.LastName)) | ||||
|                 claims.Add(new Claim("family_name", session.Account.Profile.LastName)); | ||||
|         } | ||||
|  | ||||
|         var tokenDescriptor = new SecurityTokenDescriptor | ||||
|         { | ||||
|             Subject = new ClaimsIdentity(claims), | ||||
|             Issuer = _options.IssuerUri, | ||||
|             Audience = client.Id.ToString(), | ||||
|             Expires = now.Plus(Duration.FromSeconds(_options.AccessTokenLifetime.TotalSeconds)).ToDateTimeUtc(), | ||||
|             NotBefore = now.ToDateTimeUtc(), | ||||
|             SigningCredentials = new SigningCredentials( | ||||
|                 new RsaSecurityKey(_options.GetRsaPrivateKey()), | ||||
|                 SecurityAlgorithms.RsaSha256 | ||||
|             ) | ||||
|         }; | ||||
|  | ||||
|         var token = tokenHandler.CreateToken(tokenDescriptor); | ||||
|         return tokenHandler.WriteToken(token); | ||||
|     } | ||||
|  | ||||
|     public async Task<TokenResponse> GenerateTokenResponseAsync( | ||||
|         Guid clientId, | ||||
|         string? authorizationCode = null, | ||||
| @@ -71,24 +231,43 @@ public class OidcProviderService( | ||||
|         AuthSession session; | ||||
|         var clock = SystemClock.Instance; | ||||
|         var now = clock.GetCurrentInstant(); | ||||
|  | ||||
|         string? nonce = null; | ||||
|         List<string>? scopes = null; | ||||
|  | ||||
|         if (authorizationCode != null) | ||||
|         { | ||||
|             // Authorization code flow | ||||
|             var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier); | ||||
|             if (authCode is null) throw new InvalidOperationException("Invalid authorization code"); | ||||
|             var account = await db.Accounts.Where(a => a.Id == authCode.AccountId).FirstOrDefaultAsync(); | ||||
|             if (account is null) throw new InvalidOperationException("Account was not found"); | ||||
|             if (authCode == null) | ||||
|                 throw new InvalidOperationException("Invalid authorization code"); | ||||
|  | ||||
|             // Load the session for the user | ||||
|             var existingSession = await FindValidSessionAsync(authCode.AccountId, clientId, withAccount: true); | ||||
|  | ||||
|             if (existingSession is null) | ||||
|             { | ||||
|                 var account = await db.Accounts | ||||
|                     .Where(a => a.Id == authCode.AccountId) | ||||
|                     .Include(a => a.Profile) | ||||
|                     .Include(a => a.Contacts) | ||||
|                     .FirstOrDefaultAsync(); | ||||
|                 if (account is null) throw new InvalidOperationException("Account not found"); | ||||
|                 session = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant(), clientId); | ||||
|                 session.Account = account; | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 session = existingSession; | ||||
|             } | ||||
|  | ||||
|             session = await auth.CreateSessionForOidcAsync(account, now, clientId); | ||||
|             scopes = authCode.Scopes; | ||||
|             nonce = authCode.Nonce; | ||||
|         } | ||||
|         else if (sessionId.HasValue) | ||||
|         { | ||||
|             // Refresh token flow | ||||
|             session = await FindSessionByIdAsync(sessionId.Value) ?? | ||||
|                       throw new InvalidOperationException("Invalid session"); | ||||
|                       throw new InvalidOperationException("Session not found"); | ||||
|  | ||||
|             // Verify the session is still valid | ||||
|             if (session.ExpiredAt < now) | ||||
| @@ -102,13 +281,15 @@ public class OidcProviderService( | ||||
|         var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds; | ||||
|         var expiresAt = now.Plus(Duration.FromSeconds(expiresIn)); | ||||
|  | ||||
|         // Generate an access token | ||||
|         // Generate tokens | ||||
|         var accessToken = GenerateJwtToken(client, session, expiresAt, scopes); | ||||
|         var idToken = GenerateIdToken(client, session, nonce, scopes); | ||||
|         var refreshToken = GenerateRefreshToken(session); | ||||
|  | ||||
|         return new TokenResponse | ||||
|         { | ||||
|             AccessToken = accessToken, | ||||
|             IdToken = idToken, | ||||
|             ExpiresIn = expiresIn, | ||||
|             TokenType = "Bearer", | ||||
|             RefreshToken = refreshToken, | ||||
| @@ -134,11 +315,10 @@ public class OidcProviderService( | ||||
|                 new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()), | ||||
|                 new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(), | ||||
|                     ClaimValueTypes.Integer64), | ||||
|                 new Claim("client_id", client.Id) | ||||
|             ]), | ||||
|             Expires = expiresAt.ToDateTimeUtc(), | ||||
|             Issuer = _options.IssuerUri, | ||||
|             Audience = client.Id | ||||
|             Audience = client.Slug | ||||
|         }; | ||||
|  | ||||
|         // Try to use RSA signing if keys are available, fall back to HMAC | ||||
| @@ -204,51 +384,6 @@ public class OidcProviderService( | ||||
|         return Convert.ToBase64String(session.Id.ToByteArray()); | ||||
|     } | ||||
|  | ||||
|     private static bool VerifyHashedSecret(string secret, string hashedSecret) | ||||
|     { | ||||
|         // In a real implementation, you'd use a proper password hashing algorithm like PBKDF2, bcrypt, or Argon2 | ||||
|         // For now, we'll do a simple comparison, but you should replace this with proper hashing | ||||
|         return string.Equals(secret, hashedSecret, StringComparison.Ordinal); | ||||
|     } | ||||
|  | ||||
|     public async Task<string> GenerateAuthorizationCodeForReuseSessionAsync( | ||||
|         AuthSession session, | ||||
|         Guid clientId, | ||||
|         string redirectUri, | ||||
|         IEnumerable<string> scopes, | ||||
|         string? codeChallenge = null, | ||||
|         string? codeChallengeMethod = null, | ||||
|         string? nonce = null) | ||||
|     { | ||||
|         var clock = SystemClock.Instance; | ||||
|         var now = clock.GetCurrentInstant(); | ||||
|         var code = Guid.NewGuid().ToString("N"); | ||||
|  | ||||
|         // Update the session's last activity time | ||||
|         await db.AuthSessions.Where(s => s.Id == session.Id) | ||||
|             .ExecuteUpdateAsync(s => s.SetProperty(s => s.LastGrantedAt, now)); | ||||
|  | ||||
|         // Create the authorization code info | ||||
|         var authCodeInfo = new AuthorizationCodeInfo | ||||
|         { | ||||
|             ClientId = clientId, | ||||
|             AccountId = session.AccountId, | ||||
|             RedirectUri = redirectUri, | ||||
|             Scopes = scopes.ToList(), | ||||
|             CodeChallenge = codeChallenge, | ||||
|             CodeChallengeMethod = codeChallengeMethod, | ||||
|             Nonce = nonce, | ||||
|             CreatedAt = now | ||||
|         }; | ||||
|          | ||||
|         // Store the code with its metadata in the cache | ||||
|         var cacheKey = $"auth:code:{code}"; | ||||
|         await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime); | ||||
|  | ||||
|         logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, session.AccountId); | ||||
|         return code; | ||||
|     } | ||||
|  | ||||
|     public async Task<string> GenerateAuthorizationCodeAsync( | ||||
|         Guid clientId, | ||||
|         Guid userId, | ||||
| @@ -278,7 +413,7 @@ public class OidcProviderService( | ||||
|         }; | ||||
|  | ||||
|         // Store the code with its metadata in the cache | ||||
|         var cacheKey = $"auth:code:{code}"; | ||||
|         var cacheKey = $"auth:oidc-code:{code}"; | ||||
|         await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime); | ||||
|  | ||||
|         logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId); | ||||
| @@ -292,7 +427,7 @@ public class OidcProviderService( | ||||
|         string? codeVerifier = null | ||||
|     ) | ||||
|     { | ||||
|         var cacheKey = $"auth:code:{code}"; | ||||
|         var cacheKey = $"auth:oidc-code:{code}"; | ||||
|         var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey); | ||||
|  | ||||
|         if (!found || authCode == null) | ||||
|   | ||||
| @@ -340,7 +340,7 @@ public class ConnectionController( | ||||
|  | ||||
|         var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant()); | ||||
|         var loginToken = auth.CreateToken(loginSession); | ||||
|         return Redirect($"/auth/token?token={loginToken}"); | ||||
|         return Redirect($"/auth/callback?token={loginToken}"); | ||||
|     } | ||||
|  | ||||
|     private static async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request) | ||||
|   | ||||
| @@ -84,6 +84,7 @@ public class OidcState | ||||
|     { | ||||
|         return JsonSerializer.Serialize(this, new JsonSerializerOptions | ||||
|         { | ||||
|             NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, | ||||
|             PropertyNamingPolicy = JsonNamingPolicy.CamelCase, | ||||
|             DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull | ||||
|         }); | ||||
|   | ||||
							
								
								
									
										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', | ||||
|       component: () => import('../views/callback.vue'), | ||||
|     }, | ||||
|     { | ||||
|       path: '/auth/authorize', | ||||
|       name: 'authAuthorize', | ||||
|       component: () => import('../views/authorize.vue'), | ||||
|       meta: { requiresAuth: true }, | ||||
|     }, | ||||
|     { | ||||
|       path: '/:notFound(.*)', | ||||
|       name: 'errorNotFound', | ||||
|   | ||||
| @@ -0,0 +1,191 @@ | ||||
| <template> | ||||
|   <div class="flex items-center justify-center h-full p-4"> | ||||
|     <n-card class="w-full max-w-md" title="Authorize Application"> | ||||
|       <n-spin :show="isLoading"> | ||||
|         <div v-if="error" class="mb-4"> | ||||
|           <n-alert type="error" :title="error" closable @close="error = null" /> | ||||
|         </div> | ||||
|  | ||||
|         <!-- App Info Section --> | ||||
|         <div v-if="clientInfo" class="mb-6"> | ||||
|           <div class="flex items-center"> | ||||
|             <n-avatar | ||||
|               v-if="clientInfo.picture" | ||||
|               :src="clientInfo.picture.url" | ||||
|               :alt="clientInfo.client_name" | ||||
|               size="large" | ||||
|               class="mr-3" | ||||
|             /> | ||||
|             <div> | ||||
|               <h2 class="text-xl font-semibold"> | ||||
|                 {{ clientInfo.client_name || 'Unknown Application' }} | ||||
|               </h2> | ||||
|               <span v-if="isNewApp">wants to access your Solar Network account</span> | ||||
|               <span v-else>wants to access your account</span> | ||||
|             </div> | ||||
|           </div> | ||||
|  | ||||
|           <!-- Requested Permissions --> | ||||
|           <n-card size="small" class="mt-4"> | ||||
|             <h3 class="font-medium mb-2"> | ||||
|               This will allow {{ clientInfo.client_name || 'the app' }} to: | ||||
|             </h3> | ||||
|             <ul class="space-y-1"> | ||||
|               <li v-for="scope in requestedScopes" :key="scope" class="flex items-start"> | ||||
|                 <n-icon :component="CheckBoxFilled" class="mt-1 mr-2" /> | ||||
|                 <span>{{ scope }}</span> | ||||
|               </li> | ||||
|             </ul> | ||||
|           </n-card> | ||||
|  | ||||
|           <!-- Buttons --> | ||||
|           <div class="flex gap-3 mt-4"> | ||||
|             <n-button | ||||
|               type="primary" | ||||
|               :loading="isAuthorizing" | ||||
|               @click="handleAuthorize" | ||||
|               class="flex-grow-1 w-1/2" | ||||
|             > | ||||
|               Authorize | ||||
|             </n-button> | ||||
|             <n-button | ||||
|               type="tertiary" | ||||
|               :disabled="isAuthorizing" | ||||
|               @click="handleDeny" | ||||
|               class="flex-grow-1 w-1/2" | ||||
|             > | ||||
|               Deny | ||||
|             </n-button> | ||||
|           </div> | ||||
|  | ||||
|           <div class="mt-4 text-xs text-gray-500 text-center"> | ||||
|             By authorizing, you agree to the | ||||
|             <n-button text type="primary" size="tiny" @click="openTerms" class="px-1"> | ||||
|               Terms of Service | ||||
|             </n-button> | ||||
|             and | ||||
|             <n-button text type="primary" size="tiny" @click="openPrivacy" class="px-1"> | ||||
|               Privacy Policy | ||||
|             </n-button> | ||||
|           </div> | ||||
|         </div> | ||||
|       </n-spin> | ||||
|     </n-card> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { ref, computed, onMounted } from 'vue' | ||||
| import { useRoute } from 'vue-router' | ||||
| import { NCard, NButton, NSpin, NAlert, NAvatar, NIcon } from 'naive-ui' | ||||
| import { CheckBoxFilled } from '@vicons/material' | ||||
|  | ||||
| const route = useRoute() | ||||
|  | ||||
| // State | ||||
| const isLoading = ref(true) | ||||
| const isAuthorizing = ref(false) | ||||
| const error = ref<string | null>(null) | ||||
| const clientInfo = ref<{ | ||||
|   client_name?: string | ||||
|   home_uri?: string | ||||
|   picture?: { url: string } | ||||
|   terms_of_service_uri?: string | ||||
|   privacy_policy_uri?: string | ||||
|   scopes?: string[] | ||||
| } | null>(null) | ||||
| const isNewApp = ref(false) | ||||
|  | ||||
| // Computed properties | ||||
| const requestedScopes = computed(() => { | ||||
|   return clientInfo.value?.scopes || [] | ||||
| }) | ||||
|  | ||||
| // Methods | ||||
| async function fetchClientInfo() { | ||||
|   try { | ||||
|     const response = await fetch(`/api/auth/open/authorize?${window.location.search.slice(1)}`) | ||||
|     if (!response.ok) { | ||||
|       const errorData = await response.json() | ||||
|       throw new Error(errorData.error_description || 'Failed to load authorization request') | ||||
|     } | ||||
|     clientInfo.value = await response.json() | ||||
|     checkIfNewApp() | ||||
|   } catch (err: any) { | ||||
|     error.value = err.message || 'An error occurred while loading the authorization request' | ||||
|   } finally { | ||||
|     isLoading.value = false | ||||
|   } | ||||
| } | ||||
|  | ||||
| function checkIfNewApp() { | ||||
|   // In a real app, you might want to check if this is the first time authorizing this app | ||||
|   // For now, we'll just set it to false | ||||
|   isNewApp.value = false | ||||
| } | ||||
|  | ||||
| async function handleAuthorize() { | ||||
|   isAuthorizing.value = true | ||||
|   try { | ||||
|     // In a real implementation, you would submit the authorization | ||||
|     const response = await fetch('/api/auth/open/authorize', { | ||||
|       method: 'POST', | ||||
|       headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | ||||
|       body: new URLSearchParams({ | ||||
|         ...route.query, | ||||
|         authorize: 'true', | ||||
|       }), | ||||
|     }) | ||||
|  | ||||
|     if (!response.ok) { | ||||
|       const errorData = await response.json() | ||||
|       throw new Error(errorData.error_description || 'Authorization failed') | ||||
|     } | ||||
|  | ||||
|     const data = await response.json() | ||||
|     if (data.redirect_uri) { | ||||
|       window.open(data.redirect_uri, '_self') | ||||
|     } | ||||
|   } catch (err: any) { | ||||
|     error.value = err.message || 'An error occurred during authorization' | ||||
|   } finally { | ||||
|     isAuthorizing.value = false | ||||
|   } | ||||
| } | ||||
|  | ||||
| function handleDeny() { | ||||
|   // Redirect back to the client with an error | ||||
|   // Ensure redirect_uri is always a string (not an array) | ||||
|   const redirectUriStr = Array.isArray(route.query.redirect_uri) | ||||
|     ? route.query.redirect_uri[0] || clientInfo.value?.home_uri || '/' | ||||
|     : route.query.redirect_uri || clientInfo.value?.home_uri || '/' | ||||
|   const redirectUri = new URL(redirectUriStr) | ||||
|   // Ensure state is always a string (not an array) | ||||
|   const state = Array.isArray(route.query.state) | ||||
|     ? route.query.state[0] || '' | ||||
|     : route.query.state || '' | ||||
|   const params = new URLSearchParams({ | ||||
|     error: 'access_denied', | ||||
|     error_description: 'The user denied the authorization request', | ||||
|     state: state, | ||||
|   }) | ||||
|   window.open(`${redirectUri}?${params}`, "_self") | ||||
| } | ||||
|  | ||||
| function openTerms() { | ||||
|   window.open(clientInfo.value?.terms_of_service_uri || '#', "_blank") | ||||
| } | ||||
|  | ||||
| function openPrivacy() { | ||||
|   window.open(clientInfo.value?.privacy_policy_uri || '#', "_blank") | ||||
| } | ||||
|  | ||||
| // Lifecycle | ||||
| onMounted(() => { | ||||
|   fetchClientInfo() | ||||
| }) | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| /* Add any custom styles here */ | ||||
| </style> | ||||
|   | ||||
							
								
								
									
										34
									
								
								DysonNetwork.Pass/Credit/SocialCreditRecord.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								DysonNetwork.Pass/Credit/SocialCreditRecord.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using NodaTime; | ||||
| using NodaTime.Serialization.Protobuf; | ||||
|  | ||||
| namespace DysonNetwork.Pass.Credit; | ||||
|  | ||||
| public class SocialCreditRecord : ModelBase | ||||
| { | ||||
|     public Guid Id { get; set; } | ||||
|     [MaxLength(1024)] public string ReasonType { get; set; } = string.Empty; | ||||
|     [MaxLength(1024)] public string Reason { get; set; } = string.Empty; | ||||
|     public double Delta { get; set; } | ||||
|     public Instant? ExpiredAt { get; set; } | ||||
|      | ||||
|     public Guid AccountId { get; set; } | ||||
|     public Account.Account Account { get; set; } = null!; | ||||
|  | ||||
|     public Shared.Proto.SocialCreditRecord ToProto() | ||||
|     { | ||||
|         var proto = new Shared.Proto.SocialCreditRecord | ||||
|         { | ||||
|             Id = Id.ToString(), | ||||
|             ReasonType = ReasonType, | ||||
|             Reason = Reason, | ||||
|             Delta = Delta, | ||||
|             AccountId = AccountId.ToString(), | ||||
|             CreatedAt = CreatedAt.ToTimestamp(), | ||||
|             UpdatedAt = UpdatedAt.ToTimestamp() | ||||
|         }; | ||||
|  | ||||
|         return proto; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										46
									
								
								DysonNetwork.Pass/Credit/SocialCreditService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								DysonNetwork.Pass/Credit/SocialCreditService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
|  | ||||
| namespace DysonNetwork.Pass.Credit; | ||||
|  | ||||
| public class SocialCreditService(AppDatabase db, ICacheService cache) | ||||
| { | ||||
|     private const string CacheKeyPrefix = "account:credits:"; | ||||
|      | ||||
|     public async Task<SocialCreditRecord> AddRecord(string reasonType, string reason, double delta, Guid accountId) | ||||
|     { | ||||
|         var record = new SocialCreditRecord | ||||
|         { | ||||
|             ReasonType = reasonType, | ||||
|             Reason = reason, | ||||
|             Delta = delta, | ||||
|             AccountId = accountId, | ||||
|         }; | ||||
|         db.SocialCreditRecords.Add(record); | ||||
|         await db.SaveChangesAsync(); | ||||
|          | ||||
|         await db.AccountProfiles | ||||
|             .Where(p => p.AccountId == accountId) | ||||
|             .ExecuteUpdateAsync(p => p.SetProperty(v => v.SocialCredits, v => v.SocialCredits + record.Delta)); | ||||
|          | ||||
|         await cache.RemoveAsync($"{CacheKeyPrefix}{accountId}"); | ||||
|          | ||||
|         return record; | ||||
|     } | ||||
|      | ||||
|     private const double BaseSocialCredit = 100; | ||||
|      | ||||
|     public async Task<double> GetSocialCredit(Guid accountId) | ||||
|     { | ||||
|         var cached = await cache.GetAsync<double?>($"{CacheKeyPrefix}{accountId}"); | ||||
|         if (cached.HasValue) return cached.Value; | ||||
|          | ||||
|         var records = await db.SocialCreditRecords | ||||
|             .Where(x => x.AccountId == accountId) | ||||
|             .SumAsync(x => x.Delta); | ||||
|         records += BaseSocialCredit; | ||||
|          | ||||
|         await cache.SetAsync($"{CacheKeyPrefix}{accountId}", records); | ||||
|         return records; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										27
									
								
								DysonNetwork.Pass/Credit/SocialCreditServiceGrpc.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								DysonNetwork.Pass/Credit/SocialCreditServiceGrpc.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using Grpc.Core; | ||||
|  | ||||
| namespace DysonNetwork.Pass.Credit; | ||||
|  | ||||
| public class SocialCreditServiceGrpc(SocialCreditService creditService) : Shared.Proto.SocialCreditService.SocialCreditServiceBase | ||||
| { | ||||
|     public override async Task<Shared.Proto.SocialCreditRecord> AddRecord(AddSocialCreditRecordRequest request, ServerCallContext context) | ||||
|     { | ||||
|         var accountId = Guid.Parse(request.AccountId); | ||||
|         var record = await creditService.AddRecord( | ||||
|             request.ReasonType, | ||||
|             request.Reason, | ||||
|             request.Delta, | ||||
|             accountId); | ||||
|  | ||||
|         return record.ToProto(); | ||||
|     } | ||||
|  | ||||
|     public override async Task<SocialCreditResponse> GetSocialCredit(GetSocialCreditRequest request, ServerCallContext context) | ||||
|     { | ||||
|         var accountId = Guid.Parse(request.AccountId); | ||||
|         var amount = await creditService.GetSocialCredit(accountId); | ||||
|          | ||||
|         return new SocialCreditResponse { Amount = amount }; | ||||
|     } | ||||
| } | ||||
| @@ -13,6 +13,7 @@ | ||||
|             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||
|             <PrivateAssets>all</PrivateAssets> | ||||
|         </PackageReference> | ||||
|         <PackageReference Include="Nager.Holiday" Version="1.0.1" /> | ||||
|         <PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115"> | ||||
|             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||
|             <PrivateAssets>all</PrivateAssets> | ||||
| @@ -23,7 +24,6 @@ | ||||
|         <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/> | ||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/> | ||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0"/> | ||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="9.0.4"/> | ||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4"/> | ||||
|         <PackageReference Include="OpenGraph-Net" Version="4.0.1" /> | ||||
|         <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/> | ||||
| @@ -49,6 +49,7 @@ | ||||
|     </ItemGroup> | ||||
|  | ||||
|     <ItemGroup> | ||||
|         <ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" /> | ||||
|         <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj"/> | ||||
|     </ItemGroup> | ||||
|  | ||||
| @@ -136,19 +137,12 @@ | ||||
|     </ItemGroup> | ||||
|  | ||||
|     <ItemGroup> | ||||
|         <AdditionalFiles Include="Pages\Emails\AccountDeletionEmail.razor"/> | ||||
|         <AdditionalFiles Include="Pages\Emails\ContactVerificationEmail.razor"/> | ||||
|         <AdditionalFiles Include="Pages\Emails\EmailLayout.razor"/> | ||||
|         <AdditionalFiles Include="Pages\Emails\LandingEmail.razor"/> | ||||
|         <AdditionalFiles Include="Pages\Emails\PasswordResetEmail.razor"/> | ||||
|         <AdditionalFiles Include="Pages\Emails\VerificationEmail.razor"/> | ||||
|         <AdditionalFiles Include="Resources\Localization\AccountEventResource.zh-hans.resx"/> | ||||
|         <AdditionalFiles Include="Resources\Localization\EmailResource.resx"/> | ||||
|         <AdditionalFiles Include="Resources\Localization\EmailResource.zh-hans.resx"/> | ||||
|         <AdditionalFiles Include="Resources\Localization\NotificationResource.resx"/> | ||||
|         <AdditionalFiles Include="Resources\Localization\NotificationResource.zh-hans.resx"/> | ||||
|         <AdditionalFiles Include="Resources\Localization\SharedResource.resx"/> | ||||
|         <AdditionalFiles Include="Resources\Localization\SharedResource.zh-hans.resx"/> | ||||
|         <AdditionalFiles Include="Pages\Emails\AccountDeletionEmail.razor" /> | ||||
|         <AdditionalFiles Include="Pages\Emails\ContactVerificationEmail.razor" /> | ||||
|         <AdditionalFiles Include="Pages\Emails\EmailLayout.razor" /> | ||||
|         <AdditionalFiles Include="Pages\Emails\LandingEmail.razor" /> | ||||
|         <AdditionalFiles Include="Pages\Emails\PasswordResetEmail.razor" /> | ||||
|         <AdditionalFiles Include="Pages\Emails\VerificationEmail.razor" /> | ||||
|     </ItemGroup> | ||||
|      | ||||
| </Project> | ||||
|   | ||||
| @@ -1,12 +1,10 @@ | ||||
| using dotnet_etcd; | ||||
| using dotnet_etcd.interfaces; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using Microsoft.AspNetCore.Components; | ||||
|  | ||||
| namespace DysonNetwork.Pass.Email; | ||||
|  | ||||
| public class EmailService( | ||||
|     PusherService.PusherServiceClient pusher, | ||||
|     RingService.RingServiceClient pusher, | ||||
|     RazorViewRenderer viewRenderer, | ||||
|     ILogger<EmailService> logger | ||||
| ) | ||||
|   | ||||
| @@ -42,8 +42,17 @@ public class LastActiveFlushHandler(IServiceProvider srp, ILogger<LastActiveFlus | ||||
|         var updatingSessions = sessionMap.Select(x => x.Key).ToList(); | ||||
|         var sessionUpdates = await db.AuthSessions | ||||
|             .Where(s => updatingSessions.Contains(s.Id)) | ||||
|             .ExecuteUpdateAsync(s => s.SetProperty(x => x.LastGrantedAt, now)); | ||||
|             .ExecuteUpdateAsync(s => | ||||
|                 s.SetProperty(x => x.LastGrantedAt, now) | ||||
|             ); | ||||
|         logger.LogInformation("Updated {Count} auth sessions according to LastActiveInfo", sessionUpdates); | ||||
|         var newExpiration = now.Plus(Duration.FromDays(7)); | ||||
|         var keepAliveSessionUpdates = await db.AuthSessions | ||||
|             .Where(s => updatingSessions.Contains(s.Id) && s.ExpiredAt != null) | ||||
|             .ExecuteUpdateAsync(s => | ||||
|                 s.SetProperty(x => x.ExpiredAt, newExpiration) | ||||
|             ); | ||||
|         logger.LogInformation("Updated {Count} auth sessions' duration according to LastActiveInfo", sessionUpdates); | ||||
|  | ||||
|         var updatingAccounts = accountMap.Select(x => x.Key).ToList(); | ||||
|         var profileUpdates = await db.AccountProfiles | ||||
| @@ -53,7 +62,8 @@ public class LastActiveFlushHandler(IServiceProvider srp, ILogger<LastActiveFlus | ||||
|     } | ||||
| } | ||||
|  | ||||
| public class LastActiveFlushJob(FlushBufferService fbs, LastActiveFlushHandler hdl, ILogger<LastActiveFlushJob> logger) : IJob | ||||
| public class LastActiveFlushJob(FlushBufferService fbs, LastActiveFlushHandler hdl, ILogger<LastActiveFlushJob> logger) | ||||
|     : IJob | ||||
| { | ||||
|     public async Task Execute(IJobExecutionContext context) | ||||
|     { | ||||
| @@ -62,7 +72,8 @@ public class LastActiveFlushJob(FlushBufferService fbs, LastActiveFlushHandler h | ||||
|             logger.LogInformation("Running LastActiveInfo flush job..."); | ||||
|             await fbs.FlushAsync(hdl); | ||||
|             logger.LogInformation("Completed LastActiveInfo flush job..."); | ||||
|         } catch (Exception ex) | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             logger.LogError(ex, "Error running LastActiveInfo job..."); | ||||
|         } | ||||
|   | ||||
							
								
								
									
										39
									
								
								DysonNetwork.Pass/IpCheckController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								DysonNetwork.Pass/IpCheckController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
|  | ||||
| namespace DysonNetwork.Pass; | ||||
|  | ||||
| [ApiController] | ||||
| [Route("/api/ip-check")] | ||||
| public class IpCheckController : ControllerBase | ||||
| { | ||||
|     public class IpCheckResponse | ||||
|     { | ||||
|         public string? RemoteIp { get; set; } | ||||
|         public string? XForwardedFor { get; set; } | ||||
|         public string? XForwardedProto { get; set; } | ||||
|         public string? XForwardedHost { get; set; } | ||||
|         public string? XRealIp { get; set; } | ||||
|         public string? Headers { get; set; } | ||||
|     } | ||||
|      | ||||
|     [HttpGet] | ||||
|     public ActionResult<IpCheckResponse> GetIpCheck() | ||||
|     { | ||||
|         var ip = HttpContext.Connection.RemoteIpAddress?.ToString(); | ||||
|  | ||||
|         var xForwardedFor = Request.Headers["X-Forwarded-For"].FirstOrDefault(); | ||||
|         var xForwardedProto = Request.Headers["X-Forwarded-Proto"].FirstOrDefault(); | ||||
|         var xForwardedHost = Request.Headers["X-Forwarded-Host"].FirstOrDefault(); | ||||
|         var realIp = Request.Headers["X-Real-IP"].FirstOrDefault(); | ||||
|  | ||||
|         return Ok(new IpCheckResponse | ||||
|         { | ||||
|             RemoteIp = ip, | ||||
|             XForwardedFor = xForwardedFor, | ||||
|             XForwardedProto = xForwardedProto, | ||||
|             XForwardedHost = xForwardedHost, | ||||
|             XRealIp = realIp, | ||||
|             Headers = string.Join('\n', Request.Headers.Select(h => $"{h.Key}: {h.Value}")), | ||||
|         }); | ||||
|     }  | ||||
| } | ||||
							
								
								
									
										34
									
								
								DysonNetwork.Pass/Leveling/ExperienceRecord.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								DysonNetwork.Pass/Leveling/ExperienceRecord.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using NodaTime.Serialization.Protobuf; | ||||
|  | ||||
| namespace DysonNetwork.Pass.Leveling; | ||||
|  | ||||
| public class ExperienceRecord : ModelBase | ||||
| { | ||||
|     public Guid Id { get; set; } = Guid.NewGuid(); | ||||
|     [MaxLength(1024)] public string ReasonType { get; set; } = string.Empty; | ||||
|     [MaxLength(1024)] public string Reason { get; set; } = string.Empty; | ||||
|     public long Delta { get; set; } | ||||
|     public double BonusMultiplier { get; set; } | ||||
|      | ||||
|     public Guid AccountId { get; set; } | ||||
|     public Account.Account Account { get; set; } = null!; | ||||
|  | ||||
|     public Shared.Proto.ExperienceRecord ToProto() | ||||
|     { | ||||
|         var proto = new Shared.Proto.ExperienceRecord | ||||
|         { | ||||
|             Id = Id.ToString(), | ||||
|             ReasonType = ReasonType, | ||||
|             Reason = Reason, | ||||
|             Delta = Delta, | ||||
|             BonusMultiplier = BonusMultiplier, | ||||
|             AccountId = AccountId.ToString(), | ||||
|             CreatedAt = CreatedAt.ToTimestamp(), | ||||
|             UpdatedAt = UpdatedAt.ToTimestamp() | ||||
|         }; | ||||
|  | ||||
|         return proto; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										42
									
								
								DysonNetwork.Pass/Leveling/ExperienceService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								DysonNetwork.Pass/Leveling/ExperienceService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | ||||
| using DysonNetwork.Pass.Wallet; | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
|  | ||||
| namespace DysonNetwork.Pass.Leveling; | ||||
|  | ||||
| public class ExperienceService(AppDatabase db, SubscriptionService subscriptions, ICacheService cache) | ||||
| { | ||||
|     public async Task<ExperienceRecord> AddRecord(string reasonType, string reason, long delta, Guid accountId) | ||||
|     { | ||||
|         var record = new ExperienceRecord | ||||
|         { | ||||
|             ReasonType = reasonType, | ||||
|             Reason = reason, | ||||
|             Delta = delta, | ||||
|             AccountId = accountId, | ||||
|         }; | ||||
|  | ||||
|         var perkSubscription = await subscriptions.GetPerkSubscriptionAsync(accountId); | ||||
|         if (perkSubscription is not null) | ||||
|         { | ||||
|             record.BonusMultiplier = perkSubscription.Identifier switch | ||||
|             { | ||||
|                 SubscriptionType.Stellar => 1.5, | ||||
|                 SubscriptionType.Nova => 2, | ||||
|                 SubscriptionType.Supernova => 2, | ||||
|                 _ => 1 | ||||
|             }; | ||||
|             if (record.Delta >= 0) | ||||
|                 record.Delta = (long)Math.Floor(record.Delta * record.BonusMultiplier); | ||||
|         } | ||||
|  | ||||
|         db.ExperienceRecords.Add(record); | ||||
|         await db.SaveChangesAsync(); | ||||
|          | ||||
|         await db.AccountProfiles | ||||
|             .Where(p => p.AccountId == accountId) | ||||
|             .ExecuteUpdateAsync(p => p.SetProperty(v => v.Experience, v => v.Experience + record.Delta)); | ||||
|          | ||||
|         return record; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										19
									
								
								DysonNetwork.Pass/Leveling/ExperienceServiceGrpc.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								DysonNetwork.Pass/Leveling/ExperienceServiceGrpc.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using Grpc.Core; | ||||
|  | ||||
| namespace DysonNetwork.Pass.Leveling; | ||||
|  | ||||
| public class ExperienceServiceGrpc(ExperienceService experienceService) : Shared.Proto.ExperienceService.ExperienceServiceBase | ||||
| { | ||||
|     public override async Task<Shared.Proto.ExperienceRecord> AddRecord(AddExperienceRecordRequest request, ServerCallContext context) | ||||
|     { | ||||
|         var accountId = Guid.Parse(request.AccountId); | ||||
|         var record = await experienceService.AddRecord( | ||||
|             request.ReasonType, | ||||
|             request.Reason, | ||||
|             request.Delta, | ||||
|             accountId); | ||||
|  | ||||
|         return record.ToProto(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										1821
									
								
								DysonNetwork.Pass/Migrations/20250818125540_RemoveChallengeOldDeviceId.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										1821
									
								
								DysonNetwork.Pass/Migrations/20250818125540_RemoveChallengeOldDeviceId.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -0,0 +1,29 @@ | ||||
| using Microsoft.EntityFrameworkCore.Migrations; | ||||
|  | ||||
| #nullable disable | ||||
|  | ||||
| namespace DysonNetwork.Pass.Migrations | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     public partial class RemoveChallengeOldDeviceId : Migration | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Up(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.DropColumn( | ||||
|                 name: "device_id", | ||||
|                 table: "auth_challenges"); | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Down(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.AddColumn<string>( | ||||
|                 name: "device_id", | ||||
|                 table: "auth_challenges", | ||||
|                 type: "character varying(1024)", | ||||
|                 maxLength: 1024, | ||||
|                 nullable: true); | ||||
|         } | ||||
|     } | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user