Compare commits
	
		
			133 Commits
		
	
	
		
			2e52a13c30
			...
			refactor/a
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						
						
							
						
						f6f0703cb3
	
				 | 
					
					
						|||
| 
						
						
							
						
						3d47b4e44e
	
				 | 
					
					
						|||
| 
						
						
							
						
						71fe2a30e7
	
				 | 
					
					
						|||
| 
						
						
							
						
						d8f57161ae
	
				 | 
					
					
						|||
| 
						
						
							
						
						3caa79b9a7
	
				 | 
					
					
						|||
| 
						
						
							
						
						49beb17925
	
				 | 
					
					
						|||
| 
						
						
							
						
						bd8e13f25d
	
				 | 
					
					
						|||
| 
						
						
							
						
						1128c9a0ba
	
				 | 
					
					
						|||
| 
						
						
							
						
						8dfe201afe
	
				 | 
					
					
						|||
| 
						
						
							
						
						c1016e496a
	
				 | 
					
					
						|||
| 
						
						
							
						
						091097a858
	
				 | 
					
					
						|||
| 
						
						
							
						
						5c97733b3e
	
				 | 
					
					
						|||
| 
						
						
							
						
						4ee387ab76
	
				 | 
					
					
						|||
| 
						
						
							
						
						19bf17200d
	
				 | 
					
					
						|||
| 
						
						
							
						
						be6d97ec85
	
				 | 
					
					
						|||
| 
						
						
							
						
						9d282b26f3
	
				 | 
					
					
						|||
| 
						
						
							
						
						dbc2c54ab0
	
				 | 
					
					
						|||
| 
						
						
							
						
						aa062932cf
	
				 | 
					
					
						|||
| 
						
						
							
						
						812dd03e85
	
				 | 
					
					
						|||
| 
						
						
							
						
						06d639a114
	
				 | 
					
					
						|||
| 
						
						
							
						
						74f51036b1
	
				 | 
					
					
						|||
| 
						
						
							
						
						8308325b73
	
				 | 
					
					
						|||
| 
						
						
							
						
						fa7010db3d
	
				 | 
					
					
						|||
| 
						
						
							
						
						89320fc540
	
				 | 
					
					
						|||
| 
						
						
							
						
						5ec8d89563
	
				 | 
					
					
						|||
| 
						
						
							
						
						0eeafb5352
	
				 | 
					
					
						|||
| 
						
						
							
						
						ab2bdcc7ca
	
				 | 
					
					
						|||
| 
						
						
							
						
						c2b49e6642
	
				 | 
					
					
						|||
| 
						
						
							
						
						1a89c48790
	
				 | 
					
					
						|||
| 
						
						
							
						
						8dddfe77cd
	
				 | 
					
					
						|||
| 
						
						
							
						
						8e8b011fdd
	
				 | 
					
					
						|||
| 
						
						
							
						
						abd346bb97
	
				 | 
					
					
						|||
| 
						
						
							
						
						6386ec8caa
	
				 | 
					
					
						|||
| 
						
						
							
						
						ad062828ff
	
				 | 
					
					
						|||
| 
						
						
							
						
						92e4988114
	
				 | 
					
					
						|||
| 
						
						
							
						
						f9269d7558
	
				 | 
					
					
						|||
| 
						
						
							
						
						fa01b7027a
	
				 | 
					
					
						|||
| 
						
						
							
						
						eaa3a9c297
	
				 | 
					
					
						|||
| 
						
						
							
						
						6cedda9307
	
				 | 
					
					
						|||
| 
						
						
							
						
						942ca73f8d
	
				 | 
					
					
						|||
| 
						
						
							
						
						da3f58f2ec
	
				 | 
					
					
						|||
| 
						
						
							
						
						4a8521d59d
	
				 | 
					
					
						|||
| 
						
						
							
						
						d7ad84e199
	
				 | 
					
					
						|||
| 
						
						
							
						
						52430c19a5
	
				 | 
					
					
						|||
| 
						
						
							
						
						9492b6cac6
	
				 | 
					
					
						|||
| 
						
						
							
						
						5f324a2348
	
				 | 
					
					
						|||
| 
						
						
							
						
						7452b14817
	
				 | 
					
					
						|||
| 
						
						
							
						
						4a27794ccc
	
				 | 
					
					
						|||
| 
						
						
							
						
						d2f5ba36ab
	
				 | 
					
					
						|||
