Compare commits
	
		
			97 Commits
		
	
	
		
			8c748fd57a
			...
			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 | 
							
								
								
									
										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" | ||||
|   } | ||||
| } | ||||
| @@ -31,6 +31,7 @@ | ||||
|     </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" /> | ||||
|     <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> | ||||
|   </ItemGroup> | ||||
|   | ||||
|   | ||||
| @@ -1,17 +1,16 @@ | ||||
| using DysonNetwork.Develop; | ||||
| using DysonNetwork.Shared.Auth; | ||||
| using DysonNetwork.Shared.Http; | ||||
| using DysonNetwork.Shared.Registry; | ||||
| using DysonNetwork.Develop.Startup; | ||||
| using DysonNetwork.Shared.Stream; | ||||
| 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.AddStreamConnection(builder.Configuration); | ||||
| builder.Services.AddAppServices(builder.Configuration); | ||||
| builder.Services.AddAppAuthentication(); | ||||
| builder.Services.AddAppSwagger(); | ||||
| @@ -22,6 +21,8 @@ builder.Services.AddDriveService(); | ||||
|  | ||||
| var app = builder.Build(); | ||||
|  | ||||
| app.MapDefaultEndpoints(); | ||||
|  | ||||
| using (var scope = app.Services.CreateScope()) | ||||
| { | ||||
|     var db = scope.ServiceProvider.GetRequiredService<AppDatabase>(); | ||||
|   | ||||
| @@ -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,6 +3,7 @@ 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; | ||||
| @@ -19,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); | ||||
|         }); | ||||
|  | ||||
|   | ||||
| @@ -10,10 +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", | ||||
|     "Stream": "nats.orb.local:4222" | ||||
|     "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", | ||||
| @@ -24,8 +21,6 @@ | ||||
|   }, | ||||
|   "Service": { | ||||
|     "Name": "DysonNetwork.Develop", | ||||
|     "Url": "https://localhost:7192", | ||||
|     "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> | ||||
|  | ||||
|   | ||||
							
								
								
									
										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 => | ||||
|   | ||||
| @@ -5,18 +5,18 @@ using DysonNetwork.Shared.Auth; | ||||
| using DysonNetwork.Shared.Http; | ||||
| using DysonNetwork.Shared.PageData; | ||||
| using DysonNetwork.Shared.Registry; | ||||
| using DysonNetwork.Shared.Stream; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| 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.AddStreamConnection(builder.Configuration); | ||||
|  | ||||
| builder.Services.AddAppServices(builder.Configuration); | ||||
| builder.Services.AddAppRateLimiting(); | ||||
| builder.Services.AddAppAuthentication(); | ||||
| @@ -39,6 +39,8 @@ builder.Services.AddTransient<IPageDataProvider, VersionPageData>(); | ||||
|  | ||||
| var app = builder.Build(); | ||||
|  | ||||
| app.MapDefaultEndpoints(); | ||||
|  | ||||
| // Run database migrations | ||||
| using (var scope = app.Services.CreateScope()) | ||||
| { | ||||
| @@ -51,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 | ||||
|   | ||||
| @@ -3,6 +3,8 @@ 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; | ||||
|  | ||||
| @@ -14,12 +16,23 @@ public class BroadcastEventHandler( | ||||
| { | ||||
|     protected override async Task ExecuteAsync(CancellationToken stoppingToken) | ||||
|     { | ||||
|         await foreach (var msg in nats.SubscribeAsync<byte[]>("accounts.deleted", 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) continue; | ||||
|                 if (evt == null) | ||||
|                 { | ||||
|                     await msg.AckAsync(cancellationToken: stoppingToken); | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 logger.LogInformation("Account deleted: {AccountId}", evt.AccountId); | ||||
|  | ||||
| @@ -46,10 +59,13 @@ public class BroadcastEventHandler( | ||||
|                     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; | ||||
|  | ||||
|   | ||||
| @@ -337,8 +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)) | ||||
|                         { | ||||
| @@ -672,8 +678,8 @@ public class FileService( | ||||
|             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"); | ||||
|                 if (file.HasCompression) objectsToDelete.Add(file.StorageId ?? file.Id + ".compressed"); | ||||
|                 if (file.HasThumbnail) objectsToDelete.Add(file.StorageId ?? file.Id + ".thumbnail"); | ||||
|             } | ||||
|  | ||||
|             await client.RemoveObjectsAsync( | ||||
|   | ||||
| @@ -10,10 +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", | ||||
|     "Stream": "nats.orb.local:4222" | ||||
|     "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": { | ||||
| @@ -131,8 +128,6 @@ | ||||
|   ], | ||||
|   "Service": { | ||||
|     "Name": "DysonNetwork.Drive", | ||||
|     "Url": "https://localhost:7092", | ||||
|     "ClientCert": "../Certificates/client.crt", | ||||
|     "ClientKey": "../Certificates/client.key" | ||||
|     "Url": "https://localhost:7092" | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,78 +0,0 @@ | ||||
| using System.Text; | ||||
| using dotnet_etcd.interfaces; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Yarp.ReverseProxy.Configuration; | ||||
|  | ||||
| namespace DysonNetwork.Gateway.Controllers; | ||||
|  | ||||
| [ApiController] | ||||
| [Route("/.well-known")] | ||||
| public class WellKnownController( | ||||
|     IConfiguration configuration, | ||||
|     IProxyConfigProvider proxyConfigProvider, | ||||
|     IEtcdClient etcdClient) | ||||
|     : ControllerBase | ||||
| { | ||||
|     [HttpGet("domains")] | ||||
|     public IActionResult GetDomainMappings() | ||||
|     { | ||||
|         var domainMappings = configuration.GetSection("DomainMappings").GetChildren() | ||||
|             .ToDictionary(x => x.Key, x => x.Value); | ||||
|         return Ok(domainMappings); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("services")] | ||||
|     public IActionResult GetServices() | ||||
|     { | ||||
|         var local = configuration.GetValue<bool>("LocalMode"); | ||||
|         var response = etcdClient.GetRange("/services/"); | ||||
|         var kvs = response.Kvs; | ||||
|  | ||||
|         var serviceMap = kvs.ToDictionary( | ||||
|             kv => Encoding.UTF8.GetString(kv.Key.ToByteArray()).Replace("/services/", ""), | ||||
|             kv => Encoding.UTF8.GetString(kv.Value.ToByteArray()) | ||||
|         ); | ||||
|  | ||||
|         if (local) return Ok(serviceMap); | ||||
|          | ||||
|         var domainMappings = configuration.GetSection("DomainMappings").GetChildren() | ||||
|             .ToDictionary(x => x.Key, x => x.Value); | ||||
|         foreach (var (key, _) in serviceMap.ToList()) | ||||
|         { | ||||
|             if (!domainMappings.TryGetValue(key, out var domain)) continue; | ||||
|             if (domain is not null) | ||||
|                 serviceMap[key] = "https://" + domain; | ||||
|         } | ||||
|  | ||||
|         return Ok(serviceMap); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("routes")] | ||||
|     public IActionResult GetProxyRules() | ||||
|     { | ||||
|         var config = proxyConfigProvider.GetConfig(); | ||||
|         var rules = config.Routes.Select(r => new | ||||
|         { | ||||
|             r.RouteId, | ||||
|             r.ClusterId, | ||||
|             Match = new | ||||
|             { | ||||
|                 r.Match.Path, | ||||
|                 Hosts = r.Match.Hosts != null ? string.Join(", ", r.Match.Hosts) : null | ||||
|             }, | ||||
|             Transforms = r.Transforms?.Select(t => t.Select(kv => $"{kv.Key}: {kv.Value}").ToList()) | ||||
|         }).ToList(); | ||||
|  | ||||
|         var clusters = config.Clusters.Select(c => new | ||||
|         { | ||||
|             c.ClusterId, | ||||
|             Destinations = c.Destinations?.Select(d => new | ||||
|             { | ||||
|                 d.Key, | ||||
|                 d.Value.Address | ||||
|             }).ToList() | ||||
|         }).ToList(); | ||||
|  | ||||
|         return Ok(new { Rules = rules, Clusters = clusters }); | ||||
|     } | ||||
| } | ||||
| @@ -1,23 +0,0 @@ | ||||
| FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base | ||||
| USER $APP_UID | ||||
| WORKDIR /app | ||||
| EXPOSE 8080 | ||||
| EXPOSE 8081 | ||||
|  | ||||
| FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build | ||||
| ARG BUILD_CONFIGURATION=Release | ||||
| WORKDIR /src | ||||
| COPY ["DysonNetwork.Gateway/DysonNetwork.Gateway.csproj", "DysonNetwork.Gateway/"] | ||||
| RUN dotnet restore "DysonNetwork.Gateway/DysonNetwork.Gateway.csproj" | ||||
| COPY . . | ||||
| WORKDIR "/src/DysonNetwork.Gateway" | ||||
| RUN dotnet build "./DysonNetwork.Gateway.csproj" -c $BUILD_CONFIGURATION -o /app/build | ||||
|  | ||||
| FROM build AS publish | ||||
| ARG BUILD_CONFIGURATION=Release | ||||
| RUN dotnet publish "./DysonNetwork.Gateway.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false | ||||
|  | ||||
| FROM base AS final | ||||
| WORKDIR /app | ||||
| COPY --from=publish /app/publish . | ||||
| ENTRYPOINT ["dotnet", "DysonNetwork.Gateway.dll"] | ||||
| @@ -1,23 +0,0 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk.Web"> | ||||
|  | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net9.0</TargetFramework> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|   </PropertyGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="dotnet-etcd" Version="8.0.1" /> | ||||
|     <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" /> | ||||
|     <PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115"> | ||||
|       <PrivateAssets>all</PrivateAssets> | ||||
|       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||
|     </PackageReference> | ||||
|     <PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
| </Project> | ||||
| @@ -1,38 +0,0 @@ | ||||
| using DysonNetwork.Gateway.Startup; | ||||
| using Microsoft.AspNetCore.HttpOverrides; | ||||
|  | ||||
| var builder = WebApplication.CreateBuilder(args); | ||||
|  | ||||
| builder.Host.UseContentRoot(Directory.GetCurrentDirectory()); | ||||
| builder.WebHost.ConfigureKestrel(options => | ||||
| { | ||||
|     options.Limits.MaxRequestBodySize = long.MaxValue; | ||||
|     options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2); | ||||
|     options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30); | ||||
| }); | ||||
|  | ||||
| // Add services to the container. | ||||
| builder.Services.AddGateway(builder.Configuration); | ||||
| builder.Services.AddControllers(); | ||||
|  | ||||
| var app = builder.Build(); | ||||
|  | ||||
| app.UseForwardedHeaders(new ForwardedHeadersOptions | ||||
| { | ||||
|     ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ||||
| }); | ||||
|  | ||||
| app.UseRequestTimeouts(); | ||||
| app.UseCors(opts => | ||||
|     opts.SetIsOriginAllowed(_ => true) | ||||
|         .WithExposedHeaders("*") | ||||
|         .WithHeaders("*") | ||||
|         .AllowCredentials() | ||||
|         .AllowAnyHeader() | ||||
|         .AllowAnyMethod() | ||||
| ); | ||||
|  | ||||
| app.MapControllers(); | ||||
| app.MapReverseProxy(); | ||||
|  | ||||
| app.Run(); | ||||
| @@ -1,23 +0,0 @@ | ||||
| { | ||||
|   "$schema": "https://json.schemastore.org/launchsettings.json", | ||||
|   "profiles": { | ||||
|     "http": { | ||||
|       "commandName": "Project", | ||||
|       "dotnetRunMessages": true, | ||||
|       "launchBrowser": false, | ||||
|       "applicationUrl": "http://localhost:5094", | ||||
|       "environmentVariables": { | ||||
|         "ASPNETCORE_ENVIRONMENT": "Development" | ||||
|       } | ||||
|     }, | ||||
|     "https": { | ||||
|       "commandName": "Project", | ||||
|       "dotnetRunMessages": true, | ||||
|       "launchBrowser": false, | ||||
|       "applicationUrl": "https://localhost:7034;http://0.0.0.0:5094", | ||||
|       "environmentVariables": { | ||||
|         "ASPNETCORE_ENVIRONMENT": "Development" | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -1,259 +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 route for /cgi to Gateway | ||||
|         var gatewayCluster = new ClusterConfig | ||||
|         { | ||||
|             ClusterId = "gateway-self", | ||||
|             Destinations = new Dictionary<string, DestinationConfig> | ||||
|             { | ||||
|                 { "self", new DestinationConfig { Address = _configuration["Kestrel:Endpoints:Http:Url"] ?? "http://localhost:5000" } } | ||||
|             } | ||||
|         }; | ||||
|         clusters.Add(gatewayCluster); | ||||
|  | ||||
|         var cgiRoute = new RouteConfig | ||||
|         { | ||||
|             RouteId = "gateway-cgi-route", | ||||
|             ClusterId = "gateway-self", | ||||
|             Match = new RouteMatch { Path = "/cgi/{**catch-all}" } | ||||
|         }; | ||||
|         routes.Add(cgiRoute); | ||||
|         _logger.LogInformation("    Added CGI Route: /cgi/** -> Gateway"); | ||||
|  | ||||
|         // 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,35 +0,0 @@ | ||||
| using System.Net.Security; | ||||
| using System.Security.Cryptography.X509Certificates; | ||||
| using DysonNetwork.Shared.Registry; | ||||
| using Yarp.ReverseProxy.Configuration; | ||||
| using Yarp.ReverseProxy.Transforms; | ||||
|  | ||||
| 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 | ||||
|                 }; | ||||
|             }) | ||||
|             .AddTransforms(context => | ||||
|             { | ||||
|                 context.AddForwarded(); | ||||
|             }); | ||||
|  | ||||
|         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,6 +18,7 @@ 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; | ||||
|  | ||||
| @@ -46,6 +47,7 @@ public class Account : ModelBase | ||||
|             Name = Name, | ||||
|             Nick = Nick, | ||||
|             Language = Language, | ||||
|             Region = Region, | ||||
|             ActivatedAt = ActivatedAt?.ToTimestamp(), | ||||
|             IsSuperuser = IsSuperuser, | ||||
|             Profile = Profile.ToProtoValue(), | ||||
| @@ -75,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 | ||||
|   | ||||
| @@ -3,6 +3,7 @@ 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; | ||||
| @@ -17,7 +18,8 @@ public class AccountController( | ||||
|     AccountService accounts, | ||||
|     SubscriptionService subscriptions, | ||||
|     AccountEventService events, | ||||
|     SocialCreditService socialCreditService | ||||
|     SocialCreditService socialCreditService, | ||||
|     GeoIpService geo | ||||
| ) : ControllerBase | ||||
| { | ||||
|     [HttpGet("{name}")] | ||||
| @@ -48,7 +50,9 @@ public class AccountController( | ||||
|             .Include(e => e.Badges) | ||||
|             .Where(a => a.Name == name) | ||||
|             .FirstOrDefaultAsync(); | ||||
|         return account is null ? NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier)) : account.Badges.ToList(); | ||||
|         return account is null | ||||
|             ? NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier)) | ||||
|             : account.Badges.ToList(); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("{name}/credits")] | ||||
| @@ -93,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; | ||||
|     } | ||||
| @@ -109,6 +113,10 @@ public class AccountController( | ||||
|                 [nameof(request.CaptchaToken)] = ["Invalid captcha token."] | ||||
|             }, traceId: HttpContext.TraceIdentifier)); | ||||
|  | ||||
|         var ip = HttpContext.Connection.RemoteIpAddress?.ToString(); | ||||
|         if (ip is null) return BadRequest(ApiError.NotFound(request.Name, traceId: HttpContext.TraceIdentifier)); | ||||
|         var region = geo.GetFromIp(ip)?.Country.IsoCode ?? "us"; | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var account = await accounts.CreateAccount( | ||||
| @@ -116,7 +124,8 @@ public class AccountController( | ||||
|                 request.Nick, | ||||
|                 request.Email, | ||||
|                 request.Password, | ||||
|                 request.Language | ||||
|                 request.Language, | ||||
|                 region | ||||
|             ); | ||||
|             return Ok(account); | ||||
|         } | ||||
| @@ -182,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; } | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -52,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] | ||||
| @@ -63,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); | ||||
| @@ -195,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 | ||||
| @@ -203,11 +207,15 @@ public class AccountCurrentController( | ||||
|             .OrderByDescending(e => e.CreatedAt) | ||||
|             .FirstOrDefaultAsync(); | ||||
|         if (status is null) return NotFound(ApiError.NotFound("status", traceId: HttpContext.TraceIdentifier)); | ||||
|         if (status.IsAutomated && request.AppIdentifier is null) | ||||
|             return BadRequest("Automated status cannot be updated."); | ||||
|  | ||||
|         status.Attitude = request.Attitude; | ||||
|         status.IsInvisible = request.IsInvisible; | ||||
|         status.IsNotDisturb = request.IsNotDisturb; | ||||
|         status.IsAutomated = request.IsAutomated; | ||||
|         status.Label = request.Label; | ||||
|         status.AppIdentifier = request.AppIdentifier; | ||||
|         status.ClearedAt = request.ClearedAt; | ||||
|  | ||||
|         db.Update(status); | ||||
| @@ -223,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 | ||||
|         }; | ||||
|  | ||||
| @@ -237,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(); | ||||
|  | ||||
|   | ||||
| @@ -14,7 +14,7 @@ public class AccountEventService( | ||||
|     Wallet.PaymentService payment, | ||||
|     ICacheService cache, | ||||
|     IStringLocalizer<Localization.AccountEventResource> localizer, | ||||
|     PusherService.PusherServiceClient pusher, | ||||
|     RingService.RingServiceClient pusher, | ||||
|     SubscriptionService subscriptions, | ||||
|     Pass.Leveling.ExperienceService experienceService | ||||
| ) | ||||
|   | ||||
| @@ -13,6 +13,8 @@ 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; | ||||
| @@ -26,8 +28,9 @@ public class AccountService( | ||||
|     FileReferenceService.FileReferenceServiceClient fileRefs, | ||||
|     AccountUsernameService uname, | ||||
|     EmailService mailer, | ||||
|     PusherService.PusherServiceClient pusher, | ||||
|     RingService.RingServiceClient pusher, | ||||
|     IStringLocalizer<NotificationResource> localizer, | ||||
|     IStringLocalizer<EmailResource> emailLocalizer, | ||||
|     ICacheService cache, | ||||
|     ILogger<AccountService> logger, | ||||
|     INatsConnection nats | ||||
| @@ -87,6 +90,7 @@ public class AccountService( | ||||
|         string email, | ||||
|         string? password, | ||||
|         string language = "en-US", | ||||
|         string region = "en", | ||||
|         bool isEmailVerified = false, | ||||
|         bool isActivated = false | ||||
|     ) | ||||
| @@ -106,6 +110,7 @@ public class AccountService( | ||||
|             Name = name, | ||||
|             Nick = nick, | ||||
|             Language = language, | ||||
|             Region = region, | ||||
|             Contacts = new List<AccountContact> | ||||
|             { | ||||
|                 new() | ||||
| @@ -180,12 +185,14 @@ 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) | ||||
|     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) | ||||
| @@ -432,12 +439,14 @@ public class AccountService( | ||||
|                     .Where(c => c.Type == AccountContactType.Email) | ||||
|                     .Where(c => c.VerifiedAt != null) | ||||
|                     .Where(c => c.IsPrimary) | ||||
|                     .Where(c => c.AccountId == account.Id) | ||||
|                     .Include(c => c.Account) | ||||
|                     .FirstOrDefaultAsync(); | ||||
|                 if (contact is null) | ||||
|                 { | ||||
|                     logger.LogWarning( | ||||
|                         "Unable to send factor code to #{FactorId} with, due to no contact method was found..." | ||||
|                         "Unable to send factor code to #{FactorId} with, due to no contact method was found...", | ||||
|                         factor.Id | ||||
|                     ); | ||||
|                     return; | ||||
|                 } | ||||
| @@ -446,7 +455,7 @@ public class AccountService( | ||||
|                     .SendTemplatedEmailAsync<Pages.Emails.VerificationEmail, VerificationEmailModel>( | ||||
|                         account.Nick, | ||||
|                         contact.Content, | ||||
|                         localizer["VerificationEmail"], | ||||
|                         emailLocalizer["VerificationEmail"], | ||||
|                         new VerificationEmailModel | ||||
|                         { | ||||
|                             Name = account.Name, | ||||
| @@ -734,10 +743,14 @@ public class AccountService( | ||||
|         db.Accounts.Remove(account); | ||||
|         await db.SaveChangesAsync(); | ||||
|  | ||||
|         await nats.PublishAsync(AccountDeletedEvent.Type, JsonSerializer.SerializeToUtf8Bytes(new AccountDeletedEvent | ||||
|         { | ||||
|             AccountId = account.Id, | ||||
|             DeletedAt = SystemClock.Instance.GetCurrentInstant() | ||||
|         })); | ||||
|         var js = nats.CreateJetStreamContext(); | ||||
|         await js.PublishAsync( | ||||
|             AccountDeletedEvent.Type, | ||||
|             GrpcTypeHelper.ConvertObjectToByteString(new AccountDeletedEvent | ||||
|             { | ||||
|                 AccountId = account.Id, | ||||
|                 DeletedAt = SystemClock.Instance.GetCurrentInstant() | ||||
|             }).ToByteArray() | ||||
|         ); | ||||
|     } | ||||
| } | ||||
| @@ -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!; | ||||
|   | ||||
| @@ -23,6 +23,12 @@ public class Status : ModelBase | ||||
|     public bool IsNotDisturb { get; set; } | ||||
|     [MaxLength(1024)] public string? Label { get; set; } | ||||
|     public Instant? ClearedAt { get; set; } | ||||
|     [MaxLength(4096)] public string? AppIdentifier { get; set; } | ||||
|      | ||||
|     /// <summary> | ||||
|     /// Indicates this status is created based on running process or rich presence | ||||
|     /// </summary> | ||||
|     public bool IsAutomated { get; set; } | ||||
|  | ||||
|     public Guid AccountId { get; set; } | ||||
|     public Account Account { get; set; } = null!; | ||||
|   | ||||
| @@ -3,6 +3,7 @@ using System.Text.Json; | ||||
| using DysonNetwork.Pass.Email; | ||||
| using DysonNetwork.Pass.Pages.Emails; | ||||
| using DysonNetwork.Pass.Permission; | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.Extensions.Localization; | ||||
| using NodaTime; | ||||
| @@ -15,7 +16,8 @@ public class MagicSpellService( | ||||
|     IConfiguration configuration, | ||||
|     ILogger<MagicSpellService> logger, | ||||
|     IStringLocalizer<EmailResource> localizer, | ||||
|     EmailService email | ||||
|     EmailService email, | ||||
|     ICacheService cache | ||||
| ) | ||||
| { | ||||
|     public async Task<MagicSpell> CreateMagicSpell( | ||||
| @@ -35,11 +37,8 @@ public class MagicSpellService( | ||||
|                 .Where(s => s.Type == type) | ||||
|                 .Where(s => s.ExpiresAt == null || s.ExpiresAt > now) | ||||
|                 .FirstOrDefaultAsync(); | ||||
|  | ||||
|             if (existingSpell != null) | ||||
|             { | ||||
|                 throw new InvalidOperationException($"Account already has an active magic spell of type {type}"); | ||||
|             } | ||||
|             if (existingSpell is not null) | ||||
|                 return existingSpell; | ||||
|         } | ||||
|  | ||||
|         var spellWord = _GenerateRandomString(128); | ||||
| @@ -59,8 +58,18 @@ public class MagicSpellService( | ||||
|         return spell; | ||||
|     } | ||||
|  | ||||
|     private const string SpellNotifyCacheKeyPrefix = "spells:notify:"; | ||||
|  | ||||
|     public async Task NotifyMagicSpell(MagicSpell spell, bool bypassVerify = false) | ||||
|     { | ||||
|         var cacheKey = SpellNotifyCacheKeyPrefix + spell.Id; | ||||
|         var (found, _) = await cache.GetAsyncWithStatus<bool?>(cacheKey); | ||||
|         if (found) | ||||
|         { | ||||
|             logger.LogInformation("Skip sending magic spell {SpellId} due to already sent.", spell.Id); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var contact = await db.AccountContacts | ||||
|             .Where(c => c.Account.Id == spell.AccountId) | ||||
|             .Where(c => c.Type == AccountContactType.Email) | ||||
| @@ -112,7 +121,7 @@ public class MagicSpellService( | ||||
|                     await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>( | ||||
|                         contact.Account.Nick, | ||||
|                         contact.Content, | ||||
|                         localizer["EmailAccountDeletionTitle"], | ||||
|                         localizer["EmailPasswordResetTitle"], | ||||
|                         new PasswordResetEmailModel | ||||
|                         { | ||||
|                             Name = contact.Account.Name, | ||||
| @@ -138,6 +147,8 @@ public class MagicSpellService( | ||||
|                 default: | ||||
|                     throw new ArgumentOutOfRangeException(); | ||||
|             } | ||||
|  | ||||
|             await cache.SetAsync(cacheKey, true, TimeSpan.FromMinutes(5)); | ||||
|         } | ||||
|         catch (Exception err) | ||||
|         { | ||||
|   | ||||
							
								
								
									
										53
									
								
								DysonNetwork.Pass/Account/NotableDay.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								DysonNetwork.Pass/Account/NotableDay.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | ||||
| using Nager.Holiday; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Pass.Account; | ||||
|  | ||||
| /// <summary> | ||||
| /// Reference from Nager.Holiday | ||||
| /// </summary> | ||||
| public enum NotableHolidayType | ||||
| { | ||||
|     /// <summary>Public holiday</summary> | ||||
|     Public, | ||||
|     /// <summary>Bank holiday, banks and offices are closed</summary> | ||||
|     Bank, | ||||
|     /// <summary>School holiday, schools are closed</summary> | ||||
|     School, | ||||
|     /// <summary>Authorities are closed</summary> | ||||
|     Authorities, | ||||
|     /// <summary>Majority of people take a day off</summary> | ||||
|     Optional, | ||||
|     /// <summary>Optional festivity, no paid day off</summary> | ||||
|     Observance, | ||||
| } | ||||
|  | ||||
|  | ||||
| public class NotableDay | ||||
| { | ||||
|     public Instant Date { get; set; } | ||||
|     public string? LocalName { get; set; } | ||||
|     public string? GlobalName { get; set; } | ||||
|     public string? CountryCode { get; set; } | ||||
|     public NotableHolidayType[] Holidays { get; set; } = []; | ||||
|  | ||||
|     public static NotableDay FromNagerHoliday(PublicHoliday holiday) | ||||
|     { | ||||
|         return new NotableDay() | ||||
|         { | ||||
|             Date = Instant.FromDateTimeUtc(holiday.Date.ToUniversalTime()), | ||||
|             LocalName = holiday.LocalName, | ||||
|             GlobalName = holiday.Name, | ||||
|             CountryCode = holiday.CountryCode, | ||||
|             Holidays = holiday.Types?.Select(x => x switch | ||||
|             { | ||||
|                 PublicHolidayType.Public => NotableHolidayType.Public, | ||||
|                 PublicHolidayType.Bank => NotableHolidayType.Bank, | ||||
|                 PublicHolidayType.School => NotableHolidayType.School, | ||||
|                 PublicHolidayType.Authorities => NotableHolidayType.Authorities, | ||||
|                 PublicHolidayType.Optional => NotableHolidayType.Optional, | ||||
|                 _ => NotableHolidayType.Observance | ||||
|             }).ToArray() ?? [], | ||||
|         }; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										79
									
								
								DysonNetwork.Pass/Account/NotableDaysController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								DysonNetwork.Pass/Account/NotableDaysController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,79 @@ | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
|  | ||||
| namespace DysonNetwork.Pass.Account; | ||||
|  | ||||
| [ApiController] | ||||
| [Route("/api/notable")] | ||||
| public class NotableDaysController(NotableDaysService days) : ControllerBase | ||||
| { | ||||
|     [HttpGet("{regionCode}/{year:int}")] | ||||
|     public async Task<ActionResult<List<NotableDay>>> GetRegionDays(string regionCode, int year) | ||||
|     { | ||||
|         var result = await days.GetNotableDays(year, regionCode); | ||||
|         return Ok(result); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("{regionCode}")] | ||||
|     public async Task<ActionResult<List<NotableDay>>> GetRegionDaysCurrentYear(string regionCode) | ||||
|     { | ||||
|         var currentYear = DateTime.Now.Year; | ||||
|         var result = await days.GetNotableDays(currentYear, regionCode); | ||||
|         return Ok(result); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("me/{year:int}")] | ||||
|     [Authorize] | ||||
|     public async Task<ActionResult<List<NotableDay>>> GetAccountNotableDays(int year) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||
|  | ||||
|         var region = currentUser.Region; | ||||
|         if (string.IsNullOrWhiteSpace(region)) region = "us"; | ||||
|  | ||||
|         var result = await days.GetNotableDays(year, region); | ||||
|         return Ok(result); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("me")] | ||||
|     [Authorize] | ||||
|     public async Task<ActionResult<List<NotableDay>>> GetAccountNotableDaysCurrentYear() | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||
|  | ||||
|         var currentYear = DateTime.Now.Year; | ||||
|         var region = currentUser.Region; | ||||
|         if (string.IsNullOrWhiteSpace(region)) region = "us"; | ||||
|  | ||||
|         var result = await days.GetNotableDays(currentYear, region); | ||||
|         return Ok(result); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("{regionCode}/next")] | ||||
|     public async Task<ActionResult<NotableDay?>> GetNextHoliday(string regionCode) | ||||
|     { | ||||
|         var result = await days.GetNextHoliday(regionCode); | ||||
|         if (result == null) | ||||
|         { | ||||
|             return NotFound("No upcoming holidays found"); | ||||
|         } | ||||
|         return Ok(result); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("me/next")] | ||||
|     [Authorize] | ||||
|     public async Task<ActionResult<NotableDay?>> GetAccountNextHoliday() | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||
|  | ||||
|         var region = currentUser.Region; | ||||
|         if (string.IsNullOrWhiteSpace(region)) region = "us"; | ||||
|  | ||||
|         var result = await days.GetNextHoliday(region); | ||||
|         if (result == null) | ||||
|         { | ||||
|             return NotFound("No upcoming holidays found"); | ||||
|         } | ||||
|         return Ok(result); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										55
									
								
								DysonNetwork.Pass/Account/NotableDaysService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								DysonNetwork.Pass/Account/NotableDaysService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using Nager.Holiday; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Pass.Account; | ||||
|  | ||||
| public class NotableDaysService(ICacheService cache) | ||||
| { | ||||
|     private const string NotableDaysCacheKeyPrefix = "notable:"; | ||||
|  | ||||
|     public async Task<List<NotableDay>> GetNotableDays(int? year, string regionCode) | ||||
|     { | ||||
|         year ??= DateTime.UtcNow.Year; | ||||
|  | ||||
|         // Generate cache key using year and region code | ||||
|         var cacheKey = $"{NotableDaysCacheKeyPrefix}:{year}:{regionCode}"; | ||||
|  | ||||
|         // Try to get from cache first | ||||
|         var (found, cachedDays) = await cache.GetAsyncWithStatus<List<NotableDay>>(cacheKey); | ||||
|         if (found && cachedDays != null) | ||||
|         { | ||||
|             return cachedDays; | ||||
|         } | ||||
|  | ||||
|         // If not in cache, fetch from API | ||||
|         using var holidayClient = new HolidayClient(); | ||||
|         var holidays = await holidayClient.GetHolidaysAsync(year.Value, regionCode); | ||||
|         var days = holidays?.Select(NotableDay.FromNagerHoliday).ToList() ?? []; | ||||
|  | ||||
|         // Cache the result for 1 day (holiday data doesn't change frequently) | ||||
|         await cache.SetAsync(cacheKey, days, TimeSpan.FromDays(1)); | ||||
|  | ||||
|         return days; | ||||
|     } | ||||
|  | ||||
|     public async Task<NotableDay?> GetNextHoliday(string regionCode) | ||||
|     { | ||||
|         var currentDate = SystemClock.Instance.GetCurrentInstant(); | ||||
|         var currentYear = currentDate.InUtc().Year; | ||||
|  | ||||
|         // Get holidays for current year and next year to cover all possibilities | ||||
|         var currentYearHolidays = await GetNotableDays(currentYear, regionCode); | ||||
|         var nextYearHolidays = await GetNotableDays(currentYear + 1, regionCode); | ||||
|  | ||||
|         var allHolidays = currentYearHolidays.Concat(nextYearHolidays); | ||||
|  | ||||
|         // Find the first holiday that is today or in the future | ||||
|         var nextHoliday = allHolidays | ||||
|             .Where(day => day.Date >= currentDate) | ||||
|             .OrderBy(day => day.Date) | ||||
|             .FirstOrDefault(); | ||||
|  | ||||
|         return nextHoliday; | ||||
|     } | ||||
| } | ||||
| @@ -10,7 +10,7 @@ namespace DysonNetwork.Pass.Account; | ||||
| public class RelationshipService( | ||||
|     AppDatabase db, | ||||
|     ICacheService cache, | ||||
|     PusherService.PusherServiceClient pusher, | ||||
|     RingService.RingServiceClient pusher, | ||||
|     IStringLocalizer<NotificationResource> localizer | ||||
| ) | ||||
| { | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| 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; | ||||
| @@ -59,9 +61,14 @@ public class AppDatabase( | ||||
|         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(); | ||||
|  | ||||
|   | ||||
| @@ -49,7 +49,10 @@ public class DysonTokenAuthHandler( | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var (valid, session, message) = await token.AuthenticateTokenAsync(tokenInfo.Token); | ||||
|             // Get client IP address | ||||
|             var ipAddress = Context.Connection.RemoteIpAddress?.ToString(); | ||||
|              | ||||
|             var (valid, session, message) = await token.AuthenticateTokenAsync(tokenInfo.Token, ipAddress); | ||||
|             if (!valid || session is null) | ||||
|                 return AuthenticateResult.Fail(message ?? "Authentication failed."); | ||||
|  | ||||
| @@ -67,7 +70,7 @@ public class DysonTokenAuthHandler( | ||||
|             }; | ||||
|  | ||||
|             // Add scopes as claims | ||||
|             session.Challenge.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope))); | ||||
|             session.Challenge?.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope))); | ||||
|  | ||||
|             // Add superuser claim if applicable | ||||
|             if (session.Account.IsSuperuser) | ||||
|   | ||||
| @@ -22,7 +22,7 @@ public class AuthController( | ||||
|     AuthService auth, | ||||
|     GeoIpService geo, | ||||
|     ActionLogService als, | ||||
|     PusherService.PusherServiceClient pusher, | ||||
|     RingService.RingServiceClient pusher, | ||||
|     IConfiguration configuration, | ||||
|     IStringLocalizer<NotificationResource> localizer | ||||
| ) : ControllerBase | ||||
| @@ -51,7 +51,11 @@ public class AuthController( | ||||
|             .Where(e => e.Type == PunishmentType.BlockLogin || e.Type == PunishmentType.DisableAccount) | ||||
|             .Where(e => e.ExpiredAt == null || now < e.ExpiredAt) | ||||
|             .FirstOrDefaultAsync(); | ||||
|         if (punishment is not null) return StatusCode(423, punishment); | ||||
|         if (punishment is not null) | ||||
|             return StatusCode( | ||||
|                 423, | ||||
|                 $"Your account has been suspended. Reason: {punishment.Reason}. Expired at: {punishment.ExpiredAt?.ToString() ?? "never"}" | ||||
|             ); | ||||
|  | ||||
|         var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); | ||||
|         var userAgent = HttpContext.Request.Headers.UserAgent.ToString(); | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| using System.Security.Cryptography; | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
| using DysonNetwork.Pass.Account; | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| @@ -52,7 +53,7 @@ public class AuthService( | ||||
|             riskScore += 1; | ||||
|         else | ||||
|         { | ||||
|             if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge.IpAddress) && | ||||
|             if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge?.IpAddress) && | ||||
|                 !lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase)) | ||||
|                 riskScore += 1; | ||||
|         } | ||||
| @@ -137,6 +138,7 @@ public class AuthService( | ||||
|  | ||||
|         var jsonOpts = new JsonSerializerOptions | ||||
|         { | ||||
|             NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, | ||||
|             PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, | ||||
|             DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower | ||||
|         }; | ||||
| @@ -211,8 +213,7 @@ public class AuthService( | ||||
|         var session = new AuthSession | ||||
|         { | ||||
|             LastGrantedAt = now, | ||||
|             // Never expire server-side | ||||
|             ExpiredAt = null, | ||||
|             ExpiredAt = now.Plus(Duration.FromDays(7)), | ||||
|             AccountId = challenge.AccountId, | ||||
|             ChallengeId = challenge.Id | ||||
|         }; | ||||
|   | ||||
| @@ -1,9 +1,5 @@ | ||||
| using DysonNetwork.Pass.Wallet; | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using Grpc.Core; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Pass.Auth; | ||||
|  | ||||
| @@ -18,7 +14,7 @@ public class AuthServiceGrpc( | ||||
|         ServerCallContext context | ||||
|     ) | ||||
|     { | ||||
|         var (valid, session, message) = await token.AuthenticateTokenAsync(request.Token); | ||||
|         var (valid, session, message) = await token.AuthenticateTokenAsync(request.Token, request.IpAddress); | ||||
|         if (!valid || session is null) | ||||
|             return new AuthenticateResponse { Valid = false, Message = message ?? "Authentication failed." }; | ||||
|  | ||||
|   | ||||
| @@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations; | ||||
| using System.ComponentModel.DataAnnotations.Schema; | ||||
| using System.Text.Json.Serialization; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using DysonNetwork.Shared.GeoIp; | ||||
| using NodaTime; | ||||
| using NodaTime.Serialization.Protobuf; | ||||
| using Point = NetTopologySuite.Geometries.Point; | ||||
| @@ -69,7 +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; } | ||||
|     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!; | ||||
|   | ||||
| @@ -126,7 +126,6 @@ public class OidcProviderController( | ||||
|         [FromForm(Name = "redirect_uri")] string? redirectUri = null, | ||||
|         [FromForm] string? scope = null, | ||||
|         [FromForm] string? state = null, | ||||
|         [FromForm(Name = "response_type")] string? responseType = null, | ||||
|         [FromForm] string? nonce = null, | ||||
|         [FromForm(Name = "code_challenge")] string? codeChallenge = null, | ||||
|         [FromForm(Name = "code_challenge_method")] | ||||
| @@ -156,7 +155,7 @@ public class OidcProviderController( | ||||
|             if (!string.IsNullOrEmpty(state)) queryParams["state"] = state; | ||||
|  | ||||
|             errorUri.Query = queryParams.ToString(); | ||||
|             return Redirect(errorUri.Uri.ToString()); | ||||
|             return Ok(new { redirectUri = errorUri.Uri.ToString() }); | ||||
|         } | ||||
|  | ||||
|         // Validate redirect_uri if provided | ||||
| @@ -191,7 +190,8 @@ public class OidcProviderController( | ||||
|                 scope?.Split(' ') ?? [], | ||||
|                 codeChallenge, | ||||
|                 codeChallengeMethod, | ||||
|                 nonce); | ||||
|                 nonce | ||||
|             ); | ||||
|  | ||||
|             // Build the redirect URI with the authorization code | ||||
|             var redirectBuilder = new UriBuilder(redirectUri); | ||||
| @@ -201,7 +201,7 @@ public class OidcProviderController( | ||||
|  | ||||
|             redirectBuilder.Query = queryParams.ToString(); | ||||
|  | ||||
|             return Redirect(redirectBuilder.Uri.ToString()); | ||||
|             return Ok(new { redirectUri = redirectBuilder.Uri.ToString() }); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
| @@ -307,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> | ||||
|         { | ||||
|   | ||||
| @@ -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; | ||||
|  | ||||
| @@ -37,12 +38,21 @@ public class OidcProviderService( | ||||
|         return resp.App ?? null; | ||||
|     } | ||||
|  | ||||
|     public async Task<AuthSession?> FindValidSessionAsync(Guid accountId, Guid clientId) | ||||
|     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) && | ||||
| @@ -133,6 +143,79 @@ public class OidcProviderService( | ||||
|         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, | ||||
| @@ -148,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) | ||||
| @@ -179,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, | ||||
| @@ -211,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 | ||||
| @@ -281,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, | ||||
| @@ -355,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); | ||||
| @@ -369,7 +427,7 @@ public class OidcProviderService( | ||||
|         string? codeVerifier = null | ||||
|     ) | ||||
|     { | ||||
|         var cacheKey = $"auth:code:{code}"; | ||||
|         var cacheKey = $"auth:oidc-code:{code}"; | ||||
|         var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey); | ||||
|  | ||||
|         if (!found || authCode == null) | ||||
|   | ||||
| @@ -340,7 +340,7 @@ public class ConnectionController( | ||||
|  | ||||
|         var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant()); | ||||
|         var loginToken = auth.CreateToken(loginSession); | ||||
|         return Redirect($"/auth/token?token={loginToken}"); | ||||
|         return Redirect($"/auth/callback?token={loginToken}"); | ||||
|     } | ||||
|  | ||||
|     private static async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request) | ||||
|   | ||||
| @@ -84,6 +84,7 @@ public class OidcState | ||||
|     { | ||||
|         return JsonSerializer.Serialize(this, new JsonSerializerOptions | ||||
|         { | ||||
|             NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, | ||||
|             PropertyNamingPolicy = JsonNamingPolicy.CamelCase, | ||||
|             DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull | ||||
|         }); | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| using System.IdentityModel.Tokens.Jwt; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| using DysonNetwork.Pass.Wallet; | ||||
| @@ -22,8 +23,9 @@ public class TokenAuthService( | ||||
|     /// then cache and return. | ||||
|     /// </summary> | ||||
|     /// <param name="token">Incoming token string</param> | ||||
|     /// <param name="ipAddress">Client IP address, for logging purposes</param> | ||||
|     /// <returns>(Valid, Session, Message)</returns> | ||||
|     public async Task<(bool Valid, AuthSession? Session, string? Message)> AuthenticateTokenAsync(string token) | ||||
|     public async Task<(bool Valid, AuthSession? Session, string? Message)> AuthenticateTokenAsync(string token, string? ipAddress = null) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
| @@ -33,6 +35,11 @@ public class TokenAuthService( | ||||
|                 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]; | ||||
| @@ -70,7 +77,7 @@ public class TokenAuthService( | ||||
|                     "AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})", | ||||
|                     sessionId, | ||||
|                     session.AccountId, | ||||
|                     session.Challenge.Scopes.Count, | ||||
|                     session.Challenge?.Scopes.Count, | ||||
|                     session.ExpiredAt | ||||
|                 ); | ||||
|                 return (true, session, null); | ||||
| @@ -103,11 +110,11 @@ public class TokenAuthService( | ||||
|                 "AuthenticateTokenAsync: DB session loaded (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId}, appId={AppId}, scopes={ScopeCount}, ip={Ip}, uaLen={UaLen})", | ||||
|                 sessionId, | ||||
|                 session.AccountId, | ||||
|                 session.Challenge.ClientId, | ||||
|                 session.Challenge?.ClientId, | ||||
|                 session.AppId, | ||||
|                 session.Challenge.Scopes.Count, | ||||
|                 session.Challenge.IpAddress, | ||||
|                 (session.Challenge.UserAgent ?? string.Empty).Length | ||||
|                 session.Challenge?.Scopes.Count, | ||||
|                 session.Challenge?.IpAddress, | ||||
|                 (session.Challenge?.UserAgent ?? string.Empty).Length | ||||
|             ); | ||||
|  | ||||
|             logger.LogDebug("AuthenticateTokenAsync: enriching account with subscription (accountId={AccountId})", session.AccountId); | ||||
| @@ -136,7 +143,7 @@ public class TokenAuthService( | ||||
|                 "AuthenticateTokenAsync: success via DB (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId})", | ||||
|                 sessionId, | ||||
|                 session.AccountId, | ||||
|                 session.Challenge.ClientId | ||||
|                 session.Challenge?.ClientId | ||||
|             ); | ||||
|             return (true, session, null); | ||||
|         } | ||||
|   | ||||
| @@ -169,15 +169,15 @@ function handleDeny() { | ||||
|     error_description: 'The user denied the authorization request', | ||||
|     state: state, | ||||
|   }) | ||||
|   window.location.href = `${redirectUri}?${params}` | ||||
|   window.open(`${redirectUri}?${params}`, "_self") | ||||
| } | ||||
|  | ||||
| function openTerms() { | ||||
|   window.open(clientInfo.value?.terms_of_service_uri || 'https://example.com/terms', '_blank') | ||||
|   window.open(clientInfo.value?.terms_of_service_uri || '#', "_blank") | ||||
| } | ||||
|  | ||||
| function openPrivacy() { | ||||
|   window.open(clientInfo.value?.privacy_policy_uri || 'https://example.com/privacy', '_blank') | ||||
|   window.open(clientInfo.value?.privacy_policy_uri || '#', "_blank") | ||||
| } | ||||
|  | ||||
| // Lifecycle | ||||
|   | ||||
| @@ -13,7 +13,7 @@ | ||||
|             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||
|             <PrivateAssets>all</PrivateAssets> | ||||
|         </PackageReference> | ||||
|         <PackageReference Include="NATS.Client.Core" Version="2.6.6" /> | ||||
|         <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> | ||||
| @@ -24,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"/> | ||||
| @@ -50,6 +49,7 @@ | ||||
|     </ItemGroup> | ||||
|  | ||||
|     <ItemGroup> | ||||
|         <ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" /> | ||||
|         <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj"/> | ||||
|     </ItemGroup> | ||||
|  | ||||
|   | ||||
| @@ -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}")), | ||||
|         }); | ||||
|     }  | ||||
| } | ||||
							
								
								
									
										2021
									
								
								DysonNetwork.Pass/Migrations/20250904144723_AddOrderProductIdentifier.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										2021
									
								
								DysonNetwork.Pass/Migrations/20250904144723_AddOrderProductIdentifier.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 AddOrderProductIdentifier : Migration | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Up(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.AddColumn<string>( | ||||
|                 name: "product_identifier", | ||||
|                 table: "payment_orders", | ||||
|                 type: "character varying(4096)", | ||||
|                 maxLength: 4096, | ||||
|                 nullable: true); | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Down(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.DropColumn( | ||||
|                 name: "product_identifier", | ||||
|                 table: "payment_orders"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										2027
									
								
								DysonNetwork.Pass/Migrations/20250906174610_AddAccountRegion.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										2027
									
								
								DysonNetwork.Pass/Migrations/20250906174610_AddAccountRegion.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -0,0 +1,30 @@ | ||||
| using Microsoft.EntityFrameworkCore.Migrations; | ||||
|  | ||||
| #nullable disable | ||||
|  | ||||
| namespace DysonNetwork.Pass.Migrations | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     public partial class AddAccountRegion : Migration | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Up(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.AddColumn<string>( | ||||
|                 name: "region", | ||||
|                 table: "accounts", | ||||
|                 type: "character varying(32)", | ||||
|                 maxLength: 32, | ||||
|                 nullable: false, | ||||
|                 defaultValue: ""); | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Down(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.DropColumn( | ||||
|                 name: "region", | ||||
|                 table: "accounts"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										2027
									
								
								DysonNetwork.Pass/Migrations/20250907065433_RefactorGeoIpPoint.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										2027
									
								
								DysonNetwork.Pass/Migrations/20250907065433_RefactorGeoIpPoint.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -0,0 +1,63 @@ | ||||
| using DysonNetwork.Shared.GeoIp; | ||||
| using Microsoft.EntityFrameworkCore.Migrations; | ||||
| using NetTopologySuite.Geometries; | ||||
|  | ||||
| #nullable disable | ||||
|  | ||||
| namespace DysonNetwork.Pass.Migrations | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     public partial class RefactorGeoIpPoint : Migration | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Up(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.Sql("UPDATE auth_challenges SET location = NULL;"); | ||||
|             migrationBuilder.Sql("UPDATE action_logs SET location = NULL;"); | ||||
|  | ||||
|             migrationBuilder.DropColumn( | ||||
|                 name: "location", | ||||
|                 table: "auth_challenges"); | ||||
|  | ||||
|             migrationBuilder.AddColumn<GeoPoint>( | ||||
|                 name: "location", | ||||
|                 table: "auth_challenges", | ||||
|                 type: "jsonb", | ||||
|                 nullable: true); | ||||
|  | ||||
|             migrationBuilder.DropColumn( | ||||
|                 name: "location", | ||||
|                 table: "action_logs"); | ||||
|  | ||||
|             migrationBuilder.AddColumn<GeoPoint>( | ||||
|                 name: "location", | ||||
|                 table: "action_logs", | ||||
|                 type: "jsonb", | ||||
|                 nullable: true); | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Down(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.DropColumn( | ||||
|                 name: "location", | ||||
|                 table: "auth_challenges"); | ||||
|  | ||||
|             migrationBuilder.AddColumn<Point>( | ||||
|                 name: "location", | ||||
|                 table: "auth_challenges", | ||||
|                 type: "geometry", | ||||
|                 nullable: true); | ||||
|  | ||||
|             migrationBuilder.DropColumn( | ||||
|                 name: "location", | ||||
|                 table: "action_logs"); | ||||
|  | ||||
|             migrationBuilder.AddColumn<Point>( | ||||
|                 name: "location", | ||||
|                 table: "action_logs", | ||||
|                 type: "geometry", | ||||
|                 nullable: true); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										2026
									
								
								DysonNetwork.Pass/Migrations/20250907065933_RemoveNetTopo.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										2026
									
								
								DysonNetwork.Pass/Migrations/20250907065933_RemoveNetTopo.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										24
									
								
								DysonNetwork.Pass/Migrations/20250907065933_RemoveNetTopo.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								DysonNetwork.Pass/Migrations/20250907065933_RemoveNetTopo.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| using Microsoft.EntityFrameworkCore.Migrations; | ||||
|  | ||||
| #nullable disable | ||||
|  | ||||
| namespace DysonNetwork.Pass.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", ",,"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										2035
									
								
								DysonNetwork.Pass/Migrations/20250908151924_AddAutomatedStatus.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										2035
									
								
								DysonNetwork.Pass/Migrations/20250908151924_AddAutomatedStatus.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -0,0 +1,40 @@ | ||||
| using Microsoft.EntityFrameworkCore.Migrations; | ||||
|  | ||||
| #nullable disable | ||||
|  | ||||
| namespace DysonNetwork.Pass.Migrations | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     public partial class AddAutomatedStatus : Migration | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Up(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.AddColumn<string>( | ||||
|                 name: "app_identifier", | ||||
|                 table: "account_statuses", | ||||
|                 type: "character varying(4096)", | ||||
|                 maxLength: 4096, | ||||
|                 nullable: true); | ||||
|  | ||||
|             migrationBuilder.AddColumn<bool>( | ||||
|                 name: "is_automated", | ||||
|                 table: "account_statuses", | ||||
|                 type: "boolean", | ||||
|                 nullable: false, | ||||
|                 defaultValue: false); | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Down(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.DropColumn( | ||||
|                 name: "app_identifier", | ||||
|                 table: "account_statuses"); | ||||
|  | ||||
|             migrationBuilder.DropColumn( | ||||
|                 name: "is_automated", | ||||
|                 table: "account_statuses"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -6,10 +6,10 @@ using DysonNetwork.Pass; | ||||
| using DysonNetwork.Pass.Account; | ||||
| using DysonNetwork.Pass.Wallet; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using DysonNetwork.Shared.GeoIp; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | ||||
| using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||
| using NetTopologySuite.Geometries; | ||||
| using NodaTime; | ||||
| using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||
|  | ||||
| @@ -27,7 +27,6 @@ namespace DysonNetwork.Pass.Migrations | ||||
|                 .HasAnnotation("ProductVersion", "9.0.7") | ||||
|                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||
|  | ||||
|             NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); | ||||
|             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Pass.Account.AbuseReport", b => | ||||
| @@ -132,6 +131,12 @@ namespace DysonNetwork.Pass.Migrations | ||||
|                         .HasColumnType("character varying(256)") | ||||
|                         .HasColumnName("nick"); | ||||
|  | ||||
|                     b.Property<string>("Region") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(32) | ||||
|                         .HasColumnType("character varying(32)") | ||||
|                         .HasColumnName("region"); | ||||
|  | ||||
|                     b.Property<Instant>("UpdatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("updated_at"); | ||||
| @@ -519,8 +524,8 @@ namespace DysonNetwork.Pass.Migrations | ||||
|                         .HasColumnType("character varying(128)") | ||||
|                         .HasColumnName("ip_address"); | ||||
|  | ||||
|                     b.Property<Point>("Location") | ||||
|                         .HasColumnType("geometry") | ||||
|                     b.Property<GeoPoint>("Location") | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("location"); | ||||
|  | ||||
|                     b.Property<Dictionary<string, object>>("Meta") | ||||
| @@ -762,6 +767,11 @@ namespace DysonNetwork.Pass.Migrations | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("account_id"); | ||||
|  | ||||
|                     b.Property<string>("AppIdentifier") | ||||
|                         .HasMaxLength(4096) | ||||
|                         .HasColumnType("character varying(4096)") | ||||
|                         .HasColumnName("app_identifier"); | ||||
|  | ||||
|                     b.Property<int>("Attitude") | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("attitude"); | ||||
| @@ -778,6 +788,10 @@ namespace DysonNetwork.Pass.Migrations | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("deleted_at"); | ||||
|  | ||||
|                     b.Property<bool>("IsAutomated") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("is_automated"); | ||||
|  | ||||
|                     b.Property<bool>("IsInvisible") | ||||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("is_invisible"); | ||||
| @@ -895,8 +909,8 @@ namespace DysonNetwork.Pass.Migrations | ||||
|                         .HasColumnType("character varying(128)") | ||||
|                         .HasColumnName("ip_address"); | ||||
|  | ||||
|                     b.Property<Point>("Location") | ||||
|                         .HasColumnType("geometry") | ||||
|                     b.Property<GeoPoint>("Location") | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("location"); | ||||
|  | ||||
|                     b.Property<string>("Nonce") | ||||
| @@ -1381,6 +1395,11 @@ namespace DysonNetwork.Pass.Migrations | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("payee_wallet_id"); | ||||
|  | ||||
|                     b.Property<string>("ProductIdentifier") | ||||
|                         .HasMaxLength(4096) | ||||
|                         .HasColumnType("character varying(4096)") | ||||
|                         .HasColumnName("product_identifier"); | ||||
|  | ||||
|                     b.Property<string>("Remarks") | ||||
|                         .HasMaxLength(4096) | ||||
|                         .HasColumnType("character varying(4096)") | ||||
|   | ||||
| @@ -4,25 +4,21 @@ using DysonNetwork.Pass.Startup; | ||||
| using DysonNetwork.Shared.Http; | ||||
| using DysonNetwork.Shared.PageData; | ||||
| using DysonNetwork.Shared.Registry; | ||||
| using DysonNetwork.Shared.Stream; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
|  | ||||
| var builder = WebApplication.CreateBuilder(args); | ||||
|  | ||||
| builder.AddServiceDefaults(); | ||||
|  | ||||
| // Configure Kestrel and server options | ||||
| builder.ConfigureAppKestrel(builder.Configuration); | ||||
|  | ||||
| // Add metrics and telemetry | ||||
| builder.Services.AddAppMetrics(); | ||||
|  | ||||
| // Add application services | ||||
| builder.Services.AddRegistryService(builder.Configuration); | ||||
| builder.Services.AddStreamConnection(builder.Configuration); | ||||
| builder.Services.AddAppServices(builder.Configuration); | ||||
| builder.Services.AddAppRateLimiting(); | ||||
| builder.Services.AddAppAuthentication(); | ||||
| builder.Services.AddAppSwagger(); | ||||
| builder.Services.AddPusherService(); | ||||
| builder.Services.AddRingService(); | ||||
| builder.Services.AddDriveService(); | ||||
| builder.Services.AddDevelopService(); | ||||
|  | ||||
| @@ -41,6 +37,8 @@ builder.Services.AddTransient<IPageDataProvider, AccountPageData>(); | ||||
|  | ||||
| var app = builder.Build(); | ||||
|  | ||||
| app.MapDefaultEndpoints(); | ||||
|  | ||||
| // Run database migrations | ||||
| using (var scope = app.Services.CreateScope()) | ||||
| { | ||||
| @@ -51,8 +49,6 @@ using (var scope = app.Services.CreateScope()) | ||||
| // Configure application middleware pipeline | ||||
| app.ConfigureAppMiddleware(builder.Configuration, builder.Environment.ContentRootPath); | ||||
|  | ||||
| app.MapGatewayProxy(); | ||||
|  | ||||
| app.MapPages(Path.Combine(builder.Environment.WebRootPath, "dist", "index.html")); | ||||
|  | ||||
| // Configure gRPC | ||||
|   | ||||
| @@ -170,5 +170,47 @@ namespace DysonNetwork.Sphere.Resources.Localization { | ||||
|                 return ResourceManager.GetString("NewLoginBody", resourceCulture); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         internal static string FriendRequestTitle { | ||||
|             get { | ||||
|                 return ResourceManager.GetString("FriendRequestTitle", resourceCulture); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         internal static string FriendRequestBody { | ||||
|             get { | ||||
|                 return ResourceManager.GetString("FriendRequestBody", resourceCulture); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         internal static string OrderReceivedTitle { | ||||
|             get { | ||||
|                 return ResourceManager.GetString("OrderReceivedTitle", resourceCulture); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         internal static string OrderReceivedBody { | ||||
|             get { | ||||
|                 return ResourceManager.GetString("OrderReceivedBody", resourceCulture); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         internal static string TransactionNewTitle { | ||||
|             get { | ||||
|                 return ResourceManager.GetString("TransactionNewTitle", resourceCulture); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         internal static string TransactionNewBodyPlus { | ||||
|             get { | ||||
|                 return ResourceManager.GetString("TransactionNewBodyPlus", resourceCulture); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         internal static string TransactionNewBodyMinus { | ||||
|             get { | ||||
|                 return ResourceManager.GetString("TransactionNewBodyMinus", resourceCulture); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -78,7 +78,7 @@ | ||||
|         <value>Order {0} recipent</value> | ||||
|     </data> | ||||
|     <data name="OrderPaidBody" xml:space="preserve"> | ||||
|        <value>{0} {1} was removed from your wallet to pay {2}</value> | ||||
|         <value>Paid order {2} with {0} {1}</value> | ||||
|     </data> | ||||
|     <data name="NewLoginTitle" xml:space="preserve"> | ||||
|         <value>New login detected</value> | ||||
| @@ -92,4 +92,19 @@ | ||||
|     <data name="FriendRequestBody" xml:space="preserve"> | ||||
|         <value>You can go to relationships page and decide accept their request or not.</value> | ||||
|     </data> | ||||
|     <data name="OrderReceivedTitle" xml:space="preserve"> | ||||
|         <value>Order {0} recipent</value> | ||||
|     </data> | ||||
|     <data name="OrderReceivedBody" xml:space="preserve"> | ||||
|         <value>Received {2} payment of {0} {1}</value> | ||||
|     </data> | ||||
|     <data name="TransactionNewTitle" xml:space="preserve"> | ||||
|         <value>Transaction {0}</value> | ||||
|     </data> | ||||
|     <data name="TransactionNewBodyPlus" xml:space="preserve"> | ||||
|         <value>{0} {1} added to your wallet</value> | ||||
|     </data> | ||||
|     <data name="TransactionNewBodyMinus" xml:space="preserve"> | ||||
|         <value>{0} {1} removed from your wallet</value> | ||||
|     </data> | ||||
| </root> | ||||
| @@ -67,10 +67,10 @@ | ||||
|         <value>感谢你支持 Solar Network 的开发!你的 {0} 天 {1} 订阅刚刚开始,接下来来探索新解锁的新功能吧!</value> | ||||
|     </data> | ||||
|     <data name="OrderPaidTitle" xml:space="preserve"> | ||||
|         <value>订单回执 {0}</value> | ||||
|         <value>订单收据 {0}</value> | ||||
|     </data> | ||||
|     <data name="OrderPaidBody" xml:space="preserve"> | ||||
|         <value>{0} {1} 已从你的帐户中扣除来支付 {2}</value> | ||||
|         <value>已支付订单 {2} 的 {0} {1}</value> | ||||
|     </data> | ||||
|     <data name="NewLoginTitle" xml:space="preserve"> | ||||
|         <value>检测到新登陆</value> | ||||
| @@ -84,4 +84,19 @@ | ||||
|     <data name="FriendRequestBody" xml:space="preserve"> | ||||
|         <value>您可以前往人际关系页面来决定时候要接受他们的邀请。</value> | ||||
|     </data> | ||||
|     <data name="OrderReceivedTitle" xml:space="preserve"> | ||||
|         <value>订单收据 {0}</value> | ||||
|     </data> | ||||
|     <data name="OrderReceivedBody" xml:space="preserve"> | ||||
|         <value>收到订单 {2} 支付的 {0} {1}</value> | ||||
|     </data> | ||||
|     <data name="TransactionNewTitle" xml:space="preserve"> | ||||
|         <value>交易 {0}</value> | ||||
|     </data> | ||||
|     <data name="TransactionNewBodyPlus" xml:space="preserve"> | ||||
|         <value>{0} {1} 添加到了您的钱包</value> | ||||
|     </data> | ||||
|     <data name="TransactionNewBodyMinus" xml:space="preserve"> | ||||
|         <value>{0} {1} 从您的钱包移除</value> | ||||
|     </data> | ||||
| </root> | ||||
| @@ -4,6 +4,8 @@ using DysonNetwork.Pass.Auth; | ||||
| using DysonNetwork.Pass.Credit; | ||||
| using DysonNetwork.Pass.Leveling; | ||||
| using DysonNetwork.Pass.Permission; | ||||
| using DysonNetwork.Pass.Wallet; | ||||
| using DysonNetwork.Shared.Http; | ||||
| using Microsoft.AspNetCore.HttpOverrides; | ||||
| using Microsoft.Extensions.FileProviders; | ||||
| using Prometheus; | ||||
| @@ -22,7 +24,7 @@ public static class ApplicationConfiguration | ||||
|  | ||||
|         app.UseRequestLocalization(); | ||||
|  | ||||
|         ConfigureForwardedHeaders(app, configuration); | ||||
|         app.ConfigureForwardedHeaders(configuration); | ||||
|  | ||||
|         app.UseCors(opts => | ||||
|             opts.SetIsOriginAllowed(_ => true) | ||||
| @@ -50,28 +52,6 @@ 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); | ||||
|     } | ||||
|  | ||||
|     public static WebApplication ConfigureGrpcServices(this WebApplication app) | ||||
|     { | ||||
|         app.MapGrpcService<AccountServiceGrpc>(); | ||||
| @@ -81,6 +61,8 @@ public static class ApplicationConfiguration | ||||
|         app.MapGrpcService<SocialCreditServiceGrpc>(); | ||||
|         app.MapGrpcService<ExperienceServiceGrpc>(); | ||||
|         app.MapGrpcService<BotAccountReceiverGrpc>(); | ||||
|         app.MapGrpcService<WalletServiceGrpc>(); | ||||
|         app.MapGrpcService<PaymentServiceGrpc>(); | ||||
|  | ||||
|         return app; | ||||
|     } | ||||
|   | ||||
							
								
								
									
										73
									
								
								DysonNetwork.Pass/Startup/BroadcastEventHandler.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								DysonNetwork.Pass/Startup/BroadcastEventHandler.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | ||||
| using System.Text.Json; | ||||
| using DysonNetwork.Pass.Wallet; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using DysonNetwork.Shared.Stream; | ||||
| using NATS.Client.Core; | ||||
| using NATS.Client.JetStream.Models; | ||||
| using NATS.Net; | ||||
|  | ||||
| namespace DysonNetwork.Pass.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("payment_events", [PaymentOrderEventBase.Type]); | ||||
|          | ||||
|         var consumer = await js.CreateOrUpdateConsumerAsync("payment_events",  | ||||
|             new ConsumerConfig("pass_payment_handler"),  | ||||
|             cancellationToken: stoppingToken); | ||||
|          | ||||
|         await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken)) | ||||
|         { | ||||
|             PaymentOrderEvent? evt = null; | ||||
|             try | ||||
|             { | ||||
|                 evt = JsonSerializer.Deserialize<PaymentOrderEvent>(msg.Data, GrpcTypeHelper.SerializerOptions); | ||||
|                  | ||||
|                 logger.LogInformation( | ||||
|                     "Received order event: {ProductIdentifier} {OrderId}", | ||||
|                     evt?.ProductIdentifier, | ||||
|                     evt?.OrderId | ||||
|                 ); | ||||
|  | ||||
|                 if (evt?.ProductIdentifier is null || | ||||
|                     !evt.ProductIdentifier.StartsWith(SubscriptionType.StellarProgram)) | ||||
|                     continue; | ||||
|  | ||||
|                 logger.LogInformation("Handling stellar program order: {OrderId}", evt.OrderId); | ||||
|  | ||||
|                 await using var scope = serviceProvider.CreateAsyncScope(); | ||||
|                 var db = scope.ServiceProvider.GetRequiredService<AppDatabase>(); | ||||
|                 var subscriptions = scope.ServiceProvider.GetRequiredService<SubscriptionService>(); | ||||
|  | ||||
|                 var order = await db.PaymentOrders.FindAsync( | ||||
|                     [evt.OrderId], | ||||
|                     cancellationToken: stoppingToken | ||||
|                 ); | ||||
|                 if (order is null) | ||||
|                 { | ||||
|                     logger.LogWarning("Order with ID {OrderId} not found. Redelivering.", evt.OrderId); | ||||
|                     await msg.NakAsync(cancellationToken: stoppingToken); | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 await subscriptions.HandleSubscriptionOrder(order); | ||||
|  | ||||
|                 logger.LogInformation("Subscription for order {OrderId} handled successfully.", evt.OrderId); | ||||
|                 await msg.AckAsync(cancellationToken: stoppingToken); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 logger.LogError(ex, "Error processing payment order event for order {OrderId}. Redelivering.", evt?.OrderId); | ||||
|                 await msg.NakAsync(cancellationToken: stoppingToken); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,40 +0,0 @@ | ||||
| using OpenTelemetry.Metrics; | ||||
| using OpenTelemetry.Trace; | ||||
| using Prometheus; | ||||
| using Prometheus.SystemMetrics; | ||||
|  | ||||
| namespace DysonNetwork.Pass.Startup; | ||||
|  | ||||
| public static class MetricsConfiguration | ||||
| { | ||||
|     public static IServiceCollection AddAppMetrics(this IServiceCollection services) | ||||
|     { | ||||
|         // Prometheus | ||||
|         services.UseHttpClientMetrics(); | ||||
|         services.AddHealthChecks(); | ||||
|         services.AddSystemMetrics(); | ||||
|         services.AddPrometheusEntityFrameworkMetrics(); | ||||
|         services.AddPrometheusAspNetCoreMetrics(); | ||||
|         services.AddPrometheusHttpClientMetrics(); | ||||
|  | ||||
|         // OpenTelemetry | ||||
|         services.AddOpenTelemetry() | ||||
|             .WithTracing(tracing => | ||||
|             { | ||||
|                 tracing | ||||
|                     .AddAspNetCoreInstrumentation() | ||||
|                     .AddHttpClientInstrumentation() | ||||
|                     .AddOtlpExporter(); | ||||
|             }) | ||||
|             .WithMetrics(metrics => | ||||
|             { | ||||
|                 metrics | ||||
|                     .AddAspNetCoreInstrumentation() | ||||
|                     .AddHttpClientInstrumentation() | ||||
|                     .AddRuntimeInstrumentation() | ||||
|                     .AddOtlpExporter(); | ||||
|             }); | ||||
|  | ||||
|         return services; | ||||
|     } | ||||
| } | ||||
| @@ -12,6 +12,7 @@ using NodaTime; | ||||
| using NodaTime.Serialization.SystemTextJson; | ||||
| using StackExchange.Redis; | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
| using System.Threading.RateLimiting; | ||||
| using DysonNetwork.Pass.Auth.OidcProvider.Options; | ||||
| using DysonNetwork.Pass.Auth.OidcProvider.Services; | ||||
| @@ -33,11 +34,6 @@ public static class ServiceCollectionExtensions | ||||
|         services.AddLocalization(options => options.ResourcesPath = "Resources"); | ||||
|  | ||||
|         services.AddDbContext<AppDatabase>(); | ||||
|         services.AddSingleton<IConnectionMultiplexer>(_ => | ||||
|         { | ||||
|             var connection = configuration.GetConnectionString("FastRetrieve")!; | ||||
|             return ConnectionMultiplexer.Connect(connection); | ||||
|         }); | ||||
|         services.AddSingleton<IClock>(SystemClock.Instance); | ||||
|         services.AddHttpContextAccessor(); | ||||
|         services.AddSingleton<ICacheService, CacheServiceRedis>(); | ||||
| @@ -52,11 +48,7 @@ public static class ServiceCollectionExtensions | ||||
|             options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB | ||||
|         }); | ||||
|  | ||||
|         services.AddPusherService(); | ||||
|          | ||||
|         // Register gRPC services | ||||
|         services.AddScoped<AccountServiceGrpc>(); | ||||
|         services.AddScoped<AuthServiceGrpc>(); | ||||
|         services.AddRingService(); | ||||
|  | ||||
|         // Register OIDC services | ||||
|         services.AddScoped<OidcService, GoogleOidcService>(); | ||||
| @@ -74,6 +66,7 @@ public static class ServiceCollectionExtensions | ||||
|  | ||||
|         services.AddControllers().AddJsonOptions(options => | ||||
|         { | ||||
|             options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals; | ||||
|             options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; | ||||
|             options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower; | ||||
|  | ||||
| @@ -136,7 +129,8 @@ public static class ServiceCollectionExtensions | ||||
|             { | ||||
|                 Version = "v1", | ||||
|                 Title = "Dyson Pass", | ||||
|                 Description = "The authentication service of the Dyson Network. Mainly handling authentication and authorization.", | ||||
|                 Description = | ||||
|                     "The authentication service of the Dyson Network. Mainly handling authentication and authorization.", | ||||
|                 TermsOfService = new Uri("https://solsynth.dev/terms"), | ||||
|                 License = new OpenApiLicense | ||||
|                 { | ||||
| @@ -194,6 +188,7 @@ public static class ServiceCollectionExtensions | ||||
|         services.AddScoped<ActionLogService>(); | ||||
|         services.AddScoped<AccountService>(); | ||||
|         services.AddScoped<AccountEventService>(); | ||||
|         services.AddScoped<NotableDaysService>(); | ||||
|         services.AddScoped<ActionLogService>(); | ||||
|         services.AddScoped<RelationshipService>(); | ||||
|         services.AddScoped<MagicSpellService>(); | ||||
| @@ -211,6 +206,8 @@ public static class ServiceCollectionExtensions | ||||
|         services.Configure<OidcProviderOptions>(configuration.GetSection("OidcProvider")); | ||||
|         services.AddScoped<OidcProviderService>(); | ||||
|          | ||||
|         services.AddHostedService<BroadcastEventHandler>(); | ||||
|  | ||||
|         return services; | ||||
|     } | ||||
| } | ||||
| @@ -15,9 +15,7 @@ public class OrderController(PaymentService payment, AuthService auth, AppDataba | ||||
|         var order = await db.PaymentOrders.FindAsync(id); | ||||
|          | ||||
|         if (order == null) | ||||
|         { | ||||
|             return NotFound(); | ||||
|         } | ||||
|          | ||||
|         return Ok(order); | ||||
|     } | ||||
| @@ -26,8 +24,7 @@ public class OrderController(PaymentService payment, AuthService auth, AppDataba | ||||
|     [Authorize] | ||||
|     public async Task<ActionResult<Order>> PayOrder(Guid id, [FromBody] PayOrderRequest request) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser || | ||||
|             HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized(); | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); | ||||
|      | ||||
|         // Validate PIN code | ||||
|         if (!await auth.ValidatePinCode(currentUser.Id, request.PinCode)) | ||||
| @@ -41,7 +38,7 @@ public class OrderController(PaymentService payment, AuthService auth, AppDataba | ||||
|                 return BadRequest("Wallet was not found."); | ||||
|          | ||||
|             // Pay the order | ||||
|             var paidOrder = await payment.PayOrderAsync(id, wallet.Id); | ||||
|             var paidOrder = await payment.PayOrderAsync(id, wallet); | ||||
|             return Ok(paidOrder); | ||||
|         } | ||||
|         catch (InvalidOperationException ex) | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using System.ComponentModel.DataAnnotations.Schema; | ||||
| using System.Globalization; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using NodaTime; | ||||
| using NodaTime.Serialization.Protobuf; | ||||
| @@ -23,11 +24,14 @@ public enum OrderStatus | ||||
|  | ||||
| public class Order : ModelBase | ||||
| { | ||||
|     public const string InternalAppIdentifier = "internal"; | ||||
|  | ||||
|     public Guid Id { get; set; } = Guid.NewGuid(); | ||||
|     public OrderStatus Status { get; set; } = OrderStatus.Unpaid; | ||||
|     [MaxLength(128)] public string Currency { get; set; } = null!; | ||||
|     [MaxLength(4096)] public string? Remarks { get; set; } | ||||
|     [MaxLength(4096)] public string? AppIdentifier { get; set; } | ||||
|     [MaxLength(4096)] public string? ProductIdentifier { get; set; } | ||||
|     [Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; } | ||||
|     public decimal Amount { get; set; } | ||||
|     public Instant ExpiredAt { get; set; } | ||||
| @@ -44,10 +48,11 @@ public class Order : ModelBase | ||||
|         Currency = Currency, | ||||
|         Remarks = Remarks, | ||||
|         AppIdentifier = AppIdentifier, | ||||
|         ProductIdentifier = ProductIdentifier, | ||||
|         Meta = Meta == null | ||||
|             ? null | ||||
|             : Google.Protobuf.ByteString.CopyFrom(System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(Meta)), | ||||
|         Amount = Amount.ToString(), | ||||
|         Amount = Amount.ToString(CultureInfo.InvariantCulture), | ||||
|         ExpiredAt = ExpiredAt.ToTimestamp(), | ||||
|         PayeeWalletId = PayeeWalletId?.ToString(), | ||||
|         TransactionId = TransactionId?.ToString(), | ||||
| @@ -61,13 +66,14 @@ public class Order : ModelBase | ||||
|         Currency = proto.Currency, | ||||
|         Remarks = proto.Remarks, | ||||
|         AppIdentifier = proto.AppIdentifier, | ||||
|         ProductIdentifier = proto.ProductIdentifier, | ||||
|         Meta = proto.HasMeta | ||||
|             ? System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(proto.Meta.ToByteArray()) | ||||
|             : null, | ||||
|         Amount = decimal.Parse(proto.Amount), | ||||
|         ExpiredAt = proto.ExpiredAt.ToInstant(), | ||||
|         PayeeWalletId = proto.HasPayeeWalletId ? Guid.Parse(proto.PayeeWalletId) : null, | ||||
|         TransactionId = proto.HasTransactionId ? Guid.Parse(proto.TransactionId) : null, | ||||
|         PayeeWalletId = proto.PayeeWalletId is not null ? Guid.Parse(proto.PayeeWalletId) : null, | ||||
|         TransactionId = proto.TransactionId is not null ? Guid.Parse(proto.TransactionId) : null, | ||||
|         Transaction = proto.Transaction is not null ? Transaction.FromProtoValue(proto.Transaction) : null, | ||||
|     }; | ||||
| } | ||||
| @@ -100,7 +106,7 @@ public class Transaction : ModelBase | ||||
|     { | ||||
|         Id = Id.ToString(), | ||||
|         Currency = Currency, | ||||
|         Amount = Amount.ToString(), | ||||
|         Amount = Amount.ToString(CultureInfo.InvariantCulture), | ||||
|         Remarks = Remarks, | ||||
|         Type = (Shared.Proto.TransactionType)Type, | ||||
|         PayerWalletId = PayerWalletId?.ToString(), | ||||
| @@ -114,7 +120,7 @@ public class Transaction : ModelBase | ||||
|         Amount = decimal.Parse(proto.Amount), | ||||
|         Remarks = proto.Remarks, | ||||
|         Type = (TransactionType)proto.Type, | ||||
|         PayerWalletId = proto.HasPayerWalletId ? Guid.Parse(proto.PayerWalletId) : null, | ||||
|         PayeeWalletId = proto.HasPayeeWalletId ? Guid.Parse(proto.PayeeWalletId) : null, | ||||
|         PayerWalletId = proto.PayerWalletId is not null ? Guid.Parse(proto.PayerWalletId) : null, | ||||
|         PayeeWalletId = proto.PayeeWalletId is not null ? Guid.Parse(proto.PayeeWalletId) : null, | ||||
|     }; | ||||
| } | ||||
| @@ -1,9 +1,14 @@ | ||||
| using System.Globalization; | ||||
| using System.Text.Json; | ||||
| using DysonNetwork.Pass.Localization; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using DysonNetwork.Shared.Stream; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.EntityFrameworkCore.Storage; | ||||
| using Microsoft.Extensions.Localization; | ||||
| using NATS.Client.Core; | ||||
| using NATS.Client.JetStream; | ||||
| using NATS.Net; | ||||
| using NodaTime; | ||||
| using AccountService = DysonNetwork.Pass.Account.AccountService; | ||||
|  | ||||
| @@ -12,8 +17,9 @@ namespace DysonNetwork.Pass.Wallet; | ||||
| public class PaymentService( | ||||
|     AppDatabase db, | ||||
|     WalletService wat, | ||||
|     PusherService.PusherServiceClient pusher, | ||||
|     IStringLocalizer<NotificationResource> localizer | ||||
|     RingService.RingServiceClient pusher, | ||||
|     IStringLocalizer<NotificationResource> localizer, | ||||
|     INatsConnection nats | ||||
| ) | ||||
| { | ||||
|     public async Task<Order> CreateOrderAsync( | ||||
| @@ -22,6 +28,8 @@ public class PaymentService( | ||||
|         decimal amount, | ||||
|         Duration? expiration = null, | ||||
|         string? appIdentifier = null, | ||||
|         string? productIdentifier = null, | ||||
|         string? remarks = null, | ||||
|         Dictionary<string, object>? meta = null, | ||||
|         bool reuseable = true | ||||
|     ) | ||||
| @@ -29,26 +37,25 @@ public class PaymentService( | ||||
|         // Check if there's an existing unpaid order that can be reused | ||||
|         if (reuseable && appIdentifier != null) | ||||
|         { | ||||
|             var now = SystemClock.Instance.GetCurrentInstant(); | ||||
|             var existingOrder = await db.PaymentOrders | ||||
|                 .Where(o => o.Status == OrderStatus.Unpaid && | ||||
|                             o.PayeeWalletId == payeeWalletId && | ||||
|                             o.Currency == currency && | ||||
|                             o.Amount == amount && | ||||
|                             o.AppIdentifier == appIdentifier && | ||||
|                             o.ExpiredAt > SystemClock.Instance.GetCurrentInstant()) | ||||
|                             o.ProductIdentifier == productIdentifier && | ||||
|                             o.ExpiredAt > now) | ||||
|                 .FirstOrDefaultAsync(); | ||||
|  | ||||
|             // If an existing order is found, check if meta matches | ||||
|             if (existingOrder != null && meta != null && existingOrder.Meta != null) | ||||
|             { | ||||
|                 // Compare meta dictionaries - if they are equivalent, reuse the order | ||||
|                 // Compare the meta dictionary - if they are equivalent, reuse the order | ||||
|                 var metaMatches = existingOrder.Meta.Count == meta.Count && | ||||
|                                   !existingOrder.Meta.Except(meta).Any(); | ||||
|  | ||||
|                 if (metaMatches) | ||||
|                 { | ||||
|                     return existingOrder; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
| @@ -60,6 +67,8 @@ public class PaymentService( | ||||
|             Amount = amount, | ||||
|             ExpiredAt = SystemClock.Instance.GetCurrentInstant().Plus(expiration ?? Duration.FromHours(24)), | ||||
|             AppIdentifier = appIdentifier, | ||||
|             ProductIdentifier = productIdentifier, | ||||
|             Remarks = remarks, | ||||
|             Meta = meta | ||||
|         }; | ||||
|  | ||||
| @@ -104,7 +113,8 @@ public class PaymentService( | ||||
|         string currency, | ||||
|         decimal amount, | ||||
|         string? remarks = null, | ||||
|         TransactionType type = TransactionType.System | ||||
|         TransactionType type = TransactionType.System, | ||||
|         bool silent = false | ||||
|     ) | ||||
|     { | ||||
|         if (payerWalletId == null && payeeWalletId == null) | ||||
| @@ -121,8 +131,12 @@ public class PaymentService( | ||||
|             Type = type | ||||
|         }; | ||||
|  | ||||
|         Wallet? payerWallet = null, payeeWallet = null; | ||||
|  | ||||
|         if (payerWalletId.HasValue) | ||||
|         { | ||||
|             payerWallet = await db.Wallets.FirstOrDefaultAsync(e => e.AccountId == payerWalletId.Value); | ||||
|  | ||||
|             var (payerPocket, isNewlyCreated) = | ||||
|                 await wat.GetOrCreateWalletPocketAsync(payerWalletId.Value, currency); | ||||
|  | ||||
| @@ -137,6 +151,8 @@ public class PaymentService( | ||||
|  | ||||
|         if (payeeWalletId.HasValue) | ||||
|         { | ||||
|             payeeWallet = await db.Wallets.FirstOrDefaultAsync(e => e.AccountId == payeeWalletId.Value); | ||||
|  | ||||
|             var (payeePocket, isNewlyCreated) = | ||||
|                 await wat.GetOrCreateWalletPocketAsync(payeeWalletId.Value, currency, amount); | ||||
|  | ||||
| @@ -149,13 +165,89 @@ public class PaymentService( | ||||
|  | ||||
|         db.PaymentTransactions.Add(transaction); | ||||
|         await db.SaveChangesAsync(); | ||||
|  | ||||
|         if (!silent) | ||||
|             await NotifyNewTransaction(transaction, payerWallet, payeeWallet); | ||||
|  | ||||
|         return transaction; | ||||
|     } | ||||
|  | ||||
|     public async Task<Order> PayOrderAsync(Guid orderId, Guid payerWalletId) | ||||
|     private async Task NotifyNewTransaction(Transaction transaction, Wallet? payerWallet, Wallet? payeeWallet) | ||||
|     { | ||||
|         if (payerWallet is not null) | ||||
|         { | ||||
|             var account = await db.Accounts | ||||
|                 .Where(a => a.Id == payerWallet.AccountId) | ||||
|                 .FirstOrDefaultAsync(); | ||||
|             if (account is null) return; | ||||
|  | ||||
|             AccountService.SetCultureInfo(account); | ||||
|  | ||||
|             // Due to ID is uuid, it longer than 8 words for sure | ||||
|             var readableTransactionId = transaction.Id.ToString().Replace("-", "")[..8]; | ||||
|             var readableTransactionRemark = transaction.Remarks ?? $"#{readableTransactionId}"; | ||||
|  | ||||
|             await pusher.SendPushNotificationToUserAsync( | ||||
|                 new SendPushNotificationToUserRequest | ||||
|                 { | ||||
|                     UserId = account.Id.ToString(), | ||||
|                     Notification = new PushNotification | ||||
|                     { | ||||
|                         Topic = "wallets.transactions", | ||||
|                         Title = localizer["TransactionNewTitle", readableTransactionRemark], | ||||
|                         Body = transaction.Amount > 0 | ||||
|                             ? localizer["TransactionNewBodyMinus", | ||||
|                                 transaction.Amount.ToString(CultureInfo.InvariantCulture), | ||||
|                                 transaction.Currency] | ||||
|                             : localizer["TransactionNewBodyPlus", | ||||
|                                 transaction.Amount.ToString(CultureInfo.InvariantCulture), | ||||
|                                 transaction.Currency], | ||||
|                         IsSavable = true | ||||
|                     } | ||||
|                 } | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         if (payeeWallet is not null) | ||||
|         { | ||||
|             var account = await db.Accounts | ||||
|                 .Where(a => a.Id == payeeWallet.AccountId) | ||||
|                 .FirstOrDefaultAsync(); | ||||
|             if (account is null) return; | ||||
|  | ||||
|             AccountService.SetCultureInfo(account); | ||||
|  | ||||
|             // Due to ID is uuid, it longer than 8 words for sure | ||||
|             var readableTransactionId = transaction.Id.ToString().Replace("-", "")[..8]; | ||||
|             var readableTransactionRemark = transaction.Remarks ?? $"#{readableTransactionId}"; | ||||
|  | ||||
|             await pusher.SendPushNotificationToUserAsync( | ||||
|                 new SendPushNotificationToUserRequest | ||||
|                 { | ||||
|                     UserId = account.Id.ToString(), | ||||
|                     Notification = new PushNotification | ||||
|                     { | ||||
|                         Topic = "wallets.transactions", | ||||
|                         Title = localizer["TransactionNewTitle", readableTransactionRemark], | ||||
|                         Body = transaction.Amount > 0 | ||||
|                             ? localizer["TransactionNewBodyPlus", | ||||
|                                 transaction.Amount.ToString(CultureInfo.InvariantCulture), | ||||
|                                 transaction.Currency] | ||||
|                             : localizer["TransactionNewBodyMinus", | ||||
|                                 transaction.Amount.ToString(CultureInfo.InvariantCulture), | ||||
|                                 transaction.Currency], | ||||
|                         IsSavable = true | ||||
|                     } | ||||
|                 } | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public async Task<Order> PayOrderAsync(Guid orderId, Wallet payerWallet) | ||||
|     { | ||||
|         var order = await db.PaymentOrders | ||||
|             .Include(o => o.Transaction) | ||||
|             .Include(o => o.PayeeWallet) | ||||
|             .FirstOrDefaultAsync(o => o.Id == orderId); | ||||
|  | ||||
|         if (order == null) | ||||
| @@ -163,6 +255,27 @@ public class PaymentService( | ||||
|             throw new InvalidOperationException("Order not found"); | ||||
|         } | ||||
|  | ||||
|         var js = nats.CreateJetStreamContext(); | ||||
|  | ||||
|         if (order.Status == OrderStatus.Paid) | ||||
|         { | ||||
|             await js.PublishAsync( | ||||
|                 PaymentOrderEventBase.Type, | ||||
|                 GrpcTypeHelper.ConvertObjectToByteString(new PaymentOrderEvent | ||||
|                 { | ||||
|                     OrderId = order.Id, | ||||
|                     WalletId = payerWallet.Id, | ||||
|                     AccountId = payerWallet.AccountId, | ||||
|                     AppIdentifier = order.AppIdentifier, | ||||
|                     ProductIdentifier = order.ProductIdentifier, | ||||
|                     Meta = order.Meta ?? [], | ||||
|                     Status = (int)order.Status, | ||||
|                 }).ToByteArray() | ||||
|             ); | ||||
|  | ||||
|             return order; | ||||
|         } | ||||
|  | ||||
|         if (order.Status != OrderStatus.Unpaid) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Order is in invalid status: {order.Status}"); | ||||
| @@ -176,12 +289,13 @@ public class PaymentService( | ||||
|         } | ||||
|  | ||||
|         var transaction = await CreateTransactionAsync( | ||||
|             payerWalletId, | ||||
|             payerWallet.Id, | ||||
|             order.PayeeWalletId, | ||||
|             order.Currency, | ||||
|             order.Amount, | ||||
|             order.Remarks ?? $"Payment for Order #{order.Id}", | ||||
|             type: TransactionType.Order); | ||||
|             type: TransactionType.Order, | ||||
|             silent: true); | ||||
|  | ||||
|         order.TransactionId = transaction.Id; | ||||
|         order.Transaction = transaction; | ||||
| @@ -189,38 +303,87 @@ public class PaymentService( | ||||
|  | ||||
|         await db.SaveChangesAsync(); | ||||
|  | ||||
|         await NotifyOrderPaid(order); | ||||
|         await NotifyOrderPaid(order, payerWallet, order.PayeeWallet); | ||||
|  | ||||
|         await js.PublishAsync( | ||||
|             PaymentOrderEventBase.Type, | ||||
|             GrpcTypeHelper.ConvertObjectToByteString(new PaymentOrderEvent | ||||
|             { | ||||
|                 OrderId = order.Id, | ||||
|                 WalletId = payerWallet.Id, | ||||
|                 AccountId = payerWallet.AccountId, | ||||
|                 AppIdentifier = order.AppIdentifier, | ||||
|                 ProductIdentifier = order.ProductIdentifier, | ||||
|                 Meta = order.Meta ?? [], | ||||
|                 Status = (int)order.Status, | ||||
|             }).ToByteArray() | ||||
|         ); | ||||
|  | ||||
|         return order; | ||||
|     } | ||||
|  | ||||
|     private async Task NotifyOrderPaid(Order order) | ||||
|     private async Task NotifyOrderPaid(Order order, Wallet? payerWallet, Wallet? payeeWallet) | ||||
|     { | ||||
|         if (order.PayeeWallet is null) return; | ||||
|         var account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == order.PayeeWallet.AccountId); | ||||
|         if (account is null) return; | ||||
|         if (payerWallet is not null) | ||||
|         { | ||||
|             var account = await db.Accounts | ||||
|                 .Where(a => a.Id == payerWallet.AccountId) | ||||
|                 .FirstOrDefaultAsync(); | ||||
|             if (account is null) return; | ||||
|  | ||||
|         AccountService.SetCultureInfo(account); | ||||
|             AccountService.SetCultureInfo(account); | ||||
|  | ||||
|         // Due to ID is uuid, it longer than 8 words for sure | ||||
|         var readableOrderId = order.Id.ToString().Replace("-", "")[..8]; | ||||
|         var readableOrderRemark = order.Remarks ?? $"#{readableOrderId}"; | ||||
|             // Due to ID is uuid, it longer than 8 words for sure | ||||
|             var readableOrderId = order.Id.ToString().Replace("-", "")[..8]; | ||||
|             var readableOrderRemark = order.Remarks ?? $"#{readableOrderId}"; | ||||
|  | ||||
|  | ||||
|         await pusher.SendPushNotificationToUserAsync( | ||||
|             new SendPushNotificationToUserRequest | ||||
|             { | ||||
|                 UserId = account.Id.ToString(), | ||||
|                 Notification = new PushNotification | ||||
|             await pusher.SendPushNotificationToUserAsync( | ||||
|                 new SendPushNotificationToUserRequest | ||||
|                 { | ||||
|                     Topic = "wallets.orders.paid", | ||||
|                     Title = localizer["OrderPaidTitle", $"#{readableOrderId}"], | ||||
|                     Body = localizer["OrderPaidBody", order.Amount.ToString(CultureInfo.InvariantCulture), order.Currency, | ||||
|                         readableOrderRemark], | ||||
|                     IsSavable = true | ||||
|                     UserId = account.Id.ToString(), | ||||
|                     Notification = new PushNotification | ||||
|                     { | ||||
|                         Topic = "wallets.orders.paid", | ||||
|                         Title = localizer["OrderPaidTitle", $"#{readableOrderId}"], | ||||
|                         Body = localizer["OrderPaidBody", order.Amount.ToString(CultureInfo.InvariantCulture), | ||||
|                             order.Currency, | ||||
|                             readableOrderRemark], | ||||
|                         IsSavable = true | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         ); | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         if (payeeWallet is not null) | ||||
|         { | ||||
|             var account = await db.Accounts | ||||
|                 .Where(a => a.Id == payeeWallet.AccountId) | ||||
|                 .FirstOrDefaultAsync(); | ||||
|             if (account is null) return; | ||||
|  | ||||
|             AccountService.SetCultureInfo(account); | ||||
|  | ||||
|             // Due to ID is uuid, it longer than 8 words for sure | ||||
|             var readableOrderId = order.Id.ToString().Replace("-", "")[..8]; | ||||
|             var readableOrderRemark = order.Remarks ?? $"#{readableOrderId}"; | ||||
|  | ||||
|             await pusher.SendPushNotificationToUserAsync( | ||||
|                 new SendPushNotificationToUserRequest | ||||
|                 { | ||||
|                     UserId = account.Id.ToString(), | ||||
|                     Notification = new PushNotification | ||||
|                     { | ||||
|                         Topic = "wallets.orders.received", | ||||
|                         Title = localizer["OrderReceivedTitle", $"#{readableOrderId}"], | ||||
|                         Body = localizer["OrderReceivedBody", order.Amount.ToString(CultureInfo.InvariantCulture), | ||||
|                             order.Currency, | ||||
|                             readableOrderRemark], | ||||
|                         IsSavable = true | ||||
|                     } | ||||
|                 } | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public async Task<Order> CancelOrderAsync(Guid orderId) | ||||
|   | ||||
| @@ -13,10 +13,11 @@ public class PaymentServiceGrpc(PaymentService paymentService) : Shared.Proto.Pa | ||||
|             request.Currency, | ||||
|             decimal.Parse(request.Amount), | ||||
|             request.Expiration is not null ? Duration.FromSeconds(request.Expiration.Seconds) : null, | ||||
|             request.HasAppIdentifier ? request.AppIdentifier : null, | ||||
|             // Assuming meta is a JSON string | ||||
|             request.HasAppIdentifier ? request.AppIdentifier : Order.InternalAppIdentifier, | ||||
|             request.HasProductIdentifier ? request.ProductIdentifier : null, | ||||
|             request.HasRemarks ? request.Remarks : null, | ||||
|             request.HasMeta | ||||
|                 ? System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(request.Meta.ToStringUtf8()) | ||||
|                 ? GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object>>(request.Meta) | ||||
|                 : null, | ||||
|             request.Reuseable | ||||
|         ); | ||||
|   | ||||
| @@ -150,29 +150,6 @@ public class SubscriptionController(SubscriptionService subscriptions, AfdianPay | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public class SubscriptionOrderRequest | ||||
|     { | ||||
|         [Required] public Guid OrderId { get; set; } | ||||
|     } | ||||
|  | ||||
|     [HttpPost("order/handle")] | ||||
|     [Authorize] | ||||
|     public async Task<ActionResult<Subscription>> HandleSubscriptionOrder([FromBody] SubscriptionOrderRequest request) | ||||
|     { | ||||
|         var order = await db.PaymentOrders.FindAsync(request.OrderId); | ||||
|         if (order is null) return NotFound($"Order with ID {request.OrderId} was not found."); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var subscription = await subscriptions.HandleSubscriptionOrder(order); | ||||
|             return subscription; | ||||
|         } | ||||
|         catch (InvalidOperationException ex) | ||||
|         { | ||||
|             return BadRequest(ex.Message); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public class RestorePurchaseRequest | ||||
|     { | ||||
|         [Required] public string OrderId { get; set; } = null!; | ||||
|   | ||||
| @@ -68,7 +68,8 @@ public class SubscriptionRenewalJob( | ||||
|                     null, | ||||
|                     WalletCurrency.GoldenPoint, | ||||
|                     subscription.FinalPrice, | ||||
|                     appIdentifier: SubscriptionService.SubscriptionOrderIdentifier, | ||||
|                     appIdentifier: "internal", | ||||
|                     productIdentifier: subscription.Identifier, | ||||
|                     meta: new Dictionary<string, object>() | ||||
|                     { | ||||
|                         ["subscription_id"] = subscription.Id.ToString(), | ||||
| @@ -86,7 +87,7 @@ public class SubscriptionRenewalJob( | ||||
|                         if (wallet is null) continue; | ||||
|  | ||||
|                         // Process automatic payment from wallet | ||||
|                         await paymentService.PayOrderAsync(order.Id, wallet.Id); | ||||
|                         await paymentService.PayOrderAsync(order.Id, wallet); | ||||
|  | ||||
|                         // Update subscription details | ||||
|                         subscription.BegunAt = subscription.EndedAt!.Value; | ||||
|   | ||||
| @@ -18,7 +18,7 @@ public class SubscriptionService( | ||||
|     AppDatabase db, | ||||
|     PaymentService payment, | ||||
|     AccountService accounts, | ||||
|     PusherService.PusherServiceClient pusher, | ||||
|     RingService.RingServiceClient pusher, | ||||
|     IStringLocalizer<NotificationResource> localizer, | ||||
|     IConfiguration configuration, | ||||
|     ICacheService cache, | ||||
| @@ -229,8 +229,6 @@ public class SubscriptionService( | ||||
|         return subscription; | ||||
|     } | ||||
|  | ||||
|     public const string SubscriptionOrderIdentifier = "solian.subscription.order"; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Creates a subscription order for an unpaid or expired subscription. | ||||
|     /// If the subscription is active, it will extend its expiration date. | ||||
| @@ -259,7 +257,8 @@ public class SubscriptionService( | ||||
|             null, | ||||
|             subscriptionInfo.Currency, | ||||
|             subscription.FinalPrice, | ||||
|             appIdentifier: SubscriptionOrderIdentifier, | ||||
|             appIdentifier: "internal", | ||||
|             productIdentifier: identifier, | ||||
|             meta: new Dictionary<string, object>() | ||||
|             { | ||||
|                 ["subscription_id"] = subscription.Id.ToString(), | ||||
| @@ -270,8 +269,7 @@ public class SubscriptionService( | ||||
|  | ||||
|     public async Task<Subscription> HandleSubscriptionOrder(Order order) | ||||
|     { | ||||
|         if (order.AppIdentifier != SubscriptionOrderIdentifier || order.Status != OrderStatus.Paid || | ||||
|             order.Meta?["subscription_id"] is not JsonElement subscriptionIdJson) | ||||
|         if (order.Status != OrderStatus.Paid || order.Meta?["subscription_id"] is not JsonElement subscriptionIdJson) | ||||
|             throw new InvalidOperationException("Invalid order."); | ||||
|  | ||||
|         var subscriptionId = Guid.TryParse(subscriptionIdJson.ToString(), out var parsedSubscriptionId) | ||||
|   | ||||
| @@ -46,22 +46,51 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); | ||||
|  | ||||
|         var query = db.PaymentTransactions.AsQueryable() | ||||
|             .Include(t => t.PayeeWallet) | ||||
|             .Include(t => t.PayerWallet) | ||||
|             .Where(t => (t.PayeeWallet != null && t.PayeeWallet.AccountId == currentUser.Id) || | ||||
|                         (t.PayerWallet != null && t.PayerWallet.AccountId == currentUser.Id)); | ||||
|         var accountWallet = await db.Wallets.Where(w => w.AccountId == currentUser.Id).FirstOrDefaultAsync(); | ||||
|         if (accountWallet is null) return NotFound(); | ||||
|  | ||||
|         var query = db.PaymentTransactions | ||||
|             .Where(t => t.PayeeWalletId == accountWallet.Id || t.PayerWalletId == accountWallet.Id) | ||||
|             .OrderByDescending(t => t.CreatedAt) | ||||
|             .AsQueryable(); | ||||
|  | ||||
|         var transactionCount = await query.CountAsync(); | ||||
|         Response.Headers["X-Total"] = transactionCount.ToString(); | ||||
|          | ||||
|         var transactions = await query | ||||
|             .Skip(offset) | ||||
|             .Take(take) | ||||
|             .ToListAsync(); | ||||
|  | ||||
|         return Ok(transactions); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("orders")] | ||||
|     [Authorize] | ||||
|     public async Task<ActionResult<List<Order>>> GetOrders( | ||||
|         [FromQuery] int offset = 0, [FromQuery] int take = 20 | ||||
|     ) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); | ||||
|          | ||||
|         var accountWallet = await db.Wallets.Where(w => w.AccountId == currentUser.Id).FirstOrDefaultAsync(); | ||||
|         if (accountWallet is null) return NotFound(); | ||||
|          | ||||
|         var query = db.PaymentOrders.AsQueryable() | ||||
|             .Include(o => o.Transaction) | ||||
|             .Where(o => o.Transaction != null && (o.Transaction.PayeeWalletId == accountWallet.Id || o.Transaction.PayerWalletId == accountWallet.Id)) | ||||
|             .AsQueryable(); | ||||
|          | ||||
|         var orderCount = await query.CountAsync(); | ||||
|         Response.Headers["X-Total"] = orderCount.ToString(); | ||||
|          | ||||
|         var orders = await query | ||||
|             .Skip(offset) | ||||
|             .Take(take) | ||||
|             .OrderByDescending(t => t.CreatedAt) | ||||
|             .ToListAsync(); | ||||
|  | ||||
|         Response.Headers["X-Total"] = transactionCount.ToString(); | ||||
|  | ||||
|         return Ok(transactions); | ||||
|         return Ok(orders); | ||||
|     } | ||||
|  | ||||
|     public class WalletBalanceRequest | ||||
|   | ||||
| @@ -8,11 +8,7 @@ public class WalletServiceGrpc(WalletService walletService) : Shared.Proto.Walle | ||||
|     public override async Task<Shared.Proto.Wallet> GetWallet(GetWalletRequest request, ServerCallContext context) | ||||
|     { | ||||
|         var wallet = await walletService.GetWalletAsync(Guid.Parse(request.AccountId)); | ||||
|         if (wallet == null) | ||||
|         { | ||||
|             throw new RpcException(new Status(StatusCode.NotFound, "Wallet not found.")); | ||||
|         } | ||||
|         return wallet.ToProtoValue(); | ||||
|         return wallet == null ? throw new RpcException(new Status(StatusCode.NotFound, "Wallet not found.")) : wallet.ToProtoValue(); | ||||
|     } | ||||
|  | ||||
|     public override async Task<Shared.Proto.Wallet> CreateWallet(CreateWalletRequest request, ServerCallContext context) | ||||
|   | ||||
| @@ -10,10 +10,7 @@ | ||||
|   }, | ||||
|   "AllowedHosts": "*", | ||||
|   "ConnectionStrings": { | ||||
|     "App": "Host=localhost;Port=5432;Database=dyson_pass;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60", | ||||
|     "FastRetrieve": "localhost:6379", | ||||
|     "Etcd": "etcd.orb.local:2379", | ||||
|     "Stream": "nats.orb.local:4222" | ||||
|     "App": "Host=localhost;Port=5432;Database=dyson_pass;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60" | ||||
|   }, | ||||
|   "Authentication": { | ||||
|     "Schemes": { | ||||
| @@ -83,9 +80,7 @@ | ||||
|   ], | ||||
|   "Service": { | ||||
|     "Name": "DysonNetwork.Pass", | ||||
|     "Url": "https://localhost:7058", | ||||
|     "ClientCert": "../Certificates/client.crt", | ||||
|     "ClientKey": "../Certificates/client.key" | ||||
|     "Url": "https://localhost:7058" | ||||
|   }, | ||||
|   "Etcd": { | ||||
|     "Insecure": true | ||||
|   | ||||
| @@ -1,27 +0,0 @@ | ||||
| FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base | ||||
| WORKDIR /app | ||||
| EXPOSE 8080 | ||||
| EXPOSE 8081 | ||||
|  | ||||
| RUN apt-get update && apt-get install -y --no-install-recommends \ | ||||
|     libkrb5-dev | ||||
|  | ||||
| USER $APP_UID | ||||
|  | ||||
| FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build | ||||
| ARG BUILD_CONFIGURATION=Release | ||||
| WORKDIR /src | ||||
| COPY ["DysonNetwork.Pusher/DysonNetwork.Pusher.csproj", "DysonNetwork.Pusher/"] | ||||
| RUN dotnet restore "DysonNetwork.Pusher/DysonNetwork.Pusher.csproj" | ||||
| COPY . . | ||||
| WORKDIR "/src/DysonNetwork.Pusher" | ||||
| RUN dotnet build "./DysonNetwork.Pusher.csproj" -c $BUILD_CONFIGURATION -o /app/build | ||||
|  | ||||
| FROM build AS publish | ||||
| ARG BUILD_CONFIGURATION=Release | ||||
| RUN dotnet publish "./DysonNetwork.Pusher.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false | ||||
|  | ||||
| FROM base AS final | ||||
| WORKDIR /app | ||||
| COPY --from=publish /app/publish . | ||||
| ENTRYPOINT ["dotnet", "DysonNetwork.Pusher.dll"] | ||||
| @@ -1,67 +0,0 @@ | ||||
| using System.Net; | ||||
| using DysonNetwork.Pusher.Services; | ||||
| using Microsoft.AspNetCore.HttpOverrides; | ||||
|  | ||||
| namespace DysonNetwork.Pusher.Startup; | ||||
|  | ||||
| public static class ApplicationConfiguration | ||||
| { | ||||
|     public static WebApplication ConfigureAppMiddleware(this WebApplication app, IConfiguration configuration) | ||||
|     { | ||||
|         app.MapOpenApi(); | ||||
|  | ||||
|         app.UseSwagger(); | ||||
|         app.UseSwaggerUI(); | ||||
|          | ||||
|         app.UseRequestLocalization(); | ||||
|  | ||||
|         ConfigureForwardedHeaders(app, configuration); | ||||
|  | ||||
|         app.UseCors(opts => | ||||
|             opts.SetIsOriginAllowed(_ => true) | ||||
|                 .WithExposedHeaders("*") | ||||
|                 .WithHeaders("*") | ||||
|                 .AllowCredentials() | ||||
|                 .AllowAnyHeader() | ||||
|                 .AllowAnyMethod() | ||||
|         ); | ||||
|  | ||||
|         app.UseWebSockets(); | ||||
|         app.UseRateLimiter(); | ||||
|         app.UseAuthentication(); | ||||
|         app.UseAuthorization(); | ||||
|  | ||||
|         app.MapControllers().RequireRateLimiting("fixed"); | ||||
|  | ||||
|         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); | ||||
|     } | ||||
|  | ||||
|     public static WebApplication ConfigureGrpcServices(this WebApplication app) | ||||
|     { | ||||
|         app.MapGrpcService<PusherServiceGrpc>(); | ||||
|          | ||||
|         return app; | ||||
|     } | ||||
| } | ||||
| @@ -1,6 +1,6 @@ | ||||
| using System.Linq.Expressions; | ||||
| using System.Reflection; | ||||
| using DysonNetwork.Pusher.Notification; | ||||
| using DysonNetwork.Ring.Notification; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.EntityFrameworkCore.Design; | ||||
| @@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Query; | ||||
| using NodaTime; | ||||
| using Quartz; | ||||
| 
 | ||||
| namespace DysonNetwork.Pusher; | ||||
| namespace DysonNetwork.Ring; | ||||
| 
 | ||||
| public class AppDatabase( | ||||
|     DbContextOptions<AppDatabase> options, | ||||
| @@ -1,4 +1,4 @@ | ||||
| namespace DysonNetwork.Pusher.Connection; | ||||
| namespace DysonNetwork.Ring.Connection; | ||||
| 
 | ||||
| public class ClientTypeMiddleware(RequestDelegate next) | ||||
| { | ||||
| @@ -1,7 +1,7 @@ | ||||
| using System.Net.WebSockets; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| 
 | ||||
| namespace DysonNetwork.Pusher.Connection; | ||||
| namespace DysonNetwork.Ring.Connection; | ||||
| 
 | ||||
| public interface IWebSocketPacketHandler | ||||
| { | ||||
| @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Swashbuckle.AspNetCore.Annotations; | ||||
| 
 | ||||
| namespace DysonNetwork.Pusher.Connection; | ||||
| namespace DysonNetwork.Ring.Connection; | ||||
| 
 | ||||
| [ApiController] | ||||
| public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext> logger) : ControllerBase | ||||
| @@ -24,7 +24,7 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext> | ||||
|         } | ||||
| 
 | ||||
|         var accountId = currentUser.Id!; | ||||
|         var deviceId = currentSession.Challenge.DeviceId!; | ||||
|         var deviceId = currentSession.Challenge?.DeviceId ?? Guid.NewGuid().ToString(); | ||||
| 
 | ||||
|         if (string.IsNullOrEmpty(deviceId)) | ||||
|         { | ||||
| @@ -32,7 +32,8 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext> | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         using var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync(); | ||||
|         var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync(new WebSocketAcceptContext | ||||
|             { KeepAliveInterval = TimeSpan.FromSeconds(60) }); | ||||
|         var cts = new CancellationTokenSource(); | ||||
|         var connectionKey = (accountId, deviceId); | ||||
| 
 | ||||
| @@ -65,7 +66,12 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext> | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             logger.LogError(ex, "WebSocket disconnected with user @{UserName}#{UserId} and device #{DeviceId} unexpectedly"); | ||||
|             logger.LogError(ex, | ||||
|                 "WebSocket disconnected with user @{UserName}#{UserId} and device #{DeviceId} unexpectedly", | ||||
|                 currentUser.Name, | ||||
|                 currentUser.Id, | ||||
|                 deviceId | ||||
|             ); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
| @@ -99,7 +105,7 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext> | ||||
|                     break; | ||||
| 
 | ||||
|                 var packet = WebSocketPacket.FromBytes(buffer[..receiveResult.Count]); | ||||
|                 _ = ws.HandlePacket(currentUser, connectionKey.DeviceId, packet, webSocket); | ||||
|                 await ws.HandlePacket(currentUser, connectionKey.DeviceId, packet, webSocket); | ||||
|             } | ||||
|         } | ||||
|         catch (OperationCanceledException) | ||||
| @@ -4,7 +4,7 @@ using DysonNetwork.Shared.Proto; | ||||
| using NodaTime; | ||||
| using NodaTime.Serialization.SystemTextJson; | ||||
| 
 | ||||
| namespace DysonNetwork.Pusher.Connection; | ||||
| namespace DysonNetwork.Ring.Connection; | ||||
| 
 | ||||
| public class WebSocketPacket | ||||
| { | ||||
| @@ -29,6 +29,7 @@ public class WebSocketPacket | ||||
|         var json = System.Text.Encoding.UTF8.GetString(bytes); | ||||
|         var jsonOpts = new JsonSerializerOptions | ||||
|         { | ||||
|             NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, | ||||
|             PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, | ||||
|             DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower, | ||||
|         }; | ||||
| @@ -48,6 +49,7 @@ public class WebSocketPacket | ||||
| 
 | ||||
|         var jsonOpts = new JsonSerializerOptions | ||||
|         { | ||||
|             NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, | ||||
|             PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, | ||||
|             DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower, | ||||
|         }; | ||||
| @@ -65,6 +67,7 @@ public class WebSocketPacket | ||||
|     { | ||||
|         var jsonOpts = new JsonSerializerOptions | ||||
|         { | ||||
|             NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, | ||||
|             PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, | ||||
|             DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower, | ||||
|         }.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); | ||||
| @@ -1,27 +1,23 @@ | ||||
| using System.Collections.Concurrent; | ||||
| using System.Net.WebSockets; | ||||
| using dotnet_etcd.interfaces; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using Grpc.Core; | ||||
| 
 | ||||
| namespace DysonNetwork.Pusher.Connection; | ||||
| namespace DysonNetwork.Ring.Connection; | ||||
| 
 | ||||
| public class WebSocketService | ||||
| { | ||||
|     private readonly IConfiguration _configuration; | ||||
|     private readonly ILogger<WebSocketService> _logger; | ||||
|     private readonly IEtcdClient _etcdClient; | ||||
|     private readonly IDictionary<string, IWebSocketPacketHandler> _handlerMap; | ||||
| 
 | ||||
|     public WebSocketService( | ||||
|         IEnumerable<IWebSocketPacketHandler> handlers, | ||||
|         IEtcdClient etcdClient, | ||||
|         ILogger<WebSocketService> logger, | ||||
|         IConfiguration configuration | ||||
|     ) | ||||
|     { | ||||
|         _etcdClient = etcdClient; | ||||
|         _logger = logger; | ||||
|         _configuration = configuration; | ||||
|         _handlerMap = handlers.ToDictionary(h => h.PacketType); | ||||
| @@ -49,11 +45,20 @@ public class WebSocketService | ||||
|     public void Disconnect((string AccountId, string DeviceId) key, string? reason = null) | ||||
|     { | ||||
|         if (!ActiveConnections.TryGetValue(key, out var data)) return; | ||||
|         data.Socket.CloseAsync( | ||||
|             WebSocketCloseStatus.NormalClosure, | ||||
|             reason ?? "Server just decided to disconnect.", | ||||
|             CancellationToken.None | ||||
|         ); | ||||
|         try | ||||
|         { | ||||
|             data.Socket.CloseAsync( | ||||
|                 WebSocketCloseStatus.NormalClosure, | ||||
|                 reason ?? "Server just decided to disconnect.", | ||||
|                 CancellationToken.None | ||||
|             ); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             _logger.LogWarning(ex, "Error while closing WebSocket for {AccountId}:{DeviceId}", key.AccountId, | ||||
|                 key.DeviceId); | ||||
|         } | ||||
| 
 | ||||
|         data.Cts.Cancel(); | ||||
|         ActiveConnections.TryRemove(key, out _); | ||||
|     } | ||||
| @@ -133,45 +138,24 @@ public class WebSocketService | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 // Get the service URL from etcd for the specified endpoint | ||||
|                 var serviceKey = $"/services/{packet.Endpoint}"; | ||||
|                 var response = await _etcdClient.GetAsync(serviceKey); | ||||
|                 var serviceUrl = "https://" + packet.Endpoint; | ||||
| 
 | ||||
|                 if (response.Kvs.Count > 0) | ||||
|                 var callInvoker = GrpcClientHelper.CreateCallInvoker(serviceUrl); | ||||
|                 var client = new RingHandlerService.RingHandlerServiceClient(callInvoker); | ||||
| 
 | ||||
|                 try | ||||
|                 { | ||||
|                     var serviceUrl = response.Kvs[0].Value.ToStringUtf8(); | ||||
| 
 | ||||
|                     var clientCertPath = _configuration["Service:ClientCert"]!; | ||||
|                     var clientKeyPath = _configuration["Service:ClientKey"]!; | ||||
|                     var clientCertPassword = _configuration["Service:CertPassword"]; | ||||
| 
 | ||||
|                     var callInvoker = | ||||
|                         GrpcClientHelper.CreateCallInvoker( | ||||
|                             serviceUrl, | ||||
|                             clientCertPath, | ||||
|                             clientKeyPath, | ||||
|                             clientCertPassword | ||||
|                         ); | ||||
|                     var client = new PusherHandlerService.PusherHandlerServiceClient(callInvoker); | ||||
| 
 | ||||
|                     try | ||||
|                     await client.ReceiveWebSocketPacketAsync(new ReceiveWebSocketPacketRequest | ||||
|                     { | ||||
|                         await client.ReceiveWebSocketPacketAsync(new ReceiveWebSocketPacketRequest | ||||
|                         { | ||||
|                             Account = currentUser, | ||||
|                             DeviceId = deviceId, | ||||
|                             Packet = packet.ToProtoValue() | ||||
|                         }); | ||||
|                     } | ||||
|                     catch (RpcException ex) | ||||
|                     { | ||||
|                         _logger.LogError(ex, $"Error forwarding packet to endpoint: {packet.Endpoint}"); | ||||
|                     } | ||||
| 
 | ||||
|                     return; | ||||
|                         Account = currentUser, | ||||
|                         DeviceId = deviceId, | ||||
|                         Packet = packet.ToProtoValue() | ||||
|                     }); | ||||
|                 } | ||||
|                 catch (RpcException ex) | ||||
|                 { | ||||
|                     _logger.LogError(ex, $"Error forwarding packet to endpoint: {packet.Endpoint}"); | ||||
|                 } | ||||
| 
 | ||||
|                 _logger.LogWarning($"No service registered for endpoint: {packet.Endpoint}"); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
							
								
								
									
										27
									
								
								DysonNetwork.Ring/Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								DysonNetwork.Ring/Dockerfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base | ||||
| WORKDIR /app | ||||
| EXPOSE 8080 | ||||
| EXPOSE 8081 | ||||
|  | ||||
| RUN apt-get update && apt-get install -y --no-install-recommends \ | ||||
|     libkrb5-dev | ||||
|  | ||||
| USER $APP_UID | ||||
|  | ||||
| FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build | ||||
| ARG BUILD_CONFIGURATION=Release | ||||
| WORKDIR /src | ||||
| COPY ["DysonNetwork.Ring/DysonNetwork.Ring.csproj", "DysonNetwork.Ring/"] | ||||
| RUN dotnet restore "DysonNetwork.Ring/DysonNetwork.Ring.csproj" | ||||
| COPY . . | ||||
| WORKDIR "/src/DysonNetwork.Ring" | ||||
| RUN dotnet build "./DysonNetwork.Ring.csproj" -c $BUILD_CONFIGURATION -o /app/build | ||||
|  | ||||
| FROM build AS publish | ||||
| ARG BUILD_CONFIGURATION=Release | ||||
| RUN dotnet publish "./DysonNetwork.Ring.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false | ||||
|  | ||||
| FROM base AS final | ||||
| WORKDIR /app | ||||
| COPY --from=publish /app/publish . | ||||
| ENTRYPOINT ["dotnet", "DysonNetwork.Ring.dll"] | ||||
| @@ -5,6 +5,7 @@ | ||||
|         <Nullable>enable</Nullable> | ||||
|         <ImplicitUsings>enable</ImplicitUsings> | ||||
|         <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> | ||||
|         <RootNamespace>DysonNetwork.Pusher</RootNamespace> | ||||
|     </PropertyGroup> | ||||
| 
 | ||||
|     <ItemGroup> | ||||
| @@ -41,6 +42,7 @@ | ||||
|     </ItemGroup> | ||||
| 
 | ||||
|     <ItemGroup> | ||||
|       <ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" /> | ||||
|       <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> | ||||
|     </ItemGroup> | ||||
| 
 | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user