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: | on: | ||||||
|    push: |   push: | ||||||
|      branches: |     branches: | ||||||
|        - master |       - master | ||||||
|    workflow_dispatch: |   workflow_dispatch: | ||||||
|  |  | ||||||
|  jobs: | jobs: | ||||||
|    build-sphere: |   publish: | ||||||
|      runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|      permissions: |     permissions: | ||||||
|        contents: read |       contents: read | ||||||
|        packages: write |       packages: write | ||||||
|      steps: |     steps: | ||||||
|        - name: Checkout repository |       - name: Checkout repository | ||||||
|          uses: actions/checkout@v3 |         uses: actions/checkout@v3 | ||||||
|          with: |  | ||||||
|            fetch-depth: 0 |  | ||||||
|        - name: Setup NBGV |  | ||||||
|          uses: dotnet/nbgv@master |  | ||||||
|          id: nbgv |  | ||||||
|        - name: Set up Docker Buildx |  | ||||||
|          uses: docker/setup-buildx-action@v3 |  | ||||||
|        - name: Log in to GitHub Container Registry |  | ||||||
|          uses: docker/login-action@v3 |  | ||||||
|          with: |  | ||||||
|            registry: ghcr.io |  | ||||||
|            username: ${{ github.actor }} |  | ||||||
|            password: ${{ secrets.GITHUB_TOKEN }} |  | ||||||
|        - name: Build and push DysonNetwork.Sphere Docker image |  | ||||||
|          uses: docker/build-push-action@v6 |  | ||||||
|          with: |  | ||||||
|            file: DysonNetwork.Sphere/Dockerfile |  | ||||||
|            context: . |  | ||||||
|            push: true |  | ||||||
|            tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-sphere:latest |  | ||||||
|            platforms: linux/amd64 |  | ||||||
|  |  | ||||||
|    build-pass: |       - name: Setup .NET | ||||||
|      runs-on: ubuntu-latest |         uses: actions/setup-dotnet@v3 | ||||||
|      permissions: |         with: | ||||||
|        contents: read |           dotnet-version: "9.0.x" | ||||||
|        packages: write |  | ||||||
|      steps: |  | ||||||
|        - name: Checkout repository |  | ||||||
|          uses: actions/checkout@v3 |  | ||||||
|          with: |  | ||||||
|            fetch-depth: 0 |  | ||||||
|        - name: Setup NBGV |  | ||||||
|          uses: dotnet/nbgv@master |  | ||||||
|          id: nbgv |  | ||||||
|        - name: Set up Docker Buildx |  | ||||||
|          uses: docker/setup-buildx-action@v3 |  | ||||||
|        - name: Log in to GitHub Container Registry |  | ||||||
|          uses: docker/login-action@v3 |  | ||||||
|          with: |  | ||||||
|            registry: ghcr.io |  | ||||||
|            username: ${{ github.actor }} |  | ||||||
|            password: ${{ secrets.GITHUB_TOKEN }} |  | ||||||
|        - name: Build and push DysonNetwork.Pass Docker image |  | ||||||
|          uses: docker/build-push-action@v6 |  | ||||||
|          with: |  | ||||||
|            file: DysonNetwork.Pass/Dockerfile |  | ||||||
|            context: . |  | ||||||
|            push: true |  | ||||||
|            tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-pass:latest |  | ||||||
|            platforms: linux/amd64 |  | ||||||
|  |  | ||||||
|    build-pusher: |       - name: Log in to GitHub Container Registry | ||||||
|      runs-on: ubuntu-latest |         uses: docker/login-action@v3 | ||||||
|      permissions: |         with: | ||||||
|        contents: read |           registry: ghcr.io | ||||||
|        packages: write |           username: ${{ github.actor }} | ||||||
|      steps: |           password: ${{ secrets.GITHUB_TOKEN }} | ||||||
|        - name: Checkout repository |  | ||||||
|          uses: actions/checkout@v3 |  | ||||||
|          with: |  | ||||||
|            fetch-depth: 0 |  | ||||||
|        - name: Setup NBGV |  | ||||||
|          uses: dotnet/nbgv@master |  | ||||||
|          id: nbgv |  | ||||||
|        - name: Set up Docker Buildx |  | ||||||
|          uses: docker/setup-buildx-action@v3 |  | ||||||
|        - name: Log in to GitHub Container Registry |  | ||||||
|          uses: docker/login-action@v3 |  | ||||||
|          with: |  | ||||||
|            registry: ghcr.io |  | ||||||
|            username: ${{ github.actor }} |  | ||||||
|            password: ${{ secrets.GITHUB_TOKEN }} |  | ||||||
|        - name: Build and push DysonNetwork.Pusher Docker image |  | ||||||
|          uses: docker/build-push-action@v6 |  | ||||||
|          with: |  | ||||||
|            file: DysonNetwork.Pusher/Dockerfile |  | ||||||
|            context: . |  | ||||||
|            push: true |  | ||||||
|            tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-pusher:latest |  | ||||||
|            platforms: linux/amd64 |  | ||||||
|  |  | ||||||
|    build-drive: |       - name: Install Aspire CLI | ||||||
|      runs-on: ubuntu-latest |         run: dotnet tool install -g Aspire.Cli --prerelease | ||||||
|      permissions: |  | ||||||
|        contents: read |  | ||||||
|        packages: write |  | ||||||
|      steps: |  | ||||||
|        - name: Checkout repository |  | ||||||
|          uses: actions/checkout@v3 |  | ||||||
|          with: |  | ||||||
|            fetch-depth: 0 |  | ||||||
|        - name: Setup NBGV |  | ||||||
|          uses: dotnet/nbgv@master |  | ||||||
|          id: nbgv |  | ||||||
|        - name: Set up Docker Buildx |  | ||||||
|          uses: docker/setup-buildx-action@v3 |  | ||||||
|        - name: Log in to GitHub Container Registry |  | ||||||
|          uses: docker/login-action@v3 |  | ||||||
|          with: |  | ||||||
|            registry: ghcr.io |  | ||||||
|            username: ${{ github.actor }} |  | ||||||
|            password: ${{ secrets.GITHUB_TOKEN }} |  | ||||||
|        - name: Build and push DysonNetwork.Drive Docker image |  | ||||||
|          uses: docker/build-push-action@v6 |  | ||||||
|          with: |  | ||||||
|            file: DysonNetwork.Drive/Dockerfile |  | ||||||
|            context: . |  | ||||||
|            push: true |  | ||||||
|            tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-drive:latest |  | ||||||
|            platforms: linux/amd64 |  | ||||||
|  |  | ||||||
|    build-gateway: |       - name: Build and Publish Aspire Application | ||||||
|      runs-on: ubuntu-latest |         run: aspire publish --project ./DysonNetwork.Control/DysonNetwork.Control.csproj --output publish | ||||||
|      permissions: |  | ||||||
|        contents: read |  | ||||||
|        packages: write |  | ||||||
|      steps: |  | ||||||
|        - name: Checkout repository |  | ||||||
|          uses: actions/checkout@v3 |  | ||||||
|          with: |  | ||||||
|            fetch-depth: 0 |  | ||||||
|        - name: Setup NBGV |  | ||||||
|          uses: dotnet/nbgv@master |  | ||||||
|          id: nbgv |  | ||||||
|        - name: Set up Docker Buildx |  | ||||||
|          uses: docker/setup-buildx-action@v3 |  | ||||||
|        - name: Log in to GitHub Container Registry |  | ||||||
|          uses: docker/login-action@v3 |  | ||||||
|          with: |  | ||||||
|            registry: ghcr.io |  | ||||||
|            username: ${{ github.actor }} |  | ||||||
|            password: ${{ secrets.GITHUB_TOKEN }} |  | ||||||
|        - name: Build and push DysonNetwork.Gateway Docker image |  | ||||||
|          uses: docker/build-push-action@v6 |  | ||||||
|          with: |  | ||||||
|            file: DysonNetwork.Gateway/Dockerfile |  | ||||||
|            context: . |  | ||||||
|            push: true |  | ||||||
|            tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-gateway:latest |  | ||||||
|            platforms: linux/amd64 |  | ||||||
|  |  | ||||||
|    build-develop: |       - name: Tag and Push Images | ||||||
|      runs-on: ubuntu-latest |         run: | | ||||||
|      permissions: |           IMAGES=( "sphere" "pass" "ring" "drive" "develop" ) | ||||||
|        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 |  | ||||||
|  |  | ||||||
|  |           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> | ||||||
|  |  | ||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|  |     <ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" /> | ||||||
|     <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> |     <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> | ||||||
|   </ItemGroup> |   </ItemGroup> | ||||||
|   |   | ||||||
|   | |||||||
| @@ -1,17 +1,16 @@ | |||||||
| using DysonNetwork.Develop; | using DysonNetwork.Develop; | ||||||
| using DysonNetwork.Shared.Auth; | using DysonNetwork.Shared.Auth; | ||||||
| using DysonNetwork.Shared.Http; | using DysonNetwork.Shared.Http; | ||||||
| using DysonNetwork.Shared.Registry; |  | ||||||
| using DysonNetwork.Develop.Startup; | using DysonNetwork.Develop.Startup; | ||||||
| using DysonNetwork.Shared.Stream; | using DysonNetwork.Shared.Registry; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
|  |  | ||||||
| var builder = WebApplication.CreateBuilder(args); | var builder = WebApplication.CreateBuilder(args); | ||||||
|  |  | ||||||
|  | builder.AddServiceDefaults(); | ||||||
|  |  | ||||||
| builder.ConfigureAppKestrel(builder.Configuration); | builder.ConfigureAppKestrel(builder.Configuration); | ||||||
|  |  | ||||||
| builder.Services.AddRegistryService(builder.Configuration); |  | ||||||
| builder.Services.AddStreamConnection(builder.Configuration); |  | ||||||
| builder.Services.AddAppServices(builder.Configuration); | builder.Services.AddAppServices(builder.Configuration); | ||||||
| builder.Services.AddAppAuthentication(); | builder.Services.AddAppAuthentication(); | ||||||
| builder.Services.AddAppSwagger(); | builder.Services.AddAppSwagger(); | ||||||
| @@ -22,6 +21,8 @@ builder.Services.AddDriveService(); | |||||||
|  |  | ||||||
| var app = builder.Build(); | var app = builder.Build(); | ||||||
|  |  | ||||||
|  | app.MapDefaultEndpoints(); | ||||||
|  |  | ||||||
| using (var scope = app.Services.CreateScope()) | using (var scope = app.Services.CreateScope()) | ||||||
| { | { | ||||||
|     var db = scope.ServiceProvider.GetRequiredService<AppDatabase>(); |     var db = scope.ServiceProvider.GetRequiredService<AppDatabase>(); | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| using System.Net; | using System.Net; | ||||||
| using DysonNetwork.Develop.Identity; | using DysonNetwork.Develop.Identity; | ||||||
| using DysonNetwork.Shared.Auth; | using DysonNetwork.Shared.Auth; | ||||||
|  | using DysonNetwork.Shared.Http; | ||||||
| using Microsoft.AspNetCore.HttpOverrides; | using Microsoft.AspNetCore.HttpOverrides; | ||||||
| using Prometheus; | using Prometheus; | ||||||
|  |  | ||||||
| @@ -18,7 +19,7 @@ public static class ApplicationConfiguration | |||||||
|          |          | ||||||
|         app.UseRequestLocalization(); |         app.UseRequestLocalization(); | ||||||
|  |  | ||||||
|         ConfigureForwardedHeaders(app, configuration); |         app.ConfigureForwardedHeaders(configuration); | ||||||
|  |  | ||||||
|         app.UseAuthentication(); |         app.UseAuthentication(); | ||||||
|         app.UseAuthorization(); |         app.UseAuthorization(); | ||||||
| @@ -30,26 +31,4 @@ public static class ApplicationConfiguration | |||||||
|  |  | ||||||
|         return app; |         return app; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private static void ConfigureForwardedHeaders(WebApplication app, IConfiguration configuration) |  | ||||||
|     { |  | ||||||
|         var knownProxiesSection = configuration.GetSection("KnownProxies"); |  | ||||||
|         var forwardedHeadersOptions = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All }; |  | ||||||
|  |  | ||||||
|         if (knownProxiesSection.Exists()) |  | ||||||
|         { |  | ||||||
|             var proxyAddresses = knownProxiesSection.Get<string[]>(); |  | ||||||
|             if (proxyAddresses != null) |  | ||||||
|                 foreach (var proxy in proxyAddresses) |  | ||||||
|                     if (IPAddress.TryParse(proxy, out var ipAddress)) |  | ||||||
|                         forwardedHeadersOptions.KnownProxies.Add(ipAddress); |  | ||||||
|         } |  | ||||||
|         else |  | ||||||
|         { |  | ||||||
|             forwardedHeadersOptions.KnownProxies.Add(IPAddress.Any); |  | ||||||
|             forwardedHeadersOptions.KnownProxies.Add(IPAddress.IPv6Any); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         app.UseForwardedHeaders(forwardedHeadersOptions); |  | ||||||
|     } |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ using Microsoft.OpenApi.Models; | |||||||
| using NodaTime; | using NodaTime; | ||||||
| using NodaTime.Serialization.SystemTextJson; | using NodaTime.Serialization.SystemTextJson; | ||||||
| using System.Text.Json; | using System.Text.Json; | ||||||
|  | using System.Text.Json.Serialization; | ||||||
| using DysonNetwork.Develop.Identity; | using DysonNetwork.Develop.Identity; | ||||||
| using DysonNetwork.Develop.Project; | using DysonNetwork.Develop.Project; | ||||||
| using DysonNetwork.Shared.Cache; | using DysonNetwork.Shared.Cache; | ||||||
| @@ -19,19 +20,16 @@ public static class ServiceCollectionExtensions | |||||||
|         services.AddDbContext<AppDatabase>(); |         services.AddDbContext<AppDatabase>(); | ||||||
|         services.AddSingleton<IClock>(SystemClock.Instance); |         services.AddSingleton<IClock>(SystemClock.Instance); | ||||||
|         services.AddHttpContextAccessor(); |         services.AddHttpContextAccessor(); | ||||||
|         services.AddSingleton<IConnectionMultiplexer>(_ => |  | ||||||
|         { |  | ||||||
|             var connection = configuration.GetConnectionString("FastRetrieve")!; |  | ||||||
|             return ConnectionMultiplexer.Connect(connection); |  | ||||||
|         }); |  | ||||||
|         services.AddSingleton<ICacheService, CacheServiceRedis>(); |         services.AddSingleton<ICacheService, CacheServiceRedis>(); | ||||||
|  |  | ||||||
|         services.AddHttpClient(); |         services.AddHttpClient(); | ||||||
|  |  | ||||||
|         services.AddControllers().AddJsonOptions(options => |         services.AddControllers().AddJsonOptions(options => | ||||||
|         { |         { | ||||||
|  |             options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals; | ||||||
|             options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; |             options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; | ||||||
|             options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower; |             options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower; | ||||||
|  |              | ||||||
|             options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); |             options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -10,10 +10,7 @@ | |||||||
|   }, |   }, | ||||||
|   "AllowedHosts": "*", |   "AllowedHosts": "*", | ||||||
|   "ConnectionStrings": { |   "ConnectionStrings": { | ||||||
|     "App": "Host=localhost;Port=5432;Database=dyson_network_dev;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60", |     "App": "Host=localhost;Port=5432;Database=dyson_network_dev;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60" | ||||||
|     "FastRetrieve": "localhost:6379", |  | ||||||
|     "Etcd": "etcd.orb.local:2379", |  | ||||||
|     "Stream": "nats.orb.local:4222" |  | ||||||
|   }, |   }, | ||||||
|   "KnownProxies": [ |   "KnownProxies": [ | ||||||
|     "127.0.0.1", |     "127.0.0.1", | ||||||
| @@ -24,8 +21,6 @@ | |||||||
|   }, |   }, | ||||||
|   "Service": { |   "Service": { | ||||||
|     "Name": "DysonNetwork.Develop", |     "Name": "DysonNetwork.Develop", | ||||||
|     "Url": "https://localhost:7192", |     "Url": "https://localhost:7192" | ||||||
|     "ClientCert": "../Certificates/client.crt", |  | ||||||
|     "ClientKey": "../Certificates/client.key" |  | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -31,7 +31,6 @@ public class AppDatabase( | |||||||
|             opt => opt |             opt => opt | ||||||
|                 .ConfigureDataSource(optSource => optSource.EnableDynamicJson()) |                 .ConfigureDataSource(optSource => optSource.EnableDynamicJson()) | ||||||
|                 .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) |                 .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) | ||||||
|                 .UseNetTopologySuite() |  | ||||||
|                 .UseNodaTime() |                 .UseNodaTime() | ||||||
|         ).UseSnakeCaseNamingConvention(); |         ).UseSnakeCaseNamingConvention(); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -35,7 +35,6 @@ | |||||||
|         <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" /> |         <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" /> | ||||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> |         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> | ||||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" /> |         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" /> | ||||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="9.0.4" /> |  | ||||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" /> |         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" /> | ||||||
|         <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" /> |         <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" /> | ||||||
|         <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" /> |         <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" /> | ||||||
| @@ -67,6 +66,7 @@ | |||||||
|     </ItemGroup> |     </ItemGroup> | ||||||
|  |  | ||||||
|     <ItemGroup> |     <ItemGroup> | ||||||
|  |       <ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" /> | ||||||
|       <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> |       <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> | ||||||
|     </ItemGroup> |     </ItemGroup> | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										403
									
								
								DysonNetwork.Drive/Migrations/20250907070034_RemoveNetTopo.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										403
									
								
								DysonNetwork.Drive/Migrations/20250907070034_RemoveNetTopo.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,403 @@ | |||||||
|  | // <auto-generated /> | ||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using DysonNetwork.Drive; | ||||||
|  | using DysonNetwork.Drive.Storage; | ||||||
|  | using DysonNetwork.Shared.Data; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||||
|  | using NodaTime; | ||||||
|  | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Migrations | ||||||
|  | { | ||||||
|  |     [DbContext(typeof(AppDatabase))] | ||||||
|  |     [Migration("20250907070034_RemoveNetTopo")] | ||||||
|  |     partial class RemoveNetTopo | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void BuildTargetModel(ModelBuilder modelBuilder) | ||||||
|  |         { | ||||||
|  | #pragma warning disable 612, 618 | ||||||
|  |             modelBuilder | ||||||
|  |                 .HasAnnotation("ProductVersion", "9.0.7") | ||||||
|  |                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||||
|  |  | ||||||
|  |             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("text") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("text") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<long>("Quota") | ||||||
|  |                         .HasColumnType("bigint") | ||||||
|  |                         .HasColumnName("quota"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_quota_records"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("quota_records", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<string>("Id") | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("BundleId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("bundle_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Dictionary<string, object>>("FileMeta") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("file_meta"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("HasCompression") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("has_compression"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("HasThumbnail") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("has_thumbnail"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Hash") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("hash"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsEncrypted") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_encrypted"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsMarkedRecycle") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_marked_recycle"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("MimeType") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("mime_type"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("PoolId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("pool_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<List<ContentSensitiveMark>>("SensitiveMarks") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("sensitive_marks"); | ||||||
|  |  | ||||||
|  |                     b.Property<long>("Size") | ||||||
|  |                         .HasColumnType("bigint") | ||||||
|  |                         .HasColumnName("size"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("StorageId") | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("storage_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("StorageUrl") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("storage_url"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("UploadedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("uploaded_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Dictionary<string, object>>("UserMeta") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("user_meta"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_files"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("BundleId") | ||||||
|  |                         .HasDatabaseName("ix_files_bundle_id"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("PoolId") | ||||||
|  |                         .HasDatabaseName("ix_files_pool_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("files", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("FileId") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("file_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("ResourceId") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("resource_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Usage") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("usage"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_file_references"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("FileId") | ||||||
|  |                         .HasDatabaseName("ix_file_references_file_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("file_references", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .HasMaxLength(8192) | ||||||
|  |                         .HasColumnType("character varying(8192)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Passcode") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("passcode"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Slug") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("slug"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_bundles"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("Slug") | ||||||
|  |                         .IsUnique() | ||||||
|  |                         .HasDatabaseName("ix_bundles_slug"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("bundles", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid?>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<BillingConfig>("BillingConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("billing_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(8192) | ||||||
|  |                         .HasColumnType("character varying(8192)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsHidden") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_hidden"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<PolicyConfig>("PolicyConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("policy_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<RemoteStorageConfig>("StorageConfig") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("storage_config"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_pools"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("pools", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.FileBundle", "Bundle") | ||||||
|  |                         .WithMany("Files") | ||||||
|  |                         .HasForeignKey("BundleId") | ||||||
|  |                         .HasConstraintName("fk_files_bundles_bundle_id"); | ||||||
|  |  | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool") | ||||||
|  |                         .WithMany() | ||||||
|  |                         .HasForeignKey("PoolId") | ||||||
|  |                         .HasConstraintName("fk_files_pools_pool_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Bundle"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Pool"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File") | ||||||
|  |                         .WithMany("References") | ||||||
|  |                         .HasForeignKey("FileId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasConstraintName("fk_file_references_files_file_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("File"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||||
|  |                 { | ||||||
|  |                     b.Navigation("References"); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b => | ||||||
|  |                 { | ||||||
|  |                     b.Navigation("Files"); | ||||||
|  |                 }); | ||||||
|  | #pragma warning restore 612, 618 | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,24 @@ | |||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Migrations | ||||||
|  | { | ||||||
|  |     /// <inheritdoc /> | ||||||
|  |     public partial class RemoveNetTopo : Migration | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Up(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.AlterDatabase() | ||||||
|  |                 .OldAnnotation("Npgsql:PostgresExtension:postgis", ",,"); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.AlterDatabase() | ||||||
|  |                 .Annotation("Npgsql:PostgresExtension:postgis", ",,"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -24,7 +24,6 @@ namespace DysonNetwork.Drive.Migrations | |||||||
|                 .HasAnnotation("ProductVersion", "9.0.7") |                 .HasAnnotation("ProductVersion", "9.0.7") | ||||||
|                 .HasAnnotation("Relational:MaxIdentifierLength", 63); |                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||||
|  |  | ||||||
|             NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); |  | ||||||
|             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); |             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||||
|  |  | ||||||
|             modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b => |             modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b => | ||||||
|   | |||||||
| @@ -5,18 +5,18 @@ using DysonNetwork.Shared.Auth; | |||||||
| using DysonNetwork.Shared.Http; | using DysonNetwork.Shared.Http; | ||||||
| using DysonNetwork.Shared.PageData; | using DysonNetwork.Shared.PageData; | ||||||
| using DysonNetwork.Shared.Registry; | using DysonNetwork.Shared.Registry; | ||||||
| using DysonNetwork.Shared.Stream; |  | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using tusdotnet.Stores; | using tusdotnet.Stores; | ||||||
|  |  | ||||||
| var builder = WebApplication.CreateBuilder(args); | var builder = WebApplication.CreateBuilder(args); | ||||||
|  |  | ||||||
|  | builder.AddServiceDefaults(); | ||||||
|  |  | ||||||
| // Configure Kestrel and server options | // Configure Kestrel and server options | ||||||
| builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxValue); | builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxValue); | ||||||
|  |  | ||||||
| // Add application services | // Add application services | ||||||
| builder.Services.AddRegistryService(builder.Configuration); |  | ||||||
| builder.Services.AddStreamConnection(builder.Configuration); |  | ||||||
| builder.Services.AddAppServices(builder.Configuration); | builder.Services.AddAppServices(builder.Configuration); | ||||||
| builder.Services.AddAppRateLimiting(); | builder.Services.AddAppRateLimiting(); | ||||||
| builder.Services.AddAppAuthentication(); | builder.Services.AddAppAuthentication(); | ||||||
| @@ -39,6 +39,8 @@ builder.Services.AddTransient<IPageDataProvider, VersionPageData>(); | |||||||
|  |  | ||||||
| var app = builder.Build(); | var app = builder.Build(); | ||||||
|  |  | ||||||
|  | app.MapDefaultEndpoints(); | ||||||
|  |  | ||||||
| // Run database migrations | // Run database migrations | ||||||
| using (var scope = app.Services.CreateScope()) | using (var scope = app.Services.CreateScope()) | ||||||
| { | { | ||||||
| @@ -51,8 +53,6 @@ var tusDiskStore = app.Services.GetRequiredService<TusDiskStore>(); | |||||||
| // Configure application middleware pipeline | // Configure application middleware pipeline | ||||||
| app.ConfigureAppMiddleware(tusDiskStore, builder.Environment.ContentRootPath); | app.ConfigureAppMiddleware(tusDiskStore, builder.Environment.ContentRootPath); | ||||||
|  |  | ||||||
| app.MapGatewayProxy(); |  | ||||||
|  |  | ||||||
| app.MapPages(Path.Combine(app.Environment.WebRootPath, "dist", "index.html")); | app.MapPages(Path.Combine(app.Environment.WebRootPath, "dist", "index.html")); | ||||||
|  |  | ||||||
| // Configure gRPC | // Configure gRPC | ||||||
|   | |||||||
| @@ -3,6 +3,8 @@ using DysonNetwork.Drive.Storage; | |||||||
| using DysonNetwork.Shared.Stream; | using DysonNetwork.Shared.Stream; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using NATS.Client.Core; | using NATS.Client.Core; | ||||||
|  | using NATS.Client.JetStream.Models; | ||||||
|  | using NATS.Net; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Drive.Startup; | namespace DysonNetwork.Drive.Startup; | ||||||
|  |  | ||||||
| @@ -14,12 +16,23 @@ public class BroadcastEventHandler( | |||||||
| { | { | ||||||
|     protected override async Task ExecuteAsync(CancellationToken stoppingToken) |     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 |             try | ||||||
|             { |             { | ||||||
|                 var evt = JsonSerializer.Deserialize<AccountDeletedEvent>(msg.Data); |                 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); |                 logger.LogInformation("Account deleted: {AccountId}", evt.AccountId); | ||||||
|  |  | ||||||
| @@ -46,10 +59,13 @@ public class BroadcastEventHandler( | |||||||
|                     await transaction.RollbackAsync(cancellationToken: stoppingToken); |                     await transaction.RollbackAsync(cancellationToken: stoppingToken); | ||||||
|                     throw; |                     throw; | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|  |                 await msg.AckAsync(cancellationToken: stoppingToken); | ||||||
|             } |             } | ||||||
|             catch (Exception ex) |             catch (Exception ex) | ||||||
|             { |             { | ||||||
|                 logger.LogError(ex, "Error processing AccountDeleted"); |                 logger.LogError(ex, "Error processing AccountDeleted"); | ||||||
|  |                 await msg.NakAsync(cancellationToken: stoppingToken); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| using System.Text.Json; | using System.Text.Json; | ||||||
|  | using System.Text.Json.Serialization; | ||||||
| using System.Threading.RateLimiting; | using System.Threading.RateLimiting; | ||||||
| using DysonNetwork.Shared.Cache; | using DysonNetwork.Shared.Cache; | ||||||
| using Microsoft.AspNetCore.RateLimiting; | using Microsoft.AspNetCore.RateLimiting; | ||||||
| @@ -16,11 +17,6 @@ public static class ServiceCollectionExtensions | |||||||
|     public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration) |     public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration) | ||||||
|     { |     { | ||||||
|         services.AddDbContext<AppDatabase>(); // Assuming you'll have an AppDatabase |         services.AddDbContext<AppDatabase>(); // Assuming you'll have an AppDatabase | ||||||
|         services.AddSingleton<IConnectionMultiplexer>(_ => |  | ||||||
|         { |  | ||||||
|             var connection = configuration.GetConnectionString("FastRetrieve")!; |  | ||||||
|             return ConnectionMultiplexer.Connect(connection); |  | ||||||
|         }); |  | ||||||
|         services.AddSingleton<IClock>(SystemClock.Instance); |         services.AddSingleton<IClock>(SystemClock.Instance); | ||||||
|         services.AddHttpContextAccessor(); |         services.AddHttpContextAccessor(); | ||||||
|         services.AddSingleton<ICacheService, CacheServiceRedis>(); // Uncomment if you have CacheServiceRedis |         services.AddSingleton<ICacheService, CacheServiceRedis>(); // Uncomment if you have CacheServiceRedis | ||||||
| @@ -40,6 +36,7 @@ public static class ServiceCollectionExtensions | |||||||
|  |  | ||||||
|         services.AddControllers().AddJsonOptions(options => |         services.AddControllers().AddJsonOptions(options => | ||||||
|         { |         { | ||||||
|  |             options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals; | ||||||
|             options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; |             options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; | ||||||
|             options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower; |             options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -337,8 +337,14 @@ public class FileService( | |||||||
|             if (!pool.PolicyConfig.NoOptimization) |             if (!pool.PolicyConfig.NoOptimization) | ||||||
|                 switch (contentType.Split('/')[0]) |                 switch (contentType.Split('/')[0]) | ||||||
|                 { |                 { | ||||||
|                     case "image" when !AnimatedImageTypes.Contains(contentType) && |                     case "image": | ||||||
|                                       !AnimatedImageExtensions.Contains(fileExtension): |                         if (AnimatedImageTypes.Contains(contentType) || AnimatedImageExtensions.Contains(fileExtension)) | ||||||
|  |                         { | ||||||
|  |                             logger.LogInformation("Skip optimize file {FileId} due to it is animated...", fileId); | ||||||
|  |                             uploads.Add((originalFilePath, string.Empty, contentType, false)); | ||||||
|  |                             break; | ||||||
|  |                         } | ||||||
|  |  | ||||||
|                         newMimeType = "image/webp"; |                         newMimeType = "image/webp"; | ||||||
|                         using (var vipsImage = Image.NewFromFile(originalFilePath)) |                         using (var vipsImage = Image.NewFromFile(originalFilePath)) | ||||||
|                         { |                         { | ||||||
| @@ -672,8 +678,8 @@ public class FileService( | |||||||
|             foreach (var file in fileGroup) |             foreach (var file in fileGroup) | ||||||
|             { |             { | ||||||
|                 objectsToDelete.Add(file.StorageId ?? file.Id); |                 objectsToDelete.Add(file.StorageId ?? file.Id); | ||||||
|                 if(file.HasCompression) objectsToDelete.Add(file.StorageId ?? file.Id + ".compressed"); |                 if (file.HasCompression) objectsToDelete.Add(file.StorageId ?? file.Id + ".compressed"); | ||||||
|                 if(file.HasThumbnail) objectsToDelete.Add(file.StorageId ?? file.Id + ".thumbnail"); |                 if (file.HasThumbnail) objectsToDelete.Add(file.StorageId ?? file.Id + ".thumbnail"); | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             await client.RemoveObjectsAsync( |             await client.RemoveObjectsAsync( | ||||||
|   | |||||||
| @@ -10,10 +10,7 @@ | |||||||
|   }, |   }, | ||||||
|   "AllowedHosts": "*", |   "AllowedHosts": "*", | ||||||
|   "ConnectionStrings": { |   "ConnectionStrings": { | ||||||
|     "App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60", |     "App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60" | ||||||
|     "FastRetrieve": "localhost:6379", |  | ||||||
|     "Etcd": "etcd.orb.local:2379", |  | ||||||
|     "Stream": "nats.orb.local:4222" |  | ||||||
|   }, |   }, | ||||||
|   "Authentication": { |   "Authentication": { | ||||||
|     "Schemes": { |     "Schemes": { | ||||||
| @@ -131,8 +128,6 @@ | |||||||
|   ], |   ], | ||||||
|   "Service": { |   "Service": { | ||||||
|     "Name": "DysonNetwork.Drive", |     "Name": "DysonNetwork.Drive", | ||||||
|     "Url": "https://localhost:7092", |     "Url": "https://localhost:7092" | ||||||
|     "ClientCert": "../Certificates/client.crt", |  | ||||||
|     "ClientKey": "../Certificates/client.key" |  | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,78 +0,0 @@ | |||||||
| using System.Text; |  | ||||||
| using dotnet_etcd.interfaces; |  | ||||||
| using Microsoft.AspNetCore.Mvc; |  | ||||||
| using Yarp.ReverseProxy.Configuration; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Gateway.Controllers; |  | ||||||
|  |  | ||||||
| [ApiController] |  | ||||||
| [Route("/.well-known")] |  | ||||||
| public class WellKnownController( |  | ||||||
|     IConfiguration configuration, |  | ||||||
|     IProxyConfigProvider proxyConfigProvider, |  | ||||||
|     IEtcdClient etcdClient) |  | ||||||
|     : ControllerBase |  | ||||||
| { |  | ||||||
|     [HttpGet("domains")] |  | ||||||
|     public IActionResult GetDomainMappings() |  | ||||||
|     { |  | ||||||
|         var domainMappings = configuration.GetSection("DomainMappings").GetChildren() |  | ||||||
|             .ToDictionary(x => x.Key, x => x.Value); |  | ||||||
|         return Ok(domainMappings); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpGet("services")] |  | ||||||
|     public IActionResult GetServices() |  | ||||||
|     { |  | ||||||
|         var local = configuration.GetValue<bool>("LocalMode"); |  | ||||||
|         var response = etcdClient.GetRange("/services/"); |  | ||||||
|         var kvs = response.Kvs; |  | ||||||
|  |  | ||||||
|         var serviceMap = kvs.ToDictionary( |  | ||||||
|             kv => Encoding.UTF8.GetString(kv.Key.ToByteArray()).Replace("/services/", ""), |  | ||||||
|             kv => Encoding.UTF8.GetString(kv.Value.ToByteArray()) |  | ||||||
|         ); |  | ||||||
|  |  | ||||||
|         if (local) return Ok(serviceMap); |  | ||||||
|          |  | ||||||
|         var domainMappings = configuration.GetSection("DomainMappings").GetChildren() |  | ||||||
|             .ToDictionary(x => x.Key, x => x.Value); |  | ||||||
|         foreach (var (key, _) in serviceMap.ToList()) |  | ||||||
|         { |  | ||||||
|             if (!domainMappings.TryGetValue(key, out var domain)) continue; |  | ||||||
|             if (domain is not null) |  | ||||||
|                 serviceMap[key] = "https://" + domain; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         return Ok(serviceMap); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpGet("routes")] |  | ||||||
|     public IActionResult GetProxyRules() |  | ||||||
|     { |  | ||||||
|         var config = proxyConfigProvider.GetConfig(); |  | ||||||
|         var rules = config.Routes.Select(r => new |  | ||||||
|         { |  | ||||||
|             r.RouteId, |  | ||||||
|             r.ClusterId, |  | ||||||
|             Match = new |  | ||||||
|             { |  | ||||||
|                 r.Match.Path, |  | ||||||
|                 Hosts = r.Match.Hosts != null ? string.Join(", ", r.Match.Hosts) : null |  | ||||||
|             }, |  | ||||||
|             Transforms = r.Transforms?.Select(t => t.Select(kv => $"{kv.Key}: {kv.Value}").ToList()) |  | ||||||
|         }).ToList(); |  | ||||||
|  |  | ||||||
|         var clusters = config.Clusters.Select(c => new |  | ||||||
|         { |  | ||||||
|             c.ClusterId, |  | ||||||
|             Destinations = c.Destinations?.Select(d => new |  | ||||||
|             { |  | ||||||
|                 d.Key, |  | ||||||
|                 d.Value.Address |  | ||||||
|             }).ToList() |  | ||||||
|         }).ToList(); |  | ||||||
|  |  | ||||||
|         return Ok(new { Rules = rules, Clusters = clusters }); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,23 +0,0 @@ | |||||||
| FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base |  | ||||||
| USER $APP_UID |  | ||||||
| WORKDIR /app |  | ||||||
| EXPOSE 8080 |  | ||||||
| EXPOSE 8081 |  | ||||||
|  |  | ||||||
| FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build |  | ||||||
| ARG BUILD_CONFIGURATION=Release |  | ||||||
| WORKDIR /src |  | ||||||
| COPY ["DysonNetwork.Gateway/DysonNetwork.Gateway.csproj", "DysonNetwork.Gateway/"] |  | ||||||
| RUN dotnet restore "DysonNetwork.Gateway/DysonNetwork.Gateway.csproj" |  | ||||||
| COPY . . |  | ||||||
| WORKDIR "/src/DysonNetwork.Gateway" |  | ||||||
| RUN dotnet build "./DysonNetwork.Gateway.csproj" -c $BUILD_CONFIGURATION -o /app/build |  | ||||||
|  |  | ||||||
| FROM build AS publish |  | ||||||
| ARG BUILD_CONFIGURATION=Release |  | ||||||
| RUN dotnet publish "./DysonNetwork.Gateway.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false |  | ||||||
|  |  | ||||||
| FROM base AS final |  | ||||||
| WORKDIR /app |  | ||||||
| COPY --from=publish /app/publish . |  | ||||||
| ENTRYPOINT ["dotnet", "DysonNetwork.Gateway.dll"] |  | ||||||
| @@ -1,23 +0,0 @@ | |||||||
| <Project Sdk="Microsoft.NET.Sdk.Web"> |  | ||||||
|  |  | ||||||
|   <PropertyGroup> |  | ||||||
|     <TargetFramework>net9.0</TargetFramework> |  | ||||||
|     <Nullable>enable</Nullable> |  | ||||||
|     <ImplicitUsings>enable</ImplicitUsings> |  | ||||||
|   </PropertyGroup> |  | ||||||
|  |  | ||||||
|   <ItemGroup> |  | ||||||
|     <PackageReference Include="dotnet-etcd" Version="8.0.1" /> |  | ||||||
|     <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" /> |  | ||||||
|     <PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115"> |  | ||||||
|       <PrivateAssets>all</PrivateAssets> |  | ||||||
|       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> |  | ||||||
|     </PackageReference> |  | ||||||
|     <PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" /> |  | ||||||
|   </ItemGroup> |  | ||||||
|  |  | ||||||
|   <ItemGroup> |  | ||||||
|     <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> |  | ||||||
|   </ItemGroup> |  | ||||||
|  |  | ||||||
| </Project> |  | ||||||
| @@ -1,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 Name { get; set; } = string.Empty; | ||||||
|     [MaxLength(256)] public string Nick { get; set; } = string.Empty; |     [MaxLength(256)] public string Nick { get; set; } = string.Empty; | ||||||
|     [MaxLength(32)] public string Language { get; set; } = string.Empty; |     [MaxLength(32)] public string Language { get; set; } = string.Empty; | ||||||
|  |     [MaxLength(32)] public string Region { get; set; } = string.Empty; | ||||||
|     public Instant? ActivatedAt { get; set; } |     public Instant? ActivatedAt { get; set; } | ||||||
|     public bool IsSuperuser { get; set; } = false; |     public bool IsSuperuser { get; set; } = false; | ||||||
|  |  | ||||||
| @@ -46,6 +47,7 @@ public class Account : ModelBase | |||||||
|             Name = Name, |             Name = Name, | ||||||
|             Nick = Nick, |             Nick = Nick, | ||||||
|             Language = Language, |             Language = Language, | ||||||
|  |             Region = Region, | ||||||
|             ActivatedAt = ActivatedAt?.ToTimestamp(), |             ActivatedAt = ActivatedAt?.ToTimestamp(), | ||||||
|             IsSuperuser = IsSuperuser, |             IsSuperuser = IsSuperuser, | ||||||
|             Profile = Profile.ToProtoValue(), |             Profile = Profile.ToProtoValue(), | ||||||
| @@ -75,6 +77,7 @@ public class Account : ModelBase | |||||||
|             Name = proto.Name, |             Name = proto.Name, | ||||||
|             Nick = proto.Nick, |             Nick = proto.Nick, | ||||||
|             Language = proto.Language, |             Language = proto.Language, | ||||||
|  |             Region = proto.Region, | ||||||
|             ActivatedAt = proto.ActivatedAt?.ToInstant(), |             ActivatedAt = proto.ActivatedAt?.ToInstant(), | ||||||
|             IsSuperuser = proto.IsSuperuser, |             IsSuperuser = proto.IsSuperuser, | ||||||
|             PerkSubscription = proto.PerkSubscription is not null |             PerkSubscription = proto.PerkSubscription is not null | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ using DysonNetwork.Pass.Auth; | |||||||
| using DysonNetwork.Pass.Credit; | using DysonNetwork.Pass.Credit; | ||||||
| using DysonNetwork.Pass.Wallet; | using DysonNetwork.Pass.Wallet; | ||||||
| using DysonNetwork.Shared.Error; | using DysonNetwork.Shared.Error; | ||||||
|  | using DysonNetwork.Shared.GeoIp; | ||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using NodaTime; | using NodaTime; | ||||||
| @@ -17,7 +18,8 @@ public class AccountController( | |||||||
|     AccountService accounts, |     AccountService accounts, | ||||||
|     SubscriptionService subscriptions, |     SubscriptionService subscriptions, | ||||||
|     AccountEventService events, |     AccountEventService events, | ||||||
|     SocialCreditService socialCreditService |     SocialCreditService socialCreditService, | ||||||
|  |     GeoIpService geo | ||||||
| ) : ControllerBase | ) : ControllerBase | ||||||
| { | { | ||||||
|     [HttpGet("{name}")] |     [HttpGet("{name}")] | ||||||
| @@ -48,7 +50,9 @@ public class AccountController( | |||||||
|             .Include(e => e.Badges) |             .Include(e => e.Badges) | ||||||
|             .Where(a => a.Name == name) |             .Where(a => a.Name == name) | ||||||
|             .FirstOrDefaultAsync(); |             .FirstOrDefaultAsync(); | ||||||
|         return account is null ? NotFound(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")] |     [HttpGet("{name}/credits")] | ||||||
| @@ -93,7 +97,7 @@ public class AccountController( | |||||||
|         [MaxLength(128)] |         [MaxLength(128)] | ||||||
|         public string Password { get; set; } = string.Empty; |         public string Password { get; set; } = string.Empty; | ||||||
|  |  | ||||||
|         [MaxLength(128)] public string Language { get; set; } = "en-us"; |         [MaxLength(32)] public string Language { get; set; } = "en-us"; | ||||||
|  |  | ||||||
|         [Required] public string CaptchaToken { get; set; } = string.Empty; |         [Required] public string CaptchaToken { get; set; } = string.Empty; | ||||||
|     } |     } | ||||||
| @@ -109,6 +113,10 @@ public class AccountController( | |||||||
|                 [nameof(request.CaptchaToken)] = ["Invalid captcha token."] |                 [nameof(request.CaptchaToken)] = ["Invalid captcha token."] | ||||||
|             }, traceId: HttpContext.TraceIdentifier)); |             }, traceId: HttpContext.TraceIdentifier)); | ||||||
|  |  | ||||||
|  |         var ip = HttpContext.Connection.RemoteIpAddress?.ToString(); | ||||||
|  |         if (ip is null) return BadRequest(ApiError.NotFound(request.Name, traceId: HttpContext.TraceIdentifier)); | ||||||
|  |         var region = geo.GetFromIp(ip)?.Country.IsoCode ?? "us"; | ||||||
|  |  | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
|             var account = await accounts.CreateAccount( |             var account = await accounts.CreateAccount( | ||||||
| @@ -116,7 +124,8 @@ public class AccountController( | |||||||
|                 request.Nick, |                 request.Nick, | ||||||
|                 request.Email, |                 request.Email, | ||||||
|                 request.Password, |                 request.Password, | ||||||
|                 request.Language |                 request.Language, | ||||||
|  |                 region | ||||||
|             ); |             ); | ||||||
|             return Ok(account); |             return Ok(account); | ||||||
|         } |         } | ||||||
| @@ -182,7 +191,9 @@ public class AccountController( | |||||||
|         public StatusAttitude Attitude { get; set; } |         public StatusAttitude Attitude { get; set; } | ||||||
|         public bool IsInvisible { get; set; } |         public bool IsInvisible { get; set; } | ||||||
|         public bool IsNotDisturb { get; set; } |         public bool IsNotDisturb { get; set; } | ||||||
|  |         public bool IsAutomated { get; set; } = false; | ||||||
|         [MaxLength(1024)] public string? Label { get; set; } |         [MaxLength(1024)] public string? Label { get; set; } | ||||||
|  |         [MaxLength(4096)] public string? AppIdentifier { get; set; } | ||||||
|         public Instant? ClearedAt { get; set; } |         public Instant? ClearedAt { get; set; } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -52,6 +52,7 @@ public class AccountCurrentController( | |||||||
|     { |     { | ||||||
|         [MaxLength(256)] public string? Nick { get; set; } |         [MaxLength(256)] public string? Nick { get; set; } | ||||||
|         [MaxLength(32)] public string? Language { get; set; } |         [MaxLength(32)] public string? Language { get; set; } | ||||||
|  |         [MaxLength(32)] public string? Region { get; set; } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     [HttpPatch] |     [HttpPatch] | ||||||
| @@ -63,6 +64,7 @@ public class AccountCurrentController( | |||||||
|  |  | ||||||
|         if (request.Nick is not null) account.Nick = request.Nick; |         if (request.Nick is not null) account.Nick = request.Nick; | ||||||
|         if (request.Language is not null) account.Language = request.Language; |         if (request.Language is not null) account.Language = request.Language; | ||||||
|  |         if (request.Region is not null) account.Region = request.Region; | ||||||
|  |  | ||||||
|         await db.SaveChangesAsync(); |         await db.SaveChangesAsync(); | ||||||
|         await accounts.PurgeAccountCache(currentUser); |         await accounts.PurgeAccountCache(currentUser); | ||||||
| @@ -195,6 +197,8 @@ public class AccountCurrentController( | |||||||
|     public async Task<ActionResult<Status>> UpdateStatus([FromBody] AccountController.StatusRequest request) |     public async Task<ActionResult<Status>> UpdateStatus([FromBody] AccountController.StatusRequest request) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |         if (request is { IsAutomated: true, AppIdentifier: not null }) | ||||||
|  |             return BadRequest("Automated status cannot be updated."); | ||||||
|  |  | ||||||
|         var now = SystemClock.Instance.GetCurrentInstant(); |         var now = SystemClock.Instance.GetCurrentInstant(); | ||||||
|         var status = await db.AccountStatuses |         var status = await db.AccountStatuses | ||||||
| @@ -203,11 +207,15 @@ public class AccountCurrentController( | |||||||
|             .OrderByDescending(e => e.CreatedAt) |             .OrderByDescending(e => e.CreatedAt) | ||||||
|             .FirstOrDefaultAsync(); |             .FirstOrDefaultAsync(); | ||||||
|         if (status is null) return NotFound(ApiError.NotFound("status", traceId: HttpContext.TraceIdentifier)); |         if (status is null) return NotFound(ApiError.NotFound("status", traceId: HttpContext.TraceIdentifier)); | ||||||
|  |         if (status.IsAutomated && request.AppIdentifier is null) | ||||||
|  |             return BadRequest("Automated status cannot be updated."); | ||||||
|  |  | ||||||
|         status.Attitude = request.Attitude; |         status.Attitude = request.Attitude; | ||||||
|         status.IsInvisible = request.IsInvisible; |         status.IsInvisible = request.IsInvisible; | ||||||
|         status.IsNotDisturb = request.IsNotDisturb; |         status.IsNotDisturb = request.IsNotDisturb; | ||||||
|  |         status.IsAutomated = request.IsAutomated; | ||||||
|         status.Label = request.Label; |         status.Label = request.Label; | ||||||
|  |         status.AppIdentifier = request.AppIdentifier; | ||||||
|         status.ClearedAt = request.ClearedAt; |         status.ClearedAt = request.ClearedAt; | ||||||
|  |  | ||||||
|         db.Update(status); |         db.Update(status); | ||||||
| @@ -223,13 +231,44 @@ public class AccountCurrentController( | |||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|  |         if (request is { IsAutomated: true, AppIdentifier: not null }) | ||||||
|  |         { | ||||||
|  |             var now = SystemClock.Instance.GetCurrentInstant(); | ||||||
|  |             var existingStatus = await db.AccountStatuses | ||||||
|  |                 .Where(s => s.AccountId == currentUser.Id) | ||||||
|  |                 .Where(s => s.ClearedAt == null || s.ClearedAt > now) | ||||||
|  |                 .OrderByDescending(s => s.CreatedAt) | ||||||
|  |                 .FirstOrDefaultAsync(); | ||||||
|  |             if (existingStatus is not null && existingStatus.IsAutomated) | ||||||
|  |                 if (existingStatus.IsAutomated && request.AppIdentifier == existingStatus.AppIdentifier) | ||||||
|  |                 { | ||||||
|  |                     existingStatus.Attitude = request.Attitude; | ||||||
|  |                     existingStatus.IsInvisible = request.IsInvisible; | ||||||
|  |                     existingStatus.IsNotDisturb = request.IsNotDisturb; | ||||||
|  |                     existingStatus.Label = request.Label; | ||||||
|  |                     db.Update(existingStatus); | ||||||
|  |                     await db.SaveChangesAsync(); | ||||||
|  |                     return Ok(existingStatus); | ||||||
|  |                 } | ||||||
|  |                 else | ||||||
|  |                 { | ||||||
|  |                     existingStatus.ClearedAt = now; | ||||||
|  |                     db.Update(existingStatus); | ||||||
|  |                     await db.SaveChangesAsync(); | ||||||
|  |                 } | ||||||
|  |             else if (existingStatus is not null) | ||||||
|  |                 return Ok(existingStatus); // Do not override manually set status with automated ones | ||||||
|  |         } | ||||||
|  |  | ||||||
|         var status = new Status |         var status = new Status | ||||||
|         { |         { | ||||||
|             AccountId = currentUser.Id, |             AccountId = currentUser.Id, | ||||||
|             Attitude = request.Attitude, |             Attitude = request.Attitude, | ||||||
|             IsInvisible = request.IsInvisible, |             IsInvisible = request.IsInvisible, | ||||||
|             IsNotDisturb = request.IsNotDisturb, |             IsNotDisturb = request.IsNotDisturb, | ||||||
|  |             IsAutomated = request.IsAutomated, | ||||||
|             Label = request.Label, |             Label = request.Label, | ||||||
|  |             AppIdentifier = request.AppIdentifier, | ||||||
|             ClearedAt = request.ClearedAt |             ClearedAt = request.ClearedAt | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
| @@ -237,15 +276,21 @@ public class AccountCurrentController( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     [HttpDelete("statuses")] |     [HttpDelete("statuses")] | ||||||
|     public async Task<ActionResult> DeleteStatus() |     public async Task<ActionResult> DeleteStatus([FromQuery] string? app) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         var now = SystemClock.Instance.GetCurrentInstant(); |         var now = SystemClock.Instance.GetCurrentInstant(); | ||||||
|         var status = await db.AccountStatuses |         var queryable = db.AccountStatuses | ||||||
|             .Where(s => s.AccountId == currentUser.Id) |             .Where(s => s.AccountId == currentUser.Id) | ||||||
|             .Where(s => s.ClearedAt == null || s.ClearedAt > now) |             .Where(s => s.ClearedAt == null || s.ClearedAt > now) | ||||||
|             .OrderByDescending(s => s.CreatedAt) |             .OrderByDescending(s => s.CreatedAt) | ||||||
|  |             .AsQueryable(); | ||||||
|  |  | ||||||
|  |         if (string.IsNullOrWhiteSpace(app)) | ||||||
|  |             queryable = queryable.Where(s => s.IsAutomated && s.AppIdentifier == app); | ||||||
|  |  | ||||||
|  |         var status = await queryable | ||||||
|             .FirstOrDefaultAsync(); |             .FirstOrDefaultAsync(); | ||||||
|         if (status is null) return NotFound(); |         if (status is null) return NotFound(); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -14,7 +14,7 @@ public class AccountEventService( | |||||||
|     Wallet.PaymentService payment, |     Wallet.PaymentService payment, | ||||||
|     ICacheService cache, |     ICacheService cache, | ||||||
|     IStringLocalizer<Localization.AccountEventResource> localizer, |     IStringLocalizer<Localization.AccountEventResource> localizer, | ||||||
|     PusherService.PusherServiceClient pusher, |     RingService.RingServiceClient pusher, | ||||||
|     SubscriptionService subscriptions, |     SubscriptionService subscriptions, | ||||||
|     Pass.Leveling.ExperienceService experienceService |     Pass.Leveling.ExperienceService experienceService | ||||||
| ) | ) | ||||||
|   | |||||||
| @@ -13,6 +13,8 @@ using EFCore.BulkExtensions; | |||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.Extensions.Localization; | using Microsoft.Extensions.Localization; | ||||||
| using NATS.Client.Core; | using NATS.Client.Core; | ||||||
|  | using NATS.Client.JetStream; | ||||||
|  | using NATS.Net; | ||||||
| using NodaTime; | using NodaTime; | ||||||
| using OtpNet; | using OtpNet; | ||||||
| using AuthService = DysonNetwork.Pass.Auth.AuthService; | using AuthService = DysonNetwork.Pass.Auth.AuthService; | ||||||
| @@ -26,8 +28,9 @@ public class AccountService( | |||||||
|     FileReferenceService.FileReferenceServiceClient fileRefs, |     FileReferenceService.FileReferenceServiceClient fileRefs, | ||||||
|     AccountUsernameService uname, |     AccountUsernameService uname, | ||||||
|     EmailService mailer, |     EmailService mailer, | ||||||
|     PusherService.PusherServiceClient pusher, |     RingService.RingServiceClient pusher, | ||||||
|     IStringLocalizer<NotificationResource> localizer, |     IStringLocalizer<NotificationResource> localizer, | ||||||
|  |     IStringLocalizer<EmailResource> emailLocalizer, | ||||||
|     ICacheService cache, |     ICacheService cache, | ||||||
|     ILogger<AccountService> logger, |     ILogger<AccountService> logger, | ||||||
|     INatsConnection nats |     INatsConnection nats | ||||||
| @@ -87,6 +90,7 @@ public class AccountService( | |||||||
|         string email, |         string email, | ||||||
|         string? password, |         string? password, | ||||||
|         string language = "en-US", |         string language = "en-US", | ||||||
|  |         string region = "en", | ||||||
|         bool isEmailVerified = false, |         bool isEmailVerified = false, | ||||||
|         bool isActivated = false |         bool isActivated = false | ||||||
|     ) |     ) | ||||||
| @@ -106,6 +110,7 @@ public class AccountService( | |||||||
|             Name = name, |             Name = name, | ||||||
|             Nick = nick, |             Nick = nick, | ||||||
|             Language = language, |             Language = language, | ||||||
|  |             Region = region, | ||||||
|             Contacts = new List<AccountContact> |             Contacts = new List<AccountContact> | ||||||
|             { |             { | ||||||
|                 new() |                 new() | ||||||
| @@ -180,12 +185,14 @@ public class AccountService( | |||||||
|             userInfo.Email, |             userInfo.Email, | ||||||
|             null, |             null, | ||||||
|             "en-US", |             "en-US", | ||||||
|  |             "en", | ||||||
|             userInfo.EmailVerified, |             userInfo.EmailVerified, | ||||||
|             userInfo.EmailVerified |             userInfo.EmailVerified | ||||||
|         ); |         ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<Account> CreateBotAccount(Account account, Guid automatedId, string? pictureId, string? backgroundId) |     public async Task<Account> CreateBotAccount(Account account, Guid automatedId, string? pictureId, | ||||||
|  |         string? backgroundId) | ||||||
|     { |     { | ||||||
|         var dupeAutomateCount = await db.Accounts.Where(a => a.AutomatedId == automatedId).CountAsync(); |         var dupeAutomateCount = await db.Accounts.Where(a => a.AutomatedId == automatedId).CountAsync(); | ||||||
|         if (dupeAutomateCount > 0) |         if (dupeAutomateCount > 0) | ||||||
| @@ -432,12 +439,14 @@ public class AccountService( | |||||||
|                     .Where(c => c.Type == AccountContactType.Email) |                     .Where(c => c.Type == AccountContactType.Email) | ||||||
|                     .Where(c => c.VerifiedAt != null) |                     .Where(c => c.VerifiedAt != null) | ||||||
|                     .Where(c => c.IsPrimary) |                     .Where(c => c.IsPrimary) | ||||||
|  |                     .Where(c => c.AccountId == account.Id) | ||||||
|                     .Include(c => c.Account) |                     .Include(c => c.Account) | ||||||
|                     .FirstOrDefaultAsync(); |                     .FirstOrDefaultAsync(); | ||||||
|                 if (contact is null) |                 if (contact is null) | ||||||
|                 { |                 { | ||||||
|                     logger.LogWarning( |                     logger.LogWarning( | ||||||
|                         "Unable to send factor code to #{FactorId} with, 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; |                     return; | ||||||
|                 } |                 } | ||||||
| @@ -446,7 +455,7 @@ public class AccountService( | |||||||
|                     .SendTemplatedEmailAsync<Pages.Emails.VerificationEmail, VerificationEmailModel>( |                     .SendTemplatedEmailAsync<Pages.Emails.VerificationEmail, VerificationEmailModel>( | ||||||
|                         account.Nick, |                         account.Nick, | ||||||
|                         contact.Content, |                         contact.Content, | ||||||
|                         localizer["VerificationEmail"], |                         emailLocalizer["VerificationEmail"], | ||||||
|                         new VerificationEmailModel |                         new VerificationEmailModel | ||||||
|                         { |                         { | ||||||
|                             Name = account.Name, |                             Name = account.Name, | ||||||
| @@ -734,10 +743,14 @@ public class AccountService( | |||||||
|         db.Accounts.Remove(account); |         db.Accounts.Remove(account); | ||||||
|         await db.SaveChangesAsync(); |         await db.SaveChangesAsync(); | ||||||
|  |  | ||||||
|         await nats.PublishAsync(AccountDeletedEvent.Type, JsonSerializer.SerializeToUtf8Bytes(new AccountDeletedEvent |         var js = nats.CreateJetStreamContext(); | ||||||
|         { |         await js.PublishAsync( | ||||||
|             AccountId = account.Id, |             AccountDeletedEvent.Type, | ||||||
|             DeletedAt = SystemClock.Instance.GetCurrentInstant() |             GrpcTypeHelper.ConvertObjectToByteString(new AccountDeletedEvent | ||||||
|         })); |             { | ||||||
|  |                 AccountId = account.Id, | ||||||
|  |                 DeletedAt = SystemClock.Instance.GetCurrentInstant() | ||||||
|  |             }).ToByteArray() | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -1,6 +1,8 @@ | |||||||
| using System.ComponentModel.DataAnnotations; | using System.ComponentModel.DataAnnotations; | ||||||
| using System.ComponentModel.DataAnnotations.Schema; | using System.ComponentModel.DataAnnotations.Schema; | ||||||
|  | using System.Text.Json.Serialization; | ||||||
| using DysonNetwork.Shared.Data; | using DysonNetwork.Shared.Data; | ||||||
|  | using DysonNetwork.Shared.GeoIp; | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
| using NodaTime.Serialization.Protobuf; | using NodaTime.Serialization.Protobuf; | ||||||
| using Point = NetTopologySuite.Geometries.Point; | using Point = NetTopologySuite.Geometries.Point; | ||||||
| @@ -14,7 +16,7 @@ public class ActionLog : ModelBase | |||||||
|     [Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new(); |     [Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new(); | ||||||
|     [MaxLength(512)] public string? UserAgent { get; set; } |     [MaxLength(512)] public string? UserAgent { get; set; } | ||||||
|     [MaxLength(128)] public string? IpAddress { get; set; } |     [MaxLength(128)] public string? IpAddress { get; set; } | ||||||
|     public Point? Location { get; set; } |     [Column(TypeName = "jsonb")] public GeoPoint? Location { get; set; } | ||||||
|  |  | ||||||
|     public Guid AccountId { get; set; } |     public Guid AccountId { get; set; } | ||||||
|     public Account Account { get; set; } = null!; |     public Account Account { get; set; } = null!; | ||||||
|   | |||||||
| @@ -23,6 +23,12 @@ public class Status : ModelBase | |||||||
|     public bool IsNotDisturb { get; set; } |     public bool IsNotDisturb { get; set; } | ||||||
|     [MaxLength(1024)] public string? Label { get; set; } |     [MaxLength(1024)] public string? Label { get; set; } | ||||||
|     public Instant? ClearedAt { get; set; } |     public Instant? ClearedAt { get; set; } | ||||||
|  |     [MaxLength(4096)] public string? AppIdentifier { get; set; } | ||||||
|  |      | ||||||
|  |     /// <summary> | ||||||
|  |     /// Indicates this status is created based on running process or rich presence | ||||||
|  |     /// </summary> | ||||||
|  |     public bool IsAutomated { get; set; } | ||||||
|  |  | ||||||
|     public Guid AccountId { get; set; } |     public Guid AccountId { get; set; } | ||||||
|     public Account Account { get; set; } = null!; |     public Account Account { get; set; } = null!; | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ using System.Text.Json; | |||||||
| using DysonNetwork.Pass.Email; | using DysonNetwork.Pass.Email; | ||||||
| using DysonNetwork.Pass.Pages.Emails; | using DysonNetwork.Pass.Pages.Emails; | ||||||
| using DysonNetwork.Pass.Permission; | using DysonNetwork.Pass.Permission; | ||||||
|  | using DysonNetwork.Shared.Cache; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.Extensions.Localization; | using Microsoft.Extensions.Localization; | ||||||
| using NodaTime; | using NodaTime; | ||||||
| @@ -15,7 +16,8 @@ public class MagicSpellService( | |||||||
|     IConfiguration configuration, |     IConfiguration configuration, | ||||||
|     ILogger<MagicSpellService> logger, |     ILogger<MagicSpellService> logger, | ||||||
|     IStringLocalizer<EmailResource> localizer, |     IStringLocalizer<EmailResource> localizer, | ||||||
|     EmailService email |     EmailService email, | ||||||
|  |     ICacheService cache | ||||||
| ) | ) | ||||||
| { | { | ||||||
|     public async Task<MagicSpell> CreateMagicSpell( |     public async Task<MagicSpell> CreateMagicSpell( | ||||||
| @@ -35,11 +37,8 @@ public class MagicSpellService( | |||||||
|                 .Where(s => s.Type == type) |                 .Where(s => s.Type == type) | ||||||
|                 .Where(s => s.ExpiresAt == null || s.ExpiresAt > now) |                 .Where(s => s.ExpiresAt == null || s.ExpiresAt > now) | ||||||
|                 .FirstOrDefaultAsync(); |                 .FirstOrDefaultAsync(); | ||||||
|  |             if (existingSpell is not null) | ||||||
|             if (existingSpell != null) |                 return existingSpell; | ||||||
|             { |  | ||||||
|                 throw new InvalidOperationException($"Account already has an active magic spell of type {type}"); |  | ||||||
|             } |  | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         var spellWord = _GenerateRandomString(128); |         var spellWord = _GenerateRandomString(128); | ||||||
| @@ -59,8 +58,18 @@ public class MagicSpellService( | |||||||
|         return spell; |         return spell; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     private const string SpellNotifyCacheKeyPrefix = "spells:notify:"; | ||||||
|  |  | ||||||
|     public async Task NotifyMagicSpell(MagicSpell spell, bool bypassVerify = false) |     public async Task NotifyMagicSpell(MagicSpell spell, bool bypassVerify = false) | ||||||
|     { |     { | ||||||
|  |         var cacheKey = SpellNotifyCacheKeyPrefix + spell.Id; | ||||||
|  |         var (found, _) = await cache.GetAsyncWithStatus<bool?>(cacheKey); | ||||||
|  |         if (found) | ||||||
|  |         { | ||||||
|  |             logger.LogInformation("Skip sending magic spell {SpellId} due to already sent.", spell.Id); | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|         var contact = await db.AccountContacts |         var contact = await db.AccountContacts | ||||||
|             .Where(c => c.Account.Id == spell.AccountId) |             .Where(c => c.Account.Id == spell.AccountId) | ||||||
|             .Where(c => c.Type == AccountContactType.Email) |             .Where(c => c.Type == AccountContactType.Email) | ||||||
| @@ -112,7 +121,7 @@ public class MagicSpellService( | |||||||
|                     await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>( |                     await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>( | ||||||
|                         contact.Account.Nick, |                         contact.Account.Nick, | ||||||
|                         contact.Content, |                         contact.Content, | ||||||
|                         localizer["EmailAccountDeletionTitle"], |                         localizer["EmailPasswordResetTitle"], | ||||||
|                         new PasswordResetEmailModel |                         new PasswordResetEmailModel | ||||||
|                         { |                         { | ||||||
|                             Name = contact.Account.Name, |                             Name = contact.Account.Name, | ||||||
| @@ -138,6 +147,8 @@ public class MagicSpellService( | |||||||
|                 default: |                 default: | ||||||
|                     throw new ArgumentOutOfRangeException(); |                     throw new ArgumentOutOfRangeException(); | ||||||
|             } |             } | ||||||
|  |  | ||||||
|  |             await cache.SetAsync(cacheKey, true, TimeSpan.FromMinutes(5)); | ||||||
|         } |         } | ||||||
|         catch (Exception err) |         catch (Exception err) | ||||||
|         { |         { | ||||||
|   | |||||||
							
								
								
									
										53
									
								
								DysonNetwork.Pass/Account/NotableDay.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								DysonNetwork.Pass/Account/NotableDay.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | |||||||
|  | using Nager.Holiday; | ||||||
|  | using NodaTime; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Pass.Account; | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Reference from Nager.Holiday | ||||||
|  | /// </summary> | ||||||
|  | public enum NotableHolidayType | ||||||
|  | { | ||||||
|  |     /// <summary>Public holiday</summary> | ||||||
|  |     Public, | ||||||
|  |     /// <summary>Bank holiday, banks and offices are closed</summary> | ||||||
|  |     Bank, | ||||||
|  |     /// <summary>School holiday, schools are closed</summary> | ||||||
|  |     School, | ||||||
|  |     /// <summary>Authorities are closed</summary> | ||||||
|  |     Authorities, | ||||||
|  |     /// <summary>Majority of people take a day off</summary> | ||||||
|  |     Optional, | ||||||
|  |     /// <summary>Optional festivity, no paid day off</summary> | ||||||
|  |     Observance, | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | public class NotableDay | ||||||
|  | { | ||||||
|  |     public Instant Date { get; set; } | ||||||
|  |     public string? LocalName { get; set; } | ||||||
|  |     public string? GlobalName { get; set; } | ||||||
|  |     public string? CountryCode { get; set; } | ||||||
|  |     public NotableHolidayType[] Holidays { get; set; } = []; | ||||||
|  |  | ||||||
|  |     public static NotableDay FromNagerHoliday(PublicHoliday holiday) | ||||||
|  |     { | ||||||
|  |         return new NotableDay() | ||||||
|  |         { | ||||||
|  |             Date = Instant.FromDateTimeUtc(holiday.Date.ToUniversalTime()), | ||||||
|  |             LocalName = holiday.LocalName, | ||||||
|  |             GlobalName = holiday.Name, | ||||||
|  |             CountryCode = holiday.CountryCode, | ||||||
|  |             Holidays = holiday.Types?.Select(x => x switch | ||||||
|  |             { | ||||||
|  |                 PublicHolidayType.Public => NotableHolidayType.Public, | ||||||
|  |                 PublicHolidayType.Bank => NotableHolidayType.Bank, | ||||||
|  |                 PublicHolidayType.School => NotableHolidayType.School, | ||||||
|  |                 PublicHolidayType.Authorities => NotableHolidayType.Authorities, | ||||||
|  |                 PublicHolidayType.Optional => NotableHolidayType.Optional, | ||||||
|  |                 _ => NotableHolidayType.Observance | ||||||
|  |             }).ToArray() ?? [], | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										79
									
								
								DysonNetwork.Pass/Account/NotableDaysController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								DysonNetwork.Pass/Account/NotableDaysController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,79 @@ | |||||||
|  | using Microsoft.AspNetCore.Authorization; | ||||||
|  | using Microsoft.AspNetCore.Mvc; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Pass.Account; | ||||||
|  |  | ||||||
|  | [ApiController] | ||||||
|  | [Route("/api/notable")] | ||||||
|  | public class NotableDaysController(NotableDaysService days) : ControllerBase | ||||||
|  | { | ||||||
|  |     [HttpGet("{regionCode}/{year:int}")] | ||||||
|  |     public async Task<ActionResult<List<NotableDay>>> GetRegionDays(string regionCode, int year) | ||||||
|  |     { | ||||||
|  |         var result = await days.GetNotableDays(year, regionCode); | ||||||
|  |         return Ok(result); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpGet("{regionCode}")] | ||||||
|  |     public async Task<ActionResult<List<NotableDay>>> GetRegionDaysCurrentYear(string regionCode) | ||||||
|  |     { | ||||||
|  |         var currentYear = DateTime.Now.Year; | ||||||
|  |         var result = await days.GetNotableDays(currentYear, regionCode); | ||||||
|  |         return Ok(result); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpGet("me/{year:int}")] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<ActionResult<List<NotableDay>>> GetAccountNotableDays(int year) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var region = currentUser.Region; | ||||||
|  |         if (string.IsNullOrWhiteSpace(region)) region = "us"; | ||||||
|  |  | ||||||
|  |         var result = await days.GetNotableDays(year, region); | ||||||
|  |         return Ok(result); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpGet("me")] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<ActionResult<List<NotableDay>>> GetAccountNotableDaysCurrentYear() | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var currentYear = DateTime.Now.Year; | ||||||
|  |         var region = currentUser.Region; | ||||||
|  |         if (string.IsNullOrWhiteSpace(region)) region = "us"; | ||||||
|  |  | ||||||
|  |         var result = await days.GetNotableDays(currentYear, region); | ||||||
|  |         return Ok(result); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpGet("{regionCode}/next")] | ||||||
|  |     public async Task<ActionResult<NotableDay?>> GetNextHoliday(string regionCode) | ||||||
|  |     { | ||||||
|  |         var result = await days.GetNextHoliday(regionCode); | ||||||
|  |         if (result == null) | ||||||
|  |         { | ||||||
|  |             return NotFound("No upcoming holidays found"); | ||||||
|  |         } | ||||||
|  |         return Ok(result); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpGet("me/next")] | ||||||
|  |     [Authorize] | ||||||
|  |     public async Task<ActionResult<NotableDay?>> GetAccountNextHoliday() | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|  |         var region = currentUser.Region; | ||||||
|  |         if (string.IsNullOrWhiteSpace(region)) region = "us"; | ||||||
|  |  | ||||||
|  |         var result = await days.GetNextHoliday(region); | ||||||
|  |         if (result == null) | ||||||
|  |         { | ||||||
|  |             return NotFound("No upcoming holidays found"); | ||||||
|  |         } | ||||||
|  |         return Ok(result); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										55
									
								
								DysonNetwork.Pass/Account/NotableDaysService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								DysonNetwork.Pass/Account/NotableDaysService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | |||||||
|  | using DysonNetwork.Shared.Cache; | ||||||
|  | using Nager.Holiday; | ||||||
|  | using NodaTime; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Pass.Account; | ||||||
|  |  | ||||||
|  | public class NotableDaysService(ICacheService cache) | ||||||
|  | { | ||||||
|  |     private const string NotableDaysCacheKeyPrefix = "notable:"; | ||||||
|  |  | ||||||
|  |     public async Task<List<NotableDay>> GetNotableDays(int? year, string regionCode) | ||||||
|  |     { | ||||||
|  |         year ??= DateTime.UtcNow.Year; | ||||||
|  |  | ||||||
|  |         // Generate cache key using year and region code | ||||||
|  |         var cacheKey = $"{NotableDaysCacheKeyPrefix}:{year}:{regionCode}"; | ||||||
|  |  | ||||||
|  |         // Try to get from cache first | ||||||
|  |         var (found, cachedDays) = await cache.GetAsyncWithStatus<List<NotableDay>>(cacheKey); | ||||||
|  |         if (found && cachedDays != null) | ||||||
|  |         { | ||||||
|  |             return cachedDays; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // If not in cache, fetch from API | ||||||
|  |         using var holidayClient = new HolidayClient(); | ||||||
|  |         var holidays = await holidayClient.GetHolidaysAsync(year.Value, regionCode); | ||||||
|  |         var days = holidays?.Select(NotableDay.FromNagerHoliday).ToList() ?? []; | ||||||
|  |  | ||||||
|  |         // Cache the result for 1 day (holiday data doesn't change frequently) | ||||||
|  |         await cache.SetAsync(cacheKey, days, TimeSpan.FromDays(1)); | ||||||
|  |  | ||||||
|  |         return days; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<NotableDay?> GetNextHoliday(string regionCode) | ||||||
|  |     { | ||||||
|  |         var currentDate = SystemClock.Instance.GetCurrentInstant(); | ||||||
|  |         var currentYear = currentDate.InUtc().Year; | ||||||
|  |  | ||||||
|  |         // Get holidays for current year and next year to cover all possibilities | ||||||
|  |         var currentYearHolidays = await GetNotableDays(currentYear, regionCode); | ||||||
|  |         var nextYearHolidays = await GetNotableDays(currentYear + 1, regionCode); | ||||||
|  |  | ||||||
|  |         var allHolidays = currentYearHolidays.Concat(nextYearHolidays); | ||||||
|  |  | ||||||
|  |         // Find the first holiday that is today or in the future | ||||||
|  |         var nextHoliday = allHolidays | ||||||
|  |             .Where(day => day.Date >= currentDate) | ||||||
|  |             .OrderBy(day => day.Date) | ||||||
|  |             .FirstOrDefault(); | ||||||
|  |  | ||||||
|  |         return nextHoliday; | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -10,7 +10,7 @@ namespace DysonNetwork.Pass.Account; | |||||||
| public class RelationshipService( | public class RelationshipService( | ||||||
|     AppDatabase db, |     AppDatabase db, | ||||||
|     ICacheService cache, |     ICacheService cache, | ||||||
|     PusherService.PusherServiceClient pusher, |     RingService.RingServiceClient pusher, | ||||||
|     IStringLocalizer<NotificationResource> localizer |     IStringLocalizer<NotificationResource> localizer | ||||||
| ) | ) | ||||||
| { | { | ||||||
|   | |||||||
| @@ -1,5 +1,7 @@ | |||||||
| using System.Linq.Expressions; | using System.Linq.Expressions; | ||||||
| using System.Reflection; | using System.Reflection; | ||||||
|  | using System.Text.Json; | ||||||
|  | using System.Text.Json.Serialization; | ||||||
| using DysonNetwork.Pass.Account; | using DysonNetwork.Pass.Account; | ||||||
| using DysonNetwork.Pass.Auth; | using DysonNetwork.Pass.Auth; | ||||||
| using DysonNetwork.Pass.Credit; | using DysonNetwork.Pass.Credit; | ||||||
| @@ -59,9 +61,14 @@ public class AppDatabase( | |||||||
|         optionsBuilder.UseNpgsql( |         optionsBuilder.UseNpgsql( | ||||||
|             configuration.GetConnectionString("App"), |             configuration.GetConnectionString("App"), | ||||||
|             opt => opt |             opt => opt | ||||||
|                 .ConfigureDataSource(optSource => optSource.EnableDynamicJson()) |                 .ConfigureDataSource(optSource => optSource | ||||||
|  |                     .EnableDynamicJson() | ||||||
|  |                     .ConfigureJsonOptions(new JsonSerializerOptions() | ||||||
|  |                     { | ||||||
|  |                         NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, | ||||||
|  |                     }) | ||||||
|  |                 ) | ||||||
|                 .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) |                 .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) | ||||||
|                 .UseNetTopologySuite() |  | ||||||
|                 .UseNodaTime() |                 .UseNodaTime() | ||||||
|         ).UseSnakeCaseNamingConvention(); |         ).UseSnakeCaseNamingConvention(); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -49,7 +49,10 @@ public class DysonTokenAuthHandler( | |||||||
|  |  | ||||||
|         try |         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) |             if (!valid || session is null) | ||||||
|                 return AuthenticateResult.Fail(message ?? "Authentication failed."); |                 return AuthenticateResult.Fail(message ?? "Authentication failed."); | ||||||
|  |  | ||||||
| @@ -67,7 +70,7 @@ public class DysonTokenAuthHandler( | |||||||
|             }; |             }; | ||||||
|  |  | ||||||
|             // Add scopes as claims |             // Add scopes as claims | ||||||
|             session.Challenge.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope))); |             session.Challenge?.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope))); | ||||||
|  |  | ||||||
|             // Add superuser claim if applicable |             // Add superuser claim if applicable | ||||||
|             if (session.Account.IsSuperuser) |             if (session.Account.IsSuperuser) | ||||||
|   | |||||||
| @@ -22,7 +22,7 @@ public class AuthController( | |||||||
|     AuthService auth, |     AuthService auth, | ||||||
|     GeoIpService geo, |     GeoIpService geo, | ||||||
|     ActionLogService als, |     ActionLogService als, | ||||||
|     PusherService.PusherServiceClient pusher, |     RingService.RingServiceClient pusher, | ||||||
|     IConfiguration configuration, |     IConfiguration configuration, | ||||||
|     IStringLocalizer<NotificationResource> localizer |     IStringLocalizer<NotificationResource> localizer | ||||||
| ) : ControllerBase | ) : ControllerBase | ||||||
| @@ -51,7 +51,11 @@ public class AuthController( | |||||||
|             .Where(e => e.Type == PunishmentType.BlockLogin || e.Type == PunishmentType.DisableAccount) |             .Where(e => e.Type == PunishmentType.BlockLogin || e.Type == PunishmentType.DisableAccount) | ||||||
|             .Where(e => e.ExpiredAt == null || now < e.ExpiredAt) |             .Where(e => e.ExpiredAt == null || now < e.ExpiredAt) | ||||||
|             .FirstOrDefaultAsync(); |             .FirstOrDefaultAsync(); | ||||||
|         if (punishment is not null) return StatusCode(423, punishment); |         if (punishment is not null) | ||||||
|  |             return StatusCode( | ||||||
|  |                 423, | ||||||
|  |                 $"Your account has been suspended. Reason: {punishment.Reason}. Expired at: {punishment.ExpiredAt?.ToString() ?? "never"}" | ||||||
|  |             ); | ||||||
|  |  | ||||||
|         var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); |         var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); | ||||||
|         var userAgent = HttpContext.Request.Headers.UserAgent.ToString(); |         var userAgent = HttpContext.Request.Headers.UserAgent.ToString(); | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| using System.Security.Cryptography; | using System.Security.Cryptography; | ||||||
| using System.Text.Json; | using System.Text.Json; | ||||||
|  | using System.Text.Json.Serialization; | ||||||
| using DysonNetwork.Pass.Account; | using DysonNetwork.Pass.Account; | ||||||
| using DysonNetwork.Shared.Cache; | using DysonNetwork.Shared.Cache; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| @@ -52,7 +53,7 @@ public class AuthService( | |||||||
|             riskScore += 1; |             riskScore += 1; | ||||||
|         else |         else | ||||||
|         { |         { | ||||||
|             if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge.IpAddress) && |             if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge?.IpAddress) && | ||||||
|                 !lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase)) |                 !lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase)) | ||||||
|                 riskScore += 1; |                 riskScore += 1; | ||||||
|         } |         } | ||||||
| @@ -137,6 +138,7 @@ public class AuthService( | |||||||
|  |  | ||||||
|         var jsonOpts = new JsonSerializerOptions |         var jsonOpts = new JsonSerializerOptions | ||||||
|         { |         { | ||||||
|  |             NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, | ||||||
|             PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, |             PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, | ||||||
|             DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower |             DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower | ||||||
|         }; |         }; | ||||||
| @@ -211,8 +213,7 @@ public class AuthService( | |||||||
|         var session = new AuthSession |         var session = new AuthSession | ||||||
|         { |         { | ||||||
|             LastGrantedAt = now, |             LastGrantedAt = now, | ||||||
|             // Never expire server-side |             ExpiredAt = now.Plus(Duration.FromDays(7)), | ||||||
|             ExpiredAt = null, |  | ||||||
|             AccountId = challenge.AccountId, |             AccountId = challenge.AccountId, | ||||||
|             ChallengeId = challenge.Id |             ChallengeId = challenge.Id | ||||||
|         }; |         }; | ||||||
|   | |||||||
| @@ -1,9 +1,5 @@ | |||||||
| using DysonNetwork.Pass.Wallet; |  | ||||||
| using DysonNetwork.Shared.Cache; |  | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
| using Grpc.Core; | using Grpc.Core; | ||||||
| using Microsoft.EntityFrameworkCore; |  | ||||||
| using NodaTime; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Pass.Auth; | namespace DysonNetwork.Pass.Auth; | ||||||
|  |  | ||||||
| @@ -18,7 +14,7 @@ public class AuthServiceGrpc( | |||||||
|         ServerCallContext context |         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) |         if (!valid || session is null) | ||||||
|             return new AuthenticateResponse { Valid = false, Message = message ?? "Authentication failed." }; |             return new AuthenticateResponse { Valid = false, Message = message ?? "Authentication failed." }; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations; | |||||||
| using System.ComponentModel.DataAnnotations.Schema; | using System.ComponentModel.DataAnnotations.Schema; | ||||||
| using System.Text.Json.Serialization; | using System.Text.Json.Serialization; | ||||||
| using DysonNetwork.Shared.Data; | using DysonNetwork.Shared.Data; | ||||||
|  | using DysonNetwork.Shared.GeoIp; | ||||||
| using NodaTime; | using NodaTime; | ||||||
| using NodaTime.Serialization.Protobuf; | using NodaTime.Serialization.Protobuf; | ||||||
| using Point = NetTopologySuite.Geometries.Point; | using Point = NetTopologySuite.Geometries.Point; | ||||||
| @@ -69,7 +70,7 @@ public class AuthChallenge : ModelBase | |||||||
|     [MaxLength(128)] public string? IpAddress { get; set; } |     [MaxLength(128)] public string? IpAddress { get; set; } | ||||||
|     [MaxLength(512)] public string? UserAgent { get; set; } |     [MaxLength(512)] public string? UserAgent { get; set; } | ||||||
|     [MaxLength(1024)] public string? Nonce { get; set; } |     [MaxLength(1024)] public string? Nonce { get; set; } | ||||||
|     public Point? Location { get; set; } |     [Column(TypeName = "jsonb")] public GeoPoint? Location { get; set; } | ||||||
|  |  | ||||||
|     public Guid AccountId { get; set; } |     public Guid AccountId { get; set; } | ||||||
|     [JsonIgnore] public Account.Account Account { get; set; } = null!; |     [JsonIgnore] public Account.Account Account { get; set; } = null!; | ||||||
|   | |||||||
| @@ -126,7 +126,6 @@ public class OidcProviderController( | |||||||
|         [FromForm(Name = "redirect_uri")] string? redirectUri = null, |         [FromForm(Name = "redirect_uri")] string? redirectUri = null, | ||||||
|         [FromForm] string? scope = null, |         [FromForm] string? scope = null, | ||||||
|         [FromForm] string? state = null, |         [FromForm] string? state = null, | ||||||
|         [FromForm(Name = "response_type")] string? responseType = null, |  | ||||||
|         [FromForm] string? nonce = null, |         [FromForm] string? nonce = null, | ||||||
|         [FromForm(Name = "code_challenge")] string? codeChallenge = null, |         [FromForm(Name = "code_challenge")] string? codeChallenge = null, | ||||||
|         [FromForm(Name = "code_challenge_method")] |         [FromForm(Name = "code_challenge_method")] | ||||||
| @@ -156,7 +155,7 @@ public class OidcProviderController( | |||||||
|             if (!string.IsNullOrEmpty(state)) queryParams["state"] = state; |             if (!string.IsNullOrEmpty(state)) queryParams["state"] = state; | ||||||
|  |  | ||||||
|             errorUri.Query = queryParams.ToString(); |             errorUri.Query = queryParams.ToString(); | ||||||
|             return Redirect(errorUri.Uri.ToString()); |             return Ok(new { redirectUri = errorUri.Uri.ToString() }); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Validate redirect_uri if provided |         // Validate redirect_uri if provided | ||||||
| @@ -191,7 +190,8 @@ public class OidcProviderController( | |||||||
|                 scope?.Split(' ') ?? [], |                 scope?.Split(' ') ?? [], | ||||||
|                 codeChallenge, |                 codeChallenge, | ||||||
|                 codeChallengeMethod, |                 codeChallengeMethod, | ||||||
|                 nonce); |                 nonce | ||||||
|  |             ); | ||||||
|  |  | ||||||
|             // Build the redirect URI with the authorization code |             // Build the redirect URI with the authorization code | ||||||
|             var redirectBuilder = new UriBuilder(redirectUri); |             var redirectBuilder = new UriBuilder(redirectUri); | ||||||
| @@ -201,7 +201,7 @@ public class OidcProviderController( | |||||||
|  |  | ||||||
|             redirectBuilder.Query = queryParams.ToString(); |             redirectBuilder.Query = queryParams.ToString(); | ||||||
|  |  | ||||||
|             return Redirect(redirectBuilder.Uri.ToString()); |             return Ok(new { redirectUri = redirectBuilder.Uri.ToString() }); | ||||||
|         } |         } | ||||||
|         catch (Exception ex) |         catch (Exception ex) | ||||||
|         { |         { | ||||||
| @@ -307,7 +307,7 @@ public class OidcProviderController( | |||||||
|             HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized(); |             HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized(); | ||||||
|  |  | ||||||
|         // Get requested scopes from the token |         // Get requested scopes from the token | ||||||
|         var scopes = currentSession.Challenge.Scopes; |         var scopes = currentSession.Challenge?.Scopes ?? []; | ||||||
|  |  | ||||||
|         var userInfo = new Dictionary<string, object> |         var userInfo = new Dictionary<string, object> | ||||||
|         { |         { | ||||||
|   | |||||||
| @@ -20,7 +20,6 @@ public class TokenResponse | |||||||
|     [JsonPropertyName("scope")] |     [JsonPropertyName("scope")] | ||||||
|     public string? Scope { get; set; } |     public string? Scope { get; set; } | ||||||
|  |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("id_token")] |     [JsonPropertyName("id_token")] | ||||||
|     public string? IdToken { get; set; } |     public string? IdToken { get; set; } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -11,6 +11,7 @@ using Microsoft.EntityFrameworkCore; | |||||||
| using Microsoft.Extensions.Options; | using Microsoft.Extensions.Options; | ||||||
| using Microsoft.IdentityModel.Tokens; | using Microsoft.IdentityModel.Tokens; | ||||||
| using NodaTime; | using NodaTime; | ||||||
|  | using AccountContactType = DysonNetwork.Pass.Account.AccountContactType; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Pass.Auth.OidcProvider.Services; | namespace DysonNetwork.Pass.Auth.OidcProvider.Services; | ||||||
|  |  | ||||||
| @@ -37,12 +38,21 @@ public class OidcProviderService( | |||||||
|         return resp.App ?? null; |         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(); |         var now = SystemClock.Instance.GetCurrentInstant(); | ||||||
|  |  | ||||||
|         return await db.AuthSessions |         var queryable = db.AuthSessions | ||||||
|             .Include(s => s.Challenge) |             .Include(s => s.Challenge) | ||||||
|  |             .AsQueryable(); | ||||||
|  |         if (withAccount) | ||||||
|  |             queryable = queryable | ||||||
|  |                 .Include(s => s.Account) | ||||||
|  |                 .ThenInclude(a => a.Profile) | ||||||
|  |                 .Include(a => a.Account.Contacts) | ||||||
|  |                 .AsQueryable(); | ||||||
|  |  | ||||||
|  |         return await queryable | ||||||
|             .Where(s => s.AccountId == accountId && |             .Where(s => s.AccountId == accountId && | ||||||
|                         s.AppId == clientId && |                         s.AppId == clientId && | ||||||
|                         (s.ExpiredAt == null || s.ExpiredAt > now) && |                         (s.ExpiredAt == null || s.ExpiredAt > now) && | ||||||
| @@ -133,6 +143,79 @@ public class OidcProviderService( | |||||||
|         return false; |         return false; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     private string GenerateIdToken( | ||||||
|  |         CustomApp client, | ||||||
|  |         AuthSession session, | ||||||
|  |         string? nonce = null, | ||||||
|  |         IEnumerable<string>? scopes = null | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         var tokenHandler = new JwtSecurityTokenHandler(); | ||||||
|  |         var clock = SystemClock.Instance; | ||||||
|  |         var now = clock.GetCurrentInstant(); | ||||||
|  |  | ||||||
|  |         var claims = new List<Claim> | ||||||
|  |         { | ||||||
|  |             new(JwtRegisteredClaimNames.Iss, _options.IssuerUri), | ||||||
|  |             new(JwtRegisteredClaimNames.Sub, session.AccountId.ToString()), | ||||||
|  |             new(JwtRegisteredClaimNames.Aud, client.Slug), | ||||||
|  |             new(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64), | ||||||
|  |             new(JwtRegisteredClaimNames.Exp, | ||||||
|  |                 now.Plus(Duration.FromSeconds(_options.AccessTokenLifetime.TotalSeconds)).ToUnixTimeSeconds() | ||||||
|  |                     .ToString(), ClaimValueTypes.Integer64), | ||||||
|  |             new(JwtRegisteredClaimNames.AuthTime, session.CreatedAt.ToUnixTimeSeconds().ToString(), | ||||||
|  |                 ClaimValueTypes.Integer64), | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         // Add nonce if provided (required for implicit and hybrid flows) | ||||||
|  |         if (!string.IsNullOrEmpty(nonce)) | ||||||
|  |         { | ||||||
|  |             claims.Add(new Claim("nonce", nonce)); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Add email claim if email scope is requested | ||||||
|  |         var scopesList = scopes?.ToList() ?? []; | ||||||
|  |         if (scopesList.Contains("email")) | ||||||
|  |         { | ||||||
|  |             var contact = session.Account.Contacts.FirstOrDefault(c => c.Type == AccountContactType.Email); | ||||||
|  |             if (contact is not null) | ||||||
|  |             { | ||||||
|  |                 claims.Add(new Claim(JwtRegisteredClaimNames.Email, contact.Content)); | ||||||
|  |                 claims.Add(new Claim("email_verified", contact.VerifiedAt is not null ? "true" : "false", | ||||||
|  |                     ClaimValueTypes.Boolean)); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Add profile claims if profile scope is requested | ||||||
|  |         if (scopes != null && scopesList.Contains("profile")) | ||||||
|  |         { | ||||||
|  |             if (!string.IsNullOrEmpty(session.Account.Name)) | ||||||
|  |                 claims.Add(new Claim("preferred_username", session.Account.Name)); | ||||||
|  |             if (!string.IsNullOrEmpty(session.Account.Nick)) | ||||||
|  |                 claims.Add(new Claim("name", session.Account.Nick)); | ||||||
|  |             if (!string.IsNullOrEmpty(session.Account.Profile.FirstName)) | ||||||
|  |                 claims.Add(new Claim("given_name", session.Account.Profile.FirstName)); | ||||||
|  |             if (!string.IsNullOrEmpty(session.Account.Profile.LastName)) | ||||||
|  |                 claims.Add(new Claim("family_name", session.Account.Profile.LastName)); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var tokenDescriptor = new SecurityTokenDescriptor | ||||||
|  |         { | ||||||
|  |             Subject = new ClaimsIdentity(claims), | ||||||
|  |             Issuer = _options.IssuerUri, | ||||||
|  |             Audience = client.Id.ToString(), | ||||||
|  |             Expires = now.Plus(Duration.FromSeconds(_options.AccessTokenLifetime.TotalSeconds)).ToDateTimeUtc(), | ||||||
|  |             NotBefore = now.ToDateTimeUtc(), | ||||||
|  |             SigningCredentials = new SigningCredentials( | ||||||
|  |                 new RsaSecurityKey(_options.GetRsaPrivateKey()), | ||||||
|  |                 SecurityAlgorithms.RsaSha256 | ||||||
|  |             ) | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         var token = tokenHandler.CreateToken(tokenDescriptor); | ||||||
|  |         return tokenHandler.WriteToken(token); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     public async Task<TokenResponse> GenerateTokenResponseAsync( |     public async Task<TokenResponse> GenerateTokenResponseAsync( | ||||||
|         Guid clientId, |         Guid clientId, | ||||||
|         string? authorizationCode = null, |         string? authorizationCode = null, | ||||||
| @@ -148,24 +231,43 @@ public class OidcProviderService( | |||||||
|         AuthSession session; |         AuthSession session; | ||||||
|         var clock = SystemClock.Instance; |         var clock = SystemClock.Instance; | ||||||
|         var now = clock.GetCurrentInstant(); |         var now = clock.GetCurrentInstant(); | ||||||
|  |         string? nonce = null; | ||||||
|         List<string>? scopes = null; |         List<string>? scopes = null; | ||||||
|  |  | ||||||
|         if (authorizationCode != null) |         if (authorizationCode != null) | ||||||
|         { |         { | ||||||
|             // Authorization code flow |             // Authorization code flow | ||||||
|             var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier); |             var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier); | ||||||
|             if (authCode is null) throw new InvalidOperationException("Invalid authorization code"); |             if (authCode == null) | ||||||
|             var account = await db.Accounts.Where(a => a.Id == authCode.AccountId).FirstOrDefaultAsync(); |                 throw new InvalidOperationException("Invalid authorization code"); | ||||||
|             if (account is null) throw new InvalidOperationException("Account was not found"); |  | ||||||
|  |             // Load the session for the user | ||||||
|  |             var existingSession = await FindValidSessionAsync(authCode.AccountId, clientId, withAccount: true); | ||||||
|  |  | ||||||
|  |             if (existingSession is null) | ||||||
|  |             { | ||||||
|  |                 var account = await db.Accounts | ||||||
|  |                     .Where(a => a.Id == authCode.AccountId) | ||||||
|  |                     .Include(a => a.Profile) | ||||||
|  |                     .Include(a => a.Contacts) | ||||||
|  |                     .FirstOrDefaultAsync(); | ||||||
|  |                 if (account is null) throw new InvalidOperationException("Account not found"); | ||||||
|  |                 session = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant(), clientId); | ||||||
|  |                 session.Account = account; | ||||||
|  |             } | ||||||
|  |             else | ||||||
|  |             { | ||||||
|  |                 session = existingSession; | ||||||
|  |             } | ||||||
|  |  | ||||||
|             session = await auth.CreateSessionForOidcAsync(account, now, clientId); |  | ||||||
|             scopes = authCode.Scopes; |             scopes = authCode.Scopes; | ||||||
|  |             nonce = authCode.Nonce; | ||||||
|         } |         } | ||||||
|         else if (sessionId.HasValue) |         else if (sessionId.HasValue) | ||||||
|         { |         { | ||||||
|             // Refresh token flow |             // Refresh token flow | ||||||
|             session = await FindSessionByIdAsync(sessionId.Value) ?? |             session = await FindSessionByIdAsync(sessionId.Value) ?? | ||||||
|                       throw new InvalidOperationException("Invalid session"); |                       throw new InvalidOperationException("Session not found"); | ||||||
|  |  | ||||||
|             // Verify the session is still valid |             // Verify the session is still valid | ||||||
|             if (session.ExpiredAt < now) |             if (session.ExpiredAt < now) | ||||||
| @@ -179,13 +281,15 @@ public class OidcProviderService( | |||||||
|         var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds; |         var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds; | ||||||
|         var expiresAt = now.Plus(Duration.FromSeconds(expiresIn)); |         var expiresAt = now.Plus(Duration.FromSeconds(expiresIn)); | ||||||
|  |  | ||||||
|         // Generate an access token |         // Generate tokens | ||||||
|         var accessToken = GenerateJwtToken(client, session, expiresAt, scopes); |         var accessToken = GenerateJwtToken(client, session, expiresAt, scopes); | ||||||
|  |         var idToken = GenerateIdToken(client, session, nonce, scopes); | ||||||
|         var refreshToken = GenerateRefreshToken(session); |         var refreshToken = GenerateRefreshToken(session); | ||||||
|  |  | ||||||
|         return new TokenResponse |         return new TokenResponse | ||||||
|         { |         { | ||||||
|             AccessToken = accessToken, |             AccessToken = accessToken, | ||||||
|  |             IdToken = idToken, | ||||||
|             ExpiresIn = expiresIn, |             ExpiresIn = expiresIn, | ||||||
|             TokenType = "Bearer", |             TokenType = "Bearer", | ||||||
|             RefreshToken = refreshToken, |             RefreshToken = refreshToken, | ||||||
| @@ -211,11 +315,10 @@ public class OidcProviderService( | |||||||
|                 new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()), |                 new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()), | ||||||
|                 new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(), |                 new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(), | ||||||
|                     ClaimValueTypes.Integer64), |                     ClaimValueTypes.Integer64), | ||||||
|                 new Claim("client_id", client.Id) |  | ||||||
|             ]), |             ]), | ||||||
|             Expires = expiresAt.ToDateTimeUtc(), |             Expires = expiresAt.ToDateTimeUtc(), | ||||||
|             Issuer = _options.IssuerUri, |             Issuer = _options.IssuerUri, | ||||||
|             Audience = client.Id |             Audience = client.Slug | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         // Try to use RSA signing if keys are available, fall back to HMAC |         // Try to use RSA signing if keys are available, fall back to HMAC | ||||||
| @@ -281,51 +384,6 @@ public class OidcProviderService( | |||||||
|         return Convert.ToBase64String(session.Id.ToByteArray()); |         return Convert.ToBase64String(session.Id.ToByteArray()); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private static bool VerifyHashedSecret(string secret, string hashedSecret) |  | ||||||
|     { |  | ||||||
|         // In a real implementation, you'd use a proper password hashing algorithm like PBKDF2, bcrypt, or Argon2 |  | ||||||
|         // For now, we'll do a simple comparison, but you should replace this with proper hashing |  | ||||||
|         return string.Equals(secret, hashedSecret, StringComparison.Ordinal); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<string> GenerateAuthorizationCodeForReuseSessionAsync( |  | ||||||
|         AuthSession session, |  | ||||||
|         Guid clientId, |  | ||||||
|         string redirectUri, |  | ||||||
|         IEnumerable<string> scopes, |  | ||||||
|         string? codeChallenge = null, |  | ||||||
|         string? codeChallengeMethod = null, |  | ||||||
|         string? nonce = null) |  | ||||||
|     { |  | ||||||
|         var clock = SystemClock.Instance; |  | ||||||
|         var now = clock.GetCurrentInstant(); |  | ||||||
|         var code = Guid.NewGuid().ToString("N"); |  | ||||||
|  |  | ||||||
|         // Update the session's last activity time |  | ||||||
|         await db.AuthSessions.Where(s => s.Id == session.Id) |  | ||||||
|             .ExecuteUpdateAsync(s => s.SetProperty(s => s.LastGrantedAt, now)); |  | ||||||
|  |  | ||||||
|         // Create the authorization code info |  | ||||||
|         var authCodeInfo = new AuthorizationCodeInfo |  | ||||||
|         { |  | ||||||
|             ClientId = clientId, |  | ||||||
|             AccountId = session.AccountId, |  | ||||||
|             RedirectUri = redirectUri, |  | ||||||
|             Scopes = scopes.ToList(), |  | ||||||
|             CodeChallenge = codeChallenge, |  | ||||||
|             CodeChallengeMethod = codeChallengeMethod, |  | ||||||
|             Nonce = nonce, |  | ||||||
|             CreatedAt = now |  | ||||||
|         }; |  | ||||||
|          |  | ||||||
|         // Store the code with its metadata in the cache |  | ||||||
|         var cacheKey = $"auth:code:{code}"; |  | ||||||
|         await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime); |  | ||||||
|  |  | ||||||
|         logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, session.AccountId); |  | ||||||
|         return code; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<string> GenerateAuthorizationCodeAsync( |     public async Task<string> GenerateAuthorizationCodeAsync( | ||||||
|         Guid clientId, |         Guid clientId, | ||||||
|         Guid userId, |         Guid userId, | ||||||
| @@ -355,7 +413,7 @@ public class OidcProviderService( | |||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         // Store the code with its metadata in the cache |         // Store the code with its metadata in the cache | ||||||
|         var cacheKey = $"auth:code:{code}"; |         var cacheKey = $"auth:oidc-code:{code}"; | ||||||
|         await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime); |         await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime); | ||||||
|  |  | ||||||
|         logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId); |         logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId); | ||||||
| @@ -369,7 +427,7 @@ public class OidcProviderService( | |||||||
|         string? codeVerifier = null |         string? codeVerifier = null | ||||||
|     ) |     ) | ||||||
|     { |     { | ||||||
|         var cacheKey = $"auth:code:{code}"; |         var cacheKey = $"auth:oidc-code:{code}"; | ||||||
|         var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey); |         var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey); | ||||||
|  |  | ||||||
|         if (!found || authCode == null) |         if (!found || authCode == null) | ||||||
|   | |||||||
| @@ -340,7 +340,7 @@ public class ConnectionController( | |||||||
|  |  | ||||||
|         var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant()); |         var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant()); | ||||||
|         var loginToken = auth.CreateToken(loginSession); |         var loginToken = auth.CreateToken(loginSession); | ||||||
|         return Redirect($"/auth/token?token={loginToken}"); |         return Redirect($"/auth/callback?token={loginToken}"); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private static async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request) |     private static async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request) | ||||||
|   | |||||||
| @@ -84,6 +84,7 @@ public class OidcState | |||||||
|     { |     { | ||||||
|         return JsonSerializer.Serialize(this, new JsonSerializerOptions |         return JsonSerializer.Serialize(this, new JsonSerializerOptions | ||||||
|         { |         { | ||||||
|  |             NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, | ||||||
|             PropertyNamingPolicy = JsonNamingPolicy.CamelCase, |             PropertyNamingPolicy = JsonNamingPolicy.CamelCase, | ||||||
|             DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull |             DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull | ||||||
|         }); |         }); | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | using System.IdentityModel.Tokens.Jwt; | ||||||
| using System.Security.Cryptography; | using System.Security.Cryptography; | ||||||
| using System.Text; | using System.Text; | ||||||
| using DysonNetwork.Pass.Wallet; | using DysonNetwork.Pass.Wallet; | ||||||
| @@ -22,8 +23,9 @@ public class TokenAuthService( | |||||||
|     /// then cache and return. |     /// then cache and return. | ||||||
|     /// </summary> |     /// </summary> | ||||||
|     /// <param name="token">Incoming token string</param> |     /// <param name="token">Incoming token string</param> | ||||||
|  |     /// <param name="ipAddress">Client IP address, for logging purposes</param> | ||||||
|     /// <returns>(Valid, Session, Message)</returns> |     /// <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 |         try | ||||||
|         { |         { | ||||||
| @@ -33,6 +35,11 @@ public class TokenAuthService( | |||||||
|                 return (false, null, "No token provided."); |                 return (false, null, "No token provided."); | ||||||
|             } |             } | ||||||
|              |              | ||||||
|  |             if (!string.IsNullOrEmpty(ipAddress)) | ||||||
|  |             { | ||||||
|  |                 logger.LogDebug("AuthenticateTokenAsync: client IP: {IpAddress}", ipAddress); | ||||||
|  |             } | ||||||
|  |  | ||||||
|             // token fingerprint for correlation |             // token fingerprint for correlation | ||||||
|             var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); |             var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); | ||||||
|             var tokenFp = tokenHash[..8]; |             var tokenFp = tokenHash[..8]; | ||||||
| @@ -70,7 +77,7 @@ public class TokenAuthService( | |||||||
|                     "AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})", |                     "AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})", | ||||||
|                     sessionId, |                     sessionId, | ||||||
|                     session.AccountId, |                     session.AccountId, | ||||||
|                     session.Challenge.Scopes.Count, |                     session.Challenge?.Scopes.Count, | ||||||
|                     session.ExpiredAt |                     session.ExpiredAt | ||||||
|                 ); |                 ); | ||||||
|                 return (true, session, null); |                 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})", |                 "AuthenticateTokenAsync: DB session loaded (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId}, appId={AppId}, scopes={ScopeCount}, ip={Ip}, uaLen={UaLen})", | ||||||
|                 sessionId, |                 sessionId, | ||||||
|                 session.AccountId, |                 session.AccountId, | ||||||
|                 session.Challenge.ClientId, |                 session.Challenge?.ClientId, | ||||||
|                 session.AppId, |                 session.AppId, | ||||||
|                 session.Challenge.Scopes.Count, |                 session.Challenge?.Scopes.Count, | ||||||
|                 session.Challenge.IpAddress, |                 session.Challenge?.IpAddress, | ||||||
|                 (session.Challenge.UserAgent ?? string.Empty).Length |                 (session.Challenge?.UserAgent ?? string.Empty).Length | ||||||
|             ); |             ); | ||||||
|  |  | ||||||
|             logger.LogDebug("AuthenticateTokenAsync: enriching account with subscription (accountId={AccountId})", session.AccountId); |             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})", |                 "AuthenticateTokenAsync: success via DB (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId})", | ||||||
|                 sessionId, |                 sessionId, | ||||||
|                 session.AccountId, |                 session.AccountId, | ||||||
|                 session.Challenge.ClientId |                 session.Challenge?.ClientId | ||||||
|             ); |             ); | ||||||
|             return (true, session, null); |             return (true, session, null); | ||||||
|         } |         } | ||||||
|   | |||||||
| @@ -169,15 +169,15 @@ function handleDeny() { | |||||||
|     error_description: 'The user denied the authorization request', |     error_description: 'The user denied the authorization request', | ||||||
|     state: state, |     state: state, | ||||||
|   }) |   }) | ||||||
|   window.location.href = `${redirectUri}?${params}` |   window.open(`${redirectUri}?${params}`, "_self") | ||||||
| } | } | ||||||
|  |  | ||||||
| function openTerms() { | 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() { | function openPrivacy() { | ||||||
|   window.open(clientInfo.value?.privacy_policy_uri || 'https://example.com/privacy', '_blank') |   window.open(clientInfo.value?.privacy_policy_uri || '#', "_blank") | ||||||
| } | } | ||||||
|  |  | ||||||
| // Lifecycle | // Lifecycle | ||||||
|   | |||||||
| @@ -13,7 +13,7 @@ | |||||||
|             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> |             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||||
|             <PrivateAssets>all</PrivateAssets> |             <PrivateAssets>all</PrivateAssets> | ||||||
|         </PackageReference> |         </PackageReference> | ||||||
|         <PackageReference Include="NATS.Client.Core" Version="2.6.6" /> |         <PackageReference Include="Nager.Holiday" Version="1.0.1" /> | ||||||
|         <PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115"> |         <PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115"> | ||||||
|             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> |             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||||
|             <PrivateAssets>all</PrivateAssets> |             <PrivateAssets>all</PrivateAssets> | ||||||
| @@ -24,7 +24,6 @@ | |||||||
|         <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/> |         <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/> | ||||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/> |         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/> | ||||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0"/> |         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0"/> | ||||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="9.0.4"/> |  | ||||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4"/> |         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4"/> | ||||||
|         <PackageReference Include="OpenGraph-Net" Version="4.0.1" /> |         <PackageReference Include="OpenGraph-Net" Version="4.0.1" /> | ||||||
|         <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/> |         <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/> | ||||||
| @@ -50,6 +49,7 @@ | |||||||
|     </ItemGroup> |     </ItemGroup> | ||||||
|  |  | ||||||
|     <ItemGroup> |     <ItemGroup> | ||||||
|  |         <ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" /> | ||||||
|         <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj"/> |         <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj"/> | ||||||
|     </ItemGroup> |     </ItemGroup> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,12 +1,10 @@ | |||||||
| using dotnet_etcd; |  | ||||||
| using dotnet_etcd.interfaces; |  | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
| using Microsoft.AspNetCore.Components; | using Microsoft.AspNetCore.Components; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Pass.Email; | namespace DysonNetwork.Pass.Email; | ||||||
|  |  | ||||||
| public class EmailService( | public class EmailService( | ||||||
|     PusherService.PusherServiceClient pusher, |     RingService.RingServiceClient pusher, | ||||||
|     RazorViewRenderer viewRenderer, |     RazorViewRenderer viewRenderer, | ||||||
|     ILogger<EmailService> logger |     ILogger<EmailService> logger | ||||||
| ) | ) | ||||||
|   | |||||||
| @@ -42,8 +42,17 @@ public class LastActiveFlushHandler(IServiceProvider srp, ILogger<LastActiveFlus | |||||||
|         var updatingSessions = sessionMap.Select(x => x.Key).ToList(); |         var updatingSessions = sessionMap.Select(x => x.Key).ToList(); | ||||||
|         var sessionUpdates = await db.AuthSessions |         var sessionUpdates = await db.AuthSessions | ||||||
|             .Where(s => updatingSessions.Contains(s.Id)) |             .Where(s => updatingSessions.Contains(s.Id)) | ||||||
|             .ExecuteUpdateAsync(s => s.SetProperty(x => x.LastGrantedAt, now)); |             .ExecuteUpdateAsync(s => | ||||||
|  |                 s.SetProperty(x => x.LastGrantedAt, now) | ||||||
|  |             ); | ||||||
|         logger.LogInformation("Updated {Count} auth sessions according to LastActiveInfo", sessionUpdates); |         logger.LogInformation("Updated {Count} auth sessions according to LastActiveInfo", sessionUpdates); | ||||||
|  |         var newExpiration = now.Plus(Duration.FromDays(7)); | ||||||
|  |         var keepAliveSessionUpdates = await db.AuthSessions | ||||||
|  |             .Where(s => updatingSessions.Contains(s.Id) && s.ExpiredAt != null) | ||||||
|  |             .ExecuteUpdateAsync(s => | ||||||
|  |                 s.SetProperty(x => x.ExpiredAt, newExpiration) | ||||||
|  |             ); | ||||||
|  |         logger.LogInformation("Updated {Count} auth sessions' duration according to LastActiveInfo", sessionUpdates); | ||||||
|  |  | ||||||
|         var updatingAccounts = accountMap.Select(x => x.Key).ToList(); |         var updatingAccounts = accountMap.Select(x => x.Key).ToList(); | ||||||
|         var profileUpdates = await db.AccountProfiles |         var profileUpdates = await db.AccountProfiles | ||||||
| @@ -53,7 +62,8 @@ public class LastActiveFlushHandler(IServiceProvider srp, ILogger<LastActiveFlus | |||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| public class LastActiveFlushJob(FlushBufferService fbs, LastActiveFlushHandler hdl, ILogger<LastActiveFlushJob> logger) : IJob | public class LastActiveFlushJob(FlushBufferService fbs, LastActiveFlushHandler hdl, ILogger<LastActiveFlushJob> logger) | ||||||
|  |     : IJob | ||||||
| { | { | ||||||
|     public async Task Execute(IJobExecutionContext context) |     public async Task Execute(IJobExecutionContext context) | ||||||
|     { |     { | ||||||
| @@ -62,7 +72,8 @@ public class LastActiveFlushJob(FlushBufferService fbs, LastActiveFlushHandler h | |||||||
|             logger.LogInformation("Running LastActiveInfo flush job..."); |             logger.LogInformation("Running LastActiveInfo flush job..."); | ||||||
|             await fbs.FlushAsync(hdl); |             await fbs.FlushAsync(hdl); | ||||||
|             logger.LogInformation("Completed LastActiveInfo flush job..."); |             logger.LogInformation("Completed LastActiveInfo flush job..."); | ||||||
|         } catch (Exception ex) |         } | ||||||
|  |         catch (Exception ex) | ||||||
|         { |         { | ||||||
|             logger.LogError(ex, "Error running LastActiveInfo job..."); |             logger.LogError(ex, "Error running LastActiveInfo job..."); | ||||||
|         } |         } | ||||||
|   | |||||||
							
								
								
									
										39
									
								
								DysonNetwork.Pass/IpCheckController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								DysonNetwork.Pass/IpCheckController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | |||||||
|  | using Microsoft.AspNetCore.Mvc; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Pass; | ||||||
|  |  | ||||||
|  | [ApiController] | ||||||
|  | [Route("/api/ip-check")] | ||||||
|  | public class IpCheckController : ControllerBase | ||||||
|  | { | ||||||
|  |     public class IpCheckResponse | ||||||
|  |     { | ||||||
|  |         public string? RemoteIp { get; set; } | ||||||
|  |         public string? XForwardedFor { get; set; } | ||||||
|  |         public string? XForwardedProto { get; set; } | ||||||
|  |         public string? XForwardedHost { get; set; } | ||||||
|  |         public string? XRealIp { get; set; } | ||||||
|  |         public string? Headers { get; set; } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     [HttpGet] | ||||||
|  |     public ActionResult<IpCheckResponse> GetIpCheck() | ||||||
|  |     { | ||||||
|  |         var ip = HttpContext.Connection.RemoteIpAddress?.ToString(); | ||||||
|  |  | ||||||
|  |         var xForwardedFor = Request.Headers["X-Forwarded-For"].FirstOrDefault(); | ||||||
|  |         var xForwardedProto = Request.Headers["X-Forwarded-Proto"].FirstOrDefault(); | ||||||
|  |         var xForwardedHost = Request.Headers["X-Forwarded-Host"].FirstOrDefault(); | ||||||
|  |         var realIp = Request.Headers["X-Real-IP"].FirstOrDefault(); | ||||||
|  |  | ||||||
|  |         return Ok(new IpCheckResponse | ||||||
|  |         { | ||||||
|  |             RemoteIp = ip, | ||||||
|  |             XForwardedFor = xForwardedFor, | ||||||
|  |             XForwardedProto = xForwardedProto, | ||||||
|  |             XForwardedHost = xForwardedHost, | ||||||
|  |             XRealIp = realIp, | ||||||
|  |             Headers = string.Join('\n', Request.Headers.Select(h => $"{h.Key}: {h.Value}")), | ||||||
|  |         }); | ||||||
|  |     }  | ||||||
|  | } | ||||||
							
								
								
									
										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.Account; | ||||||
| using DysonNetwork.Pass.Wallet; | using DysonNetwork.Pass.Wallet; | ||||||
| using DysonNetwork.Shared.Data; | using DysonNetwork.Shared.Data; | ||||||
|  | using DysonNetwork.Shared.GeoIp; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||||
| using NetTopologySuite.Geometries; |  | ||||||
| using NodaTime; | using NodaTime; | ||||||
| using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||||
|  |  | ||||||
| @@ -27,7 +27,6 @@ namespace DysonNetwork.Pass.Migrations | |||||||
|                 .HasAnnotation("ProductVersion", "9.0.7") |                 .HasAnnotation("ProductVersion", "9.0.7") | ||||||
|                 .HasAnnotation("Relational:MaxIdentifierLength", 63); |                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||||
|  |  | ||||||
|             NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); |  | ||||||
|             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); |             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||||
|  |  | ||||||
|             modelBuilder.Entity("DysonNetwork.Pass.Account.AbuseReport", b => |             modelBuilder.Entity("DysonNetwork.Pass.Account.AbuseReport", b => | ||||||
| @@ -132,6 +131,12 @@ namespace DysonNetwork.Pass.Migrations | |||||||
|                         .HasColumnType("character varying(256)") |                         .HasColumnType("character varying(256)") | ||||||
|                         .HasColumnName("nick"); |                         .HasColumnName("nick"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Region") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("region"); | ||||||
|  |  | ||||||
|                     b.Property<Instant>("UpdatedAt") |                     b.Property<Instant>("UpdatedAt") | ||||||
|                         .HasColumnType("timestamp with time zone") |                         .HasColumnType("timestamp with time zone") | ||||||
|                         .HasColumnName("updated_at"); |                         .HasColumnName("updated_at"); | ||||||
| @@ -519,8 +524,8 @@ namespace DysonNetwork.Pass.Migrations | |||||||
|                         .HasColumnType("character varying(128)") |                         .HasColumnType("character varying(128)") | ||||||
|                         .HasColumnName("ip_address"); |                         .HasColumnName("ip_address"); | ||||||
|  |  | ||||||
|                     b.Property<Point>("Location") |                     b.Property<GeoPoint>("Location") | ||||||
|                         .HasColumnType("geometry") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("location"); |                         .HasColumnName("location"); | ||||||
|  |  | ||||||
|                     b.Property<Dictionary<string, object>>("Meta") |                     b.Property<Dictionary<string, object>>("Meta") | ||||||
| @@ -762,6 +767,11 @@ namespace DysonNetwork.Pass.Migrations | |||||||
|                         .HasColumnType("uuid") |                         .HasColumnType("uuid") | ||||||
|                         .HasColumnName("account_id"); |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("AppIdentifier") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("app_identifier"); | ||||||
|  |  | ||||||
|                     b.Property<int>("Attitude") |                     b.Property<int>("Attitude") | ||||||
|                         .HasColumnType("integer") |                         .HasColumnType("integer") | ||||||
|                         .HasColumnName("attitude"); |                         .HasColumnName("attitude"); | ||||||
| @@ -778,6 +788,10 @@ namespace DysonNetwork.Pass.Migrations | |||||||
|                         .HasColumnType("timestamp with time zone") |                         .HasColumnType("timestamp with time zone") | ||||||
|                         .HasColumnName("deleted_at"); |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsAutomated") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_automated"); | ||||||
|  |  | ||||||
|                     b.Property<bool>("IsInvisible") |                     b.Property<bool>("IsInvisible") | ||||||
|                         .HasColumnType("boolean") |                         .HasColumnType("boolean") | ||||||
|                         .HasColumnName("is_invisible"); |                         .HasColumnName("is_invisible"); | ||||||
| @@ -895,8 +909,8 @@ namespace DysonNetwork.Pass.Migrations | |||||||
|                         .HasColumnType("character varying(128)") |                         .HasColumnType("character varying(128)") | ||||||
|                         .HasColumnName("ip_address"); |                         .HasColumnName("ip_address"); | ||||||
|  |  | ||||||
|                     b.Property<Point>("Location") |                     b.Property<GeoPoint>("Location") | ||||||
|                         .HasColumnType("geometry") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("location"); |                         .HasColumnName("location"); | ||||||
|  |  | ||||||
|                     b.Property<string>("Nonce") |                     b.Property<string>("Nonce") | ||||||
| @@ -1381,6 +1395,11 @@ namespace DysonNetwork.Pass.Migrations | |||||||
|                         .HasColumnType("uuid") |                         .HasColumnType("uuid") | ||||||
|                         .HasColumnName("payee_wallet_id"); |                         .HasColumnName("payee_wallet_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("ProductIdentifier") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("product_identifier"); | ||||||
|  |  | ||||||
|                     b.Property<string>("Remarks") |                     b.Property<string>("Remarks") | ||||||
|                         .HasMaxLength(4096) |                         .HasMaxLength(4096) | ||||||
|                         .HasColumnType("character varying(4096)") |                         .HasColumnType("character varying(4096)") | ||||||
|   | |||||||
| @@ -4,25 +4,21 @@ using DysonNetwork.Pass.Startup; | |||||||
| using DysonNetwork.Shared.Http; | using DysonNetwork.Shared.Http; | ||||||
| using DysonNetwork.Shared.PageData; | using DysonNetwork.Shared.PageData; | ||||||
| using DysonNetwork.Shared.Registry; | using DysonNetwork.Shared.Registry; | ||||||
| using DysonNetwork.Shared.Stream; |  | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
|  |  | ||||||
| var builder = WebApplication.CreateBuilder(args); | var builder = WebApplication.CreateBuilder(args); | ||||||
|  |  | ||||||
|  | builder.AddServiceDefaults(); | ||||||
|  |  | ||||||
| // Configure Kestrel and server options | // Configure Kestrel and server options | ||||||
| builder.ConfigureAppKestrel(builder.Configuration); | builder.ConfigureAppKestrel(builder.Configuration); | ||||||
|  |  | ||||||
| // Add metrics and telemetry |  | ||||||
| builder.Services.AddAppMetrics(); |  | ||||||
|  |  | ||||||
| // Add application services | // Add application services | ||||||
| builder.Services.AddRegistryService(builder.Configuration); |  | ||||||
| builder.Services.AddStreamConnection(builder.Configuration); |  | ||||||
| builder.Services.AddAppServices(builder.Configuration); | builder.Services.AddAppServices(builder.Configuration); | ||||||
| builder.Services.AddAppRateLimiting(); | builder.Services.AddAppRateLimiting(); | ||||||
| builder.Services.AddAppAuthentication(); | builder.Services.AddAppAuthentication(); | ||||||
| builder.Services.AddAppSwagger(); | builder.Services.AddAppSwagger(); | ||||||
| builder.Services.AddPusherService(); | builder.Services.AddRingService(); | ||||||
| builder.Services.AddDriveService(); | builder.Services.AddDriveService(); | ||||||
| builder.Services.AddDevelopService(); | builder.Services.AddDevelopService(); | ||||||
|  |  | ||||||
| @@ -41,6 +37,8 @@ builder.Services.AddTransient<IPageDataProvider, AccountPageData>(); | |||||||
|  |  | ||||||
| var app = builder.Build(); | var app = builder.Build(); | ||||||
|  |  | ||||||
|  | app.MapDefaultEndpoints(); | ||||||
|  |  | ||||||
| // Run database migrations | // Run database migrations | ||||||
| using (var scope = app.Services.CreateScope()) | using (var scope = app.Services.CreateScope()) | ||||||
| { | { | ||||||
| @@ -51,8 +49,6 @@ using (var scope = app.Services.CreateScope()) | |||||||
| // Configure application middleware pipeline | // Configure application middleware pipeline | ||||||
| app.ConfigureAppMiddleware(builder.Configuration, builder.Environment.ContentRootPath); | app.ConfigureAppMiddleware(builder.Configuration, builder.Environment.ContentRootPath); | ||||||
|  |  | ||||||
| app.MapGatewayProxy(); |  | ||||||
|  |  | ||||||
| app.MapPages(Path.Combine(builder.Environment.WebRootPath, "dist", "index.html")); | app.MapPages(Path.Combine(builder.Environment.WebRootPath, "dist", "index.html")); | ||||||
|  |  | ||||||
| // Configure gRPC | // Configure gRPC | ||||||
|   | |||||||
| @@ -170,5 +170,47 @@ namespace DysonNetwork.Sphere.Resources.Localization { | |||||||
|                 return ResourceManager.GetString("NewLoginBody", resourceCulture); |                 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> |         <value>Order {0} recipent</value> | ||||||
|     </data> |     </data> | ||||||
|     <data name="OrderPaidBody" xml:space="preserve"> |     <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> | ||||||
|     <data name="NewLoginTitle" xml:space="preserve"> |     <data name="NewLoginTitle" xml:space="preserve"> | ||||||
|         <value>New login detected</value> |         <value>New login detected</value> | ||||||
| @@ -92,4 +92,19 @@ | |||||||
|     <data name="FriendRequestBody" xml:space="preserve"> |     <data name="FriendRequestBody" xml:space="preserve"> | ||||||
|         <value>You can go to relationships page and decide accept their request or not.</value> |         <value>You can go to relationships page and decide accept their request or not.</value> | ||||||
|     </data> |     </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> | </root> | ||||||
| @@ -67,10 +67,10 @@ | |||||||
|         <value>感谢你支持 Solar Network 的开发!你的 {0} 天 {1} 订阅刚刚开始,接下来来探索新解锁的新功能吧!</value> |         <value>感谢你支持 Solar Network 的开发!你的 {0} 天 {1} 订阅刚刚开始,接下来来探索新解锁的新功能吧!</value> | ||||||
|     </data> |     </data> | ||||||
|     <data name="OrderPaidTitle" xml:space="preserve"> |     <data name="OrderPaidTitle" xml:space="preserve"> | ||||||
|         <value>订单回执 {0}</value> |         <value>订单收据 {0}</value> | ||||||
|     </data> |     </data> | ||||||
|     <data name="OrderPaidBody" xml:space="preserve"> |     <data name="OrderPaidBody" xml:space="preserve"> | ||||||
|         <value>{0} {1} 已从你的帐户中扣除来支付 {2}</value> |         <value>已支付订单 {2} 的 {0} {1}</value> | ||||||
|     </data> |     </data> | ||||||
|     <data name="NewLoginTitle" xml:space="preserve"> |     <data name="NewLoginTitle" xml:space="preserve"> | ||||||
|         <value>检测到新登陆</value> |         <value>检测到新登陆</value> | ||||||
| @@ -84,4 +84,19 @@ | |||||||
|     <data name="FriendRequestBody" xml:space="preserve"> |     <data name="FriendRequestBody" xml:space="preserve"> | ||||||
|         <value>您可以前往人际关系页面来决定时候要接受他们的邀请。</value> |         <value>您可以前往人际关系页面来决定时候要接受他们的邀请。</value> | ||||||
|     </data> |     </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> | </root> | ||||||
| @@ -4,6 +4,8 @@ using DysonNetwork.Pass.Auth; | |||||||
| using DysonNetwork.Pass.Credit; | using DysonNetwork.Pass.Credit; | ||||||
| using DysonNetwork.Pass.Leveling; | using DysonNetwork.Pass.Leveling; | ||||||
| using DysonNetwork.Pass.Permission; | using DysonNetwork.Pass.Permission; | ||||||
|  | using DysonNetwork.Pass.Wallet; | ||||||
|  | using DysonNetwork.Shared.Http; | ||||||
| using Microsoft.AspNetCore.HttpOverrides; | using Microsoft.AspNetCore.HttpOverrides; | ||||||
| using Microsoft.Extensions.FileProviders; | using Microsoft.Extensions.FileProviders; | ||||||
| using Prometheus; | using Prometheus; | ||||||
| @@ -22,7 +24,7 @@ public static class ApplicationConfiguration | |||||||
|  |  | ||||||
|         app.UseRequestLocalization(); |         app.UseRequestLocalization(); | ||||||
|  |  | ||||||
|         ConfigureForwardedHeaders(app, configuration); |         app.ConfigureForwardedHeaders(configuration); | ||||||
|  |  | ||||||
|         app.UseCors(opts => |         app.UseCors(opts => | ||||||
|             opts.SetIsOriginAllowed(_ => true) |             opts.SetIsOriginAllowed(_ => true) | ||||||
| @@ -50,28 +52,6 @@ public static class ApplicationConfiguration | |||||||
|         return app; |         return app; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private static void ConfigureForwardedHeaders(WebApplication app, IConfiguration configuration) |  | ||||||
|     { |  | ||||||
|         var knownProxiesSection = configuration.GetSection("KnownProxies"); |  | ||||||
|         var forwardedHeadersOptions = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All }; |  | ||||||
|  |  | ||||||
|         if (knownProxiesSection.Exists()) |  | ||||||
|         { |  | ||||||
|             var proxyAddresses = knownProxiesSection.Get<string[]>(); |  | ||||||
|             if (proxyAddresses != null) |  | ||||||
|                 foreach (var proxy in proxyAddresses) |  | ||||||
|                     if (IPAddress.TryParse(proxy, out var ipAddress)) |  | ||||||
|                         forwardedHeadersOptions.KnownProxies.Add(ipAddress); |  | ||||||
|         } |  | ||||||
|         else |  | ||||||
|         { |  | ||||||
|             forwardedHeadersOptions.KnownProxies.Add(IPAddress.Any); |  | ||||||
|             forwardedHeadersOptions.KnownProxies.Add(IPAddress.IPv6Any); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         app.UseForwardedHeaders(forwardedHeadersOptions); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public static WebApplication ConfigureGrpcServices(this WebApplication app) |     public static WebApplication ConfigureGrpcServices(this WebApplication app) | ||||||
|     { |     { | ||||||
|         app.MapGrpcService<AccountServiceGrpc>(); |         app.MapGrpcService<AccountServiceGrpc>(); | ||||||
| @@ -81,6 +61,8 @@ public static class ApplicationConfiguration | |||||||
|         app.MapGrpcService<SocialCreditServiceGrpc>(); |         app.MapGrpcService<SocialCreditServiceGrpc>(); | ||||||
|         app.MapGrpcService<ExperienceServiceGrpc>(); |         app.MapGrpcService<ExperienceServiceGrpc>(); | ||||||
|         app.MapGrpcService<BotAccountReceiverGrpc>(); |         app.MapGrpcService<BotAccountReceiverGrpc>(); | ||||||
|  |         app.MapGrpcService<WalletServiceGrpc>(); | ||||||
|  |         app.MapGrpcService<PaymentServiceGrpc>(); | ||||||
|  |  | ||||||
|         return app; |         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 NodaTime.Serialization.SystemTextJson; | ||||||
| using StackExchange.Redis; | using StackExchange.Redis; | ||||||
| using System.Text.Json; | using System.Text.Json; | ||||||
|  | using System.Text.Json.Serialization; | ||||||
| using System.Threading.RateLimiting; | using System.Threading.RateLimiting; | ||||||
| using DysonNetwork.Pass.Auth.OidcProvider.Options; | using DysonNetwork.Pass.Auth.OidcProvider.Options; | ||||||
| using DysonNetwork.Pass.Auth.OidcProvider.Services; | using DysonNetwork.Pass.Auth.OidcProvider.Services; | ||||||
| @@ -33,11 +34,6 @@ public static class ServiceCollectionExtensions | |||||||
|         services.AddLocalization(options => options.ResourcesPath = "Resources"); |         services.AddLocalization(options => options.ResourcesPath = "Resources"); | ||||||
|  |  | ||||||
|         services.AddDbContext<AppDatabase>(); |         services.AddDbContext<AppDatabase>(); | ||||||
|         services.AddSingleton<IConnectionMultiplexer>(_ => |  | ||||||
|         { |  | ||||||
|             var connection = configuration.GetConnectionString("FastRetrieve")!; |  | ||||||
|             return ConnectionMultiplexer.Connect(connection); |  | ||||||
|         }); |  | ||||||
|         services.AddSingleton<IClock>(SystemClock.Instance); |         services.AddSingleton<IClock>(SystemClock.Instance); | ||||||
|         services.AddHttpContextAccessor(); |         services.AddHttpContextAccessor(); | ||||||
|         services.AddSingleton<ICacheService, CacheServiceRedis>(); |         services.AddSingleton<ICacheService, CacheServiceRedis>(); | ||||||
| @@ -52,11 +48,7 @@ public static class ServiceCollectionExtensions | |||||||
|             options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB |             options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|         services.AddPusherService(); |         services.AddRingService(); | ||||||
|          |  | ||||||
|         // Register gRPC services |  | ||||||
|         services.AddScoped<AccountServiceGrpc>(); |  | ||||||
|         services.AddScoped<AuthServiceGrpc>(); |  | ||||||
|  |  | ||||||
|         // Register OIDC services |         // Register OIDC services | ||||||
|         services.AddScoped<OidcService, GoogleOidcService>(); |         services.AddScoped<OidcService, GoogleOidcService>(); | ||||||
| @@ -74,6 +66,7 @@ public static class ServiceCollectionExtensions | |||||||
|  |  | ||||||
|         services.AddControllers().AddJsonOptions(options => |         services.AddControllers().AddJsonOptions(options => | ||||||
|         { |         { | ||||||
|  |             options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals; | ||||||
|             options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; |             options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; | ||||||
|             options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower; |             options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower; | ||||||
|  |  | ||||||
| @@ -136,7 +129,8 @@ public static class ServiceCollectionExtensions | |||||||
|             { |             { | ||||||
|                 Version = "v1", |                 Version = "v1", | ||||||
|                 Title = "Dyson Pass", |                 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"), |                 TermsOfService = new Uri("https://solsynth.dev/terms"), | ||||||
|                 License = new OpenApiLicense |                 License = new OpenApiLicense | ||||||
|                 { |                 { | ||||||
| @@ -194,6 +188,7 @@ public static class ServiceCollectionExtensions | |||||||
|         services.AddScoped<ActionLogService>(); |         services.AddScoped<ActionLogService>(); | ||||||
|         services.AddScoped<AccountService>(); |         services.AddScoped<AccountService>(); | ||||||
|         services.AddScoped<AccountEventService>(); |         services.AddScoped<AccountEventService>(); | ||||||
|  |         services.AddScoped<NotableDaysService>(); | ||||||
|         services.AddScoped<ActionLogService>(); |         services.AddScoped<ActionLogService>(); | ||||||
|         services.AddScoped<RelationshipService>(); |         services.AddScoped<RelationshipService>(); | ||||||
|         services.AddScoped<MagicSpellService>(); |         services.AddScoped<MagicSpellService>(); | ||||||
| @@ -211,6 +206,8 @@ public static class ServiceCollectionExtensions | |||||||
|         services.Configure<OidcProviderOptions>(configuration.GetSection("OidcProvider")); |         services.Configure<OidcProviderOptions>(configuration.GetSection("OidcProvider")); | ||||||
|         services.AddScoped<OidcProviderService>(); |         services.AddScoped<OidcProviderService>(); | ||||||
|          |          | ||||||
|  |         services.AddHostedService<BroadcastEventHandler>(); | ||||||
|  |  | ||||||
|         return services; |         return services; | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -15,9 +15,7 @@ public class OrderController(PaymentService payment, AuthService auth, AppDataba | |||||||
|         var order = await db.PaymentOrders.FindAsync(id); |         var order = await db.PaymentOrders.FindAsync(id); | ||||||
|          |          | ||||||
|         if (order == null) |         if (order == null) | ||||||
|         { |  | ||||||
|             return NotFound(); |             return NotFound(); | ||||||
|         } |  | ||||||
|          |          | ||||||
|         return Ok(order); |         return Ok(order); | ||||||
|     } |     } | ||||||
| @@ -26,8 +24,7 @@ public class OrderController(PaymentService payment, AuthService auth, AppDataba | |||||||
|     [Authorize] |     [Authorize] | ||||||
|     public async Task<ActionResult<Order>> PayOrder(Guid id, [FromBody] PayOrderRequest request) |     public async Task<ActionResult<Order>> PayOrder(Guid id, [FromBody] PayOrderRequest request) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser || |         if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); | ||||||
|             HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized(); |  | ||||||
|      |      | ||||||
|         // Validate PIN code |         // Validate PIN code | ||||||
|         if (!await auth.ValidatePinCode(currentUser.Id, request.PinCode)) |         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."); |                 return BadRequest("Wallet was not found."); | ||||||
|          |          | ||||||
|             // Pay the order |             // Pay the order | ||||||
|             var paidOrder = await payment.PayOrderAsync(id, wallet.Id); |             var paidOrder = await payment.PayOrderAsync(id, wallet); | ||||||
|             return Ok(paidOrder); |             return Ok(paidOrder); | ||||||
|         } |         } | ||||||
|         catch (InvalidOperationException ex) |         catch (InvalidOperationException ex) | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| using System.ComponentModel.DataAnnotations; | using System.ComponentModel.DataAnnotations; | ||||||
| using System.ComponentModel.DataAnnotations.Schema; | using System.ComponentModel.DataAnnotations.Schema; | ||||||
|  | using System.Globalization; | ||||||
| using DysonNetwork.Shared.Data; | using DysonNetwork.Shared.Data; | ||||||
| using NodaTime; | using NodaTime; | ||||||
| using NodaTime.Serialization.Protobuf; | using NodaTime.Serialization.Protobuf; | ||||||
| @@ -23,11 +24,14 @@ public enum OrderStatus | |||||||
|  |  | ||||||
| public class Order : ModelBase | public class Order : ModelBase | ||||||
| { | { | ||||||
|  |     public const string InternalAppIdentifier = "internal"; | ||||||
|  |  | ||||||
|     public Guid Id { get; set; } = Guid.NewGuid(); |     public Guid Id { get; set; } = Guid.NewGuid(); | ||||||
|     public OrderStatus Status { get; set; } = OrderStatus.Unpaid; |     public OrderStatus Status { get; set; } = OrderStatus.Unpaid; | ||||||
|     [MaxLength(128)] public string Currency { get; set; } = null!; |     [MaxLength(128)] public string Currency { get; set; } = null!; | ||||||
|     [MaxLength(4096)] public string? Remarks { get; set; } |     [MaxLength(4096)] public string? Remarks { get; set; } | ||||||
|     [MaxLength(4096)] public string? AppIdentifier { 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; } |     [Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; } | ||||||
|     public decimal Amount { get; set; } |     public decimal Amount { get; set; } | ||||||
|     public Instant ExpiredAt { get; set; } |     public Instant ExpiredAt { get; set; } | ||||||
| @@ -44,10 +48,11 @@ public class Order : ModelBase | |||||||
|         Currency = Currency, |         Currency = Currency, | ||||||
|         Remarks = Remarks, |         Remarks = Remarks, | ||||||
|         AppIdentifier = AppIdentifier, |         AppIdentifier = AppIdentifier, | ||||||
|  |         ProductIdentifier = ProductIdentifier, | ||||||
|         Meta = Meta == null |         Meta = Meta == null | ||||||
|             ? null |             ? null | ||||||
|             : Google.Protobuf.ByteString.CopyFrom(System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(Meta)), |             : Google.Protobuf.ByteString.CopyFrom(System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(Meta)), | ||||||
|         Amount = Amount.ToString(), |         Amount = Amount.ToString(CultureInfo.InvariantCulture), | ||||||
|         ExpiredAt = ExpiredAt.ToTimestamp(), |         ExpiredAt = ExpiredAt.ToTimestamp(), | ||||||
|         PayeeWalletId = PayeeWalletId?.ToString(), |         PayeeWalletId = PayeeWalletId?.ToString(), | ||||||
|         TransactionId = TransactionId?.ToString(), |         TransactionId = TransactionId?.ToString(), | ||||||
| @@ -61,13 +66,14 @@ public class Order : ModelBase | |||||||
|         Currency = proto.Currency, |         Currency = proto.Currency, | ||||||
|         Remarks = proto.Remarks, |         Remarks = proto.Remarks, | ||||||
|         AppIdentifier = proto.AppIdentifier, |         AppIdentifier = proto.AppIdentifier, | ||||||
|  |         ProductIdentifier = proto.ProductIdentifier, | ||||||
|         Meta = proto.HasMeta |         Meta = proto.HasMeta | ||||||
|             ? System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(proto.Meta.ToByteArray()) |             ? System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(proto.Meta.ToByteArray()) | ||||||
|             : null, |             : null, | ||||||
|         Amount = decimal.Parse(proto.Amount), |         Amount = decimal.Parse(proto.Amount), | ||||||
|         ExpiredAt = proto.ExpiredAt.ToInstant(), |         ExpiredAt = proto.ExpiredAt.ToInstant(), | ||||||
|         PayeeWalletId = proto.HasPayeeWalletId ? Guid.Parse(proto.PayeeWalletId) : null, |         PayeeWalletId = proto.PayeeWalletId is not null ? Guid.Parse(proto.PayeeWalletId) : null, | ||||||
|         TransactionId = proto.HasTransactionId ? Guid.Parse(proto.TransactionId) : null, |         TransactionId = proto.TransactionId is not null ? Guid.Parse(proto.TransactionId) : null, | ||||||
|         Transaction = proto.Transaction is not null ? Transaction.FromProtoValue(proto.Transaction) : null, |         Transaction = proto.Transaction is not null ? Transaction.FromProtoValue(proto.Transaction) : null, | ||||||
|     }; |     }; | ||||||
| } | } | ||||||
| @@ -100,7 +106,7 @@ public class Transaction : ModelBase | |||||||
|     { |     { | ||||||
|         Id = Id.ToString(), |         Id = Id.ToString(), | ||||||
|         Currency = Currency, |         Currency = Currency, | ||||||
|         Amount = Amount.ToString(), |         Amount = Amount.ToString(CultureInfo.InvariantCulture), | ||||||
|         Remarks = Remarks, |         Remarks = Remarks, | ||||||
|         Type = (Shared.Proto.TransactionType)Type, |         Type = (Shared.Proto.TransactionType)Type, | ||||||
|         PayerWalletId = PayerWalletId?.ToString(), |         PayerWalletId = PayerWalletId?.ToString(), | ||||||
| @@ -114,7 +120,7 @@ public class Transaction : ModelBase | |||||||
|         Amount = decimal.Parse(proto.Amount), |         Amount = decimal.Parse(proto.Amount), | ||||||
|         Remarks = proto.Remarks, |         Remarks = proto.Remarks, | ||||||
|         Type = (TransactionType)proto.Type, |         Type = (TransactionType)proto.Type, | ||||||
|         PayerWalletId = proto.HasPayerWalletId ? Guid.Parse(proto.PayerWalletId) : null, |         PayerWalletId = proto.PayerWalletId is not null ? Guid.Parse(proto.PayerWalletId) : null, | ||||||
|         PayeeWalletId = proto.HasPayeeWalletId ? Guid.Parse(proto.PayeeWalletId) : null, |         PayeeWalletId = proto.PayeeWalletId is not null ? Guid.Parse(proto.PayeeWalletId) : null, | ||||||
|     }; |     }; | ||||||
| } | } | ||||||
| @@ -1,9 +1,14 @@ | |||||||
| using System.Globalization; | using System.Globalization; | ||||||
|  | using System.Text.Json; | ||||||
| using DysonNetwork.Pass.Localization; | using DysonNetwork.Pass.Localization; | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
|  | using DysonNetwork.Shared.Stream; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.EntityFrameworkCore.Storage; | using Microsoft.EntityFrameworkCore.Storage; | ||||||
| using Microsoft.Extensions.Localization; | using Microsoft.Extensions.Localization; | ||||||
|  | using NATS.Client.Core; | ||||||
|  | using NATS.Client.JetStream; | ||||||
|  | using NATS.Net; | ||||||
| using NodaTime; | using NodaTime; | ||||||
| using AccountService = DysonNetwork.Pass.Account.AccountService; | using AccountService = DysonNetwork.Pass.Account.AccountService; | ||||||
|  |  | ||||||
| @@ -12,8 +17,9 @@ namespace DysonNetwork.Pass.Wallet; | |||||||
| public class PaymentService( | public class PaymentService( | ||||||
|     AppDatabase db, |     AppDatabase db, | ||||||
|     WalletService wat, |     WalletService wat, | ||||||
|     PusherService.PusherServiceClient pusher, |     RingService.RingServiceClient pusher, | ||||||
|     IStringLocalizer<NotificationResource> localizer |     IStringLocalizer<NotificationResource> localizer, | ||||||
|  |     INatsConnection nats | ||||||
| ) | ) | ||||||
| { | { | ||||||
|     public async Task<Order> CreateOrderAsync( |     public async Task<Order> CreateOrderAsync( | ||||||
| @@ -22,6 +28,8 @@ public class PaymentService( | |||||||
|         decimal amount, |         decimal amount, | ||||||
|         Duration? expiration = null, |         Duration? expiration = null, | ||||||
|         string? appIdentifier = null, |         string? appIdentifier = null, | ||||||
|  |         string? productIdentifier = null, | ||||||
|  |         string? remarks = null, | ||||||
|         Dictionary<string, object>? meta = null, |         Dictionary<string, object>? meta = null, | ||||||
|         bool reuseable = true |         bool reuseable = true | ||||||
|     ) |     ) | ||||||
| @@ -29,26 +37,25 @@ public class PaymentService( | |||||||
|         // Check if there's an existing unpaid order that can be reused |         // Check if there's an existing unpaid order that can be reused | ||||||
|         if (reuseable && appIdentifier != null) |         if (reuseable && appIdentifier != null) | ||||||
|         { |         { | ||||||
|  |             var now = SystemClock.Instance.GetCurrentInstant(); | ||||||
|             var existingOrder = await db.PaymentOrders |             var existingOrder = await db.PaymentOrders | ||||||
|                 .Where(o => o.Status == OrderStatus.Unpaid && |                 .Where(o => o.Status == OrderStatus.Unpaid && | ||||||
|                             o.PayeeWalletId == payeeWalletId && |                             o.PayeeWalletId == payeeWalletId && | ||||||
|                             o.Currency == currency && |                             o.Currency == currency && | ||||||
|                             o.Amount == amount && |                             o.Amount == amount && | ||||||
|                             o.AppIdentifier == appIdentifier && |                             o.AppIdentifier == appIdentifier && | ||||||
|                             o.ExpiredAt > SystemClock.Instance.GetCurrentInstant()) |                             o.ProductIdentifier == productIdentifier && | ||||||
|  |                             o.ExpiredAt > now) | ||||||
|                 .FirstOrDefaultAsync(); |                 .FirstOrDefaultAsync(); | ||||||
|  |  | ||||||
|             // If an existing order is found, check if meta matches |             // If an existing order is found, check if meta matches | ||||||
|             if (existingOrder != null && meta != null && existingOrder.Meta != null) |             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 && |                 var metaMatches = existingOrder.Meta.Count == meta.Count && | ||||||
|                                   !existingOrder.Meta.Except(meta).Any(); |                                   !existingOrder.Meta.Except(meta).Any(); | ||||||
|  |  | ||||||
|                 if (metaMatches) |                 if (metaMatches) | ||||||
|                 { |  | ||||||
|                     return existingOrder; |                     return existingOrder; | ||||||
|                 } |  | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -60,6 +67,8 @@ public class PaymentService( | |||||||
|             Amount = amount, |             Amount = amount, | ||||||
|             ExpiredAt = SystemClock.Instance.GetCurrentInstant().Plus(expiration ?? Duration.FromHours(24)), |             ExpiredAt = SystemClock.Instance.GetCurrentInstant().Plus(expiration ?? Duration.FromHours(24)), | ||||||
|             AppIdentifier = appIdentifier, |             AppIdentifier = appIdentifier, | ||||||
|  |             ProductIdentifier = productIdentifier, | ||||||
|  |             Remarks = remarks, | ||||||
|             Meta = meta |             Meta = meta | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
| @@ -104,7 +113,8 @@ public class PaymentService( | |||||||
|         string currency, |         string currency, | ||||||
|         decimal amount, |         decimal amount, | ||||||
|         string? remarks = null, |         string? remarks = null, | ||||||
|         TransactionType type = TransactionType.System |         TransactionType type = TransactionType.System, | ||||||
|  |         bool silent = false | ||||||
|     ) |     ) | ||||||
|     { |     { | ||||||
|         if (payerWalletId == null && payeeWalletId == null) |         if (payerWalletId == null && payeeWalletId == null) | ||||||
| @@ -121,8 +131,12 @@ public class PaymentService( | |||||||
|             Type = type |             Type = type | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|  |         Wallet? payerWallet = null, payeeWallet = null; | ||||||
|  |  | ||||||
|         if (payerWalletId.HasValue) |         if (payerWalletId.HasValue) | ||||||
|         { |         { | ||||||
|  |             payerWallet = await db.Wallets.FirstOrDefaultAsync(e => e.AccountId == payerWalletId.Value); | ||||||
|  |  | ||||||
|             var (payerPocket, isNewlyCreated) = |             var (payerPocket, isNewlyCreated) = | ||||||
|                 await wat.GetOrCreateWalletPocketAsync(payerWalletId.Value, currency); |                 await wat.GetOrCreateWalletPocketAsync(payerWalletId.Value, currency); | ||||||
|  |  | ||||||
| @@ -137,6 +151,8 @@ public class PaymentService( | |||||||
|  |  | ||||||
|         if (payeeWalletId.HasValue) |         if (payeeWalletId.HasValue) | ||||||
|         { |         { | ||||||
|  |             payeeWallet = await db.Wallets.FirstOrDefaultAsync(e => e.AccountId == payeeWalletId.Value); | ||||||
|  |  | ||||||
|             var (payeePocket, isNewlyCreated) = |             var (payeePocket, isNewlyCreated) = | ||||||
|                 await wat.GetOrCreateWalletPocketAsync(payeeWalletId.Value, currency, amount); |                 await wat.GetOrCreateWalletPocketAsync(payeeWalletId.Value, currency, amount); | ||||||
|  |  | ||||||
| @@ -149,13 +165,89 @@ public class PaymentService( | |||||||
|  |  | ||||||
|         db.PaymentTransactions.Add(transaction); |         db.PaymentTransactions.Add(transaction); | ||||||
|         await db.SaveChangesAsync(); |         await db.SaveChangesAsync(); | ||||||
|  |  | ||||||
|  |         if (!silent) | ||||||
|  |             await NotifyNewTransaction(transaction, payerWallet, payeeWallet); | ||||||
|  |  | ||||||
|         return transaction; |         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 |         var order = await db.PaymentOrders | ||||||
|             .Include(o => o.Transaction) |             .Include(o => o.Transaction) | ||||||
|  |             .Include(o => o.PayeeWallet) | ||||||
|             .FirstOrDefaultAsync(o => o.Id == orderId); |             .FirstOrDefaultAsync(o => o.Id == orderId); | ||||||
|  |  | ||||||
|         if (order == null) |         if (order == null) | ||||||
| @@ -163,6 +255,27 @@ public class PaymentService( | |||||||
|             throw new InvalidOperationException("Order not found"); |             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) |         if (order.Status != OrderStatus.Unpaid) | ||||||
|         { |         { | ||||||
|             throw new InvalidOperationException($"Order is in invalid status: {order.Status}"); |             throw new InvalidOperationException($"Order is in invalid status: {order.Status}"); | ||||||
| @@ -176,12 +289,13 @@ public class PaymentService( | |||||||
|         } |         } | ||||||
|  |  | ||||||
|         var transaction = await CreateTransactionAsync( |         var transaction = await CreateTransactionAsync( | ||||||
|             payerWalletId, |             payerWallet.Id, | ||||||
|             order.PayeeWalletId, |             order.PayeeWalletId, | ||||||
|             order.Currency, |             order.Currency, | ||||||
|             order.Amount, |             order.Amount, | ||||||
|             order.Remarks ?? $"Payment for Order #{order.Id}", |             order.Remarks ?? $"Payment for Order #{order.Id}", | ||||||
|             type: TransactionType.Order); |             type: TransactionType.Order, | ||||||
|  |             silent: true); | ||||||
|  |  | ||||||
|         order.TransactionId = transaction.Id; |         order.TransactionId = transaction.Id; | ||||||
|         order.Transaction = transaction; |         order.Transaction = transaction; | ||||||
| @@ -189,38 +303,87 @@ public class PaymentService( | |||||||
|  |  | ||||||
|         await db.SaveChangesAsync(); |         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; |         return order; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private async Task NotifyOrderPaid(Order order) |     private async Task NotifyOrderPaid(Order order, Wallet? payerWallet, Wallet? payeeWallet) | ||||||
|     { |     { | ||||||
|         if (order.PayeeWallet is null) return; |         if (payerWallet is not null) | ||||||
|         var account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == order.PayeeWallet.AccountId); |         { | ||||||
|         if (account is null) return; |             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 |             // Due to ID is uuid, it longer than 8 words for sure | ||||||
|         var readableOrderId = order.Id.ToString().Replace("-", "")[..8]; |             var readableOrderId = order.Id.ToString().Replace("-", "")[..8]; | ||||||
|         var readableOrderRemark = order.Remarks ?? $"#{readableOrderId}"; |             var readableOrderRemark = order.Remarks ?? $"#{readableOrderId}"; | ||||||
|  |  | ||||||
|  |  | ||||||
|         await pusher.SendPushNotificationToUserAsync( |             await pusher.SendPushNotificationToUserAsync( | ||||||
|             new SendPushNotificationToUserRequest |                 new SendPushNotificationToUserRequest | ||||||
|             { |  | ||||||
|                 UserId = account.Id.ToString(), |  | ||||||
|                 Notification = new PushNotification |  | ||||||
|                 { |                 { | ||||||
|                     Topic = "wallets.orders.paid", |                     UserId = account.Id.ToString(), | ||||||
|                     Title = localizer["OrderPaidTitle", $"#{readableOrderId}"], |                     Notification = new PushNotification | ||||||
|                     Body = localizer["OrderPaidBody", order.Amount.ToString(CultureInfo.InvariantCulture), order.Currency, |                     { | ||||||
|                         readableOrderRemark], |                         Topic = "wallets.orders.paid", | ||||||
|                     IsSavable = true |                         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) |     public async Task<Order> CancelOrderAsync(Guid orderId) | ||||||
|   | |||||||
| @@ -13,10 +13,11 @@ public class PaymentServiceGrpc(PaymentService paymentService) : Shared.Proto.Pa | |||||||
|             request.Currency, |             request.Currency, | ||||||
|             decimal.Parse(request.Amount), |             decimal.Parse(request.Amount), | ||||||
|             request.Expiration is not null ? Duration.FromSeconds(request.Expiration.Seconds) : null, |             request.Expiration is not null ? Duration.FromSeconds(request.Expiration.Seconds) : null, | ||||||
|             request.HasAppIdentifier ? request.AppIdentifier : null, |             request.HasAppIdentifier ? request.AppIdentifier : Order.InternalAppIdentifier, | ||||||
|             // Assuming meta is a JSON string |             request.HasProductIdentifier ? request.ProductIdentifier : null, | ||||||
|  |             request.HasRemarks ? request.Remarks : null, | ||||||
|             request.HasMeta |             request.HasMeta | ||||||
|                 ? System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(request.Meta.ToStringUtf8()) |                 ? GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object>>(request.Meta) | ||||||
|                 : null, |                 : null, | ||||||
|             request.Reuseable |             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 |     public class RestorePurchaseRequest | ||||||
|     { |     { | ||||||
|         [Required] public string OrderId { get; set; } = null!; |         [Required] public string OrderId { get; set; } = null!; | ||||||
|   | |||||||
| @@ -68,7 +68,8 @@ public class SubscriptionRenewalJob( | |||||||
|                     null, |                     null, | ||||||
|                     WalletCurrency.GoldenPoint, |                     WalletCurrency.GoldenPoint, | ||||||
|                     subscription.FinalPrice, |                     subscription.FinalPrice, | ||||||
|                     appIdentifier: SubscriptionService.SubscriptionOrderIdentifier, |                     appIdentifier: "internal", | ||||||
|  |                     productIdentifier: subscription.Identifier, | ||||||
|                     meta: new Dictionary<string, object>() |                     meta: new Dictionary<string, object>() | ||||||
|                     { |                     { | ||||||
|                         ["subscription_id"] = subscription.Id.ToString(), |                         ["subscription_id"] = subscription.Id.ToString(), | ||||||
| @@ -86,7 +87,7 @@ public class SubscriptionRenewalJob( | |||||||
|                         if (wallet is null) continue; |                         if (wallet is null) continue; | ||||||
|  |  | ||||||
|                         // Process automatic payment from wallet |                         // Process automatic payment from wallet | ||||||
|                         await paymentService.PayOrderAsync(order.Id, wallet.Id); |                         await paymentService.PayOrderAsync(order.Id, wallet); | ||||||
|  |  | ||||||
|                         // Update subscription details |                         // Update subscription details | ||||||
|                         subscription.BegunAt = subscription.EndedAt!.Value; |                         subscription.BegunAt = subscription.EndedAt!.Value; | ||||||
|   | |||||||
| @@ -18,7 +18,7 @@ public class SubscriptionService( | |||||||
|     AppDatabase db, |     AppDatabase db, | ||||||
|     PaymentService payment, |     PaymentService payment, | ||||||
|     AccountService accounts, |     AccountService accounts, | ||||||
|     PusherService.PusherServiceClient pusher, |     RingService.RingServiceClient pusher, | ||||||
|     IStringLocalizer<NotificationResource> localizer, |     IStringLocalizer<NotificationResource> localizer, | ||||||
|     IConfiguration configuration, |     IConfiguration configuration, | ||||||
|     ICacheService cache, |     ICacheService cache, | ||||||
| @@ -229,8 +229,6 @@ public class SubscriptionService( | |||||||
|         return subscription; |         return subscription; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public const string SubscriptionOrderIdentifier = "solian.subscription.order"; |  | ||||||
|  |  | ||||||
|     /// <summary> |     /// <summary> | ||||||
|     /// Creates a subscription order for an unpaid or expired subscription. |     /// Creates a subscription order for an unpaid or expired subscription. | ||||||
|     /// If the subscription is active, it will extend its expiration date. |     /// If the subscription is active, it will extend its expiration date. | ||||||
| @@ -259,7 +257,8 @@ public class SubscriptionService( | |||||||
|             null, |             null, | ||||||
|             subscriptionInfo.Currency, |             subscriptionInfo.Currency, | ||||||
|             subscription.FinalPrice, |             subscription.FinalPrice, | ||||||
|             appIdentifier: SubscriptionOrderIdentifier, |             appIdentifier: "internal", | ||||||
|  |             productIdentifier: identifier, | ||||||
|             meta: new Dictionary<string, object>() |             meta: new Dictionary<string, object>() | ||||||
|             { |             { | ||||||
|                 ["subscription_id"] = subscription.Id.ToString(), |                 ["subscription_id"] = subscription.Id.ToString(), | ||||||
| @@ -270,8 +269,7 @@ public class SubscriptionService( | |||||||
|  |  | ||||||
|     public async Task<Subscription> HandleSubscriptionOrder(Order order) |     public async Task<Subscription> HandleSubscriptionOrder(Order order) | ||||||
|     { |     { | ||||||
|         if (order.AppIdentifier != SubscriptionOrderIdentifier || order.Status != OrderStatus.Paid || |         if (order.Status != OrderStatus.Paid || order.Meta?["subscription_id"] is not JsonElement subscriptionIdJson) | ||||||
|             order.Meta?["subscription_id"] is not JsonElement subscriptionIdJson) |  | ||||||
|             throw new InvalidOperationException("Invalid order."); |             throw new InvalidOperationException("Invalid order."); | ||||||
|  |  | ||||||
|         var subscriptionId = Guid.TryParse(subscriptionIdJson.ToString(), out var parsedSubscriptionId) |         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(); |         if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         var query = db.PaymentTransactions.AsQueryable() |         var accountWallet = await db.Wallets.Where(w => w.AccountId == currentUser.Id).FirstOrDefaultAsync(); | ||||||
|             .Include(t => t.PayeeWallet) |         if (accountWallet is null) return NotFound(); | ||||||
|             .Include(t => t.PayerWallet) |  | ||||||
|             .Where(t => (t.PayeeWallet != null && t.PayeeWallet.AccountId == currentUser.Id) || |         var query = db.PaymentTransactions | ||||||
|                         (t.PayerWallet != null && t.PayerWallet.AccountId == currentUser.Id)); |             .Where(t => t.PayeeWalletId == accountWallet.Id || t.PayerWalletId == accountWallet.Id) | ||||||
|  |             .OrderByDescending(t => t.CreatedAt) | ||||||
|  |             .AsQueryable(); | ||||||
|  |  | ||||||
|         var transactionCount = await query.CountAsync(); |         var transactionCount = await query.CountAsync(); | ||||||
|  |         Response.Headers["X-Total"] = transactionCount.ToString(); | ||||||
|  |          | ||||||
|         var transactions = await query |         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) |             .Skip(offset) | ||||||
|             .Take(take) |             .Take(take) | ||||||
|             .OrderByDescending(t => t.CreatedAt) |             .OrderByDescending(t => t.CreatedAt) | ||||||
|             .ToListAsync(); |             .ToListAsync(); | ||||||
|  |  | ||||||
|         Response.Headers["X-Total"] = transactionCount.ToString(); |         return Ok(orders); | ||||||
|  |  | ||||||
|         return Ok(transactions); |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public class WalletBalanceRequest |     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) |     public override async Task<Shared.Proto.Wallet> GetWallet(GetWalletRequest request, ServerCallContext context) | ||||||
|     { |     { | ||||||
|         var wallet = await walletService.GetWalletAsync(Guid.Parse(request.AccountId)); |         var wallet = await walletService.GetWalletAsync(Guid.Parse(request.AccountId)); | ||||||
|         if (wallet == null) |         return wallet == null ? throw new RpcException(new Status(StatusCode.NotFound, "Wallet not found.")) : wallet.ToProtoValue(); | ||||||
|         { |  | ||||||
|             throw new RpcException(new Status(StatusCode.NotFound, "Wallet not found.")); |  | ||||||
|         } |  | ||||||
|         return wallet.ToProtoValue(); |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public override async Task<Shared.Proto.Wallet> CreateWallet(CreateWalletRequest request, ServerCallContext context) |     public override async Task<Shared.Proto.Wallet> CreateWallet(CreateWalletRequest request, ServerCallContext context) | ||||||
|   | |||||||
| @@ -10,10 +10,7 @@ | |||||||
|   }, |   }, | ||||||
|   "AllowedHosts": "*", |   "AllowedHosts": "*", | ||||||
|   "ConnectionStrings": { |   "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", |     "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" |  | ||||||
|   }, |   }, | ||||||
|   "Authentication": { |   "Authentication": { | ||||||
|     "Schemes": { |     "Schemes": { | ||||||
| @@ -83,9 +80,7 @@ | |||||||
|   ], |   ], | ||||||
|   "Service": { |   "Service": { | ||||||
|     "Name": "DysonNetwork.Pass", |     "Name": "DysonNetwork.Pass", | ||||||
|     "Url": "https://localhost:7058", |     "Url": "https://localhost:7058" | ||||||
|     "ClientCert": "../Certificates/client.crt", |  | ||||||
|     "ClientKey": "../Certificates/client.key" |  | ||||||
|   }, |   }, | ||||||
|   "Etcd": { |   "Etcd": { | ||||||
|     "Insecure": true |     "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.Linq.Expressions; | ||||||
| using System.Reflection; | using System.Reflection; | ||||||
| using DysonNetwork.Pusher.Notification; | using DysonNetwork.Ring.Notification; | ||||||
| using DysonNetwork.Shared.Data; | using DysonNetwork.Shared.Data; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.EntityFrameworkCore.Design; | using Microsoft.EntityFrameworkCore.Design; | ||||||
| @@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Query; | |||||||
| using NodaTime; | using NodaTime; | ||||||
| using Quartz; | using Quartz; | ||||||
| 
 | 
 | ||||||
| namespace DysonNetwork.Pusher; | namespace DysonNetwork.Ring; | ||||||
| 
 | 
 | ||||||
| public class AppDatabase( | public class AppDatabase( | ||||||
|     DbContextOptions<AppDatabase> options, |     DbContextOptions<AppDatabase> options, | ||||||
| @@ -1,4 +1,4 @@ | |||||||
| namespace DysonNetwork.Pusher.Connection; | namespace DysonNetwork.Ring.Connection; | ||||||
| 
 | 
 | ||||||
| public class ClientTypeMiddleware(RequestDelegate next) | public class ClientTypeMiddleware(RequestDelegate next) | ||||||
| { | { | ||||||
| @@ -1,7 +1,7 @@ | |||||||
| using System.Net.WebSockets; | using System.Net.WebSockets; | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
| 
 | 
 | ||||||
| namespace DysonNetwork.Pusher.Connection; | namespace DysonNetwork.Ring.Connection; | ||||||
| 
 | 
 | ||||||
| public interface IWebSocketPacketHandler | public interface IWebSocketPacketHandler | ||||||
| { | { | ||||||
| @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Authorization; | |||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| using Swashbuckle.AspNetCore.Annotations; | using Swashbuckle.AspNetCore.Annotations; | ||||||
| 
 | 
 | ||||||
| namespace DysonNetwork.Pusher.Connection; | namespace DysonNetwork.Ring.Connection; | ||||||
| 
 | 
 | ||||||
| [ApiController] | [ApiController] | ||||||
| public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext> logger) : ControllerBase | 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 accountId = currentUser.Id!; | ||||||
|         var deviceId = currentSession.Challenge.DeviceId!; |         var deviceId = currentSession.Challenge?.DeviceId ?? Guid.NewGuid().ToString(); | ||||||
| 
 | 
 | ||||||
|         if (string.IsNullOrEmpty(deviceId)) |         if (string.IsNullOrEmpty(deviceId)) | ||||||
|         { |         { | ||||||
| @@ -32,7 +32,8 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext> | |||||||
|             return; |             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 cts = new CancellationTokenSource(); | ||||||
|         var connectionKey = (accountId, deviceId); |         var connectionKey = (accountId, deviceId); | ||||||
| 
 | 
 | ||||||
| @@ -65,7 +66,12 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext> | |||||||
|         } |         } | ||||||
|         catch (Exception ex) |         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 |         finally | ||||||
|         { |         { | ||||||
| @@ -99,7 +105,7 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext> | |||||||
|                     break; |                     break; | ||||||
| 
 | 
 | ||||||
|                 var packet = WebSocketPacket.FromBytes(buffer[..receiveResult.Count]); |                 var packet = WebSocketPacket.FromBytes(buffer[..receiveResult.Count]); | ||||||
|                 _ = ws.HandlePacket(currentUser, connectionKey.DeviceId, packet, webSocket); |                 await ws.HandlePacket(currentUser, connectionKey.DeviceId, packet, webSocket); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|         catch (OperationCanceledException) |         catch (OperationCanceledException) | ||||||
| @@ -4,7 +4,7 @@ using DysonNetwork.Shared.Proto; | |||||||
| using NodaTime; | using NodaTime; | ||||||
| using NodaTime.Serialization.SystemTextJson; | using NodaTime.Serialization.SystemTextJson; | ||||||
| 
 | 
 | ||||||
| namespace DysonNetwork.Pusher.Connection; | namespace DysonNetwork.Ring.Connection; | ||||||
| 
 | 
 | ||||||
| public class WebSocketPacket | public class WebSocketPacket | ||||||
| { | { | ||||||
| @@ -29,6 +29,7 @@ public class WebSocketPacket | |||||||
|         var json = System.Text.Encoding.UTF8.GetString(bytes); |         var json = System.Text.Encoding.UTF8.GetString(bytes); | ||||||
|         var jsonOpts = new JsonSerializerOptions |         var jsonOpts = new JsonSerializerOptions | ||||||
|         { |         { | ||||||
|  |             NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, | ||||||
|             PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, |             PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, | ||||||
|             DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower, |             DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower, | ||||||
|         }; |         }; | ||||||
| @@ -48,6 +49,7 @@ public class WebSocketPacket | |||||||
| 
 | 
 | ||||||
|         var jsonOpts = new JsonSerializerOptions |         var jsonOpts = new JsonSerializerOptions | ||||||
|         { |         { | ||||||
|  |             NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, | ||||||
|             PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, |             PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, | ||||||
|             DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower, |             DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower, | ||||||
|         }; |         }; | ||||||
| @@ -65,6 +67,7 @@ public class WebSocketPacket | |||||||
|     { |     { | ||||||
|         var jsonOpts = new JsonSerializerOptions |         var jsonOpts = new JsonSerializerOptions | ||||||
|         { |         { | ||||||
|  |             NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, | ||||||
|             PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, |             PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, | ||||||
|             DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower, |             DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower, | ||||||
|         }.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); |         }.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); | ||||||
| @@ -1,27 +1,23 @@ | |||||||
| using System.Collections.Concurrent; | using System.Collections.Concurrent; | ||||||
| using System.Net.WebSockets; | using System.Net.WebSockets; | ||||||
| using dotnet_etcd.interfaces; |  | ||||||
| using DysonNetwork.Shared.Data; | using DysonNetwork.Shared.Data; | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
| using Grpc.Core; | using Grpc.Core; | ||||||
| 
 | 
 | ||||||
| namespace DysonNetwork.Pusher.Connection; | namespace DysonNetwork.Ring.Connection; | ||||||
| 
 | 
 | ||||||
| public class WebSocketService | public class WebSocketService | ||||||
| { | { | ||||||
|     private readonly IConfiguration _configuration; |     private readonly IConfiguration _configuration; | ||||||
|     private readonly ILogger<WebSocketService> _logger; |     private readonly ILogger<WebSocketService> _logger; | ||||||
|     private readonly IEtcdClient _etcdClient; |  | ||||||
|     private readonly IDictionary<string, IWebSocketPacketHandler> _handlerMap; |     private readonly IDictionary<string, IWebSocketPacketHandler> _handlerMap; | ||||||
| 
 | 
 | ||||||
|     public WebSocketService( |     public WebSocketService( | ||||||
|         IEnumerable<IWebSocketPacketHandler> handlers, |         IEnumerable<IWebSocketPacketHandler> handlers, | ||||||
|         IEtcdClient etcdClient, |  | ||||||
|         ILogger<WebSocketService> logger, |         ILogger<WebSocketService> logger, | ||||||
|         IConfiguration configuration |         IConfiguration configuration | ||||||
|     ) |     ) | ||||||
|     { |     { | ||||||
|         _etcdClient = etcdClient; |  | ||||||
|         _logger = logger; |         _logger = logger; | ||||||
|         _configuration = configuration; |         _configuration = configuration; | ||||||
|         _handlerMap = handlers.ToDictionary(h => h.PacketType); |         _handlerMap = handlers.ToDictionary(h => h.PacketType); | ||||||
| @@ -49,11 +45,20 @@ public class WebSocketService | |||||||
|     public void Disconnect((string AccountId, string DeviceId) key, string? reason = null) |     public void Disconnect((string AccountId, string DeviceId) key, string? reason = null) | ||||||
|     { |     { | ||||||
|         if (!ActiveConnections.TryGetValue(key, out var data)) return; |         if (!ActiveConnections.TryGetValue(key, out var data)) return; | ||||||
|         data.Socket.CloseAsync( |         try | ||||||
|             WebSocketCloseStatus.NormalClosure, |         { | ||||||
|             reason ?? "Server just decided to disconnect.", |             data.Socket.CloseAsync( | ||||||
|             CancellationToken.None |                 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(); |         data.Cts.Cancel(); | ||||||
|         ActiveConnections.TryRemove(key, out _); |         ActiveConnections.TryRemove(key, out _); | ||||||
|     } |     } | ||||||
| @@ -133,45 +138,24 @@ public class WebSocketService | |||||||
|         { |         { | ||||||
|             try |             try | ||||||
|             { |             { | ||||||
|                 // Get the service URL from etcd for the specified endpoint |                 var serviceUrl = "https://" + packet.Endpoint; | ||||||
|                 var serviceKey = $"/services/{packet.Endpoint}"; |  | ||||||
|                 var response = await _etcdClient.GetAsync(serviceKey); |  | ||||||
| 
 | 
 | ||||||
|                 if (response.Kvs.Count > 0) |                 var callInvoker = GrpcClientHelper.CreateCallInvoker(serviceUrl); | ||||||
|  |                 var client = new RingHandlerService.RingHandlerServiceClient(callInvoker); | ||||||
|  | 
 | ||||||
|  |                 try | ||||||
|                 { |                 { | ||||||
|                     var serviceUrl = response.Kvs[0].Value.ToStringUtf8(); |                     await client.ReceiveWebSocketPacketAsync(new ReceiveWebSocketPacketRequest | ||||||
| 
 |  | ||||||
|                     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 |                         Account = currentUser, | ||||||
|                         { |                         DeviceId = deviceId, | ||||||
|                             Account = currentUser, |                         Packet = packet.ToProtoValue() | ||||||
|                             DeviceId = deviceId, |                     }); | ||||||
|                             Packet = packet.ToProtoValue() |                 } | ||||||
|                         }); |                 catch (RpcException ex) | ||||||
|                     } |                 { | ||||||
|                     catch (RpcException ex) |                     _logger.LogError(ex, $"Error forwarding packet to endpoint: {packet.Endpoint}"); | ||||||
|                     { |  | ||||||
|                         _logger.LogError(ex, $"Error forwarding packet to endpoint: {packet.Endpoint}"); |  | ||||||
|                     } |  | ||||||
| 
 |  | ||||||
|                     return; |  | ||||||
|                 } |                 } | ||||||
| 
 |  | ||||||
|                 _logger.LogWarning($"No service registered for endpoint: {packet.Endpoint}"); |  | ||||||
|             } |             } | ||||||
|             catch (Exception ex) |             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> |         <Nullable>enable</Nullable> | ||||||
|         <ImplicitUsings>enable</ImplicitUsings> |         <ImplicitUsings>enable</ImplicitUsings> | ||||||
|         <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> |         <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> | ||||||
|  |         <RootNamespace>DysonNetwork.Pusher</RootNamespace> | ||||||
|     </PropertyGroup> |     </PropertyGroup> | ||||||
| 
 | 
 | ||||||
|     <ItemGroup> |     <ItemGroup> | ||||||
| @@ -41,6 +42,7 @@ | |||||||
|     </ItemGroup> |     </ItemGroup> | ||||||
| 
 | 
 | ||||||
|     <ItemGroup> |     <ItemGroup> | ||||||
|  |       <ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" /> | ||||||
|       <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> |       <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> | ||||||
|     </ItemGroup> |     </ItemGroup> | ||||||
| 
 | 
 | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user