| 0117fdf084 | |||
| 02680d224a | |||
| 68bfdebcbd | |||
| 54907eede1 | |||
| a21d19c3ef | |||
| df732616d5 | |||
| 79a31ae060 | |||
| 6eacfcd8f2 | |||
| 5e328509bd | |||
| 9c078db564 | |||
| ddd109c77c | |||
| 3ee04d0b24 | |||
| 7f110313e9 | |||
| bc2e87c56f | |||
| d7271a2d11 | |||
| c57d65db67 | |||
| edf3aab173 | |||
| 352746a141 | |||
| 216c72ea36 | |||
| d0723b366b | |||
| fb6721cb1b | |||
| 9fcb169c94 | |||
| 572874431d | |||
| f595ac8001 | |||
| 18674e0e1d | |||
| da4c4d3a84 | |||
| aec01b117d | |||
| d299c32e35 | |||
| 344007af66 | |||
| d4de5aeac2 | |||
| 8ce5ba50f4 | |||
| 5a44952b27 | |||
| c30946daf6 | |||
| 0221d7b294 | |||
| c44b0b64c3 | |||
| 442ee3bcfd | |||
| 081815c512 | |||
| eab2a388ae | |||
| 5f7ab49abb | |||
| 4ff89173b2 | |||
| f2052410c7 | |||
| 83a49be725 | |||
| 9b205a73fd | |||
| d5157eb7e3 | |||
| 75c92c51db | |||
| 915054fce0 | |||
| 63653680ba | |||
| 84c4df6620 | |||
| 8c748fd57a | |||
| 4684550ebf | |||
| 51db08f374 | |||
| 9f38a288b9 | |||
| 75a975049c | |||
| f8c35c0350 | |||
| d9a5fed77f | |||
| 7cb14940d9 | |||
| 953bf5d4de | |||
| d9620fd6a4 | |||
| 541e2dd14c | |||
| c7925d98c8 | |||
| f759b19bcb | |||
| 5d7429a416 | |||
| fb7e52d6f3 | |||
| 50e888b075 | |||
| 76c8bbf307 | |||
| 8f3825e92c | |||
| d1c3610ec8 | |||
| 4b958a3c31 | |||
| 1f9021d459 | |||
| 7ad9deaf70 | |||
| c1c17b5f4e | |||
| d92220b4bc | |||
| 4d1972bc99 | |||
| 83c052ec4e | |||
| 57a75fe9e6 | |||
| 379bc37aff | |||
| 0217fbb13b | |||
| 4e9943e6a2 | |||
| b3cc623168 | |||
| 3ee5e5367d | |||
| 85fef30c7f | |||
| e8d8dcbb2d | |||
| 3b679d6134 | |||
| ec44b51ab6 | 
							
								
								
									
										3
									
								
								.aspire/settings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								.aspire/settings.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "appHostPath": "../DysonNetwork.Control/DysonNetwork.Control.csproj"
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										35
									
								
								.env
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								.env
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,35 @@
 | 
				
			|||||||
 | 
					# Default container port for ring
 | 
				
			||||||
 | 
					RING_PORT=8080
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Default container port for pass
 | 
				
			||||||
 | 
					PASS_PORT=8080
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Default container port for drive
 | 
				
			||||||
 | 
					DRIVE_PORT=8080
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Default container port for sphere
 | 
				
			||||||
 | 
					SPHERE_PORT=8080
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Default container port for develop
 | 
				
			||||||
 | 
					DEVELOP_PORT=8080
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Parameter cache-password
 | 
				
			||||||
 | 
					CACHE_PASSWORD=KS3jSPaU9e
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Parameter queue-password
 | 
				
			||||||
 | 
					QUEUE_PASSWORD=8xEECa4ckz
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Container image name for ring
 | 
				
			||||||
 | 
					RING_IMAGE=ring:latest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Container image name for pass
 | 
				
			||||||
 | 
					PASS_IMAGE=pass:latest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Container image name for drive
 | 
				
			||||||
 | 
					DRIVE_IMAGE=drive:latest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Container image name for sphere
 | 
				
			||||||
 | 
					SPHERE_IMAGE=sphere:latest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Container image name for develop
 | 
				
			||||||
 | 
					DEVELOP_IMAGE=develop:latest
 | 
				
			||||||
							
								
								
									
										249
									
								
								.github/workflows/docker-build.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										249
									
								
								.github/workflows/docker-build.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,189 +1,60 @@
 | 
				
			|||||||
 name: Build and Push Microservices
 | 
					name: Aspire Publish Workflow
 | 
				
			||||||
 
 | 
					
 | 
				
			||||||
 on:
 | 
					on:
 | 
				
			||||||
   push:
 | 
					  push:
 | 
				
			||||||
     branches:
 | 
					    branches:
 | 
				
			||||||
       - master
 | 
					      - master
 | 
				
			||||||
   workflow_dispatch:
 | 
					  workflow_dispatch:
 | 
				
			||||||
 
 | 
					
 | 
				
			||||||
 jobs:
 | 
					jobs:
 | 
				
			||||||
   build-sphere:
 | 
					  publish:
 | 
				
			||||||
     runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
     permissions:
 | 
					    permissions:
 | 
				
			||||||
       contents: read
 | 
					      contents: read
 | 
				
			||||||
       packages: write
 | 
					      packages: write
 | 
				
			||||||
     steps:
 | 
					    steps:
 | 
				
			||||||
       - name: Checkout repository
 | 
					      - name: Checkout repository
 | 
				
			||||||
         uses: actions/checkout@v3
 | 
					        uses: actions/checkout@v3
 | 
				
			||||||
         with:
 | 
					
 | 
				
			||||||
           fetch-depth: 0
 | 
					      - name: Setup .NET
 | 
				
			||||||
       - name: Setup NBGV
 | 
					        uses: actions/setup-dotnet@v3
 | 
				
			||||||
         uses: dotnet/nbgv@master
 | 
					        with:
 | 
				
			||||||
         id: nbgv
 | 
					          dotnet-version: "9.0.x"
 | 
				
			||||||
       - name: Set up Docker Buildx
 | 
					
 | 
				
			||||||
         uses: docker/setup-buildx-action@v3
 | 
					      - name: Log in to GitHub Container Registry
 | 
				
			||||||
       - name: Log in to GitHub Container Registry
 | 
					        uses: docker/login-action@v3
 | 
				
			||||||
         uses: docker/login-action@v3
 | 
					        with:
 | 
				
			||||||
         with:
 | 
					          registry: ghcr.io
 | 
				
			||||||
           registry: ghcr.io
 | 
					          username: ${{ github.actor }}
 | 
				
			||||||
           username: ${{ github.actor }}
 | 
					          password: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
           password: ${{ secrets.GITHUB_TOKEN }}
 | 
					
 | 
				
			||||||
       - name: Build and push DysonNetwork.Sphere Docker image
 | 
					      - name: Install Aspire CLI
 | 
				
			||||||
         uses: docker/build-push-action@v6
 | 
					        run: dotnet tool install -g Aspire.Cli --prerelease
 | 
				
			||||||
         with:
 | 
					
 | 
				
			||||||
           file: DysonNetwork.Sphere/Dockerfile
 | 
					      - name: Build and Publish Aspire Application
 | 
				
			||||||
           context: .
 | 
					        run: aspire publish --project ./DysonNetwork.Control/DysonNetwork.Control.csproj --output publish
 | 
				
			||||||
           push: true
 | 
					
 | 
				
			||||||
           tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-sphere:latest
 | 
					      - name: Tag and Push Images
 | 
				
			||||||
           platforms: linux/amd64
 | 
					        run: |
 | 
				
			||||||
 
 | 
					          IMAGES=( "sphere" "pass" "ring" "drive" "develop" )
 | 
				
			||||||
   build-pass:
 | 
					
 | 
				
			||||||
     runs-on: ubuntu-latest
 | 
					          for image in "${IMAGES[@]}"; do
 | 
				
			||||||
     permissions:
 | 
					            IMAGE_NAME="ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-$image:alpha"
 | 
				
			||||||
       contents: read
 | 
					            SOURCE_IMAGE_NAME="$image:latest" # Aspire's default local image name
 | 
				
			||||||
       packages: write
 | 
					
 | 
				
			||||||
     steps:
 | 
					            echo "Tagging and pushing $SOURCE_IMAGE_NAME to $IMAGE_NAME..."
 | 
				
			||||||
       - name: Checkout repository
 | 
					            docker tag $SOURCE_IMAGE_NAME $IMAGE_NAME
 | 
				
			||||||
         uses: actions/checkout@v3
 | 
					            docker push $IMAGE_NAME
 | 
				
			||||||
         with:
 | 
					          done
 | 
				
			||||||
           fetch-depth: 0
 | 
					
 | 
				
			||||||
       - name: Setup NBGV
 | 
					      - name: Upload Aspire Publish Directory
 | 
				
			||||||
         uses: dotnet/nbgv@master
 | 
					        uses: actions/upload-artifact@v3
 | 
				
			||||||
         id: nbgv
 | 
					        with:
 | 
				
			||||||
       - name: Set up Docker Buildx
 | 
					          name: aspire-publish-output
 | 
				
			||||||
         uses: docker/setup-buildx-action@v3
 | 
					          path: ./publish/
 | 
				
			||||||
       - name: Log in to GitHub Container Registry
 | 
					
 | 
				
			||||||
         uses: docker/login-action@v3
 | 
					      - name: Upload Docker Compose file
 | 
				
			||||||
         with:
 | 
					        uses: actions/upload-artifact@v3
 | 
				
			||||||
           registry: ghcr.io
 | 
					        with:
 | 
				
			||||||
           username: ${{ github.actor }}
 | 
					          name: docker-compose-output
 | 
				
			||||||
           password: ${{ secrets.GITHUB_TOKEN }}
 | 
					          path: ./publish/docker-compose.yml
 | 
				
			||||||
       - name: Build and push DysonNetwork.Pass Docker image
 | 
					 | 
				
			||||||
         uses: docker/build-push-action@v6
 | 
					 | 
				
			||||||
         with:
 | 
					 | 
				
			||||||
           file: DysonNetwork.Pass/Dockerfile
 | 
					 | 
				
			||||||
           context: .
 | 
					 | 
				
			||||||
           push: true
 | 
					 | 
				
			||||||
           tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-pass:latest
 | 
					 | 
				
			||||||
           platforms: linux/amd64
 | 
					 | 
				
			||||||
 
 | 
					 | 
				
			||||||
   build-pusher:
 | 
					 | 
				
			||||||
     runs-on: ubuntu-latest
 | 
					 | 
				
			||||||
     permissions:
 | 
					 | 
				
			||||||
       contents: read
 | 
					 | 
				
			||||||
       packages: write
 | 
					 | 
				
			||||||
     steps:
 | 
					 | 
				
			||||||
       - name: Checkout repository
 | 
					 | 
				
			||||||
         uses: actions/checkout@v3
 | 
					 | 
				
			||||||
         with:
 | 
					 | 
				
			||||||
           fetch-depth: 0
 | 
					 | 
				
			||||||
       - name: Setup NBGV
 | 
					 | 
				
			||||||
         uses: dotnet/nbgv@master
 | 
					 | 
				
			||||||
         id: nbgv
 | 
					 | 
				
			||||||
       - name: Set up Docker Buildx
 | 
					 | 
				
			||||||
         uses: docker/setup-buildx-action@v3
 | 
					 | 
				
			||||||
       - name: Log in to GitHub Container Registry
 | 
					 | 
				
			||||||
         uses: docker/login-action@v3
 | 
					 | 
				
			||||||
         with:
 | 
					 | 
				
			||||||
           registry: ghcr.io
 | 
					 | 
				
			||||||
           username: ${{ github.actor }}
 | 
					 | 
				
			||||||
           password: ${{ secrets.GITHUB_TOKEN }}
 | 
					 | 
				
			||||||
       - name: Build and push DysonNetwork.Pusher Docker image
 | 
					 | 
				
			||||||
         uses: docker/build-push-action@v6
 | 
					 | 
				
			||||||
         with:
 | 
					 | 
				
			||||||
           file: DysonNetwork.Pusher/Dockerfile
 | 
					 | 
				
			||||||
           context: .
 | 
					 | 
				
			||||||
           push: true
 | 
					 | 
				
			||||||
           tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-pusher:latest
 | 
					 | 
				
			||||||
           platforms: linux/amd64
 | 
					 | 
				
			||||||
 
 | 
					 | 
				
			||||||
   build-drive:
 | 
					 | 
				
			||||||
     runs-on: ubuntu-latest
 | 
					 | 
				
			||||||
     permissions:
 | 
					 | 
				
			||||||
       contents: read
 | 
					 | 
				
			||||||
       packages: write
 | 
					 | 
				
			||||||
     steps:
 | 
					 | 
				
			||||||
       - name: Checkout repository
 | 
					 | 
				
			||||||
         uses: actions/checkout@v3
 | 
					 | 
				
			||||||
         with:
 | 
					 | 
				
			||||||
           fetch-depth: 0
 | 
					 | 
				
			||||||
       - name: Setup NBGV
 | 
					 | 
				
			||||||
         uses: dotnet/nbgv@master
 | 
					 | 
				
			||||||
         id: nbgv
 | 
					 | 
				
			||||||
       - name: Set up Docker Buildx
 | 
					 | 
				
			||||||
         uses: docker/setup-buildx-action@v3
 | 
					 | 
				
			||||||
       - name: Log in to GitHub Container Registry
 | 
					 | 
				
			||||||
         uses: docker/login-action@v3
 | 
					 | 
				
			||||||
         with:
 | 
					 | 
				
			||||||
           registry: ghcr.io
 | 
					 | 
				
			||||||
           username: ${{ github.actor }}
 | 
					 | 
				
			||||||
           password: ${{ secrets.GITHUB_TOKEN }}
 | 
					 | 
				
			||||||
       - name: Build and push DysonNetwork.Drive Docker image
 | 
					 | 
				
			||||||
         uses: docker/build-push-action@v6
 | 
					 | 
				
			||||||
         with:
 | 
					 | 
				
			||||||
           file: DysonNetwork.Drive/Dockerfile
 | 
					 | 
				
			||||||
           context: .
 | 
					 | 
				
			||||||
           push: true
 | 
					 | 
				
			||||||
           tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-drive:latest
 | 
					 | 
				
			||||||
           platforms: linux/amd64
 | 
					 | 
				
			||||||
 
 | 
					 | 
				
			||||||
   build-gateway:
 | 
					 | 
				
			||||||
     runs-on: ubuntu-latest
 | 
					 | 
				
			||||||
     permissions:
 | 
					 | 
				
			||||||
       contents: read
 | 
					 | 
				
			||||||
       packages: write
 | 
					 | 
				
			||||||
     steps:
 | 
					 | 
				
			||||||
       - name: Checkout repository
 | 
					 | 
				
			||||||
         uses: actions/checkout@v3
 | 
					 | 
				
			||||||
         with:
 | 
					 | 
				
			||||||
           fetch-depth: 0
 | 
					 | 
				
			||||||
       - name: Setup NBGV
 | 
					 | 
				
			||||||
         uses: dotnet/nbgv@master
 | 
					 | 
				
			||||||
         id: nbgv
 | 
					 | 
				
			||||||
       - name: Set up Docker Buildx
 | 
					 | 
				
			||||||
         uses: docker/setup-buildx-action@v3
 | 
					 | 
				
			||||||
       - name: Log in to GitHub Container Registry
 | 
					 | 
				
			||||||
         uses: docker/login-action@v3
 | 
					 | 
				
			||||||
         with:
 | 
					 | 
				
			||||||
           registry: ghcr.io
 | 
					 | 
				
			||||||
           username: ${{ github.actor }}
 | 
					 | 
				
			||||||
           password: ${{ secrets.GITHUB_TOKEN }}
 | 
					 | 
				
			||||||
       - name: Build and push DysonNetwork.Gateway Docker image
 | 
					 | 
				
			||||||
         uses: docker/build-push-action@v6
 | 
					 | 
				
			||||||
         with:
 | 
					 | 
				
			||||||
           file: DysonNetwork.Gateway/Dockerfile
 | 
					 | 
				
			||||||
           context: .
 | 
					 | 
				
			||||||
           push: true
 | 
					 | 
				
			||||||
           tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-gateway:latest
 | 
					 | 
				
			||||||
           platforms: linux/amd64
 | 
					 | 
				
			||||||
 
 | 
					 | 
				
			||||||
   build-develop:
 | 
					 | 
				
			||||||
     runs-on: ubuntu-latest
 | 
					 | 
				
			||||||
     permissions:
 | 
					 | 
				
			||||||
       contents: read
 | 
					 | 
				
			||||||
       packages: write
 | 
					 | 
				
			||||||
     steps:
 | 
					 | 
				
			||||||
       - name: Checkout repository
 | 
					 | 
				
			||||||
         uses: actions/checkout@v3
 | 
					 | 
				
			||||||
         with:
 | 
					 | 
				
			||||||
           fetch-depth: 0
 | 
					 | 
				
			||||||
       - name: Setup NBGV
 | 
					 | 
				
			||||||
         uses: dotnet/nbgv@master
 | 
					 | 
				
			||||||
         id: nbgv
 | 
					 | 
				
			||||||
       - name: Set up Docker Buildx
 | 
					 | 
				
			||||||
         uses: docker/setup-buildx-action@v3
 | 
					 | 
				
			||||||
       - name: Log in to GitHub Container Registry
 | 
					 | 
				
			||||||
         uses: docker/login-action@v3
 | 
					 | 
				
			||||||
         with:
 | 
					 | 
				
			||||||
           registry: ghcr.io
 | 
					 | 
				
			||||||
           username: ${{ github.actor }}
 | 
					 | 
				
			||||||
           password: ${{ secrets.GITHUB_TOKEN }}
 | 
					 | 
				
			||||||
       - name: Build and push DysonNetwork.Develop Docker image
 | 
					 | 
				
			||||||
         uses: docker/build-push-action@v6
 | 
					 | 
				
			||||||
         with:
 | 
					 | 
				
			||||||
           file: DysonNetwork.Develop/Dockerfile
 | 
					 | 
				
			||||||
           context: .
 | 
					 | 
				
			||||||
           push: true
 | 
					 | 
				
			||||||
           tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-develop:latest
 | 
					 | 
				
			||||||
           platforms: linux/amd64
 | 
					 | 
				
			||||||
 
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										77
									
								
								DysonNetwork.Control/AppHost.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								DysonNetwork.Control/AppHost.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,77 @@
 | 
				
			|||||||
 | 
					using Aspire.Hosting.Yarp.Transforms;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					var builder = DistributedApplication.CreateBuilder(args);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Database was configured separately in each service.
 | 
				
			||||||
 | 
					// var database = builder.AddPostgres("database");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					var cache = builder.AddRedis("cache");
 | 
				
			||||||
 | 
					var queue = builder.AddNats("queue").WithJetStream();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					var ringService = builder.AddProject<Projects.DysonNetwork_Ring>("ring")
 | 
				
			||||||
 | 
					    .WithReference(queue)
 | 
				
			||||||
 | 
					    .WithHttpHealthCheck()
 | 
				
			||||||
 | 
					    .WithEndpoint(5001, 5001, "https", name: "grpc");
 | 
				
			||||||
 | 
					var passService = builder.AddProject<Projects.DysonNetwork_Pass>("pass")
 | 
				
			||||||
 | 
					    .WithReference(cache)
 | 
				
			||||||
 | 
					    .WithReference(queue)
 | 
				
			||||||
 | 
					    .WithReference(ringService)
 | 
				
			||||||
 | 
					    .WithHttpHealthCheck()
 | 
				
			||||||
 | 
					    .WithEndpoint(5001, 5001, "https", name: "grpc");
 | 
				
			||||||
 | 
					var driveService = builder.AddProject<Projects.DysonNetwork_Drive>("drive")
 | 
				
			||||||
 | 
					    .WithReference(cache)
 | 
				
			||||||
 | 
					    .WithReference(queue)
 | 
				
			||||||
 | 
					    .WithReference(passService)
 | 
				
			||||||
 | 
					    .WithReference(ringService)
 | 
				
			||||||
 | 
					    .WithHttpHealthCheck()
 | 
				
			||||||
 | 
					    .WithEndpoint(5001, 5001, "https", name: "grpc");
 | 
				
			||||||
 | 
					var sphereService = builder.AddProject<Projects.DysonNetwork_Sphere>("sphere")
 | 
				
			||||||
 | 
					    .WithReference(cache)
 | 
				
			||||||
 | 
					    .WithReference(queue)
 | 
				
			||||||
 | 
					    .WithReference(passService)
 | 
				
			||||||
 | 
					    .WithReference(ringService)
 | 
				
			||||||
 | 
					    .WithHttpHealthCheck()
 | 
				
			||||||
 | 
					    .WithEndpoint(5001, 5001, "https", name: "grpc");
 | 
				
			||||||
 | 
					var developService = builder.AddProject<Projects.DysonNetwork_Develop>("develop")
 | 
				
			||||||
 | 
					    .WithReference(cache)
 | 
				
			||||||
 | 
					    .WithReference(passService)
 | 
				
			||||||
 | 
					    .WithReference(ringService)
 | 
				
			||||||
 | 
					    .WithHttpHealthCheck()
 | 
				
			||||||
 | 
					    .WithEndpoint(5001, 5001, "https", name: "grpc");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Extra double-ended references
 | 
				
			||||||
 | 
					ringService.WithReference(passService);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					builder.AddYarp("gateway")
 | 
				
			||||||
 | 
					    .WithHostPort(5000)
 | 
				
			||||||
 | 
					    .WithConfiguration(yarp =>
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var ringCluster = yarp.AddCluster(ringService.GetEndpoint("http"));
 | 
				
			||||||
 | 
					        yarp.AddRoute("/ws", ringCluster);
 | 
				
			||||||
 | 
					        yarp.AddRoute("/ring/{**catch-all}", ringCluster)
 | 
				
			||||||
 | 
					            .WithTransformPathRemovePrefix("/ring")
 | 
				
			||||||
 | 
					            .WithTransformPathPrefix("/api");
 | 
				
			||||||
 | 
					        var passCluster = yarp.AddCluster(passService.GetEndpoint("http"));
 | 
				
			||||||
 | 
					        yarp.AddRoute("/.well-known/openid-configuration", passCluster);
 | 
				
			||||||
 | 
					        yarp.AddRoute("/.well-known/jwks", passCluster);
 | 
				
			||||||
 | 
					        yarp.AddRoute("/id/{**catch-all}", passCluster)
 | 
				
			||||||
 | 
					            .WithTransformPathRemovePrefix("/id")
 | 
				
			||||||
 | 
					            .WithTransformPathPrefix("/api");
 | 
				
			||||||
 | 
					        var driveCluster = yarp.AddCluster(driveService.GetEndpoint("http"));
 | 
				
			||||||
 | 
					        yarp.AddRoute("/api/tus", driveCluster);
 | 
				
			||||||
 | 
					        yarp.AddRoute("/drive/{**catch-all}", driveCluster)
 | 
				
			||||||
 | 
					            .WithTransformPathRemovePrefix("/drive")
 | 
				
			||||||
 | 
					            .WithTransformPathPrefix("/api");
 | 
				
			||||||
 | 
					        var sphereCluster = yarp.AddCluster(sphereService.GetEndpoint("http"));
 | 
				
			||||||
 | 
					        yarp.AddRoute("/sphere/{**catch-all}", sphereCluster)
 | 
				
			||||||
 | 
					            .WithTransformPathRemovePrefix("/sphere")
 | 
				
			||||||
 | 
					            .WithTransformPathPrefix("/api");
 | 
				
			||||||
 | 
					        var developCluster = yarp.AddCluster(developService.GetEndpoint("http"));
 | 
				
			||||||
 | 
					        yarp.AddRoute("/develop/{**catch-all}", developCluster)
 | 
				
			||||||
 | 
					            .WithTransformPathRemovePrefix("/develop")
 | 
				
			||||||
 | 
					            .WithTransformPathPrefix("/api");
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					builder.AddDockerComposeEnvironment("docker-compose");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					builder.Build().Run();
 | 
				
			||||||
							
								
								
									
										30
									
								
								DysonNetwork.Control/DysonNetwork.Control.csproj
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								DysonNetwork.Control/DysonNetwork.Control.csproj
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,30 @@
 | 
				
			|||||||
 | 
					<Project Sdk="Microsoft.NET.Sdk">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <Sdk Name="Aspire.AppHost.Sdk" Version="9.4.2"/>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <PropertyGroup>
 | 
				
			||||||
 | 
					        <OutputType>Exe</OutputType>
 | 
				
			||||||
 | 
					        <TargetFramework>net9.0</TargetFramework>
 | 
				
			||||||
 | 
					        <ImplicitUsings>enable</ImplicitUsings>
 | 
				
			||||||
 | 
					        <Nullable>enable</Nullable>
 | 
				
			||||||
 | 
					        <UserSecretsId>a68b3195-a00d-40c2-b5ed-d675356b7cde</UserSecretsId>
 | 
				
			||||||
 | 
					        <RootNamespace>DysonNetwork.Control</RootNamespace>
 | 
				
			||||||
 | 
					    </PropertyGroup>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <ItemGroup>
 | 
				
			||||||
 | 
					        <PackageReference Include="Aspire.Hosting.AppHost" Version="9.4.2"/>
 | 
				
			||||||
 | 
					        <PackageReference Include="Aspire.Hosting.Docker" Version="9.4.2-preview.1.25428.12" />
 | 
				
			||||||
 | 
					        <PackageReference Include="Aspire.Hosting.Nats" Version="9.4.2" />
 | 
				
			||||||
 | 
					        <PackageReference Include="Aspire.Hosting.Redis" Version="9.4.2" />
 | 
				
			||||||
 | 
					        <PackageReference Include="Aspire.Hosting.Yarp" Version="9.4.2-preview.1.25428.12" />
 | 
				
			||||||
 | 
					    </ItemGroup>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <ItemGroup>
 | 
				
			||||||
 | 
					      <ProjectReference Include="..\DysonNetwork.Develop\DysonNetwork.Develop.csproj" />
 | 
				
			||||||
 | 
					      <ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
 | 
				
			||||||
 | 
					      <ProjectReference Include="..\DysonNetwork.Pass\DysonNetwork.Pass.csproj" />
 | 
				
			||||||
 | 
					      <ProjectReference Include="..\DysonNetwork.Ring\DysonNetwork.Ring.csproj" />
 | 
				
			||||||
 | 
					      <ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" />
 | 
				
			||||||
 | 
					    </ItemGroup>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					</Project>
 | 
				
			||||||
							
								
								
									
										29
									
								
								DysonNetwork.Control/Properties/launchSettings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								DysonNetwork.Control/Properties/launchSettings.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,29 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "$schema": "https://json.schemastore.org/launchsettings.json",
 | 
				
			||||||
 | 
					  "profiles": {
 | 
				
			||||||
 | 
					    "https": {
 | 
				
			||||||
 | 
					      "commandName": "Project",
 | 
				
			||||||
 | 
					      "dotnetRunMessages": true,
 | 
				
			||||||
 | 
					      "launchBrowser": true,
 | 
				
			||||||
 | 
					      "applicationUrl": "https://localhost:17025;http://localhost:15057",
 | 
				
			||||||
 | 
					      "environmentVariables": {
 | 
				
			||||||
 | 
					        "ASPNETCORE_ENVIRONMENT": "Development",
 | 
				
			||||||
 | 
					        "DOTNET_ENVIRONMENT": "Development",
 | 
				
			||||||
 | 
					        "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21175",
 | 
				
			||||||
 | 
					        "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22189"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "http": {
 | 
				
			||||||
 | 
					      "commandName": "Project",
 | 
				
			||||||
 | 
					      "dotnetRunMessages": true,
 | 
				
			||||||
 | 
					      "launchBrowser": true,
 | 
				
			||||||
 | 
					      "applicationUrl": "http://localhost:15057",
 | 
				
			||||||
 | 
					      "environmentVariables": {
 | 
				
			||||||
 | 
					        "ASPNETCORE_ENVIRONMENT": "Development",
 | 
				
			||||||
 | 
					        "DOTNET_ENVIRONMENT": "Development",
 | 
				
			||||||
 | 
					        "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19163",
 | 
				
			||||||
 | 
					        "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20185"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										11
									
								
								DysonNetwork.Control/appsettings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								DysonNetwork.Control/appsettings.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "Logging": {
 | 
				
			||||||
 | 
					    "LogLevel": {
 | 
				
			||||||
 | 
					      "Default": "Information",
 | 
				
			||||||
 | 
					      "Microsoft.AspNetCore": "Warning"
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "ConnectionStrings": {
 | 
				
			||||||
 | 
					    "cache": "localhost:6379"
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -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,7 +1,7 @@
 | 
				
			|||||||
using System.ComponentModel.DataAnnotations;
 | 
					using System.ComponentModel.DataAnnotations;
 | 
				
			||||||
 | 
					using System.ComponentModel.DataAnnotations.Schema;
 | 
				
			||||||
using DysonNetwork.Develop.Project;
 | 
					using DysonNetwork.Develop.Project;
 | 
				
			||||||
using DysonNetwork.Shared.Data;
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
using NodaTime;
 | 
					 | 
				
			||||||
using NodaTime.Serialization.Protobuf;
 | 
					using NodaTime.Serialization.Protobuf;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace DysonNetwork.Develop.Identity;
 | 
					namespace DysonNetwork.Develop.Identity;
 | 
				
			||||||
@@ -15,6 +15,14 @@ public class BotAccount : ModelBase
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    public Guid ProjectId { get; set; }
 | 
					    public Guid ProjectId { get; set; }
 | 
				
			||||||
    public DevProject Project { get; set; } = null!;
 | 
					    public DevProject Project { get; set; } = null!;
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    [NotMapped] public AccountReference? Account { get; set; }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    /// <summary>
 | 
				
			||||||
 | 
					    /// This developer field is to serve the transparent info for user to know which developer
 | 
				
			||||||
 | 
					    /// published this robot. Not for relationships usage.
 | 
				
			||||||
 | 
					    /// </summary>
 | 
				
			||||||
 | 
					    [NotMapped] public Developer? Developer { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public Shared.Proto.BotAccount ToProtoValue()
 | 
					    public Shared.Proto.BotAccount ToProtoValue()
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,8 +1,13 @@
 | 
				
			|||||||
using System.ComponentModel.DataAnnotations;
 | 
					using System.ComponentModel.DataAnnotations;
 | 
				
			||||||
using DysonNetwork.Develop.Project;
 | 
					using DysonNetwork.Develop.Project;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
using DysonNetwork.Shared.Proto;
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Registry;
 | 
				
			||||||
 | 
					using Grpc.Core;
 | 
				
			||||||
using Microsoft.AspNetCore.Authorization;
 | 
					using Microsoft.AspNetCore.Authorization;
 | 
				
			||||||
using Microsoft.AspNetCore.Mvc;
 | 
					using Microsoft.AspNetCore.Mvc;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					using NodaTime.Serialization.Protobuf;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace DysonNetwork.Develop.Identity;
 | 
					namespace DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -13,18 +18,62 @@ public class BotAccountController(
 | 
				
			|||||||
    BotAccountService botService,
 | 
					    BotAccountService botService,
 | 
				
			||||||
    DeveloperService developerService,
 | 
					    DeveloperService developerService,
 | 
				
			||||||
    DevProjectService projectService,
 | 
					    DevProjectService projectService,
 | 
				
			||||||
    ILogger<BotAccountController> logger
 | 
					    ILogger<BotAccountController> logger,
 | 
				
			||||||
 | 
					    AccountClientHelper accounts,
 | 
				
			||||||
 | 
					    BotAccountReceiverService.BotAccountReceiverServiceClient accountsReceiver
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
    : ControllerBase
 | 
					    : ControllerBase
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    public record BotRequest(
 | 
					    public class CommonBotRequest
 | 
				
			||||||
        [Required] [MaxLength(1024)] string? Slug
 | 
					    {
 | 
				
			||||||
    );
 | 
					        [MaxLength(256)] public string? FirstName { get; set; }
 | 
				
			||||||
 | 
					        [MaxLength(256)] public string? MiddleName { get; set; }
 | 
				
			||||||
 | 
					        [MaxLength(256)] public string? LastName { get; set; }
 | 
				
			||||||
 | 
					        [MaxLength(1024)] public string? Gender { get; set; }
 | 
				
			||||||
 | 
					        [MaxLength(1024)] public string? Pronouns { get; set; }
 | 
				
			||||||
 | 
					        [MaxLength(1024)] public string? TimeZone { get; set; }
 | 
				
			||||||
 | 
					        [MaxLength(1024)] public string? Location { get; set; }
 | 
				
			||||||
 | 
					        [MaxLength(4096)] public string? Bio { get; set; }
 | 
				
			||||||
 | 
					        public Instant? Birthday { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public record UpdateBotRequest(
 | 
					        [MaxLength(32)] public string? PictureId { get; set; }
 | 
				
			||||||
       [MaxLength(1024)] string? Slug,
 | 
					        [MaxLength(32)] public string? BackgroundId { get; set; }
 | 
				
			||||||
        bool? IsActive
 | 
					    }
 | 
				
			||||||
    ) : BotRequest(Slug);
 | 
					
 | 
				
			||||||
 | 
					    public class BotCreateRequest : CommonBotRequest
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        [Required]
 | 
				
			||||||
 | 
					        [MinLength(2)]
 | 
				
			||||||
 | 
					        [MaxLength(256)]
 | 
				
			||||||
 | 
					        [RegularExpression(@"^[A-Za-z0-9_-]+$",
 | 
				
			||||||
 | 
					            ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.")
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        public string Name { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        [Required] [MaxLength(256)] public string Nick { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        [Required] [MaxLength(1024)] public string Slug { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        [MaxLength(128)] public string Language { get; set; } = "en-us";
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public class UpdateBotRequest : CommonBotRequest
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        [MinLength(2)]
 | 
				
			||||||
 | 
					        [MaxLength(256)]
 | 
				
			||||||
 | 
					        [RegularExpression(@"^[A-Za-z0-9_-]+$",
 | 
				
			||||||
 | 
					            ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.")
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        public string? Name { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        [MaxLength(256)] public string? Nick { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        [Required] [MaxLength(1024)] public string? Slug { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        [MaxLength(128)] public string? Language { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        public bool? IsActive { get; set; }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [HttpGet]
 | 
					    [HttpGet]
 | 
				
			||||||
    public async Task<IActionResult> ListBots(
 | 
					    public async Task<IActionResult> ListBots(
 | 
				
			||||||
@@ -39,15 +88,15 @@ public class BotAccountController(
 | 
				
			|||||||
            return NotFound("Developer not found");
 | 
					            return NotFound("Developer not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
 | 
					        if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
 | 
				
			||||||
                PublisherMemberRole.Editor))
 | 
					                PublisherMemberRole.Viewer))
 | 
				
			||||||
            return StatusCode(403, "You must be an editor of the developer to list bots");
 | 
					            return StatusCode(403, "You must be an viewer of the developer to list bots");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
        if (project is null)
 | 
					        if (project is null)
 | 
				
			||||||
            return NotFound("Project not found or you don't have access");
 | 
					            return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var bots = await botService.GetBotsByProjectAsync(projectId);
 | 
					        var bots = await botService.GetBotsByProjectAsync(projectId);
 | 
				
			||||||
        return Ok(bots);
 | 
					        return Ok(await botService.LoadBotsAccountAsync(bots));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [HttpGet("{botId:guid}")]
 | 
					    [HttpGet("{botId:guid}")]
 | 
				
			||||||
@@ -64,8 +113,8 @@ public class BotAccountController(
 | 
				
			|||||||
            return NotFound("Developer not found");
 | 
					            return NotFound("Developer not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
 | 
					        if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
 | 
				
			||||||
                PublisherMemberRole.Editor))
 | 
					                PublisherMemberRole.Viewer))
 | 
				
			||||||
            return StatusCode(403, "You must be an editor of the developer to view bot details");
 | 
					            return StatusCode(403, "You must be an viewer of the developer to view bot details");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
        if (project is null)
 | 
					        if (project is null)
 | 
				
			||||||
@@ -75,18 +124,16 @@ public class BotAccountController(
 | 
				
			|||||||
        if (bot is null || bot.ProjectId != projectId)
 | 
					        if (bot is null || bot.ProjectId != projectId)
 | 
				
			||||||
            return NotFound("Bot not found");
 | 
					            return NotFound("Bot not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return Ok(bot);
 | 
					        return Ok(await botService.LoadBotAccountAsync(bot));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [HttpPost]
 | 
					    [HttpPost]
 | 
				
			||||||
    public async Task<IActionResult> CreateBot(
 | 
					    public async Task<IActionResult> CreateBot(
 | 
				
			||||||
        [FromRoute] string pubName,
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
        [FromRoute] Guid projectId,
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
        [FromBody] BotRequest request
 | 
					        [FromBody] BotCreateRequest createRequest
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        if (string.IsNullOrWhiteSpace(request.Slug))
 | 
					 | 
				
			||||||
            return BadRequest("Name is required");
 | 
					 | 
				
			||||||
        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
            return Unauthorized();
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -102,9 +149,43 @@ public class BotAccountController(
 | 
				
			|||||||
        if (project is null)
 | 
					        if (project is null)
 | 
				
			||||||
            return NotFound("Project not found or you don't have access");
 | 
					            return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var now = SystemClock.Instance.GetCurrentInstant();
 | 
				
			||||||
 | 
					        var accountId = Guid.NewGuid();
 | 
				
			||||||
 | 
					        var account = new Account()
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Id = accountId.ToString(),
 | 
				
			||||||
 | 
					            Name = createRequest.Name,
 | 
				
			||||||
 | 
					            Nick = createRequest.Nick,
 | 
				
			||||||
 | 
					            Language = createRequest.Language,
 | 
				
			||||||
 | 
					            Profile = new AccountProfile()
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Id = Guid.NewGuid().ToString(),
 | 
				
			||||||
 | 
					                Bio = createRequest.Bio,
 | 
				
			||||||
 | 
					                Gender = createRequest.Gender,
 | 
				
			||||||
 | 
					                FirstName = createRequest.FirstName,
 | 
				
			||||||
 | 
					                MiddleName = createRequest.MiddleName,
 | 
				
			||||||
 | 
					                LastName = createRequest.LastName,
 | 
				
			||||||
 | 
					                TimeZone = createRequest.TimeZone,
 | 
				
			||||||
 | 
					                Pronouns = createRequest.Pronouns,
 | 
				
			||||||
 | 
					                Location = createRequest.Location,
 | 
				
			||||||
 | 
					                Birthday = createRequest.Birthday?.ToTimestamp(),
 | 
				
			||||||
 | 
					                AccountId = accountId.ToString(),
 | 
				
			||||||
 | 
					                CreatedAt = now.ToTimestamp(),
 | 
				
			||||||
 | 
					                UpdatedAt = now.ToTimestamp()
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            CreatedAt = now.ToTimestamp(),
 | 
				
			||||||
 | 
					            UpdatedAt = now.ToTimestamp()
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        try
 | 
					        try
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            var bot = await botService.CreateBotAsync(project, request.Slug);
 | 
					            var bot = await botService.CreateBotAsync(
 | 
				
			||||||
 | 
					                project,
 | 
				
			||||||
 | 
					                createRequest.Slug,
 | 
				
			||||||
 | 
					                account,
 | 
				
			||||||
 | 
					                createRequest.PictureId,
 | 
				
			||||||
 | 
					                createRequest.BackgroundId
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
            return Ok(bot);
 | 
					            return Ok(bot);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        catch (Exception ex)
 | 
					        catch (Exception ex)
 | 
				
			||||||
@@ -114,7 +195,7 @@ public class BotAccountController(
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [HttpPut("{botId:guid}")]
 | 
					    [HttpPatch("{botId:guid}")]
 | 
				
			||||||
    public async Task<IActionResult> UpdateBot(
 | 
					    public async Task<IActionResult> UpdateBot(
 | 
				
			||||||
        [FromRoute] string pubName,
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
        [FromRoute] Guid projectId,
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
@@ -141,12 +222,31 @@ public class BotAccountController(
 | 
				
			|||||||
        if (bot is null || bot.ProjectId != projectId)
 | 
					        if (bot is null || bot.ProjectId != projectId)
 | 
				
			||||||
            return NotFound("Bot not found");
 | 
					            return NotFound("Bot not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var botAccount = await accounts.GetBotAccount(bot.Id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (request.Name is not null) botAccount.Name = request.Name;
 | 
				
			||||||
 | 
					        if (request.Nick is not null) botAccount.Nick = request.Nick;
 | 
				
			||||||
 | 
					        if (request.Language is not null) botAccount.Language = request.Language;
 | 
				
			||||||
 | 
					        if (request.Bio is not null) botAccount.Profile.Bio = request.Bio;
 | 
				
			||||||
 | 
					        if (request.Gender is not null) botAccount.Profile.Gender = request.Gender;
 | 
				
			||||||
 | 
					        if (request.FirstName is not null) botAccount.Profile.FirstName = request.FirstName;
 | 
				
			||||||
 | 
					        if (request.MiddleName is not null) botAccount.Profile.MiddleName = request.MiddleName;
 | 
				
			||||||
 | 
					        if (request.LastName is not null) botAccount.Profile.LastName = request.LastName;
 | 
				
			||||||
 | 
					        if (request.TimeZone is not null) botAccount.Profile.TimeZone = request.TimeZone;
 | 
				
			||||||
 | 
					        if (request.Pronouns is not null) botAccount.Profile.Pronouns = request.Pronouns;
 | 
				
			||||||
 | 
					        if (request.Location is not null) botAccount.Profile.Location = request.Location;
 | 
				
			||||||
 | 
					        if (request.Birthday is not null) botAccount.Profile.Birthday = request.Birthday?.ToTimestamp();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (request.Slug is not null) bot.Slug = request.Slug;
 | 
				
			||||||
 | 
					        if (request.IsActive is not null) bot.IsActive = request.IsActive.Value;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        try
 | 
					        try
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            var updatedBot = await botService.UpdateBotAsync(
 | 
					            var updatedBot = await botService.UpdateBotAsync(
 | 
				
			||||||
                bot,
 | 
					                bot,
 | 
				
			||||||
                request.Slug,
 | 
					                botAccount,
 | 
				
			||||||
                request.IsActive
 | 
					                request.PictureId,
 | 
				
			||||||
 | 
					                request.BackgroundId
 | 
				
			||||||
            );
 | 
					            );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            return Ok(updatedBot);
 | 
					            return Ok(updatedBot);
 | 
				
			||||||
@@ -194,4 +294,167 @@ public class BotAccountController(
 | 
				
			|||||||
            return StatusCode(500, "An error occurred while deleting the bot account");
 | 
					            return StatusCode(500, "An error occurred while deleting the bot account");
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("{botId:guid}/keys")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<List<ApiKeyReference>>> ListBotKeys(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid botId
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Viewer);
 | 
				
			||||||
 | 
					        if (developer == null) return NotFound("Developer not found");
 | 
				
			||||||
 | 
					        if (project == null) return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					        if (bot == null) return NotFound("Bot not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var keys = await accountsReceiver.ListApiKeyAsync(new ListApiKeyRequest
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            AutomatedId = bot.Id.ToString()
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        var data = keys.Data.Select(ApiKeyReference.FromProtoValue).ToList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Ok(data);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("{botId:guid}/keys/{keyId:guid}")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<ApiKeyReference>> GetBotKey(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid botId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid keyId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Viewer);
 | 
				
			||||||
 | 
					        if (developer == null) return NotFound("Developer not found");
 | 
				
			||||||
 | 
					        if (project == null) return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					        if (bot == null) return NotFound("Bot not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var key = await accountsReceiver.GetApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() });
 | 
				
			||||||
 | 
					            if (key == null) return NotFound("API key not found");
 | 
				
			||||||
 | 
					            return Ok(ApiKeyReference.FromProtoValue(key));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return NotFound("API key not found");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public class CreateApiKeyRequest
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        [Required, MaxLength(1024)]
 | 
				
			||||||
 | 
					        public string Label { get; set; } = null!;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost("{botId:guid}/keys")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<ApiKeyReference>> CreateBotKey(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid botId,
 | 
				
			||||||
 | 
					        [FromBody] CreateApiKeyRequest request)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor);
 | 
				
			||||||
 | 
					        if (developer == null) return NotFound("Developer not found");
 | 
				
			||||||
 | 
					        if (project == null) return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					        if (bot == null) return NotFound("Bot not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var newKey = new ApiKey
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                AccountId = bot.Id.ToString(),
 | 
				
			||||||
 | 
					                Label = request.Label
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            var createdKey = await accountsReceiver.CreateApiKeyAsync(newKey);
 | 
				
			||||||
 | 
					            return Ok(ApiKeyReference.FromProtoValue(createdKey));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(ex.Status.Detail);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost("{botId:guid}/keys/{keyId:guid}/rotate")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<ApiKeyReference>> RotateBotKey(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid botId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid keyId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor);
 | 
				
			||||||
 | 
					        if (developer == null) return NotFound("Developer not found");
 | 
				
			||||||
 | 
					        if (project == null) return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					        if (bot == null) return NotFound("Bot not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var rotatedKey = await accountsReceiver.RotateApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() });
 | 
				
			||||||
 | 
					            return Ok(ApiKeyReference.FromProtoValue(rotatedKey));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return NotFound("API key not found");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpDelete("{botId:guid}/keys/{keyId:guid}")]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> DeleteBotKey(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid botId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid keyId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor);
 | 
				
			||||||
 | 
					        if (developer == null) return NotFound("Developer not found");
 | 
				
			||||||
 | 
					        if (project == null) return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					        if (bot == null) return NotFound("Bot not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            await accountsReceiver.DeleteApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() });
 | 
				
			||||||
 | 
					            return NoContent();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return NotFound("API key not found");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private async Task<(Developer?, DevProject?, BotAccount?)> ValidateBotAccess(
 | 
				
			||||||
 | 
					        string pubName,
 | 
				
			||||||
 | 
					        Guid projectId,
 | 
				
			||||||
 | 
					        Guid botId,
 | 
				
			||||||
 | 
					        Account currentUser,
 | 
				
			||||||
 | 
					        PublisherMemberRole requiredRole)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var developer = await developerService.GetDeveloperByName(pubName);
 | 
				
			||||||
 | 
					        if (developer == null) return (null, null, null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), requiredRole))
 | 
				
			||||||
 | 
					            return (null, null, null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
 | 
					        if (project == null) return (developer, null, null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var bot = await botService.GetBotByIdAsync(botId);
 | 
				
			||||||
 | 
					        if (bot == null || bot.ProjectId != projectId) return (developer, project, null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return (developer, project, bot);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
							
								
								
									
										35
									
								
								DysonNetwork.Develop/Identity/BotAccountPublicController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								DysonNetwork.Develop/Identity/BotAccountPublicController.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,35 @@
 | 
				
			|||||||
 | 
					using Microsoft.AspNetCore.Mvc;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[ApiController]
 | 
				
			||||||
 | 
					[Route("api/bots")]
 | 
				
			||||||
 | 
					public class BotAccountPublicController(BotAccountService botService, DeveloperService developerService) : ControllerBase
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    [HttpGet("{botId:guid}")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<BotAccount>> GetBotTransparentInfo([FromRoute] Guid botId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var bot = await botService.GetBotByIdAsync(botId);
 | 
				
			||||||
 | 
					        if (bot is null) return NotFound("Bot not found");
 | 
				
			||||||
 | 
					        bot = await botService.LoadBotAccountAsync(bot);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var developer = await developerService.GetDeveloperById(bot!.Project.DeveloperId);
 | 
				
			||||||
 | 
					        if (developer is null) return NotFound("Developer not found");
 | 
				
			||||||
 | 
					        bot.Developer = await developerService.LoadDeveloperPublisher(developer);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Ok(bot);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("{botId:guid}/developer")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<Developer>> GetBotDeveloper([FromRoute] Guid botId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var bot = await botService.GetBotByIdAsync(botId);
 | 
				
			||||||
 | 
					        if (bot is null) return NotFound("Bot not found");
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        var developer = await developerService.GetDeveloperById(bot!.Project.DeveloperId);
 | 
				
			||||||
 | 
					        if (developer is null) return NotFound("Developer not found");
 | 
				
			||||||
 | 
					        developer = await developerService.LoadDeveloperPublisher(developer);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Ok(developer);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,13 +1,18 @@
 | 
				
			|||||||
using DysonNetwork.Develop.Project;
 | 
					using DysonNetwork.Develop.Project;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
using DysonNetwork.Shared.Proto;
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Registry;
 | 
				
			||||||
using Grpc.Core;
 | 
					using Grpc.Core;
 | 
				
			||||||
using Microsoft.EntityFrameworkCore;
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
using NodaTime;
 | 
					 | 
				
			||||||
using NodaTime.Serialization.Protobuf;
 | 
					using NodaTime.Serialization.Protobuf;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace DysonNetwork.Develop.Identity;
 | 
					namespace DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public class BotAccountService(AppDatabase db, BotAccountReceiverService.BotAccountReceiverServiceClient accountReceiver)
 | 
					public class BotAccountService(
 | 
				
			||||||
 | 
					    AppDatabase db,
 | 
				
			||||||
 | 
					    BotAccountReceiverService.BotAccountReceiverServiceClient accountReceiver,
 | 
				
			||||||
 | 
					    AccountClientHelper accounts
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    public async Task<BotAccount?> GetBotByIdAsync(Guid id)
 | 
					    public async Task<BotAccount?> GetBotByIdAsync(Guid id)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
@@ -23,39 +28,30 @@ public class BotAccountService(AppDatabase db, BotAccountReceiverService.BotAcco
 | 
				
			|||||||
            .ToListAsync();
 | 
					            .ToListAsync();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async Task<BotAccount> CreateBotAsync(DevProject project, string slug)
 | 
					    public async Task<BotAccount> CreateBotAsync(
 | 
				
			||||||
 | 
					        DevProject project,
 | 
				
			||||||
 | 
					        string slug,
 | 
				
			||||||
 | 
					        Account account,
 | 
				
			||||||
 | 
					        string? pictureId,
 | 
				
			||||||
 | 
					        string? backgroundId
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        // First, check if a bot with this slug already exists in this project
 | 
					        // First, check if a bot with this slug already exists in this project
 | 
				
			||||||
        var existingBot = await db.BotAccounts
 | 
					        var existingBot = await db.BotAccounts
 | 
				
			||||||
            .FirstOrDefaultAsync(b => b.ProjectId == project.Id && b.Slug == slug);
 | 
					            .FirstOrDefaultAsync(b => b.ProjectId == project.Id && b.Slug == slug);
 | 
				
			||||||
            
 | 
					 | 
				
			||||||
        if (existingBot != null)
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            throw new InvalidOperationException("A bot with this slug already exists in this project.");
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
					        if (existingBot != null)
 | 
				
			||||||
        
 | 
					            throw new InvalidOperationException("A bot with this slug already exists in this project.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        try
 | 
					        try
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            // First create the bot account in the Pass service
 | 
					            var automatedId = Guid.NewGuid();
 | 
				
			||||||
            var createRequest = new CreateBotAccountRequest
 | 
					            var createRequest = new CreateBotAccountRequest
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                AutomatedId = Guid.NewGuid().ToString(),
 | 
					                AutomatedId = automatedId.ToString(),
 | 
				
			||||||
                Account = new Account
 | 
					                Account = account,
 | 
				
			||||||
                {
 | 
					                PictureId = pictureId,
 | 
				
			||||||
                    Name = slug,
 | 
					                BackgroundId = backgroundId
 | 
				
			||||||
                    Nick = $"Bot {slug}",
 | 
					 | 
				
			||||||
                    Language = "en",
 | 
					 | 
				
			||||||
                    Profile = new AccountProfile
 | 
					 | 
				
			||||||
                    {
 | 
					 | 
				
			||||||
                        Id = Guid.NewGuid().ToString(),
 | 
					 | 
				
			||||||
                        CreatedAt = now.ToTimestamp(),
 | 
					 | 
				
			||||||
                        UpdatedAt = now.ToTimestamp()
 | 
					 | 
				
			||||||
                    },
 | 
					 | 
				
			||||||
                    CreatedAt = now.ToTimestamp(),
 | 
					 | 
				
			||||||
                    UpdatedAt = now.ToTimestamp()
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            };
 | 
					            };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            var createResponse = await accountReceiver.CreateBotAccountAsync(createRequest);
 | 
					            var createResponse = await accountReceiver.CreateBotAccountAsync(createRequest);
 | 
				
			||||||
@@ -64,7 +60,7 @@ public class BotAccountService(AppDatabase db, BotAccountReceiverService.BotAcco
 | 
				
			|||||||
            // Then create the local bot account
 | 
					            // Then create the local bot account
 | 
				
			||||||
            var bot = new BotAccount
 | 
					            var bot = new BotAccount
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                Id = Guid.Parse(botAccount.AutomatedId),
 | 
					                Id = automatedId,
 | 
				
			||||||
                Slug = slug,
 | 
					                Slug = slug,
 | 
				
			||||||
                ProjectId = project.Id,
 | 
					                ProjectId = project.Id,
 | 
				
			||||||
                Project = project,
 | 
					                Project = project,
 | 
				
			||||||
@@ -80,7 +76,8 @@ public class BotAccountService(AppDatabase db, BotAccountReceiverService.BotAcco
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
        catch (RpcException ex) when (ex.StatusCode == StatusCode.AlreadyExists)
 | 
					        catch (RpcException ex) when (ex.StatusCode == StatusCode.AlreadyExists)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            throw new InvalidOperationException("A bot account with this ID already exists in the authentication service.", ex);
 | 
					            throw new InvalidOperationException(
 | 
				
			||||||
 | 
					                "A bot account with this ID already exists in the authentication service.", ex);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        catch (RpcException ex) when (ex.StatusCode == StatusCode.InvalidArgument)
 | 
					        catch (RpcException ex) when (ex.StatusCode == StatusCode.InvalidArgument)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
@@ -92,22 +89,15 @@ public class BotAccountService(AppDatabase db, BotAccountReceiverService.BotAcco
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async Task<BotAccount> UpdateBotAsync(BotAccount bot, string? slug = null, bool? isActive = null)
 | 
					    public async Task<BotAccount> UpdateBotAsync(
 | 
				
			||||||
 | 
					        BotAccount bot,
 | 
				
			||||||
 | 
					        Account account,
 | 
				
			||||||
 | 
					        string? pictureId,
 | 
				
			||||||
 | 
					        string? backgroundId
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var updated = false;
 | 
					        db.Update(bot);
 | 
				
			||||||
        if (slug != null && bot.Slug != slug)
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            bot.Slug = slug;
 | 
					 | 
				
			||||||
            updated = true;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        if (isActive.HasValue && bot.IsActive != isActive.Value)
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            bot.IsActive = isActive.Value;
 | 
					 | 
				
			||||||
            updated = true;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if (!updated) return bot;
 | 
					 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        try
 | 
					        try
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
@@ -115,12 +105,9 @@ public class BotAccountService(AppDatabase db, BotAccountReceiverService.BotAcco
 | 
				
			|||||||
            var updateRequest = new UpdateBotAccountRequest
 | 
					            var updateRequest = new UpdateBotAccountRequest
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                AutomatedId = bot.Id.ToString(),
 | 
					                AutomatedId = bot.Id.ToString(),
 | 
				
			||||||
                Account = new Shared.Proto.Account
 | 
					                Account = account,
 | 
				
			||||||
                {
 | 
					                PictureId = pictureId,
 | 
				
			||||||
                    Name = $"bot-{bot.Slug}",
 | 
					                BackgroundId = backgroundId
 | 
				
			||||||
                    Nick = $"Bot {bot.Slug}",
 | 
					 | 
				
			||||||
                    UpdatedAt = SystemClock.Instance.GetCurrentInstant().ToTimestamp()
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            };
 | 
					            };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            var updateResponse = await accountReceiver.UpdateBotAccountAsync(updateRequest);
 | 
					            var updateResponse = await accountReceiver.UpdateBotAccountAsync(updateRequest);
 | 
				
			||||||
@@ -160,9 +147,28 @@ public class BotAccountService(AppDatabase db, BotAccountReceiverService.BotAcco
 | 
				
			|||||||
        {
 | 
					        {
 | 
				
			||||||
            // Account not found in Pass service, continue with local deletion
 | 
					            // Account not found in Pass service, continue with local deletion
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        
 | 
					
 | 
				
			||||||
        // Delete the local bot account
 | 
					        // Delete the local bot account
 | 
				
			||||||
        db.BotAccounts.Remove(bot);
 | 
					        db.BotAccounts.Remove(bot);
 | 
				
			||||||
        await db.SaveChangesAsync();
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					
 | 
				
			||||||
 | 
					    public async Task<BotAccount?> LoadBotAccountAsync(BotAccount bot) =>
 | 
				
			||||||
 | 
					        (await LoadBotsAccountAsync([bot])).FirstOrDefault();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<List<BotAccount>> LoadBotsAccountAsync(IEnumerable<BotAccount> bots)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        bots = bots.ToList();
 | 
				
			||||||
 | 
					        var automatedIds = bots.Select(b => b.Id).ToList();
 | 
				
			||||||
 | 
					        var data = await accounts.GetBotAccountBatch(automatedIds);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        foreach (var bot in bots)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            bot.Account = data
 | 
				
			||||||
 | 
					                .Select(AccountReference.FromProtoValue)
 | 
				
			||||||
 | 
					                .FirstOrDefault(e => e.AutomatedId == bot.Id);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return bots as List<BotAccount> ?? [];
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -32,7 +32,7 @@ public class CustomApp : ModelBase, IIdentifiedResource
 | 
				
			|||||||
    [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
 | 
					    [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
 | 
				
			||||||
    [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
 | 
					    [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [Column(TypeName = "jsonb")] public DysonNetwork.Shared.Data.VerificationMark? Verification { get; set; }
 | 
					    [Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
 | 
				
			||||||
    [Column(TypeName = "jsonb")] public CustomAppOauthConfig? OauthConfig { get; set; }
 | 
					    [Column(TypeName = "jsonb")] public CustomAppOauthConfig? OauthConfig { get; set; }
 | 
				
			||||||
    [Column(TypeName = "jsonb")] public CustomAppLinks? Links { get; set; }
 | 
					    [Column(TypeName = "jsonb")] public CustomAppLinks? Links { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -62,17 +62,22 @@ public class CustomApp : ModelBase, IIdentifiedResource
 | 
				
			|||||||
                CustomAppStatus.Suspended => Shared.Proto.CustomAppStatus.Suspended,
 | 
					                CustomAppStatus.Suspended => Shared.Proto.CustomAppStatus.Suspended,
 | 
				
			||||||
                _ => Shared.Proto.CustomAppStatus.Unspecified
 | 
					                _ => Shared.Proto.CustomAppStatus.Unspecified
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
            Picture = Picture is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Picture)),
 | 
					            Picture = Picture?.ToProtoValue(),
 | 
				
			||||||
            Background = Background is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Background)),
 | 
					            Background = Background?.ToProtoValue(),
 | 
				
			||||||
            Verification = Verification is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Verification)),
 | 
					            Verification = Verification?.ToProtoValue(),
 | 
				
			||||||
            Links = Links is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Links)),
 | 
					            Links = Links is null ? null : new DysonNetwork.Shared.Proto.CustomAppLinks
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                HomePage = Links.HomePage ?? string.Empty,
 | 
				
			||||||
 | 
					                PrivacyPolicy = Links.PrivacyPolicy ?? string.Empty,
 | 
				
			||||||
 | 
					                TermsOfService = Links.TermsOfService ?? string.Empty
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
            OauthConfig = OauthConfig is null ? null : new DysonNetwork.Shared.Proto.CustomAppOauthConfig
 | 
					            OauthConfig = OauthConfig is null ? null : new DysonNetwork.Shared.Proto.CustomAppOauthConfig
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                ClientUri = OauthConfig.ClientUri ?? string.Empty,
 | 
					                ClientUri = OauthConfig.ClientUri ?? string.Empty,
 | 
				
			||||||
                RedirectUris = { OauthConfig.RedirectUris ?? Array.Empty<string>() },
 | 
					                RedirectUris = { OauthConfig.RedirectUris ?? [] },
 | 
				
			||||||
                PostLogoutRedirectUris = { OauthConfig.PostLogoutRedirectUris ?? Array.Empty<string>() },
 | 
					                PostLogoutRedirectUris = { OauthConfig.PostLogoutRedirectUris ?? [] },
 | 
				
			||||||
                AllowedScopes = { OauthConfig.AllowedScopes ?? Array.Empty<string>() },
 | 
					                AllowedScopes = { OauthConfig.AllowedScopes ?? [] },
 | 
				
			||||||
                AllowedGrantTypes = { OauthConfig.AllowedGrantTypes ?? Array.Empty<string>() },
 | 
					                AllowedGrantTypes = { OauthConfig.AllowedGrantTypes ?? [] },
 | 
				
			||||||
                RequirePkce = OauthConfig.RequirePkce,
 | 
					                RequirePkce = OauthConfig.RequirePkce,
 | 
				
			||||||
                AllowOfflineAccess = OauthConfig.AllowOfflineAccess
 | 
					                AllowOfflineAccess = OauthConfig.AllowOfflineAccess
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
@@ -99,10 +104,18 @@ public class CustomApp : ModelBase, IIdentifiedResource
 | 
				
			|||||||
        ProjectId = string.IsNullOrEmpty(p.ProjectId) ? Guid.Empty : Guid.Parse(p.ProjectId);
 | 
					        ProjectId = string.IsNullOrEmpty(p.ProjectId) ? Guid.Empty : Guid.Parse(p.ProjectId);
 | 
				
			||||||
        CreatedAt = p.CreatedAt.ToInstant();
 | 
					        CreatedAt = p.CreatedAt.ToInstant();
 | 
				
			||||||
        UpdatedAt = p.UpdatedAt.ToInstant();
 | 
					        UpdatedAt = p.UpdatedAt.ToInstant();
 | 
				
			||||||
        if (p.Picture.Length > 0) Picture = System.Text.Json.JsonSerializer.Deserialize<CloudFileReferenceObject>(p.Picture.ToStringUtf8());
 | 
					        if (p.Picture is not null) Picture = CloudFileReferenceObject.FromProtoValue(p.Picture);
 | 
				
			||||||
        if (p.Background.Length > 0) Background = System.Text.Json.JsonSerializer.Deserialize<CloudFileReferenceObject>(p.Background.ToStringUtf8());
 | 
					        if (p.Background is not null) Background = CloudFileReferenceObject.FromProtoValue(p.Background);
 | 
				
			||||||
        if (p.Verification.Length > 0) Verification = System.Text.Json.JsonSerializer.Deserialize<DysonNetwork.Shared.Data.VerificationMark>(p.Verification.ToStringUtf8());
 | 
					        if (p.Verification is not null) Verification = VerificationMark.FromProtoValue(p.Verification);
 | 
				
			||||||
        if (p.Links.Length > 0) Links = System.Text.Json.JsonSerializer.Deserialize<CustomAppLinks>(p.Links.ToStringUtf8());
 | 
					        if (p.Links is not null)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Links = new CustomAppLinks
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                HomePage = string.IsNullOrEmpty(p.Links.HomePage) ? null : p.Links.HomePage,
 | 
				
			||||||
 | 
					                PrivacyPolicy = string.IsNullOrEmpty(p.Links.PrivacyPolicy) ? null : p.Links.PrivacyPolicy,
 | 
				
			||||||
 | 
					                TermsOfService = string.IsNullOrEmpty(p.Links.TermsOfService) ? null : p.Links.TermsOfService
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
        return this;
 | 
					        return this;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,12 +3,14 @@ using DysonNetwork.Develop.Project;
 | 
				
			|||||||
using DysonNetwork.Shared.Proto;
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
using Microsoft.AspNetCore.Authorization;
 | 
					using Microsoft.AspNetCore.Authorization;
 | 
				
			||||||
using Microsoft.AspNetCore.Mvc;
 | 
					using Microsoft.AspNetCore.Mvc;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace DysonNetwork.Develop.Identity;
 | 
					namespace DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[ApiController]
 | 
					[ApiController]
 | 
				
			||||||
[Route("/api/developers/{pubName}/projects/{projectId:guid}/apps")]
 | 
					[Route("/api/developers/{pubName}/projects/{projectId:guid}/apps")]
 | 
				
			||||||
public class CustomAppController(CustomAppService customApps, DeveloperService ds, DevProjectService projectService) : ControllerBase
 | 
					public class CustomAppController(CustomAppService customApps, DeveloperService ds, DevProjectService projectService)
 | 
				
			||||||
 | 
					    : ControllerBase
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    public record CustomAppRequest(
 | 
					    public record CustomAppRequest(
 | 
				
			||||||
        [MaxLength(1024)] string? Slug,
 | 
					        [MaxLength(1024)] string? Slug,
 | 
				
			||||||
@@ -21,25 +23,58 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
 | 
				
			|||||||
        CustomAppOauthConfig? OauthConfig
 | 
					        CustomAppOauthConfig? OauthConfig
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public record CreateSecretRequest(
 | 
				
			||||||
 | 
					        [MaxLength(4096)] string? Description,
 | 
				
			||||||
 | 
					        TimeSpan? ExpiresIn = null,
 | 
				
			||||||
 | 
					        bool IsOidc = false
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public record SecretResponse(
 | 
				
			||||||
 | 
					        string Id,
 | 
				
			||||||
 | 
					        string? Secret,
 | 
				
			||||||
 | 
					        string? Description,
 | 
				
			||||||
 | 
					        Instant? ExpiresAt,
 | 
				
			||||||
 | 
					        bool IsOidc,
 | 
				
			||||||
 | 
					        Instant CreatedAt,
 | 
				
			||||||
 | 
					        Instant UpdatedAt
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [HttpGet]
 | 
					    [HttpGet]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
    public async Task<IActionResult> ListApps([FromRoute] string pubName, [FromRoute] Guid projectId)
 | 
					    public async Task<IActionResult> ListApps([FromRoute] string pubName, [FromRoute] Guid projectId)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var developer = await ds.GetDeveloperByName(pubName);
 | 
					        var developer = await ds.GetDeveloperByName(pubName);
 | 
				
			||||||
        if (developer is null) return NotFound();
 | 
					        if (developer is null) return NotFound();
 | 
				
			||||||
        
 | 
					
 | 
				
			||||||
 | 
					        var accountId = Guid.Parse(currentUser.Id);
 | 
				
			||||||
 | 
					        if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, PublisherMemberRole.Viewer))
 | 
				
			||||||
 | 
					            return StatusCode(403, "You must be a viewer of the developer to list custom apps");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
        if (project is null) return NotFound();
 | 
					        if (project is null) return NotFound();
 | 
				
			||||||
        
 | 
					
 | 
				
			||||||
        var apps = await customApps.GetAppsByProjectAsync(projectId);
 | 
					        var apps = await customApps.GetAppsByProjectAsync(projectId);
 | 
				
			||||||
        return Ok(apps);
 | 
					        return Ok(apps);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [HttpGet("{appId:guid}")]
 | 
					    [HttpGet("{appId:guid}")]
 | 
				
			||||||
    public async Task<IActionResult> GetApp([FromRoute] string pubName, [FromRoute] Guid projectId, [FromRoute] Guid appId)
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> GetApp([FromRoute] string pubName, [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid appId)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
        var developer = await ds.GetDeveloperByName(pubName);
 | 
					        var developer = await ds.GetDeveloperByName(pubName);
 | 
				
			||||||
        if (developer is null) return NotFound();
 | 
					        if (developer is null) return NotFound();
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
 | 
					        var accountId = Guid.Parse(currentUser.Id);
 | 
				
			||||||
 | 
					        if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, PublisherMemberRole.Viewer))
 | 
				
			||||||
 | 
					            return StatusCode(403, "You must be a viewer of the developer to list custom apps");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
        if (project is null) return NotFound();
 | 
					        if (project is null) return NotFound();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -53,18 +88,20 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
 | 
				
			|||||||
    [HttpPost]
 | 
					    [HttpPost]
 | 
				
			||||||
    [Authorize]
 | 
					    [Authorize]
 | 
				
			||||||
    public async Task<IActionResult> CreateApp(
 | 
					    public async Task<IActionResult> CreateApp(
 | 
				
			||||||
        [FromRoute] string pubName, 
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
        [FromRoute] Guid projectId,
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
        [FromBody] CustomAppRequest request)
 | 
					        [FromBody] CustomAppRequest request)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        if (HttpContext.Items["CurrentUser"] is not Account currentUser) 
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
            return Unauthorized();
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var developer = await ds.GetDeveloperByName(pubName);
 | 
					        var developer = await ds.GetDeveloperByName(pubName);
 | 
				
			||||||
        var accountId = Guid.Parse(currentUser.Id);
 | 
					        if (developer is null)
 | 
				
			||||||
        if (developer is null || developer.Id != accountId)
 | 
					            return NotFound("Developer not found");
 | 
				
			||||||
            return Forbid();
 | 
					
 | 
				
			||||||
            
 | 
					        if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
 | 
				
			||||||
 | 
					            return StatusCode(403, "You must be an editor of the developer to create a custom app");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
        if (project is null)
 | 
					        if (project is null)
 | 
				
			||||||
            return NotFound("Project not found or you don't have access");
 | 
					            return NotFound("Project not found or you don't have access");
 | 
				
			||||||
@@ -72,17 +109,14 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
 | 
				
			|||||||
        if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Slug))
 | 
					        if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Slug))
 | 
				
			||||||
            return BadRequest("Name and slug are required");
 | 
					            return BadRequest("Name and slug are required");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
 | 
					 | 
				
			||||||
            return StatusCode(403, "You must be an editor of the developer to create a custom app");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        try
 | 
					        try
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            var app = await customApps.CreateAppAsync(projectId, request);
 | 
					            var app = await customApps.CreateAppAsync(projectId, request);
 | 
				
			||||||
            if (app == null)
 | 
					            if (app == null)
 | 
				
			||||||
                return BadRequest("Failed to create app");
 | 
					                return BadRequest("Failed to create app");
 | 
				
			||||||
                
 | 
					
 | 
				
			||||||
            return CreatedAtAction(
 | 
					            return CreatedAtAction(
 | 
				
			||||||
                nameof(GetApp), 
 | 
					                nameof(GetApp),
 | 
				
			||||||
                new { pubName, projectId, appId = app.Id },
 | 
					                new { pubName, projectId, appId = app.Id },
 | 
				
			||||||
                app
 | 
					                app
 | 
				
			||||||
            );
 | 
					            );
 | 
				
			||||||
@@ -102,16 +136,16 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
 | 
				
			|||||||
        [FromBody] CustomAppRequest request
 | 
					        [FromBody] CustomAppRequest request
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        if (HttpContext.Items["CurrentUser"] is not Account currentUser) 
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
            return Unauthorized();
 | 
					            return Unauthorized();
 | 
				
			||||||
        
 | 
					
 | 
				
			||||||
        var developer = await ds.GetDeveloperByName(pubName);
 | 
					        var developer = await ds.GetDeveloperByName(pubName);
 | 
				
			||||||
        if (developer is null)
 | 
					        if (developer is null)
 | 
				
			||||||
            return NotFound("Developer not found");
 | 
					            return NotFound("Developer not found");
 | 
				
			||||||
            
 | 
					
 | 
				
			||||||
        if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
 | 
					        if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
 | 
				
			||||||
            return StatusCode(403, "You must be an editor of the developer to update a custom app");
 | 
					            return StatusCode(403, "You must be an editor of the developer to update a custom app");
 | 
				
			||||||
            
 | 
					
 | 
				
			||||||
        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
        if (project is null)
 | 
					        if (project is null)
 | 
				
			||||||
            return NotFound("Project not found or you don't have access");
 | 
					            return NotFound("Project not found or you don't have access");
 | 
				
			||||||
@@ -139,16 +173,16 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
 | 
				
			|||||||
        [FromRoute] Guid appId
 | 
					        [FromRoute] Guid appId
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        if (HttpContext.Items["CurrentUser"] is not Account currentUser) 
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
            return Unauthorized();
 | 
					            return Unauthorized();
 | 
				
			||||||
        
 | 
					
 | 
				
			||||||
        var developer = await ds.GetDeveloperByName(pubName);
 | 
					        var developer = await ds.GetDeveloperByName(pubName);
 | 
				
			||||||
        if (developer is null)
 | 
					        if (developer is null)
 | 
				
			||||||
            return NotFound("Developer not found");
 | 
					            return NotFound("Developer not found");
 | 
				
			||||||
            
 | 
					
 | 
				
			||||||
        if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
 | 
					        if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
 | 
				
			||||||
            return StatusCode(403, "You must be an editor of the developer to delete a custom app");
 | 
					            return StatusCode(403, "You must be an editor of the developer to delete a custom app");
 | 
				
			||||||
            
 | 
					
 | 
				
			||||||
        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
        if (project is null)
 | 
					        if (project is null)
 | 
				
			||||||
            return NotFound("Project not found or you don't have access");
 | 
					            return NotFound("Project not found or you don't have access");
 | 
				
			||||||
@@ -160,7 +194,238 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
 | 
				
			|||||||
        var result = await customApps.DeleteAppAsync(appId);
 | 
					        var result = await customApps.DeleteAppAsync(appId);
 | 
				
			||||||
        if (!result)
 | 
					        if (!result)
 | 
				
			||||||
            return NotFound();
 | 
					            return NotFound();
 | 
				
			||||||
            
 | 
					
 | 
				
			||||||
        return NoContent();
 | 
					        return NoContent();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("{appId:guid}/secrets")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> ListSecrets(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid appId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var developer = await ds.GetDeveloperByName(pubName);
 | 
				
			||||||
 | 
					        if (developer is null)
 | 
				
			||||||
 | 
					            return NotFound("Developer not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
 | 
				
			||||||
 | 
					            return StatusCode(403, "You must be an editor of the developer to view app secrets");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
 | 
					        if (project is null)
 | 
				
			||||||
 | 
					            return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var app = await customApps.GetAppAsync(appId, projectId);
 | 
				
			||||||
 | 
					        if (app == null)
 | 
				
			||||||
 | 
					            return NotFound("App not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var secrets = await customApps.GetAppSecretsAsync(appId);
 | 
				
			||||||
 | 
					        return Ok(secrets.Select(s => new SecretResponse(
 | 
				
			||||||
 | 
					            s.Id.ToString(),
 | 
				
			||||||
 | 
					            null,
 | 
				
			||||||
 | 
					            s.Description,
 | 
				
			||||||
 | 
					            s.ExpiredAt,
 | 
				
			||||||
 | 
					            s.IsOidc,
 | 
				
			||||||
 | 
					            s.CreatedAt,
 | 
				
			||||||
 | 
					            s.UpdatedAt
 | 
				
			||||||
 | 
					        )));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost("{appId:guid}/secrets")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> CreateSecret(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid appId,
 | 
				
			||||||
 | 
					        [FromBody] CreateSecretRequest request)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var developer = await ds.GetDeveloperByName(pubName);
 | 
				
			||||||
 | 
					        if (developer is null)
 | 
				
			||||||
 | 
					            return NotFound("Developer not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
 | 
				
			||||||
 | 
					            return StatusCode(403, "You must be an editor of the developer to create app secrets");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
 | 
					        if (project is null)
 | 
				
			||||||
 | 
					            return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var app = await customApps.GetAppAsync(appId, projectId);
 | 
				
			||||||
 | 
					        if (app == null)
 | 
				
			||||||
 | 
					            return NotFound("App not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var secret = await customApps.CreateAppSecretAsync(new CustomAppSecret
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                AppId = appId,
 | 
				
			||||||
 | 
					                Description = request.Description,
 | 
				
			||||||
 | 
					                ExpiredAt = request.ExpiresIn.HasValue
 | 
				
			||||||
 | 
					                    ? NodaTime.SystemClock.Instance.GetCurrentInstant()
 | 
				
			||||||
 | 
					                        .Plus(Duration.FromTimeSpan(request.ExpiresIn.Value))
 | 
				
			||||||
 | 
					                    : (NodaTime.Instant?)null,
 | 
				
			||||||
 | 
					                IsOidc = request.IsOidc
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            return CreatedAtAction(
 | 
				
			||||||
 | 
					                nameof(GetSecret),
 | 
				
			||||||
 | 
					                new { pubName, projectId, appId, secretId = secret.Id },
 | 
				
			||||||
 | 
					                new SecretResponse(
 | 
				
			||||||
 | 
					                    secret.Id.ToString(),
 | 
				
			||||||
 | 
					                    secret.Secret,
 | 
				
			||||||
 | 
					                    secret.Description,
 | 
				
			||||||
 | 
					                    secret.ExpiredAt,
 | 
				
			||||||
 | 
					                    secret.IsOidc,
 | 
				
			||||||
 | 
					                    secret.CreatedAt,
 | 
				
			||||||
 | 
					                    secret.UpdatedAt
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (InvalidOperationException ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(ex.Message);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("{appId:guid}/secrets/{secretId:guid}")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> GetSecret(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid appId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid secretId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var developer = await ds.GetDeveloperByName(pubName);
 | 
				
			||||||
 | 
					        if (developer is null)
 | 
				
			||||||
 | 
					            return NotFound("Developer not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
 | 
				
			||||||
 | 
					            return StatusCode(403, "You must be an editor of the developer to view app secrets");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
 | 
					        if (project is null)
 | 
				
			||||||
 | 
					            return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var app = await customApps.GetAppAsync(appId, projectId);
 | 
				
			||||||
 | 
					        if (app == null)
 | 
				
			||||||
 | 
					            return NotFound("App not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var secret = await customApps.GetAppSecretAsync(secretId, appId);
 | 
				
			||||||
 | 
					        if (secret == null)
 | 
				
			||||||
 | 
					            return NotFound("Secret not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Ok(new SecretResponse(
 | 
				
			||||||
 | 
					            secret.Id.ToString(),
 | 
				
			||||||
 | 
					            null,
 | 
				
			||||||
 | 
					            secret.Description,
 | 
				
			||||||
 | 
					            secret.ExpiredAt,
 | 
				
			||||||
 | 
					            secret.IsOidc,
 | 
				
			||||||
 | 
					            secret.CreatedAt,
 | 
				
			||||||
 | 
					            secret.UpdatedAt
 | 
				
			||||||
 | 
					        ));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpDelete("{appId:guid}/secrets/{secretId:guid}")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> DeleteSecret(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid appId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid secretId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var developer = await ds.GetDeveloperByName(pubName);
 | 
				
			||||||
 | 
					        if (developer is null)
 | 
				
			||||||
 | 
					            return NotFound("Developer not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
 | 
				
			||||||
 | 
					            return StatusCode(403, "You must be an editor of the developer to delete app secrets");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
 | 
					        if (project is null)
 | 
				
			||||||
 | 
					            return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var app = await customApps.GetAppAsync(appId, projectId);
 | 
				
			||||||
 | 
					        if (app == null)
 | 
				
			||||||
 | 
					            return NotFound("App not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var secret = await customApps.GetAppSecretAsync(secretId, appId);
 | 
				
			||||||
 | 
					        if (secret == null)
 | 
				
			||||||
 | 
					            return NotFound("Secret not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var result = await customApps.DeleteAppSecretAsync(secretId, appId);
 | 
				
			||||||
 | 
					        if (!result)
 | 
				
			||||||
 | 
					            return NotFound("Failed to delete secret");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return NoContent();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost("{appId:guid}/secrets/{secretId:guid}/rotate")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> RotateSecret(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid appId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid secretId,
 | 
				
			||||||
 | 
					        [FromBody] CreateSecretRequest? request = null)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var developer = await ds.GetDeveloperByName(pubName);
 | 
				
			||||||
 | 
					        if (developer is null)
 | 
				
			||||||
 | 
					            return NotFound("Developer not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
 | 
				
			||||||
 | 
					            return StatusCode(403, "You must be an editor of the developer to rotate app secrets");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
 | 
					        if (project is null)
 | 
				
			||||||
 | 
					            return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var app = await customApps.GetAppAsync(appId, projectId);
 | 
				
			||||||
 | 
					        if (app == null)
 | 
				
			||||||
 | 
					            return NotFound("App not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var secret = await customApps.RotateAppSecretAsync(new CustomAppSecret
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Id = secretId,
 | 
				
			||||||
 | 
					                AppId = appId,
 | 
				
			||||||
 | 
					                Description = request?.Description,
 | 
				
			||||||
 | 
					                ExpiredAt = request?.ExpiresIn.HasValue == true
 | 
				
			||||||
 | 
					                    ? NodaTime.SystemClock.Instance.GetCurrentInstant()
 | 
				
			||||||
 | 
					                        .Plus(Duration.FromTimeSpan(request.ExpiresIn.Value))
 | 
				
			||||||
 | 
					                    : (NodaTime.Instant?)null,
 | 
				
			||||||
 | 
					                IsOidc = request?.IsOidc ?? false
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            return Ok(new SecretResponse(
 | 
				
			||||||
 | 
					                secret.Id.ToString(),
 | 
				
			||||||
 | 
					                secret.Secret,
 | 
				
			||||||
 | 
					                secret.Description,
 | 
				
			||||||
 | 
					                secret.ExpiredAt,
 | 
				
			||||||
 | 
					                secret.IsOidc,
 | 
				
			||||||
 | 
					                secret.CreatedAt,
 | 
				
			||||||
 | 
					                secret.UpdatedAt
 | 
				
			||||||
 | 
					            ));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (InvalidOperationException ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(ex.Message);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -2,6 +2,8 @@ using DysonNetwork.Develop.Project;
 | 
				
			|||||||
using DysonNetwork.Shared.Data;
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
using DysonNetwork.Shared.Proto;
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
using Microsoft.EntityFrameworkCore;
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					using System.Security.Cryptography;
 | 
				
			||||||
 | 
					using System.Text;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace DysonNetwork.Develop.Identity;
 | 
					namespace DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -94,6 +96,87 @@ public class CustomAppService(
 | 
				
			|||||||
        return await query.FirstOrDefaultAsync(a => a.Id == id);
 | 
					        return await query.FirstOrDefaultAsync(a => a.Id == id);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<List<CustomAppSecret>> GetAppSecretsAsync(Guid appId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return await db.CustomAppSecrets
 | 
				
			||||||
 | 
					            .Where(s => s.AppId == appId)
 | 
				
			||||||
 | 
					            .OrderByDescending(s => s.CreatedAt)
 | 
				
			||||||
 | 
					            .ToListAsync();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<CustomAppSecret?> GetAppSecretAsync(Guid secretId, Guid appId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return await db.CustomAppSecrets
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<CustomAppSecret> CreateAppSecretAsync(CustomAppSecret secret)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (string.IsNullOrWhiteSpace(secret.Secret))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            // Generate a new random secret if not provided
 | 
				
			||||||
 | 
					            secret.Secret = GenerateRandomSecret();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        secret.Id = Guid.NewGuid();
 | 
				
			||||||
 | 
					        secret.CreatedAt = NodaTime.SystemClock.Instance.GetCurrentInstant();
 | 
				
			||||||
 | 
					        secret.UpdatedAt = secret.CreatedAt;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        db.CustomAppSecrets.Add(secret);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return secret;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<bool> DeleteAppSecretAsync(Guid secretId, Guid appId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var secret = await db.CustomAppSecrets
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (secret == null)
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        db.CustomAppSecrets.Remove(secret);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					        return true;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<CustomAppSecret> RotateAppSecretAsync(CustomAppSecret secretUpdate)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var existingSecret = await db.CustomAppSecrets
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync(s => s.Id == secretUpdate.Id && s.AppId == secretUpdate.AppId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (existingSecret == null)
 | 
				
			||||||
 | 
					            throw new InvalidOperationException("Secret not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Update the existing secret with new values
 | 
				
			||||||
 | 
					        existingSecret.Secret = GenerateRandomSecret();
 | 
				
			||||||
 | 
					        existingSecret.Description = secretUpdate.Description ?? existingSecret.Description;
 | 
				
			||||||
 | 
					        existingSecret.ExpiredAt = secretUpdate.ExpiredAt ?? existingSecret.ExpiredAt;
 | 
				
			||||||
 | 
					        existingSecret.IsOidc = secretUpdate.IsOidc;
 | 
				
			||||||
 | 
					        existingSecret.UpdatedAt = NodaTime.SystemClock.Instance.GetCurrentInstant();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					        return existingSecret;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static string GenerateRandomSecret(int length = 64)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        const string valid = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-._~+";
 | 
				
			||||||
 | 
					        var res = new StringBuilder();
 | 
				
			||||||
 | 
					        using (var rng = RandomNumberGenerator.Create())
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var uintBuffer = new byte[sizeof(uint)];
 | 
				
			||||||
 | 
					            while (length-- > 0)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                rng.GetBytes(uintBuffer);
 | 
				
			||||||
 | 
					                var num = BitConverter.ToUInt32(uintBuffer, 0);
 | 
				
			||||||
 | 
					                res.Append(valid[(int)(num % (uint)valid.Length)]);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return res.ToString();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async Task<List<CustomApp>> GetAppsByProjectAsync(Guid projectId)
 | 
					    public async Task<List<CustomApp>> GetAppsByProjectAsync(Guid projectId)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        return await db.CustomApps
 | 
					        return await db.CustomApps
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,5 @@
 | 
				
			|||||||
using System.ComponentModel.DataAnnotations.Schema;
 | 
					using System.ComponentModel.DataAnnotations.Schema;
 | 
				
			||||||
 | 
					using System.Text.Json.Serialization;
 | 
				
			||||||
using DysonNetwork.Develop.Project;
 | 
					using DysonNetwork.Develop.Project;
 | 
				
			||||||
using DysonNetwork.Shared.Proto;
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
using DysonNetwork.Shared.Data;
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
@@ -11,7 +12,7 @@ public class Developer
 | 
				
			|||||||
    public Guid Id { get; set; } = Guid.NewGuid();
 | 
					    public Guid Id { get; set; } = Guid.NewGuid();
 | 
				
			||||||
    public Guid PublisherId { get; set; }
 | 
					    public Guid PublisherId { get; set; }
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    public List<DevProject> Projects { get; set; } = [];
 | 
					    [JsonIgnore] public List<DevProject> Projects { get; set; } = [];
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    [NotMapped] public PublisherInfo? Publisher { get; set; }
 | 
					    [NotMapped] public PublisherInfo? Publisher { get; set; }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,7 +4,10 @@ using Microsoft.EntityFrameworkCore;
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
namespace DysonNetwork.Develop.Identity;
 | 
					namespace DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public class DeveloperService(AppDatabase db, PublisherService.PublisherServiceClient ps, ILogger<DeveloperService> logger)
 | 
					public class DeveloperService(
 | 
				
			||||||
 | 
					    AppDatabase db,
 | 
				
			||||||
 | 
					    PublisherService.PublisherServiceClient ps,
 | 
				
			||||||
 | 
					    ILogger<DeveloperService> logger)
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    public async Task<Developer> LoadDeveloperPublisher(Developer developer)
 | 
					    public async Task<Developer> LoadDeveloperPublisher(Developer developer)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
@@ -47,6 +50,11 @@ public class DeveloperService(AppDatabase db, PublisherService.PublisherServiceC
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<Developer?> GetDeveloperById(Guid id)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return await db.Developers.FirstOrDefaultAsync(d => d.Id == id);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async Task<bool> IsMemberWithRole(Guid pubId, Guid accountId, PublisherMemberRole role)
 | 
					    public async Task<bool> IsMemberWithRole(Guid pubId, Guid accountId, PublisherMemberRole role)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        try
 | 
					        try
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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:7099",
 | 
					    "Url": "https://localhost:7192"
 | 
				
			||||||
    "ClientCert": "../Certificates/client.crt",
 | 
					 | 
				
			||||||
    "ClientKey": "../Certificates/client.key"
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -31,7 +31,6 @@ public class AppDatabase(
 | 
				
			|||||||
            opt => opt
 | 
					            opt => opt
 | 
				
			||||||
                .ConfigureDataSource(optSource => optSource.EnableDynamicJson())
 | 
					                .ConfigureDataSource(optSource => optSource.EnableDynamicJson())
 | 
				
			||||||
                .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
 | 
					                .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
 | 
				
			||||||
                .UseNetTopologySuite()
 | 
					 | 
				
			||||||
                .UseNodaTime()
 | 
					                .UseNodaTime()
 | 
				
			||||||
        ).UseSnakeCaseNamingConvention();
 | 
					        ).UseSnakeCaseNamingConvention();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -35,7 +35,6 @@
 | 
				
			|||||||
        <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
 | 
					        <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
 | 
				
			||||||
        <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
 | 
					        <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
 | 
				
			||||||
        <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
 | 
					        <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
 | 
				
			||||||
        <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="9.0.4" />
 | 
					 | 
				
			||||||
        <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
 | 
					        <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
 | 
				
			||||||
        <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
 | 
					        <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
 | 
				
			||||||
        <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
 | 
					        <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
 | 
				
			||||||
@@ -67,6 +66,7 @@
 | 
				
			|||||||
    </ItemGroup>
 | 
					    </ItemGroup>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <ItemGroup>
 | 
					    <ItemGroup>
 | 
				
			||||||
 | 
					      <ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
 | 
				
			||||||
      <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
 | 
					      <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
 | 
				
			||||||
    </ItemGroup>
 | 
					    </ItemGroup>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										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,9 +18,10 @@ public class Account : ModelBase
 | 
				
			|||||||
    [MaxLength(256)] public string Name { get; set; } = string.Empty;
 | 
					    [MaxLength(256)] public string Name { get; set; } = string.Empty;
 | 
				
			||||||
    [MaxLength(256)] public string Nick { get; set; } = string.Empty;
 | 
					    [MaxLength(256)] public string Nick { get; set; } = string.Empty;
 | 
				
			||||||
    [MaxLength(32)] public string Language { get; set; } = string.Empty;
 | 
					    [MaxLength(32)] public string Language { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					    [MaxLength(32)] public string Region { get; set; } = string.Empty;
 | 
				
			||||||
    public Instant? ActivatedAt { get; set; }
 | 
					    public Instant? ActivatedAt { get; set; }
 | 
				
			||||||
    public bool IsSuperuser { get; set; } = false;
 | 
					    public bool IsSuperuser { get; set; } = false;
 | 
				
			||||||
    
 | 
					
 | 
				
			||||||
    // The ID is the BotAccount ID in the DysonNetwork.Develop
 | 
					    // The ID is the BotAccount ID in the DysonNetwork.Develop
 | 
				
			||||||
    public Guid? AutomatedId { get; set; }
 | 
					    public Guid? AutomatedId { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -46,12 +47,14 @@ public class Account : ModelBase
 | 
				
			|||||||
            Name = Name,
 | 
					            Name = Name,
 | 
				
			||||||
            Nick = Nick,
 | 
					            Nick = Nick,
 | 
				
			||||||
            Language = Language,
 | 
					            Language = Language,
 | 
				
			||||||
 | 
					            Region = Region,
 | 
				
			||||||
            ActivatedAt = ActivatedAt?.ToTimestamp(),
 | 
					            ActivatedAt = ActivatedAt?.ToTimestamp(),
 | 
				
			||||||
            IsSuperuser = IsSuperuser,
 | 
					            IsSuperuser = IsSuperuser,
 | 
				
			||||||
            Profile = Profile.ToProtoValue(),
 | 
					            Profile = Profile.ToProtoValue(),
 | 
				
			||||||
            PerkSubscription = PerkSubscription?.ToProtoValue(),
 | 
					            PerkSubscription = PerkSubscription?.ToProtoValue(),
 | 
				
			||||||
            CreatedAt = CreatedAt.ToTimestamp(),
 | 
					            CreatedAt = CreatedAt.ToTimestamp(),
 | 
				
			||||||
            UpdatedAt = UpdatedAt.ToTimestamp()
 | 
					            UpdatedAt = UpdatedAt.ToTimestamp(),
 | 
				
			||||||
 | 
					            AutomatedId = AutomatedId?.ToString()
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Add contacts
 | 
					        // Add contacts
 | 
				
			||||||
@@ -74,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
 | 
				
			||||||
@@ -81,10 +85,10 @@ public class Account : ModelBase
 | 
				
			|||||||
                : null,
 | 
					                : null,
 | 
				
			||||||
            CreatedAt = proto.CreatedAt.ToInstant(),
 | 
					            CreatedAt = proto.CreatedAt.ToInstant(),
 | 
				
			||||||
            UpdatedAt = proto.UpdatedAt.ToInstant(),
 | 
					            UpdatedAt = proto.UpdatedAt.ToInstant(),
 | 
				
			||||||
 | 
					            AutomatedId = proto.AutomatedId is not null ? Guid.Parse(proto.AutomatedId) : null,
 | 
				
			||||||
 | 
					            Profile = AccountProfile.FromProtoValue(proto.Profile)
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        account.Profile = AccountProfile.FromProtoValue(proto.Profile);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        foreach (var contactProto in proto.Contacts)
 | 
					        foreach (var contactProto in proto.Contacts)
 | 
				
			||||||
            account.Contacts.Add(AccountContact.FromProtoValue(contactProto));
 | 
					            account.Contacts.Add(AccountContact.FromProtoValue(contactProto));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -119,7 +123,7 @@ public abstract class Leveling
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
public class AccountProfile : ModelBase, IIdentifiedResource
 | 
					public class AccountProfile : ModelBase, IIdentifiedResource
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    public Guid Id { get; set; }
 | 
					    public Guid Id { get; set; } = Guid.NewGuid();
 | 
				
			||||||
    [MaxLength(256)] public string? FirstName { get; set; }
 | 
					    [MaxLength(256)] public string? FirstName { get; set; }
 | 
				
			||||||
    [MaxLength(256)] public string? MiddleName { get; set; }
 | 
					    [MaxLength(256)] public string? MiddleName { get; set; }
 | 
				
			||||||
    [MaxLength(256)] public string? LastName { get; set; }
 | 
					    [MaxLength(256)] public string? LastName { get; set; }
 | 
				
			||||||
@@ -135,9 +139,20 @@ public class AccountProfile : ModelBase, IIdentifiedResource
 | 
				
			|||||||
    [Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
 | 
					    [Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
 | 
				
			||||||
    [Column(TypeName = "jsonb")] public BadgeReferenceObject? ActiveBadge { get; set; }
 | 
					    [Column(TypeName = "jsonb")] public BadgeReferenceObject? ActiveBadge { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public int Experience { get; set; } = 0;
 | 
					    public int Experience { get; set; }
 | 
				
			||||||
    [NotMapped] public int Level => Leveling.ExperiencePerLevel.Count(xp => Experience >= xp) - 1;
 | 
					    [NotMapped] public int Level => Leveling.ExperiencePerLevel.Count(xp => Experience >= xp) - 1;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public double SocialCredits { get; set; } = 100;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [NotMapped]
 | 
				
			||||||
 | 
					    public int SocialCreditsLevel => SocialCredits switch
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        < 100 => -1,
 | 
				
			||||||
 | 
					        > 100 and < 200 => 0,
 | 
				
			||||||
 | 
					        < 200 => 1,
 | 
				
			||||||
 | 
					        _ => 2
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [NotMapped]
 | 
					    [NotMapped]
 | 
				
			||||||
    public double LevelingProgress => Level >= Leveling.ExperiencePerLevel.Count - 1
 | 
					    public double LevelingProgress => Level >= Leveling.ExperiencePerLevel.Count - 1
 | 
				
			||||||
        ? 100
 | 
					        ? 100
 | 
				
			||||||
@@ -168,6 +183,8 @@ public class AccountProfile : ModelBase, IIdentifiedResource
 | 
				
			|||||||
            Experience = Experience,
 | 
					            Experience = Experience,
 | 
				
			||||||
            Level = Level,
 | 
					            Level = Level,
 | 
				
			||||||
            LevelingProgress = LevelingProgress,
 | 
					            LevelingProgress = LevelingProgress,
 | 
				
			||||||
 | 
					            SocialCredits = SocialCredits,
 | 
				
			||||||
 | 
					            SocialCreditsLevel = SocialCreditsLevel,
 | 
				
			||||||
            Picture = Picture?.ToProtoValue(),
 | 
					            Picture = Picture?.ToProtoValue(),
 | 
				
			||||||
            Background = Background?.ToProtoValue(),
 | 
					            Background = Background?.ToProtoValue(),
 | 
				
			||||||
            AccountId = AccountId.ToString(),
 | 
					            AccountId = AccountId.ToString(),
 | 
				
			||||||
@@ -198,6 +215,7 @@ public class AccountProfile : ModelBase, IIdentifiedResource
 | 
				
			|||||||
            Verification = proto.Verification is null ? null : VerificationMark.FromProtoValue(proto.Verification),
 | 
					            Verification = proto.Verification is null ? null : VerificationMark.FromProtoValue(proto.Verification),
 | 
				
			||||||
            ActiveBadge = proto.ActiveBadge is null ? null : BadgeReferenceObject.FromProtoValue(proto.ActiveBadge),
 | 
					            ActiveBadge = proto.ActiveBadge is null ? null : BadgeReferenceObject.FromProtoValue(proto.ActiveBadge),
 | 
				
			||||||
            Experience = proto.Experience,
 | 
					            Experience = proto.Experience,
 | 
				
			||||||
 | 
					            SocialCredits = proto.SocialCredits,
 | 
				
			||||||
            Picture = proto.Picture is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Picture),
 | 
					            Picture = proto.Picture is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Picture),
 | 
				
			||||||
            Background = proto.Background is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Background),
 | 
					            Background = proto.Background is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Background),
 | 
				
			||||||
            AccountId = Guid.Parse(proto.AccountId),
 | 
					            AccountId = Guid.Parse(proto.AccountId),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,7 +1,9 @@
 | 
				
			|||||||
using System.ComponentModel.DataAnnotations;
 | 
					using System.ComponentModel.DataAnnotations;
 | 
				
			||||||
using DysonNetwork.Pass.Auth;
 | 
					using DysonNetwork.Pass.Auth;
 | 
				
			||||||
 | 
					using DysonNetwork.Pass.Credit;
 | 
				
			||||||
using DysonNetwork.Pass.Wallet;
 | 
					using DysonNetwork.Pass.Wallet;
 | 
				
			||||||
using DysonNetwork.Shared.Error;
 | 
					using DysonNetwork.Shared.Error;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.GeoIp;
 | 
				
			||||||
using Microsoft.AspNetCore.Mvc;
 | 
					using Microsoft.AspNetCore.Mvc;
 | 
				
			||||||
using Microsoft.EntityFrameworkCore;
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
using NodaTime;
 | 
					using NodaTime;
 | 
				
			||||||
@@ -15,7 +17,9 @@ public class AccountController(
 | 
				
			|||||||
    AuthService auth,
 | 
					    AuthService auth,
 | 
				
			||||||
    AccountService accounts,
 | 
					    AccountService accounts,
 | 
				
			||||||
    SubscriptionService subscriptions,
 | 
					    SubscriptionService subscriptions,
 | 
				
			||||||
    AccountEventService events
 | 
					    AccountEventService events,
 | 
				
			||||||
 | 
					    SocialCreditService socialCreditService,
 | 
				
			||||||
 | 
					    GeoIpService geo
 | 
				
			||||||
) : ControllerBase
 | 
					) : ControllerBase
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    [HttpGet("{name}")]
 | 
					    [HttpGet("{name}")]
 | 
				
			||||||
@@ -30,10 +34,10 @@ public class AccountController(
 | 
				
			|||||||
            .Where(a => a.Name == name)
 | 
					            .Where(a => a.Name == name)
 | 
				
			||||||
            .FirstOrDefaultAsync();
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
        if (account is null) return NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier));
 | 
					        if (account is null) return NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier));
 | 
				
			||||||
        
 | 
					
 | 
				
			||||||
        var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id);
 | 
					        var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id);
 | 
				
			||||||
        account.PerkSubscription = perk?.ToReference();
 | 
					        account.PerkSubscription = perk?.ToReference();
 | 
				
			||||||
        
 | 
					
 | 
				
			||||||
        return account;
 | 
					        return account;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -46,7 +50,28 @@ public class AccountController(
 | 
				
			|||||||
            .Include(e => e.Badges)
 | 
					            .Include(e => e.Badges)
 | 
				
			||||||
            .Where(a => a.Name == name)
 | 
					            .Where(a => a.Name == name)
 | 
				
			||||||
            .FirstOrDefaultAsync();
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
        return account is null ? NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier)) : account.Badges.ToList();
 | 
					        return account is null
 | 
				
			||||||
 | 
					            ? NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier))
 | 
				
			||||||
 | 
					            : account.Badges.ToList();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("{name}/credits")]
 | 
				
			||||||
 | 
					    [ProducesResponseType<double>(StatusCodes.Status200OK)]
 | 
				
			||||||
 | 
					    [ProducesResponseType(StatusCodes.Status404NotFound)]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<double>> GetSocialCredits(string name)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var account = await db.Accounts
 | 
				
			||||||
 | 
					            .Where(a => a.Name == name)
 | 
				
			||||||
 | 
					            .Select(a => new { a.Id })
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (account is null)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var credits = await socialCreditService.GetSocialCredit(account.Id);
 | 
				
			||||||
 | 
					        return credits;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public class AccountCreateRequest
 | 
					    public class AccountCreateRequest
 | 
				
			||||||
@@ -72,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;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -88,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(
 | 
				
			||||||
@@ -95,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);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
@@ -161,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; }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -24,11 +24,13 @@ public class AccountCurrentController(
 | 
				
			|||||||
    AccountEventService events,
 | 
					    AccountEventService events,
 | 
				
			||||||
    AuthService auth,
 | 
					    AuthService auth,
 | 
				
			||||||
    FileService.FileServiceClient files,
 | 
					    FileService.FileServiceClient files,
 | 
				
			||||||
    FileReferenceService.FileReferenceServiceClient fileRefs
 | 
					    FileReferenceService.FileReferenceServiceClient fileRefs,
 | 
				
			||||||
 | 
					    Credit.SocialCreditService creditService
 | 
				
			||||||
) : ControllerBase
 | 
					) : ControllerBase
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    [HttpGet]
 | 
					    [HttpGet]
 | 
				
			||||||
    [ProducesResponseType<Account>(StatusCodes.Status200OK)]
 | 
					    [ProducesResponseType<Account>(StatusCodes.Status200OK)]
 | 
				
			||||||
 | 
					    [ProducesResponseType<ApiError>(StatusCodes.Status401Unauthorized)]
 | 
				
			||||||
    public async Task<ActionResult<Account>> GetCurrentIdentity()
 | 
					    public async Task<ActionResult<Account>> GetCurrentIdentity()
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
				
			||||||
@@ -50,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]
 | 
				
			||||||
@@ -61,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);
 | 
				
			||||||
@@ -193,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
 | 
				
			||||||
@@ -201,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);
 | 
				
			||||||
@@ -221,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
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -235,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();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -268,7 +315,9 @@ public class AccountCurrentController(
 | 
				
			|||||||
            .OrderByDescending(x => x.CreatedAt)
 | 
					            .OrderByDescending(x => x.CreatedAt)
 | 
				
			||||||
            .FirstOrDefaultAsync();
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return result is null ? NotFound(ApiError.NotFound("check-in", traceId: HttpContext.TraceIdentifier)) : Ok(result);
 | 
					        return result is null
 | 
				
			||||||
 | 
					            ? NotFound(ApiError.NotFound("check-in", traceId: HttpContext.TraceIdentifier))
 | 
				
			||||||
 | 
					            : Ok(result);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [HttpPost("check-in")]
 | 
					    [HttpPost("check-in")]
 | 
				
			||||||
@@ -323,10 +372,11 @@ public class AccountCurrentController(
 | 
				
			|||||||
                        TraceId = HttpContext.TraceIdentifier
 | 
					                        TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
                true when !await auth.ValidateCaptcha(captchaToken!) => BadRequest(ApiError.Validation(new Dictionary<string, string[]>
 | 
					                true when !await auth.ValidateCaptcha(captchaToken!) => BadRequest(ApiError.Validation(
 | 
				
			||||||
                {
 | 
					                    new Dictionary<string, string[]>
 | 
				
			||||||
                    ["captchaToken"] = new[] { "Invalid captcha token." }
 | 
					                    {
 | 
				
			||||||
                }, traceId: HttpContext.TraceIdentifier)),
 | 
					                        ["captchaToken"] = new[] { "Invalid captcha token." }
 | 
				
			||||||
 | 
					                    }, traceId: HttpContext.TraceIdentifier)),
 | 
				
			||||||
                _ => await events.CheckInDaily(currentUser, backdated)
 | 
					                _ => await events.CheckInDaily(currentUser, backdated)
 | 
				
			||||||
            };
 | 
					            };
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
@@ -823,4 +873,60 @@ public class AccountCurrentController(
 | 
				
			|||||||
            return BadRequest(ex.Message);
 | 
					            return BadRequest(ex.Message);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("leveling")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<ExperienceRecord>> GetLevelingHistory(
 | 
				
			||||||
 | 
					        [FromQuery] int take = 20,
 | 
				
			||||||
 | 
					        [FromQuery] int offset = 0
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var queryable = db.ExperienceRecords
 | 
				
			||||||
 | 
					            .Where(r => r.AccountId == currentUser.Id)
 | 
				
			||||||
 | 
					            .OrderByDescending(r => r.CreatedAt)
 | 
				
			||||||
 | 
					            .AsQueryable();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var totalCount = await queryable.CountAsync();
 | 
				
			||||||
 | 
					        Response.Headers["X-Total"] = totalCount.ToString();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var records = await queryable
 | 
				
			||||||
 | 
					            .Skip(offset)
 | 
				
			||||||
 | 
					            .Take(take)
 | 
				
			||||||
 | 
					            .ToListAsync();
 | 
				
			||||||
 | 
					        return Ok(records);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("credits")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<bool>> GetSocialCredit()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var credit = await creditService.GetSocialCredit(currentUser.Id);
 | 
				
			||||||
 | 
					        return Ok(credit);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("credits/history")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<SocialCreditRecord>> GetCreditHistory(
 | 
				
			||||||
 | 
					        [FromQuery] int take = 20,
 | 
				
			||||||
 | 
					        [FromQuery] int offset = 0
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var queryable = db.SocialCreditRecords
 | 
				
			||||||
 | 
					            .Where(r => r.AccountId == currentUser.Id)
 | 
				
			||||||
 | 
					            .OrderByDescending(r => r.CreatedAt)
 | 
				
			||||||
 | 
					            .AsQueryable();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var totalCount = await queryable.CountAsync();
 | 
				
			||||||
 | 
					        Response.Headers["X-Total"] = totalCount.ToString();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var records = await queryable
 | 
				
			||||||
 | 
					            .Skip(offset)
 | 
				
			||||||
 | 
					            .Take(take)
 | 
				
			||||||
 | 
					            .ToListAsync();
 | 
				
			||||||
 | 
					        return Ok(records);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -14,8 +14,9 @@ public class AccountEventService(
 | 
				
			|||||||
    Wallet.PaymentService payment,
 | 
					    Wallet.PaymentService payment,
 | 
				
			||||||
    ICacheService cache,
 | 
					    ICacheService cache,
 | 
				
			||||||
    IStringLocalizer<Localization.AccountEventResource> localizer,
 | 
					    IStringLocalizer<Localization.AccountEventResource> localizer,
 | 
				
			||||||
    PusherService.PusherServiceClient pusher,
 | 
					    RingService.RingServiceClient pusher,
 | 
				
			||||||
    SubscriptionService subscriptions
 | 
					    SubscriptionService subscriptions,
 | 
				
			||||||
 | 
					    Pass.Leveling.ExperienceService experienceService
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    private static readonly Random Random = new();
 | 
					    private static readonly Random Random = new();
 | 
				
			||||||
@@ -327,13 +328,15 @@ public class AccountEventService(
 | 
				
			|||||||
            result.RewardPoints = null;
 | 
					            result.RewardPoints = null;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        await db.AccountProfiles
 | 
					 | 
				
			||||||
            .Where(p => p.AccountId == user.Id)
 | 
					 | 
				
			||||||
            .ExecuteUpdateAsync(s =>
 | 
					 | 
				
			||||||
                s.SetProperty(b => b.Experience, b => b.Experience + result.RewardExperience)
 | 
					 | 
				
			||||||
            );
 | 
					 | 
				
			||||||
        db.AccountCheckInResults.Add(result);
 | 
					        db.AccountCheckInResults.Add(result);
 | 
				
			||||||
        await db.SaveChangesAsync(); // Don't forget to save changes to the database
 | 
					        await db.SaveChangesAsync(); // Remember to save changes to the database
 | 
				
			||||||
 | 
					        if (result.RewardExperience is not null)
 | 
				
			||||||
 | 
					            await experienceService.AddRecord(
 | 
				
			||||||
 | 
					                "check-in",
 | 
				
			||||||
 | 
					                $"Check-in reward on {now:yyyy/MM/dd}",
 | 
				
			||||||
 | 
					                result.RewardExperience.Value,
 | 
				
			||||||
 | 
					                user.Id
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // The lock will be automatically released by the await using statement
 | 
					        // The lock will be automatically released by the await using statement
 | 
				
			||||||
        return result;
 | 
					        return result;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,12 +6,15 @@ using DysonNetwork.Pass.Email;
 | 
				
			|||||||
using DysonNetwork.Pass.Localization;
 | 
					using DysonNetwork.Pass.Localization;
 | 
				
			||||||
using DysonNetwork.Pass.Permission;
 | 
					using DysonNetwork.Pass.Permission;
 | 
				
			||||||
using DysonNetwork.Shared.Cache;
 | 
					using DysonNetwork.Shared.Cache;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
using DysonNetwork.Shared.Proto;
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
using DysonNetwork.Shared.Stream;
 | 
					using DysonNetwork.Shared.Stream;
 | 
				
			||||||
using EFCore.BulkExtensions;
 | 
					using EFCore.BulkExtensions;
 | 
				
			||||||
using Microsoft.EntityFrameworkCore;
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
using Microsoft.Extensions.Localization;
 | 
					using Microsoft.Extensions.Localization;
 | 
				
			||||||
using NATS.Client.Core;
 | 
					using NATS.Client.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;
 | 
				
			||||||
@@ -21,10 +24,13 @@ namespace DysonNetwork.Pass.Account;
 | 
				
			|||||||
public class AccountService(
 | 
					public class AccountService(
 | 
				
			||||||
    AppDatabase db,
 | 
					    AppDatabase db,
 | 
				
			||||||
    MagicSpellService spells,
 | 
					    MagicSpellService spells,
 | 
				
			||||||
 | 
					    FileService.FileServiceClient files,
 | 
				
			||||||
 | 
					    FileReferenceService.FileReferenceServiceClient fileRefs,
 | 
				
			||||||
    AccountUsernameService uname,
 | 
					    AccountUsernameService uname,
 | 
				
			||||||
    EmailService mailer,
 | 
					    EmailService mailer,
 | 
				
			||||||
    PusherService.PusherServiceClient pusher,
 | 
					    RingService.RingServiceClient pusher,
 | 
				
			||||||
    IStringLocalizer<NotificationResource> localizer,
 | 
					    IStringLocalizer<NotificationResource> localizer,
 | 
				
			||||||
 | 
					    IStringLocalizer<EmailResource> emailLocalizer,
 | 
				
			||||||
    ICacheService cache,
 | 
					    ICacheService cache,
 | 
				
			||||||
    ILogger<AccountService> logger,
 | 
					    ILogger<AccountService> logger,
 | 
				
			||||||
    INatsConnection nats
 | 
					    INatsConnection nats
 | 
				
			||||||
@@ -84,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
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
@@ -103,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()
 | 
				
			||||||
@@ -177,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)
 | 
					    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)
 | 
				
			||||||
@@ -195,8 +205,38 @@ public class AccountService(
 | 
				
			|||||||
        account.AutomatedId = automatedId;
 | 
					        account.AutomatedId = automatedId;
 | 
				
			||||||
        account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
 | 
					        account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
 | 
				
			||||||
        account.IsSuperuser = false;
 | 
					        account.IsSuperuser = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!string.IsNullOrEmpty(pictureId))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var file = await files.GetFileAsync(new GetFileRequest { Id = pictureId });
 | 
				
			||||||
 | 
					            await fileRefs.CreateReferenceAsync(
 | 
				
			||||||
 | 
					                new CreateReferenceRequest
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    ResourceId = account.Profile.ResourceIdentifier,
 | 
				
			||||||
 | 
					                    FileId = pictureId,
 | 
				
			||||||
 | 
					                    Usage = "profile.picture"
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					            account.Profile.Picture = CloudFileReferenceObject.FromProtoValue(file);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!string.IsNullOrEmpty(backgroundId))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var file = await files.GetFileAsync(new GetFileRequest { Id = backgroundId });
 | 
				
			||||||
 | 
					            await fileRefs.CreateReferenceAsync(
 | 
				
			||||||
 | 
					                new CreateReferenceRequest
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    ResourceId = account.Profile.ResourceIdentifier,
 | 
				
			||||||
 | 
					                    FileId = backgroundId,
 | 
				
			||||||
 | 
					                    Usage = "profile.background"
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					            account.Profile.Background = CloudFileReferenceObject.FromProtoValue(file);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        db.Accounts.Add(account);
 | 
					        db.Accounts.Add(account);
 | 
				
			||||||
        await db.SaveChangesAsync();
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return account;
 | 
					        return account;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -399,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;
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
@@ -413,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,
 | 
				
			||||||
@@ -701,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()
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -42,6 +42,26 @@ public class AccountServiceGrpc(
 | 
				
			|||||||
        return account.ToProtoValue();
 | 
					        return account.ToProtoValue();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public override async Task<Shared.Proto.Account> GetBotAccount(GetBotAccountRequest request,
 | 
				
			||||||
 | 
					        ServerCallContext context)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (!Guid.TryParse(request.AutomatedId, out var automatedId))
 | 
				
			||||||
 | 
					            throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid automated ID format"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var account = await _db.Accounts
 | 
				
			||||||
 | 
					            .AsNoTracking()
 | 
				
			||||||
 | 
					            .Include(a => a.Profile)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync(a => a.AutomatedId == automatedId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (account == null)
 | 
				
			||||||
 | 
					            throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Account with automated ID {request.AutomatedId} not found"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id);
 | 
				
			||||||
 | 
					        account.PerkSubscription = perk?.ToReference();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return account.ToProtoValue();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public override async Task<GetAccountBatchResponse> GetAccountBatch(GetAccountBatchRequest request,
 | 
					    public override async Task<GetAccountBatchResponse> GetAccountBatch(GetAccountBatchRequest request,
 | 
				
			||||||
        ServerCallContext context)
 | 
					        ServerCallContext context)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
@@ -56,7 +76,35 @@ public class AccountServiceGrpc(
 | 
				
			|||||||
            .Where(a => accountIds.Contains(a.Id))
 | 
					            .Where(a => accountIds.Contains(a.Id))
 | 
				
			||||||
            .Include(a => a.Profile)
 | 
					            .Include(a => a.Profile)
 | 
				
			||||||
            .ToListAsync();
 | 
					            .ToListAsync();
 | 
				
			||||||
        
 | 
					
 | 
				
			||||||
 | 
					        var perks = await subscriptions.GetPerkSubscriptionsAsync(
 | 
				
			||||||
 | 
					            accounts.Select(x => x.Id).ToList()
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        foreach (var account in accounts)
 | 
				
			||||||
 | 
					            if (perks.TryGetValue(account.Id, out var perk))
 | 
				
			||||||
 | 
					                account.PerkSubscription = perk?.ToReference();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var response = new GetAccountBatchResponse();
 | 
				
			||||||
 | 
					        response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue()));
 | 
				
			||||||
 | 
					        return response;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    public override async Task<GetAccountBatchResponse> GetBotAccountBatch(GetBotAccountBatchRequest request,
 | 
				
			||||||
 | 
					        ServerCallContext context)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var automatedIds = request.AutomatedId
 | 
				
			||||||
 | 
					            .Select(id => Guid.TryParse(id, out var automatedId) ? automatedId : (Guid?)null)
 | 
				
			||||||
 | 
					            .Where(id => id.HasValue)
 | 
				
			||||||
 | 
					            .Select(id => id!.Value)
 | 
				
			||||||
 | 
					            .ToList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var accounts = await _db.Accounts
 | 
				
			||||||
 | 
					            .AsNoTracking()
 | 
				
			||||||
 | 
					            .Where(a => a.AutomatedId != null && automatedIds.Contains(a.AutomatedId.Value))
 | 
				
			||||||
 | 
					            .Include(a => a.Profile)
 | 
				
			||||||
 | 
					            .ToListAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var perks = await subscriptions.GetPerkSubscriptionsAsync(
 | 
					        var perks = await subscriptions.GetPerkSubscriptionsAsync(
 | 
				
			||||||
            accounts.Select(x => x.Id).ToList()
 | 
					            accounts.Select(x => x.Id).ToList()
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
@@ -76,7 +124,8 @@ public class AccountServiceGrpc(
 | 
				
			|||||||
        return status.ToProtoValue();
 | 
					        return status.ToProtoValue();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public override async Task<GetAccountStatusBatchResponse> GetAccountStatusBatch(GetAccountBatchRequest request, ServerCallContext context)
 | 
					    public override async Task<GetAccountStatusBatchResponse> GetAccountStatusBatch(GetAccountBatchRequest request,
 | 
				
			||||||
 | 
					        ServerCallContext context)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var accountIds = request.Id
 | 
					        var accountIds = request.Id
 | 
				
			||||||
            .Select(id => Guid.TryParse(id, out var accountId) ? accountId : (Guid?)null)
 | 
					            .Select(id => Guid.TryParse(id, out var accountId) ? accountId : (Guid?)null)
 | 
				
			||||||
@@ -98,14 +147,14 @@ public class AccountServiceGrpc(
 | 
				
			|||||||
            .Where(a => accountNames.Contains(a.Name))
 | 
					            .Where(a => accountNames.Contains(a.Name))
 | 
				
			||||||
            .Include(a => a.Profile)
 | 
					            .Include(a => a.Profile)
 | 
				
			||||||
            .ToListAsync();
 | 
					            .ToListAsync();
 | 
				
			||||||
        
 | 
					
 | 
				
			||||||
        var perks = await subscriptions.GetPerkSubscriptionsAsync(
 | 
					        var perks = await subscriptions.GetPerkSubscriptionsAsync(
 | 
				
			||||||
            accounts.Select(x => x.Id).ToList()
 | 
					            accounts.Select(x => x.Id).ToList()
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
        foreach (var account in accounts)
 | 
					        foreach (var account in accounts)
 | 
				
			||||||
            if (perks.TryGetValue(account.Id, out var perk))
 | 
					            if (perks.TryGetValue(account.Id, out var perk))
 | 
				
			||||||
                account.PerkSubscription = perk?.ToReference();
 | 
					                account.PerkSubscription = perk?.ToReference();
 | 
				
			||||||
        
 | 
					
 | 
				
			||||||
        var response = new GetAccountBatchResponse();
 | 
					        var response = new GetAccountBatchResponse();
 | 
				
			||||||
        response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue()));
 | 
					        response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue()));
 | 
				
			||||||
        return response;
 | 
					        return response;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,8 @@
 | 
				
			|||||||
using System.ComponentModel.DataAnnotations;
 | 
					using System.ComponentModel.DataAnnotations;
 | 
				
			||||||
using System.ComponentModel.DataAnnotations.Schema;
 | 
					using System.ComponentModel.DataAnnotations.Schema;
 | 
				
			||||||
 | 
					using System.Text.Json.Serialization;
 | 
				
			||||||
using DysonNetwork.Shared.Data;
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.GeoIp;
 | 
				
			||||||
using DysonNetwork.Shared.Proto;
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
using NodaTime.Serialization.Protobuf;
 | 
					using NodaTime.Serialization.Protobuf;
 | 
				
			||||||
using Point = NetTopologySuite.Geometries.Point;
 | 
					using Point = NetTopologySuite.Geometries.Point;
 | 
				
			||||||
@@ -14,7 +16,7 @@ public class ActionLog : ModelBase
 | 
				
			|||||||
    [Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
 | 
					    [Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
 | 
				
			||||||
    [MaxLength(512)] public string? UserAgent { get; set; }
 | 
					    [MaxLength(512)] public string? UserAgent { get; set; }
 | 
				
			||||||
    [MaxLength(128)] public string? IpAddress { get; set; }
 | 
					    [MaxLength(128)] public string? IpAddress { get; set; }
 | 
				
			||||||
    public Point? Location { get; set; }
 | 
					    [Column(TypeName = "jsonb")] public GeoPoint? Location { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public Guid AccountId { get; set; }
 | 
					    public Guid AccountId { get; set; }
 | 
				
			||||||
    public Account Account { get; set; } = null!;
 | 
					    public Account Account { get; set; } = null!;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,11 +1,20 @@
 | 
				
			|||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
using DysonNetwork.Shared.Proto;
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
using Grpc.Core;
 | 
					using Grpc.Core;
 | 
				
			||||||
using NodaTime;
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
using NodaTime.Serialization.Protobuf;
 | 
					using NodaTime.Serialization.Protobuf;
 | 
				
			||||||
 | 
					using ApiKey = DysonNetwork.Shared.Proto.ApiKey;
 | 
				
			||||||
 | 
					using AuthService = DysonNetwork.Pass.Auth.AuthService;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace DysonNetwork.Pass.Account;
 | 
					namespace DysonNetwork.Pass.Account;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public class BotAccountReceiverGrpc(AppDatabase db, AccountService accounts)
 | 
					public class BotAccountReceiverGrpc(
 | 
				
			||||||
 | 
					    AppDatabase db,
 | 
				
			||||||
 | 
					    AccountService accounts,
 | 
				
			||||||
 | 
					    FileService.FileServiceClient files,
 | 
				
			||||||
 | 
					    FileReferenceService.FileReferenceServiceClient fileRefs,
 | 
				
			||||||
 | 
					    AuthService authService
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
    : BotAccountReceiverService.BotAccountReceiverServiceBase
 | 
					    : BotAccountReceiverService.BotAccountReceiverServiceBase
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    public override async Task<CreateBotAccountResponse> CreateBotAccount(
 | 
					    public override async Task<CreateBotAccountResponse> CreateBotAccount(
 | 
				
			||||||
@@ -14,7 +23,12 @@ public class BotAccountReceiverGrpc(AppDatabase db, AccountService accounts)
 | 
				
			|||||||
    )
 | 
					    )
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var account = Account.FromProtoValue(request.Account);
 | 
					        var account = Account.FromProtoValue(request.Account);
 | 
				
			||||||
        account = await accounts.CreateBotAccount(account, Guid.Parse(request.AutomatedId));
 | 
					        account = await accounts.CreateBotAccount(
 | 
				
			||||||
 | 
					            account,
 | 
				
			||||||
 | 
					            Guid.Parse(request.AutomatedId),
 | 
				
			||||||
 | 
					            request.PictureId,
 | 
				
			||||||
 | 
					            request.BackgroundId
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return new CreateBotAccountResponse
 | 
					        return new CreateBotAccountResponse
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
@@ -34,16 +48,44 @@ public class BotAccountReceiverGrpc(AppDatabase db, AccountService accounts)
 | 
				
			|||||||
        ServerCallContext context
 | 
					        ServerCallContext context
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var automatedId = Guid.Parse(request.AutomatedId);
 | 
					        var account = Account.FromProtoValue(request.Account);
 | 
				
			||||||
        var account = await accounts.GetBotAccount(automatedId);
 | 
					
 | 
				
			||||||
        if (account is null)
 | 
					        if (request.PictureId is not null)
 | 
				
			||||||
            throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found"));
 | 
					        {
 | 
				
			||||||
 | 
					            var file = await files.GetFileAsync(new GetFileRequest { Id = request.PictureId });
 | 
				
			||||||
 | 
					            if (account.Profile.Picture is not null)
 | 
				
			||||||
 | 
					                await fileRefs.DeleteResourceReferencesAsync(
 | 
				
			||||||
 | 
					                    new DeleteResourceReferencesRequest { ResourceId = account.Profile.ResourceIdentifier }
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					            await fileRefs.CreateReferenceAsync(
 | 
				
			||||||
 | 
					                new CreateReferenceRequest
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    ResourceId = account.Profile.ResourceIdentifier,
 | 
				
			||||||
 | 
					                    FileId = request.PictureId,
 | 
				
			||||||
 | 
					                    Usage = "profile.picture"
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					            account.Profile.Picture = CloudFileReferenceObject.FromProtoValue(file);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (request.BackgroundId is not null)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var file = await files.GetFileAsync(new GetFileRequest { Id = request.BackgroundId });
 | 
				
			||||||
 | 
					            if (account.Profile.Background is not null)
 | 
				
			||||||
 | 
					                await fileRefs.DeleteResourceReferencesAsync(
 | 
				
			||||||
 | 
					                    new DeleteResourceReferencesRequest { ResourceId = account.Profile.ResourceIdentifier }
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					            await fileRefs.CreateReferenceAsync(
 | 
				
			||||||
 | 
					                new CreateReferenceRequest
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    ResourceId = account.Profile.ResourceIdentifier,
 | 
				
			||||||
 | 
					                    FileId = request.BackgroundId,
 | 
				
			||||||
 | 
					                    Usage = "profile.background"
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					            account.Profile.Background = CloudFileReferenceObject.FromProtoValue(file);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        account.Name = request.Account.Name;
 | 
					 | 
				
			||||||
        account.Nick = request.Account.Nick;
 | 
					 | 
				
			||||||
        account.Profile = AccountProfile.FromProtoValue(request.Account.Profile);
 | 
					 | 
				
			||||||
        account.Language = request.Account.Language;
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        db.Accounts.Update(account);
 | 
					        db.Accounts.Update(account);
 | 
				
			||||||
        await db.SaveChangesAsync();
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -56,7 +98,7 @@ public class BotAccountReceiverGrpc(AppDatabase db, AccountService accounts)
 | 
				
			|||||||
                CreatedAt = account.CreatedAt.ToTimestamp(),
 | 
					                CreatedAt = account.CreatedAt.ToTimestamp(),
 | 
				
			||||||
                UpdatedAt = account.UpdatedAt.ToTimestamp(),
 | 
					                UpdatedAt = account.UpdatedAt.ToTimestamp(),
 | 
				
			||||||
                IsActive = true
 | 
					                IsActive = true
 | 
				
			||||||
            } 
 | 
					            }
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -68,10 +110,109 @@ public class BotAccountReceiverGrpc(AppDatabase db, AccountService accounts)
 | 
				
			|||||||
        var automatedId = Guid.Parse(request.AutomatedId);
 | 
					        var automatedId = Guid.Parse(request.AutomatedId);
 | 
				
			||||||
        var account = await accounts.GetBotAccount(automatedId);
 | 
					        var account = await accounts.GetBotAccount(automatedId);
 | 
				
			||||||
        if (account is null)
 | 
					        if (account is null)
 | 
				
			||||||
            throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found"));
 | 
					            throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.NotFound, "Account not found"));
 | 
				
			||||||
        
 | 
					
 | 
				
			||||||
        await accounts.DeleteAccount(account);
 | 
					        await accounts.DeleteAccount(account);
 | 
				
			||||||
        
 | 
					
 | 
				
			||||||
        return new DeleteBotAccountResponse();
 | 
					        return new DeleteBotAccountResponse();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public override async Task<ApiKey> GetApiKey(GetApiKeyRequest request, ServerCallContext context)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var keyId = Guid.Parse(request.Id);
 | 
				
			||||||
 | 
					        var key = await db.ApiKeys
 | 
				
			||||||
 | 
					            .Include(k => k.Account)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync(k => k.Id == keyId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (key == null)
 | 
				
			||||||
 | 
					            throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return key.ToProtoValue();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public override async Task<GetApiKeyBatchResponse> ListApiKey(ListApiKeyRequest request, ServerCallContext context)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var automatedId = Guid.Parse(request.AutomatedId);
 | 
				
			||||||
 | 
					        var account = await accounts.GetBotAccount(automatedId);
 | 
				
			||||||
 | 
					        if (account == null)
 | 
				
			||||||
 | 
					            throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var keys = await db.ApiKeys
 | 
				
			||||||
 | 
					            .Where(k => k.AccountId == account.Id)
 | 
				
			||||||
 | 
					            .Select(k => k.ToProtoValue())
 | 
				
			||||||
 | 
					            .ToListAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var response = new GetApiKeyBatchResponse();
 | 
				
			||||||
 | 
					        response.Data.AddRange(keys);
 | 
				
			||||||
 | 
					        return response;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public override async Task<ApiKey> CreateApiKey(ApiKey request, ServerCallContext context)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var accountId = Guid.Parse(request.AccountId);
 | 
				
			||||||
 | 
					        var account = await accounts.GetBotAccount(accountId);
 | 
				
			||||||
 | 
					        if (account == null)
 | 
				
			||||||
 | 
					            throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (string.IsNullOrWhiteSpace(request.Label))
 | 
				
			||||||
 | 
					            throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Label is required"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var key = await authService.CreateApiKey(account.Id, request.Label, null);
 | 
				
			||||||
 | 
					        key.Key = await authService.IssueApiKeyToken(key);
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        return key.ToProtoValue();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public override async Task<ApiKey> UpdateApiKey(ApiKey request, ServerCallContext context)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var keyId = Guid.Parse(request.Id);
 | 
				
			||||||
 | 
					        var accountId = Guid.Parse(request.AccountId);
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        var key = await db.ApiKeys
 | 
				
			||||||
 | 
					            .Include(k => k.Session)
 | 
				
			||||||
 | 
					            .Where(k => k.Id == keyId && k.AccountId == accountId)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					        if (key == null)
 | 
				
			||||||
 | 
					            throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Only update the label if provided
 | 
				
			||||||
 | 
					        if (string.IsNullOrWhiteSpace(request.Label)) return key.ToProtoValue();
 | 
				
			||||||
 | 
					        key.Label = request.Label;
 | 
				
			||||||
 | 
					        db.ApiKeys.Update(key);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return key.ToProtoValue();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public override async Task<ApiKey> RotateApiKey(GetApiKeyRequest request, ServerCallContext context)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var keyId = Guid.Parse(request.Id);
 | 
				
			||||||
 | 
					        var key = await db.ApiKeys
 | 
				
			||||||
 | 
					            .Include(k => k.Session)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync(k => k.Id == keyId);
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					        if (key == null)
 | 
				
			||||||
 | 
					            throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        key = await authService.RotateApiKeyToken(key);
 | 
				
			||||||
 | 
					        key.Key = await authService.IssueApiKeyToken(key);
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        return key.ToProtoValue();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public override async Task<DeleteApiKeyResponse> DeleteApiKey(GetApiKeyRequest request, ServerCallContext context)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var keyId = Guid.Parse(request.Id);
 | 
				
			||||||
 | 
					        var key = await db.ApiKeys
 | 
				
			||||||
 | 
					            .Include(k => k.Session)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync(k => k.Id == keyId);
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					        if (key == null)
 | 
				
			||||||
 | 
					            throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await authService.RevokeApiKeyToken(key);
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        return new DeleteApiKeyResponse { Success = true };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -23,6 +23,12 @@ public class Status : ModelBase
 | 
				
			|||||||
    public bool IsNotDisturb { get; set; }
 | 
					    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,7 +1,11 @@
 | 
				
			|||||||
using System.Linq.Expressions;
 | 
					using System.Linq.Expressions;
 | 
				
			||||||
using System.Reflection;
 | 
					using System.Reflection;
 | 
				
			||||||
 | 
					using System.Text.Json;
 | 
				
			||||||
 | 
					using System.Text.Json.Serialization;
 | 
				
			||||||
using DysonNetwork.Pass.Account;
 | 
					using DysonNetwork.Pass.Account;
 | 
				
			||||||
using DysonNetwork.Pass.Auth;
 | 
					using DysonNetwork.Pass.Auth;
 | 
				
			||||||
 | 
					using DysonNetwork.Pass.Credit;
 | 
				
			||||||
 | 
					using DysonNetwork.Pass.Leveling;
 | 
				
			||||||
using DysonNetwork.Pass.Permission;
 | 
					using DysonNetwork.Pass.Permission;
 | 
				
			||||||
using DysonNetwork.Pass.Wallet;
 | 
					using DysonNetwork.Pass.Wallet;
 | 
				
			||||||
using DysonNetwork.Shared.Data;
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
@@ -38,6 +42,7 @@ public class AppDatabase(
 | 
				
			|||||||
    public DbSet<AuthSession> AuthSessions { get; set; } = null!;
 | 
					    public DbSet<AuthSession> AuthSessions { get; set; } = null!;
 | 
				
			||||||
    public DbSet<AuthChallenge> AuthChallenges { get; set; } = null!;
 | 
					    public DbSet<AuthChallenge> AuthChallenges { get; set; } = null!;
 | 
				
			||||||
    public DbSet<AuthClient> AuthClients { get; set; } = null!;
 | 
					    public DbSet<AuthClient> AuthClients { get; set; } = null!;
 | 
				
			||||||
 | 
					    public DbSet<ApiKey> ApiKeys { get; set; } = null!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public DbSet<Wallet.Wallet> Wallets { get; set; } = null!;
 | 
					    public DbSet<Wallet.Wallet> Wallets { get; set; } = null!;
 | 
				
			||||||
    public DbSet<WalletPocket> WalletPockets { get; set; } = null!;
 | 
					    public DbSet<WalletPocket> WalletPockets { get; set; } = null!;
 | 
				
			||||||
@@ -48,14 +53,22 @@ public class AppDatabase(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    public DbSet<Punishment> Punishments { get; set; } = null!;
 | 
					    public DbSet<Punishment> Punishments { get; set; } = null!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public DbSet<SocialCreditRecord> SocialCreditRecords { get; set; } = null!;
 | 
				
			||||||
 | 
					    public DbSet<ExperienceRecord> ExperienceRecords { get; set; } = null!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
 | 
					    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        optionsBuilder.UseNpgsql(
 | 
					        optionsBuilder.UseNpgsql(
 | 
				
			||||||
            configuration.GetConnectionString("App"),
 | 
					            configuration.GetConnectionString("App"),
 | 
				
			||||||
            opt => opt
 | 
					            opt => opt
 | 
				
			||||||
                .ConfigureDataSource(optSource => optSource.EnableDynamicJson())
 | 
					                .ConfigureDataSource(optSource => optSource
 | 
				
			||||||
 | 
					                    .EnableDynamicJson()
 | 
				
			||||||
 | 
					                    .ConfigureJsonOptions(new JsonSerializerOptions()
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
 | 
				
			||||||
 | 
					                    })
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
                .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
 | 
					                .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
 | 
				
			||||||
                .UseNetTopologySuite()
 | 
					 | 
				
			||||||
                .UseNodaTime()
 | 
					                .UseNodaTime()
 | 
				
			||||||
        ).UseSnakeCaseNamingConvention();
 | 
					        ).UseSnakeCaseNamingConvention();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -270,4 +283,4 @@ public static class OptionalQueryExtensions
 | 
				
			|||||||
    {
 | 
					    {
 | 
				
			||||||
        return condition ? transform(source) : source;
 | 
					        return condition ? transform(source) : source;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
							
								
								
									
										50
									
								
								DysonNetwork.Pass/Auth/ApiKey.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								DysonNetwork.Pass/Auth/ApiKey.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,50 @@
 | 
				
			|||||||
 | 
					using System.ComponentModel.DataAnnotations;
 | 
				
			||||||
 | 
					using System.ComponentModel.DataAnnotations.Schema;
 | 
				
			||||||
 | 
					using System.Text.Json.Serialization;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
 | 
					using NodaTime.Serialization.Protobuf;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Pass.Auth;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class ApiKey : ModelBase
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public Guid Id { get; set; } = Guid.NewGuid();
 | 
				
			||||||
 | 
					    [MaxLength(1024)] public string Label { get; set; } = null!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public Guid AccountId { get; set; }
 | 
				
			||||||
 | 
					    public Account.Account Account { get; set; } = null!;
 | 
				
			||||||
 | 
					    public Guid SessionId { get; set; }
 | 
				
			||||||
 | 
					    public AuthSession Session { get; set; } = null!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [NotMapped]
 | 
				
			||||||
 | 
					    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
 | 
				
			||||||
 | 
					    public string? Key { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public DysonNetwork.Shared.Proto.ApiKey ToProtoValue()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return new DysonNetwork.Shared.Proto.ApiKey
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Id = Id.ToString(),
 | 
				
			||||||
 | 
					            Label = Label,
 | 
				
			||||||
 | 
					            AccountId = AccountId.ToString(),
 | 
				
			||||||
 | 
					            SessionId = SessionId.ToString(),
 | 
				
			||||||
 | 
					            Key = Key,
 | 
				
			||||||
 | 
					            CreatedAt = CreatedAt.ToTimestamp(),
 | 
				
			||||||
 | 
					            UpdatedAt = UpdatedAt.ToTimestamp()
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static ApiKey FromProtoValue(DysonNetwork.Shared.Proto.ApiKey proto)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return new ApiKey
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Id = Guid.Parse(proto.Id),
 | 
				
			||||||
 | 
					            AccountId = Guid.Parse(proto.AccountId),
 | 
				
			||||||
 | 
					            SessionId = Guid.Parse(proto.SessionId),
 | 
				
			||||||
 | 
					            Label = proto.Label,
 | 
				
			||||||
 | 
					            Key = proto.Key,
 | 
				
			||||||
 | 
					            CreatedAt = proto.CreatedAt.ToInstant(),
 | 
				
			||||||
 | 
					            UpdatedAt = proto.UpdatedAt.ToInstant()
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										90
									
								
								DysonNetwork.Pass/Auth/ApiKeyController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								DysonNetwork.Pass/Auth/ApiKeyController.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,90 @@
 | 
				
			|||||||
 | 
					using System.ComponentModel.DataAnnotations;
 | 
				
			||||||
 | 
					using Microsoft.AspNetCore.Authorization;
 | 
				
			||||||
 | 
					using Microsoft.AspNetCore.Mvc;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Pass.Auth;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[ApiController]
 | 
				
			||||||
 | 
					[Route("/api/auth/keys")]
 | 
				
			||||||
 | 
					public class ApiKeyController(AppDatabase db, AuthService auth) : ControllerBase
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    [HttpGet]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> GetKeys([FromQuery] int offset = 0, [FromQuery] int take = 20)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var query = db.ApiKeys
 | 
				
			||||||
 | 
					            .Where(e => e.AccountId == currentUser.Id)
 | 
				
			||||||
 | 
					            .AsQueryable();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var totalCount = await query.CountAsync();
 | 
				
			||||||
 | 
					        Response.Headers["X-Total"] = totalCount.ToString();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var keys = await query
 | 
				
			||||||
 | 
					            .Skip(offset)
 | 
				
			||||||
 | 
					            .Take(take)
 | 
				
			||||||
 | 
					            .ToListAsync();
 | 
				
			||||||
 | 
					        return Ok(keys);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("{id:guid}")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> GetKey(Guid id)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var key = await db.ApiKeys
 | 
				
			||||||
 | 
					            .Where(e => e.AccountId == currentUser.Id)
 | 
				
			||||||
 | 
					            .Where(e => e.Id == id)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					        if (key == null) return NotFound();
 | 
				
			||||||
 | 
					        return Ok(key);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public class ApiKeyRequest
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        [MaxLength(1024)] public string? Label { get; set; }
 | 
				
			||||||
 | 
					        public Instant? ExpiredAt { get; set; }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> CreateKey([FromBody] ApiKeyRequest request)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (string.IsNullOrWhiteSpace(request.Label))
 | 
				
			||||||
 | 
					            return BadRequest("Label is required");
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var key = await auth.CreateApiKey(currentUser.Id, request.Label, request.ExpiredAt);
 | 
				
			||||||
 | 
					        key.Key = await auth.IssueApiKeyToken(key);
 | 
				
			||||||
 | 
					        return Ok(key);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost("{id:guid}/rotate")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> RotateKey(Guid id)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        var key = await auth.GetApiKey(id, currentUser.Id);
 | 
				
			||||||
 | 
					        if(key is null) return NotFound();
 | 
				
			||||||
 | 
					        key = await auth.RotateApiKeyToken(key);
 | 
				
			||||||
 | 
					        key.Key = await auth.IssueApiKeyToken(key);
 | 
				
			||||||
 | 
					        return Ok(key);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpDelete("{id:guid}")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> DeleteKey(Guid id)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        var key = await auth.GetApiKey(id, currentUser.Id);
 | 
				
			||||||
 | 
					        if(key is null) return NotFound();
 | 
				
			||||||
 | 
					        await auth.RevokeApiKeyToken(key);
 | 
				
			||||||
 | 
					        return NoContent();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -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
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
@@ -318,6 +319,87 @@ public class AuthService(
 | 
				
			|||||||
        return factor.VerifyPassword(pinCode);
 | 
					        return factor.VerifyPassword(pinCode);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<ApiKey?> GetApiKey(Guid id, Guid? accountId = null)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var key = await db.ApiKeys
 | 
				
			||||||
 | 
					            .Include(e => e.Session)
 | 
				
			||||||
 | 
					            .Where(e => e.Id == id)
 | 
				
			||||||
 | 
					            .If(accountId.HasValue, q => q.Where(e => e.AccountId == accountId!.Value))
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					        return key;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<ApiKey> CreateApiKey(Guid accountId, string label, Instant? expiredAt = null)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var key = new ApiKey
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            AccountId = accountId,
 | 
				
			||||||
 | 
					            Label = label,
 | 
				
			||||||
 | 
					            Session = new AuthSession
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                AccountId = accountId,
 | 
				
			||||||
 | 
					                ExpiredAt = expiredAt
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        db.ApiKeys.Add(key);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return key;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<string> IssueApiKeyToken(ApiKey key)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        key.Session.LastGrantedAt = SystemClock.Instance.GetCurrentInstant();
 | 
				
			||||||
 | 
					        db.Update(key.Session);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					        var tk = CreateToken(key.Session);
 | 
				
			||||||
 | 
					        return tk;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task RevokeApiKeyToken(ApiKey key)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        db.Remove(key);
 | 
				
			||||||
 | 
					        db.Remove(key.Session);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<ApiKey> RotateApiKeyToken(ApiKey key)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        await using var transaction = await db.Database.BeginTransactionAsync();
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var oldSessionId = key.SessionId;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Create new session
 | 
				
			||||||
 | 
					            var newSession = new AuthSession
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                AccountId = key.AccountId,
 | 
				
			||||||
 | 
					                ExpiredAt = key.Session?.ExpiredAt
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            db.AuthSessions.Add(newSession);
 | 
				
			||||||
 | 
					            await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Update ApiKey to point to new session
 | 
				
			||||||
 | 
					            key.SessionId = newSession.Id;
 | 
				
			||||||
 | 
					            key.Session = newSession;
 | 
				
			||||||
 | 
					            db.ApiKeys.Update(key);
 | 
				
			||||||
 | 
					            await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Delete old session
 | 
				
			||||||
 | 
					            await db.AuthSessions.Where(s => s.Id == oldSessionId).ExecuteDeleteAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            await transaction.CommitAsync();
 | 
				
			||||||
 | 
					            return key;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            await transaction.RollbackAsync();
 | 
				
			||||||
 | 
					            throw;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Helper methods for Base64Url encoding/decoding
 | 
					    // Helper methods for Base64Url encoding/decoding
 | 
				
			||||||
    private static string Base64UrlEncode(byte[] data)
 | 
					    private static string Base64UrlEncode(byte[] data)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
@@ -329,7 +411,7 @@ public class AuthService(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    private static byte[] Base64UrlDecode(string base64Url)
 | 
					    private static byte[] Base64UrlDecode(string base64Url)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        string padded = base64Url
 | 
					        var padded = base64Url
 | 
				
			||||||
            .Replace('-', '+')
 | 
					            .Replace('-', '+')
 | 
				
			||||||
            .Replace('_', '/');
 | 
					            .Replace('_', '/');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,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,7 +2,7 @@ using System.ComponentModel.DataAnnotations;
 | 
				
			|||||||
using System.ComponentModel.DataAnnotations.Schema;
 | 
					using System.ComponentModel.DataAnnotations.Schema;
 | 
				
			||||||
using System.Text.Json.Serialization;
 | 
					using System.Text.Json.Serialization;
 | 
				
			||||||
using DysonNetwork.Shared.Data;
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
using Microsoft.EntityFrameworkCore;
 | 
					using DysonNetwork.Shared.GeoIp;
 | 
				
			||||||
using NodaTime;
 | 
					using NodaTime;
 | 
				
			||||||
using NodaTime.Serialization.Protobuf;
 | 
					using NodaTime.Serialization.Protobuf;
 | 
				
			||||||
using Point = NetTopologySuite.Geometries.Point;
 | 
					using Point = NetTopologySuite.Geometries.Point;
 | 
				
			||||||
@@ -12,26 +12,28 @@ namespace DysonNetwork.Pass.Auth;
 | 
				
			|||||||
public class AuthSession : ModelBase
 | 
					public class AuthSession : ModelBase
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    public Guid Id { get; set; } = Guid.NewGuid();
 | 
					    public Guid Id { get; set; } = Guid.NewGuid();
 | 
				
			||||||
    [MaxLength(1024)] public string? Label { get; set; }
 | 
					 | 
				
			||||||
    public Instant? LastGrantedAt { get; set; }
 | 
					    public Instant? LastGrantedAt { get; set; }
 | 
				
			||||||
    public Instant? ExpiredAt { get; set; }
 | 
					    public Instant? ExpiredAt { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public Guid AccountId { get; set; }
 | 
					    public Guid AccountId { get; set; }
 | 
				
			||||||
    [JsonIgnore] public Account.Account Account { get; set; } = null!;
 | 
					    [JsonIgnore] public Account.Account Account { get; set; } = null!;
 | 
				
			||||||
    public Guid ChallengeId { get; set; }
 | 
					
 | 
				
			||||||
    public AuthChallenge Challenge { get; set; } = null!;
 | 
					    // When the challenge is null, indicates the session is for an API Key
 | 
				
			||||||
 | 
					    public Guid? ChallengeId { get; set; }
 | 
				
			||||||
 | 
					    public AuthChallenge? Challenge { get; set; } = null!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Indicates the session is for an OIDC connection
 | 
				
			||||||
    public Guid? AppId { get; set; }
 | 
					    public Guid? AppId { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public Shared.Proto.AuthSession ToProtoValue() => new()
 | 
					    public Shared.Proto.AuthSession ToProtoValue() => new()
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        Id = Id.ToString(),
 | 
					        Id = Id.ToString(),
 | 
				
			||||||
        Label = Label,
 | 
					 | 
				
			||||||
        LastGrantedAt = LastGrantedAt?.ToTimestamp(),
 | 
					        LastGrantedAt = LastGrantedAt?.ToTimestamp(),
 | 
				
			||||||
        ExpiredAt = ExpiredAt?.ToTimestamp(),
 | 
					        ExpiredAt = ExpiredAt?.ToTimestamp(),
 | 
				
			||||||
        AccountId = AccountId.ToString(),
 | 
					        AccountId = AccountId.ToString(),
 | 
				
			||||||
        Account = Account.ToProtoValue(),
 | 
					        Account = Account.ToProtoValue(),
 | 
				
			||||||
        ChallengeId = ChallengeId.ToString(),
 | 
					        ChallengeId = ChallengeId.ToString(),
 | 
				
			||||||
        Challenge = Challenge.ToProtoValue(),
 | 
					        Challenge = Challenge?.ToProtoValue(),
 | 
				
			||||||
        AppId = AppId?.ToString()
 | 
					        AppId = AppId?.ToString()
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -68,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!;
 | 
				
			||||||
@@ -128,4 +130,4 @@ public class AuthClientWithChallenge : AuthClient
 | 
				
			|||||||
            AccountId = client.AccountId,
 | 
					            AccountId = client.AccountId,
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -5,8 +5,10 @@ using Microsoft.AspNetCore.Authorization;
 | 
				
			|||||||
using Microsoft.AspNetCore.Mvc;
 | 
					using Microsoft.AspNetCore.Mvc;
 | 
				
			||||||
using Microsoft.Extensions.Options;
 | 
					using Microsoft.Extensions.Options;
 | 
				
			||||||
using System.Text.Json.Serialization;
 | 
					using System.Text.Json.Serialization;
 | 
				
			||||||
 | 
					using System.Web;
 | 
				
			||||||
using DysonNetwork.Pass.Account;
 | 
					using DysonNetwork.Pass.Account;
 | 
				
			||||||
using DysonNetwork.Pass.Auth.OidcProvider.Options;
 | 
					using DysonNetwork.Pass.Auth.OidcProvider.Options;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
using Microsoft.EntityFrameworkCore;
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
using Microsoft.IdentityModel.Tokens;
 | 
					using Microsoft.IdentityModel.Tokens;
 | 
				
			||||||
using NodaTime;
 | 
					using NodaTime;
 | 
				
			||||||
@@ -19,10 +21,199 @@ public class OidcProviderController(
 | 
				
			|||||||
    AppDatabase db,
 | 
					    AppDatabase db,
 | 
				
			||||||
    OidcProviderService oidcService,
 | 
					    OidcProviderService oidcService,
 | 
				
			||||||
    IConfiguration configuration,
 | 
					    IConfiguration configuration,
 | 
				
			||||||
    IOptions<OidcProviderOptions> options
 | 
					    IOptions<OidcProviderOptions> options,
 | 
				
			||||||
)
 | 
					    ILogger<OidcProviderController> logger
 | 
				
			||||||
    : ControllerBase
 | 
					) : ControllerBase
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
 | 
					    [HttpGet("authorize")]
 | 
				
			||||||
 | 
					    [Produces("application/json")]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> Authorize(
 | 
				
			||||||
 | 
					        [FromQuery(Name = "client_id")] string clientId,
 | 
				
			||||||
 | 
					        [FromQuery(Name = "response_type")] string responseType,
 | 
				
			||||||
 | 
					        [FromQuery(Name = "redirect_uri")] string? redirectUri = null,
 | 
				
			||||||
 | 
					        [FromQuery] string? scope = null,
 | 
				
			||||||
 | 
					        [FromQuery] string? state = null,
 | 
				
			||||||
 | 
					        [FromQuery(Name = "response_mode")] string? responseMode = null,
 | 
				
			||||||
 | 
					        [FromQuery] string? nonce = null,
 | 
				
			||||||
 | 
					        [FromQuery] string? display = null,
 | 
				
			||||||
 | 
					        [FromQuery] string? prompt = null,
 | 
				
			||||||
 | 
					        [FromQuery(Name = "code_challenge")] string? codeChallenge = null,
 | 
				
			||||||
 | 
					        [FromQuery(Name = "code_challenge_method")]
 | 
				
			||||||
 | 
					        string? codeChallengeMethod = null)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (string.IsNullOrEmpty(clientId))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(new ErrorResponse
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Error = "invalid_request",
 | 
				
			||||||
 | 
					                ErrorDescription = "client_id is required"
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var client = await oidcService.FindClientBySlugAsync(clientId);
 | 
				
			||||||
 | 
					        if (client == null)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(new ErrorResponse
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Error = "unauthorized_client",
 | 
				
			||||||
 | 
					                ErrorDescription = "Client not found"
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Validate response_type
 | 
				
			||||||
 | 
					        if (string.IsNullOrEmpty(responseType))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(new ErrorResponse
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Error = "invalid_request",
 | 
				
			||||||
 | 
					                ErrorDescription = "response_type is required"
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Check if the client is allowed to use the requested response type
 | 
				
			||||||
 | 
					        var allowedResponseTypes = new[] { "code", "token", "id_token" };
 | 
				
			||||||
 | 
					        var requestedResponseTypes = responseType.Split(' ', StringSplitOptions.RemoveEmptyEntries);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (requestedResponseTypes.Any(rt => !allowedResponseTypes.Contains(rt)))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(new ErrorResponse
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Error = "unsupported_response_type",
 | 
				
			||||||
 | 
					                ErrorDescription = "The requested response type is not supported"
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Validate redirect_uri if provided
 | 
				
			||||||
 | 
					        if (!string.IsNullOrEmpty(redirectUri) &&
 | 
				
			||||||
 | 
					            !await oidcService.ValidateRedirectUriAsync(Guid.Parse(client.Id), redirectUri))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(new ErrorResponse
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Error = "invalid_request",
 | 
				
			||||||
 | 
					                ErrorDescription = "Invalid redirect_uri"
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Return client information
 | 
				
			||||||
 | 
					        var clientInfo = new ClientInfoResponse
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            ClientId = Guid.Parse(client.Id),
 | 
				
			||||||
 | 
					            Picture = client.Picture is not null ? CloudFileReferenceObject.FromProtoValue(client.Picture) : null,
 | 
				
			||||||
 | 
					            Background = client.Background is not null
 | 
				
			||||||
 | 
					                ? CloudFileReferenceObject.FromProtoValue(client.Background)
 | 
				
			||||||
 | 
					                : null,
 | 
				
			||||||
 | 
					            ClientName = client.Name,
 | 
				
			||||||
 | 
					            HomeUri = client.Links.HomePage,
 | 
				
			||||||
 | 
					            PolicyUri = client.Links.PrivacyPolicy,
 | 
				
			||||||
 | 
					            TermsOfServiceUri = client.Links.TermsOfService,
 | 
				
			||||||
 | 
					            ResponseTypes = responseType,
 | 
				
			||||||
 | 
					            Scopes = scope?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? [],
 | 
				
			||||||
 | 
					            State = state,
 | 
				
			||||||
 | 
					            Nonce = nonce,
 | 
				
			||||||
 | 
					            CodeChallenge = codeChallenge,
 | 
				
			||||||
 | 
					            CodeChallengeMethod = codeChallengeMethod
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Ok(clientInfo);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost("authorize")]
 | 
				
			||||||
 | 
					    [Consumes("application/x-www-form-urlencoded")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> HandleAuthorizationResponse(
 | 
				
			||||||
 | 
					        [FromForm(Name = "authorize")] string? authorize,
 | 
				
			||||||
 | 
					        [FromForm(Name = "client_id")] string clientId,
 | 
				
			||||||
 | 
					        [FromForm(Name = "redirect_uri")] string? redirectUri = null,
 | 
				
			||||||
 | 
					        [FromForm] string? scope = null,
 | 
				
			||||||
 | 
					        [FromForm] string? state = null,
 | 
				
			||||||
 | 
					        [FromForm] string? nonce = null,
 | 
				
			||||||
 | 
					        [FromForm(Name = "code_challenge")] string? codeChallenge = null,
 | 
				
			||||||
 | 
					        [FromForm(Name = "code_challenge_method")]
 | 
				
			||||||
 | 
					        string? codeChallengeMethod = null)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account.Account account)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Find the client
 | 
				
			||||||
 | 
					        var client = await oidcService.FindClientBySlugAsync(clientId);
 | 
				
			||||||
 | 
					        if (client == null)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(new ErrorResponse
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Error = "unauthorized_client",
 | 
				
			||||||
 | 
					                ErrorDescription = "Client not found"
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // If user denied the request
 | 
				
			||||||
 | 
					        if (string.IsNullOrEmpty(authorize) || !bool.TryParse(authorize, out var isAuthorized) || !isAuthorized)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var errorUri = new UriBuilder(redirectUri ?? client.Links?.HomePage ?? "https://example.com");
 | 
				
			||||||
 | 
					            var queryParams = HttpUtility.ParseQueryString(errorUri.Query);
 | 
				
			||||||
 | 
					            queryParams["error"] = "access_denied";
 | 
				
			||||||
 | 
					            queryParams["error_description"] = "The user denied the authorization request";
 | 
				
			||||||
 | 
					            if (!string.IsNullOrEmpty(state)) queryParams["state"] = state;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            errorUri.Query = queryParams.ToString();
 | 
				
			||||||
 | 
					            return Ok(new { redirectUri = errorUri.Uri.ToString() });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Validate redirect_uri if provided
 | 
				
			||||||
 | 
					        if (!string.IsNullOrEmpty(redirectUri) &&
 | 
				
			||||||
 | 
					            !await oidcService.ValidateRedirectUriAsync(Guid.Parse(client!.Id), redirectUri))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(new ErrorResponse
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Error = "invalid_request",
 | 
				
			||||||
 | 
					                ErrorDescription = "Invalid redirect_uri"
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Default to client's first redirect URI if not provided
 | 
				
			||||||
 | 
					        redirectUri ??= client.OauthConfig?.RedirectUris?.FirstOrDefault();
 | 
				
			||||||
 | 
					        if (string.IsNullOrEmpty(redirectUri))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(new ErrorResponse
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Error = "invalid_request",
 | 
				
			||||||
 | 
					                ErrorDescription = "No valid redirect_uri available"
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            // Generate authorization code and create session
 | 
				
			||||||
 | 
					            var authorizationCode = await oidcService.GenerateAuthorizationCodeAsync(
 | 
				
			||||||
 | 
					                Guid.Parse(client.Id),
 | 
				
			||||||
 | 
					                account.Id,
 | 
				
			||||||
 | 
					                redirectUri,
 | 
				
			||||||
 | 
					                scope?.Split(' ') ?? [],
 | 
				
			||||||
 | 
					                codeChallenge,
 | 
				
			||||||
 | 
					                codeChallengeMethod,
 | 
				
			||||||
 | 
					                nonce
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Build the redirect URI with the authorization code
 | 
				
			||||||
 | 
					            var redirectBuilder = new UriBuilder(redirectUri);
 | 
				
			||||||
 | 
					            var queryParams = HttpUtility.ParseQueryString(redirectBuilder.Query);
 | 
				
			||||||
 | 
					            queryParams["code"] = authorizationCode;
 | 
				
			||||||
 | 
					            if (!string.IsNullOrEmpty(state)) queryParams["state"] = state;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            redirectBuilder.Query = queryParams.ToString();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            return Ok(new { redirectUri = redirectBuilder.Uri.ToString() });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            logger.LogError(ex, "Error processing authorization request");
 | 
				
			||||||
 | 
					            return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Error = "server_error",
 | 
				
			||||||
 | 
					                ErrorDescription = "An error occurred while processing your request"
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [HttpPost("token")]
 | 
					    [HttpPost("token")]
 | 
				
			||||||
    [Consumes("application/x-www-form-urlencoded")]
 | 
					    [Consumes("application/x-www-form-urlencoded")]
 | 
				
			||||||
    public async Task<IActionResult> Token([FromForm] TokenRequest request)
 | 
					    public async Task<IActionResult> Token([FromForm] TokenRequest request)
 | 
				
			||||||
@@ -35,74 +226,74 @@ public class OidcProviderController(
 | 
				
			|||||||
            case "authorization_code" when request.Code == null:
 | 
					            case "authorization_code" when request.Code == null:
 | 
				
			||||||
                return BadRequest("Authorization code is required");
 | 
					                return BadRequest("Authorization code is required");
 | 
				
			||||||
            case "authorization_code":
 | 
					            case "authorization_code":
 | 
				
			||||||
                {
 | 
					            {
 | 
				
			||||||
                    var client = await oidcService.FindClientByIdAsync(request.ClientId.Value);
 | 
					                var client = await oidcService.FindClientBySlugAsync(request.ClientId);
 | 
				
			||||||
                    if (client == null ||
 | 
					                if (client == null ||
 | 
				
			||||||
                        !await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret))
 | 
					                    !await oidcService.ValidateClientCredentialsAsync(Guid.Parse(client.Id), request.ClientSecret))
 | 
				
			||||||
                        return BadRequest(new ErrorResponse
 | 
					                    return BadRequest(new ErrorResponse
 | 
				
			||||||
                        { Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
 | 
					                        { Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    // Generate tokens
 | 
					                // Generate tokens
 | 
				
			||||||
                    var tokenResponse = await oidcService.GenerateTokenResponseAsync(
 | 
					                var tokenResponse = await oidcService.GenerateTokenResponseAsync(
 | 
				
			||||||
                        clientId: request.ClientId.Value,
 | 
					                    clientId: Guid.Parse(client.Id),
 | 
				
			||||||
                        authorizationCode: request.Code!,
 | 
					                    authorizationCode: request.Code!,
 | 
				
			||||||
                        redirectUri: request.RedirectUri,
 | 
					                    redirectUri: request.RedirectUri,
 | 
				
			||||||
                        codeVerifier: request.CodeVerifier
 | 
					                    codeVerifier: request.CodeVerifier
 | 
				
			||||||
                    );
 | 
					                );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    return Ok(tokenResponse);
 | 
					                return Ok(tokenResponse);
 | 
				
			||||||
                }
 | 
					            }
 | 
				
			||||||
            case "refresh_token" when string.IsNullOrEmpty(request.RefreshToken):
 | 
					            case "refresh_token" when string.IsNullOrEmpty(request.RefreshToken):
 | 
				
			||||||
                return BadRequest(new ErrorResponse
 | 
					                return BadRequest(new ErrorResponse
 | 
				
			||||||
                { Error = "invalid_request", ErrorDescription = "Refresh token is required" });
 | 
					                    { Error = "invalid_request", ErrorDescription = "Refresh token is required" });
 | 
				
			||||||
            case "refresh_token":
 | 
					            case "refresh_token":
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                try
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
                    try
 | 
					                    // Decode the base64 refresh token to get the session ID
 | 
				
			||||||
                    {
 | 
					                    var sessionIdBytes = Convert.FromBase64String(request.RefreshToken);
 | 
				
			||||||
                        // Decode the base64 refresh token to get the session ID
 | 
					                    var sessionId = new Guid(sessionIdBytes);
 | 
				
			||||||
                        var sessionIdBytes = Convert.FromBase64String(request.RefreshToken);
 | 
					 | 
				
			||||||
                        var sessionId = new Guid(sessionIdBytes);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
                        // Find the session and related data
 | 
					                    // Find the session and related data
 | 
				
			||||||
                        var session = await oidcService.FindSessionByIdAsync(sessionId);
 | 
					                    var session = await oidcService.FindSessionByIdAsync(sessionId);
 | 
				
			||||||
                        var now = SystemClock.Instance.GetCurrentInstant();
 | 
					                    var now = SystemClock.Instance.GetCurrentInstant();
 | 
				
			||||||
                        if (session?.AppId is null || session.ExpiredAt < now)
 | 
					                    if (session?.AppId is null || session.ExpiredAt < now)
 | 
				
			||||||
                        {
 | 
					 | 
				
			||||||
                            return BadRequest(new ErrorResponse
 | 
					 | 
				
			||||||
                            {
 | 
					 | 
				
			||||||
                                Error = "invalid_grant",
 | 
					 | 
				
			||||||
                                ErrorDescription = "Invalid or expired refresh token"
 | 
					 | 
				
			||||||
                            });
 | 
					 | 
				
			||||||
                        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                        // Get the client
 | 
					 | 
				
			||||||
                        var client = await oidcService.FindClientByIdAsync(session.AppId.Value);
 | 
					 | 
				
			||||||
                        if (client == null)
 | 
					 | 
				
			||||||
                        {
 | 
					 | 
				
			||||||
                            return BadRequest(new ErrorResponse
 | 
					 | 
				
			||||||
                            {
 | 
					 | 
				
			||||||
                                Error = "invalid_client",
 | 
					 | 
				
			||||||
                                ErrorDescription = "Client not found"
 | 
					 | 
				
			||||||
                            });
 | 
					 | 
				
			||||||
                        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                        // Generate new tokens
 | 
					 | 
				
			||||||
                        var tokenResponse = await oidcService.GenerateTokenResponseAsync(
 | 
					 | 
				
			||||||
                            clientId: session.AppId!.Value,
 | 
					 | 
				
			||||||
                            sessionId: session.Id
 | 
					 | 
				
			||||||
                        );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                        return Ok(tokenResponse);
 | 
					 | 
				
			||||||
                    }
 | 
					 | 
				
			||||||
                    catch (FormatException)
 | 
					 | 
				
			||||||
                    {
 | 
					                    {
 | 
				
			||||||
                        return BadRequest(new ErrorResponse
 | 
					                        return BadRequest(new ErrorResponse
 | 
				
			||||||
                        {
 | 
					                        {
 | 
				
			||||||
                            Error = "invalid_grant",
 | 
					                            Error = "invalid_grant",
 | 
				
			||||||
                            ErrorDescription = "Invalid refresh token format"
 | 
					                            ErrorDescription = "Invalid or expired refresh token"
 | 
				
			||||||
                        });
 | 
					                        });
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    // Get the client
 | 
				
			||||||
 | 
					                    var client = await oidcService.FindClientByIdAsync(session.AppId.Value);
 | 
				
			||||||
 | 
					                    if (client == null)
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        return BadRequest(new ErrorResponse
 | 
				
			||||||
 | 
					                        {
 | 
				
			||||||
 | 
					                            Error = "invalid_client",
 | 
				
			||||||
 | 
					                            ErrorDescription = "Client not found"
 | 
				
			||||||
 | 
					                        });
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    // Generate new tokens
 | 
				
			||||||
 | 
					                    var tokenResponse = await oidcService.GenerateTokenResponseAsync(
 | 
				
			||||||
 | 
					                        clientId: session.AppId!.Value,
 | 
				
			||||||
 | 
					                        sessionId: session.Id
 | 
				
			||||||
 | 
					                    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    return Ok(tokenResponse);
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
 | 
					                catch (FormatException)
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    return BadRequest(new ErrorResponse
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        Error = "invalid_grant",
 | 
				
			||||||
 | 
					                        ErrorDescription = "Invalid refresh token format"
 | 
				
			||||||
 | 
					                    });
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
            default:
 | 
					            default:
 | 
				
			||||||
                return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" });
 | 
					                return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" });
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
@@ -116,7 +307,7 @@ public class OidcProviderController(
 | 
				
			|||||||
            HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
 | 
					            HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Get requested scopes from the token
 | 
					        // Get requested scopes from the token
 | 
				
			||||||
        var scopes = currentSession.Challenge.Scopes;
 | 
					        var scopes = currentSession.Challenge?.Scopes ?? [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var userInfo = new Dictionary<string, object>
 | 
					        var userInfo = new Dictionary<string, object>
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
@@ -150,10 +341,10 @@ public class OidcProviderController(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        return Ok(new
 | 
					        return Ok(new
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            issuer = issuer,
 | 
					            issuer,
 | 
				
			||||||
            authorization_endpoint = $"{baseUrl}/auth/authorize",
 | 
					            authorization_endpoint = $"{baseUrl}/auth/authorize",
 | 
				
			||||||
            token_endpoint = $"{baseUrl}/auth/open/token",
 | 
					            token_endpoint = $"{baseUrl}/api/auth/open/token",
 | 
				
			||||||
            userinfo_endpoint = $"{baseUrl}/auth/open/userinfo",
 | 
					            userinfo_endpoint = $"{baseUrl}/api/auth/open/userinfo",
 | 
				
			||||||
            jwks_uri = $"{baseUrl}/.well-known/jwks",
 | 
					            jwks_uri = $"{baseUrl}/.well-known/jwks",
 | 
				
			||||||
            scopes_supported = new[] { "openid", "profile", "email" },
 | 
					            scopes_supported = new[] { "openid", "profile", "email" },
 | 
				
			||||||
            response_types_supported = new[]
 | 
					            response_types_supported = new[]
 | 
				
			||||||
@@ -220,7 +411,7 @@ public class TokenRequest
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    [JsonPropertyName("client_id")]
 | 
					    [JsonPropertyName("client_id")]
 | 
				
			||||||
    [FromForm(Name = "client_id")]
 | 
					    [FromForm(Name = "client_id")]
 | 
				
			||||||
    public Guid? ClientId { get; set; }
 | 
					    public string? ClientId { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [JsonPropertyName("client_secret")]
 | 
					    [JsonPropertyName("client_secret")]
 | 
				
			||||||
    [FromForm(Name = "client_secret")]
 | 
					    [FromForm(Name = "client_secret")]
 | 
				
			||||||
@@ -237,4 +428,4 @@ public class TokenRequest
 | 
				
			|||||||
    [JsonPropertyName("code_verifier")]
 | 
					    [JsonPropertyName("code_verifier")]
 | 
				
			||||||
    [FromForm(Name = "code_verifier")]
 | 
					    [FromForm(Name = "code_verifier")]
 | 
				
			||||||
    public string? CodeVerifier { get; set; }
 | 
					    public string? CodeVerifier { get; set; }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -0,0 +1,21 @@
 | 
				
			|||||||
 | 
					using System.Text.Json.Serialization;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Pass.Auth.OidcProvider.Responses;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class ClientInfoResponse
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public Guid ClientId { get; set; }
 | 
				
			||||||
 | 
					    public CloudFileReferenceObject? Picture { get; set; }
 | 
				
			||||||
 | 
					    public CloudFileReferenceObject? Background { get; set; }
 | 
				
			||||||
 | 
					    public string? ClientName { get; set; }
 | 
				
			||||||
 | 
					    public string? HomeUri { get; set; }
 | 
				
			||||||
 | 
					    public string? PolicyUri { get; set; }
 | 
				
			||||||
 | 
					    public string? TermsOfServiceUri { get; set; }
 | 
				
			||||||
 | 
					    public string? ResponseTypes { get; set; }
 | 
				
			||||||
 | 
					    public string[]? Scopes { get; set; }
 | 
				
			||||||
 | 
					    public string? State { get; set; }
 | 
				
			||||||
 | 
					    public string? Nonce { get; set; }
 | 
				
			||||||
 | 
					    public string? CodeChallenge { get; set; }
 | 
				
			||||||
 | 
					    public string? CodeChallengeMethod { get; set; }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -20,7 +20,6 @@ public class TokenResponse
 | 
				
			|||||||
    [JsonPropertyName("scope")]
 | 
					    [JsonPropertyName("scope")]
 | 
				
			||||||
    public string? Scope { get; set; }
 | 
					    public string? Scope { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
    [JsonPropertyName("id_token")]
 | 
					    [JsonPropertyName("id_token")]
 | 
				
			||||||
    public string? IdToken { get; set; }
 | 
					    public string? IdToken { get; set; }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -11,6 +11,7 @@ using Microsoft.EntityFrameworkCore;
 | 
				
			|||||||
using Microsoft.Extensions.Options;
 | 
					using Microsoft.Extensions.Options;
 | 
				
			||||||
using Microsoft.IdentityModel.Tokens;
 | 
					using Microsoft.IdentityModel.Tokens;
 | 
				
			||||||
using NodaTime;
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					using AccountContactType = DysonNetwork.Pass.Account.AccountContactType;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace DysonNetwork.Pass.Auth.OidcProvider.Services;
 | 
					namespace DysonNetwork.Pass.Auth.OidcProvider.Services;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -31,15 +32,31 @@ public class OidcProviderService(
 | 
				
			|||||||
        return resp.App ?? null;
 | 
					        return resp.App ?? null;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async Task<AuthSession?> FindValidSessionAsync(Guid accountId, Guid clientId)
 | 
					    public async Task<CustomApp?> FindClientBySlugAsync(string slug)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var resp = await customApps.GetCustomAppAsync(new GetCustomAppRequest { Slug = slug });
 | 
				
			||||||
 | 
					        return resp.App ?? null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<AuthSession?> FindValidSessionAsync(Guid accountId, Guid clientId, bool withAccount = false)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
					        var now = SystemClock.Instance.GetCurrentInstant();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return await db.AuthSessions
 | 
					        var queryable = db.AuthSessions
 | 
				
			||||||
            .Include(s => s.Challenge)
 | 
					            .Include(s => s.Challenge)
 | 
				
			||||||
 | 
					            .AsQueryable();
 | 
				
			||||||
 | 
					        if (withAccount)
 | 
				
			||||||
 | 
					            queryable = queryable
 | 
				
			||||||
 | 
					                .Include(s => s.Account)
 | 
				
			||||||
 | 
					                .ThenInclude(a => a.Profile)
 | 
				
			||||||
 | 
					                .Include(a => a.Account.Contacts)
 | 
				
			||||||
 | 
					                .AsQueryable();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return await queryable
 | 
				
			||||||
            .Where(s => s.AccountId == accountId &&
 | 
					            .Where(s => s.AccountId == accountId &&
 | 
				
			||||||
                        s.AppId == clientId &&
 | 
					                        s.AppId == clientId &&
 | 
				
			||||||
                        (s.ExpiredAt == null || s.ExpiredAt > now) &&
 | 
					                        (s.ExpiredAt == null || s.ExpiredAt > now) &&
 | 
				
			||||||
 | 
					                        s.Challenge != null &&
 | 
				
			||||||
                        s.Challenge.Type == ChallengeType.OAuth)
 | 
					                        s.Challenge.Type == ChallengeType.OAuth)
 | 
				
			||||||
            .OrderByDescending(s => s.CreatedAt)
 | 
					            .OrderByDescending(s => s.CreatedAt)
 | 
				
			||||||
            .FirstOrDefaultAsync();
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
@@ -56,6 +73,149 @@ public class OidcProviderService(
 | 
				
			|||||||
        return resp.Valid;
 | 
					        return resp.Valid;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<bool> ValidateRedirectUriAsync(Guid clientId, string redirectUri)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (string.IsNullOrEmpty(redirectUri))
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var client = await FindClientByIdAsync(clientId);
 | 
				
			||||||
 | 
					        if (client?.Status != CustomAppStatus.Production)
 | 
				
			||||||
 | 
					            return true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (client?.OauthConfig?.RedirectUris == null)
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Check if the redirect URI matches any of the allowed URIs
 | 
				
			||||||
 | 
					        // For exact match
 | 
				
			||||||
 | 
					        if (client.OauthConfig.RedirectUris.Contains(redirectUri))
 | 
				
			||||||
 | 
					            return true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Check for wildcard matches (e.g., https://*.example.com/*)
 | 
				
			||||||
 | 
					        foreach (var allowedUri in client.OauthConfig.RedirectUris)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            if (string.IsNullOrEmpty(allowedUri))
 | 
				
			||||||
 | 
					                continue;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Handle wildcard in domain
 | 
				
			||||||
 | 
					            if (allowedUri.Contains("*.") && allowedUri.StartsWith("http"))
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                try
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    var allowedUriObj = new Uri(allowedUri);
 | 
				
			||||||
 | 
					                    var redirectUriObj = new Uri(redirectUri);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    if (allowedUriObj.Scheme != redirectUriObj.Scheme ||
 | 
				
			||||||
 | 
					                        allowedUriObj.Port != redirectUriObj.Port)
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        continue;
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    // Check if the domain matches the wildcard pattern
 | 
				
			||||||
 | 
					                    var allowedDomain = allowedUriObj.Host;
 | 
				
			||||||
 | 
					                    var redirectDomain = redirectUriObj.Host;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    if (allowedDomain.StartsWith("*."))
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        var baseDomain = allowedDomain[2..]; // Remove the "*." prefix
 | 
				
			||||||
 | 
					                        if (redirectDomain == baseDomain || redirectDomain.EndsWith($".{baseDomain}"))
 | 
				
			||||||
 | 
					                        {
 | 
				
			||||||
 | 
					                            // Check path
 | 
				
			||||||
 | 
					                            var allowedPath = allowedUriObj.AbsolutePath.TrimEnd('/');
 | 
				
			||||||
 | 
					                            var redirectPath = redirectUriObj.AbsolutePath.TrimEnd('/');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                            if (string.IsNullOrEmpty(allowedPath) ||
 | 
				
			||||||
 | 
					                                redirectPath.StartsWith(allowedPath, StringComparison.OrdinalIgnoreCase))
 | 
				
			||||||
 | 
					                            {
 | 
				
			||||||
 | 
					                                return true;
 | 
				
			||||||
 | 
					                            }
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                catch (UriFormatException)
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    // Invalid URI format in allowed URIs, skip
 | 
				
			||||||
 | 
					                    continue;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private string GenerateIdToken(
 | 
				
			||||||
 | 
					        CustomApp client,
 | 
				
			||||||
 | 
					        AuthSession session,
 | 
				
			||||||
 | 
					        string? nonce = null,
 | 
				
			||||||
 | 
					        IEnumerable<string>? scopes = null
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var tokenHandler = new JwtSecurityTokenHandler();
 | 
				
			||||||
 | 
					        var clock = SystemClock.Instance;
 | 
				
			||||||
 | 
					        var now = clock.GetCurrentInstant();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var claims = new List<Claim>
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            new(JwtRegisteredClaimNames.Iss, _options.IssuerUri),
 | 
				
			||||||
 | 
					            new(JwtRegisteredClaimNames.Sub, session.AccountId.ToString()),
 | 
				
			||||||
 | 
					            new(JwtRegisteredClaimNames.Aud, client.Slug),
 | 
				
			||||||
 | 
					            new(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
 | 
				
			||||||
 | 
					            new(JwtRegisteredClaimNames.Exp,
 | 
				
			||||||
 | 
					                now.Plus(Duration.FromSeconds(_options.AccessTokenLifetime.TotalSeconds)).ToUnixTimeSeconds()
 | 
				
			||||||
 | 
					                    .ToString(), ClaimValueTypes.Integer64),
 | 
				
			||||||
 | 
					            new(JwtRegisteredClaimNames.AuthTime, session.CreatedAt.ToUnixTimeSeconds().ToString(),
 | 
				
			||||||
 | 
					                ClaimValueTypes.Integer64),
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Add nonce if provided (required for implicit and hybrid flows)
 | 
				
			||||||
 | 
					        if (!string.IsNullOrEmpty(nonce))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            claims.Add(new Claim("nonce", nonce));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Add email claim if email scope is requested
 | 
				
			||||||
 | 
					        var scopesList = scopes?.ToList() ?? [];
 | 
				
			||||||
 | 
					        if (scopesList.Contains("email"))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var contact = session.Account.Contacts.FirstOrDefault(c => c.Type == AccountContactType.Email);
 | 
				
			||||||
 | 
					            if (contact is not null)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                claims.Add(new Claim(JwtRegisteredClaimNames.Email, contact.Content));
 | 
				
			||||||
 | 
					                claims.Add(new Claim("email_verified", contact.VerifiedAt is not null ? "true" : "false",
 | 
				
			||||||
 | 
					                    ClaimValueTypes.Boolean));
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Add profile claims if profile scope is requested
 | 
				
			||||||
 | 
					        if (scopes != null && scopesList.Contains("profile"))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            if (!string.IsNullOrEmpty(session.Account.Name))
 | 
				
			||||||
 | 
					                claims.Add(new Claim("preferred_username", session.Account.Name));
 | 
				
			||||||
 | 
					            if (!string.IsNullOrEmpty(session.Account.Nick))
 | 
				
			||||||
 | 
					                claims.Add(new Claim("name", session.Account.Nick));
 | 
				
			||||||
 | 
					            if (!string.IsNullOrEmpty(session.Account.Profile.FirstName))
 | 
				
			||||||
 | 
					                claims.Add(new Claim("given_name", session.Account.Profile.FirstName));
 | 
				
			||||||
 | 
					            if (!string.IsNullOrEmpty(session.Account.Profile.LastName))
 | 
				
			||||||
 | 
					                claims.Add(new Claim("family_name", session.Account.Profile.LastName));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var tokenDescriptor = new SecurityTokenDescriptor
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Subject = new ClaimsIdentity(claims),
 | 
				
			||||||
 | 
					            Issuer = _options.IssuerUri,
 | 
				
			||||||
 | 
					            Audience = client.Id.ToString(),
 | 
				
			||||||
 | 
					            Expires = now.Plus(Duration.FromSeconds(_options.AccessTokenLifetime.TotalSeconds)).ToDateTimeUtc(),
 | 
				
			||||||
 | 
					            NotBefore = now.ToDateTimeUtc(),
 | 
				
			||||||
 | 
					            SigningCredentials = new SigningCredentials(
 | 
				
			||||||
 | 
					                new RsaSecurityKey(_options.GetRsaPrivateKey()),
 | 
				
			||||||
 | 
					                SecurityAlgorithms.RsaSha256
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var token = tokenHandler.CreateToken(tokenDescriptor);
 | 
				
			||||||
 | 
					        return tokenHandler.WriteToken(token);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async Task<TokenResponse> GenerateTokenResponseAsync(
 | 
					    public async Task<TokenResponse> GenerateTokenResponseAsync(
 | 
				
			||||||
        Guid clientId,
 | 
					        Guid clientId,
 | 
				
			||||||
        string? authorizationCode = null,
 | 
					        string? authorizationCode = null,
 | 
				
			||||||
@@ -71,24 +231,43 @@ public class OidcProviderService(
 | 
				
			|||||||
        AuthSession session;
 | 
					        AuthSession session;
 | 
				
			||||||
        var clock = SystemClock.Instance;
 | 
					        var clock = SystemClock.Instance;
 | 
				
			||||||
        var now = clock.GetCurrentInstant();
 | 
					        var now = clock.GetCurrentInstant();
 | 
				
			||||||
 | 
					        string? nonce = null;
 | 
				
			||||||
        List<string>? scopes = null;
 | 
					        List<string>? scopes = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (authorizationCode != null)
 | 
					        if (authorizationCode != null)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            // Authorization code flow
 | 
					            // Authorization code flow
 | 
				
			||||||
            var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier);
 | 
					            var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier);
 | 
				
			||||||
            if (authCode is null) throw new InvalidOperationException("Invalid authorization code");
 | 
					            if (authCode == null)
 | 
				
			||||||
            var account = await db.Accounts.Where(a => a.Id == authCode.AccountId).FirstOrDefaultAsync();
 | 
					                throw new InvalidOperationException("Invalid authorization code");
 | 
				
			||||||
            if (account is null) throw new InvalidOperationException("Account was not found");
 | 
					
 | 
				
			||||||
 | 
					            // Load the session for the user
 | 
				
			||||||
 | 
					            var existingSession = await FindValidSessionAsync(authCode.AccountId, clientId, withAccount: true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (existingSession is null)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                var account = await db.Accounts
 | 
				
			||||||
 | 
					                    .Where(a => a.Id == authCode.AccountId)
 | 
				
			||||||
 | 
					                    .Include(a => a.Profile)
 | 
				
			||||||
 | 
					                    .Include(a => a.Contacts)
 | 
				
			||||||
 | 
					                    .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					                if (account is null) throw new InvalidOperationException("Account not found");
 | 
				
			||||||
 | 
					                session = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant(), clientId);
 | 
				
			||||||
 | 
					                session.Account = account;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            else
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                session = existingSession;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            session = await auth.CreateSessionForOidcAsync(account, now, clientId);
 | 
					 | 
				
			||||||
            scopes = authCode.Scopes;
 | 
					            scopes = authCode.Scopes;
 | 
				
			||||||
 | 
					            nonce = authCode.Nonce;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        else if (sessionId.HasValue)
 | 
					        else if (sessionId.HasValue)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            // Refresh token flow
 | 
					            // Refresh token flow
 | 
				
			||||||
            session = await FindSessionByIdAsync(sessionId.Value) ??
 | 
					            session = await FindSessionByIdAsync(sessionId.Value) ??
 | 
				
			||||||
                      throw new InvalidOperationException("Invalid session");
 | 
					                      throw new InvalidOperationException("Session not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // Verify the session is still valid
 | 
					            // Verify the session is still valid
 | 
				
			||||||
            if (session.ExpiredAt < now)
 | 
					            if (session.ExpiredAt < now)
 | 
				
			||||||
@@ -102,13 +281,15 @@ public class OidcProviderService(
 | 
				
			|||||||
        var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
 | 
					        var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
 | 
				
			||||||
        var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
 | 
					        var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Generate an access token
 | 
					        // Generate tokens
 | 
				
			||||||
        var accessToken = GenerateJwtToken(client, session, expiresAt, scopes);
 | 
					        var accessToken = GenerateJwtToken(client, session, expiresAt, scopes);
 | 
				
			||||||
 | 
					        var idToken = GenerateIdToken(client, session, nonce, scopes);
 | 
				
			||||||
        var refreshToken = GenerateRefreshToken(session);
 | 
					        var refreshToken = GenerateRefreshToken(session);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return new TokenResponse
 | 
					        return new TokenResponse
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            AccessToken = accessToken,
 | 
					            AccessToken = accessToken,
 | 
				
			||||||
 | 
					            IdToken = idToken,
 | 
				
			||||||
            ExpiresIn = expiresIn,
 | 
					            ExpiresIn = expiresIn,
 | 
				
			||||||
            TokenType = "Bearer",
 | 
					            TokenType = "Bearer",
 | 
				
			||||||
            RefreshToken = refreshToken,
 | 
					            RefreshToken = refreshToken,
 | 
				
			||||||
@@ -134,11 +315,10 @@ public class OidcProviderService(
 | 
				
			|||||||
                new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()),
 | 
					                new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()),
 | 
				
			||||||
                new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(),
 | 
					                new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(),
 | 
				
			||||||
                    ClaimValueTypes.Integer64),
 | 
					                    ClaimValueTypes.Integer64),
 | 
				
			||||||
                new Claim("client_id", client.Id)
 | 
					 | 
				
			||||||
            ]),
 | 
					            ]),
 | 
				
			||||||
            Expires = expiresAt.ToDateTimeUtc(),
 | 
					            Expires = expiresAt.ToDateTimeUtc(),
 | 
				
			||||||
            Issuer = _options.IssuerUri,
 | 
					            Issuer = _options.IssuerUri,
 | 
				
			||||||
            Audience = client.Id
 | 
					            Audience = client.Slug
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Try to use RSA signing if keys are available, fall back to HMAC
 | 
					        // Try to use RSA signing if keys are available, fall back to HMAC
 | 
				
			||||||
@@ -204,51 +384,6 @@ public class OidcProviderService(
 | 
				
			|||||||
        return Convert.ToBase64String(session.Id.ToByteArray());
 | 
					        return Convert.ToBase64String(session.Id.ToByteArray());
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private static bool VerifyHashedSecret(string secret, string hashedSecret)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        // In a real implementation, you'd use a proper password hashing algorithm like PBKDF2, bcrypt, or Argon2
 | 
					 | 
				
			||||||
        // For now, we'll do a simple comparison, but you should replace this with proper hashing
 | 
					 | 
				
			||||||
        return string.Equals(secret, hashedSecret, StringComparison.Ordinal);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public async Task<string> GenerateAuthorizationCodeForReuseSessionAsync(
 | 
					 | 
				
			||||||
        AuthSession session,
 | 
					 | 
				
			||||||
        Guid clientId,
 | 
					 | 
				
			||||||
        string redirectUri,
 | 
					 | 
				
			||||||
        IEnumerable<string> scopes,
 | 
					 | 
				
			||||||
        string? codeChallenge = null,
 | 
					 | 
				
			||||||
        string? codeChallengeMethod = null,
 | 
					 | 
				
			||||||
        string? nonce = null)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        var clock = SystemClock.Instance;
 | 
					 | 
				
			||||||
        var now = clock.GetCurrentInstant();
 | 
					 | 
				
			||||||
        var code = Guid.NewGuid().ToString("N");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        // Update the session's last activity time
 | 
					 | 
				
			||||||
        await db.AuthSessions.Where(s => s.Id == session.Id)
 | 
					 | 
				
			||||||
            .ExecuteUpdateAsync(s => s.SetProperty(s => s.LastGrantedAt, now));
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        // Create the authorization code info
 | 
					 | 
				
			||||||
        var authCodeInfo = new AuthorizationCodeInfo
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            ClientId = clientId,
 | 
					 | 
				
			||||||
            AccountId = session.AccountId,
 | 
					 | 
				
			||||||
            RedirectUri = redirectUri,
 | 
					 | 
				
			||||||
            Scopes = scopes.ToList(),
 | 
					 | 
				
			||||||
            CodeChallenge = codeChallenge,
 | 
					 | 
				
			||||||
            CodeChallengeMethod = codeChallengeMethod,
 | 
					 | 
				
			||||||
            Nonce = nonce,
 | 
					 | 
				
			||||||
            CreatedAt = now
 | 
					 | 
				
			||||||
        };
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        // Store the code with its metadata in the cache
 | 
					 | 
				
			||||||
        var cacheKey = $"auth:code:{code}";
 | 
					 | 
				
			||||||
        await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, session.AccountId);
 | 
					 | 
				
			||||||
        return code;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public async Task<string> GenerateAuthorizationCodeAsync(
 | 
					    public async Task<string> GenerateAuthorizationCodeAsync(
 | 
				
			||||||
        Guid clientId,
 | 
					        Guid clientId,
 | 
				
			||||||
        Guid userId,
 | 
					        Guid userId,
 | 
				
			||||||
@@ -278,7 +413,7 @@ public class OidcProviderService(
 | 
				
			|||||||
        };
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Store the code with its metadata in the cache
 | 
					        // Store the code with its metadata in the cache
 | 
				
			||||||
        var cacheKey = $"auth:code:{code}";
 | 
					        var cacheKey = $"auth:oidc-code:{code}";
 | 
				
			||||||
        await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
 | 
					        await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId);
 | 
					        logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId);
 | 
				
			||||||
@@ -292,7 +427,7 @@ public class OidcProviderService(
 | 
				
			|||||||
        string? codeVerifier = null
 | 
					        string? codeVerifier = null
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var cacheKey = $"auth:code:{code}";
 | 
					        var cacheKey = $"auth:oidc-code:{code}";
 | 
				
			||||||
        var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey);
 | 
					        var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (!found || authCode == null)
 | 
					        if (!found || authCode == null)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -340,7 +340,7 @@ public class ConnectionController(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant());
 | 
					        var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant());
 | 
				
			||||||
        var loginToken = auth.CreateToken(loginSession);
 | 
					        var loginToken = auth.CreateToken(loginSession);
 | 
				
			||||||
        return Redirect($"/auth/token?token={loginToken}");
 | 
					        return Redirect($"/auth/callback?token={loginToken}");
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private static async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request)
 | 
					    private static async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -84,6 +84,7 @@ public class OidcState
 | 
				
			|||||||
    {
 | 
					    {
 | 
				
			||||||
        return JsonSerializer.Serialize(this, new JsonSerializerOptions
 | 
					        return JsonSerializer.Serialize(this, new JsonSerializerOptions
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
 | 
					            NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
 | 
				
			||||||
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
 | 
					            PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
 | 
				
			||||||
            DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
 | 
					            DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
@@ -32,6 +34,11 @@ public class TokenAuthService(
 | 
				
			|||||||
                logger.LogWarning("AuthenticateTokenAsync: no token provided");
 | 
					                logger.LogWarning("AuthenticateTokenAsync: no token provided");
 | 
				
			||||||
                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)));
 | 
				
			||||||
@@ -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);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -60,6 +60,12 @@ const router = createRouter({
 | 
				
			|||||||
      name: 'authCallback',
 | 
					      name: 'authCallback',
 | 
				
			||||||
      component: () => import('../views/callback.vue'),
 | 
					      component: () => import('../views/callback.vue'),
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      path: '/auth/authorize',
 | 
				
			||||||
 | 
					      name: 'authAuthorize',
 | 
				
			||||||
 | 
					      component: () => import('../views/authorize.vue'),
 | 
				
			||||||
 | 
					      meta: { requiresAuth: true },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
      path: '/:notFound(.*)',
 | 
					      path: '/:notFound(.*)',
 | 
				
			||||||
      name: 'errorNotFound',
 | 
					      name: 'errorNotFound',
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,191 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div class="flex items-center justify-center h-full p-4">
 | 
				
			||||||
 | 
					    <n-card class="w-full max-w-md" title="Authorize Application">
 | 
				
			||||||
 | 
					      <n-spin :show="isLoading">
 | 
				
			||||||
 | 
					        <div v-if="error" class="mb-4">
 | 
				
			||||||
 | 
					          <n-alert type="error" :title="error" closable @close="error = null" />
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <!-- App Info Section -->
 | 
				
			||||||
 | 
					        <div v-if="clientInfo" class="mb-6">
 | 
				
			||||||
 | 
					          <div class="flex items-center">
 | 
				
			||||||
 | 
					            <n-avatar
 | 
				
			||||||
 | 
					              v-if="clientInfo.picture"
 | 
				
			||||||
 | 
					              :src="clientInfo.picture.url"
 | 
				
			||||||
 | 
					              :alt="clientInfo.client_name"
 | 
				
			||||||
 | 
					              size="large"
 | 
				
			||||||
 | 
					              class="mr-3"
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
 | 
					            <div>
 | 
				
			||||||
 | 
					              <h2 class="text-xl font-semibold">
 | 
				
			||||||
 | 
					                {{ clientInfo.client_name || 'Unknown Application' }}
 | 
				
			||||||
 | 
					              </h2>
 | 
				
			||||||
 | 
					              <span v-if="isNewApp">wants to access your Solar Network account</span>
 | 
				
			||||||
 | 
					              <span v-else>wants to access your account</span>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <!-- Requested Permissions -->
 | 
				
			||||||
 | 
					          <n-card size="small" class="mt-4">
 | 
				
			||||||
 | 
					            <h3 class="font-medium mb-2">
 | 
				
			||||||
 | 
					              This will allow {{ clientInfo.client_name || 'the app' }} to:
 | 
				
			||||||
 | 
					            </h3>
 | 
				
			||||||
 | 
					            <ul class="space-y-1">
 | 
				
			||||||
 | 
					              <li v-for="scope in requestedScopes" :key="scope" class="flex items-start">
 | 
				
			||||||
 | 
					                <n-icon :component="CheckBoxFilled" class="mt-1 mr-2" />
 | 
				
			||||||
 | 
					                <span>{{ scope }}</span>
 | 
				
			||||||
 | 
					              </li>
 | 
				
			||||||
 | 
					            </ul>
 | 
				
			||||||
 | 
					          </n-card>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <!-- Buttons -->
 | 
				
			||||||
 | 
					          <div class="flex gap-3 mt-4">
 | 
				
			||||||
 | 
					            <n-button
 | 
				
			||||||
 | 
					              type="primary"
 | 
				
			||||||
 | 
					              :loading="isAuthorizing"
 | 
				
			||||||
 | 
					              @click="handleAuthorize"
 | 
				
			||||||
 | 
					              class="flex-grow-1 w-1/2"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              Authorize
 | 
				
			||||||
 | 
					            </n-button>
 | 
				
			||||||
 | 
					            <n-button
 | 
				
			||||||
 | 
					              type="tertiary"
 | 
				
			||||||
 | 
					              :disabled="isAuthorizing"
 | 
				
			||||||
 | 
					              @click="handleDeny"
 | 
				
			||||||
 | 
					              class="flex-grow-1 w-1/2"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              Deny
 | 
				
			||||||
 | 
					            </n-button>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <div class="mt-4 text-xs text-gray-500 text-center">
 | 
				
			||||||
 | 
					            By authorizing, you agree to the
 | 
				
			||||||
 | 
					            <n-button text type="primary" size="tiny" @click="openTerms" class="px-1">
 | 
				
			||||||
 | 
					              Terms of Service
 | 
				
			||||||
 | 
					            </n-button>
 | 
				
			||||||
 | 
					            and
 | 
				
			||||||
 | 
					            <n-button text type="primary" size="tiny" @click="openPrivacy" class="px-1">
 | 
				
			||||||
 | 
					              Privacy Policy
 | 
				
			||||||
 | 
					            </n-button>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </n-spin>
 | 
				
			||||||
 | 
					    </n-card>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import { ref, computed, onMounted } from 'vue'
 | 
				
			||||||
 | 
					import { useRoute } from 'vue-router'
 | 
				
			||||||
 | 
					import { NCard, NButton, NSpin, NAlert, NAvatar, NIcon } from 'naive-ui'
 | 
				
			||||||
 | 
					import { CheckBoxFilled } from '@vicons/material'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const route = useRoute()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// State
 | 
				
			||||||
 | 
					const isLoading = ref(true)
 | 
				
			||||||
 | 
					const isAuthorizing = ref(false)
 | 
				
			||||||
 | 
					const error = ref<string | null>(null)
 | 
				
			||||||
 | 
					const clientInfo = ref<{
 | 
				
			||||||
 | 
					  client_name?: string
 | 
				
			||||||
 | 
					  home_uri?: string
 | 
				
			||||||
 | 
					  picture?: { url: string }
 | 
				
			||||||
 | 
					  terms_of_service_uri?: string
 | 
				
			||||||
 | 
					  privacy_policy_uri?: string
 | 
				
			||||||
 | 
					  scopes?: string[]
 | 
				
			||||||
 | 
					} | null>(null)
 | 
				
			||||||
 | 
					const isNewApp = ref(false)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Computed properties
 | 
				
			||||||
 | 
					const requestedScopes = computed(() => {
 | 
				
			||||||
 | 
					  return clientInfo.value?.scopes || []
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Methods
 | 
				
			||||||
 | 
					async function fetchClientInfo() {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const response = await fetch(`/api/auth/open/authorize?${window.location.search.slice(1)}`)
 | 
				
			||||||
 | 
					    if (!response.ok) {
 | 
				
			||||||
 | 
					      const errorData = await response.json()
 | 
				
			||||||
 | 
					      throw new Error(errorData.error_description || 'Failed to load authorization request')
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    clientInfo.value = await response.json()
 | 
				
			||||||
 | 
					    checkIfNewApp()
 | 
				
			||||||
 | 
					  } catch (err: any) {
 | 
				
			||||||
 | 
					    error.value = err.message || 'An error occurred while loading the authorization request'
 | 
				
			||||||
 | 
					  } finally {
 | 
				
			||||||
 | 
					    isLoading.value = false
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function checkIfNewApp() {
 | 
				
			||||||
 | 
					  // In a real app, you might want to check if this is the first time authorizing this app
 | 
				
			||||||
 | 
					  // For now, we'll just set it to false
 | 
				
			||||||
 | 
					  isNewApp.value = false
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function handleAuthorize() {
 | 
				
			||||||
 | 
					  isAuthorizing.value = true
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    // In a real implementation, you would submit the authorization
 | 
				
			||||||
 | 
					    const response = await fetch('/api/auth/open/authorize', {
 | 
				
			||||||
 | 
					      method: 'POST',
 | 
				
			||||||
 | 
					      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
 | 
				
			||||||
 | 
					      body: new URLSearchParams({
 | 
				
			||||||
 | 
					        ...route.query,
 | 
				
			||||||
 | 
					        authorize: 'true',
 | 
				
			||||||
 | 
					      }),
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!response.ok) {
 | 
				
			||||||
 | 
					      const errorData = await response.json()
 | 
				
			||||||
 | 
					      throw new Error(errorData.error_description || 'Authorization failed')
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const data = await response.json()
 | 
				
			||||||
 | 
					    if (data.redirect_uri) {
 | 
				
			||||||
 | 
					      window.open(data.redirect_uri, '_self')
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  } catch (err: any) {
 | 
				
			||||||
 | 
					    error.value = err.message || 'An error occurred during authorization'
 | 
				
			||||||
 | 
					  } finally {
 | 
				
			||||||
 | 
					    isAuthorizing.value = false
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function handleDeny() {
 | 
				
			||||||
 | 
					  // Redirect back to the client with an error
 | 
				
			||||||
 | 
					  // Ensure redirect_uri is always a string (not an array)
 | 
				
			||||||
 | 
					  const redirectUriStr = Array.isArray(route.query.redirect_uri)
 | 
				
			||||||
 | 
					    ? route.query.redirect_uri[0] || clientInfo.value?.home_uri || '/'
 | 
				
			||||||
 | 
					    : route.query.redirect_uri || clientInfo.value?.home_uri || '/'
 | 
				
			||||||
 | 
					  const redirectUri = new URL(redirectUriStr)
 | 
				
			||||||
 | 
					  // Ensure state is always a string (not an array)
 | 
				
			||||||
 | 
					  const state = Array.isArray(route.query.state)
 | 
				
			||||||
 | 
					    ? route.query.state[0] || ''
 | 
				
			||||||
 | 
					    : route.query.state || ''
 | 
				
			||||||
 | 
					  const params = new URLSearchParams({
 | 
				
			||||||
 | 
					    error: 'access_denied',
 | 
				
			||||||
 | 
					    error_description: 'The user denied the authorization request',
 | 
				
			||||||
 | 
					    state: state,
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  window.open(`${redirectUri}?${params}`, "_self")
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function openTerms() {
 | 
				
			||||||
 | 
					  window.open(clientInfo.value?.terms_of_service_uri || '#', "_blank")
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function openPrivacy() {
 | 
				
			||||||
 | 
					  window.open(clientInfo.value?.privacy_policy_uri || '#', "_blank")
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Lifecycle
 | 
				
			||||||
 | 
					onMounted(() => {
 | 
				
			||||||
 | 
					  fetchClientInfo()
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped>
 | 
				
			||||||
 | 
					/* Add any custom styles here */
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										34
									
								
								DysonNetwork.Pass/Credit/SocialCreditRecord.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								DysonNetwork.Pass/Credit/SocialCreditRecord.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,34 @@
 | 
				
			|||||||
 | 
					using System.ComponentModel.DataAnnotations;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					using NodaTime.Serialization.Protobuf;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Pass.Credit;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class SocialCreditRecord : ModelBase
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public Guid Id { get; set; }
 | 
				
			||||||
 | 
					    [MaxLength(1024)] public string ReasonType { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					    [MaxLength(1024)] public string Reason { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					    public double Delta { get; set; }
 | 
				
			||||||
 | 
					    public Instant? ExpiredAt { get; set; }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    public Guid AccountId { get; set; }
 | 
				
			||||||
 | 
					    public Account.Account Account { get; set; } = null!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public Shared.Proto.SocialCreditRecord ToProto()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var proto = new Shared.Proto.SocialCreditRecord
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Id = Id.ToString(),
 | 
				
			||||||
 | 
					            ReasonType = ReasonType,
 | 
				
			||||||
 | 
					            Reason = Reason,
 | 
				
			||||||
 | 
					            Delta = Delta,
 | 
				
			||||||
 | 
					            AccountId = AccountId.ToString(),
 | 
				
			||||||
 | 
					            CreatedAt = CreatedAt.ToTimestamp(),
 | 
				
			||||||
 | 
					            UpdatedAt = UpdatedAt.ToTimestamp()
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return proto;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										46
									
								
								DysonNetwork.Pass/Credit/SocialCreditService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								DysonNetwork.Pass/Credit/SocialCreditService.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,46 @@
 | 
				
			|||||||
 | 
					using DysonNetwork.Shared.Cache;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Pass.Credit;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class SocialCreditService(AppDatabase db, ICacheService cache)
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    private const string CacheKeyPrefix = "account:credits:";
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    public async Task<SocialCreditRecord> AddRecord(string reasonType, string reason, double delta, Guid accountId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var record = new SocialCreditRecord
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            ReasonType = reasonType,
 | 
				
			||||||
 | 
					            Reason = reason,
 | 
				
			||||||
 | 
					            Delta = delta,
 | 
				
			||||||
 | 
					            AccountId = accountId,
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					        db.SocialCreditRecords.Add(record);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        await db.AccountProfiles
 | 
				
			||||||
 | 
					            .Where(p => p.AccountId == accountId)
 | 
				
			||||||
 | 
					            .ExecuteUpdateAsync(p => p.SetProperty(v => v.SocialCredits, v => v.SocialCredits + record.Delta));
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        await cache.RemoveAsync($"{CacheKeyPrefix}{accountId}");
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        return record;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    private const double BaseSocialCredit = 100;
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    public async Task<double> GetSocialCredit(Guid accountId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var cached = await cache.GetAsync<double?>($"{CacheKeyPrefix}{accountId}");
 | 
				
			||||||
 | 
					        if (cached.HasValue) return cached.Value;
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        var records = await db.SocialCreditRecords
 | 
				
			||||||
 | 
					            .Where(x => x.AccountId == accountId)
 | 
				
			||||||
 | 
					            .SumAsync(x => x.Delta);
 | 
				
			||||||
 | 
					        records += BaseSocialCredit;
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        await cache.SetAsync($"{CacheKeyPrefix}{accountId}", records);
 | 
				
			||||||
 | 
					        return records;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										27
									
								
								DysonNetwork.Pass/Credit/SocialCreditServiceGrpc.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								DysonNetwork.Pass/Credit/SocialCreditServiceGrpc.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,27 @@
 | 
				
			|||||||
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
 | 
					using Grpc.Core;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Pass.Credit;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class SocialCreditServiceGrpc(SocialCreditService creditService) : Shared.Proto.SocialCreditService.SocialCreditServiceBase
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public override async Task<Shared.Proto.SocialCreditRecord> AddRecord(AddSocialCreditRecordRequest request, ServerCallContext context)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var accountId = Guid.Parse(request.AccountId);
 | 
				
			||||||
 | 
					        var record = await creditService.AddRecord(
 | 
				
			||||||
 | 
					            request.ReasonType,
 | 
				
			||||||
 | 
					            request.Reason,
 | 
				
			||||||
 | 
					            request.Delta,
 | 
				
			||||||
 | 
					            accountId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return record.ToProto();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public override async Task<SocialCreditResponse> GetSocialCredit(GetSocialCreditRequest request, ServerCallContext context)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var accountId = Guid.Parse(request.AccountId);
 | 
				
			||||||
 | 
					        var amount = await creditService.GetSocialCredit(accountId);
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        return new SocialCreditResponse { Amount = amount };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -13,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
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -18,7 +18,7 @@ public class LastActiveFlushHandler(IServiceProvider srp, ILogger<LastActiveFlus
 | 
				
			|||||||
    public async Task FlushAsync(IReadOnlyList<LastActiveInfo> items)
 | 
					    public async Task FlushAsync(IReadOnlyList<LastActiveInfo> items)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        logger.LogInformation("Flushing {Count} LastActiveInfo items...", items.Count);
 | 
					        logger.LogInformation("Flushing {Count} LastActiveInfo items...", items.Count);
 | 
				
			||||||
        
 | 
					
 | 
				
			||||||
        using var scope = srp.CreateScope();
 | 
					        using var scope = srp.CreateScope();
 | 
				
			||||||
        var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
 | 
					        var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -38,13 +38,22 @@ public class LastActiveFlushHandler(IServiceProvider srp, ILogger<LastActiveFlus
 | 
				
			|||||||
            .ToDictionary(g => g.Key, g => g.Last().SeenAt);
 | 
					            .ToDictionary(g => g.Key, g => g.Last().SeenAt);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
					        var now = SystemClock.Instance.GetCurrentInstant();
 | 
				
			||||||
        
 | 
					
 | 
				
			||||||
        var updatingSessions = sessionMap.Select(x => x.Key).ToList();
 | 
					        var updatingSessions = sessionMap.Select(x => x.Key).ToList();
 | 
				
			||||||
        var sessionUpdates = await db.AuthSessions
 | 
					        var sessionUpdates = await db.AuthSessions
 | 
				
			||||||
            .Where(s => updatingSessions.Contains(s.Id))
 | 
					            .Where(s => updatingSessions.Contains(s.Id))
 | 
				
			||||||
            .ExecuteUpdateAsync(s => s.SetProperty(x => x.LastGrantedAt, now));
 | 
					            .ExecuteUpdateAsync(s =>
 | 
				
			||||||
 | 
					                s.SetProperty(x => x.LastGrantedAt, now)
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
        logger.LogInformation("Updated {Count} auth sessions according to LastActiveInfo", sessionUpdates);
 | 
					        logger.LogInformation("Updated {Count} auth sessions according to LastActiveInfo", sessionUpdates);
 | 
				
			||||||
        
 | 
					        var newExpiration = now.Plus(Duration.FromDays(7));
 | 
				
			||||||
 | 
					        var keepAliveSessionUpdates = await db.AuthSessions
 | 
				
			||||||
 | 
					            .Where(s => updatingSessions.Contains(s.Id) && s.ExpiredAt != null)
 | 
				
			||||||
 | 
					            .ExecuteUpdateAsync(s =>
 | 
				
			||||||
 | 
					                s.SetProperty(x => x.ExpiredAt, newExpiration)
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					        logger.LogInformation("Updated {Count} auth sessions' duration according to LastActiveInfo", sessionUpdates);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var updatingAccounts = accountMap.Select(x => x.Key).ToList();
 | 
					        var updatingAccounts = accountMap.Select(x => x.Key).ToList();
 | 
				
			||||||
        var profileUpdates = await db.AccountProfiles
 | 
					        var profileUpdates = await db.AccountProfiles
 | 
				
			||||||
            .Where(a => updatingAccounts.Contains(a.AccountId))
 | 
					            .Where(a => updatingAccounts.Contains(a.AccountId))
 | 
				
			||||||
@@ -53,7 +62,8 @@ public class LastActiveFlushHandler(IServiceProvider srp, ILogger<LastActiveFlus
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public class LastActiveFlushJob(FlushBufferService fbs, LastActiveFlushHandler hdl, ILogger<LastActiveFlushJob> logger) : IJob
 | 
					public class LastActiveFlushJob(FlushBufferService fbs, LastActiveFlushHandler hdl, ILogger<LastActiveFlushJob> logger)
 | 
				
			||||||
 | 
					    : IJob
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    public async Task Execute(IJobExecutionContext context)
 | 
					    public async Task Execute(IJobExecutionContext context)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
@@ -62,7 +72,8 @@ public class LastActiveFlushJob(FlushBufferService fbs, LastActiveFlushHandler h
 | 
				
			|||||||
            logger.LogInformation("Running LastActiveInfo flush job...");
 | 
					            logger.LogInformation("Running LastActiveInfo flush job...");
 | 
				
			||||||
            await fbs.FlushAsync(hdl);
 | 
					            await fbs.FlushAsync(hdl);
 | 
				
			||||||
            logger.LogInformation("Completed LastActiveInfo flush job...");
 | 
					            logger.LogInformation("Completed LastActiveInfo flush job...");
 | 
				
			||||||
        } catch (Exception ex)
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            logger.LogError(ex, "Error running LastActiveInfo job...");
 | 
					            logger.LogError(ex, "Error running LastActiveInfo job...");
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										39
									
								
								DysonNetwork.Pass/IpCheckController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								DysonNetwork.Pass/IpCheckController.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,39 @@
 | 
				
			|||||||
 | 
					using Microsoft.AspNetCore.Mvc;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Pass;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[ApiController]
 | 
				
			||||||
 | 
					[Route("/api/ip-check")]
 | 
				
			||||||
 | 
					public class IpCheckController : ControllerBase
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public class IpCheckResponse
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        public string? RemoteIp { get; set; }
 | 
				
			||||||
 | 
					        public string? XForwardedFor { get; set; }
 | 
				
			||||||
 | 
					        public string? XForwardedProto { get; set; }
 | 
				
			||||||
 | 
					        public string? XForwardedHost { get; set; }
 | 
				
			||||||
 | 
					        public string? XRealIp { get; set; }
 | 
				
			||||||
 | 
					        public string? Headers { get; set; }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    [HttpGet]
 | 
				
			||||||
 | 
					    public ActionResult<IpCheckResponse> GetIpCheck()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var xForwardedFor = Request.Headers["X-Forwarded-For"].FirstOrDefault();
 | 
				
			||||||
 | 
					        var xForwardedProto = Request.Headers["X-Forwarded-Proto"].FirstOrDefault();
 | 
				
			||||||
 | 
					        var xForwardedHost = Request.Headers["X-Forwarded-Host"].FirstOrDefault();
 | 
				
			||||||
 | 
					        var realIp = Request.Headers["X-Real-IP"].FirstOrDefault();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Ok(new IpCheckResponse
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            RemoteIp = ip,
 | 
				
			||||||
 | 
					            XForwardedFor = xForwardedFor,
 | 
				
			||||||
 | 
					            XForwardedProto = xForwardedProto,
 | 
				
			||||||
 | 
					            XForwardedHost = xForwardedHost,
 | 
				
			||||||
 | 
					            XRealIp = realIp,
 | 
				
			||||||
 | 
					            Headers = string.Join('\n', Request.Headers.Select(h => $"{h.Key}: {h.Value}")),
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    } 
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										34
									
								
								DysonNetwork.Pass/Leveling/ExperienceRecord.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								DysonNetwork.Pass/Leveling/ExperienceRecord.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,34 @@
 | 
				
			|||||||
 | 
					using System.ComponentModel.DataAnnotations;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
 | 
					using NodaTime.Serialization.Protobuf;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Pass.Leveling;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class ExperienceRecord : ModelBase
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public Guid Id { get; set; } = Guid.NewGuid();
 | 
				
			||||||
 | 
					    [MaxLength(1024)] public string ReasonType { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					    [MaxLength(1024)] public string Reason { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					    public long Delta { get; set; }
 | 
				
			||||||
 | 
					    public double BonusMultiplier { get; set; }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    public Guid AccountId { get; set; }
 | 
				
			||||||
 | 
					    public Account.Account Account { get; set; } = null!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public Shared.Proto.ExperienceRecord ToProto()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var proto = new Shared.Proto.ExperienceRecord
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Id = Id.ToString(),
 | 
				
			||||||
 | 
					            ReasonType = ReasonType,
 | 
				
			||||||
 | 
					            Reason = Reason,
 | 
				
			||||||
 | 
					            Delta = Delta,
 | 
				
			||||||
 | 
					            BonusMultiplier = BonusMultiplier,
 | 
				
			||||||
 | 
					            AccountId = AccountId.ToString(),
 | 
				
			||||||
 | 
					            CreatedAt = CreatedAt.ToTimestamp(),
 | 
				
			||||||
 | 
					            UpdatedAt = UpdatedAt.ToTimestamp()
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return proto;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										42
									
								
								DysonNetwork.Pass/Leveling/ExperienceService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								DysonNetwork.Pass/Leveling/ExperienceService.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,42 @@
 | 
				
			|||||||
 | 
					using DysonNetwork.Pass.Wallet;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Cache;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Pass.Leveling;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class ExperienceService(AppDatabase db, SubscriptionService subscriptions, ICacheService cache)
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public async Task<ExperienceRecord> AddRecord(string reasonType, string reason, long delta, Guid accountId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var record = new ExperienceRecord
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            ReasonType = reasonType,
 | 
				
			||||||
 | 
					            Reason = reason,
 | 
				
			||||||
 | 
					            Delta = delta,
 | 
				
			||||||
 | 
					            AccountId = accountId,
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var perkSubscription = await subscriptions.GetPerkSubscriptionAsync(accountId);
 | 
				
			||||||
 | 
					        if (perkSubscription is not null)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            record.BonusMultiplier = perkSubscription.Identifier switch
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                SubscriptionType.Stellar => 1.5,
 | 
				
			||||||
 | 
					                SubscriptionType.Nova => 2,
 | 
				
			||||||
 | 
					                SubscriptionType.Supernova => 2,
 | 
				
			||||||
 | 
					                _ => 1
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					            if (record.Delta >= 0)
 | 
				
			||||||
 | 
					                record.Delta = (long)Math.Floor(record.Delta * record.BonusMultiplier);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        db.ExperienceRecords.Add(record);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        await db.AccountProfiles
 | 
				
			||||||
 | 
					            .Where(p => p.AccountId == accountId)
 | 
				
			||||||
 | 
					            .ExecuteUpdateAsync(p => p.SetProperty(v => v.Experience, v => v.Experience + record.Delta));
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        return record;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										19
									
								
								DysonNetwork.Pass/Leveling/ExperienceServiceGrpc.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								DysonNetwork.Pass/Leveling/ExperienceServiceGrpc.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,19 @@
 | 
				
			|||||||
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
 | 
					using Grpc.Core;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Pass.Leveling;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class ExperienceServiceGrpc(ExperienceService experienceService) : Shared.Proto.ExperienceService.ExperienceServiceBase
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public override async Task<Shared.Proto.ExperienceRecord> AddRecord(AddExperienceRecordRequest request, ServerCallContext context)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var accountId = Guid.Parse(request.AccountId);
 | 
				
			||||||
 | 
					        var record = await experienceService.AddRecord(
 | 
				
			||||||
 | 
					            request.ReasonType,
 | 
				
			||||||
 | 
					            request.Reason,
 | 
				
			||||||
 | 
					            request.Delta,
 | 
				
			||||||
 | 
					            accountId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return record.ToProto();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										1884
									
								
								DysonNetwork.Pass/Migrations/20250820104425_AddApiKeys.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										1884
									
								
								DysonNetwork.Pass/Migrations/20250820104425_AddApiKeys.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										114
									
								
								DysonNetwork.Pass/Migrations/20250820104425_AddApiKeys.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								DysonNetwork.Pass/Migrations/20250820104425_AddApiKeys.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,114 @@
 | 
				
			|||||||
 | 
					using System;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Migrations;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#nullable disable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Pass.Migrations
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    /// <inheritdoc />
 | 
				
			||||||
 | 
					    public partial class AddApiKeys : Migration
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void Up(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.DropForeignKey(
 | 
				
			||||||
 | 
					                name: "fk_auth_sessions_auth_challenges_challenge_id",
 | 
				
			||||||
 | 
					                table: "auth_sessions");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.DropColumn(
 | 
				
			||||||
 | 
					                name: "label",
 | 
				
			||||||
 | 
					                table: "auth_sessions");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.AlterColumn<Guid>(
 | 
				
			||||||
 | 
					                name: "challenge_id",
 | 
				
			||||||
 | 
					                table: "auth_sessions",
 | 
				
			||||||
 | 
					                type: "uuid",
 | 
				
			||||||
 | 
					                nullable: true,
 | 
				
			||||||
 | 
					                oldClrType: typeof(Guid),
 | 
				
			||||||
 | 
					                oldType: "uuid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.CreateTable(
 | 
				
			||||||
 | 
					                name: "api_keys",
 | 
				
			||||||
 | 
					                columns: table => new
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    id = table.Column<Guid>(type: "uuid", nullable: false),
 | 
				
			||||||
 | 
					                    label = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
 | 
				
			||||||
 | 
					                    account_id = table.Column<Guid>(type: "uuid", nullable: false),
 | 
				
			||||||
 | 
					                    session_id = table.Column<Guid>(type: "uuid", nullable: false),
 | 
				
			||||||
 | 
					                    created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
 | 
				
			||||||
 | 
					                    updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
 | 
				
			||||||
 | 
					                    deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                constraints: table =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    table.PrimaryKey("pk_api_keys", x => x.id);
 | 
				
			||||||
 | 
					                    table.ForeignKey(
 | 
				
			||||||
 | 
					                        name: "fk_api_keys_accounts_account_id",
 | 
				
			||||||
 | 
					                        column: x => x.account_id,
 | 
				
			||||||
 | 
					                        principalTable: "accounts",
 | 
				
			||||||
 | 
					                        principalColumn: "id",
 | 
				
			||||||
 | 
					                        onDelete: ReferentialAction.Cascade);
 | 
				
			||||||
 | 
					                    table.ForeignKey(
 | 
				
			||||||
 | 
					                        name: "fk_api_keys_auth_sessions_session_id",
 | 
				
			||||||
 | 
					                        column: x => x.session_id,
 | 
				
			||||||
 | 
					                        principalTable: "auth_sessions",
 | 
				
			||||||
 | 
					                        principalColumn: "id",
 | 
				
			||||||
 | 
					                        onDelete: ReferentialAction.Cascade);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.CreateIndex(
 | 
				
			||||||
 | 
					                name: "ix_api_keys_account_id",
 | 
				
			||||||
 | 
					                table: "api_keys",
 | 
				
			||||||
 | 
					                column: "account_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.CreateIndex(
 | 
				
			||||||
 | 
					                name: "ix_api_keys_session_id",
 | 
				
			||||||
 | 
					                table: "api_keys",
 | 
				
			||||||
 | 
					                column: "session_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.AddForeignKey(
 | 
				
			||||||
 | 
					                name: "fk_auth_sessions_auth_challenges_challenge_id",
 | 
				
			||||||
 | 
					                table: "auth_sessions",
 | 
				
			||||||
 | 
					                column: "challenge_id",
 | 
				
			||||||
 | 
					                principalTable: "auth_challenges",
 | 
				
			||||||
 | 
					                principalColumn: "id");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void Down(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.DropForeignKey(
 | 
				
			||||||
 | 
					                name: "fk_auth_sessions_auth_challenges_challenge_id",
 | 
				
			||||||
 | 
					                table: "auth_sessions");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.DropTable(
 | 
				
			||||||
 | 
					                name: "api_keys");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.AlterColumn<Guid>(
 | 
				
			||||||
 | 
					                name: "challenge_id",
 | 
				
			||||||
 | 
					                table: "auth_sessions",
 | 
				
			||||||
 | 
					                type: "uuid",
 | 
				
			||||||
 | 
					                nullable: false,
 | 
				
			||||||
 | 
					                defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
 | 
				
			||||||
 | 
					                oldClrType: typeof(Guid),
 | 
				
			||||||
 | 
					                oldType: "uuid",
 | 
				
			||||||
 | 
					                oldNullable: true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.AddColumn<string>(
 | 
				
			||||||
 | 
					                name: "label",
 | 
				
			||||||
 | 
					                table: "auth_sessions",
 | 
				
			||||||
 | 
					                type: "character varying(1024)",
 | 
				
			||||||
 | 
					                maxLength: 1024,
 | 
				
			||||||
 | 
					                nullable: true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.AddForeignKey(
 | 
				
			||||||
 | 
					                name: "fk_auth_sessions_auth_challenges_challenge_id",
 | 
				
			||||||
 | 
					                table: "auth_sessions",
 | 
				
			||||||
 | 
					                column: "challenge_id",
 | 
				
			||||||
 | 
					                principalTable: "auth_challenges",
 | 
				
			||||||
 | 
					                principalColumn: "id",
 | 
				
			||||||
 | 
					                onDelete: ReferentialAction.Cascade);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										2008
									
								
								DysonNetwork.Pass/Migrations/20250820120632_AddCreditAndLevelingRecords.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										2008
									
								
								DysonNetwork.Pass/Migrations/20250820120632_AddCreditAndLevelingRecords.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -0,0 +1,85 @@
 | 
				
			|||||||
 | 
					using System;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Migrations;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#nullable disable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Pass.Migrations
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    /// <inheritdoc />
 | 
				
			||||||
 | 
					    public partial class AddCreditAndLevelingRecords : Migration
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void Up(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.CreateTable(
 | 
				
			||||||
 | 
					                name: "experience_records",
 | 
				
			||||||
 | 
					                columns: table => new
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    id = table.Column<Guid>(type: "uuid", nullable: false),
 | 
				
			||||||
 | 
					                    reason_type = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
 | 
				
			||||||
 | 
					                    reason = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
 | 
				
			||||||
 | 
					                    delta = table.Column<long>(type: "bigint", nullable: false),
 | 
				
			||||||
 | 
					                    account_id = table.Column<Guid>(type: "uuid", nullable: false),
 | 
				
			||||||
 | 
					                    created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
 | 
				
			||||||
 | 
					                    updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
 | 
				
			||||||
 | 
					                    deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                constraints: table =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    table.PrimaryKey("pk_experience_records", x => x.id);
 | 
				
			||||||
 | 
					                    table.ForeignKey(
 | 
				
			||||||
 | 
					                        name: "fk_experience_records_accounts_account_id",
 | 
				
			||||||
 | 
					                        column: x => x.account_id,
 | 
				
			||||||
 | 
					                        principalTable: "accounts",
 | 
				
			||||||
 | 
					                        principalColumn: "id",
 | 
				
			||||||
 | 
					                        onDelete: ReferentialAction.Cascade);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.CreateTable(
 | 
				
			||||||
 | 
					                name: "social_credit_records",
 | 
				
			||||||
 | 
					                columns: table => new
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    id = table.Column<Guid>(type: "uuid", nullable: false),
 | 
				
			||||||
 | 
					                    reason_type = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
 | 
				
			||||||
 | 
					                    reason = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
 | 
				
			||||||
 | 
					                    delta = table.Column<double>(type: "double precision", nullable: false),
 | 
				
			||||||
 | 
					                    expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
 | 
				
			||||||
 | 
					                    account_id = table.Column<Guid>(type: "uuid", nullable: false),
 | 
				
			||||||
 | 
					                    created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
 | 
				
			||||||
 | 
					                    updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
 | 
				
			||||||
 | 
					                    deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                constraints: table =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    table.PrimaryKey("pk_social_credit_records", x => x.id);
 | 
				
			||||||
 | 
					                    table.ForeignKey(
 | 
				
			||||||
 | 
					                        name: "fk_social_credit_records_accounts_account_id",
 | 
				
			||||||
 | 
					                        column: x => x.account_id,
 | 
				
			||||||
 | 
					                        principalTable: "accounts",
 | 
				
			||||||
 | 
					                        principalColumn: "id",
 | 
				
			||||||
 | 
					                        onDelete: ReferentialAction.Cascade);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.CreateIndex(
 | 
				
			||||||
 | 
					                name: "ix_experience_records_account_id",
 | 
				
			||||||
 | 
					                table: "experience_records",
 | 
				
			||||||
 | 
					                column: "account_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.CreateIndex(
 | 
				
			||||||
 | 
					                name: "ix_social_credit_records_account_id",
 | 
				
			||||||
 | 
					                table: "social_credit_records",
 | 
				
			||||||
 | 
					                column: "account_id");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void Down(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.DropTable(
 | 
				
			||||||
 | 
					                name: "experience_records");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.DropTable(
 | 
				
			||||||
 | 
					                name: "social_credit_records");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										2012
									
								
								DysonNetwork.Pass/Migrations/20250821093930_AddLevelingBonusMultiplier.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										2012
									
								
								DysonNetwork.Pass/Migrations/20250821093930_AddLevelingBonusMultiplier.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 AddLevelingBonusMultiplier : Migration
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void Up(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.AddColumn<double>(
 | 
				
			||||||
 | 
					                name: "bonus_multiplier",
 | 
				
			||||||
 | 
					                table: "experience_records",
 | 
				
			||||||
 | 
					                type: "double precision",
 | 
				
			||||||
 | 
					                nullable: false,
 | 
				
			||||||
 | 
					                defaultValue: 0.0);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void Down(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.DropColumn(
 | 
				
			||||||
 | 
					                name: "bonus_multiplier",
 | 
				
			||||||
 | 
					                table: "experience_records");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										2016
									
								
								DysonNetwork.Pass/Migrations/20250822142926_CacheSocialCreditsInProfile.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										2016
									
								
								DysonNetwork.Pass/Migrations/20250822142926_CacheSocialCreditsInProfile.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 CacheSocialCreditsInProfile : Migration
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void Up(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.AddColumn<double>(
 | 
				
			||||||
 | 
					                name: "social_credits",
 | 
				
			||||||
 | 
					                table: "account_profiles",
 | 
				
			||||||
 | 
					                type: "double precision",
 | 
				
			||||||
 | 
					                nullable: false,
 | 
				
			||||||
 | 
					                defaultValue: 0.0);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void Down(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.DropColumn(
 | 
				
			||||||
 | 
					                name: "social_credits",
 | 
				
			||||||
 | 
					                table: "account_profiles");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										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");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user