Compare commits
	
		
			351 Commits
		
	
	
		
			3e7376c1f7
			...
			refactor/a
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						
						
							
						
						f6f0703cb3
	
				 | 
					
					
						|||
| 
						
						
							
						
						3d47b4e44e
	
				 | 
					
					
						|||
| 
						
						
							
						
						71fe2a30e7
	
				 | 
					
					
						|||
| 
						
						
							
						
						d8f57161ae
	
				 | 
					
					
						|||
| 
						
						
							
						
						3caa79b9a7
	
				 | 
					
					
						|||
| 
						
						
							
						
						49beb17925
	
				 | 
					
					
						|||
| 
						
						
							
						
						bd8e13f25d
	
				 | 
					
					
						|||
| 
						
						
							
						
						1128c9a0ba
	
				 | 
					
					
						|||
| 
						
						
							
						
						8dfe201afe
	
				 | 
					
					
						|||
| 
						
						
							
						
						c1016e496a
	
				 | 
					
					
						|||
| 
						
						
							
						
						091097a858
	
				 | 
					
					
						|||
| 
						
						
							
						
						5c97733b3e
	
				 | 
					
					
						|||
| 
						
						
							
						
						4ee387ab76
	
				 | 
					
					
						|||
| 
						
						
							
						
						19bf17200d
	
				 | 
					
					
						|||
| 
						
						
							
						
						be6d97ec85
	
				 | 
					
					
						|||
| 
						
						
							
						
						9d282b26f3
	
				 | 
					
					
						|||
| 
						
						
							
						
						dbc2c54ab0
	
				 | 
					
					
						|||
| 
						
						
							
						
						aa062932cf
	
				 | 
					
					
						|||
| 
						
						
							
						
						812dd03e85
	
				 | 
					
					
						|||
| 
						
						
							
						
						06d639a114
	
				 | 
					
					
						|||
| 
						
						
							
						
						74f51036b1
	
				 | 
					
					
						|||
| 
						
						
							
						
						8308325b73
	
				 | 
					
					
						|||
| 
						
						
							
						
						fa7010db3d
	
				 | 
					
					
						|||
| 
						
						
							
						
						89320fc540
	
				 | 
					
					
						|||
| 
						
						
							
						
						5ec8d89563
	
				 | 
					
					
						|||
| 
						
						
							
						
						0eeafb5352
	
				 | 
					
					
						|||
| 
						
						
							
						
						ab2bdcc7ca
	
				 | 
					
					
						|||
| 
						
						
							
						
						c2b49e6642
	
				 | 
					
					
						|||
| 
						
						
							
						
						1a89c48790
	
				 | 
					
					
						|||
| 
						
						
							
						
						8dddfe77cd
	
				 | 
					
					
						|||
| 
						
						
							
						
						8e8b011fdd
	
				 | 
					
					
						|||
| 
						
						
							
						
						abd346bb97
	
				 | 
					
					
						|||
| 
						
						
							
						
						6386ec8caa
	
				 | 
					
					
						|||
| 
						
						
							
						
						ad062828ff
	
				 | 
					
					
						|||
| 
						
						
							
						
						92e4988114
	
				 | 
					
					
						|||
| 
						
						
							
						
						f9269d7558
	
				 | 
					
					
						|||
| 
						
						
							
						
						fa01b7027a
	
				 | 
					
					
						|||
| 
						
						
							
						
						eaa3a9c297
	
				 | 
					
					
						|||
| 
						
						
							
						
						6cedda9307
	
				 | 
					
					
						|||
| 
						
						
							
						
						942ca73f8d
	
				 | 
					
					
						|||
| 
						
						
							
						
						da3f58f2ec
	
				 | 
					
					
						|||
| 
						
						
							
						
						4a8521d59d
	
				 | 
					
					
						|||
| 
						
						
							
						
						d7ad84e199
	
				 | 
					
					
						|||
| 
						
						
							
						
						52430c19a5
	
				 | 
					
					
						|||
| 
						
						
							
						
						9492b6cac6
	
				 | 
					
					
						|||
| 
						
						
							
						
						5f324a2348
	
				 | 
					
					
						|||
| 
						
						
							
						
						7452b14817
	
				 | 
					
					
						|||
| 
						
						
							
						
						4a27794ccc
	
				 | 
					
					
						|||
| 
						
						
							
						
						d2f5ba36ab
	
				 | 
					
					
						|||
| 0117fdf084 | |||
| 02680d224a | |||
| 68bfdebcbd | |||
| 54907eede1 | |||
| a21d19c3ef | |||
| df732616d5 | |||
| 79a31ae060 | |||
| 6eacfcd8f2 | |||
| 5e328509bd | |||
| 9c078db564 | |||
| ddd109c77c | |||
| 3ee04d0b24 | |||
| 7f110313e9 | |||
| bc2e87c56f | |||
| d7271a2d11 | |||
| c57d65db67 | |||
| edf3aab173 | |||
| 352746a141 | |||
| 216c72ea36 | |||
| d0723b366b | |||
| fb6721cb1b | |||
| 9fcb169c94 | |||
| 572874431d | |||
| f595ac8001 | |||
| 18674e0e1d | |||
| da4c4d3a84 | |||
| aec01b117d | |||
| d299c32e35 | |||
| 344007af66 | |||
| d4de5aeac2 | |||
| 8ce5ba50f4 | |||
| 5a44952b27 | |||
| c30946daf6 | |||
| 0221d7b294 | |||
| c44b0b64c3 | |||
| 442ee3bcfd | |||
| 081815c512 | |||
| eab2a388ae | |||
| 5f7ab49abb | |||
| 4ff89173b2 | |||
| f2052410c7 | |||
| 83a49be725 | |||
| 9b205a73fd | |||
| d5157eb7e3 | |||
| 75c92c51db | |||
| 915054fce0 | |||
| 63653680ba | |||
| 84c4df6620 | |||
| 8c748fd57a | |||
| 4684550ebf | |||
| 51db08f374 | |||
| 9f38a288b9 | |||
| 75a975049c | |||
| f8c35c0350 | |||
| d9a5fed77f | |||
| 7cb14940d9 | |||
| 953bf5d4de | |||
| d9620fd6a4 | |||
| 541e2dd14c | |||
| c7925d98c8 | |||
| f759b19bcb | |||
| 5d7429a416 | |||
| fb7e52d6f3 | |||
| 50e888b075 | |||
| 76c8bbf307 | |||
| 8f3825e92c | |||
| d1c3610ec8 | |||
| 4b958a3c31 | |||
| 1f9021d459 | |||
| 7ad9deaf70 | |||
| c1c17b5f4e | |||
| d92220b4bc | |||
| 4d1972bc99 | |||
| 83c052ec4e | |||
| 57a75fe9e6 | |||
| 379bc37aff | |||
| 0217fbb13b | |||
| 4e9943e6a2 | |||
| b3cc623168 | |||
| 3ee5e5367d | |||
| 85fef30c7f | |||
| e8d8dcbb2d | |||
| 3b679d6134 | |||
| ec44b51ab6 | |||
| 2e52a13c30 | |||
| 1e8e2e9ea7 | |||
| 9e8363c004 | |||
| 56c40ee001 | |||
| e3dfccfee3 | |||
| d555fcaf17 | |||
| 2fdefae718 | |||
| e78858b7b4 | |||
| 636b674229 | |||
| fc6cee17d7 | |||
| 7f7b47fb1c | |||
| bf181b88ec | |||
| c056938b6e | |||
| 66eadf96b0 | |||
| 665595b8b4 | |||
| 29550401fd | |||
| 1bb0012c40 | |||
| 2cea391ebf | |||
| 32e91da0b2 | |||
| 69b56b9658 | |||
| 83e3d77f79 | |||
| 38a8eecd50 | |||
| bd77137714 | |||
| 201126e5d0 | |||
| d4a2e5ef5b | |||
| 2761abf405 | |||
| add16ffdad | |||
| b49cd1c382 | |||
| aa9ae5c11e | |||
| 8e8965eb3d | |||
| a0fe8fd0f0 | |||
| 855031a4fe | |||
| adc2b20aeb | |||
| c860f10cf9 | |||
| d441eff2d2 | |||
| d31f36d3dc | |||
| 4fc7bd47f9 | |||
| a66037d947 | |||
| bb4e04df0b | |||
| d3752caf1d | |||
| 614c77d7ce | |||
| 5d13f08d47 | |||
| 07ba148d9b | |||
| 917e2d5393 | |||
| e384763faf | |||
| 7fb199b187 | |||
| 924e31aad5 | |||
| 48f776e6ff | |||
| a27bda4720 | |||
| a7e0e1e369 | |||
| 5bb5018cc0 | |||
| a9aab6b7e5 | |||
| 651c06caac | |||
| e0d58085f3 | |||
| cb420c2262 | |||
| 6211f546b1 | |||
| 9070fe7fa3 | |||
| c86d7275ec | |||
| 9e1178b7a1 | |||
| cd76cedb7b | |||
| f273445451 | |||
| 740d9a33cf | |||
| 792d703b6f | |||
| f09832404d | |||
| 134b11e7f0 | |||
| 8c01ec364c | |||
| 27e6dde7c4 | |||
| b04b17c8ae | |||
| b037ecad79 | |||
| 7ec3f25d43 | |||
| 1778ab112d | |||
| 5f70d53c94 | |||
| 4b66e97bda | |||
| f8d8e485f1 | |||
| e21bf531e1 | |||
| 76fdf14e79 | |||
| 96cceafe77 | |||
| 58e34b20e1 | |||
| 
						 | 
					e420b183ce | ||
| 
						 | 
					a08f058806 | ||
| 616491e6d8 | |||
| 05c6d67c03 | |||
| e66130e893 | |||
| 5bb9bbac73 | |||
| 8474fc7160 | |||
| ea8158cb50 | |||
| 65398c5fec | |||
| 5181897463 | |||
| 96c7927632 | |||
| 0eb3ffcdbe | |||
| 
						 | 
					736db75cfd | ||
| 0b44c4547c | |||
| 
						 | 
					728ac9c166 | ||
| 360b58885e | |||
| 09d412053f | |||
| e0107f189d | |||
| 42af09034c | |||
| 963470b693 | |||
| da57936d92 | |||
| 78cec27ef0 | |||
| c3f5ed881f | |||
| 1c52b4d661 | |||
| 765be4f214 | |||
| 91de6797c5 | |||
| 4bceb119ea | |||
| 14a5c01a6d | |||
| 83df727f8f | |||
| 3444e27a96 | |||
| 865505f883 | |||
| 0ed47be689 | |||
| d8c1c63e56 | |||
| 2934225a6c | |||
| 
						 | 
					d1e5058dae | ||
| 
						 | 
					cbd58d3e72 | ||
| 
						 | 
					735268fe46 | ||
| 7ddb904335 | |||
| c514adfbbf | |||
| a32c06552f | |||
| 
						 | 
					aefc1aeb4f | ||
| 
						 | 
					7fc36b5d22 | ||
| 5fd52e7b9e | |||
| e7d14d4687 | |||
| a57ae840ff | |||
| 009621a456 | |||
| 36ed0dc893 | |||
| 8a1c490907 | |||
| 32054705d0 | |||
| 5859483654 | |||
| d0ca8db162 | |||
| a3e138cc2d | |||
| 1fab398778 | |||
| 77ccc9aeb5 | |||
| a6dfe8712c | |||
| 973b2f81ea | |||
| 554f73b550 | |||
| ee8e9df12e | |||
| 00cdd1bc5d | |||
| f1ea7c1c5a | |||
| d13e18534f | |||
| 1dc33c5bd4 | |||
| e09922c8df | |||
| e85af628bf | |||
| 4f2e18ca27 | |||
| 1105d6f11e | |||
| f2bba64ee5 | |||
| ebbe14f293 | |||
| 681934a0dc | |||
| a52b09b787 | |||
| b0af3af059 | |||
| 6bc5bcfd1a | |||
| 999ba52003 | |||
| e0ebed7c09 | |||
| e50ce2f515 | |||
| 5bb9ed5f04 | |||
| 4a36557714 | |||
| 1a93cdad46 | |||
| 2bbef9b9d1 | |||
| 22101c8280 | |||
| 256c6469a6 | |||
| 7367f372c0 | |||
| 822a339532 | |||
| 5d2ad2479b | |||
| 795ca04d7c | |||
| 111701a2c4 | |||
| a793a03a20 | |||
| d231b5f27e | |||
| 709dc44d57 | |||
| d7a39ab574 | |||
| 18882c08d9 | |||
| ce6f9a174f | |||
| f5c8b75122 | |||
| 165d2e4d93 | |||
| 9e9d0dc563 | |||
| a9a5082e1a | |||
| eca9601a89 | |||
| 6bfe784b3f | |||
| 6524a56eeb | |||
| b7f853d84f | |||
| 473155b68d | |||
| 608b93fb61 | |||
| 4a36b30d6b | |||
| 72b26c6a2c | |||
| 7fc86441d1 | |||
| 1a05f16299 | |||
| db5d631049 | |||
| 2d7dd26882 | |||
| b0834f48d4 | |||
| 7d3236550c | |||
| adf62fb42b | |||
| 14c6913af7 | |||
| 192ea0fcdd | |||
| 189abd4982 | |||
| 3df66dabd9 | |||
| f46f70b33c | |||
| e689d15688 | |||
| 3d236c35c9 | |||
| 665538bdd3 | |||
| be7d7536fc | |||
| a932108c87 | |||
| 71eccbb466 | |||
| 700803f7a6 | |||
| 1f38d827c5 | |||
| 8d73c0f289 | |||
| f9884e32fb | |||
| 27b6f2022f | |||
| 98b5808b09 | |||
| f4df8c0c3b | |||
| 882c14df06 | |||
| b3ed98322b | |||
| 4cfd4387b6 | |||
| 89406870bd | |||
| c747d03aff | |||
| 77df275ac0 | |||
| d7dcb7221f | |||
| 92a8709df0 | |||
| e3499ff283 | |||
| 0306b54a0f | |||
| 3afbeacffb | 
							
								
								
									
										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
 | 
				
			||||||
							
								
								
									
										162
									
								
								.github/workflows/docker-build.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										162
									
								
								.github/workflows/docker-build.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,4 +1,4 @@
 | 
				
			|||||||
name: Build and Push Microservices
 | 
					name: Aspire Publish Workflow
 | 
				
			||||||
 | 
					
 | 
				
			||||||
on:
 | 
					on:
 | 
				
			||||||
  push:
 | 
					  push:
 | 
				
			||||||
@@ -7,7 +7,7 @@ on:
 | 
				
			|||||||
  workflow_dispatch:
 | 
					  workflow_dispatch:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
jobs:
 | 
					jobs:
 | 
				
			||||||
  build-sphere:
 | 
					  publish:
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    permissions:
 | 
					    permissions:
 | 
				
			||||||
      contents: read
 | 
					      contents: read
 | 
				
			||||||
@@ -15,144 +15,46 @@ jobs:
 | 
				
			|||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      - name: Checkout repository
 | 
					      - name: Checkout repository
 | 
				
			||||||
        uses: actions/checkout@v3
 | 
					        uses: actions/checkout@v3
 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          fetch-depth: 0
 | 
					 | 
				
			||||||
      - name: Setup NBGV
 | 
					 | 
				
			||||||
        uses: dotnet/nbgv@master
 | 
					 | 
				
			||||||
        id: nbgv
 | 
					 | 
				
			||||||
      - name: Set up Docker Buildx
 | 
					 | 
				
			||||||
        uses: docker/setup-buildx-action@v3
 | 
					 | 
				
			||||||
      - name: Log in to GitHub Container Registry
 | 
					 | 
				
			||||||
        uses: docker/login-action@v3
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          registry: ghcr.io
 | 
					 | 
				
			||||||
          username: ${{ github.actor }}
 | 
					 | 
				
			||||||
          password: ${{ secrets.GITHUB_TOKEN }}
 | 
					 | 
				
			||||||
      - name: Build and push DysonNetwork.Sphere Docker image
 | 
					 | 
				
			||||||
        uses: docker/build-push-action@v6
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          file: DysonNetwork.Sphere/Dockerfile
 | 
					 | 
				
			||||||
          context: .
 | 
					 | 
				
			||||||
          push: true
 | 
					 | 
				
			||||||
          tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-sphere:latest
 | 
					 | 
				
			||||||
          platforms: linux/amd64
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  build-pass:
 | 
					      - name: Setup .NET
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					        uses: actions/setup-dotnet@v3
 | 
				
			||||||
    permissions:
 | 
					 | 
				
			||||||
      contents: read
 | 
					 | 
				
			||||||
      packages: write
 | 
					 | 
				
			||||||
    steps:
 | 
					 | 
				
			||||||
      - name: Checkout repository
 | 
					 | 
				
			||||||
        uses: actions/checkout@v3
 | 
					 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          fetch-depth: 0
 | 
					          dotnet-version: "9.0.x"
 | 
				
			||||||
      - name: Setup NBGV
 | 
					 | 
				
			||||||
        uses: dotnet/nbgv@master
 | 
					 | 
				
			||||||
        id: nbgv
 | 
					 | 
				
			||||||
      - name: Set up Docker Buildx
 | 
					 | 
				
			||||||
        uses: docker/setup-buildx-action@v3
 | 
					 | 
				
			||||||
      - name: Log in to GitHub Container Registry
 | 
					 | 
				
			||||||
        uses: docker/login-action@v3
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          registry: ghcr.io
 | 
					 | 
				
			||||||
          username: ${{ github.actor }}
 | 
					 | 
				
			||||||
          password: ${{ secrets.GITHUB_TOKEN }}
 | 
					 | 
				
			||||||
      - name: Build and push DysonNetwork.Pass Docker image
 | 
					 | 
				
			||||||
        uses: docker/build-push-action@v6
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          file: DysonNetwork.Pass/Dockerfile
 | 
					 | 
				
			||||||
          context: .
 | 
					 | 
				
			||||||
          push: true
 | 
					 | 
				
			||||||
          tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-pass:latest
 | 
					 | 
				
			||||||
          platforms: linux/amd64
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  build-pusher:
 | 
					 | 
				
			||||||
    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
 | 
					      - 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.Pusher Docker image
 | 
					 | 
				
			||||||
        uses: docker/build-push-action@v6
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          file: DysonNetwork.Pusher/Dockerfile
 | 
					 | 
				
			||||||
          context: .
 | 
					 | 
				
			||||||
          push: true
 | 
					 | 
				
			||||||
          tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-pusher:latest
 | 
					 | 
				
			||||||
          platforms: linux/amd64
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  build-drive:
 | 
					      - name: Install Aspire CLI
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					        run: dotnet tool install -g Aspire.Cli --prerelease
 | 
				
			||||||
    permissions:
 | 
					 | 
				
			||||||
      contents: read
 | 
					 | 
				
			||||||
      packages: write
 | 
					 | 
				
			||||||
    steps:
 | 
					 | 
				
			||||||
      - name: Checkout repository
 | 
					 | 
				
			||||||
        uses: actions/checkout@v3
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          fetch-depth: 0
 | 
					 | 
				
			||||||
      - name: Setup NBGV
 | 
					 | 
				
			||||||
        uses: dotnet/nbgv@master
 | 
					 | 
				
			||||||
        id: nbgv
 | 
					 | 
				
			||||||
      - name: Set up Docker Buildx
 | 
					 | 
				
			||||||
        uses: docker/setup-buildx-action@v3
 | 
					 | 
				
			||||||
      - name: Log in to GitHub Container Registry
 | 
					 | 
				
			||||||
        uses: docker/login-action@v3
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          registry: ghcr.io
 | 
					 | 
				
			||||||
          username: ${{ github.actor }}
 | 
					 | 
				
			||||||
          password: ${{ secrets.GITHUB_TOKEN }}
 | 
					 | 
				
			||||||
      - name: Build and push DysonNetwork.Drive Docker image
 | 
					 | 
				
			||||||
        uses: docker/build-push-action@v6
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          file: DysonNetwork.Drive/Dockerfile
 | 
					 | 
				
			||||||
          context: .
 | 
					 | 
				
			||||||
          push: true
 | 
					 | 
				
			||||||
          tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-drive:latest
 | 
					 | 
				
			||||||
          platforms: linux/amd64
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  build-gateway:
 | 
					      - name: Build and Publish Aspire Application
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					        run: aspire publish --project ./DysonNetwork.Control/DysonNetwork.Control.csproj --output publish
 | 
				
			||||||
    permissions:
 | 
					
 | 
				
			||||||
      contents: read
 | 
					      - name: Tag and Push Images
 | 
				
			||||||
      packages: write
 | 
					        run: |
 | 
				
			||||||
    steps:
 | 
					          IMAGES=( "sphere" "pass" "ring" "drive" "develop" )
 | 
				
			||||||
      - name: Checkout repository
 | 
					
 | 
				
			||||||
        uses: actions/checkout@v3
 | 
					          for image in "${IMAGES[@]}"; do
 | 
				
			||||||
 | 
					            IMAGE_NAME="ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-$image:alpha"
 | 
				
			||||||
 | 
					            SOURCE_IMAGE_NAME="$image:latest" # Aspire's default local image name
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            echo "Tagging and pushing $SOURCE_IMAGE_NAME to $IMAGE_NAME..."
 | 
				
			||||||
 | 
					            docker tag $SOURCE_IMAGE_NAME $IMAGE_NAME
 | 
				
			||||||
 | 
					            docker push $IMAGE_NAME
 | 
				
			||||||
 | 
					          done
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Upload Aspire Publish Directory
 | 
				
			||||||
 | 
					        uses: actions/upload-artifact@v3
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          fetch-depth: 0
 | 
					          name: aspire-publish-output
 | 
				
			||||||
      - name: Setup NBGV
 | 
					          path: ./publish/
 | 
				
			||||||
        uses: dotnet/nbgv@master
 | 
					
 | 
				
			||||||
        id: nbgv
 | 
					      - name: Upload Docker Compose file
 | 
				
			||||||
      - name: Set up Docker Buildx
 | 
					        uses: actions/upload-artifact@v3
 | 
				
			||||||
        uses: docker/setup-buildx-action@v3
 | 
					 | 
				
			||||||
      - name: Log in to GitHub Container Registry
 | 
					 | 
				
			||||||
        uses: docker/login-action@v3
 | 
					 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          registry: ghcr.io
 | 
					          name: docker-compose-output
 | 
				
			||||||
          username: ${{ github.actor }}
 | 
					          path: ./publish/docker-compose.yml
 | 
				
			||||||
          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
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										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"
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										53
									
								
								DysonNetwork.Develop/AppDatabase.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								DysonNetwork.Develop/AppDatabase.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,53 @@
 | 
				
			|||||||
 | 
					using System.Text.Json;
 | 
				
			||||||
 | 
					using DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					using DysonNetwork.Develop.Project;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Design;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Develop;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class AppDatabase(
 | 
				
			||||||
 | 
					    DbContextOptions<AppDatabase> options,
 | 
				
			||||||
 | 
					    IConfiguration configuration
 | 
				
			||||||
 | 
					) : DbContext(options)
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public DbSet<Developer> Developers { get; set; } = null!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public DbSet<DevProject> DevProjects { get; set; } = null!;
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    public DbSet<CustomApp> CustomApps { get; set; } = null!;
 | 
				
			||||||
 | 
					    public DbSet<CustomAppSecret> CustomAppSecrets { get; set; } = null!;
 | 
				
			||||||
 | 
					    public DbSet<BotAccount> BotAccounts { get; set; } = null!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        optionsBuilder.UseNpgsql(
 | 
				
			||||||
 | 
					            configuration.GetConnectionString("App"),
 | 
				
			||||||
 | 
					            opt => opt
 | 
				
			||||||
 | 
					                .ConfigureDataSource(optSource => optSource.EnableDynamicJson())
 | 
				
			||||||
 | 
					                .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
 | 
				
			||||||
 | 
					                .UseNodaTime()
 | 
				
			||||||
 | 
					        ).UseSnakeCaseNamingConvention();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        base.OnConfiguring(optionsBuilder);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    protected override void OnModelCreating(ModelBuilder modelBuilder)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        base.OnModelCreating(modelBuilder);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class AppDatabaseFactory : IDesignTimeDbContextFactory<AppDatabase>
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public AppDatabase CreateDbContext(string[] args)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var configuration = new ConfigurationBuilder()
 | 
				
			||||||
 | 
					            .SetBasePath(Directory.GetCurrentDirectory())
 | 
				
			||||||
 | 
					            .AddJsonFile("appsettings.json")
 | 
				
			||||||
 | 
					            .Build();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var optionsBuilder = new DbContextOptionsBuilder<AppDatabase>();
 | 
				
			||||||
 | 
					        return new AppDatabase(optionsBuilder.Options, configuration);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										23
									
								
								DysonNetwork.Develop/Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								DysonNetwork.Develop/Dockerfile
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
				
			|||||||
 | 
					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.Develop/DysonNetwork.Develop.csproj", "DysonNetwork.Develop/"]
 | 
				
			||||||
 | 
					RUN dotnet restore "DysonNetwork.Develop/DysonNetwork.Develop.csproj"
 | 
				
			||||||
 | 
					COPY . .
 | 
				
			||||||
 | 
					WORKDIR "/src/DysonNetwork.Develop"
 | 
				
			||||||
 | 
					RUN dotnet build "./DysonNetwork.Develop.csproj" -c $BUILD_CONFIGURATION -o /app/build
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					FROM build AS publish
 | 
				
			||||||
 | 
					ARG BUILD_CONFIGURATION=Release
 | 
				
			||||||
 | 
					RUN dotnet publish "./DysonNetwork.Develop.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					FROM base AS final
 | 
				
			||||||
 | 
					WORKDIR /app
 | 
				
			||||||
 | 
					COPY --from=publish /app/publish .
 | 
				
			||||||
 | 
					ENTRYPOINT ["dotnet", "DysonNetwork.Develop.dll"]
 | 
				
			||||||
							
								
								
									
										38
									
								
								DysonNetwork.Develop/DysonNetwork.Develop.csproj
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								DysonNetwork.Develop/DysonNetwork.Develop.csproj
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,38 @@
 | 
				
			|||||||
 | 
					<Project Sdk="Microsoft.NET.Sdk.Web">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <PropertyGroup>
 | 
				
			||||||
 | 
					        <TargetFramework>net9.0</TargetFramework>
 | 
				
			||||||
 | 
					        <Nullable>enable</Nullable>
 | 
				
			||||||
 | 
					        <ImplicitUsings>enable</ImplicitUsings>
 | 
				
			||||||
 | 
					        <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
 | 
				
			||||||
 | 
					    </PropertyGroup>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <ItemGroup>
 | 
				
			||||||
 | 
					        <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
 | 
				
			||||||
 | 
					        <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7"/>
 | 
				
			||||||
 | 
					        <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
 | 
				
			||||||
 | 
					            <PrivateAssets>all</PrivateAssets>
 | 
				
			||||||
 | 
					            <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
 | 
				
			||||||
 | 
					        </PackageReference>
 | 
				
			||||||
 | 
					        <PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" />
 | 
				
			||||||
 | 
					        <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/>
 | 
				
			||||||
 | 
					        <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
 | 
				
			||||||
 | 
					        <PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/>
 | 
				
			||||||
 | 
					        <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3"/>
 | 
				
			||||||
 | 
					        <PackageReference Include="NodaTime" Version="3.2.2"/>
 | 
				
			||||||
 | 
					        <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/>
 | 
				
			||||||
 | 
					        <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0"/>
 | 
				
			||||||
 | 
					  </ItemGroup>
 | 
				
			||||||
 | 
					 
 | 
				
			||||||
 | 
					  <ItemGroup>
 | 
				
			||||||
 | 
					      <Content Include="..\.dockerignore">
 | 
				
			||||||
 | 
					        <Link>.dockerignore</Link>
 | 
				
			||||||
 | 
					      </Content>
 | 
				
			||||||
 | 
					    </ItemGroup>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <ItemGroup>
 | 
				
			||||||
 | 
					    <ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
 | 
				
			||||||
 | 
					    <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
 | 
				
			||||||
 | 
					  </ItemGroup>
 | 
				
			||||||
 | 
					 
 | 
				
			||||||
 | 
					</Project>
 | 
				
			||||||
							
								
								
									
										54
									
								
								DysonNetwork.Develop/Identity/BotAccount.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								DysonNetwork.Develop/Identity/BotAccount.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,54 @@
 | 
				
			|||||||
 | 
					using System.ComponentModel.DataAnnotations;
 | 
				
			||||||
 | 
					using System.ComponentModel.DataAnnotations.Schema;
 | 
				
			||||||
 | 
					using DysonNetwork.Develop.Project;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
 | 
					using NodaTime.Serialization.Protobuf;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class BotAccount : ModelBase
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public Guid Id { get; set; } = Guid.NewGuid();
 | 
				
			||||||
 | 
					    [MaxLength(1024)] public string Slug { get; set; } = null!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public bool IsActive { get; set; } = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public Guid ProjectId { get; set; }
 | 
				
			||||||
 | 
					    public DevProject Project { get; set; } = null!;
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    [NotMapped] public AccountReference? Account { get; set; }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    /// <summary>
 | 
				
			||||||
 | 
					    /// This developer field is to serve the transparent info for user to know which developer
 | 
				
			||||||
 | 
					    /// published this robot. Not for relationships usage.
 | 
				
			||||||
 | 
					    /// </summary>
 | 
				
			||||||
 | 
					    [NotMapped] public Developer? Developer { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public Shared.Proto.BotAccount ToProtoValue()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var proto = new Shared.Proto.BotAccount
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Slug = Slug,
 | 
				
			||||||
 | 
					            IsActive = IsActive,
 | 
				
			||||||
 | 
					            AutomatedId = Id.ToString(),
 | 
				
			||||||
 | 
					            CreatedAt = CreatedAt.ToTimestamp(),
 | 
				
			||||||
 | 
					            UpdatedAt = UpdatedAt.ToTimestamp()
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return proto;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static BotAccount FromProto(Shared.Proto.BotAccount proto)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var botAccount = new BotAccount
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Id = Guid.Parse(proto.AutomatedId),
 | 
				
			||||||
 | 
					            Slug = proto.Slug,
 | 
				
			||||||
 | 
					            IsActive = proto.IsActive,
 | 
				
			||||||
 | 
					            CreatedAt = proto.CreatedAt.ToInstant(),
 | 
				
			||||||
 | 
					            UpdatedAt = proto.UpdatedAt.ToInstant()
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return botAccount;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										460
									
								
								DysonNetwork.Develop/Identity/BotAccountController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										460
									
								
								DysonNetwork.Develop/Identity/BotAccountController.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,460 @@
 | 
				
			|||||||
 | 
					using System.ComponentModel.DataAnnotations;
 | 
				
			||||||
 | 
					using DysonNetwork.Develop.Project;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Registry;
 | 
				
			||||||
 | 
					using Grpc.Core;
 | 
				
			||||||
 | 
					using Microsoft.AspNetCore.Authorization;
 | 
				
			||||||
 | 
					using Microsoft.AspNetCore.Mvc;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					using NodaTime.Serialization.Protobuf;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[ApiController]
 | 
				
			||||||
 | 
					[Route("/api/developers/{pubName}/projects/{projectId:guid}/bots")]
 | 
				
			||||||
 | 
					[Authorize]
 | 
				
			||||||
 | 
					public class BotAccountController(
 | 
				
			||||||
 | 
					    BotAccountService botService,
 | 
				
			||||||
 | 
					    DeveloperService developerService,
 | 
				
			||||||
 | 
					    DevProjectService projectService,
 | 
				
			||||||
 | 
					    ILogger<BotAccountController> logger,
 | 
				
			||||||
 | 
					    AccountClientHelper accounts,
 | 
				
			||||||
 | 
					    BotAccountReceiverService.BotAccountReceiverServiceClient accountsReceiver
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					    : ControllerBase
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public class CommonBotRequest
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        [MaxLength(256)] public string? FirstName { get; set; }
 | 
				
			||||||
 | 
					        [MaxLength(256)] public string? MiddleName { get; set; }
 | 
				
			||||||
 | 
					        [MaxLength(256)] public string? LastName { get; set; }
 | 
				
			||||||
 | 
					        [MaxLength(1024)] public string? Gender { get; set; }
 | 
				
			||||||
 | 
					        [MaxLength(1024)] public string? Pronouns { get; set; }
 | 
				
			||||||
 | 
					        [MaxLength(1024)] public string? TimeZone { get; set; }
 | 
				
			||||||
 | 
					        [MaxLength(1024)] public string? Location { get; set; }
 | 
				
			||||||
 | 
					        [MaxLength(4096)] public string? Bio { get; set; }
 | 
				
			||||||
 | 
					        public Instant? Birthday { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        [MaxLength(32)] public string? PictureId { get; set; }
 | 
				
			||||||
 | 
					        [MaxLength(32)] public string? BackgroundId { get; set; }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public class BotCreateRequest : CommonBotRequest
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        [Required]
 | 
				
			||||||
 | 
					        [MinLength(2)]
 | 
				
			||||||
 | 
					        [MaxLength(256)]
 | 
				
			||||||
 | 
					        [RegularExpression(@"^[A-Za-z0-9_-]+$",
 | 
				
			||||||
 | 
					            ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.")
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        public string Name { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        [Required] [MaxLength(256)] public string Nick { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        [Required] [MaxLength(1024)] public string Slug { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        [MaxLength(128)] public string Language { get; set; } = "en-us";
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public class UpdateBotRequest : CommonBotRequest
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        [MinLength(2)]
 | 
				
			||||||
 | 
					        [MaxLength(256)]
 | 
				
			||||||
 | 
					        [RegularExpression(@"^[A-Za-z0-9_-]+$",
 | 
				
			||||||
 | 
					            ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.")
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        public string? Name { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        [MaxLength(256)] public string? Nick { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        [Required] [MaxLength(1024)] public string? Slug { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        [MaxLength(128)] public string? Language { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        public bool? IsActive { get; set; }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> ListBots(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var developer = await developerService.GetDeveloperByName(pubName);
 | 
				
			||||||
 | 
					        if (developer is null)
 | 
				
			||||||
 | 
					            return NotFound("Developer not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
 | 
				
			||||||
 | 
					                PublisherMemberRole.Viewer))
 | 
				
			||||||
 | 
					            return StatusCode(403, "You must be an viewer of the developer to list bots");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
 | 
					        if (project is null)
 | 
				
			||||||
 | 
					            return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var bots = await botService.GetBotsByProjectAsync(projectId);
 | 
				
			||||||
 | 
					        return Ok(await botService.LoadBotsAccountAsync(bots));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("{botId:guid}")]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> GetBot(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid botId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var developer = await developerService.GetDeveloperByName(pubName);
 | 
				
			||||||
 | 
					        if (developer is null)
 | 
				
			||||||
 | 
					            return NotFound("Developer not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
 | 
				
			||||||
 | 
					                PublisherMemberRole.Viewer))
 | 
				
			||||||
 | 
					            return StatusCode(403, "You must be an viewer of the developer to view bot details");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
 | 
					        if (project is null)
 | 
				
			||||||
 | 
					            return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var bot = await botService.GetBotByIdAsync(botId);
 | 
				
			||||||
 | 
					        if (bot is null || bot.ProjectId != projectId)
 | 
				
			||||||
 | 
					            return NotFound("Bot not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Ok(await botService.LoadBotAccountAsync(bot));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> CreateBot(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromBody] BotCreateRequest createRequest
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var developer = await developerService.GetDeveloperByName(pubName);
 | 
				
			||||||
 | 
					        if (developer is null)
 | 
				
			||||||
 | 
					            return NotFound("Developer not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
 | 
				
			||||||
 | 
					                PublisherMemberRole.Editor))
 | 
				
			||||||
 | 
					            return StatusCode(403, "You must be an editor of the developer to create a bot");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
 | 
					        if (project is null)
 | 
				
			||||||
 | 
					            return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var now = SystemClock.Instance.GetCurrentInstant();
 | 
				
			||||||
 | 
					        var accountId = Guid.NewGuid();
 | 
				
			||||||
 | 
					        var account = new Account()
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Id = accountId.ToString(),
 | 
				
			||||||
 | 
					            Name = createRequest.Name,
 | 
				
			||||||
 | 
					            Nick = createRequest.Nick,
 | 
				
			||||||
 | 
					            Language = createRequest.Language,
 | 
				
			||||||
 | 
					            Profile = new AccountProfile()
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Id = Guid.NewGuid().ToString(),
 | 
				
			||||||
 | 
					                Bio = createRequest.Bio,
 | 
				
			||||||
 | 
					                Gender = createRequest.Gender,
 | 
				
			||||||
 | 
					                FirstName = createRequest.FirstName,
 | 
				
			||||||
 | 
					                MiddleName = createRequest.MiddleName,
 | 
				
			||||||
 | 
					                LastName = createRequest.LastName,
 | 
				
			||||||
 | 
					                TimeZone = createRequest.TimeZone,
 | 
				
			||||||
 | 
					                Pronouns = createRequest.Pronouns,
 | 
				
			||||||
 | 
					                Location = createRequest.Location,
 | 
				
			||||||
 | 
					                Birthday = createRequest.Birthday?.ToTimestamp(),
 | 
				
			||||||
 | 
					                AccountId = accountId.ToString(),
 | 
				
			||||||
 | 
					                CreatedAt = now.ToTimestamp(),
 | 
				
			||||||
 | 
					                UpdatedAt = now.ToTimestamp()
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            CreatedAt = now.ToTimestamp(),
 | 
				
			||||||
 | 
					            UpdatedAt = now.ToTimestamp()
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var bot = await botService.CreateBotAsync(
 | 
				
			||||||
 | 
					                project,
 | 
				
			||||||
 | 
					                createRequest.Slug,
 | 
				
			||||||
 | 
					                account,
 | 
				
			||||||
 | 
					                createRequest.PictureId,
 | 
				
			||||||
 | 
					                createRequest.BackgroundId
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					            return Ok(bot);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            logger.LogError(ex, "Error creating bot account");
 | 
				
			||||||
 | 
					            return StatusCode(500, "An error occurred while creating the bot account");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPatch("{botId:guid}")]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> UpdateBot(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid botId,
 | 
				
			||||||
 | 
					        [FromBody] UpdateBotRequest request
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var developer = await developerService.GetDeveloperByName(pubName);
 | 
				
			||||||
 | 
					        if (developer is null)
 | 
				
			||||||
 | 
					            return NotFound("Developer not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
 | 
				
			||||||
 | 
					                PublisherMemberRole.Editor))
 | 
				
			||||||
 | 
					            return StatusCode(403, "You must be an editor of the developer to update a bot");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
 | 
					        if (project is null)
 | 
				
			||||||
 | 
					            return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var bot = await botService.GetBotByIdAsync(botId);
 | 
				
			||||||
 | 
					        if (bot is null || bot.ProjectId != projectId)
 | 
				
			||||||
 | 
					            return NotFound("Bot not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var botAccount = await accounts.GetBotAccount(bot.Id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (request.Name is not null) botAccount.Name = request.Name;
 | 
				
			||||||
 | 
					        if (request.Nick is not null) botAccount.Nick = request.Nick;
 | 
				
			||||||
 | 
					        if (request.Language is not null) botAccount.Language = request.Language;
 | 
				
			||||||
 | 
					        if (request.Bio is not null) botAccount.Profile.Bio = request.Bio;
 | 
				
			||||||
 | 
					        if (request.Gender is not null) botAccount.Profile.Gender = request.Gender;
 | 
				
			||||||
 | 
					        if (request.FirstName is not null) botAccount.Profile.FirstName = request.FirstName;
 | 
				
			||||||
 | 
					        if (request.MiddleName is not null) botAccount.Profile.MiddleName = request.MiddleName;
 | 
				
			||||||
 | 
					        if (request.LastName is not null) botAccount.Profile.LastName = request.LastName;
 | 
				
			||||||
 | 
					        if (request.TimeZone is not null) botAccount.Profile.TimeZone = request.TimeZone;
 | 
				
			||||||
 | 
					        if (request.Pronouns is not null) botAccount.Profile.Pronouns = request.Pronouns;
 | 
				
			||||||
 | 
					        if (request.Location is not null) botAccount.Profile.Location = request.Location;
 | 
				
			||||||
 | 
					        if (request.Birthday is not null) botAccount.Profile.Birthday = request.Birthday?.ToTimestamp();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (request.Slug is not null) bot.Slug = request.Slug;
 | 
				
			||||||
 | 
					        if (request.IsActive is not null) bot.IsActive = request.IsActive.Value;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var updatedBot = await botService.UpdateBotAsync(
 | 
				
			||||||
 | 
					                bot,
 | 
				
			||||||
 | 
					                botAccount,
 | 
				
			||||||
 | 
					                request.PictureId,
 | 
				
			||||||
 | 
					                request.BackgroundId
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            return Ok(updatedBot);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            logger.LogError(ex, "Error updating bot account {BotId}", botId);
 | 
				
			||||||
 | 
					            return StatusCode(500, "An error occurred while updating the bot account");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpDelete("{botId:guid}")]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> DeleteBot(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid botId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var developer = await developerService.GetDeveloperByName(pubName);
 | 
				
			||||||
 | 
					        if (developer is null)
 | 
				
			||||||
 | 
					            return NotFound("Developer not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
 | 
				
			||||||
 | 
					                PublisherMemberRole.Editor))
 | 
				
			||||||
 | 
					            return StatusCode(403, "You must be an editor of the developer to delete a bot");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
 | 
					        if (project is null)
 | 
				
			||||||
 | 
					            return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var bot = await botService.GetBotByIdAsync(botId);
 | 
				
			||||||
 | 
					        if (bot is null || bot.ProjectId != projectId)
 | 
				
			||||||
 | 
					            return NotFound("Bot not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            await botService.DeleteBotAsync(bot);
 | 
				
			||||||
 | 
					            return NoContent();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            logger.LogError(ex, "Error deleting bot {BotId}", botId);
 | 
				
			||||||
 | 
					            return StatusCode(500, "An error occurred while deleting the bot account");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("{botId:guid}/keys")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<List<ApiKeyReference>>> ListBotKeys(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid botId
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Viewer);
 | 
				
			||||||
 | 
					        if (developer == null) return NotFound("Developer not found");
 | 
				
			||||||
 | 
					        if (project == null) return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					        if (bot == null) return NotFound("Bot not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var keys = await accountsReceiver.ListApiKeyAsync(new ListApiKeyRequest
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            AutomatedId = bot.Id.ToString()
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        var data = keys.Data.Select(ApiKeyReference.FromProtoValue).ToList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Ok(data);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("{botId:guid}/keys/{keyId:guid}")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<ApiKeyReference>> GetBotKey(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid botId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid keyId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Viewer);
 | 
				
			||||||
 | 
					        if (developer == null) return NotFound("Developer not found");
 | 
				
			||||||
 | 
					        if (project == null) return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					        if (bot == null) return NotFound("Bot not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var key = await accountsReceiver.GetApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() });
 | 
				
			||||||
 | 
					            if (key == null) return NotFound("API key not found");
 | 
				
			||||||
 | 
					            return Ok(ApiKeyReference.FromProtoValue(key));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return NotFound("API key not found");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public class CreateApiKeyRequest
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        [Required, MaxLength(1024)]
 | 
				
			||||||
 | 
					        public string Label { get; set; } = null!;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost("{botId:guid}/keys")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<ApiKeyReference>> CreateBotKey(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid botId,
 | 
				
			||||||
 | 
					        [FromBody] CreateApiKeyRequest request)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor);
 | 
				
			||||||
 | 
					        if (developer == null) return NotFound("Developer not found");
 | 
				
			||||||
 | 
					        if (project == null) return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					        if (bot == null) return NotFound("Bot not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var newKey = new ApiKey
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                AccountId = bot.Id.ToString(),
 | 
				
			||||||
 | 
					                Label = request.Label
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            var createdKey = await accountsReceiver.CreateApiKeyAsync(newKey);
 | 
				
			||||||
 | 
					            return Ok(ApiKeyReference.FromProtoValue(createdKey));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(ex.Status.Detail);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost("{botId:guid}/keys/{keyId:guid}/rotate")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<ApiKeyReference>> RotateBotKey(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid botId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid keyId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor);
 | 
				
			||||||
 | 
					        if (developer == null) return NotFound("Developer not found");
 | 
				
			||||||
 | 
					        if (project == null) return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					        if (bot == null) return NotFound("Bot not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var rotatedKey = await accountsReceiver.RotateApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() });
 | 
				
			||||||
 | 
					            return Ok(ApiKeyReference.FromProtoValue(rotatedKey));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return NotFound("API key not found");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpDelete("{botId:guid}/keys/{keyId:guid}")]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> DeleteBotKey(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid botId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid keyId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor);
 | 
				
			||||||
 | 
					        if (developer == null) return NotFound("Developer not found");
 | 
				
			||||||
 | 
					        if (project == null) return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					        if (bot == null) return NotFound("Bot not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            await accountsReceiver.DeleteApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() });
 | 
				
			||||||
 | 
					            return NoContent();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return NotFound("API key not found");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private async Task<(Developer?, DevProject?, BotAccount?)> ValidateBotAccess(
 | 
				
			||||||
 | 
					        string pubName,
 | 
				
			||||||
 | 
					        Guid projectId,
 | 
				
			||||||
 | 
					        Guid botId,
 | 
				
			||||||
 | 
					        Account currentUser,
 | 
				
			||||||
 | 
					        PublisherMemberRole requiredRole)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var developer = await developerService.GetDeveloperByName(pubName);
 | 
				
			||||||
 | 
					        if (developer == null) return (null, null, null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), requiredRole))
 | 
				
			||||||
 | 
					            return (null, null, null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
 | 
					        if (project == null) return (developer, null, null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var bot = await botService.GetBotByIdAsync(botId);
 | 
				
			||||||
 | 
					        if (bot == null || bot.ProjectId != projectId) return (developer, project, null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return (developer, project, bot);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										35
									
								
								DysonNetwork.Develop/Identity/BotAccountPublicController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								DysonNetwork.Develop/Identity/BotAccountPublicController.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,35 @@
 | 
				
			|||||||
 | 
					using Microsoft.AspNetCore.Mvc;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[ApiController]
 | 
				
			||||||
 | 
					[Route("api/bots")]
 | 
				
			||||||
 | 
					public class BotAccountPublicController(BotAccountService botService, DeveloperService developerService) : ControllerBase
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    [HttpGet("{botId:guid}")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<BotAccount>> GetBotTransparentInfo([FromRoute] Guid botId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var bot = await botService.GetBotByIdAsync(botId);
 | 
				
			||||||
 | 
					        if (bot is null) return NotFound("Bot not found");
 | 
				
			||||||
 | 
					        bot = await botService.LoadBotAccountAsync(bot);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var developer = await developerService.GetDeveloperById(bot!.Project.DeveloperId);
 | 
				
			||||||
 | 
					        if (developer is null) return NotFound("Developer not found");
 | 
				
			||||||
 | 
					        bot.Developer = await developerService.LoadDeveloperPublisher(developer);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Ok(bot);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("{botId:guid}/developer")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<Developer>> GetBotDeveloper([FromRoute] Guid botId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var bot = await botService.GetBotByIdAsync(botId);
 | 
				
			||||||
 | 
					        if (bot is null) return NotFound("Bot not found");
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        var developer = await developerService.GetDeveloperById(bot!.Project.DeveloperId);
 | 
				
			||||||
 | 
					        if (developer is null) return NotFound("Developer not found");
 | 
				
			||||||
 | 
					        developer = await developerService.LoadDeveloperPublisher(developer);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Ok(developer);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										174
									
								
								DysonNetwork.Develop/Identity/BotAccountService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										174
									
								
								DysonNetwork.Develop/Identity/BotAccountService.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,174 @@
 | 
				
			|||||||
 | 
					using DysonNetwork.Develop.Project;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Registry;
 | 
				
			||||||
 | 
					using Grpc.Core;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					using NodaTime.Serialization.Protobuf;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class BotAccountService(
 | 
				
			||||||
 | 
					    AppDatabase db,
 | 
				
			||||||
 | 
					    BotAccountReceiverService.BotAccountReceiverServiceClient accountReceiver,
 | 
				
			||||||
 | 
					    AccountClientHelper accounts
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public async Task<BotAccount?> GetBotByIdAsync(Guid id)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return await db.BotAccounts
 | 
				
			||||||
 | 
					            .Include(b => b.Project)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync(b => b.Id == id);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<IEnumerable<BotAccount>> GetBotsByProjectAsync(Guid projectId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return await db.BotAccounts
 | 
				
			||||||
 | 
					            .Where(b => b.ProjectId == projectId)
 | 
				
			||||||
 | 
					            .ToListAsync();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<BotAccount> CreateBotAsync(
 | 
				
			||||||
 | 
					        DevProject project,
 | 
				
			||||||
 | 
					        string slug,
 | 
				
			||||||
 | 
					        Account account,
 | 
				
			||||||
 | 
					        string? pictureId,
 | 
				
			||||||
 | 
					        string? backgroundId
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        // First, check if a bot with this slug already exists in this project
 | 
				
			||||||
 | 
					        var existingBot = await db.BotAccounts
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync(b => b.ProjectId == project.Id && b.Slug == slug);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (existingBot != null)
 | 
				
			||||||
 | 
					            throw new InvalidOperationException("A bot with this slug already exists in this project.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var automatedId = Guid.NewGuid();
 | 
				
			||||||
 | 
					            var createRequest = new CreateBotAccountRequest
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                AutomatedId = automatedId.ToString(),
 | 
				
			||||||
 | 
					                Account = account,
 | 
				
			||||||
 | 
					                PictureId = pictureId,
 | 
				
			||||||
 | 
					                BackgroundId = backgroundId
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            var createResponse = await accountReceiver.CreateBotAccountAsync(createRequest);
 | 
				
			||||||
 | 
					            var botAccount = createResponse.Bot;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Then create the local bot account
 | 
				
			||||||
 | 
					            var bot = new BotAccount
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Id = automatedId,
 | 
				
			||||||
 | 
					                Slug = slug,
 | 
				
			||||||
 | 
					                ProjectId = project.Id,
 | 
				
			||||||
 | 
					                Project = project,
 | 
				
			||||||
 | 
					                IsActive = botAccount.IsActive,
 | 
				
			||||||
 | 
					                CreatedAt = botAccount.CreatedAt.ToInstant(),
 | 
				
			||||||
 | 
					                UpdatedAt = botAccount.UpdatedAt.ToInstant()
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            db.BotAccounts.Add(bot);
 | 
				
			||||||
 | 
					            await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            return bot;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (RpcException ex) when (ex.StatusCode == StatusCode.AlreadyExists)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            throw new InvalidOperationException(
 | 
				
			||||||
 | 
					                "A bot account with this ID already exists in the authentication service.", ex);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (RpcException ex) when (ex.StatusCode == StatusCode.InvalidArgument)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            throw new ArgumentException($"Invalid bot account data: {ex.Status.Detail}", ex);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (RpcException ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            throw new Exception($"Failed to create bot account: {ex.Status.Detail}", ex);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<BotAccount> UpdateBotAsync(
 | 
				
			||||||
 | 
					        BotAccount bot,
 | 
				
			||||||
 | 
					        Account account,
 | 
				
			||||||
 | 
					        string? pictureId,
 | 
				
			||||||
 | 
					        string? backgroundId
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        db.Update(bot);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            // Update the bot account in the Pass service
 | 
				
			||||||
 | 
					            var updateRequest = new UpdateBotAccountRequest
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                AutomatedId = bot.Id.ToString(),
 | 
				
			||||||
 | 
					                Account = account,
 | 
				
			||||||
 | 
					                PictureId = pictureId,
 | 
				
			||||||
 | 
					                BackgroundId = backgroundId
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            var updateResponse = await accountReceiver.UpdateBotAccountAsync(updateRequest);
 | 
				
			||||||
 | 
					            var updatedBot = updateResponse.Bot;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Update local bot account
 | 
				
			||||||
 | 
					            bot.UpdatedAt = updatedBot.UpdatedAt.ToInstant();
 | 
				
			||||||
 | 
					            bot.IsActive = updatedBot.IsActive;
 | 
				
			||||||
 | 
					            await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            throw new Exception("Bot account not found in the authentication service", ex);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (RpcException ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            throw new Exception($"Failed to update bot account: {ex.Status.Detail}", ex);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return bot;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task DeleteBotAsync(BotAccount bot)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            // Delete the bot account from the Pass service
 | 
				
			||||||
 | 
					            var deleteRequest = new DeleteBotAccountRequest
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                AutomatedId = bot.Id.ToString(),
 | 
				
			||||||
 | 
					                Force = false
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            await accountReceiver.DeleteBotAccountAsync(deleteRequest);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            // Account not found in Pass service, continue with local deletion
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Delete the local bot account
 | 
				
			||||||
 | 
					        db.BotAccounts.Remove(bot);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<BotAccount?> LoadBotAccountAsync(BotAccount bot) =>
 | 
				
			||||||
 | 
					        (await LoadBotsAccountAsync([bot])).FirstOrDefault();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<List<BotAccount>> LoadBotsAccountAsync(IEnumerable<BotAccount> bots)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        bots = bots.ToList();
 | 
				
			||||||
 | 
					        var automatedIds = bots.Select(b => b.Id).ToList();
 | 
				
			||||||
 | 
					        var data = await accounts.GetBotAccountBatch(automatedIds);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        foreach (var bot in bots)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            bot.Account = data
 | 
				
			||||||
 | 
					                .Select(AccountReference.FromProtoValue)
 | 
				
			||||||
 | 
					                .FirstOrDefault(e => e.AutomatedId == bot.Id);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return bots as List<BotAccount> ?? [];
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										178
									
								
								DysonNetwork.Develop/Identity/CustomApp.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										178
									
								
								DysonNetwork.Develop/Identity/CustomApp.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,178 @@
 | 
				
			|||||||
 | 
					using System.ComponentModel.DataAnnotations;
 | 
				
			||||||
 | 
					using System.ComponentModel.DataAnnotations.Schema;
 | 
				
			||||||
 | 
					using System.Text.Json.Serialization;
 | 
				
			||||||
 | 
					using DysonNetwork.Develop.Project;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
 | 
					using Google.Protobuf;
 | 
				
			||||||
 | 
					using Google.Protobuf.WellKnownTypes;
 | 
				
			||||||
 | 
					using NodaTime.Serialization.Protobuf;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					using VerificationMark = DysonNetwork.Shared.Data.VerificationMark;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public enum CustomAppStatus
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    Developing,
 | 
				
			||||||
 | 
					    Staging,
 | 
				
			||||||
 | 
					    Production,
 | 
				
			||||||
 | 
					    Suspended
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class CustomApp : ModelBase, IIdentifiedResource
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public Guid Id { get; set; } = Guid.NewGuid();
 | 
				
			||||||
 | 
					    [MaxLength(1024)] public string Slug { get; set; } = null!;
 | 
				
			||||||
 | 
					    [MaxLength(1024)] public string Name { get; set; } = null!;
 | 
				
			||||||
 | 
					    [MaxLength(4096)] public string? Description { get; set; }
 | 
				
			||||||
 | 
					    public CustomAppStatus Status { get; set; } = CustomAppStatus.Developing;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
 | 
				
			||||||
 | 
					    [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
 | 
				
			||||||
 | 
					    [Column(TypeName = "jsonb")] public CustomAppOauthConfig? OauthConfig { get; set; }
 | 
				
			||||||
 | 
					    [Column(TypeName = "jsonb")] public CustomAppLinks? Links { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [JsonIgnore] public ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public Guid ProjectId { get; set; }
 | 
				
			||||||
 | 
					    public DevProject Project { get; set; } = null!;
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    [NotMapped]
 | 
				
			||||||
 | 
					    public Developer Developer => Project.Developer;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [NotMapped] public string ResourceIdentifier => "custom-app:" + Id;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public Shared.Proto.CustomApp ToProto()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return new Shared.Proto.CustomApp
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Id = Id.ToString(),
 | 
				
			||||||
 | 
					            Slug = Slug,
 | 
				
			||||||
 | 
					            Name = Name,
 | 
				
			||||||
 | 
					            Description = Description ?? string.Empty,
 | 
				
			||||||
 | 
					            Status = Status switch
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                CustomAppStatus.Developing => Shared.Proto.CustomAppStatus.Developing,
 | 
				
			||||||
 | 
					                CustomAppStatus.Staging => Shared.Proto.CustomAppStatus.Staging,
 | 
				
			||||||
 | 
					                CustomAppStatus.Production => Shared.Proto.CustomAppStatus.Production,
 | 
				
			||||||
 | 
					                CustomAppStatus.Suspended => Shared.Proto.CustomAppStatus.Suspended,
 | 
				
			||||||
 | 
					                _ => Shared.Proto.CustomAppStatus.Unspecified
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            Picture = Picture?.ToProtoValue(),
 | 
				
			||||||
 | 
					            Background = Background?.ToProtoValue(),
 | 
				
			||||||
 | 
					            Verification = Verification?.ToProtoValue(),
 | 
				
			||||||
 | 
					            Links = Links is null ? null : new DysonNetwork.Shared.Proto.CustomAppLinks
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                HomePage = Links.HomePage ?? string.Empty,
 | 
				
			||||||
 | 
					                PrivacyPolicy = Links.PrivacyPolicy ?? string.Empty,
 | 
				
			||||||
 | 
					                TermsOfService = Links.TermsOfService ?? string.Empty
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            OauthConfig = OauthConfig is null ? null : new DysonNetwork.Shared.Proto.CustomAppOauthConfig
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                ClientUri = OauthConfig.ClientUri ?? string.Empty,
 | 
				
			||||||
 | 
					                RedirectUris = { OauthConfig.RedirectUris ?? [] },
 | 
				
			||||||
 | 
					                PostLogoutRedirectUris = { OauthConfig.PostLogoutRedirectUris ?? [] },
 | 
				
			||||||
 | 
					                AllowedScopes = { OauthConfig.AllowedScopes ?? [] },
 | 
				
			||||||
 | 
					                AllowedGrantTypes = { OauthConfig.AllowedGrantTypes ?? [] },
 | 
				
			||||||
 | 
					                RequirePkce = OauthConfig.RequirePkce,
 | 
				
			||||||
 | 
					                AllowOfflineAccess = OauthConfig.AllowOfflineAccess
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            ProjectId = ProjectId.ToString(),
 | 
				
			||||||
 | 
					            CreatedAt = CreatedAt.ToTimestamp(),
 | 
				
			||||||
 | 
					            UpdatedAt = UpdatedAt.ToTimestamp()
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public CustomApp FromProtoValue(Shared.Proto.CustomApp p)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        Id = Guid.Parse(p.Id);
 | 
				
			||||||
 | 
					        Slug = p.Slug;
 | 
				
			||||||
 | 
					        Name = p.Name;
 | 
				
			||||||
 | 
					        Description = string.IsNullOrEmpty(p.Description) ? null : p.Description;
 | 
				
			||||||
 | 
					        Status = p.Status switch
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Shared.Proto.CustomAppStatus.Developing => CustomAppStatus.Developing,
 | 
				
			||||||
 | 
					            Shared.Proto.CustomAppStatus.Staging => CustomAppStatus.Staging,
 | 
				
			||||||
 | 
					            Shared.Proto.CustomAppStatus.Production => CustomAppStatus.Production,
 | 
				
			||||||
 | 
					            Shared.Proto.CustomAppStatus.Suspended => CustomAppStatus.Suspended,
 | 
				
			||||||
 | 
					            _ => CustomAppStatus.Developing
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					        ProjectId = string.IsNullOrEmpty(p.ProjectId) ? Guid.Empty : Guid.Parse(p.ProjectId);
 | 
				
			||||||
 | 
					        CreatedAt = p.CreatedAt.ToInstant();
 | 
				
			||||||
 | 
					        UpdatedAt = p.UpdatedAt.ToInstant();
 | 
				
			||||||
 | 
					        if (p.Picture is not null) Picture = CloudFileReferenceObject.FromProtoValue(p.Picture);
 | 
				
			||||||
 | 
					        if (p.Background is not null) Background = CloudFileReferenceObject.FromProtoValue(p.Background);
 | 
				
			||||||
 | 
					        if (p.Verification is not null) Verification = VerificationMark.FromProtoValue(p.Verification);
 | 
				
			||||||
 | 
					        if (p.Links is not null)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Links = new CustomAppLinks
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                HomePage = string.IsNullOrEmpty(p.Links.HomePage) ? null : p.Links.HomePage,
 | 
				
			||||||
 | 
					                PrivacyPolicy = string.IsNullOrEmpty(p.Links.PrivacyPolicy) ? null : p.Links.PrivacyPolicy,
 | 
				
			||||||
 | 
					                TermsOfService = string.IsNullOrEmpty(p.Links.TermsOfService) ? null : p.Links.TermsOfService
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return this;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class CustomAppLinks
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    [MaxLength(8192)] public string? HomePage { get; set; }
 | 
				
			||||||
 | 
					    [MaxLength(8192)] public string? PrivacyPolicy { get; set; }
 | 
				
			||||||
 | 
					    [MaxLength(8192)] public string? TermsOfService { get; set; }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class CustomAppOauthConfig
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    [MaxLength(1024)] public string? ClientUri { get; set; }
 | 
				
			||||||
 | 
					    [MaxLength(4096)] public string[] RedirectUris { get; set; } = [];
 | 
				
			||||||
 | 
					    [MaxLength(4096)] public string[]? PostLogoutRedirectUris { get; set; }
 | 
				
			||||||
 | 
					    [MaxLength(256)] public string[]? AllowedScopes { get; set; } = ["openid", "profile", "email"];
 | 
				
			||||||
 | 
					    [MaxLength(256)] public string[] AllowedGrantTypes { get; set; } = ["authorization_code", "refresh_token"];
 | 
				
			||||||
 | 
					    public bool RequirePkce { get; set; } = true;
 | 
				
			||||||
 | 
					    public bool AllowOfflineAccess { get; set; } = false;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class CustomAppSecret : ModelBase
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public Guid Id { get; set; } = Guid.NewGuid();
 | 
				
			||||||
 | 
					    [MaxLength(1024)] public string Secret { get; set; } = null!;
 | 
				
			||||||
 | 
					    [MaxLength(4096)] public string? Description { get; set; } = null!;
 | 
				
			||||||
 | 
					    public Instant? ExpiredAt { get; set; }
 | 
				
			||||||
 | 
					    public bool IsOidc { get; set; } = false; // Indicates if this secret is for OIDC/OAuth
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public Guid AppId { get; set; }
 | 
				
			||||||
 | 
					    public CustomApp App { get; set; } = null!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static CustomAppSecret FromProtoValue(DysonNetwork.Shared.Proto.CustomAppSecret p)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return new CustomAppSecret
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Id = Guid.Parse(p.Id),
 | 
				
			||||||
 | 
					            Secret = p.Secret,
 | 
				
			||||||
 | 
					            Description = p.Description,
 | 
				
			||||||
 | 
					            ExpiredAt = p.ExpiredAt?.ToInstant(),
 | 
				
			||||||
 | 
					            IsOidc = p.IsOidc,
 | 
				
			||||||
 | 
					            AppId = Guid.Parse(p.AppId),
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public DysonNetwork.Shared.Proto.CustomAppSecret ToProto()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return new DysonNetwork.Shared.Proto.CustomAppSecret
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Id = Id.ToString(),
 | 
				
			||||||
 | 
					            Secret = Secret,
 | 
				
			||||||
 | 
					            Description = Description,
 | 
				
			||||||
 | 
					            ExpiredAt = ExpiredAt?.ToTimestamp(),
 | 
				
			||||||
 | 
					            IsOidc = IsOidc,
 | 
				
			||||||
 | 
					            AppId = Id.ToString(),
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										431
									
								
								DysonNetwork.Develop/Identity/CustomAppController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										431
									
								
								DysonNetwork.Develop/Identity/CustomAppController.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,431 @@
 | 
				
			|||||||
 | 
					using System.ComponentModel.DataAnnotations;
 | 
				
			||||||
 | 
					using DysonNetwork.Develop.Project;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
 | 
					using Microsoft.AspNetCore.Authorization;
 | 
				
			||||||
 | 
					using Microsoft.AspNetCore.Mvc;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[ApiController]
 | 
				
			||||||
 | 
					[Route("/api/developers/{pubName}/projects/{projectId:guid}/apps")]
 | 
				
			||||||
 | 
					public class CustomAppController(CustomAppService customApps, DeveloperService ds, DevProjectService projectService)
 | 
				
			||||||
 | 
					    : ControllerBase
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public record CustomAppRequest(
 | 
				
			||||||
 | 
					        [MaxLength(1024)] string? Slug,
 | 
				
			||||||
 | 
					        [MaxLength(1024)] string? Name,
 | 
				
			||||||
 | 
					        [MaxLength(4096)] string? Description,
 | 
				
			||||||
 | 
					        string? PictureId,
 | 
				
			||||||
 | 
					        string? BackgroundId,
 | 
				
			||||||
 | 
					        CustomAppStatus? Status,
 | 
				
			||||||
 | 
					        CustomAppLinks? Links,
 | 
				
			||||||
 | 
					        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]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> ListApps([FromRoute] string pubName, [FromRoute] Guid projectId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var developer = await ds.GetDeveloperByName(pubName);
 | 
				
			||||||
 | 
					        if (developer is null) return NotFound();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var accountId = Guid.Parse(currentUser.Id);
 | 
				
			||||||
 | 
					        if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, PublisherMemberRole.Viewer))
 | 
				
			||||||
 | 
					            return StatusCode(403, "You must be a viewer of the developer to list custom apps");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
 | 
					        if (project is null) return NotFound();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var apps = await customApps.GetAppsByProjectAsync(projectId);
 | 
				
			||||||
 | 
					        return Ok(apps);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("{appId:guid}")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> GetApp([FromRoute] string pubName, [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid appId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        var developer = await ds.GetDeveloperByName(pubName);
 | 
				
			||||||
 | 
					        if (developer is null) return NotFound();
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        var accountId = Guid.Parse(currentUser.Id);
 | 
				
			||||||
 | 
					        if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, PublisherMemberRole.Viewer))
 | 
				
			||||||
 | 
					            return StatusCode(403, "You must be a viewer of the developer to list custom apps");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
 | 
					        if (project is null) return NotFound();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var app = await customApps.GetAppAsync(appId, projectId);
 | 
				
			||||||
 | 
					        if (app == null)
 | 
				
			||||||
 | 
					            return NotFound();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Ok(app);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> CreateApp(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromBody] CustomAppRequest 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 a custom app");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
 | 
					        if (project is null)
 | 
				
			||||||
 | 
					            return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Slug))
 | 
				
			||||||
 | 
					            return BadRequest("Name and slug are required");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var app = await customApps.CreateAppAsync(projectId, request);
 | 
				
			||||||
 | 
					            if (app == null)
 | 
				
			||||||
 | 
					                return BadRequest("Failed to create app");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            return CreatedAtAction(
 | 
				
			||||||
 | 
					                nameof(GetApp),
 | 
				
			||||||
 | 
					                new { pubName, projectId, appId = app.Id },
 | 
				
			||||||
 | 
					                app
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (InvalidOperationException ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(ex.Message);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPatch("{appId:guid}")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> UpdateApp(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid appId,
 | 
				
			||||||
 | 
					        [FromBody] CustomAppRequest 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 update a custom app");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
 | 
					        if (project is null)
 | 
				
			||||||
 | 
					            return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var app = await customApps.GetAppAsync(appId, projectId);
 | 
				
			||||||
 | 
					        if (app == null)
 | 
				
			||||||
 | 
					            return NotFound();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            app = await customApps.UpdateAppAsync(app, request);
 | 
				
			||||||
 | 
					            return Ok(app);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (InvalidOperationException ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(ex.Message);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpDelete("{appId:guid}")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> DeleteApp(
 | 
				
			||||||
 | 
					        [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 delete a custom app");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
 | 
					        if (project is null)
 | 
				
			||||||
 | 
					            return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var app = await customApps.GetAppAsync(appId, projectId);
 | 
				
			||||||
 | 
					        if (app == null)
 | 
				
			||||||
 | 
					            return NotFound();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var result = await customApps.DeleteAppAsync(appId);
 | 
				
			||||||
 | 
					        if (!result)
 | 
				
			||||||
 | 
					            return NotFound();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return NoContent();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("{appId:guid}/secrets")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> ListSecrets(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid appId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var developer = await ds.GetDeveloperByName(pubName);
 | 
				
			||||||
 | 
					        if (developer is null)
 | 
				
			||||||
 | 
					            return NotFound("Developer not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
 | 
				
			||||||
 | 
					            return StatusCode(403, "You must be an editor of the developer to view app secrets");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
 | 
					        if (project is null)
 | 
				
			||||||
 | 
					            return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var app = await customApps.GetAppAsync(appId, projectId);
 | 
				
			||||||
 | 
					        if (app == null)
 | 
				
			||||||
 | 
					            return NotFound("App not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var secrets = await customApps.GetAppSecretsAsync(appId);
 | 
				
			||||||
 | 
					        return Ok(secrets.Select(s => new SecretResponse(
 | 
				
			||||||
 | 
					            s.Id.ToString(),
 | 
				
			||||||
 | 
					            null,
 | 
				
			||||||
 | 
					            s.Description,
 | 
				
			||||||
 | 
					            s.ExpiredAt,
 | 
				
			||||||
 | 
					            s.IsOidc,
 | 
				
			||||||
 | 
					            s.CreatedAt,
 | 
				
			||||||
 | 
					            s.UpdatedAt
 | 
				
			||||||
 | 
					        )));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost("{appId:guid}/secrets")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> CreateSecret(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid appId,
 | 
				
			||||||
 | 
					        [FromBody] CreateSecretRequest request)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var developer = await ds.GetDeveloperByName(pubName);
 | 
				
			||||||
 | 
					        if (developer is null)
 | 
				
			||||||
 | 
					            return NotFound("Developer not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
 | 
				
			||||||
 | 
					            return StatusCode(403, "You must be an editor of the developer to create app secrets");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
 | 
					        if (project is null)
 | 
				
			||||||
 | 
					            return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var app = await customApps.GetAppAsync(appId, projectId);
 | 
				
			||||||
 | 
					        if (app == null)
 | 
				
			||||||
 | 
					            return NotFound("App not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var secret = await customApps.CreateAppSecretAsync(new CustomAppSecret
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                AppId = appId,
 | 
				
			||||||
 | 
					                Description = request.Description,
 | 
				
			||||||
 | 
					                ExpiredAt = request.ExpiresIn.HasValue
 | 
				
			||||||
 | 
					                    ? NodaTime.SystemClock.Instance.GetCurrentInstant()
 | 
				
			||||||
 | 
					                        .Plus(Duration.FromTimeSpan(request.ExpiresIn.Value))
 | 
				
			||||||
 | 
					                    : (NodaTime.Instant?)null,
 | 
				
			||||||
 | 
					                IsOidc = request.IsOidc
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            return CreatedAtAction(
 | 
				
			||||||
 | 
					                nameof(GetSecret),
 | 
				
			||||||
 | 
					                new { pubName, projectId, appId, secretId = secret.Id },
 | 
				
			||||||
 | 
					                new SecretResponse(
 | 
				
			||||||
 | 
					                    secret.Id.ToString(),
 | 
				
			||||||
 | 
					                    secret.Secret,
 | 
				
			||||||
 | 
					                    secret.Description,
 | 
				
			||||||
 | 
					                    secret.ExpiredAt,
 | 
				
			||||||
 | 
					                    secret.IsOidc,
 | 
				
			||||||
 | 
					                    secret.CreatedAt,
 | 
				
			||||||
 | 
					                    secret.UpdatedAt
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (InvalidOperationException ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(ex.Message);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("{appId:guid}/secrets/{secretId:guid}")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> GetSecret(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid appId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid secretId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var developer = await ds.GetDeveloperByName(pubName);
 | 
				
			||||||
 | 
					        if (developer is null)
 | 
				
			||||||
 | 
					            return NotFound("Developer not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
 | 
				
			||||||
 | 
					            return StatusCode(403, "You must be an editor of the developer to view app secrets");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
 | 
					        if (project is null)
 | 
				
			||||||
 | 
					            return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var app = await customApps.GetAppAsync(appId, projectId);
 | 
				
			||||||
 | 
					        if (app == null)
 | 
				
			||||||
 | 
					            return NotFound("App not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var secret = await customApps.GetAppSecretAsync(secretId, appId);
 | 
				
			||||||
 | 
					        if (secret == null)
 | 
				
			||||||
 | 
					            return NotFound("Secret not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Ok(new SecretResponse(
 | 
				
			||||||
 | 
					            secret.Id.ToString(),
 | 
				
			||||||
 | 
					            null,
 | 
				
			||||||
 | 
					            secret.Description,
 | 
				
			||||||
 | 
					            secret.ExpiredAt,
 | 
				
			||||||
 | 
					            secret.IsOidc,
 | 
				
			||||||
 | 
					            secret.CreatedAt,
 | 
				
			||||||
 | 
					            secret.UpdatedAt
 | 
				
			||||||
 | 
					        ));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpDelete("{appId:guid}/secrets/{secretId:guid}")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> DeleteSecret(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid appId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid secretId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var developer = await ds.GetDeveloperByName(pubName);
 | 
				
			||||||
 | 
					        if (developer is null)
 | 
				
			||||||
 | 
					            return NotFound("Developer not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
 | 
				
			||||||
 | 
					            return StatusCode(403, "You must be an editor of the developer to delete app secrets");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
 | 
					        if (project is null)
 | 
				
			||||||
 | 
					            return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var app = await customApps.GetAppAsync(appId, projectId);
 | 
				
			||||||
 | 
					        if (app == null)
 | 
				
			||||||
 | 
					            return NotFound("App not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var secret = await customApps.GetAppSecretAsync(secretId, appId);
 | 
				
			||||||
 | 
					        if (secret == null)
 | 
				
			||||||
 | 
					            return NotFound("Secret not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var result = await customApps.DeleteAppSecretAsync(secretId, appId);
 | 
				
			||||||
 | 
					        if (!result)
 | 
				
			||||||
 | 
					            return NotFound("Failed to delete secret");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return NoContent();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost("{appId:guid}/secrets/{secretId:guid}/rotate")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> RotateSecret(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName,
 | 
				
			||||||
 | 
					        [FromRoute] Guid projectId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid appId,
 | 
				
			||||||
 | 
					        [FromRoute] Guid secretId,
 | 
				
			||||||
 | 
					        [FromBody] CreateSecretRequest? request = null)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var developer = await ds.GetDeveloperByName(pubName);
 | 
				
			||||||
 | 
					        if (developer is null)
 | 
				
			||||||
 | 
					            return NotFound("Developer not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
 | 
				
			||||||
 | 
					            return StatusCode(403, "You must be an editor of the developer to rotate app secrets");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var project = await projectService.GetProjectAsync(projectId, developer.Id);
 | 
				
			||||||
 | 
					        if (project is null)
 | 
				
			||||||
 | 
					            return NotFound("Project not found or you don't have access");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var app = await customApps.GetAppAsync(appId, projectId);
 | 
				
			||||||
 | 
					        if (app == null)
 | 
				
			||||||
 | 
					            return NotFound("App not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var secret = await customApps.RotateAppSecretAsync(new CustomAppSecret
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Id = secretId,
 | 
				
			||||||
 | 
					                AppId = appId,
 | 
				
			||||||
 | 
					                Description = request?.Description,
 | 
				
			||||||
 | 
					                ExpiredAt = request?.ExpiresIn.HasValue == true
 | 
				
			||||||
 | 
					                    ? NodaTime.SystemClock.Instance.GetCurrentInstant()
 | 
				
			||||||
 | 
					                        .Plus(Duration.FromTimeSpan(request.ExpiresIn.Value))
 | 
				
			||||||
 | 
					                    : (NodaTime.Instant?)null,
 | 
				
			||||||
 | 
					                IsOidc = request?.IsOidc ?? false
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            return Ok(new SecretResponse(
 | 
				
			||||||
 | 
					                secret.Id.ToString(),
 | 
				
			||||||
 | 
					                secret.Secret,
 | 
				
			||||||
 | 
					                secret.Description,
 | 
				
			||||||
 | 
					                secret.ExpiredAt,
 | 
				
			||||||
 | 
					                secret.IsOidc,
 | 
				
			||||||
 | 
					                secret.CreatedAt,
 | 
				
			||||||
 | 
					                secret.UpdatedAt
 | 
				
			||||||
 | 
					            ));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (InvalidOperationException ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(ex.Message);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,8 +1,11 @@
 | 
				
			|||||||
 | 
					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.Sphere.Developer;
 | 
					namespace DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public class CustomAppService(
 | 
					public class CustomAppService(
 | 
				
			||||||
    AppDatabase db,
 | 
					    AppDatabase db,
 | 
				
			||||||
@@ -11,10 +14,17 @@ public class CustomAppService(
 | 
				
			|||||||
)
 | 
					)
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    public async Task<CustomApp?> CreateAppAsync(
 | 
					    public async Task<CustomApp?> CreateAppAsync(
 | 
				
			||||||
        Publisher.Publisher pub,
 | 
					        Guid projectId,
 | 
				
			||||||
        CustomAppController.CustomAppRequest request
 | 
					        CustomAppController.CustomAppRequest request
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
 | 
					        var project = await db.DevProjects
 | 
				
			||||||
 | 
					            .Include(p => p.Developer)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync(p => p.Id == projectId);
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					        if (project == null)
 | 
				
			||||||
 | 
					            return null;
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
        var app = new CustomApp
 | 
					        var app = new CustomApp
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            Slug = request.Slug!,
 | 
					            Slug = request.Slug!,
 | 
				
			||||||
@@ -23,7 +33,7 @@ public class CustomAppService(
 | 
				
			|||||||
            Status = request.Status ?? CustomAppStatus.Developing,
 | 
					            Status = request.Status ?? CustomAppStatus.Developing,
 | 
				
			||||||
            Links = request.Links,
 | 
					            Links = request.Links,
 | 
				
			||||||
            OauthConfig = request.OauthConfig,
 | 
					            OauthConfig = request.OauthConfig,
 | 
				
			||||||
            PublisherId = pub.Id
 | 
					            ProjectId = projectId
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (request.PictureId is not null)
 | 
					        if (request.PictureId is not null)
 | 
				
			||||||
@@ -74,17 +84,104 @@ public class CustomAppService(
 | 
				
			|||||||
        return app;
 | 
					        return app;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async Task<CustomApp?> GetAppAsync(Guid id, Guid? publisherId = null)
 | 
					    public async Task<CustomApp?> GetAppAsync(Guid id, Guid? projectId = null)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var query = db.CustomApps.Where(a => a.Id == id).AsQueryable();
 | 
					        var query = db.CustomApps.AsQueryable();
 | 
				
			||||||
        if (publisherId.HasValue)
 | 
					        
 | 
				
			||||||
            query = query.Where(a => a.PublisherId == publisherId.Value);
 | 
					        if (projectId.HasValue)
 | 
				
			||||||
        return await query.FirstOrDefaultAsync();
 | 
					        {
 | 
				
			||||||
 | 
					            query = query.Where(a => a.ProjectId == projectId.Value);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async Task<List<CustomApp>> GetAppsByPublisherAsync(Guid publisherId)
 | 
					        return await query.FirstOrDefaultAsync(a => a.Id == id);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<List<CustomAppSecret>> GetAppSecretsAsync(Guid appId)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        return await db.CustomApps.Where(a => a.PublisherId == publisherId).ToListAsync();
 | 
					        return await db.CustomAppSecrets
 | 
				
			||||||
 | 
					            .Where(s => s.AppId == appId)
 | 
				
			||||||
 | 
					            .OrderByDescending(s => s.CreatedAt)
 | 
				
			||||||
 | 
					            .ToListAsync();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<CustomAppSecret?> GetAppSecretAsync(Guid secretId, Guid appId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return await db.CustomAppSecrets
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<CustomAppSecret> CreateAppSecretAsync(CustomAppSecret secret)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (string.IsNullOrWhiteSpace(secret.Secret))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            // Generate a new random secret if not provided
 | 
				
			||||||
 | 
					            secret.Secret = GenerateRandomSecret();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        secret.Id = Guid.NewGuid();
 | 
				
			||||||
 | 
					        secret.CreatedAt = NodaTime.SystemClock.Instance.GetCurrentInstant();
 | 
				
			||||||
 | 
					        secret.UpdatedAt = secret.CreatedAt;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        db.CustomAppSecrets.Add(secret);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return secret;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<bool> DeleteAppSecretAsync(Guid secretId, Guid appId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var secret = await db.CustomAppSecrets
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (secret == null)
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        db.CustomAppSecrets.Remove(secret);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					        return true;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<CustomAppSecret> RotateAppSecretAsync(CustomAppSecret secretUpdate)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var existingSecret = await db.CustomAppSecrets
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync(s => s.Id == secretUpdate.Id && s.AppId == secretUpdate.AppId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (existingSecret == null)
 | 
				
			||||||
 | 
					            throw new InvalidOperationException("Secret not found");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Update the existing secret with new values
 | 
				
			||||||
 | 
					        existingSecret.Secret = GenerateRandomSecret();
 | 
				
			||||||
 | 
					        existingSecret.Description = secretUpdate.Description ?? existingSecret.Description;
 | 
				
			||||||
 | 
					        existingSecret.ExpiredAt = secretUpdate.ExpiredAt ?? existingSecret.ExpiredAt;
 | 
				
			||||||
 | 
					        existingSecret.IsOidc = secretUpdate.IsOidc;
 | 
				
			||||||
 | 
					        existingSecret.UpdatedAt = NodaTime.SystemClock.Instance.GetCurrentInstant();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					        return existingSecret;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static string GenerateRandomSecret(int length = 64)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        const string valid = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-._~+";
 | 
				
			||||||
 | 
					        var res = new StringBuilder();
 | 
				
			||||||
 | 
					        using (var rng = RandomNumberGenerator.Create())
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var uintBuffer = new byte[sizeof(uint)];
 | 
				
			||||||
 | 
					            while (length-- > 0)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                rng.GetBytes(uintBuffer);
 | 
				
			||||||
 | 
					                var num = BitConverter.ToUInt32(uintBuffer, 0);
 | 
				
			||||||
 | 
					                res.Append(valid[(int)(num % (uint)valid.Length)]);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return res.ToString();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<List<CustomApp>> GetAppsByProjectAsync(Guid projectId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return await db.CustomApps
 | 
				
			||||||
 | 
					            .Where(a => a.ProjectId == projectId)
 | 
				
			||||||
 | 
					            .ToListAsync();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async Task<CustomApp?> UpdateAppAsync(CustomApp app, CustomAppController.CustomAppRequest request)
 | 
					    public async Task<CustomApp?> UpdateAppAsync(CustomApp app, CustomAppController.CustomAppRequest request)
 | 
				
			||||||
							
								
								
									
										68
									
								
								DysonNetwork.Develop/Identity/CustomAppServiceGrpc.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								DysonNetwork.Develop/Identity/CustomAppServiceGrpc.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,68 @@
 | 
				
			|||||||
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
 | 
					using Grpc.Core;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class CustomAppServiceGrpc(AppDatabase db) : Shared.Proto.CustomAppService.CustomAppServiceBase
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public override async Task<GetCustomAppResponse> GetCustomApp(GetCustomAppRequest request, ServerCallContext context)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var q = db.CustomApps.AsQueryable();
 | 
				
			||||||
 | 
					        switch (request.QueryCase)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            case GetCustomAppRequest.QueryOneofCase.Id when !string.IsNullOrWhiteSpace(request.Id):
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                if (!Guid.TryParse(request.Id, out var id))
 | 
				
			||||||
 | 
					                    throw new RpcException(new Status(StatusCode.InvalidArgument, "invalid id"));
 | 
				
			||||||
 | 
					                var appById = await q.FirstOrDefaultAsync(a => a.Id == id);
 | 
				
			||||||
 | 
					                if (appById is null)
 | 
				
			||||||
 | 
					                    throw new RpcException(new Status(StatusCode.NotFound, "app not found"));
 | 
				
			||||||
 | 
					                return new GetCustomAppResponse { App = appById.ToProto() };
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            case GetCustomAppRequest.QueryOneofCase.Slug when !string.IsNullOrWhiteSpace(request.Slug):
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                var appBySlug = await q.FirstOrDefaultAsync(a => a.Slug == request.Slug);
 | 
				
			||||||
 | 
					                if (appBySlug is null)
 | 
				
			||||||
 | 
					                    throw new RpcException(new Status(StatusCode.NotFound, "app not found"));
 | 
				
			||||||
 | 
					                return new GetCustomAppResponse { App = appBySlug.ToProto() };
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            default:
 | 
				
			||||||
 | 
					                throw new RpcException(new Status(StatusCode.InvalidArgument, "id or slug required"));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public override async Task<CheckCustomAppSecretResponse> CheckCustomAppSecret(CheckCustomAppSecretRequest request, ServerCallContext context)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (string.IsNullOrEmpty(request.Secret))
 | 
				
			||||||
 | 
					            throw new RpcException(new Status(StatusCode.InvalidArgument, "secret required"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        IQueryable<CustomAppSecret> q = db.CustomAppSecrets;
 | 
				
			||||||
 | 
					        switch (request.SecretIdentifierCase)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            case CheckCustomAppSecretRequest.SecretIdentifierOneofCase.SecretId:
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                if (!Guid.TryParse(request.SecretId, out var sid))
 | 
				
			||||||
 | 
					                    throw new RpcException(new Status(StatusCode.InvalidArgument, "invalid secret_id"));
 | 
				
			||||||
 | 
					                q = q.Where(s => s.Id == sid);
 | 
				
			||||||
 | 
					                break;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            case CheckCustomAppSecretRequest.SecretIdentifierOneofCase.AppId:
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                if (!Guid.TryParse(request.AppId, out var aid))
 | 
				
			||||||
 | 
					                    throw new RpcException(new Status(StatusCode.InvalidArgument, "invalid app_id"));
 | 
				
			||||||
 | 
					                q = q.Where(s => s.AppId == aid);
 | 
				
			||||||
 | 
					                break;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            default:
 | 
				
			||||||
 | 
					                throw new RpcException(new Status(StatusCode.InvalidArgument, "secret_id or app_id required"));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (request.HasIsOidc)
 | 
				
			||||||
 | 
					            q = q.Where(s => s.IsOidc == request.IsOidc);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var now = NodaTime.SystemClock.Instance.GetCurrentInstant();
 | 
				
			||||||
 | 
					        var exists = await q.AnyAsync(s => s.Secret == request.Secret && (s.ExpiredAt == null || s.ExpiredAt > now));
 | 
				
			||||||
 | 
					        return new CheckCustomAppSecretResponse { Valid = exists };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										79
									
								
								DysonNetwork.Develop/Identity/Developer.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								DysonNetwork.Develop/Identity/Developer.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,79 @@
 | 
				
			|||||||
 | 
					using System.ComponentModel.DataAnnotations.Schema;
 | 
				
			||||||
 | 
					using System.Text.Json.Serialization;
 | 
				
			||||||
 | 
					using DysonNetwork.Develop.Project;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
 | 
					using VerificationMark = DysonNetwork.Shared.Data.VerificationMark;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class Developer
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public Guid Id { get; set; } = Guid.NewGuid();
 | 
				
			||||||
 | 
					    public Guid PublisherId { get; set; }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    [JsonIgnore] public List<DevProject> Projects { get; set; } = [];
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    [NotMapped] public PublisherInfo? Publisher { get; set; }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class PublisherInfo
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public Guid Id { get; set; }
 | 
				
			||||||
 | 
					    public PublisherType Type { get; set; }
 | 
				
			||||||
 | 
					    public string Name { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					    public string Nick { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					    public string? Bio { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public CloudFileReferenceObject? Picture { get; set; }
 | 
				
			||||||
 | 
					    public CloudFileReferenceObject? Background { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public VerificationMark? Verification { get; set; }
 | 
				
			||||||
 | 
					    public Guid? AccountId { get; set; }
 | 
				
			||||||
 | 
					    public Guid? RealmId { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static PublisherInfo FromProto(Publisher proto)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var info = new PublisherInfo
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Id = Guid.Parse(proto.Id),
 | 
				
			||||||
 | 
					            Type = proto.Type == PublisherType.PubIndividual
 | 
				
			||||||
 | 
					                ? PublisherType.PubIndividual
 | 
				
			||||||
 | 
					                : PublisherType.PubOrganizational,
 | 
				
			||||||
 | 
					            Name = proto.Name,
 | 
				
			||||||
 | 
					            Nick = proto.Nick,
 | 
				
			||||||
 | 
					            Bio = string.IsNullOrEmpty(proto.Bio) ? null : proto.Bio,
 | 
				
			||||||
 | 
					            Verification = proto.VerificationMark is not null
 | 
				
			||||||
 | 
					                ? VerificationMark.FromProtoValue(proto.VerificationMark)
 | 
				
			||||||
 | 
					                : null,
 | 
				
			||||||
 | 
					            AccountId = string.IsNullOrEmpty(proto.AccountId) ? null : Guid.Parse(proto.AccountId),
 | 
				
			||||||
 | 
					            RealmId = string.IsNullOrEmpty(proto.RealmId) ? null : Guid.Parse(proto.RealmId)
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (proto.Picture != null)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            info.Picture = new CloudFileReferenceObject
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Id = proto.Picture.Id,
 | 
				
			||||||
 | 
					                Name = proto.Picture.Name,
 | 
				
			||||||
 | 
					                MimeType = proto.Picture.MimeType,
 | 
				
			||||||
 | 
					                Hash = proto.Picture.Hash,
 | 
				
			||||||
 | 
					                Size = proto.Picture.Size
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (proto.Background != null)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            info.Background = new CloudFileReferenceObject
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Id = proto.Background.Id,
 | 
				
			||||||
 | 
					                Name = proto.Background.Name,
 | 
				
			||||||
 | 
					                MimeType = proto.Background.MimeType,
 | 
				
			||||||
 | 
					                Hash = proto.Background.Hash,
 | 
				
			||||||
 | 
					                Size = (long)proto.Background.Size
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return info;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										129
									
								
								DysonNetwork.Develop/Identity/DeveloperController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								DysonNetwork.Develop/Identity/DeveloperController.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,129 @@
 | 
				
			|||||||
 | 
					using DysonNetwork.Shared.Auth;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
 | 
					using Grpc.Core;
 | 
				
			||||||
 | 
					using Microsoft.AspNetCore.Authorization;
 | 
				
			||||||
 | 
					using Microsoft.AspNetCore.Mvc;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[ApiController]
 | 
				
			||||||
 | 
					[Route("/api/developers")]
 | 
				
			||||||
 | 
					public class DeveloperController(
 | 
				
			||||||
 | 
					    AppDatabase db,
 | 
				
			||||||
 | 
					    PublisherService.PublisherServiceClient ps,
 | 
				
			||||||
 | 
					    ActionLogService.ActionLogServiceClient als,
 | 
				
			||||||
 | 
					    DeveloperService ds
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					    : ControllerBase
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    [HttpGet("{name}")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<Developer>> GetDeveloper(string name)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var developer = await ds.GetDeveloperByName(name);
 | 
				
			||||||
 | 
					        if (developer is null) return NotFound();
 | 
				
			||||||
 | 
					        return Ok(await ds.LoadDeveloperPublisher(developer));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("{name}/stats")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<DeveloperStats>> GetDeveloperStats(string name)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var developer = await ds.GetDeveloperByName(name);
 | 
				
			||||||
 | 
					        if (developer is null) return NotFound();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Get custom apps count
 | 
				
			||||||
 | 
					        var customAppsCount = await db.CustomApps
 | 
				
			||||||
 | 
					            .Include(a => a.Project)
 | 
				
			||||||
 | 
					            .Where(a => a.Project.DeveloperId == developer.Id)
 | 
				
			||||||
 | 
					            .CountAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var stats = new DeveloperStats
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            TotalCustomApps = customAppsCount
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Ok(stats);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<List<Developer>>> ListJoinedDevelopers()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					        var accountId = Guid.Parse(currentUser.Id);
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        var pubResponse = await ps.ListPublishersAsync(new ListPublishersRequest { AccountId = currentUser.Id });
 | 
				
			||||||
 | 
					        var pubIds = pubResponse.Publishers.Select(p => p.Id).Select(Guid.Parse).ToList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var developerQuery = db.Developers
 | 
				
			||||||
 | 
					            .Where(d => pubIds.Contains(d.PublisherId))
 | 
				
			||||||
 | 
					            .AsQueryable();
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        var totalCount = await developerQuery.CountAsync(); 
 | 
				
			||||||
 | 
					        Response.Headers.Append("X-Total", totalCount.ToString());
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        var developers = await developerQuery.ToListAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Ok(await ds.LoadDeveloperPublisher(developers));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost("{name}/enroll")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    [RequiredPermission("global", "developers.create")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<Developer>> EnrollDeveloperProgram(string name)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					        var accountId = Guid.Parse(currentUser.Id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        PublisherInfo? pub;
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Name = name });
 | 
				
			||||||
 | 
					            pub = PublisherInfo.FromProto(pubResponse.Publisher);
 | 
				
			||||||
 | 
					        } catch (RpcException ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return NotFound(ex.Status.Detail);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Check if the user is an owner of the publisher
 | 
				
			||||||
 | 
					        var permResponse = await ps.IsPublisherMemberAsync(new IsPublisherMemberRequest
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            PublisherId = pub.Id.ToString(),
 | 
				
			||||||
 | 
					            AccountId = currentUser.Id,
 | 
				
			||||||
 | 
					            Role = PublisherMemberRole.Owner
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        if (!permResponse.Valid) return StatusCode(403, "You must be the owner of the publisher to join the developer program");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var hasDeveloper = await db.Developers.AnyAsync(d => d.PublisherId == pub.Id);
 | 
				
			||||||
 | 
					        if (hasDeveloper) return BadRequest("Publisher is already in the developer program");
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        var developer = new Developer
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Id = Guid.NewGuid(),
 | 
				
			||||||
 | 
					            PublisherId = pub.Id
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        db.Developers.Add(developer);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        _ = als.CreateActionLogAsync(new CreateActionLogRequest
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Action = "developers.enroll",
 | 
				
			||||||
 | 
					            Meta = 
 | 
				
			||||||
 | 
					            { 
 | 
				
			||||||
 | 
					                { "publisher_id", Google.Protobuf.WellKnownTypes.Value.ForString(pub.Id.ToString()) },
 | 
				
			||||||
 | 
					                { "publisher_name", Google.Protobuf.WellKnownTypes.Value.ForString(pub.Name) }
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            AccountId = currentUser.Id,
 | 
				
			||||||
 | 
					            UserAgent = Request.Headers.UserAgent,
 | 
				
			||||||
 | 
					            IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Ok(await ds.LoadDeveloperPublisher(developer));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public class DeveloperStats
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        public int TotalCustomApps { get; set; }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										75
									
								
								DysonNetwork.Develop/Identity/DeveloperService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								DysonNetwork.Develop/Identity/DeveloperService.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,75 @@
 | 
				
			|||||||
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
 | 
					using Grpc.Core;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class DeveloperService(
 | 
				
			||||||
 | 
					    AppDatabase db,
 | 
				
			||||||
 | 
					    PublisherService.PublisherServiceClient ps,
 | 
				
			||||||
 | 
					    ILogger<DeveloperService> logger)
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public async Task<Developer> LoadDeveloperPublisher(Developer developer)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Id = developer.PublisherId.ToString() });
 | 
				
			||||||
 | 
					        developer.Publisher = PublisherInfo.FromProto(pubResponse.Publisher);
 | 
				
			||||||
 | 
					        return developer;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<IEnumerable<Developer>> LoadDeveloperPublisher(IEnumerable<Developer> developers)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var enumerable = developers.ToList();
 | 
				
			||||||
 | 
					        var pubIds = enumerable.Select(d => d.PublisherId).ToList();
 | 
				
			||||||
 | 
					        var pubRequest = new GetPublisherBatchRequest();
 | 
				
			||||||
 | 
					        pubIds.ForEach(x => pubRequest.Ids.Add(x.ToString()));
 | 
				
			||||||
 | 
					        var pubResponse = await ps.GetPublisherBatchAsync(pubRequest);
 | 
				
			||||||
 | 
					        var pubs = pubResponse.Publishers.ToDictionary(p => Guid.Parse(p.Id), PublisherInfo.FromProto);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return enumerable.Select(d =>
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            d.Publisher = pubs[d.PublisherId];
 | 
				
			||||||
 | 
					            return d;
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<Developer?> GetDeveloperByName(string name)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Name = name });
 | 
				
			||||||
 | 
					            var pubId = Guid.Parse(pubResponse.Publisher.Id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            var developer = await db.Developers.FirstOrDefaultAsync(d => d.PublisherId == pubId);
 | 
				
			||||||
 | 
					            return developer;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (RpcException ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            logger.LogError(ex, "Developer {name} not found", name);
 | 
				
			||||||
 | 
					            return null;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<Developer?> GetDeveloperById(Guid id)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return await db.Developers.FirstOrDefaultAsync(d => d.Id == id);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<bool> IsMemberWithRole(Guid pubId, Guid accountId, PublisherMemberRole role)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var permResponse = await ps.IsPublisherMemberAsync(new IsPublisherMemberRequest
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                PublisherId = pubId.ToString(),
 | 
				
			||||||
 | 
					                AccountId = accountId.ToString(),
 | 
				
			||||||
 | 
					                Role = role
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					            return permResponse.Valid;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (RpcException)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										203
									
								
								DysonNetwork.Develop/Migrations/20250807133702_InitialMigration.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										203
									
								
								DysonNetwork.Develop/Migrations/20250807133702_InitialMigration.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							@@ -0,0 +1,203 @@
 | 
				
			|||||||
 | 
					// <auto-generated />
 | 
				
			||||||
 | 
					using System;
 | 
				
			||||||
 | 
					using DysonNetwork.Develop;
 | 
				
			||||||
 | 
					using DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Infrastructure;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Migrations;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#nullable disable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Develop.Migrations
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    [DbContext(typeof(AppDatabase))]
 | 
				
			||||||
 | 
					    [Migration("20250807133702_InitialMigration")]
 | 
				
			||||||
 | 
					    partial class InitialMigration
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void BuildTargetModel(ModelBuilder modelBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					#pragma warning disable 612, 618
 | 
				
			||||||
 | 
					            modelBuilder
 | 
				
			||||||
 | 
					                .HasAnnotation("ProductVersion", "9.0.7")
 | 
				
			||||||
 | 
					                .HasAnnotation("Relational:MaxIdentifierLength", 63);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<CloudFileReferenceObject>("Background")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("background");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("CreatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("created_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("DeletedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("deleted_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Description")
 | 
				
			||||||
 | 
					                        .HasMaxLength(4096)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(4096)")
 | 
				
			||||||
 | 
					                        .HasColumnName("description");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("DeveloperId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("developer_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<CustomAppLinks>("Links")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("links");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Name")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("name");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<CustomAppOauthConfig>("OauthConfig")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("oauth_config");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<CloudFileReferenceObject>("Picture")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("picture");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Slug")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("slug");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<int>("Status")
 | 
				
			||||||
 | 
					                        .HasColumnType("integer")
 | 
				
			||||||
 | 
					                        .HasColumnName("status");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("UpdatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("updated_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<VerificationMark>("Verification")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("verification");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_custom_apps");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("DeveloperId")
 | 
				
			||||||
 | 
					                        .HasDatabaseName("ix_custom_apps_developer_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("custom_apps", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("AppId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("app_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("CreatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("created_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("DeletedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("deleted_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Description")
 | 
				
			||||||
 | 
					                        .HasMaxLength(4096)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(4096)")
 | 
				
			||||||
 | 
					                        .HasColumnName("description");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("ExpiredAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("expired_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<bool>("IsOidc")
 | 
				
			||||||
 | 
					                        .HasColumnType("boolean")
 | 
				
			||||||
 | 
					                        .HasColumnName("is_oidc");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Secret")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("secret");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("UpdatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("updated_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_custom_app_secrets");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("AppId")
 | 
				
			||||||
 | 
					                        .HasDatabaseName("ix_custom_app_secrets_app_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("custom_app_secrets", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("PublisherId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("publisher_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_developers");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("developers", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer")
 | 
				
			||||||
 | 
					                        .WithMany()
 | 
				
			||||||
 | 
					                        .HasForeignKey("DeveloperId")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasConstraintName("fk_custom_apps_developers_developer_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Navigation("Developer");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.HasOne("DysonNetwork.Develop.Identity.CustomApp", "App")
 | 
				
			||||||
 | 
					                        .WithMany("Secrets")
 | 
				
			||||||
 | 
					                        .HasForeignKey("AppId")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasConstraintName("fk_custom_app_secrets_custom_apps_app_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Navigation("App");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Navigation("Secrets");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					#pragma warning restore 612, 618
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -0,0 +1,108 @@
 | 
				
			|||||||
 | 
					using System;
 | 
				
			||||||
 | 
					using DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Migrations;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#nullable disable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Develop.Migrations
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    /// <inheritdoc />
 | 
				
			||||||
 | 
					    public partial class InitialMigration : Migration
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void Up(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.CreateTable(
 | 
				
			||||||
 | 
					                name: "developers",
 | 
				
			||||||
 | 
					                columns: table => new
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    id = table.Column<Guid>(type: "uuid", nullable: false),
 | 
				
			||||||
 | 
					                    publisher_id = table.Column<Guid>(type: "uuid", nullable: false)
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                constraints: table =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    table.PrimaryKey("pk_developers", x => x.id);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.CreateTable(
 | 
				
			||||||
 | 
					                name: "custom_apps",
 | 
				
			||||||
 | 
					                columns: table => new
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    id = table.Column<Guid>(type: "uuid", nullable: false),
 | 
				
			||||||
 | 
					                    slug = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
 | 
				
			||||||
 | 
					                    name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
 | 
				
			||||||
 | 
					                    description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
 | 
				
			||||||
 | 
					                    status = table.Column<int>(type: "integer", nullable: false),
 | 
				
			||||||
 | 
					                    picture = table.Column<CloudFileReferenceObject>(type: "jsonb", nullable: true),
 | 
				
			||||||
 | 
					                    background = table.Column<CloudFileReferenceObject>(type: "jsonb", nullable: true),
 | 
				
			||||||
 | 
					                    verification = table.Column<VerificationMark>(type: "jsonb", nullable: true),
 | 
				
			||||||
 | 
					                    oauth_config = table.Column<CustomAppOauthConfig>(type: "jsonb", nullable: true),
 | 
				
			||||||
 | 
					                    links = table.Column<CustomAppLinks>(type: "jsonb", nullable: true),
 | 
				
			||||||
 | 
					                    developer_id = table.Column<Guid>(type: "uuid", nullable: false),
 | 
				
			||||||
 | 
					                    created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
 | 
				
			||||||
 | 
					                    updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
 | 
				
			||||||
 | 
					                    deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                constraints: table =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    table.PrimaryKey("pk_custom_apps", x => x.id);
 | 
				
			||||||
 | 
					                    table.ForeignKey(
 | 
				
			||||||
 | 
					                        name: "fk_custom_apps_developers_developer_id",
 | 
				
			||||||
 | 
					                        column: x => x.developer_id,
 | 
				
			||||||
 | 
					                        principalTable: "developers",
 | 
				
			||||||
 | 
					                        principalColumn: "id",
 | 
				
			||||||
 | 
					                        onDelete: ReferentialAction.Cascade);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.CreateTable(
 | 
				
			||||||
 | 
					                name: "custom_app_secrets",
 | 
				
			||||||
 | 
					                columns: table => new
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    id = table.Column<Guid>(type: "uuid", nullable: false),
 | 
				
			||||||
 | 
					                    secret = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
 | 
				
			||||||
 | 
					                    description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
 | 
				
			||||||
 | 
					                    expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
 | 
				
			||||||
 | 
					                    is_oidc = table.Column<bool>(type: "boolean", nullable: false),
 | 
				
			||||||
 | 
					                    app_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_custom_app_secrets", x => x.id);
 | 
				
			||||||
 | 
					                    table.ForeignKey(
 | 
				
			||||||
 | 
					                        name: "fk_custom_app_secrets_custom_apps_app_id",
 | 
				
			||||||
 | 
					                        column: x => x.app_id,
 | 
				
			||||||
 | 
					                        principalTable: "custom_apps",
 | 
				
			||||||
 | 
					                        principalColumn: "id",
 | 
				
			||||||
 | 
					                        onDelete: ReferentialAction.Cascade);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.CreateIndex(
 | 
				
			||||||
 | 
					                name: "ix_custom_app_secrets_app_id",
 | 
				
			||||||
 | 
					                table: "custom_app_secrets",
 | 
				
			||||||
 | 
					                column: "app_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.CreateIndex(
 | 
				
			||||||
 | 
					                name: "ix_custom_apps_developer_id",
 | 
				
			||||||
 | 
					                table: "custom_apps",
 | 
				
			||||||
 | 
					                column: "developer_id");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void Down(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.DropTable(
 | 
				
			||||||
 | 
					                name: "custom_app_secrets");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.DropTable(
 | 
				
			||||||
 | 
					                name: "custom_apps");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.DropTable(
 | 
				
			||||||
 | 
					                name: "developers");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										270
									
								
								DysonNetwork.Develop/Migrations/20250818124844_AddDevProject.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										270
									
								
								DysonNetwork.Develop/Migrations/20250818124844_AddDevProject.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							@@ -0,0 +1,270 @@
 | 
				
			|||||||
 | 
					// <auto-generated />
 | 
				
			||||||
 | 
					using System;
 | 
				
			||||||
 | 
					using DysonNetwork.Develop;
 | 
				
			||||||
 | 
					using DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Infrastructure;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Migrations;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#nullable disable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Develop.Migrations
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    [DbContext(typeof(AppDatabase))]
 | 
				
			||||||
 | 
					    [Migration("20250818124844_AddDevProject")]
 | 
				
			||||||
 | 
					    partial class AddDevProject
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void BuildTargetModel(ModelBuilder modelBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					#pragma warning disable 612, 618
 | 
				
			||||||
 | 
					            modelBuilder
 | 
				
			||||||
 | 
					                .HasAnnotation("ProductVersion", "9.0.7")
 | 
				
			||||||
 | 
					                .HasAnnotation("Relational:MaxIdentifierLength", 63);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<CloudFileReferenceObject>("Background")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("background");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("CreatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("created_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("DeletedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("deleted_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Description")
 | 
				
			||||||
 | 
					                        .HasMaxLength(4096)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(4096)")
 | 
				
			||||||
 | 
					                        .HasColumnName("description");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<CustomAppLinks>("Links")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("links");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Name")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("name");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<CustomAppOauthConfig>("OauthConfig")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("oauth_config");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<CloudFileReferenceObject>("Picture")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("picture");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("ProjectId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("project_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Slug")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("slug");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<int>("Status")
 | 
				
			||||||
 | 
					                        .HasColumnType("integer")
 | 
				
			||||||
 | 
					                        .HasColumnName("status");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("UpdatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("updated_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<VerificationMark>("Verification")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("verification");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_custom_apps");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("ProjectId")
 | 
				
			||||||
 | 
					                        .HasDatabaseName("ix_custom_apps_project_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("custom_apps", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("AppId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("app_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("CreatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("created_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("DeletedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("deleted_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Description")
 | 
				
			||||||
 | 
					                        .HasMaxLength(4096)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(4096)")
 | 
				
			||||||
 | 
					                        .HasColumnName("description");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("ExpiredAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("expired_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<bool>("IsOidc")
 | 
				
			||||||
 | 
					                        .HasColumnType("boolean")
 | 
				
			||||||
 | 
					                        .HasColumnName("is_oidc");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Secret")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("secret");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("UpdatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("updated_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_custom_app_secrets");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("AppId")
 | 
				
			||||||
 | 
					                        .HasDatabaseName("ix_custom_app_secrets_app_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("custom_app_secrets", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("PublisherId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("publisher_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_developers");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("developers", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("CreatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("created_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("DeletedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("deleted_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Description")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(4096)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(4096)")
 | 
				
			||||||
 | 
					                        .HasColumnName("description");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("DeveloperId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("developer_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Name")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("name");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Slug")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("slug");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("UpdatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("updated_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_dev_projects");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("DeveloperId")
 | 
				
			||||||
 | 
					                        .HasDatabaseName("ix_dev_projects_developer_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("dev_projects", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
 | 
				
			||||||
 | 
					                        .WithMany()
 | 
				
			||||||
 | 
					                        .HasForeignKey("ProjectId")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasConstraintName("fk_custom_apps_dev_projects_project_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Navigation("Project");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.HasOne("DysonNetwork.Develop.Identity.CustomApp", "App")
 | 
				
			||||||
 | 
					                        .WithMany("Secrets")
 | 
				
			||||||
 | 
					                        .HasForeignKey("AppId")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasConstraintName("fk_custom_app_secrets_custom_apps_app_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Navigation("App");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer")
 | 
				
			||||||
 | 
					                        .WithMany("Projects")
 | 
				
			||||||
 | 
					                        .HasForeignKey("DeveloperId")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasConstraintName("fk_dev_projects_developers_developer_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Navigation("Developer");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Navigation("Secrets");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Navigation("Projects");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					#pragma warning restore 612, 618
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -0,0 +1,96 @@
 | 
				
			|||||||
 | 
					using System;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Migrations;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#nullable disable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Develop.Migrations
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    /// <inheritdoc />
 | 
				
			||||||
 | 
					    public partial class AddDevProject : Migration
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void Up(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.DropForeignKey(
 | 
				
			||||||
 | 
					                name: "fk_custom_apps_developers_developer_id",
 | 
				
			||||||
 | 
					                table: "custom_apps");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.RenameColumn(
 | 
				
			||||||
 | 
					                name: "developer_id",
 | 
				
			||||||
 | 
					                table: "custom_apps",
 | 
				
			||||||
 | 
					                newName: "project_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.RenameIndex(
 | 
				
			||||||
 | 
					                name: "ix_custom_apps_developer_id",
 | 
				
			||||||
 | 
					                table: "custom_apps",
 | 
				
			||||||
 | 
					                newName: "ix_custom_apps_project_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.CreateTable(
 | 
				
			||||||
 | 
					                name: "dev_projects",
 | 
				
			||||||
 | 
					                columns: table => new
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    id = table.Column<Guid>(type: "uuid", nullable: false),
 | 
				
			||||||
 | 
					                    slug = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
 | 
				
			||||||
 | 
					                    name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
 | 
				
			||||||
 | 
					                    description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
 | 
				
			||||||
 | 
					                    developer_id = table.Column<Guid>(type: "uuid", nullable: false),
 | 
				
			||||||
 | 
					                    created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
 | 
				
			||||||
 | 
					                    updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
 | 
				
			||||||
 | 
					                    deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                constraints: table =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    table.PrimaryKey("pk_dev_projects", x => x.id);
 | 
				
			||||||
 | 
					                    table.ForeignKey(
 | 
				
			||||||
 | 
					                        name: "fk_dev_projects_developers_developer_id",
 | 
				
			||||||
 | 
					                        column: x => x.developer_id,
 | 
				
			||||||
 | 
					                        principalTable: "developers",
 | 
				
			||||||
 | 
					                        principalColumn: "id",
 | 
				
			||||||
 | 
					                        onDelete: ReferentialAction.Cascade);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.CreateIndex(
 | 
				
			||||||
 | 
					                name: "ix_dev_projects_developer_id",
 | 
				
			||||||
 | 
					                table: "dev_projects",
 | 
				
			||||||
 | 
					                column: "developer_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.AddForeignKey(
 | 
				
			||||||
 | 
					                name: "fk_custom_apps_dev_projects_project_id",
 | 
				
			||||||
 | 
					                table: "custom_apps",
 | 
				
			||||||
 | 
					                column: "project_id",
 | 
				
			||||||
 | 
					                principalTable: "dev_projects",
 | 
				
			||||||
 | 
					                principalColumn: "id",
 | 
				
			||||||
 | 
					                onDelete: ReferentialAction.Cascade);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void Down(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.DropForeignKey(
 | 
				
			||||||
 | 
					                name: "fk_custom_apps_dev_projects_project_id",
 | 
				
			||||||
 | 
					                table: "custom_apps");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.DropTable(
 | 
				
			||||||
 | 
					                name: "dev_projects");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.RenameColumn(
 | 
				
			||||||
 | 
					                name: "project_id",
 | 
				
			||||||
 | 
					                table: "custom_apps",
 | 
				
			||||||
 | 
					                newName: "developer_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.RenameIndex(
 | 
				
			||||||
 | 
					                name: "ix_custom_apps_project_id",
 | 
				
			||||||
 | 
					                table: "custom_apps",
 | 
				
			||||||
 | 
					                newName: "ix_custom_apps_developer_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.AddForeignKey(
 | 
				
			||||||
 | 
					                name: "fk_custom_apps_developers_developer_id",
 | 
				
			||||||
 | 
					                table: "custom_apps",
 | 
				
			||||||
 | 
					                column: "developer_id",
 | 
				
			||||||
 | 
					                principalTable: "developers",
 | 
				
			||||||
 | 
					                principalColumn: "id",
 | 
				
			||||||
 | 
					                onDelete: ReferentialAction.Cascade);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										324
									
								
								DysonNetwork.Develop/Migrations/20250819163227_AddBotAccount.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										324
									
								
								DysonNetwork.Develop/Migrations/20250819163227_AddBotAccount.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							@@ -0,0 +1,324 @@
 | 
				
			|||||||
 | 
					// <auto-generated />
 | 
				
			||||||
 | 
					using System;
 | 
				
			||||||
 | 
					using DysonNetwork.Develop;
 | 
				
			||||||
 | 
					using DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Infrastructure;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Migrations;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#nullable disable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Develop.Migrations
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    [DbContext(typeof(AppDatabase))]
 | 
				
			||||||
 | 
					    [Migration("20250819163227_AddBotAccount")]
 | 
				
			||||||
 | 
					    partial class AddBotAccount
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void BuildTargetModel(ModelBuilder modelBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					#pragma warning disable 612, 618
 | 
				
			||||||
 | 
					            modelBuilder
 | 
				
			||||||
 | 
					                .HasAnnotation("ProductVersion", "9.0.7")
 | 
				
			||||||
 | 
					                .HasAnnotation("Relational:MaxIdentifierLength", 63);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("CreatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("created_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("DeletedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("deleted_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<bool>("IsActive")
 | 
				
			||||||
 | 
					                        .HasColumnType("boolean")
 | 
				
			||||||
 | 
					                        .HasColumnName("is_active");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("ProjectId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("project_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Slug")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("slug");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("UpdatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("updated_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_bot_accounts");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("ProjectId")
 | 
				
			||||||
 | 
					                        .HasDatabaseName("ix_bot_accounts_project_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("bot_accounts", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<CloudFileReferenceObject>("Background")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("background");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("CreatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("created_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("DeletedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("deleted_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Description")
 | 
				
			||||||
 | 
					                        .HasMaxLength(4096)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(4096)")
 | 
				
			||||||
 | 
					                        .HasColumnName("description");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<CustomAppLinks>("Links")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("links");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Name")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("name");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<CustomAppOauthConfig>("OauthConfig")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("oauth_config");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<CloudFileReferenceObject>("Picture")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("picture");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("ProjectId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("project_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Slug")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("slug");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<int>("Status")
 | 
				
			||||||
 | 
					                        .HasColumnType("integer")
 | 
				
			||||||
 | 
					                        .HasColumnName("status");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("UpdatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("updated_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<VerificationMark>("Verification")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("verification");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_custom_apps");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("ProjectId")
 | 
				
			||||||
 | 
					                        .HasDatabaseName("ix_custom_apps_project_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("custom_apps", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("AppId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("app_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("CreatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("created_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("DeletedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("deleted_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Description")
 | 
				
			||||||
 | 
					                        .HasMaxLength(4096)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(4096)")
 | 
				
			||||||
 | 
					                        .HasColumnName("description");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("ExpiredAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("expired_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<bool>("IsOidc")
 | 
				
			||||||
 | 
					                        .HasColumnType("boolean")
 | 
				
			||||||
 | 
					                        .HasColumnName("is_oidc");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Secret")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("secret");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("UpdatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("updated_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_custom_app_secrets");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("AppId")
 | 
				
			||||||
 | 
					                        .HasDatabaseName("ix_custom_app_secrets_app_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("custom_app_secrets", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("PublisherId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("publisher_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_developers");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("developers", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("CreatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("created_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("DeletedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("deleted_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Description")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(4096)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(4096)")
 | 
				
			||||||
 | 
					                        .HasColumnName("description");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("DeveloperId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("developer_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Name")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("name");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Slug")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("slug");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("UpdatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("updated_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_dev_projects");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("DeveloperId")
 | 
				
			||||||
 | 
					                        .HasDatabaseName("ix_dev_projects_developer_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("dev_projects", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
 | 
				
			||||||
 | 
					                        .WithMany()
 | 
				
			||||||
 | 
					                        .HasForeignKey("ProjectId")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasConstraintName("fk_bot_accounts_dev_projects_project_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Navigation("Project");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
 | 
				
			||||||
 | 
					                        .WithMany()
 | 
				
			||||||
 | 
					                        .HasForeignKey("ProjectId")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasConstraintName("fk_custom_apps_dev_projects_project_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Navigation("Project");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.HasOne("DysonNetwork.Develop.Identity.CustomApp", "App")
 | 
				
			||||||
 | 
					                        .WithMany("Secrets")
 | 
				
			||||||
 | 
					                        .HasForeignKey("AppId")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasConstraintName("fk_custom_app_secrets_custom_apps_app_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Navigation("App");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer")
 | 
				
			||||||
 | 
					                        .WithMany("Projects")
 | 
				
			||||||
 | 
					                        .HasForeignKey("DeveloperId")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasConstraintName("fk_dev_projects_developers_developer_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Navigation("Developer");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Navigation("Secrets");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Navigation("Projects");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					#pragma warning restore 612, 618
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -0,0 +1,51 @@
 | 
				
			|||||||
 | 
					using System;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Migrations;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#nullable disable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Develop.Migrations
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    /// <inheritdoc />
 | 
				
			||||||
 | 
					    public partial class AddBotAccount : Migration
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void Up(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.CreateTable(
 | 
				
			||||||
 | 
					                name: "bot_accounts",
 | 
				
			||||||
 | 
					                columns: table => new
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    id = table.Column<Guid>(type: "uuid", nullable: false),
 | 
				
			||||||
 | 
					                    slug = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
 | 
				
			||||||
 | 
					                    is_active = table.Column<bool>(type: "boolean", nullable: false),
 | 
				
			||||||
 | 
					                    project_id = table.Column<Guid>(type: "uuid", nullable: false),
 | 
				
			||||||
 | 
					                    created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
 | 
				
			||||||
 | 
					                    updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
 | 
				
			||||||
 | 
					                    deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                constraints: table =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    table.PrimaryKey("pk_bot_accounts", x => x.id);
 | 
				
			||||||
 | 
					                    table.ForeignKey(
 | 
				
			||||||
 | 
					                        name: "fk_bot_accounts_dev_projects_project_id",
 | 
				
			||||||
 | 
					                        column: x => x.project_id,
 | 
				
			||||||
 | 
					                        principalTable: "dev_projects",
 | 
				
			||||||
 | 
					                        principalColumn: "id",
 | 
				
			||||||
 | 
					                        onDelete: ReferentialAction.Cascade);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            migrationBuilder.CreateIndex(
 | 
				
			||||||
 | 
					                name: "ix_bot_accounts_project_id",
 | 
				
			||||||
 | 
					                table: "bot_accounts",
 | 
				
			||||||
 | 
					                column: "project_id");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void Down(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.DropTable(
 | 
				
			||||||
 | 
					                name: "bot_accounts");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										321
									
								
								DysonNetwork.Develop/Migrations/AppDatabaseModelSnapshot.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										321
									
								
								DysonNetwork.Develop/Migrations/AppDatabaseModelSnapshot.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,321 @@
 | 
				
			|||||||
 | 
					// <auto-generated />
 | 
				
			||||||
 | 
					using System;
 | 
				
			||||||
 | 
					using DysonNetwork.Develop;
 | 
				
			||||||
 | 
					using DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Infrastructure;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#nullable disable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Develop.Migrations
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    [DbContext(typeof(AppDatabase))]
 | 
				
			||||||
 | 
					    partial class AppDatabaseModelSnapshot : ModelSnapshot
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        protected override void BuildModel(ModelBuilder modelBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					#pragma warning disable 612, 618
 | 
				
			||||||
 | 
					            modelBuilder
 | 
				
			||||||
 | 
					                .HasAnnotation("ProductVersion", "9.0.7")
 | 
				
			||||||
 | 
					                .HasAnnotation("Relational:MaxIdentifierLength", 63);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("CreatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("created_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("DeletedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("deleted_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<bool>("IsActive")
 | 
				
			||||||
 | 
					                        .HasColumnType("boolean")
 | 
				
			||||||
 | 
					                        .HasColumnName("is_active");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("ProjectId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("project_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Slug")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("slug");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("UpdatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("updated_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_bot_accounts");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("ProjectId")
 | 
				
			||||||
 | 
					                        .HasDatabaseName("ix_bot_accounts_project_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("bot_accounts", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<CloudFileReferenceObject>("Background")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("background");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("CreatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("created_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("DeletedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("deleted_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Description")
 | 
				
			||||||
 | 
					                        .HasMaxLength(4096)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(4096)")
 | 
				
			||||||
 | 
					                        .HasColumnName("description");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<CustomAppLinks>("Links")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("links");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Name")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("name");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<CustomAppOauthConfig>("OauthConfig")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("oauth_config");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<CloudFileReferenceObject>("Picture")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("picture");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("ProjectId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("project_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Slug")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("slug");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<int>("Status")
 | 
				
			||||||
 | 
					                        .HasColumnType("integer")
 | 
				
			||||||
 | 
					                        .HasColumnName("status");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("UpdatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("updated_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<VerificationMark>("Verification")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("verification");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_custom_apps");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("ProjectId")
 | 
				
			||||||
 | 
					                        .HasDatabaseName("ix_custom_apps_project_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("custom_apps", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("AppId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("app_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("CreatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("created_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("DeletedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("deleted_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Description")
 | 
				
			||||||
 | 
					                        .HasMaxLength(4096)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(4096)")
 | 
				
			||||||
 | 
					                        .HasColumnName("description");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("ExpiredAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("expired_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<bool>("IsOidc")
 | 
				
			||||||
 | 
					                        .HasColumnType("boolean")
 | 
				
			||||||
 | 
					                        .HasColumnName("is_oidc");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Secret")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("secret");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("UpdatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("updated_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_custom_app_secrets");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("AppId")
 | 
				
			||||||
 | 
					                        .HasDatabaseName("ix_custom_app_secrets_app_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("custom_app_secrets", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("PublisherId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("publisher_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_developers");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("developers", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("CreatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("created_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("DeletedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("deleted_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Description")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(4096)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(4096)")
 | 
				
			||||||
 | 
					                        .HasColumnName("description");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("DeveloperId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("developer_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Name")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("name");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Slug")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("slug");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("UpdatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("updated_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_dev_projects");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("DeveloperId")
 | 
				
			||||||
 | 
					                        .HasDatabaseName("ix_dev_projects_developer_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("dev_projects", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
 | 
				
			||||||
 | 
					                        .WithMany()
 | 
				
			||||||
 | 
					                        .HasForeignKey("ProjectId")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasConstraintName("fk_bot_accounts_dev_projects_project_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Navigation("Project");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
 | 
				
			||||||
 | 
					                        .WithMany()
 | 
				
			||||||
 | 
					                        .HasForeignKey("ProjectId")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasConstraintName("fk_custom_apps_dev_projects_project_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Navigation("Project");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.HasOne("DysonNetwork.Develop.Identity.CustomApp", "App")
 | 
				
			||||||
 | 
					                        .WithMany("Secrets")
 | 
				
			||||||
 | 
					                        .HasForeignKey("AppId")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasConstraintName("fk_custom_app_secrets_custom_apps_app_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Navigation("App");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer")
 | 
				
			||||||
 | 
					                        .WithMany("Projects")
 | 
				
			||||||
 | 
					                        .HasForeignKey("DeveloperId")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasConstraintName("fk_dev_projects_developers_developer_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Navigation("Developer");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Navigation("Secrets");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Navigation("Projects");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					#pragma warning restore 612, 618
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										34
									
								
								DysonNetwork.Develop/Program.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								DysonNetwork.Develop/Program.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,34 @@
 | 
				
			|||||||
 | 
					using DysonNetwork.Develop;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Auth;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Http;
 | 
				
			||||||
 | 
					using DysonNetwork.Develop.Startup;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Registry;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					var builder = WebApplication.CreateBuilder(args);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					builder.AddServiceDefaults();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					builder.ConfigureAppKestrel(builder.Configuration);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					builder.Services.AddAppServices(builder.Configuration);
 | 
				
			||||||
 | 
					builder.Services.AddAppAuthentication();
 | 
				
			||||||
 | 
					builder.Services.AddAppSwagger();
 | 
				
			||||||
 | 
					builder.Services.AddDysonAuth();
 | 
				
			||||||
 | 
					builder.Services.AddPublisherService();
 | 
				
			||||||
 | 
					builder.Services.AddAccountService();
 | 
				
			||||||
 | 
					builder.Services.AddDriveService();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					var app = builder.Build();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app.MapDefaultEndpoints();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					using (var scope = app.Services.CreateScope())
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
 | 
				
			||||||
 | 
					    await db.Database.MigrateAsync();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app.ConfigureAppMiddleware(builder.Configuration);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app.Run();
 | 
				
			||||||
							
								
								
									
										16
									
								
								DysonNetwork.Develop/Project/DevProject.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								DysonNetwork.Develop/Project/DevProject.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
				
			|||||||
 | 
					using System.ComponentModel.DataAnnotations;
 | 
				
			||||||
 | 
					using DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Develop.Project;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class DevProject : ModelBase
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public Guid Id { get; set; } = Guid.NewGuid();
 | 
				
			||||||
 | 
					    [MaxLength(1024)] public string Slug { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					    [MaxLength(1024)] public string Name { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					    [MaxLength(4096)] public string Description { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    public Developer Developer { get; set; } = null!;
 | 
				
			||||||
 | 
					    public Guid DeveloperId { get; set; }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										107
									
								
								DysonNetwork.Develop/Project/DevProjectController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								DysonNetwork.Develop/Project/DevProjectController.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,107 @@
 | 
				
			|||||||
 | 
					using System.ComponentModel.DataAnnotations;
 | 
				
			||||||
 | 
					using DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					using Microsoft.AspNetCore.Authorization;
 | 
				
			||||||
 | 
					using Microsoft.AspNetCore.Mvc;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Develop.Project;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[ApiController]
 | 
				
			||||||
 | 
					[Route("/api/developers/{pubName}/projects")]
 | 
				
			||||||
 | 
					public class DevProjectController(DevProjectService projectService, DeveloperService developerService) : ControllerBase
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public record DevProjectRequest(
 | 
				
			||||||
 | 
					        [MaxLength(1024)] string? Slug,
 | 
				
			||||||
 | 
					        [MaxLength(1024)] string? Name,
 | 
				
			||||||
 | 
					        [MaxLength(4096)] string? Description
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> ListProjects([FromRoute] string pubName)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var developer = await developerService.GetDeveloperByName(pubName);
 | 
				
			||||||
 | 
					        if (developer is null) return NotFound();
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        var projects = await projectService.GetProjectsByDeveloperAsync(developer.Id);
 | 
				
			||||||
 | 
					        return Ok(projects);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("{id:guid}")]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> GetProject([FromRoute] string pubName, Guid id)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var developer = await developerService.GetDeveloperByName(pubName);
 | 
				
			||||||
 | 
					        if (developer is null) return NotFound();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var project = await projectService.GetProjectAsync(id, developer.Id);
 | 
				
			||||||
 | 
					        if (project is null) return NotFound();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Ok(project);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> CreateProject([FromRoute] string pubName, [FromBody] DevProjectRequest request)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser) 
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var developer = await developerService.GetDeveloperByName(pubName);
 | 
				
			||||||
 | 
					        if (developer is null)
 | 
				
			||||||
 | 
					            return NotFound("Developer not found");
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					        if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
 | 
				
			||||||
 | 
					            return StatusCode(403, "You must be an editor of the developer to create a project");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (string.IsNullOrWhiteSpace(request.Slug) || string.IsNullOrWhiteSpace(request.Name))
 | 
				
			||||||
 | 
					            return BadRequest("Slug and Name are required");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var project = await projectService.CreateProjectAsync(developer, request);
 | 
				
			||||||
 | 
					        return CreatedAtAction(
 | 
				
			||||||
 | 
					            nameof(GetProject), 
 | 
				
			||||||
 | 
					            new { pubName, id = project.Id },
 | 
				
			||||||
 | 
					            project
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPut("{id:guid}")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> UpdateProject(
 | 
				
			||||||
 | 
					        [FromRoute] string pubName, 
 | 
				
			||||||
 | 
					        [FromRoute] Guid id,
 | 
				
			||||||
 | 
					        [FromBody] DevProjectRequest request
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser) 
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var developer = await developerService.GetDeveloperByName(pubName);
 | 
				
			||||||
 | 
					        var accountId = Guid.Parse(currentUser.Id);
 | 
				
			||||||
 | 
					        if (developer is null || developer.Id != accountId)
 | 
				
			||||||
 | 
					            return Forbid();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var project = await projectService.UpdateProjectAsync(id, developer.Id, request);
 | 
				
			||||||
 | 
					        if (project is null)
 | 
				
			||||||
 | 
					            return NotFound();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Ok(project);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpDelete("{id:guid}")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> DeleteProject([FromRoute] string pubName, [FromRoute] Guid id)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser) 
 | 
				
			||||||
 | 
					            return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var developer = await developerService.GetDeveloperByName(pubName);
 | 
				
			||||||
 | 
					        var accountId = Guid.Parse(currentUser.Id);
 | 
				
			||||||
 | 
					        if (developer is null || developer.Id != accountId)
 | 
				
			||||||
 | 
					            return Forbid();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var success = await projectService.DeleteProjectAsync(id, developer.Id);
 | 
				
			||||||
 | 
					        if (!success)
 | 
				
			||||||
 | 
					            return NotFound();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return NoContent();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										77
									
								
								DysonNetwork.Develop/Project/DevProjectService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								DysonNetwork.Develop/Project/DevProjectService.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,77 @@
 | 
				
			|||||||
 | 
					using DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Develop.Project;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class DevProjectService(
 | 
				
			||||||
 | 
					    AppDatabase db,
 | 
				
			||||||
 | 
					    FileReferenceService.FileReferenceServiceClient fileRefs,
 | 
				
			||||||
 | 
					    FileService.FileServiceClient files
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public async Task<DevProject> CreateProjectAsync(
 | 
				
			||||||
 | 
					        Developer developer,
 | 
				
			||||||
 | 
					        DevProjectController.DevProjectRequest request
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var project = new DevProject
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Slug = request.Slug!,
 | 
				
			||||||
 | 
					            Name = request.Name!,
 | 
				
			||||||
 | 
					            Description = request.Description ?? string.Empty,
 | 
				
			||||||
 | 
					            DeveloperId = developer.Id
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        db.DevProjects.Add(project);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        return project;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<DevProject?> GetProjectAsync(Guid id, Guid? developerId = null)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var query = db.DevProjects.AsQueryable();
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        if (developerId.HasValue)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            query = query.Where(p => p.DeveloperId == developerId.Value);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return await query.FirstOrDefaultAsync(p => p.Id == id);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<List<DevProject>> GetProjectsByDeveloperAsync(Guid developerId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return await db.DevProjects
 | 
				
			||||||
 | 
					            .Where(p => p.DeveloperId == developerId)
 | 
				
			||||||
 | 
					            .ToListAsync();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<DevProject?> UpdateProjectAsync(
 | 
				
			||||||
 | 
					        Guid id,
 | 
				
			||||||
 | 
					        Guid developerId,
 | 
				
			||||||
 | 
					        DevProjectController.DevProjectRequest request
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var project = await GetProjectAsync(id, developerId);
 | 
				
			||||||
 | 
					        if (project == null) return null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (request.Slug != null) project.Slug = request.Slug;
 | 
				
			||||||
 | 
					        if (request.Name != null) project.Name = request.Name;
 | 
				
			||||||
 | 
					        if (request.Description != null) project.Description = request.Description;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					        return project;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<bool> DeleteProjectAsync(Guid id, Guid developerId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var project = await GetProjectAsync(id, developerId);
 | 
				
			||||||
 | 
					        if (project == null) return false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        db.DevProjects.Remove(project);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					        return true;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -5,7 +5,7 @@
 | 
				
			|||||||
      "commandName": "Project",
 | 
					      "commandName": "Project",
 | 
				
			||||||
      "dotnetRunMessages": true,
 | 
					      "dotnetRunMessages": true,
 | 
				
			||||||
      "launchBrowser": false,
 | 
					      "launchBrowser": false,
 | 
				
			||||||
      "applicationUrl": "http://localhost:5094",
 | 
					      "applicationUrl": "http://localhost:5156",
 | 
				
			||||||
      "environmentVariables": {
 | 
					      "environmentVariables": {
 | 
				
			||||||
        "ASPNETCORE_ENVIRONMENT": "Development"
 | 
					        "ASPNETCORE_ENVIRONMENT": "Development"
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
@@ -14,7 +14,7 @@
 | 
				
			|||||||
      "commandName": "Project",
 | 
					      "commandName": "Project",
 | 
				
			||||||
      "dotnetRunMessages": true,
 | 
					      "dotnetRunMessages": true,
 | 
				
			||||||
      "launchBrowser": false,
 | 
					      "launchBrowser": false,
 | 
				
			||||||
      "applicationUrl": "https://localhost:7034;http://0.0.0.0:5094",
 | 
					      "applicationUrl": "https://localhost:7192;http://localhost:5156",
 | 
				
			||||||
      "environmentVariables": {
 | 
					      "environmentVariables": {
 | 
				
			||||||
        "ASPNETCORE_ENVIRONMENT": "Development"
 | 
					        "ASPNETCORE_ENVIRONMENT": "Development"
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
							
								
								
									
										34
									
								
								DysonNetwork.Develop/Startup/ApplicationConfiguration.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								DysonNetwork.Develop/Startup/ApplicationConfiguration.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,34 @@
 | 
				
			|||||||
 | 
					using System.Net;
 | 
				
			||||||
 | 
					using DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Auth;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Http;
 | 
				
			||||||
 | 
					using Microsoft.AspNetCore.HttpOverrides;
 | 
				
			||||||
 | 
					using Prometheus;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Develop.Startup;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public static class ApplicationConfiguration
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public static WebApplication ConfigureAppMiddleware(this WebApplication app, IConfiguration configuration)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        app.MapMetrics();
 | 
				
			||||||
 | 
					        app.MapOpenApi();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        app.UseSwagger();
 | 
				
			||||||
 | 
					        app.UseSwaggerUI();
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        app.UseRequestLocalization();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        app.ConfigureForwardedHeaders(configuration);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        app.UseAuthentication();
 | 
				
			||||||
 | 
					        app.UseAuthorization();
 | 
				
			||||||
 | 
					        app.UseMiddleware<PermissionMiddleware>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        app.MapControllers();
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        app.MapGrpcService<CustomAppServiceGrpc>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return app;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										79
									
								
								DysonNetwork.Develop/Startup/ServiceCollectionExtensions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								DysonNetwork.Develop/Startup/ServiceCollectionExtensions.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,79 @@
 | 
				
			|||||||
 | 
					using System.Globalization;
 | 
				
			||||||
 | 
					using Microsoft.OpenApi.Models;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					using NodaTime.Serialization.SystemTextJson;
 | 
				
			||||||
 | 
					using System.Text.Json;
 | 
				
			||||||
 | 
					using System.Text.Json.Serialization;
 | 
				
			||||||
 | 
					using DysonNetwork.Develop.Identity;
 | 
				
			||||||
 | 
					using DysonNetwork.Develop.Project;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Cache;
 | 
				
			||||||
 | 
					using StackExchange.Redis;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Develop.Startup;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public static class ServiceCollectionExtensions
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        services.AddLocalization();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        services.AddDbContext<AppDatabase>();
 | 
				
			||||||
 | 
					        services.AddSingleton<IClock>(SystemClock.Instance);
 | 
				
			||||||
 | 
					        services.AddHttpContextAccessor();
 | 
				
			||||||
 | 
					        services.AddSingleton<ICacheService, CacheServiceRedis>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        services.AddHttpClient();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        services.AddControllers().AddJsonOptions(options =>
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
 | 
				
			||||||
 | 
					            options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
 | 
				
			||||||
 | 
					            options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        services.AddGrpc(options => { options.EnableDetailedErrors = true; });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        services.Configure<RequestLocalizationOptions>(options =>
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var supportedCultures = new[]
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                new CultureInfo("en-US"),
 | 
				
			||||||
 | 
					                new CultureInfo("zh-Hans"),
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            options.SupportedCultures = supportedCultures;
 | 
				
			||||||
 | 
					            options.SupportedUICultures = supportedCultures;
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        services.AddScoped<DeveloperService>();
 | 
				
			||||||
 | 
					        services.AddScoped<CustomAppService>();
 | 
				
			||||||
 | 
					        services.AddScoped<DevProjectService>();
 | 
				
			||||||
 | 
					        services.AddScoped<BotAccountService>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return services;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        services.AddCors();
 | 
				
			||||||
 | 
					        services.AddAuthorization();
 | 
				
			||||||
 | 
					        return services;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static IServiceCollection AddAppSwagger(this IServiceCollection services)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        services.AddEndpointsApiExplorer();
 | 
				
			||||||
 | 
					        services.AddSwaggerGen(options =>
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            options.SwaggerDoc("v1", new OpenApiInfo
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Version = "v1",
 | 
				
			||||||
 | 
					                Title = "Develop API",
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        services.AddOpenApi();
 | 
				
			||||||
 | 
					        return services;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										26
									
								
								DysonNetwork.Develop/appsettings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								DysonNetwork.Develop/appsettings.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "Debug": true,
 | 
				
			||||||
 | 
					  "BaseUrl": "http://localhost:5071",
 | 
				
			||||||
 | 
					  "SiteUrl": "https://solian.app",
 | 
				
			||||||
 | 
					  "Logging": {
 | 
				
			||||||
 | 
					    "LogLevel": {
 | 
				
			||||||
 | 
					      "Default": "Information",
 | 
				
			||||||
 | 
					      "Microsoft.AspNetCore": "Warning"
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "AllowedHosts": "*",
 | 
				
			||||||
 | 
					  "ConnectionStrings": {
 | 
				
			||||||
 | 
					    "App": "Host=localhost;Port=5432;Database=dyson_network_dev;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "KnownProxies": [
 | 
				
			||||||
 | 
					    "127.0.0.1",
 | 
				
			||||||
 | 
					    "::1"
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  "Etcd": {
 | 
				
			||||||
 | 
					    "Insecure": true
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "Service": {
 | 
				
			||||||
 | 
					    "Name": "DysonNetwork.Develop",
 | 
				
			||||||
 | 
					    "Url": "https://localhost:7192"
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -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();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -34,6 +34,13 @@
 | 
				
			|||||||
              :is-date-disabled="disablePreviousDate"
 | 
					              :is-date-disabled="disablePreviousDate"
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
 | 
					          <div
 | 
				
			||||||
 | 
					            v-if="currentFilePool?.policy_config?.enable_fast_upload || route.query.pool"
 | 
				
			||||||
 | 
					            class="flex items-center gap-2"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <p class="pl-1 mb-0.5">Fast Upload</p>
 | 
				
			||||||
 | 
					            <n-switch v-model:value="fastUpload" />
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </n-card>
 | 
					      </n-card>
 | 
				
			||||||
    </n-collapse-transition>
 | 
					    </n-collapse-transition>
 | 
				
			||||||
@@ -78,12 +85,14 @@ import {
 | 
				
			|||||||
  NDatePicker,
 | 
					  NDatePicker,
 | 
				
			||||||
  NAlert,
 | 
					  NAlert,
 | 
				
			||||||
  NCard,
 | 
					  NCard,
 | 
				
			||||||
 | 
					  NSwitch,
 | 
				
			||||||
  type UploadCustomRequestOptions,
 | 
					  type UploadCustomRequestOptions,
 | 
				
			||||||
  type UploadSettledFileInfo,
 | 
					  type UploadSettledFileInfo,
 | 
				
			||||||
  type UploadFileInfo,
 | 
					  type UploadFileInfo,
 | 
				
			||||||
  useMessage,
 | 
					  useMessage,
 | 
				
			||||||
} from 'naive-ui'
 | 
					} from 'naive-ui'
 | 
				
			||||||
import { computed, ref } from 'vue'
 | 
					import { computed, ref } from 'vue'
 | 
				
			||||||
 | 
					import { useRoute } from 'vue-router'
 | 
				
			||||||
import { CloudUploadRound } from '@vicons/material'
 | 
					import { CloudUploadRound } from '@vicons/material'
 | 
				
			||||||
import type { SnFilePool } from '@/types/pool'
 | 
					import type { SnFilePool } from '@/types/pool'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -96,21 +105,26 @@ const props = defineProps<{
 | 
				
			|||||||
  bundleId?: string
 | 
					  bundleId?: string
 | 
				
			||||||
}>()
 | 
					}>()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const route = useRoute()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const filePass = ref<string>('')
 | 
					const filePass = ref<string>('')
 | 
				
			||||||
const fileExpire = ref<number | null>(null)
 | 
					const fileExpire = ref<number | null>(null)
 | 
				
			||||||
 | 
					const fastUpload = ref<boolean>(false)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const effectiveFilePool = computed(() => (route.query.pool as string) || props.filePool)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const currentFilePool = computed(() => {
 | 
					const currentFilePool = computed(() => {
 | 
				
			||||||
  if (!props.filePool) return null
 | 
					  if (!effectiveFilePool.value) return null
 | 
				
			||||||
  return props.pools?.find((pool) => pool.id === props.filePool) ?? null
 | 
					  return props.pools?.find((pool) => pool.id === effectiveFilePool.value) ?? null
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
const showRecycleHint = computed(() => {
 | 
					const showRecycleHint = computed(() => {
 | 
				
			||||||
  if (!props.filePool) return true
 | 
					  if (!effectiveFilePool.value) return true
 | 
				
			||||||
  return currentFilePool.value?.policy_config?.enable_recycle || false
 | 
					  return currentFilePool.value?.policy_config?.enable_recycle || false
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const messageDisplay = useMessage()
 | 
					const messageDisplay = useMessage()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function customRequest({
 | 
					async function customRequest({
 | 
				
			||||||
  file,
 | 
					  file,
 | 
				
			||||||
  headers,
 | 
					  headers,
 | 
				
			||||||
  withCredentials,
 | 
					  withCredentials,
 | 
				
			||||||
@@ -118,12 +132,68 @@ function customRequest({
 | 
				
			|||||||
  onError,
 | 
					  onError,
 | 
				
			||||||
  onProgress,
 | 
					  onProgress,
 | 
				
			||||||
}: UploadCustomRequestOptions) {
 | 
					}: UploadCustomRequestOptions) {
 | 
				
			||||||
 | 
					  if (fastUpload.value) {
 | 
				
			||||||
 | 
					    const hash = await crypto.subtle.digest('SHA-256', await file.file!.arrayBuffer())
 | 
				
			||||||
 | 
					    const hashString = Array.from(new Uint8Array(hash))
 | 
				
			||||||
 | 
					      .map((b) => b.toString(16).padStart(2, '0'))
 | 
				
			||||||
 | 
					      .join('')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const resp = await fetch('/api/files/fast', {
 | 
				
			||||||
 | 
					      method: 'POST',
 | 
				
			||||||
 | 
					      headers: { 'Content-Type': 'application/json' },
 | 
				
			||||||
 | 
					      body: JSON.stringify({
 | 
				
			||||||
 | 
					        name: file.name,
 | 
				
			||||||
 | 
					        size: file.file?.size,
 | 
				
			||||||
 | 
					        hash: hashString,
 | 
				
			||||||
 | 
					        mime_type: file.file?.type,
 | 
				
			||||||
 | 
					        pool_id: effectiveFilePool.value,
 | 
				
			||||||
 | 
					      }),
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!resp.ok) {
 | 
				
			||||||
 | 
					      messageDisplay.error(`Failed to get presigned URL: ${await resp.text()}`)
 | 
				
			||||||
 | 
					      onError()
 | 
				
			||||||
 | 
					      return
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const respData = await resp.json()
 | 
				
			||||||
 | 
					    const url = respData.fast_upload_link
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const xhr = new XMLHttpRequest()
 | 
				
			||||||
 | 
					      xhr.open('PUT', url, true)
 | 
				
			||||||
 | 
					      xhr.upload.onprogress = (event) => {
 | 
				
			||||||
 | 
					        if (event.lengthComputable) {
 | 
				
			||||||
 | 
					          onProgress({ percent: (event.loaded / event.total) * 100 })
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      xhr.onload = () => {
 | 
				
			||||||
 | 
					        if (xhr.status >= 200 && xhr.status < 300) {
 | 
				
			||||||
 | 
					          onFinish()
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          messageDisplay.error(`Upload failed: ${xhr.responseText}`)
 | 
				
			||||||
 | 
					          onError()
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      xhr.onerror = () => {
 | 
				
			||||||
 | 
					        messageDisplay.error('Upload failed due to a network error.')
 | 
				
			||||||
 | 
					        onError()
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      xhr.send(file.file)
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      console.error(e)
 | 
				
			||||||
 | 
					      messageDisplay.error(`Upload failed: ${e}`)
 | 
				
			||||||
 | 
					      onError()
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const requestHeaders: Record<string, string> = {}
 | 
					  const requestHeaders: Record<string, string> = {}
 | 
				
			||||||
  if (props.filePool) requestHeaders['X-FilePool'] = props.filePool
 | 
					  if (effectiveFilePool.value) requestHeaders['X-FilePool'] = effectiveFilePool.value
 | 
				
			||||||
  if (filePass.value) requestHeaders['X-FilePass'] = filePass.value
 | 
					  if (filePass.value) requestHeaders['X-FilePass'] = filePass.value
 | 
				
			||||||
  if (fileExpire.value) requestHeaders['X-FileExpire'] = fileExpire.value.toString()
 | 
					  if (fileExpire.value) requestHeaders['X-FileExpire'] = fileExpire.value.toString()
 | 
				
			||||||
  if (props.bundleId) requestHeaders['X-FileBundle'] = props.bundleId
 | 
					  if (props.bundleId) requestHeaders['X-FileBundle'] = props.bundleId
 | 
				
			||||||
  const upload = new tus.Upload(file.file, {
 | 
					  const upload = new tus.Upload(file.file as any, {
 | 
				
			||||||
    endpoint: '/api/tus',
 | 
					    endpoint: '/api/tus',
 | 
				
			||||||
    retryDelays: [0, 3000, 5000, 10000, 20000],
 | 
					    retryDelays: [0, 3000, 5000, 10000, 20000],
 | 
				
			||||||
    removeFingerprintOnSuccess: false,
 | 
					    removeFingerprintOnSuccess: false,
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										7
									
								
								DysonNetwork.Drive/Client/src/dy-prefetch.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								DysonNetwork.Drive/Client/src/dy-prefetch.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
				
			|||||||
 | 
					export {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					declare global {
 | 
				
			||||||
 | 
					  interface Window {
 | 
				
			||||||
 | 
					    DyPrefetch?: any
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -35,7 +35,6 @@
 | 
				
			|||||||
        <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
 | 
					        <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
 | 
				
			||||||
        <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
 | 
					        <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
 | 
				
			||||||
        <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
 | 
					        <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
 | 
				
			||||||
        <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="9.0.4" />
 | 
					 | 
				
			||||||
        <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
 | 
					        <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
 | 
				
			||||||
        <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
 | 
					        <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
 | 
				
			||||||
        <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
 | 
					        <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
 | 
				
			||||||
@@ -67,6 +66,7 @@
 | 
				
			|||||||
    </ItemGroup>
 | 
					    </ItemGroup>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <ItemGroup>
 | 
					    <ItemGroup>
 | 
				
			||||||
 | 
					      <ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
 | 
				
			||||||
      <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
 | 
					      <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
 | 
				
			||||||
    </ItemGroup>
 | 
					    </ItemGroup>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										404
									
								
								DysonNetwork.Drive/Migrations/20250808170904_AddHiddenPool.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										404
									
								
								DysonNetwork.Drive/Migrations/20250808170904_AddHiddenPool.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							@@ -0,0 +1,404 @@
 | 
				
			|||||||
 | 
					// <auto-generated />
 | 
				
			||||||
 | 
					using System;
 | 
				
			||||||
 | 
					using System.Collections.Generic;
 | 
				
			||||||
 | 
					using DysonNetwork.Drive;
 | 
				
			||||||
 | 
					using DysonNetwork.Drive.Storage;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Infrastructure;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Migrations;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#nullable disable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Drive.Migrations
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    [DbContext(typeof(AppDatabase))]
 | 
				
			||||||
 | 
					    [Migration("20250808170904_AddHiddenPool")]
 | 
				
			||||||
 | 
					    partial class AddHiddenPool
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void BuildTargetModel(ModelBuilder modelBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					#pragma warning disable 612, 618
 | 
				
			||||||
 | 
					            modelBuilder
 | 
				
			||||||
 | 
					                .HasAnnotation("ProductVersion", "9.0.7")
 | 
				
			||||||
 | 
					                .HasAnnotation("Relational:MaxIdentifierLength", 63);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
 | 
				
			||||||
 | 
					            NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("AccountId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("account_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("CreatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("created_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("DeletedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("deleted_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Description")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("text")
 | 
				
			||||||
 | 
					                        .HasColumnName("description");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("ExpiredAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("expired_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Name")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("text")
 | 
				
			||||||
 | 
					                        .HasColumnName("name");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<long>("Quota")
 | 
				
			||||||
 | 
					                        .HasColumnType("bigint")
 | 
				
			||||||
 | 
					                        .HasColumnName("quota");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("UpdatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("updated_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_quota_records");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("quota_records", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<string>("Id")
 | 
				
			||||||
 | 
					                        .HasMaxLength(32)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(32)")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("AccountId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("account_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid?>("BundleId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("bundle_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("CreatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("created_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("DeletedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("deleted_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Description")
 | 
				
			||||||
 | 
					                        .HasMaxLength(4096)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(4096)")
 | 
				
			||||||
 | 
					                        .HasColumnName("description");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("ExpiredAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("expired_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Dictionary<string, object>>("FileMeta")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("file_meta");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<bool>("HasCompression")
 | 
				
			||||||
 | 
					                        .HasColumnType("boolean")
 | 
				
			||||||
 | 
					                        .HasColumnName("has_compression");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<bool>("HasThumbnail")
 | 
				
			||||||
 | 
					                        .HasColumnType("boolean")
 | 
				
			||||||
 | 
					                        .HasColumnName("has_thumbnail");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Hash")
 | 
				
			||||||
 | 
					                        .HasMaxLength(256)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(256)")
 | 
				
			||||||
 | 
					                        .HasColumnName("hash");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<bool>("IsEncrypted")
 | 
				
			||||||
 | 
					                        .HasColumnType("boolean")
 | 
				
			||||||
 | 
					                        .HasColumnName("is_encrypted");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<bool>("IsMarkedRecycle")
 | 
				
			||||||
 | 
					                        .HasColumnType("boolean")
 | 
				
			||||||
 | 
					                        .HasColumnName("is_marked_recycle");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("MimeType")
 | 
				
			||||||
 | 
					                        .HasMaxLength(256)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(256)")
 | 
				
			||||||
 | 
					                        .HasColumnName("mime_type");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Name")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("name");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid?>("PoolId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("pool_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("sensitive_marks");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<long>("Size")
 | 
				
			||||||
 | 
					                        .HasColumnType("bigint")
 | 
				
			||||||
 | 
					                        .HasColumnName("size");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("StorageId")
 | 
				
			||||||
 | 
					                        .HasMaxLength(32)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(32)")
 | 
				
			||||||
 | 
					                        .HasColumnName("storage_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("StorageUrl")
 | 
				
			||||||
 | 
					                        .HasMaxLength(4096)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(4096)")
 | 
				
			||||||
 | 
					                        .HasColumnName("storage_url");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("UpdatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("updated_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("UploadedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("uploaded_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("UploadedTo")
 | 
				
			||||||
 | 
					                        .HasMaxLength(128)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(128)")
 | 
				
			||||||
 | 
					                        .HasColumnName("uploaded_to");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    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()
 | 
				
			||||||
 | 
					                        .HasForeignKey("FileId")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasConstraintName("fk_file_references_files_file_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Navigation("File");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Navigation("Files");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					#pragma warning restore 612, 618
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -0,0 +1,29 @@
 | 
				
			|||||||
 | 
					using Microsoft.EntityFrameworkCore.Migrations;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#nullable disable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Drive.Migrations
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    /// <inheritdoc />
 | 
				
			||||||
 | 
					    public partial class AddHiddenPool : Migration
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void Up(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.AddColumn<bool>(
 | 
				
			||||||
 | 
					                name: "is_hidden",
 | 
				
			||||||
 | 
					                table: "pools",
 | 
				
			||||||
 | 
					                type: "boolean",
 | 
				
			||||||
 | 
					                nullable: false,
 | 
				
			||||||
 | 
					                defaultValue: false);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void Down(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.DropColumn(
 | 
				
			||||||
 | 
					                name: "is_hidden",
 | 
				
			||||||
 | 
					                table: "pools");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										404
									
								
								DysonNetwork.Drive/Migrations/20250819164302_RemoveUploadedTo.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										404
									
								
								DysonNetwork.Drive/Migrations/20250819164302_RemoveUploadedTo.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							@@ -0,0 +1,404 @@
 | 
				
			|||||||
 | 
					// <auto-generated />
 | 
				
			||||||
 | 
					using System;
 | 
				
			||||||
 | 
					using System.Collections.Generic;
 | 
				
			||||||
 | 
					using DysonNetwork.Drive;
 | 
				
			||||||
 | 
					using DysonNetwork.Drive.Storage;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Infrastructure;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Migrations;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#nullable disable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Drive.Migrations
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    [DbContext(typeof(AppDatabase))]
 | 
				
			||||||
 | 
					    [Migration("20250819164302_RemoveUploadedTo")]
 | 
				
			||||||
 | 
					    partial class RemoveUploadedTo
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void BuildTargetModel(ModelBuilder modelBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					#pragma warning disable 612, 618
 | 
				
			||||||
 | 
					            modelBuilder
 | 
				
			||||||
 | 
					                .HasAnnotation("ProductVersion", "9.0.7")
 | 
				
			||||||
 | 
					                .HasAnnotation("Relational:MaxIdentifierLength", 63);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
 | 
				
			||||||
 | 
					            NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("AccountId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("account_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("CreatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("created_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("DeletedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("deleted_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Description")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("text")
 | 
				
			||||||
 | 
					                        .HasColumnName("description");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("ExpiredAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("expired_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Name")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("text")
 | 
				
			||||||
 | 
					                        .HasColumnName("name");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<long>("Quota")
 | 
				
			||||||
 | 
					                        .HasColumnType("bigint")
 | 
				
			||||||
 | 
					                        .HasColumnName("quota");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("UpdatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("updated_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_quota_records");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("quota_records", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<string>("Id")
 | 
				
			||||||
 | 
					                        .HasMaxLength(32)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(32)")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("AccountId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("account_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid?>("BundleId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("bundle_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("CreatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("created_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("DeletedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("deleted_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Description")
 | 
				
			||||||
 | 
					                        .HasMaxLength(4096)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(4096)")
 | 
				
			||||||
 | 
					                        .HasColumnName("description");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("ExpiredAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("expired_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Dictionary<string, object>>("FileMeta")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("file_meta");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<bool>("HasCompression")
 | 
				
			||||||
 | 
					                        .HasColumnType("boolean")
 | 
				
			||||||
 | 
					                        .HasColumnName("has_compression");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<bool>("HasThumbnail")
 | 
				
			||||||
 | 
					                        .HasColumnType("boolean")
 | 
				
			||||||
 | 
					                        .HasColumnName("has_thumbnail");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Hash")
 | 
				
			||||||
 | 
					                        .HasMaxLength(256)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(256)")
 | 
				
			||||||
 | 
					                        .HasColumnName("hash");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<bool>("IsEncrypted")
 | 
				
			||||||
 | 
					                        .HasColumnType("boolean")
 | 
				
			||||||
 | 
					                        .HasColumnName("is_encrypted");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<bool>("IsMarkedRecycle")
 | 
				
			||||||
 | 
					                        .HasColumnType("boolean")
 | 
				
			||||||
 | 
					                        .HasColumnName("is_marked_recycle");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("MimeType")
 | 
				
			||||||
 | 
					                        .HasMaxLength(256)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(256)")
 | 
				
			||||||
 | 
					                        .HasColumnName("mime_type");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Name")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("name");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid?>("PoolId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("pool_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("sensitive_marks");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<long>("Size")
 | 
				
			||||||
 | 
					                        .HasColumnType("bigint")
 | 
				
			||||||
 | 
					                        .HasColumnName("size");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("StorageId")
 | 
				
			||||||
 | 
					                        .HasMaxLength(32)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(32)")
 | 
				
			||||||
 | 
					                        .HasColumnName("storage_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("StorageUrl")
 | 
				
			||||||
 | 
					                        .HasMaxLength(4096)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(4096)")
 | 
				
			||||||
 | 
					                        .HasColumnName("storage_url");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("UpdatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("updated_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("UploadedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("uploaded_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Dictionary<string, object>>("UserMeta")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("user_meta");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_files");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("BundleId")
 | 
				
			||||||
 | 
					                        .HasDatabaseName("ix_files_bundle_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("PoolId")
 | 
				
			||||||
 | 
					                        .HasDatabaseName("ix_files_pool_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("files", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("CreatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("created_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("DeletedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("deleted_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("ExpiredAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("expired_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("FileId")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(32)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(32)")
 | 
				
			||||||
 | 
					                        .HasColumnName("file_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("ResourceId")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("resource_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("UpdatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("updated_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Usage")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("usage");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_file_references");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("FileId")
 | 
				
			||||||
 | 
					                        .HasDatabaseName("ix_file_references_file_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("file_references", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("AccountId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("account_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("CreatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("created_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("DeletedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("deleted_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Description")
 | 
				
			||||||
 | 
					                        .HasMaxLength(8192)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(8192)")
 | 
				
			||||||
 | 
					                        .HasColumnName("description");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("ExpiredAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("expired_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Name")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("name");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Passcode")
 | 
				
			||||||
 | 
					                        .HasMaxLength(256)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(256)")
 | 
				
			||||||
 | 
					                        .HasColumnName("passcode");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Slug")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("slug");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("UpdatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("updated_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_bundles");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("Slug")
 | 
				
			||||||
 | 
					                        .IsUnique()
 | 
				
			||||||
 | 
					                        .HasDatabaseName("ix_bundles_slug");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("bundles", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid?>("AccountId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("account_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<BillingConfig>("BillingConfig")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("billing_config");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("CreatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("created_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("DeletedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("deleted_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Description")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(8192)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(8192)")
 | 
				
			||||||
 | 
					                        .HasColumnName("description");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<bool>("IsHidden")
 | 
				
			||||||
 | 
					                        .HasColumnType("boolean")
 | 
				
			||||||
 | 
					                        .HasColumnName("is_hidden");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Name")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("name");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<PolicyConfig>("PolicyConfig")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("policy_config");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<RemoteStorageConfig>("StorageConfig")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("storage_config");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("UpdatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("updated_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_pools");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("pools", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.HasOne("DysonNetwork.Drive.Storage.FileBundle", "Bundle")
 | 
				
			||||||
 | 
					                        .WithMany("Files")
 | 
				
			||||||
 | 
					                        .HasForeignKey("BundleId")
 | 
				
			||||||
 | 
					                        .HasConstraintName("fk_files_bundles_bundle_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool")
 | 
				
			||||||
 | 
					                        .WithMany()
 | 
				
			||||||
 | 
					                        .HasForeignKey("PoolId")
 | 
				
			||||||
 | 
					                        .HasConstraintName("fk_files_pools_pool_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Navigation("Bundle");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Navigation("Pool");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
 | 
				
			||||||
 | 
					                        .WithMany("References")
 | 
				
			||||||
 | 
					                        .HasForeignKey("FileId")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasConstraintName("fk_file_references_files_file_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Navigation("File");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Navigation("References");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Navigation("Files");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					#pragma warning restore 612, 618
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -0,0 +1,29 @@
 | 
				
			|||||||
 | 
					using Microsoft.EntityFrameworkCore.Migrations;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#nullable disable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Drive.Migrations
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    /// <inheritdoc />
 | 
				
			||||||
 | 
					    public partial class RemoveUploadedTo : Migration
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void Up(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.DropColumn(
 | 
				
			||||||
 | 
					                name: "uploaded_to",
 | 
				
			||||||
 | 
					                table: "files");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void Down(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.AddColumn<string>(
 | 
				
			||||||
 | 
					                name: "uploaded_to",
 | 
				
			||||||
 | 
					                table: "files",
 | 
				
			||||||
 | 
					                type: "character varying(128)",
 | 
				
			||||||
 | 
					                maxLength: 128,
 | 
				
			||||||
 | 
					                nullable: true);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										403
									
								
								DysonNetwork.Drive/Migrations/20250907070034_RemoveNetTopo.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										403
									
								
								DysonNetwork.Drive/Migrations/20250907070034_RemoveNetTopo.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							@@ -0,0 +1,403 @@
 | 
				
			|||||||
 | 
					// <auto-generated />
 | 
				
			||||||
 | 
					using System;
 | 
				
			||||||
 | 
					using System.Collections.Generic;
 | 
				
			||||||
 | 
					using DysonNetwork.Drive;
 | 
				
			||||||
 | 
					using DysonNetwork.Drive.Storage;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Infrastructure;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Migrations;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#nullable disable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Drive.Migrations
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    [DbContext(typeof(AppDatabase))]
 | 
				
			||||||
 | 
					    [Migration("20250907070034_RemoveNetTopo")]
 | 
				
			||||||
 | 
					    partial class RemoveNetTopo
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void BuildTargetModel(ModelBuilder modelBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					#pragma warning disable 612, 618
 | 
				
			||||||
 | 
					            modelBuilder
 | 
				
			||||||
 | 
					                .HasAnnotation("ProductVersion", "9.0.7")
 | 
				
			||||||
 | 
					                .HasAnnotation("Relational:MaxIdentifierLength", 63);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("AccountId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("account_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("CreatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("created_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("DeletedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("deleted_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Description")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("text")
 | 
				
			||||||
 | 
					                        .HasColumnName("description");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("ExpiredAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("expired_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Name")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("text")
 | 
				
			||||||
 | 
					                        .HasColumnName("name");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<long>("Quota")
 | 
				
			||||||
 | 
					                        .HasColumnType("bigint")
 | 
				
			||||||
 | 
					                        .HasColumnName("quota");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("UpdatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("updated_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_quota_records");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("quota_records", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<string>("Id")
 | 
				
			||||||
 | 
					                        .HasMaxLength(32)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(32)")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("AccountId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("account_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid?>("BundleId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("bundle_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("CreatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("created_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("DeletedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("deleted_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Description")
 | 
				
			||||||
 | 
					                        .HasMaxLength(4096)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(4096)")
 | 
				
			||||||
 | 
					                        .HasColumnName("description");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("ExpiredAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("expired_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Dictionary<string, object>>("FileMeta")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("file_meta");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<bool>("HasCompression")
 | 
				
			||||||
 | 
					                        .HasColumnType("boolean")
 | 
				
			||||||
 | 
					                        .HasColumnName("has_compression");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<bool>("HasThumbnail")
 | 
				
			||||||
 | 
					                        .HasColumnType("boolean")
 | 
				
			||||||
 | 
					                        .HasColumnName("has_thumbnail");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Hash")
 | 
				
			||||||
 | 
					                        .HasMaxLength(256)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(256)")
 | 
				
			||||||
 | 
					                        .HasColumnName("hash");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<bool>("IsEncrypted")
 | 
				
			||||||
 | 
					                        .HasColumnType("boolean")
 | 
				
			||||||
 | 
					                        .HasColumnName("is_encrypted");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<bool>("IsMarkedRecycle")
 | 
				
			||||||
 | 
					                        .HasColumnType("boolean")
 | 
				
			||||||
 | 
					                        .HasColumnName("is_marked_recycle");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("MimeType")
 | 
				
			||||||
 | 
					                        .HasMaxLength(256)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(256)")
 | 
				
			||||||
 | 
					                        .HasColumnName("mime_type");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Name")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("name");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid?>("PoolId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("pool_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("sensitive_marks");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<long>("Size")
 | 
				
			||||||
 | 
					                        .HasColumnType("bigint")
 | 
				
			||||||
 | 
					                        .HasColumnName("size");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("StorageId")
 | 
				
			||||||
 | 
					                        .HasMaxLength(32)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(32)")
 | 
				
			||||||
 | 
					                        .HasColumnName("storage_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("StorageUrl")
 | 
				
			||||||
 | 
					                        .HasMaxLength(4096)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(4096)")
 | 
				
			||||||
 | 
					                        .HasColumnName("storage_url");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("UpdatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("updated_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("UploadedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("uploaded_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Dictionary<string, object>>("UserMeta")
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("user_meta");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_files");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("BundleId")
 | 
				
			||||||
 | 
					                        .HasDatabaseName("ix_files_bundle_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("PoolId")
 | 
				
			||||||
 | 
					                        .HasDatabaseName("ix_files_pool_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("files", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("CreatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("created_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("DeletedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("deleted_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("ExpiredAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("expired_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("FileId")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(32)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(32)")
 | 
				
			||||||
 | 
					                        .HasColumnName("file_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("ResourceId")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("resource_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("UpdatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("updated_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Usage")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("usage");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_file_references");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("FileId")
 | 
				
			||||||
 | 
					                        .HasDatabaseName("ix_file_references_file_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("file_references", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid>("AccountId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("account_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("CreatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("created_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("DeletedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("deleted_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Description")
 | 
				
			||||||
 | 
					                        .HasMaxLength(8192)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(8192)")
 | 
				
			||||||
 | 
					                        .HasColumnName("description");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("ExpiredAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("expired_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Name")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("name");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Passcode")
 | 
				
			||||||
 | 
					                        .HasMaxLength(256)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(256)")
 | 
				
			||||||
 | 
					                        .HasColumnName("passcode");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Slug")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("slug");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("UpdatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("updated_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_bundles");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasIndex("Slug")
 | 
				
			||||||
 | 
					                        .IsUnique()
 | 
				
			||||||
 | 
					                        .HasDatabaseName("ix_bundles_slug");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("bundles", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<Guid>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Guid?>("AccountId")
 | 
				
			||||||
 | 
					                        .HasColumnType("uuid")
 | 
				
			||||||
 | 
					                        .HasColumnName("account_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<BillingConfig>("BillingConfig")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("billing_config");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("CreatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("created_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant?>("DeletedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("deleted_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Description")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(8192)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(8192)")
 | 
				
			||||||
 | 
					                        .HasColumnName("description");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<bool>("IsHidden")
 | 
				
			||||||
 | 
					                        .HasColumnType("boolean")
 | 
				
			||||||
 | 
					                        .HasColumnName("is_hidden");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Name")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
 | 
					                        .HasColumnType("character varying(1024)")
 | 
				
			||||||
 | 
					                        .HasColumnName("name");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<PolicyConfig>("PolicyConfig")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("policy_config");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<RemoteStorageConfig>("StorageConfig")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
 | 
					                        .HasColumnName("storage_config");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<Instant>("UpdatedAt")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("updated_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_pools");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("pools", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.HasOne("DysonNetwork.Drive.Storage.FileBundle", "Bundle")
 | 
				
			||||||
 | 
					                        .WithMany("Files")
 | 
				
			||||||
 | 
					                        .HasForeignKey("BundleId")
 | 
				
			||||||
 | 
					                        .HasConstraintName("fk_files_bundles_bundle_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool")
 | 
				
			||||||
 | 
					                        .WithMany()
 | 
				
			||||||
 | 
					                        .HasForeignKey("PoolId")
 | 
				
			||||||
 | 
					                        .HasConstraintName("fk_files_pools_pool_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Navigation("Bundle");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Navigation("Pool");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
 | 
				
			||||||
 | 
					                        .WithMany("References")
 | 
				
			||||||
 | 
					                        .HasForeignKey("FileId")
 | 
				
			||||||
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasConstraintName("fk_file_references_files_file_id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Navigation("File");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Navigation("References");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Navigation("Files");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					#pragma warning restore 612, 618
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -0,0 +1,24 @@
 | 
				
			|||||||
 | 
					using Microsoft.EntityFrameworkCore.Migrations;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#nullable disable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Drive.Migrations
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    /// <inheritdoc />
 | 
				
			||||||
 | 
					    public partial class RemoveNetTopo : Migration
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void Up(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.AlterDatabase()
 | 
				
			||||||
 | 
					                .OldAnnotation("Npgsql:PostgresExtension:postgis", ",,");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        /// <inheritdoc />
 | 
				
			||||||
 | 
					        protected override void Down(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.AlterDatabase()
 | 
				
			||||||
 | 
					                .Annotation("Npgsql:PostgresExtension:postgis", ",,");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -24,7 +24,6 @@ namespace DysonNetwork.Drive.Migrations
 | 
				
			|||||||
                .HasAnnotation("ProductVersion", "9.0.7")
 | 
					                .HasAnnotation("ProductVersion", "9.0.7")
 | 
				
			||||||
                .HasAnnotation("Relational:MaxIdentifierLength", 63);
 | 
					                .HasAnnotation("Relational:MaxIdentifierLength", 63);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
 | 
					 | 
				
			||||||
            NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
 | 
					            NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b =>
 | 
					            modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b =>
 | 
				
			||||||
@@ -172,11 +171,6 @@ namespace DysonNetwork.Drive.Migrations
 | 
				
			|||||||
                        .HasColumnType("timestamp with time zone")
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
                        .HasColumnName("uploaded_at");
 | 
					                        .HasColumnName("uploaded_at");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    b.Property<string>("UploadedTo")
 | 
					 | 
				
			||||||
                        .HasMaxLength(128)
 | 
					 | 
				
			||||||
                        .HasColumnType("character varying(128)")
 | 
					 | 
				
			||||||
                        .HasColumnName("uploaded_to");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                    b.Property<Dictionary<string, object>>("UserMeta")
 | 
					                    b.Property<Dictionary<string, object>>("UserMeta")
 | 
				
			||||||
                        .HasColumnType("jsonb")
 | 
					                        .HasColumnType("jsonb")
 | 
				
			||||||
                        .HasColumnName("user_meta");
 | 
					                        .HasColumnName("user_meta");
 | 
				
			||||||
@@ -332,6 +326,10 @@ namespace DysonNetwork.Drive.Migrations
 | 
				
			|||||||
                        .HasColumnType("character varying(8192)")
 | 
					                        .HasColumnType("character varying(8192)")
 | 
				
			||||||
                        .HasColumnName("description");
 | 
					                        .HasColumnName("description");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<bool>("IsHidden")
 | 
				
			||||||
 | 
					                        .HasColumnType("boolean")
 | 
				
			||||||
 | 
					                        .HasColumnName("is_hidden");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    b.Property<string>("Name")
 | 
					                    b.Property<string>("Name")
 | 
				
			||||||
                        .IsRequired()
 | 
					                        .IsRequired()
 | 
				
			||||||
                        .HasMaxLength(1024)
 | 
					                        .HasMaxLength(1024)
 | 
				
			||||||
@@ -378,7 +376,7 @@ namespace DysonNetwork.Drive.Migrations
 | 
				
			|||||||
            modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
 | 
					            modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
                    b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
 | 
					                    b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
 | 
				
			||||||
                        .WithMany()
 | 
					                        .WithMany("References")
 | 
				
			||||||
                        .HasForeignKey("FileId")
 | 
					                        .HasForeignKey("FileId")
 | 
				
			||||||
                        .OnDelete(DeleteBehavior.Cascade)
 | 
					                        .OnDelete(DeleteBehavior.Cascade)
 | 
				
			||||||
                        .IsRequired()
 | 
					                        .IsRequired()
 | 
				
			||||||
@@ -387,6 +385,11 @@ namespace DysonNetwork.Drive.Migrations
 | 
				
			|||||||
                    b.Navigation("File");
 | 
					                    b.Navigation("File");
 | 
				
			||||||
                });
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Navigation("References");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
 | 
					            modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
                    b.Navigation("Files");
 | 
					                    b.Navigation("Files");
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -10,11 +10,13 @@ using tusdotnet.Stores;
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
var builder = WebApplication.CreateBuilder(args);
 | 
					var builder = WebApplication.CreateBuilder(args);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					builder.AddServiceDefaults();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Configure Kestrel and server options
 | 
					// Configure Kestrel and server options
 | 
				
			||||||
builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxValue);
 | 
					builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxValue);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Add application services
 | 
					// Add application services
 | 
				
			||||||
builder.Services.AddRegistryService(builder.Configuration);
 | 
					
 | 
				
			||||||
builder.Services.AddAppServices(builder.Configuration);
 | 
					builder.Services.AddAppServices(builder.Configuration);
 | 
				
			||||||
builder.Services.AddAppRateLimiting();
 | 
					builder.Services.AddAppRateLimiting();
 | 
				
			||||||
builder.Services.AddAppAuthentication();
 | 
					builder.Services.AddAppAuthentication();
 | 
				
			||||||
@@ -37,6 +39,8 @@ builder.Services.AddTransient<IPageDataProvider, VersionPageData>();
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
var app = builder.Build();
 | 
					var app = builder.Build();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app.MapDefaultEndpoints();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Run database migrations
 | 
					// Run database migrations
 | 
				
			||||||
using (var scope = app.Services.CreateScope())
 | 
					using (var scope = app.Services.CreateScope())
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
@@ -49,8 +53,6 @@ var tusDiskStore = app.Services.GetRequiredService<TusDiskStore>();
 | 
				
			|||||||
// Configure application middleware pipeline
 | 
					// Configure application middleware pipeline
 | 
				
			||||||
app.ConfigureAppMiddleware(tusDiskStore, builder.Environment.ContentRootPath);
 | 
					app.ConfigureAppMiddleware(tusDiskStore, builder.Environment.ContentRootPath);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
app.MapGatewayProxy();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
app.MapPages(Path.Combine(app.Environment.WebRootPath, "dist", "index.html"));
 | 
					app.MapPages(Path.Combine(app.Environment.WebRootPath, "dist", "index.html"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Configure gRPC
 | 
					// Configure gRPC
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										72
									
								
								DysonNetwork.Drive/Startup/BroadcastEventHandler.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								DysonNetwork.Drive/Startup/BroadcastEventHandler.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,72 @@
 | 
				
			|||||||
 | 
					using System.Text.Json;
 | 
				
			||||||
 | 
					using DysonNetwork.Drive.Storage;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Stream;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					using NATS.Client.Core;
 | 
				
			||||||
 | 
					using NATS.Client.JetStream.Models;
 | 
				
			||||||
 | 
					using NATS.Net;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Drive.Startup;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class BroadcastEventHandler(
 | 
				
			||||||
 | 
					    INatsConnection nats,
 | 
				
			||||||
 | 
					    ILogger<BroadcastEventHandler> logger,
 | 
				
			||||||
 | 
					    IServiceProvider serviceProvider
 | 
				
			||||||
 | 
					) : BackgroundService
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var js = nats.CreateJetStreamContext();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await js.EnsureStreamCreated("account_events", [AccountDeletedEvent.Type]);
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        var consumer = await js.CreateOrUpdateConsumerAsync("account_events",
 | 
				
			||||||
 | 
					            new ConsumerConfig("drive_account_deleted_handler"), cancellationToken: stoppingToken);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            try
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                var evt = JsonSerializer.Deserialize<AccountDeletedEvent>(msg.Data);
 | 
				
			||||||
 | 
					                if (evt == null)
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    await msg.AckAsync(cancellationToken: stoppingToken);
 | 
				
			||||||
 | 
					                    continue;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                logger.LogInformation("Account deleted: {AccountId}", evt.AccountId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                using var scope = serviceProvider.CreateScope();
 | 
				
			||||||
 | 
					                var fs = scope.ServiceProvider.GetRequiredService<FileService>();
 | 
				
			||||||
 | 
					                var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken: stoppingToken);
 | 
				
			||||||
 | 
					                try
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    var files = await db.Files
 | 
				
			||||||
 | 
					                        .Where(p => p.AccountId == evt.AccountId)
 | 
				
			||||||
 | 
					                        .ToListAsync(cancellationToken: stoppingToken);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    await fs.DeleteFileDataBatchAsync(files);
 | 
				
			||||||
 | 
					                    await db.Files
 | 
				
			||||||
 | 
					                        .Where(p => p.AccountId == evt.AccountId)
 | 
				
			||||||
 | 
					                        .ExecuteDeleteAsync(cancellationToken: stoppingToken);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    await transaction.CommitAsync(cancellationToken: stoppingToken);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                catch (Exception)
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    await transaction.RollbackAsync(cancellationToken: stoppingToken);
 | 
				
			||||||
 | 
					                    throw;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                await msg.AckAsync(cancellationToken: stoppingToken);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            catch (Exception ex)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                logger.LogError(ex, "Error processing AccountDeleted");
 | 
				
			||||||
 | 
					                await msg.NakAsync(cancellationToken: stoppingToken);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,4 +1,5 @@
 | 
				
			|||||||
using System.Text.Json;
 | 
					using System.Text.Json;
 | 
				
			||||||
 | 
					using System.Text.Json.Serialization;
 | 
				
			||||||
using System.Threading.RateLimiting;
 | 
					using System.Threading.RateLimiting;
 | 
				
			||||||
using DysonNetwork.Shared.Cache;
 | 
					using DysonNetwork.Shared.Cache;
 | 
				
			||||||
using Microsoft.AspNetCore.RateLimiting;
 | 
					using Microsoft.AspNetCore.RateLimiting;
 | 
				
			||||||
@@ -16,11 +17,6 @@ public static class ServiceCollectionExtensions
 | 
				
			|||||||
    public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
 | 
					    public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        services.AddDbContext<AppDatabase>(); // Assuming you'll have an AppDatabase
 | 
					        services.AddDbContext<AppDatabase>(); // Assuming you'll have an AppDatabase
 | 
				
			||||||
        services.AddSingleton<IConnectionMultiplexer>(_ =>
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            var connection = configuration.GetConnectionString("FastRetrieve")!;
 | 
					 | 
				
			||||||
            return ConnectionMultiplexer.Connect(connection);
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
        services.AddSingleton<IClock>(SystemClock.Instance);
 | 
					        services.AddSingleton<IClock>(SystemClock.Instance);
 | 
				
			||||||
        services.AddHttpContextAccessor();
 | 
					        services.AddHttpContextAccessor();
 | 
				
			||||||
        services.AddSingleton<ICacheService, CacheServiceRedis>(); // Uncomment if you have CacheServiceRedis
 | 
					        services.AddSingleton<ICacheService, CacheServiceRedis>(); // Uncomment if you have CacheServiceRedis
 | 
				
			||||||
@@ -40,6 +36,7 @@ public static class ServiceCollectionExtensions
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        services.AddControllers().AddJsonOptions(options =>
 | 
					        services.AddControllers().AddJsonOptions(options =>
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
 | 
					            options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
 | 
				
			||||||
            options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
 | 
					            options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
 | 
				
			||||||
            options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
 | 
					            options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -141,6 +138,8 @@ public static class ServiceCollectionExtensions
 | 
				
			|||||||
        services.AddScoped<Billing.UsageService>();
 | 
					        services.AddScoped<Billing.UsageService>();
 | 
				
			||||||
        services.AddScoped<Billing.QuotaService>();
 | 
					        services.AddScoped<Billing.QuotaService>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        services.AddHostedService<BroadcastEventHandler>();
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
        return services;
 | 
					        return services;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -3,33 +3,16 @@ using System.ComponentModel.DataAnnotations.Schema;
 | 
				
			|||||||
using System.Text.Json.Serialization;
 | 
					using System.Text.Json.Serialization;
 | 
				
			||||||
using DysonNetwork.Shared.Data;
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
using DysonNetwork.Shared.Proto;
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
using Google.Protobuf;
 | 
					 | 
				
			||||||
using NodaTime;
 | 
					using NodaTime;
 | 
				
			||||||
using NodaTime.Serialization.Protobuf;
 | 
					using NodaTime.Serialization.Protobuf;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace DysonNetwork.Drive.Storage;
 | 
					namespace DysonNetwork.Drive.Storage;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// <summary>
 | 
					 | 
				
			||||||
/// The class that used in jsonb columns which referenced the cloud file.
 | 
					 | 
				
			||||||
/// The aim of this class is to store some properties that won't change to a file to reduce the database load.
 | 
					 | 
				
			||||||
/// </summary>
 | 
					 | 
				
			||||||
public class CloudFileReferenceObject : ModelBase, ICloudFile
 | 
					 | 
				
			||||||
{
 | 
					 | 
				
			||||||
    public string Id { get; set; } = null!;
 | 
					 | 
				
			||||||
    public string Name { get; set; } = string.Empty;
 | 
					 | 
				
			||||||
    public Dictionary<string, object?> FileMeta { get; set; } = null!;
 | 
					 | 
				
			||||||
    public Dictionary<string, object>? UserMeta { get; set; } = null!;
 | 
					 | 
				
			||||||
    public string? MimeType { get; set; }
 | 
					 | 
				
			||||||
    public string? Hash { get; set; }
 | 
					 | 
				
			||||||
    public long Size { get; set; }
 | 
					 | 
				
			||||||
    public bool HasCompression { get; set; } = false;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
 | 
					public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    /// The id generated by TuS, basically just UUID remove the dash lines
 | 
					    /// The id generated by TuS, basically just UUID remove the dash lines
 | 
				
			||||||
    [MaxLength(32)]
 | 
					    [MaxLength(32)]
 | 
				
			||||||
    public string Id { get; set; } = Guid.NewGuid().ToString();
 | 
					    public string Id { get; set; } = Guid.NewGuid().ToString().Replace("-", string.Empty);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [MaxLength(1024)] public string Name { get; set; } = string.Empty;
 | 
					    [MaxLength(1024)] public string Name { get; set; } = string.Empty;
 | 
				
			||||||
    [MaxLength(4096)] public string? Description { get; set; }
 | 
					    [MaxLength(4096)] public string? Description { get; set; }
 | 
				
			||||||
@@ -50,10 +33,6 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
 | 
				
			|||||||
    [JsonIgnore] public FileBundle? Bundle { get; set; }
 | 
					    [JsonIgnore] public FileBundle? Bundle { get; set; }
 | 
				
			||||||
    public Guid? BundleId { get; set; }
 | 
					    public Guid? BundleId { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [Obsolete("Deprecated, use PoolId instead. For database migration only.")]
 | 
					 | 
				
			||||||
    [MaxLength(128)]
 | 
					 | 
				
			||||||
    public string? UploadedTo { get; set; }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    /// <summary>
 | 
					    /// <summary>
 | 
				
			||||||
    /// The field is set to true if the recycling job plans to delete the file.
 | 
					    /// The field is set to true if the recycling job plans to delete the file.
 | 
				
			||||||
    /// Due to the unstable of the recycling job, this doesn't really delete the file until a human verifies it.
 | 
					    /// Due to the unstable of the recycling job, this doesn't really delete the file until a human verifies it.
 | 
				
			||||||
@@ -74,6 +53,12 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
 | 
				
			|||||||
    [MaxLength(4096)]
 | 
					    [MaxLength(4096)]
 | 
				
			||||||
    public string? StorageUrl { get; set; }
 | 
					    public string? StorageUrl { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [NotMapped]
 | 
				
			||||||
 | 
					    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
 | 
				
			||||||
 | 
					    public string? FastUploadLink { get; set; }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    public ICollection<CloudFileReference> References { get; set; } = new List<CloudFileReference>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public Guid AccountId { get; set; }
 | 
					    public Guid AccountId { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public CloudFileReferenceObject ToReferenceObject()
 | 
					    public CloudFileReferenceObject ToReferenceObject()
 | 
				
			||||||
@@ -85,8 +70,9 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
 | 
				
			|||||||
            DeletedAt = DeletedAt,
 | 
					            DeletedAt = DeletedAt,
 | 
				
			||||||
            Id = Id,
 | 
					            Id = Id,
 | 
				
			||||||
            Name = Name,
 | 
					            Name = Name,
 | 
				
			||||||
            FileMeta = FileMeta,
 | 
					            FileMeta = FileMeta ?? [],
 | 
				
			||||||
            UserMeta = UserMeta,
 | 
					            UserMeta = UserMeta ?? [],
 | 
				
			||||||
 | 
					            SensitiveMarks = SensitiveMarks,
 | 
				
			||||||
            MimeType = MimeType,
 | 
					            MimeType = MimeType,
 | 
				
			||||||
            Hash = Hash,
 | 
					            Hash = Hash,
 | 
				
			||||||
            Size = Size,
 | 
					            Size = Size,
 | 
				
			||||||
@@ -105,7 +91,7 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
 | 
				
			|||||||
        var proto = new Shared.Proto.CloudFile
 | 
					        var proto = new Shared.Proto.CloudFile
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            Id = Id,
 | 
					            Id = Id,
 | 
				
			||||||
            Name = Name ?? string.Empty,
 | 
					            Name = Name,
 | 
				
			||||||
            MimeType = MimeType ?? string.Empty,
 | 
					            MimeType = MimeType ?? string.Empty,
 | 
				
			||||||
            Hash = Hash ?? string.Empty,
 | 
					            Hash = Hash ?? string.Empty,
 | 
				
			||||||
            Size = Size,
 | 
					            Size = Size,
 | 
				
			||||||
@@ -117,6 +103,7 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
 | 
				
			|||||||
            FileMeta = GrpcTypeHelper.ConvertObjectToByteString(FileMeta),
 | 
					            FileMeta = GrpcTypeHelper.ConvertObjectToByteString(FileMeta),
 | 
				
			||||||
            // Convert user metadata
 | 
					            // Convert user metadata
 | 
				
			||||||
            UserMeta = GrpcTypeHelper.ConvertObjectToByteString(UserMeta),
 | 
					            UserMeta = GrpcTypeHelper.ConvertObjectToByteString(UserMeta),
 | 
				
			||||||
 | 
					            SensitiveMarks = GrpcTypeHelper.ConvertObjectToByteString(SensitiveMarks)
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return proto;
 | 
					        return proto;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,7 @@
 | 
				
			|||||||
 | 
					using DysonNetwork.Drive.Billing;
 | 
				
			||||||
using DysonNetwork.Shared.Auth;
 | 
					using DysonNetwork.Shared.Auth;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
using DysonNetwork.Shared.Proto;
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
using Grpc.Core;
 | 
					 | 
				
			||||||
using Microsoft.AspNetCore.Authorization;
 | 
					using Microsoft.AspNetCore.Authorization;
 | 
				
			||||||
using Microsoft.AspNetCore.Mvc;
 | 
					using Microsoft.AspNetCore.Mvc;
 | 
				
			||||||
using Microsoft.EntityFrameworkCore;
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
@@ -13,6 +14,7 @@ namespace DysonNetwork.Drive.Storage;
 | 
				
			|||||||
public class FileController(
 | 
					public class FileController(
 | 
				
			||||||
    AppDatabase db,
 | 
					    AppDatabase db,
 | 
				
			||||||
    FileService fs,
 | 
					    FileService fs,
 | 
				
			||||||
 | 
					    QuotaService qs,
 | 
				
			||||||
    IConfiguration configuration,
 | 
					    IConfiguration configuration,
 | 
				
			||||||
    IWebHostEnvironment env
 | 
					    IWebHostEnvironment env
 | 
				
			||||||
) : ControllerBase
 | 
					) : ControllerBase
 | 
				
			||||||
@@ -65,9 +67,16 @@ public class FileController(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        var fileName = string.IsNullOrWhiteSpace(file.StorageId) ? file.Id : file.StorageId;
 | 
					        var fileName = string.IsNullOrWhiteSpace(file.StorageId) ? file.Id : file.StorageId;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (thumbnail && file.HasThumbnail)
 | 
					        switch (thumbnail)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            case true when file.HasThumbnail:
 | 
				
			||||||
                fileName += ".thumbnail";
 | 
					                fileName += ".thumbnail";
 | 
				
			||||||
        else if (!original && file.HasCompression)
 | 
					                break;
 | 
				
			||||||
 | 
					            case true when !file.HasThumbnail:
 | 
				
			||||||
 | 
					                return NotFound();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        if (!original && file.HasCompression)
 | 
				
			||||||
            fileName += ".compressed";
 | 
					            fileName += ".compressed";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (dest.ImageProxy is not null && (file.MimeType?.StartsWith("image/") ?? false))
 | 
					        if (dest.ImageProxy is not null && (file.MimeType?.StartsWith("image/") ?? false))
 | 
				
			||||||
@@ -141,6 +150,53 @@ public class FileController(
 | 
				
			|||||||
        return file;
 | 
					        return file;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    [HttpPatch("{id}/name")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<CloudFile>> UpdateFileName(string id, [FromBody] string name)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					        var accountId = Guid.Parse(currentUser.Id);
 | 
				
			||||||
 | 
					        var file = await db.Files.FirstOrDefaultAsync(f => f.Id == id && f.AccountId == accountId);
 | 
				
			||||||
 | 
					        if (file is null) return NotFound();
 | 
				
			||||||
 | 
					        file.Name = name;
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					        await fs._PurgeCacheAsync(file.Id);
 | 
				
			||||||
 | 
					        return file;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public class MarkFileRequest
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        public List<ContentSensitiveMark>? SensitiveMarks { get; set; }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    [HttpPut("{id}/marks")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<CloudFile>> MarkFile(string id, [FromBody] MarkFileRequest request)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					        var accountId = Guid.Parse(currentUser.Id);
 | 
				
			||||||
 | 
					        var file = await db.Files.FirstOrDefaultAsync(f => f.Id == id && f.AccountId == accountId);
 | 
				
			||||||
 | 
					        if (file is null) return NotFound();
 | 
				
			||||||
 | 
					        file.SensitiveMarks = request.SensitiveMarks;
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					        await fs._PurgeCacheAsync(file.Id);
 | 
				
			||||||
 | 
					        return file;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    [HttpPut("{id}/meta")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<CloudFile>> UpdateFileMeta(string id, [FromBody] Dictionary<string, object?> meta)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					        var accountId = Guid.Parse(currentUser.Id);
 | 
				
			||||||
 | 
					        var file = await db.Files.FirstOrDefaultAsync(f => f.Id == id && f.AccountId == accountId);
 | 
				
			||||||
 | 
					        if (file is null) return NotFound();
 | 
				
			||||||
 | 
					        file.UserMeta = meta;
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					        await fs._PurgeCacheAsync(file.Id);
 | 
				
			||||||
 | 
					        return file;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [Authorize]
 | 
					    [Authorize]
 | 
				
			||||||
    [HttpGet("me")]
 | 
					    [HttpGet("me")]
 | 
				
			||||||
    public async Task<ActionResult<List<CloudFile>>> GetMyFiles(
 | 
					    public async Task<ActionResult<List<CloudFile>>> GetMyFiles(
 | 
				
			||||||
@@ -211,4 +267,110 @@ public class FileController(
 | 
				
			|||||||
        var count = await fs.DeleteAllRecycledFilesAsync();
 | 
					        var count = await fs.DeleteAllRecycledFilesAsync();
 | 
				
			||||||
        return Ok(new { Count = count });
 | 
					        return Ok(new { Count = count });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public class CreateFastFileRequest
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        public string Name { get; set; } = null!;
 | 
				
			||||||
 | 
					        public long Size { get; set; }
 | 
				
			||||||
 | 
					        public string Hash { get; set; } = null!;
 | 
				
			||||||
 | 
					        public string? MimeType { get; set; }
 | 
				
			||||||
 | 
					        public string? Description { get; set; }
 | 
				
			||||||
 | 
					        public Dictionary<string, object?>? UserMeta { get; set; }
 | 
				
			||||||
 | 
					        public Dictionary<string, object?>? FileMeta { get; set; }
 | 
				
			||||||
 | 
					        public List<ContentSensitiveMark>? SensitiveMarks { get; set; }
 | 
				
			||||||
 | 
					        public Guid PoolId { get; set; }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    [HttpPost("fast")]
 | 
				
			||||||
 | 
					    [RequiredPermission("global", "files.create")]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<CloudFile>> CreateFastFile([FromBody] CreateFastFileRequest request)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					        var accountId = Guid.Parse(currentUser.Id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var pool = await db.Pools.FirstOrDefaultAsync(p => p.Id == request.PoolId);
 | 
				
			||||||
 | 
					        if (pool is null) return BadRequest();
 | 
				
			||||||
 | 
					        if (!currentUser.IsSuperuser && pool.AccountId != accountId)
 | 
				
			||||||
 | 
					            return StatusCode(403, "You don't have permission to create files in this pool.");
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        if (!pool.PolicyConfig.EnableFastUpload)
 | 
				
			||||||
 | 
					            return StatusCode(
 | 
				
			||||||
 | 
					                403,
 | 
				
			||||||
 | 
					                "This pool does not allow fast upload"
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        if (pool.PolicyConfig.RequirePrivilege > 0)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            if (currentUser.PerkSubscription is null)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                return StatusCode(
 | 
				
			||||||
 | 
					                    403,
 | 
				
			||||||
 | 
					                    $"You need to have join the Stellar Program to use this pool"
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            var privilege =
 | 
				
			||||||
 | 
					                PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier);
 | 
				
			||||||
 | 
					            if (privilege < pool.PolicyConfig.RequirePrivilege)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                return StatusCode(
 | 
				
			||||||
 | 
					                    403,
 | 
				
			||||||
 | 
					                    $"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use this pool, you are tier {privilege}"
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        if (request.Size > pool.PolicyConfig.MaxFileSize)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return StatusCode(
 | 
				
			||||||
 | 
					                403,
 | 
				
			||||||
 | 
					                $"File size {request.Size} is larger than the pool's maximum file size {pool.PolicyConfig.MaxFileSize}"
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        var (ok, billableUnit, quota) = await qs.IsFileAcceptable(
 | 
				
			||||||
 | 
					            accountId,
 | 
				
			||||||
 | 
					            pool.BillingConfig.CostMultiplier ?? 1.0,
 | 
				
			||||||
 | 
					            request.Size
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        if (!ok)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return StatusCode(
 | 
				
			||||||
 | 
					                403,
 | 
				
			||||||
 | 
					                $"File size {billableUnit} is larger than the user's quota {quota}"
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await using var transaction = await db.Database.BeginTransactionAsync();
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var file = new CloudFile
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Name = request.Name,
 | 
				
			||||||
 | 
					                Size = request.Size,
 | 
				
			||||||
 | 
					                Hash = request.Hash,
 | 
				
			||||||
 | 
					                MimeType = request.MimeType,
 | 
				
			||||||
 | 
					                Description = request.Description,
 | 
				
			||||||
 | 
					                AccountId = accountId,
 | 
				
			||||||
 | 
					                UserMeta = request.UserMeta,
 | 
				
			||||||
 | 
					                FileMeta = request.FileMeta,
 | 
				
			||||||
 | 
					                SensitiveMarks = request.SensitiveMarks,
 | 
				
			||||||
 | 
					                PoolId = request.PoolId
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					            db.Files.Add(file);
 | 
				
			||||||
 | 
					            await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					            await fs._PurgeCacheAsync(file.Id);
 | 
				
			||||||
 | 
					            await transaction.CommitAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            file.FastUploadLink = await fs.CreateFastUploadLinkAsync(file);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            return file;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            await transaction.RollbackAsync();
 | 
				
			||||||
 | 
					            throw;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -27,6 +27,7 @@ public class BillingConfig
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
public class PolicyConfig
 | 
					public class PolicyConfig
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
 | 
					    public bool EnableFastUpload { get; set; } = false;
 | 
				
			||||||
    public bool EnableRecycle { get; set; } = false;
 | 
					    public bool EnableRecycle { get; set; } = false;
 | 
				
			||||||
    public bool PublicIndexable { get; set; } = false;
 | 
					    public bool PublicIndexable { get; set; } = false;
 | 
				
			||||||
    public bool PublicUsable { get; set; } = false;
 | 
					    public bool PublicUsable { get; set; } = false;
 | 
				
			||||||
@@ -47,6 +48,7 @@ public class FilePool : ModelBase, IIdentifiedResource
 | 
				
			|||||||
    [Column(TypeName = "jsonb")] public RemoteStorageConfig StorageConfig { get; set; } = new();
 | 
					    [Column(TypeName = "jsonb")] public RemoteStorageConfig StorageConfig { get; set; } = new();
 | 
				
			||||||
    [Column(TypeName = "jsonb")] public BillingConfig BillingConfig { get; set; } = new();
 | 
					    [Column(TypeName = "jsonb")] public BillingConfig BillingConfig { get; set; } = new();
 | 
				
			||||||
    [Column(TypeName = "jsonb")] public PolicyConfig PolicyConfig { get; set; } = new();
 | 
					    [Column(TypeName = "jsonb")] public PolicyConfig PolicyConfig { get; set; } = new();
 | 
				
			||||||
 | 
					    public bool IsHidden { get; set; } = false;
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    public Guid? AccountId { get; set; }
 | 
					    public Guid? AccountId { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -18,6 +18,7 @@ public class FilePoolController(AppDatabase db, FileService fs) : ControllerBase
 | 
				
			|||||||
        var accountId = Guid.Parse(currentUser.Id);
 | 
					        var accountId = Guid.Parse(currentUser.Id);
 | 
				
			||||||
        var pools = await db.Pools
 | 
					        var pools = await db.Pools
 | 
				
			||||||
            .Where(p => p.PolicyConfig.PublicUsable || p.AccountId == accountId)
 | 
					            .Where(p => p.PolicyConfig.PublicUsable || p.AccountId == accountId)
 | 
				
			||||||
 | 
					            .Where(p => !p.IsHidden || p.AccountId == accountId)
 | 
				
			||||||
            .ToListAsync();
 | 
					            .ToListAsync();
 | 
				
			||||||
        pools = pools.Select(p =>
 | 
					        pools = pools.Select(p =>
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -190,10 +190,8 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
 | 
				
			|||||||
            .Where(r => r.ResourceId == resourceId && r.Usage == usage)
 | 
					            .Where(r => r.ResourceId == resourceId && r.Usage == usage)
 | 
				
			||||||
            .ToListAsync();
 | 
					            .ToListAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (!references.Any())
 | 
					        if (references.Count == 0)
 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            return 0;
 | 
					            return 0;
 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var fileIds = references.Select(r => r.FileId).Distinct().ToList();
 | 
					        var fileIds = references.Select(r => r.FileId).Distinct().ToList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -208,6 +206,28 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
 | 
				
			|||||||
        return deletedCount;
 | 
					        return deletedCount;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
 | 
					    public async Task<int> DeleteResourceReferencesBatchAsync(IEnumerable<string> resourceIds, string? usage = null)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var references = await db.FileReferences
 | 
				
			||||||
 | 
					            .Where(r => resourceIds.Contains(r.ResourceId))
 | 
				
			||||||
 | 
					            .If(usage != null, q => q.Where(q => q.Usage == usage))
 | 
				
			||||||
 | 
					            .ToListAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (references.Count == 0)
 | 
				
			||||||
 | 
					            return 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var fileIds = references.Select(r => r.FileId).Distinct().ToList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        db.FileReferences.RemoveRange(references);
 | 
				
			||||||
 | 
					        var deletedCount = await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Purge caches
 | 
				
			||||||
 | 
					        var tasks = fileIds.Select(fileService._PurgeCacheAsync).ToList();
 | 
				
			||||||
 | 
					        await Task.WhenAll(tasks);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return deletedCount;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /// <summary>
 | 
					    /// <summary>
 | 
				
			||||||
    /// Deletes a specific file reference
 | 
					    /// Deletes a specific file reference
 | 
				
			||||||
    /// </summary>
 | 
					    /// </summary>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -85,7 +85,7 @@ namespace DysonNetwork.Drive.Storage
 | 
				
			|||||||
        public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferences(
 | 
					        public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferences(
 | 
				
			||||||
            DeleteResourceReferencesRequest request, ServerCallContext context)
 | 
					            DeleteResourceReferencesRequest request, ServerCallContext context)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            var deletedCount = 0;
 | 
					            int deletedCount;
 | 
				
			||||||
            if (request.Usage is null)
 | 
					            if (request.Usage is null)
 | 
				
			||||||
                deletedCount = await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId);
 | 
					                deletedCount = await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId);
 | 
				
			||||||
            else
 | 
					            else
 | 
				
			||||||
@@ -94,6 +94,18 @@ namespace DysonNetwork.Drive.Storage
 | 
				
			|||||||
            return new DeleteResourceReferencesResponse { DeletedCount = deletedCount };
 | 
					            return new DeleteResourceReferencesResponse { DeletedCount = deletedCount };
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
 | 
					        public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferencesBatch(DeleteResourceReferencesBatchRequest request, ServerCallContext context)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var resourceIds = request.ResourceIds.ToList();
 | 
				
			||||||
 | 
					            int deletedCount;
 | 
				
			||||||
 | 
					            if (request.Usage is null)
 | 
				
			||||||
 | 
					                deletedCount = await fileReferenceService.DeleteResourceReferencesBatchAsync(resourceIds);
 | 
				
			||||||
 | 
					            else
 | 
				
			||||||
 | 
					                deletedCount =
 | 
				
			||||||
 | 
					                    await fileReferenceService.DeleteResourceReferencesBatchAsync(resourceIds, request.Usage!);
 | 
				
			||||||
 | 
					            return new DeleteResourceReferencesResponse { DeletedCount = deletedCount };
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        public override async Task<DeleteReferenceResponse> DeleteReference(DeleteReferenceRequest request,
 | 
					        public override async Task<DeleteReferenceResponse> DeleteReference(DeleteReferenceRequest request,
 | 
				
			||||||
            ServerCallContext context)
 | 
					            ServerCallContext context)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -12,6 +12,7 @@ using NetVips;
 | 
				
			|||||||
using NodaTime;
 | 
					using NodaTime;
 | 
				
			||||||
using tusdotnet.Stores;
 | 
					using tusdotnet.Stores;
 | 
				
			||||||
using System.Linq.Expressions;
 | 
					using System.Linq.Expressions;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
using Microsoft.EntityFrameworkCore.Query;
 | 
					using Microsoft.EntityFrameworkCore.Query;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace DysonNetwork.Drive.Storage;
 | 
					namespace DysonNetwork.Drive.Storage;
 | 
				
			||||||
@@ -19,7 +20,6 @@ namespace DysonNetwork.Drive.Storage;
 | 
				
			|||||||
public class FileService(
 | 
					public class FileService(
 | 
				
			||||||
    AppDatabase db,
 | 
					    AppDatabase db,
 | 
				
			||||||
    IConfiguration configuration,
 | 
					    IConfiguration configuration,
 | 
				
			||||||
    TusDiskStore store,
 | 
					 | 
				
			||||||
    ILogger<FileService> logger,
 | 
					    ILogger<FileService> logger,
 | 
				
			||||||
    IServiceScopeFactory scopeFactory,
 | 
					    IServiceScopeFactory scopeFactory,
 | 
				
			||||||
    ICacheService cache
 | 
					    ICacheService cache
 | 
				
			||||||
@@ -101,7 +101,10 @@ public class FileService(
 | 
				
			|||||||
    private const string TempFilePrefix = "dyn-cloudfile";
 | 
					    private const string TempFilePrefix = "dyn-cloudfile";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private static readonly string[] AnimatedImageTypes =
 | 
					    private static readonly string[] AnimatedImageTypes =
 | 
				
			||||||
        ["image/gif", "image/apng", "image/webp", "image/avif"];
 | 
					        ["image/gif", "image/apng", "image/avif"];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static readonly string[] AnimatedImageExtensions =
 | 
				
			||||||
 | 
					        [".gif", ".apng", ".avif"];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async Task<CloudFile> ProcessNewFileAsync(
 | 
					    public async Task<CloudFile> ProcessNewFileAsync(
 | 
				
			||||||
        Account account,
 | 
					        Account account,
 | 
				
			||||||
@@ -265,12 +268,24 @@ public class FileService(
 | 
				
			|||||||
                        // Add detailed stream information
 | 
					                        // Add detailed stream information
 | 
				
			||||||
                        ["video_streams"] = mediaInfo.VideoStreams.Select(s => new
 | 
					                        ["video_streams"] = mediaInfo.VideoStreams.Select(s => new
 | 
				
			||||||
                        {
 | 
					                        {
 | 
				
			||||||
                            s.AvgFrameRate, s.BitRate, s.CodecName, s.Duration, s.Height, s.Width, s.Language,
 | 
					                            s.AvgFrameRate,
 | 
				
			||||||
                            s.PixelFormat, s.Rotation
 | 
					                            s.BitRate,
 | 
				
			||||||
 | 
					                            s.CodecName,
 | 
				
			||||||
 | 
					                            s.Duration,
 | 
				
			||||||
 | 
					                            s.Height,
 | 
				
			||||||
 | 
					                            s.Width,
 | 
				
			||||||
 | 
					                            s.Language,
 | 
				
			||||||
 | 
					                            s.PixelFormat,
 | 
				
			||||||
 | 
					                            s.Rotation
 | 
				
			||||||
                        }).Where(s => double.IsNormal(s.AvgFrameRate)).ToList(),
 | 
					                        }).Where(s => double.IsNormal(s.AvgFrameRate)).ToList(),
 | 
				
			||||||
                        ["audio_streams"] = mediaInfo.AudioStreams.Select(s => new
 | 
					                        ["audio_streams"] = mediaInfo.AudioStreams.Select(s => new
 | 
				
			||||||
                            {
 | 
					                            {
 | 
				
			||||||
                                s.BitRate, s.Channels, s.ChannelLayout, s.CodecName, s.Duration, s.Language,
 | 
					                                s.BitRate,
 | 
				
			||||||
 | 
					                                s.Channels,
 | 
				
			||||||
 | 
					                                s.ChannelLayout,
 | 
				
			||||||
 | 
					                                s.CodecName,
 | 
				
			||||||
 | 
					                                s.Duration,
 | 
				
			||||||
 | 
					                                s.Language,
 | 
				
			||||||
                                s.SampleRateHz
 | 
					                                s.SampleRateHz
 | 
				
			||||||
                            })
 | 
					                            })
 | 
				
			||||||
                            .ToList(),
 | 
					                            .ToList(),
 | 
				
			||||||
@@ -317,10 +332,19 @@ public class FileService(
 | 
				
			|||||||
        {
 | 
					        {
 | 
				
			||||||
            logger.LogInformation("Processing file {FileId} in background...", fileId);
 | 
					            logger.LogInformation("Processing file {FileId} in background...", fileId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            var fileExtension = Path.GetExtension(originalFilePath);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if (!pool.PolicyConfig.NoOptimization)
 | 
					            if (!pool.PolicyConfig.NoOptimization)
 | 
				
			||||||
                switch (contentType.Split('/')[0])
 | 
					                switch (contentType.Split('/')[0])
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
                    case "image" when !AnimatedImageTypes.Contains(contentType):
 | 
					                    case "image":
 | 
				
			||||||
 | 
					                        if (AnimatedImageTypes.Contains(contentType) || AnimatedImageExtensions.Contains(fileExtension))
 | 
				
			||||||
 | 
					                        {
 | 
				
			||||||
 | 
					                            logger.LogInformation("Skip optimize file {FileId} due to it is animated...", fileId);
 | 
				
			||||||
 | 
					                            uploads.Add((originalFilePath, string.Empty, contentType, false));
 | 
				
			||||||
 | 
					                            break;
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                        newMimeType = "image/webp";
 | 
					                        newMimeType = "image/webp";
 | 
				
			||||||
                        using (var vipsImage = Image.NewFromFile(originalFilePath))
 | 
					                        using (var vipsImage = Image.NewFromFile(originalFilePath))
 | 
				
			||||||
                        {
 | 
					                        {
 | 
				
			||||||
@@ -627,7 +651,44 @@ public class FileService(
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async Task<FileBundle?> GetBundleAsync(Guid id, Guid accountId)
 | 
					    /// <summary>
 | 
				
			||||||
 | 
					    /// The most efficent way to delete file data (stored files) in batch.
 | 
				
			||||||
 | 
					    /// But this DO NOT check the storage id, so use with caution!
 | 
				
			||||||
 | 
					    /// </summary>
 | 
				
			||||||
 | 
					    /// <param name="files">Files to delete</param>
 | 
				
			||||||
 | 
					    /// <exception cref="InvalidOperationException">Something went wrong</exception>
 | 
				
			||||||
 | 
					    public async Task DeleteFileDataBatchAsync(List<CloudFile> files)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        files = files.Where(f => f.PoolId.HasValue).ToList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        foreach (var fileGroup in files.GroupBy(f => f.PoolId!.Value))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            // If any other file with the same storage ID is referenced, don't delete the actual file data
 | 
				
			||||||
 | 
					            var dest = await GetRemoteStorageConfig(fileGroup.Key);
 | 
				
			||||||
 | 
					            if (dest is null)
 | 
				
			||||||
 | 
					                throw new InvalidOperationException($"No remote storage configured for pool {fileGroup.Key}");
 | 
				
			||||||
 | 
					            var client = CreateMinioClient(dest);
 | 
				
			||||||
 | 
					            if (client is null)
 | 
				
			||||||
 | 
					                throw new InvalidOperationException(
 | 
				
			||||||
 | 
					                    $"Failed to configure client for remote destination '{fileGroup.Key}'"
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            List<string> objectsToDelete = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            foreach (var file in fileGroup)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                objectsToDelete.Add(file.StorageId ?? file.Id);
 | 
				
			||||||
 | 
					                if (file.HasCompression) objectsToDelete.Add(file.StorageId ?? file.Id + ".compressed");
 | 
				
			||||||
 | 
					                if (file.HasThumbnail) objectsToDelete.Add(file.StorageId ?? file.Id + ".thumbnail");
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            await client.RemoveObjectsAsync(
 | 
				
			||||||
 | 
					                new RemoveObjectsArgs().WithBucket(dest.Bucket).WithObjects(objectsToDelete)
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private async Task<FileBundle?> GetBundleAsync(Guid id, Guid accountId)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var bundle = await db.Bundles
 | 
					        var bundle = await db.Bundles
 | 
				
			||||||
            .Where(e => e.Id == id)
 | 
					            .Where(e => e.Id == id)
 | 
				
			||||||
@@ -820,6 +881,27 @@ public class FileService(
 | 
				
			|||||||
        await db.SaveChangesAsync();
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
        return count;
 | 
					        return count;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<string> CreateFastUploadLinkAsync(CloudFile file)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (file.PoolId is null) throw new InvalidOperationException("Pool ID is null");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var dest = await GetRemoteStorageConfig(file.PoolId.Value);
 | 
				
			||||||
 | 
					        if (dest is null) throw new InvalidOperationException($"No remote storage configured for pool {file.PoolId}");
 | 
				
			||||||
 | 
					        var client = CreateMinioClient(dest);
 | 
				
			||||||
 | 
					        if (client is null)
 | 
				
			||||||
 | 
					            throw new InvalidOperationException(
 | 
				
			||||||
 | 
					                $"Failed to configure client for remote destination '{file.PoolId}'"
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var url = await client.PresignedPutObjectAsync(
 | 
				
			||||||
 | 
					            new PresignedPutObjectArgs()
 | 
				
			||||||
 | 
					                .WithBucket(dest.Bucket)
 | 
				
			||||||
 | 
					                .WithObject(file.Id)
 | 
				
			||||||
 | 
					                .WithExpiry(60 * 60 * 24)
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        return url;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// <summary>
 | 
					/// <summary>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,4 @@
 | 
				
			|||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
using DysonNetwork.Shared.Proto;
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
using Google.Protobuf.WellKnownTypes;
 | 
					using Google.Protobuf.WellKnownTypes;
 | 
				
			||||||
using Grpc.Core;
 | 
					using Grpc.Core;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -228,7 +228,26 @@ public abstract class TusService
 | 
				
			|||||||
                        );
 | 
					                        );
 | 
				
			||||||
                        rejected = true;
 | 
					                        rejected = true;
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
                    else if (!policy.AcceptTypes.Contains(contentType))
 | 
					                    else
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        var foundMatch = false;
 | 
				
			||||||
 | 
					                        foreach (var acceptType in policy.AcceptTypes)
 | 
				
			||||||
 | 
					                        {
 | 
				
			||||||
 | 
					                            if (acceptType.EndsWith("/*", StringComparison.OrdinalIgnoreCase))
 | 
				
			||||||
 | 
					                            {
 | 
				
			||||||
 | 
					                                var type = acceptType[..^2];
 | 
				
			||||||
 | 
					                                if (!contentType.StartsWith($"{type}/", StringComparison.OrdinalIgnoreCase)) continue;
 | 
				
			||||||
 | 
					                                foundMatch = true;
 | 
				
			||||||
 | 
					                                break;
 | 
				
			||||||
 | 
					                            }
 | 
				
			||||||
 | 
					                            else if (acceptType.Equals(contentType, StringComparison.OrdinalIgnoreCase))
 | 
				
			||||||
 | 
					                            {
 | 
				
			||||||
 | 
					                                foundMatch = true;
 | 
				
			||||||
 | 
					                                break;
 | 
				
			||||||
 | 
					                            }
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                        if (!foundMatch)
 | 
				
			||||||
                        {
 | 
					                        {
 | 
				
			||||||
                            eventContext.FailRequest(
 | 
					                            eventContext.FailRequest(
 | 
				
			||||||
                                HttpStatusCode.Forbidden,
 | 
					                                HttpStatusCode.Forbidden,
 | 
				
			||||||
@@ -237,6 +256,7 @@ public abstract class TusService
 | 
				
			|||||||
                            rejected = true;
 | 
					                            rejected = true;
 | 
				
			||||||
                        }
 | 
					                        }
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                if (!rejected && policy.MaxFileSize is not null)
 | 
					                if (!rejected && policy.MaxFileSize is not null)
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,7 @@
 | 
				
			|||||||
{
 | 
					{
 | 
				
			||||||
  "Debug": true,
 | 
					  "Debug": true,
 | 
				
			||||||
  "BaseUrl": "http://localhost:5071",
 | 
					  "BaseUrl": "http://localhost:5090",
 | 
				
			||||||
 | 
					  "GatewayUrl": "http://localhost:5094",
 | 
				
			||||||
  "Logging": {
 | 
					  "Logging": {
 | 
				
			||||||
    "LogLevel": {
 | 
					    "LogLevel": {
 | 
				
			||||||
      "Default": "Information",
 | 
					      "Default": "Information",
 | 
				
			||||||
@@ -9,9 +10,7 @@
 | 
				
			|||||||
  },
 | 
					  },
 | 
				
			||||||
  "AllowedHosts": "*",
 | 
					  "AllowedHosts": "*",
 | 
				
			||||||
  "ConnectionStrings": {
 | 
					  "ConnectionStrings": {
 | 
				
			||||||
    "App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
 | 
					    "App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
 | 
				
			||||||
    "FastRetrieve": "localhost:6379",
 | 
					 | 
				
			||||||
    "Etcd": "etcd.orb.local:2379"
 | 
					 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "Authentication": {
 | 
					  "Authentication": {
 | 
				
			||||||
    "Schemes": {
 | 
					    "Schemes": {
 | 
				
			||||||
@@ -129,8 +128,6 @@
 | 
				
			|||||||
  ],
 | 
					  ],
 | 
				
			||||||
  "Service": {
 | 
					  "Service": {
 | 
				
			||||||
    "Name": "DysonNetwork.Drive",
 | 
					    "Name": "DysonNetwork.Drive",
 | 
				
			||||||
    "Url": "https://localhost:7092",
 | 
					    "Url": "https://localhost:7092"
 | 
				
			||||||
    "ClientCert": "../Certificates/client.crt",
 | 
					 | 
				
			||||||
    "ClientKey": "../Certificates/client.key"
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,78 +0,0 @@
 | 
				
			|||||||
using System.Text;
 | 
					 | 
				
			||||||
using dotnet_etcd.interfaces;
 | 
					 | 
				
			||||||
using Microsoft.AspNetCore.Mvc;
 | 
					 | 
				
			||||||
using Yarp.ReverseProxy.Configuration;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
namespace DysonNetwork.Gateway.Controllers;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
[ApiController]
 | 
					 | 
				
			||||||
[Route("/.well-known")]
 | 
					 | 
				
			||||||
public class WellKnownController(
 | 
					 | 
				
			||||||
    IConfiguration configuration,
 | 
					 | 
				
			||||||
    IProxyConfigProvider proxyConfigProvider,
 | 
					 | 
				
			||||||
    IEtcdClient etcdClient)
 | 
					 | 
				
			||||||
    : ControllerBase
 | 
					 | 
				
			||||||
{
 | 
					 | 
				
			||||||
    [HttpGet("domains")]
 | 
					 | 
				
			||||||
    public IActionResult GetDomainMappings()
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        var domainMappings = configuration.GetSection("DomainMappings").GetChildren()
 | 
					 | 
				
			||||||
            .ToDictionary(x => x.Key, x => x.Value);
 | 
					 | 
				
			||||||
        return Ok(domainMappings);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    [HttpGet("services")]
 | 
					 | 
				
			||||||
    public IActionResult GetServices()
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        var local = configuration.GetValue<bool>("LocalMode");
 | 
					 | 
				
			||||||
        var response = etcdClient.GetRange("/services/");
 | 
					 | 
				
			||||||
        var kvs = response.Kvs;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        var serviceMap = kvs.ToDictionary(
 | 
					 | 
				
			||||||
            kv => Encoding.UTF8.GetString(kv.Key.ToByteArray()).Replace("/services/", ""),
 | 
					 | 
				
			||||||
            kv => Encoding.UTF8.GetString(kv.Value.ToByteArray())
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if (local) return Ok(serviceMap);
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        var domainMappings = configuration.GetSection("DomainMappings").GetChildren()
 | 
					 | 
				
			||||||
            .ToDictionary(x => x.Key, x => x.Value);
 | 
					 | 
				
			||||||
        foreach (var (key, _) in serviceMap.ToList())
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            if (!domainMappings.TryGetValue(key, out var domain)) continue;
 | 
					 | 
				
			||||||
            if (domain is not null)
 | 
					 | 
				
			||||||
                serviceMap[key] = "https://" + domain;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return Ok(serviceMap);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    [HttpGet("routes")]
 | 
					 | 
				
			||||||
    public IActionResult GetProxyRules()
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        var config = proxyConfigProvider.GetConfig();
 | 
					 | 
				
			||||||
        var rules = config.Routes.Select(r => new
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            r.RouteId,
 | 
					 | 
				
			||||||
            r.ClusterId,
 | 
					 | 
				
			||||||
            Match = new
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                r.Match.Path,
 | 
					 | 
				
			||||||
                Hosts = r.Match.Hosts != null ? string.Join(", ", r.Match.Hosts) : null
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
            Transforms = r.Transforms?.Select(t => t.Select(kv => $"{kv.Key}: {kv.Value}").ToList())
 | 
					 | 
				
			||||||
        }).ToList();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        var clusters = config.Clusters.Select(c => new
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            c.ClusterId,
 | 
					 | 
				
			||||||
            Destinations = c.Destinations?.Select(d => new
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                d.Key,
 | 
					 | 
				
			||||||
                d.Value.Address
 | 
					 | 
				
			||||||
            }).ToList()
 | 
					 | 
				
			||||||
        }).ToList();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return Ok(new { Rules = rules, Clusters = clusters });
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,23 +0,0 @@
 | 
				
			|||||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
 | 
					 | 
				
			||||||
USER $APP_UID
 | 
					 | 
				
			||||||
WORKDIR /app
 | 
					 | 
				
			||||||
EXPOSE 8080
 | 
					 | 
				
			||||||
EXPOSE 8081
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
 | 
					 | 
				
			||||||
ARG BUILD_CONFIGURATION=Release
 | 
					 | 
				
			||||||
WORKDIR /src
 | 
					 | 
				
			||||||
COPY ["DysonNetwork.Gateway/DysonNetwork.Gateway.csproj", "DysonNetwork.Gateway/"]
 | 
					 | 
				
			||||||
RUN dotnet restore "DysonNetwork.Gateway/DysonNetwork.Gateway.csproj"
 | 
					 | 
				
			||||||
COPY . .
 | 
					 | 
				
			||||||
WORKDIR "/src/DysonNetwork.Gateway"
 | 
					 | 
				
			||||||
RUN dotnet build "./DysonNetwork.Gateway.csproj" -c $BUILD_CONFIGURATION -o /app/build
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
FROM build AS publish
 | 
					 | 
				
			||||||
ARG BUILD_CONFIGURATION=Release
 | 
					 | 
				
			||||||
RUN dotnet publish "./DysonNetwork.Gateway.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
FROM base AS final
 | 
					 | 
				
			||||||
WORKDIR /app
 | 
					 | 
				
			||||||
COPY --from=publish /app/publish .
 | 
					 | 
				
			||||||
ENTRYPOINT ["dotnet", "DysonNetwork.Gateway.dll"]
 | 
					 | 
				
			||||||
@@ -1,23 +0,0 @@
 | 
				
			|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  <PropertyGroup>
 | 
					 | 
				
			||||||
    <TargetFramework>net9.0</TargetFramework>
 | 
					 | 
				
			||||||
    <Nullable>enable</Nullable>
 | 
					 | 
				
			||||||
    <ImplicitUsings>enable</ImplicitUsings>
 | 
					 | 
				
			||||||
  </PropertyGroup>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  <ItemGroup>
 | 
					 | 
				
			||||||
    <PackageReference Include="dotnet-etcd" Version="8.0.1" />
 | 
					 | 
				
			||||||
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" />
 | 
					 | 
				
			||||||
    <PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115">
 | 
					 | 
				
			||||||
      <PrivateAssets>all</PrivateAssets>
 | 
					 | 
				
			||||||
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
 | 
					 | 
				
			||||||
    </PackageReference>
 | 
					 | 
				
			||||||
    <PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
 | 
					 | 
				
			||||||
  </ItemGroup>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  <ItemGroup>
 | 
					 | 
				
			||||||
    <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
 | 
					 | 
				
			||||||
  </ItemGroup>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
</Project>
 | 
					 | 
				
			||||||
@@ -1,32 +0,0 @@
 | 
				
			|||||||
using DysonNetwork.Gateway.Startup;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
var builder = WebApplication.CreateBuilder(args);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
builder.Host.UseContentRoot(Directory.GetCurrentDirectory());
 | 
					 | 
				
			||||||
builder.WebHost.ConfigureKestrel(options =>
 | 
					 | 
				
			||||||
{
 | 
					 | 
				
			||||||
    options.Limits.MaxRequestBodySize = long.MaxValue;
 | 
					 | 
				
			||||||
    options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2);
 | 
					 | 
				
			||||||
    options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30);
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Add services to the container.
 | 
					 | 
				
			||||||
builder.Services.AddGateway(builder.Configuration);
 | 
					 | 
				
			||||||
builder.Services.AddControllers();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
var app = builder.Build();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
app.UseRequestTimeouts();
 | 
					 | 
				
			||||||
app.UseCors(opts =>
 | 
					 | 
				
			||||||
    opts.SetIsOriginAllowed(_ => true)
 | 
					 | 
				
			||||||
        .WithExposedHeaders("*")
 | 
					 | 
				
			||||||
        .WithHeaders("*")
 | 
					 | 
				
			||||||
        .AllowCredentials()
 | 
					 | 
				
			||||||
        .AllowAnyHeader()
 | 
					 | 
				
			||||||
        .AllowAnyMethod()
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
app.MapControllers();
 | 
					 | 
				
			||||||
app.MapReverseProxy();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
app.Run();
 | 
					 | 
				
			||||||
@@ -1,239 +0,0 @@
 | 
				
			|||||||
using System.Text;
 | 
					 | 
				
			||||||
using dotnet_etcd.interfaces;
 | 
					 | 
				
			||||||
using Yarp.ReverseProxy.Configuration;
 | 
					 | 
				
			||||||
using Yarp.ReverseProxy.Forwarder;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
namespace DysonNetwork.Gateway;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
public class RegistryProxyConfigProvider : IProxyConfigProvider, IDisposable
 | 
					 | 
				
			||||||
{
 | 
					 | 
				
			||||||
    private readonly object _lock = new();
 | 
					 | 
				
			||||||
    private readonly IEtcdClient _etcdClient;
 | 
					 | 
				
			||||||
    private readonly IConfiguration _configuration;
 | 
					 | 
				
			||||||
    private readonly ILogger<RegistryProxyConfigProvider> _logger;
 | 
					 | 
				
			||||||
    private readonly CancellationTokenSource _watchCts = new();
 | 
					 | 
				
			||||||
    private CancellationTokenSource _cts;
 | 
					 | 
				
			||||||
    private IProxyConfig _config;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public RegistryProxyConfigProvider(
 | 
					 | 
				
			||||||
        IEtcdClient etcdClient,
 | 
					 | 
				
			||||||
        IConfiguration configuration,
 | 
					 | 
				
			||||||
        ILogger<RegistryProxyConfigProvider> logger
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        _etcdClient = etcdClient;
 | 
					 | 
				
			||||||
        _configuration = configuration;
 | 
					 | 
				
			||||||
        _logger = logger;
 | 
					 | 
				
			||||||
        _cts = new CancellationTokenSource();
 | 
					 | 
				
			||||||
        _config = LoadConfig();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        // Watch for changes in etcd
 | 
					 | 
				
			||||||
        _etcdClient.WatchRange("/services/", _ =>
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            _logger.LogInformation("Etcd configuration changed. Reloading proxy config.");
 | 
					 | 
				
			||||||
            ReloadConfig();
 | 
					 | 
				
			||||||
        }, cancellationToken: _watchCts.Token);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public IProxyConfig GetConfig() => _config;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private void ReloadConfig()
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        lock (_lock)
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            var oldCts = _cts;
 | 
					 | 
				
			||||||
            _cts = new CancellationTokenSource();
 | 
					 | 
				
			||||||
            _config = LoadConfig();
 | 
					 | 
				
			||||||
            oldCts.Cancel();
 | 
					 | 
				
			||||||
            oldCts.Dispose();
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private IProxyConfig LoadConfig()
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        _logger.LogInformation("Generating new proxy config.");
 | 
					 | 
				
			||||||
        var response = _etcdClient.GetRange("/services/");
 | 
					 | 
				
			||||||
        var kvs = response.Kvs;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        var serviceMap = kvs.ToDictionary(
 | 
					 | 
				
			||||||
            kv => Encoding.UTF8.GetString(kv.Key.ToByteArray()).Replace("/services/", ""),
 | 
					 | 
				
			||||||
            kv => Encoding.UTF8.GetString(kv.Value.ToByteArray())
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        var clusters = new List<ClusterConfig>();
 | 
					 | 
				
			||||||
        var routes = new List<RouteConfig>();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        var domainMappings = _configuration.GetSection("DomainMappings").GetChildren()
 | 
					 | 
				
			||||||
            .ToDictionary(x => x.Key, x => x.Value);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        var pathAliases = _configuration.GetSection("PathAliases").GetChildren()
 | 
					 | 
				
			||||||
            .ToDictionary(x => x.Key, x => x.Value);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        var directRoutes = _configuration.GetSection("DirectRoutes").Get<List<DirectRouteConfig>>() ??
 | 
					 | 
				
			||||||
                           [];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        _logger.LogInformation("Indexing {ServiceCount} services from Etcd.", kvs.Count);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        var gatewayServiceName = _configuration["Service:Name"];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        // Add direct routes
 | 
					 | 
				
			||||||
        foreach (var directRoute in directRoutes)
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            if (serviceMap.TryGetValue(directRoute.Service, out var serviceUrl))
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                var existingCluster = clusters.FirstOrDefault(c => c.ClusterId == directRoute.Service);
 | 
					 | 
				
			||||||
                if (existingCluster is null)
 | 
					 | 
				
			||||||
                {
 | 
					 | 
				
			||||||
                    var cluster = new ClusterConfig
 | 
					 | 
				
			||||||
                    {
 | 
					 | 
				
			||||||
                        ClusterId = directRoute.Service,
 | 
					 | 
				
			||||||
                        Destinations = new Dictionary<string, DestinationConfig>
 | 
					 | 
				
			||||||
                        {
 | 
					 | 
				
			||||||
                            { "destination1", new DestinationConfig { Address = serviceUrl } }
 | 
					 | 
				
			||||||
                        },
 | 
					 | 
				
			||||||
                    };
 | 
					 | 
				
			||||||
                    clusters.Add(cluster);
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                var route = new RouteConfig
 | 
					 | 
				
			||||||
                {
 | 
					 | 
				
			||||||
                    RouteId = $"direct-{directRoute.Service}-{directRoute.Path.Replace("/", "-")}",
 | 
					 | 
				
			||||||
                    ClusterId = directRoute.Service,
 | 
					 | 
				
			||||||
                    Match = new RouteMatch { Path = directRoute.Path },
 | 
					 | 
				
			||||||
                };
 | 
					 | 
				
			||||||
                routes.Add(route);
 | 
					 | 
				
			||||||
                _logger.LogInformation("    Added Direct Route: {Path} -> {Service}", directRoute.Path,
 | 
					 | 
				
			||||||
                    directRoute.Service);
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            else
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                _logger.LogWarning("    Direct route service {Service} not found in Etcd.", directRoute.Service);
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        foreach (var serviceName in serviceMap.Keys)
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            if (serviceName == gatewayServiceName)
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                _logger.LogInformation("Skipping gateway service: {ServiceName}", serviceName);
 | 
					 | 
				
			||||||
                continue;
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            var serviceUrl = serviceMap[serviceName];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            // Determine the path alias
 | 
					 | 
				
			||||||
            string? pathAlias;
 | 
					 | 
				
			||||||
            pathAlias = pathAliases.TryGetValue(serviceName, out var alias)
 | 
					 | 
				
			||||||
                ? alias
 | 
					 | 
				
			||||||
                : serviceName.Split('.').Last().ToLowerInvariant();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            _logger.LogInformation("  Service: {ServiceName}, URL: {ServiceUrl}, Path Alias: {PathAlias}", serviceName,
 | 
					 | 
				
			||||||
                serviceUrl, pathAlias);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            // Check if the cluster already exists
 | 
					 | 
				
			||||||
            var existingCluster = clusters.FirstOrDefault(c => c.ClusterId == serviceName);
 | 
					 | 
				
			||||||
            if (existingCluster == null)
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                var cluster = new ClusterConfig
 | 
					 | 
				
			||||||
                {
 | 
					 | 
				
			||||||
                    ClusterId = serviceName,
 | 
					 | 
				
			||||||
                    Destinations = new Dictionary<string, DestinationConfig>
 | 
					 | 
				
			||||||
                    {
 | 
					 | 
				
			||||||
                        { "destination1", new DestinationConfig { Address = serviceUrl } }
 | 
					 | 
				
			||||||
                    }
 | 
					 | 
				
			||||||
                };
 | 
					 | 
				
			||||||
                clusters.Add(cluster);
 | 
					 | 
				
			||||||
                _logger.LogInformation("  Added Cluster: {ServiceName}", serviceName);
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            else if (existingCluster.Destinations is not null)
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                // Create a new cluster with merged destinations
 | 
					 | 
				
			||||||
                var newDestinations = new Dictionary<string, DestinationConfig>(existingCluster.Destinations)
 | 
					 | 
				
			||||||
                {
 | 
					 | 
				
			||||||
                    {
 | 
					 | 
				
			||||||
                        $"destination{existingCluster.Destinations.Count + 1}",
 | 
					 | 
				
			||||||
                        new DestinationConfig { Address = serviceUrl }
 | 
					 | 
				
			||||||
                    }
 | 
					 | 
				
			||||||
                };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                var mergedCluster = new ClusterConfig
 | 
					 | 
				
			||||||
                {
 | 
					 | 
				
			||||||
                    ClusterId = serviceName,
 | 
					 | 
				
			||||||
                    Destinations = newDestinations
 | 
					 | 
				
			||||||
                };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                // Replace the existing cluster with the merged one
 | 
					 | 
				
			||||||
                var index = clusters.IndexOf(existingCluster);
 | 
					 | 
				
			||||||
                clusters[index] = mergedCluster;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                _logger.LogInformation("  Updated Cluster {ServiceName} with {DestinationCount} destinations",
 | 
					 | 
				
			||||||
                    serviceName, mergedCluster.Destinations.Count);
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            // Host-based routing
 | 
					 | 
				
			||||||
            if (domainMappings.TryGetValue(serviceName, out var domain))
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                var hostRoute = new RouteConfig
 | 
					 | 
				
			||||||
                {
 | 
					 | 
				
			||||||
                    RouteId = $"{serviceName}-host",
 | 
					 | 
				
			||||||
                    ClusterId = serviceName,
 | 
					 | 
				
			||||||
                    Match = new RouteMatch
 | 
					 | 
				
			||||||
                    {
 | 
					 | 
				
			||||||
                        Hosts = [domain],
 | 
					 | 
				
			||||||
                        Path = "/{**catch-all}"
 | 
					 | 
				
			||||||
                    }
 | 
					 | 
				
			||||||
                };
 | 
					 | 
				
			||||||
                routes.Add(hostRoute);
 | 
					 | 
				
			||||||
                _logger.LogInformation("    Added Host-based Route: {Host}", domain);
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            // Path-based routing
 | 
					 | 
				
			||||||
            var pathRoute = new RouteConfig
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                RouteId = $"{serviceName}-path",
 | 
					 | 
				
			||||||
                ClusterId = serviceName,
 | 
					 | 
				
			||||||
                Match = new RouteMatch { Path = $"/{pathAlias}/{{**catch-all}}" },
 | 
					 | 
				
			||||||
                Transforms = new List<Dictionary<string, string>>
 | 
					 | 
				
			||||||
                {
 | 
					 | 
				
			||||||
                    new() { { "PathRemovePrefix", $"/{pathAlias}" } },
 | 
					 | 
				
			||||||
                    new() { { "PathPrefix", "/api" } }
 | 
					 | 
				
			||||||
                },
 | 
					 | 
				
			||||||
                Timeout = TimeSpan.FromSeconds(5)
 | 
					 | 
				
			||||||
            };
 | 
					 | 
				
			||||||
            routes.Add(pathRoute);
 | 
					 | 
				
			||||||
            _logger.LogInformation("    Added Path-based Route: {Path}", pathRoute.Match.Path);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return new CustomProxyConfig(
 | 
					 | 
				
			||||||
            routes,
 | 
					 | 
				
			||||||
            clusters,
 | 
					 | 
				
			||||||
            new Microsoft.Extensions.Primitives.CancellationChangeToken(_cts.Token)
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private class CustomProxyConfig(
 | 
					 | 
				
			||||||
        IReadOnlyList<RouteConfig> routes,
 | 
					 | 
				
			||||||
        IReadOnlyList<ClusterConfig> clusters,
 | 
					 | 
				
			||||||
        Microsoft.Extensions.Primitives.IChangeToken changeToken
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
        : IProxyConfig
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        public IReadOnlyList<RouteConfig> Routes { get; } = routes;
 | 
					 | 
				
			||||||
        public IReadOnlyList<ClusterConfig> Clusters { get; } = clusters;
 | 
					 | 
				
			||||||
        public Microsoft.Extensions.Primitives.IChangeToken ChangeToken { get; } = changeToken;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public record DirectRouteConfig
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        public required string Path { get; set; }
 | 
					 | 
				
			||||||
        public required string Service { get; set; }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public virtual void Dispose()
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        _cts.Cancel();
 | 
					 | 
				
			||||||
        _cts.Dispose();
 | 
					 | 
				
			||||||
        _watchCts.Cancel();
 | 
					 | 
				
			||||||
        _watchCts.Dispose();
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,30 +0,0 @@
 | 
				
			|||||||
using System.Net.Security;
 | 
					 | 
				
			||||||
using System.Security.Cryptography.X509Certificates;
 | 
					 | 
				
			||||||
using DysonNetwork.Shared.Registry;
 | 
					 | 
				
			||||||
using Yarp.ReverseProxy.Configuration;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
namespace DysonNetwork.Gateway.Startup;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
public static class ServiceCollectionExtensions
 | 
					 | 
				
			||||||
{
 | 
					 | 
				
			||||||
    public static IServiceCollection AddGateway(this IServiceCollection services, IConfiguration configuration)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        services.AddRequestTimeouts();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        services
 | 
					 | 
				
			||||||
            .AddReverseProxy()
 | 
					 | 
				
			||||||
            .ConfigureHttpClient((context, handler) =>
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                var caCert = X509CertificateLoader.LoadCertificateFromFile(configuration["CaCert"]!);
 | 
					 | 
				
			||||||
                handler.SslOptions = new SslClientAuthenticationOptions
 | 
					 | 
				
			||||||
                {
 | 
					 | 
				
			||||||
                    RemoteCertificateValidationCallback = (sender, cert, chain, errors) => true
 | 
					 | 
				
			||||||
                };
 | 
					 | 
				
			||||||
            });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        services.AddRegistryService(configuration, addForwarder: false);
 | 
					 | 
				
			||||||
        services.AddSingleton<IProxyConfigProvider, RegistryProxyConfigProvider>();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return services;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,20 +0,0 @@
 | 
				
			|||||||
using DysonNetwork.Shared.Data;
 | 
					 | 
				
			||||||
using Microsoft.AspNetCore.Mvc;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
namespace DysonNetwork.Gateway;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
[ApiController]
 | 
					 | 
				
			||||||
[Route("/api/version")]
 | 
					 | 
				
			||||||
public class VersionController : ControllerBase
 | 
					 | 
				
			||||||
{
 | 
					 | 
				
			||||||
    [HttpGet]
 | 
					 | 
				
			||||||
    public IActionResult Get()
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        return Ok(new AppVersion
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            Version = ThisAssembly.AssemblyVersion,
 | 
					 | 
				
			||||||
            Commit = ThisAssembly.GitCommitId,
 | 
					 | 
				
			||||||
            UpdateDate = ThisAssembly.GitCommitDate
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,46 +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",
 | 
					 | 
				
			||||||
      "IsWebsocket": true
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
      "Path": "/.well-known/openid-configuration",
 | 
					 | 
				
			||||||
      "Service": "DysonNetwork.Pass"
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
      "Path": "/.well-known/jwks",
 | 
					 | 
				
			||||||
      "Service": "DysonNetwork.Pass"
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  ]
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,7 +0,0 @@
 | 
				
			|||||||
{
 | 
					 | 
				
			||||||
  "version": "1.0",
 | 
					 | 
				
			||||||
  "publicReleaseRefSpec": ["^refs/heads/main$"],
 | 
					 | 
				
			||||||
  "cloudBuild": {
 | 
					 | 
				
			||||||
    "setVersionVariables": true
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -18,9 +18,13 @@ public class Account : ModelBase
 | 
				
			|||||||
    [MaxLength(256)] public string Name { get; set; } = string.Empty;
 | 
					    [MaxLength(256)] public string Name { get; set; } = string.Empty;
 | 
				
			||||||
    [MaxLength(256)] public string Nick { get; set; } = string.Empty;
 | 
					    [MaxLength(256)] public string Nick { get; set; } = string.Empty;
 | 
				
			||||||
    [MaxLength(32)] public string Language { get; set; } = string.Empty;
 | 
					    [MaxLength(32)] public string Language { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					    [MaxLength(32)] public string Region { get; set; } = string.Empty;
 | 
				
			||||||
    public Instant? ActivatedAt { get; set; }
 | 
					    public Instant? ActivatedAt { get; set; }
 | 
				
			||||||
    public bool IsSuperuser { get; set; } = false;
 | 
					    public bool IsSuperuser { get; set; } = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // The ID is the BotAccount ID in the DysonNetwork.Develop
 | 
				
			||||||
 | 
					    public Guid? AutomatedId { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public AccountProfile Profile { get; set; } = null!;
 | 
					    public AccountProfile Profile { get; set; } = null!;
 | 
				
			||||||
    public ICollection<AccountContact> Contacts { get; set; } = new List<AccountContact>();
 | 
					    public ICollection<AccountContact> Contacts { get; set; } = new List<AccountContact>();
 | 
				
			||||||
    public ICollection<AccountBadge> Badges { get; set; } = new List<AccountBadge>();
 | 
					    public ICollection<AccountBadge> Badges { get; set; } = new List<AccountBadge>();
 | 
				
			||||||
@@ -43,12 +47,14 @@ public class Account : ModelBase
 | 
				
			|||||||
            Name = Name,
 | 
					            Name = Name,
 | 
				
			||||||
            Nick = Nick,
 | 
					            Nick = Nick,
 | 
				
			||||||
            Language = Language,
 | 
					            Language = Language,
 | 
				
			||||||
 | 
					            Region = Region,
 | 
				
			||||||
            ActivatedAt = ActivatedAt?.ToTimestamp(),
 | 
					            ActivatedAt = ActivatedAt?.ToTimestamp(),
 | 
				
			||||||
            IsSuperuser = IsSuperuser,
 | 
					            IsSuperuser = IsSuperuser,
 | 
				
			||||||
            Profile = Profile.ToProtoValue(),
 | 
					            Profile = Profile.ToProtoValue(),
 | 
				
			||||||
            PerkSubscription = PerkSubscription?.ToProtoValue(),
 | 
					            PerkSubscription = PerkSubscription?.ToProtoValue(),
 | 
				
			||||||
            CreatedAt = CreatedAt.ToTimestamp(),
 | 
					            CreatedAt = CreatedAt.ToTimestamp(),
 | 
				
			||||||
            UpdatedAt = UpdatedAt.ToTimestamp()
 | 
					            UpdatedAt = UpdatedAt.ToTimestamp(),
 | 
				
			||||||
 | 
					            AutomatedId = AutomatedId?.ToString()
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Add contacts
 | 
					        // Add contacts
 | 
				
			||||||
@@ -71,6 +77,7 @@ public class Account : ModelBase
 | 
				
			|||||||
            Name = proto.Name,
 | 
					            Name = proto.Name,
 | 
				
			||||||
            Nick = proto.Nick,
 | 
					            Nick = proto.Nick,
 | 
				
			||||||
            Language = proto.Language,
 | 
					            Language = proto.Language,
 | 
				
			||||||
 | 
					            Region = proto.Region,
 | 
				
			||||||
            ActivatedAt = proto.ActivatedAt?.ToInstant(),
 | 
					            ActivatedAt = proto.ActivatedAt?.ToInstant(),
 | 
				
			||||||
            IsSuperuser = proto.IsSuperuser,
 | 
					            IsSuperuser = proto.IsSuperuser,
 | 
				
			||||||
            PerkSubscription = proto.PerkSubscription is not null
 | 
					            PerkSubscription = proto.PerkSubscription is not null
 | 
				
			||||||
@@ -78,10 +85,10 @@ public class Account : ModelBase
 | 
				
			|||||||
                : null,
 | 
					                : null,
 | 
				
			||||||
            CreatedAt = proto.CreatedAt.ToInstant(),
 | 
					            CreatedAt = proto.CreatedAt.ToInstant(),
 | 
				
			||||||
            UpdatedAt = proto.UpdatedAt.ToInstant(),
 | 
					            UpdatedAt = proto.UpdatedAt.ToInstant(),
 | 
				
			||||||
 | 
					            AutomatedId = proto.AutomatedId is not null ? Guid.Parse(proto.AutomatedId) : null,
 | 
				
			||||||
 | 
					            Profile = AccountProfile.FromProtoValue(proto.Profile)
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        account.Profile = AccountProfile.FromProtoValue(proto.Profile);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        foreach (var contactProto in proto.Contacts)
 | 
					        foreach (var contactProto in proto.Contacts)
 | 
				
			||||||
            account.Contacts.Add(AccountContact.FromProtoValue(contactProto));
 | 
					            account.Contacts.Add(AccountContact.FromProtoValue(contactProto));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -116,7 +123,7 @@ public abstract class Leveling
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
public class AccountProfile : ModelBase, IIdentifiedResource
 | 
					public class AccountProfile : ModelBase, IIdentifiedResource
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    public Guid Id { get; set; }
 | 
					    public Guid Id { get; set; } = Guid.NewGuid();
 | 
				
			||||||
    [MaxLength(256)] public string? FirstName { get; set; }
 | 
					    [MaxLength(256)] public string? FirstName { get; set; }
 | 
				
			||||||
    [MaxLength(256)] public string? MiddleName { get; set; }
 | 
					    [MaxLength(256)] public string? MiddleName { get; set; }
 | 
				
			||||||
    [MaxLength(256)] public string? LastName { get; set; }
 | 
					    [MaxLength(256)] public string? LastName { get; set; }
 | 
				
			||||||
@@ -125,15 +132,27 @@ public class AccountProfile : ModelBase, IIdentifiedResource
 | 
				
			|||||||
    [MaxLength(1024)] public string? Pronouns { get; set; }
 | 
					    [MaxLength(1024)] public string? Pronouns { get; set; }
 | 
				
			||||||
    [MaxLength(1024)] public string? TimeZone { get; set; }
 | 
					    [MaxLength(1024)] public string? TimeZone { get; set; }
 | 
				
			||||||
    [MaxLength(1024)] public string? Location { get; set; }
 | 
					    [MaxLength(1024)] public string? Location { get; set; }
 | 
				
			||||||
 | 
					    [Column(TypeName = "jsonb")] public List<ProfileLink>? Links { get; set; }
 | 
				
			||||||
    public Instant? Birthday { get; set; }
 | 
					    public Instant? Birthday { get; set; }
 | 
				
			||||||
    public Instant? LastSeenAt { get; set; }
 | 
					    public Instant? LastSeenAt { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [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
 | 
				
			||||||
@@ -164,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(),
 | 
				
			||||||
@@ -194,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),
 | 
				
			||||||
@@ -207,12 +229,19 @@ public class AccountProfile : ModelBase, IIdentifiedResource
 | 
				
			|||||||
    public string ResourceIdentifier => $"account:profile:{Id}";
 | 
					    public string ResourceIdentifier => $"account:profile:{Id}";
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class ProfileLink
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public string Name { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					    public string Url { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public class AccountContact : ModelBase
 | 
					public class AccountContact : ModelBase
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    public Guid Id { get; set; }
 | 
					    public Guid Id { get; set; }
 | 
				
			||||||
    public AccountContactType Type { get; set; }
 | 
					    public AccountContactType Type { get; set; }
 | 
				
			||||||
    public Instant? VerifiedAt { get; set; }
 | 
					    public Instant? VerifiedAt { get; set; }
 | 
				
			||||||
    public bool IsPrimary { get; set; } = false;
 | 
					    public bool IsPrimary { get; set; } = false;
 | 
				
			||||||
 | 
					    public bool IsPublic { get; set; } = false;
 | 
				
			||||||
    [MaxLength(1024)] public string Content { get; set; } = string.Empty;
 | 
					    [MaxLength(1024)] public string Content { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public Guid AccountId { get; set; }
 | 
					    public Guid AccountId { get; set; }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,9 @@
 | 
				
			|||||||
using System.ComponentModel.DataAnnotations;
 | 
					using System.ComponentModel.DataAnnotations;
 | 
				
			||||||
using DysonNetwork.Pass.Auth;
 | 
					using DysonNetwork.Pass.Auth;
 | 
				
			||||||
 | 
					using DysonNetwork.Pass.Credit;
 | 
				
			||||||
using DysonNetwork.Pass.Wallet;
 | 
					using DysonNetwork.Pass.Wallet;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Error;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.GeoIp;
 | 
				
			||||||
using Microsoft.AspNetCore.Mvc;
 | 
					using Microsoft.AspNetCore.Mvc;
 | 
				
			||||||
using Microsoft.EntityFrameworkCore;
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
using NodaTime;
 | 
					using NodaTime;
 | 
				
			||||||
@@ -14,7 +17,9 @@ public class AccountController(
 | 
				
			|||||||
    AuthService auth,
 | 
					    AuthService auth,
 | 
				
			||||||
    AccountService accounts,
 | 
					    AccountService accounts,
 | 
				
			||||||
    SubscriptionService subscriptions,
 | 
					    SubscriptionService subscriptions,
 | 
				
			||||||
    AccountEventService events
 | 
					    AccountEventService events,
 | 
				
			||||||
 | 
					    SocialCreditService socialCreditService,
 | 
				
			||||||
 | 
					    GeoIpService geo
 | 
				
			||||||
) : ControllerBase
 | 
					) : ControllerBase
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    [HttpGet("{name}")]
 | 
					    [HttpGet("{name}")]
 | 
				
			||||||
@@ -25,9 +30,10 @@ public class AccountController(
 | 
				
			|||||||
        var account = await db.Accounts
 | 
					        var account = await db.Accounts
 | 
				
			||||||
            .Include(e => e.Badges)
 | 
					            .Include(e => e.Badges)
 | 
				
			||||||
            .Include(e => e.Profile)
 | 
					            .Include(e => e.Profile)
 | 
				
			||||||
 | 
					            .Include(e => e.Contacts.Where(c => c.IsPublic))
 | 
				
			||||||
            .Where(a => a.Name == name)
 | 
					            .Where(a => a.Name == name)
 | 
				
			||||||
            .FirstOrDefaultAsync();
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
        if (account is null) return new NotFoundResult();
 | 
					        if (account is null) return NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id);
 | 
					        var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id);
 | 
				
			||||||
        account.PerkSubscription = perk?.ToReference();
 | 
					        account.PerkSubscription = perk?.ToReference();
 | 
				
			||||||
@@ -44,7 +50,28 @@ public class AccountController(
 | 
				
			|||||||
            .Include(e => e.Badges)
 | 
					            .Include(e => e.Badges)
 | 
				
			||||||
            .Where(a => a.Name == name)
 | 
					            .Where(a => a.Name == name)
 | 
				
			||||||
            .FirstOrDefaultAsync();
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
        return account is null ? NotFound() : account.Badges.ToList();
 | 
					        return account is null
 | 
				
			||||||
 | 
					            ? NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier))
 | 
				
			||||||
 | 
					            : account.Badges.ToList();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("{name}/credits")]
 | 
				
			||||||
 | 
					    [ProducesResponseType<double>(StatusCodes.Status200OK)]
 | 
				
			||||||
 | 
					    [ProducesResponseType(StatusCodes.Status404NotFound)]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<double>> GetSocialCredits(string name)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var account = await db.Accounts
 | 
				
			||||||
 | 
					            .Where(a => a.Name == name)
 | 
				
			||||||
 | 
					            .Select(a => new { a.Id })
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (account is null)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var credits = await socialCreditService.GetSocialCredit(account.Id);
 | 
				
			||||||
 | 
					        return credits;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public class AccountCreateRequest
 | 
					    public class AccountCreateRequest
 | 
				
			||||||
@@ -70,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;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -80,7 +107,15 @@ public class AccountController(
 | 
				
			|||||||
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
 | 
					    [ProducesResponseType(StatusCodes.Status400BadRequest)]
 | 
				
			||||||
    public async Task<ActionResult<Account>> CreateAccount([FromBody] AccountCreateRequest request)
 | 
					    public async Task<ActionResult<Account>> CreateAccount([FromBody] AccountCreateRequest request)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        if (!await auth.ValidateCaptcha(request.CaptchaToken)) return BadRequest("Invalid captcha token.");
 | 
					        if (!await auth.ValidateCaptcha(request.CaptchaToken))
 | 
				
			||||||
 | 
					            return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                [nameof(request.CaptchaToken)] = ["Invalid captcha token."]
 | 
				
			||||||
 | 
					            }, traceId: HttpContext.TraceIdentifier));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
 | 
				
			||||||
 | 
					        if (ip is null) return BadRequest(ApiError.NotFound(request.Name, traceId: HttpContext.TraceIdentifier));
 | 
				
			||||||
 | 
					        var region = geo.GetFromIp(ip)?.Country.IsoCode ?? "us";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        try
 | 
					        try
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
@@ -89,13 +124,21 @@ public class AccountController(
 | 
				
			|||||||
                request.Nick,
 | 
					                request.Nick,
 | 
				
			||||||
                request.Email,
 | 
					                request.Email,
 | 
				
			||||||
                request.Password,
 | 
					                request.Password,
 | 
				
			||||||
                request.Language
 | 
					                request.Language,
 | 
				
			||||||
 | 
					                region
 | 
				
			||||||
            );
 | 
					            );
 | 
				
			||||||
            return Ok(account);
 | 
					            return Ok(account);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        catch (Exception ex)
 | 
					        catch (Exception ex)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            return BadRequest(ex.Message);
 | 
					            return BadRequest(new ApiError
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Code = "BAD_REQUEST",
 | 
				
			||||||
 | 
					                Message = "Failed to create account.",
 | 
				
			||||||
 | 
					                Detail = ex.Message,
 | 
				
			||||||
 | 
					                Status = 400,
 | 
				
			||||||
 | 
					                TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -108,10 +151,22 @@ public class AccountController(
 | 
				
			|||||||
    [HttpPost("recovery/password")]
 | 
					    [HttpPost("recovery/password")]
 | 
				
			||||||
    public async Task<ActionResult> RequestResetPassword([FromBody] RecoveryPasswordRequest request)
 | 
					    public async Task<ActionResult> RequestResetPassword([FromBody] RecoveryPasswordRequest request)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        if (!await auth.ValidateCaptcha(request.CaptchaToken)) return BadRequest("Invalid captcha token.");
 | 
					        if (!await auth.ValidateCaptcha(request.CaptchaToken))
 | 
				
			||||||
 | 
					            return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                [nameof(request.CaptchaToken)] = new[] { "Invalid captcha token." }
 | 
				
			||||||
 | 
					            }, traceId: HttpContext.TraceIdentifier));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var account = await accounts.LookupAccount(request.Account);
 | 
					        var account = await accounts.LookupAccount(request.Account);
 | 
				
			||||||
        if (account is null) return BadRequest("Unable to find the account.");
 | 
					        if (account is null)
 | 
				
			||||||
 | 
					            return BadRequest(new ApiError
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Code = "NOT_FOUND",
 | 
				
			||||||
 | 
					                Message = "Unable to find the account.",
 | 
				
			||||||
 | 
					                Detail = request.Account,
 | 
				
			||||||
 | 
					                Status = 400,
 | 
				
			||||||
 | 
					                TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        try
 | 
					        try
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
@@ -119,7 +174,13 @@ public class AccountController(
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
        catch (InvalidOperationException)
 | 
					        catch (InvalidOperationException)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            return BadRequest("You already requested password reset within 24 hours.");
 | 
					            return BadRequest(new ApiError
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Code = "TOO_MANY_REQUESTS",
 | 
				
			||||||
 | 
					                Message = "You already requested password reset within 24 hours.",
 | 
				
			||||||
 | 
					                Status = 400,
 | 
				
			||||||
 | 
					                TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return Ok();
 | 
					        return Ok();
 | 
				
			||||||
@@ -130,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; }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -138,7 +201,15 @@ public class AccountController(
 | 
				
			|||||||
    public async Task<ActionResult<Status>> GetOtherStatus(string name)
 | 
					    public async Task<ActionResult<Status>> GetOtherStatus(string name)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name);
 | 
					        var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name);
 | 
				
			||||||
        if (account is null) return BadRequest();
 | 
					        if (account is null)
 | 
				
			||||||
 | 
					            return BadRequest(new ApiError
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Code = "NOT_FOUND",
 | 
				
			||||||
 | 
					                Message = "Account not found.",
 | 
				
			||||||
 | 
					                Detail = name,
 | 
				
			||||||
 | 
					                Status = 400,
 | 
				
			||||||
 | 
					                TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
        var status = await events.GetStatus(account.Id);
 | 
					        var status = await events.GetStatus(account.Id);
 | 
				
			||||||
        status.IsInvisible = false; // Keep the invisible field not available for other users
 | 
					        status.IsInvisible = false; // Keep the invisible field not available for other users
 | 
				
			||||||
        return Ok(status);
 | 
					        return Ok(status);
 | 
				
			||||||
@@ -155,11 +226,27 @@ public class AccountController(
 | 
				
			|||||||
        month ??= currentDate.Month;
 | 
					        month ??= currentDate.Month;
 | 
				
			||||||
        year ??= currentDate.Year;
 | 
					        year ??= currentDate.Year;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (month is < 1 or > 12) return BadRequest("Invalid month.");
 | 
					        if (month is < 1 or > 12)
 | 
				
			||||||
        if (year < 1) return BadRequest("Invalid year.");
 | 
					            return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                [nameof(month)] = new[] { "Month must be between 1 and 12." }
 | 
				
			||||||
 | 
					            }, traceId: HttpContext.TraceIdentifier));
 | 
				
			||||||
 | 
					        if (year < 1)
 | 
				
			||||||
 | 
					            return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                [nameof(year)] = new[] { "Year must be a positive integer." }
 | 
				
			||||||
 | 
					            }, traceId: HttpContext.TraceIdentifier));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name);
 | 
					        var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name);
 | 
				
			||||||
        if (account is null) return BadRequest();
 | 
					        if (account is null)
 | 
				
			||||||
 | 
					            return BadRequest(new ApiError
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Code = "not_found",
 | 
				
			||||||
 | 
					                Message = "Account not found.",
 | 
				
			||||||
 | 
					                Detail = name,
 | 
				
			||||||
 | 
					                Status = 400,
 | 
				
			||||||
 | 
					                TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var calendar = await events.GetEventCalendar(account, month.Value, year.Value, replaceInvisible: true);
 | 
					        var calendar = await events.GetEventCalendar(account, month.Value, year.Value, replaceInvisible: true);
 | 
				
			||||||
        return Ok(calendar);
 | 
					        return Ok(calendar);
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,7 +1,9 @@
 | 
				
			|||||||
using System.ComponentModel.DataAnnotations;
 | 
					using System.ComponentModel.DataAnnotations;
 | 
				
			||||||
 | 
					using DysonNetwork.Pass.Auth;
 | 
				
			||||||
using DysonNetwork.Pass.Permission;
 | 
					using DysonNetwork.Pass.Permission;
 | 
				
			||||||
using DysonNetwork.Pass.Wallet;
 | 
					using DysonNetwork.Pass.Wallet;
 | 
				
			||||||
using DysonNetwork.Shared.Data;
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Error;
 | 
				
			||||||
using DysonNetwork.Shared.Proto;
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
using Microsoft.AspNetCore.Authorization;
 | 
					using Microsoft.AspNetCore.Authorization;
 | 
				
			||||||
using Microsoft.AspNetCore.Mvc;
 | 
					using Microsoft.AspNetCore.Mvc;
 | 
				
			||||||
@@ -9,7 +11,6 @@ using Microsoft.EntityFrameworkCore;
 | 
				
			|||||||
using NodaTime;
 | 
					using NodaTime;
 | 
				
			||||||
using AuthService = DysonNetwork.Pass.Auth.AuthService;
 | 
					using AuthService = DysonNetwork.Pass.Auth.AuthService;
 | 
				
			||||||
using AuthSession = DysonNetwork.Pass.Auth.AuthSession;
 | 
					using AuthSession = DysonNetwork.Pass.Auth.AuthSession;
 | 
				
			||||||
using ChallengePlatform = DysonNetwork.Pass.Auth.ChallengePlatform;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace DysonNetwork.Pass.Account;
 | 
					namespace DysonNetwork.Pass.Account;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -23,11 +24,13 @@ public class AccountCurrentController(
 | 
				
			|||||||
    AccountEventService events,
 | 
					    AccountEventService events,
 | 
				
			||||||
    AuthService auth,
 | 
					    AuthService auth,
 | 
				
			||||||
    FileService.FileServiceClient files,
 | 
					    FileService.FileServiceClient files,
 | 
				
			||||||
    FileReferenceService.FileReferenceServiceClient fileRefs
 | 
					    FileReferenceService.FileReferenceServiceClient fileRefs,
 | 
				
			||||||
 | 
					    Credit.SocialCreditService creditService
 | 
				
			||||||
) : ControllerBase
 | 
					) : ControllerBase
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    [HttpGet]
 | 
					    [HttpGet]
 | 
				
			||||||
    [ProducesResponseType<Account>(StatusCodes.Status200OK)]
 | 
					    [ProducesResponseType<Account>(StatusCodes.Status200OK)]
 | 
				
			||||||
 | 
					    [ProducesResponseType<ApiError>(StatusCodes.Status401Unauthorized)]
 | 
				
			||||||
    public async Task<ActionResult<Account>> GetCurrentIdentity()
 | 
					    public async Task<ActionResult<Account>> GetCurrentIdentity()
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
				
			||||||
@@ -49,6 +52,7 @@ public class AccountCurrentController(
 | 
				
			|||||||
    {
 | 
					    {
 | 
				
			||||||
        [MaxLength(256)] public string? Nick { get; set; }
 | 
					        [MaxLength(256)] public string? Nick { get; set; }
 | 
				
			||||||
        [MaxLength(32)] public string? Language { get; set; }
 | 
					        [MaxLength(32)] public string? Language { get; set; }
 | 
				
			||||||
 | 
					        [MaxLength(32)] public string? Region { get; set; }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [HttpPatch]
 | 
					    [HttpPatch]
 | 
				
			||||||
@@ -60,6 +64,7 @@ public class AccountCurrentController(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        if (request.Nick is not null) account.Nick = request.Nick;
 | 
					        if (request.Nick is not null) account.Nick = request.Nick;
 | 
				
			||||||
        if (request.Language is not null) account.Language = request.Language;
 | 
					        if (request.Language is not null) account.Language = request.Language;
 | 
				
			||||||
 | 
					        if (request.Region is not null) account.Region = request.Region;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        await db.SaveChangesAsync();
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
        await accounts.PurgeAccountCache(currentUser);
 | 
					        await accounts.PurgeAccountCache(currentUser);
 | 
				
			||||||
@@ -77,6 +82,7 @@ public class AccountCurrentController(
 | 
				
			|||||||
        [MaxLength(1024)] public string? Location { get; set; }
 | 
					        [MaxLength(1024)] public string? Location { get; set; }
 | 
				
			||||||
        [MaxLength(4096)] public string? Bio { get; set; }
 | 
					        [MaxLength(4096)] public string? Bio { get; set; }
 | 
				
			||||||
        public Instant? Birthday { get; set; }
 | 
					        public Instant? Birthday { get; set; }
 | 
				
			||||||
 | 
					        public List<ProfileLink>? Links { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        [MaxLength(32)] public string? PictureId { get; set; }
 | 
					        [MaxLength(32)] public string? PictureId { get; set; }
 | 
				
			||||||
        [MaxLength(32)] public string? BackgroundId { get; set; }
 | 
					        [MaxLength(32)] public string? BackgroundId { get; set; }
 | 
				
			||||||
@@ -91,7 +97,14 @@ public class AccountCurrentController(
 | 
				
			|||||||
        var profile = await db.AccountProfiles
 | 
					        var profile = await db.AccountProfiles
 | 
				
			||||||
            .Where(p => p.Account.Id == userId)
 | 
					            .Where(p => p.Account.Id == userId)
 | 
				
			||||||
            .FirstOrDefaultAsync();
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
        if (profile is null) return BadRequest("Unable to get your account.");
 | 
					        if (profile is null)
 | 
				
			||||||
 | 
					            return BadRequest(new ApiError
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Code = "NOT_FOUND",
 | 
				
			||||||
 | 
					                Message = "Unable to get your account.",
 | 
				
			||||||
 | 
					                Status = 400,
 | 
				
			||||||
 | 
					                TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (request.FirstName is not null) profile.FirstName = request.FirstName;
 | 
					        if (request.FirstName is not null) profile.FirstName = request.FirstName;
 | 
				
			||||||
        if (request.MiddleName is not null) profile.MiddleName = request.MiddleName;
 | 
					        if (request.MiddleName is not null) profile.MiddleName = request.MiddleName;
 | 
				
			||||||
@@ -102,6 +115,7 @@ public class AccountCurrentController(
 | 
				
			|||||||
        if (request.Birthday is not null) profile.Birthday = request.Birthday;
 | 
					        if (request.Birthday is not null) profile.Birthday = request.Birthday;
 | 
				
			||||||
        if (request.Location is not null) profile.Location = request.Location;
 | 
					        if (request.Location is not null) profile.Location = request.Location;
 | 
				
			||||||
        if (request.TimeZone is not null) profile.TimeZone = request.TimeZone;
 | 
					        if (request.TimeZone is not null) profile.TimeZone = request.TimeZone;
 | 
				
			||||||
 | 
					        if (request.Links is not null) profile.Links = request.Links;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (request.PictureId is not null)
 | 
					        if (request.PictureId is not null)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
@@ -158,7 +172,13 @@ public class AccountCurrentController(
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
        catch (InvalidOperationException)
 | 
					        catch (InvalidOperationException)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            return BadRequest("You already requested account deletion within 24 hours.");
 | 
					            return BadRequest(new ApiError
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Code = "TOO_MANY_REQUESTS",
 | 
				
			||||||
 | 
					                Message = "You already requested account deletion within 24 hours.",
 | 
				
			||||||
 | 
					                Status = 400,
 | 
				
			||||||
 | 
					                TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return Ok();
 | 
					        return Ok();
 | 
				
			||||||
@@ -177,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
 | 
				
			||||||
@@ -184,12 +206,16 @@ public class AccountCurrentController(
 | 
				
			|||||||
            .Where(e => e.ClearedAt == null || e.ClearedAt > now)
 | 
					            .Where(e => e.ClearedAt == null || e.ClearedAt > now)
 | 
				
			||||||
            .OrderByDescending(e => e.CreatedAt)
 | 
					            .OrderByDescending(e => e.CreatedAt)
 | 
				
			||||||
            .FirstOrDefaultAsync();
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
        if (status is null) return NotFound();
 | 
					        if (status is null) return NotFound(ApiError.NotFound("status", traceId: HttpContext.TraceIdentifier));
 | 
				
			||||||
 | 
					        if (status.IsAutomated && request.AppIdentifier is null)
 | 
				
			||||||
 | 
					            return BadRequest("Automated status cannot be updated.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        status.Attitude = request.Attitude;
 | 
					        status.Attitude = request.Attitude;
 | 
				
			||||||
        status.IsInvisible = request.IsInvisible;
 | 
					        status.IsInvisible = request.IsInvisible;
 | 
				
			||||||
        status.IsNotDisturb = request.IsNotDisturb;
 | 
					        status.IsNotDisturb = request.IsNotDisturb;
 | 
				
			||||||
 | 
					        status.IsAutomated = request.IsAutomated;
 | 
				
			||||||
        status.Label = request.Label;
 | 
					        status.Label = request.Label;
 | 
				
			||||||
 | 
					        status.AppIdentifier = request.AppIdentifier;
 | 
				
			||||||
        status.ClearedAt = request.ClearedAt;
 | 
					        status.ClearedAt = request.ClearedAt;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        db.Update(status);
 | 
					        db.Update(status);
 | 
				
			||||||
@@ -205,29 +231,66 @@ 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
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return await events.CreateStatus(currentUser, status);
 | 
					        return await events.CreateStatus(currentUser, status);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [HttpDelete("me/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();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -252,7 +315,9 @@ public class AccountCurrentController(
 | 
				
			|||||||
            .OrderByDescending(x => x.CreatedAt)
 | 
					            .OrderByDescending(x => x.CreatedAt)
 | 
				
			||||||
            .FirstOrDefaultAsync();
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return result is null ? NotFound() : Ok(result);
 | 
					        return result is null
 | 
				
			||||||
 | 
					            ? NotFound(ApiError.NotFound("check-in", traceId: HttpContext.TraceIdentifier))
 | 
				
			||||||
 | 
					            : Ok(result);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [HttpPost("check-in")]
 | 
					    [HttpPost("check-in")]
 | 
				
			||||||
@@ -267,15 +332,30 @@ public class AccountCurrentController(
 | 
				
			|||||||
        {
 | 
					        {
 | 
				
			||||||
            var isAvailable = await events.CheckInDailyIsAvailable(currentUser);
 | 
					            var isAvailable = await events.CheckInDailyIsAvailable(currentUser);
 | 
				
			||||||
            if (!isAvailable)
 | 
					            if (!isAvailable)
 | 
				
			||||||
                return BadRequest("Check-in is not available for today.");
 | 
					                return BadRequest(new ApiError
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    Code = "BAD_REQUEST",
 | 
				
			||||||
 | 
					                    Message = "Check-in is not available for today.",
 | 
				
			||||||
 | 
					                    Status = 400,
 | 
				
			||||||
 | 
					                    TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        else
 | 
					        else
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            if (currentUser.PerkSubscription is null)
 | 
					            if (currentUser.PerkSubscription is null)
 | 
				
			||||||
                return StatusCode(403, "You need to have a subscription to check-in backdated.");
 | 
					                return StatusCode(403, ApiError.Unauthorized(
 | 
				
			||||||
 | 
					                    message: "You need to have a subscription to check-in backdated.",
 | 
				
			||||||
 | 
					                    forbidden: true,
 | 
				
			||||||
 | 
					                    traceId: HttpContext.TraceIdentifier));
 | 
				
			||||||
            var isAvailable = await events.CheckInBackdatedIsAvailable(currentUser, backdated.Value);
 | 
					            var isAvailable = await events.CheckInBackdatedIsAvailable(currentUser, backdated.Value);
 | 
				
			||||||
            if (!isAvailable)
 | 
					            if (!isAvailable)
 | 
				
			||||||
                return BadRequest("Check-in is not available for this date.");
 | 
					                return BadRequest(new ApiError
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    Code = "BAD_REQUEST",
 | 
				
			||||||
 | 
					                    Message = "Check-in is not available for this date.",
 | 
				
			||||||
 | 
					                    Status = 400,
 | 
				
			||||||
 | 
					                    TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        try
 | 
					        try
 | 
				
			||||||
@@ -284,15 +364,32 @@ public class AccountCurrentController(
 | 
				
			|||||||
            return needsCaptcha switch
 | 
					            return needsCaptcha switch
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                true when string.IsNullOrWhiteSpace(captchaToken) => StatusCode(423,
 | 
					                true when string.IsNullOrWhiteSpace(captchaToken) => StatusCode(423,
 | 
				
			||||||
                    "Captcha is required for this check-in."
 | 
					                    new ApiError
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        Code = "CAPTCHA_REQUIRED",
 | 
				
			||||||
 | 
					                        Message = "Captcha is required for this check-in.",
 | 
				
			||||||
 | 
					                        Status = 423,
 | 
				
			||||||
 | 
					                        TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
                true when !await auth.ValidateCaptcha(captchaToken!) => BadRequest("Invalid captcha token."),
 | 
					                true when !await auth.ValidateCaptcha(captchaToken!) => BadRequest(ApiError.Validation(
 | 
				
			||||||
 | 
					                    new Dictionary<string, string[]>
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        ["captchaToken"] = new[] { "Invalid captcha token." }
 | 
				
			||||||
 | 
					                    }, traceId: HttpContext.TraceIdentifier)),
 | 
				
			||||||
                _ => await events.CheckInDaily(currentUser, backdated)
 | 
					                _ => await events.CheckInDaily(currentUser, backdated)
 | 
				
			||||||
            };
 | 
					            };
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        catch (InvalidOperationException ex)
 | 
					        catch (InvalidOperationException ex)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            return BadRequest(ex.Message);
 | 
					            return BadRequest(new ApiError
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Code = "BAD_REQUEST",
 | 
				
			||||||
 | 
					                Message = "Check-in failed.",
 | 
				
			||||||
 | 
					                Detail = ex.Message,
 | 
				
			||||||
 | 
					                Status = 400,
 | 
				
			||||||
 | 
					                TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -306,8 +403,16 @@ public class AccountCurrentController(
 | 
				
			|||||||
        month ??= currentDate.Month;
 | 
					        month ??= currentDate.Month;
 | 
				
			||||||
        year ??= currentDate.Year;
 | 
					        year ??= currentDate.Year;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (month is < 1 or > 12) return BadRequest("Invalid month.");
 | 
					        if (month is < 1 or > 12)
 | 
				
			||||||
        if (year < 1) return BadRequest("Invalid year.");
 | 
					            return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                [nameof(month)] = new[] { "Month must be between 1 and 12." }
 | 
				
			||||||
 | 
					            }, traceId: HttpContext.TraceIdentifier));
 | 
				
			||||||
 | 
					        if (year < 1)
 | 
				
			||||||
 | 
					            return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                [nameof(year)] = new[] { "Year must be a positive integer." }
 | 
				
			||||||
 | 
					            }, traceId: HttpContext.TraceIdentifier));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var calendar = await events.GetEventCalendar(currentUser, month.Value, year.Value);
 | 
					        var calendar = await events.GetEventCalendar(currentUser, month.Value, year.Value);
 | 
				
			||||||
        return Ok(calendar);
 | 
					        return Ok(calendar);
 | 
				
			||||||
@@ -363,7 +468,13 @@ public class AccountCurrentController(
 | 
				
			|||||||
    {
 | 
					    {
 | 
				
			||||||
        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
				
			||||||
        if (await accounts.CheckAuthFactorExists(currentUser, request.Type))
 | 
					        if (await accounts.CheckAuthFactorExists(currentUser, request.Type))
 | 
				
			||||||
            return BadRequest($"Auth factor with type {request.Type} is already exists.");
 | 
					            return BadRequest(new ApiError
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Code = "ALREADY_EXISTS",
 | 
				
			||||||
 | 
					                Message = $"Auth factor with type {request.Type} already exists.",
 | 
				
			||||||
 | 
					                Status = 400,
 | 
				
			||||||
 | 
					                TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var factor = await accounts.CreateAuthFactor(currentUser, request.Type, request.Secret);
 | 
					        var factor = await accounts.CreateAuthFactor(currentUser, request.Type, request.Secret);
 | 
				
			||||||
        return Ok(factor);
 | 
					        return Ok(factor);
 | 
				
			||||||
@@ -378,7 +489,7 @@ public class AccountCurrentController(
 | 
				
			|||||||
        var factor = await db.AccountAuthFactors
 | 
					        var factor = await db.AccountAuthFactors
 | 
				
			||||||
            .Where(f => f.AccountId == currentUser.Id && f.Id == id)
 | 
					            .Where(f => f.AccountId == currentUser.Id && f.Id == id)
 | 
				
			||||||
            .FirstOrDefaultAsync();
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
        if (factor is null) return NotFound();
 | 
					        if (factor is null) return NotFound(ApiError.NotFound(id.ToString(), traceId: HttpContext.TraceIdentifier));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        try
 | 
					        try
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
@@ -387,7 +498,14 @@ public class AccountCurrentController(
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
        catch (Exception ex)
 | 
					        catch (Exception ex)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            return BadRequest(ex.Message);
 | 
					            return BadRequest(new ApiError
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Code = "BAD_REQUEST",
 | 
				
			||||||
 | 
					                Message = "Failed to enable auth factor.",
 | 
				
			||||||
 | 
					                Detail = ex.Message,
 | 
				
			||||||
 | 
					                Status = 400,
 | 
				
			||||||
 | 
					                TraceId = HttpContext.TraceIdentifier
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -435,45 +553,31 @@ public class AccountCurrentController(
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public class AuthorizedDevice
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        public string? Label { get; set; }
 | 
					 | 
				
			||||||
        public string UserAgent { get; set; } = null!;
 | 
					 | 
				
			||||||
        public string DeviceId { get; set; } = null!;
 | 
					 | 
				
			||||||
        public ChallengePlatform Platform { get; set; }
 | 
					 | 
				
			||||||
        public List<AuthSession> Sessions { get; set; } = [];
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    [HttpGet("devices")]
 | 
					    [HttpGet("devices")]
 | 
				
			||||||
    [Authorize]
 | 
					    [Authorize]
 | 
				
			||||||
    public async Task<ActionResult<List<AuthorizedDevice>>> GetDevices()
 | 
					    public async Task<ActionResult<List<AuthClientWithChallenge>>> GetDevices()
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
 | 
				
			||||||
            HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
 | 
					            HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
 | 
					        Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Group sessions by the related DeviceId, then create an AuthorizedDevice for each group.
 | 
					        var devices = await db.AuthClients
 | 
				
			||||||
        var deviceGroups = await db.AuthSessions
 | 
					            .Where(device => device.AccountId == currentUser.Id)
 | 
				
			||||||
            .Where(s => s.Account.Id == currentUser.Id)
 | 
					 | 
				
			||||||
            .Include(s => s.Challenge)
 | 
					 | 
				
			||||||
            .GroupBy(s => s.Challenge.DeviceId!)
 | 
					 | 
				
			||||||
            .Select(g => new AuthorizedDevice
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                DeviceId = g.Key!,
 | 
					 | 
				
			||||||
                UserAgent = g.First(x => x.Challenge.UserAgent != null).Challenge.UserAgent!,
 | 
					 | 
				
			||||||
                Platform = g.First().Challenge.Platform!,
 | 
					 | 
				
			||||||
                Label = g.Where(x => !string.IsNullOrWhiteSpace(x.Label)).Select(x => x.Label).FirstOrDefault(),
 | 
					 | 
				
			||||||
                Sessions = g
 | 
					 | 
				
			||||||
                    .OrderByDescending(x => x.LastGrantedAt)
 | 
					 | 
				
			||||||
                    .ToList()
 | 
					 | 
				
			||||||
            })
 | 
					 | 
				
			||||||
            .ToListAsync();
 | 
					            .ToListAsync();
 | 
				
			||||||
        deviceGroups = deviceGroups
 | 
					 | 
				
			||||||
            .OrderByDescending(s => s.Sessions.First().LastGrantedAt)
 | 
					 | 
				
			||||||
            .ToList();
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return Ok(deviceGroups);
 | 
					        var challengeDevices = devices.Select(AuthClientWithChallenge.FromClient).ToList();
 | 
				
			||||||
 | 
					        var deviceIds = challengeDevices.Select(x => x.Id).ToList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var authChallenges = await db.AuthChallenges
 | 
				
			||||||
 | 
					            .Where(c => c.ClientId != null && deviceIds.Contains(c.ClientId.Value))
 | 
				
			||||||
 | 
					            .GroupBy(c => c.ClientId)
 | 
				
			||||||
 | 
					            .ToDictionaryAsync(c => c.Key!.Value, c => c.ToList());
 | 
				
			||||||
 | 
					        foreach (var challengeDevice in challengeDevices)
 | 
				
			||||||
 | 
					            if (authChallenges.TryGetValue(challengeDevice.Id, out var challenge))
 | 
				
			||||||
 | 
					                challengeDevice.Challenges = challenge;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return Ok(challengeDevices);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [HttpGet("sessions")]
 | 
					    [HttpGet("sessions")]
 | 
				
			||||||
@@ -521,6 +625,23 @@ public class AccountCurrentController(
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpDelete("devices/{deviceId}")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<AuthSession>> DeleteDevice(string deviceId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            await accounts.DeleteDevice(currentUser, deviceId);
 | 
				
			||||||
 | 
					            return NoContent();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(ex.Message);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [HttpDelete("sessions/current")]
 | 
					    [HttpDelete("sessions/current")]
 | 
				
			||||||
    [Authorize]
 | 
					    [Authorize]
 | 
				
			||||||
    public async Task<ActionResult<AuthSession>> DeleteCurrentSession()
 | 
					    public async Task<ActionResult<AuthSession>> DeleteCurrentSession()
 | 
				
			||||||
@@ -539,14 +660,15 @@ public class AccountCurrentController(
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [HttpPatch("sessions/{id:guid}/label")]
 | 
					    [HttpPatch("devices/{deviceId}/label")]
 | 
				
			||||||
    public async Task<ActionResult<AuthSession>> UpdateSessionLabel(Guid id, [FromBody] string label)
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<AuthSession>> UpdateDeviceLabel(string deviceId, [FromBody] string label)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        try
 | 
					        try
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            await accounts.UpdateSessionLabel(currentUser, id, label);
 | 
					            await accounts.UpdateDeviceName(currentUser, deviceId, label);
 | 
				
			||||||
            return NoContent();
 | 
					            return NoContent();
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        catch (Exception ex)
 | 
					        catch (Exception ex)
 | 
				
			||||||
@@ -555,15 +677,19 @@ public class AccountCurrentController(
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [HttpPatch("sessions/current/label")]
 | 
					    [HttpPatch("devices/current/label")]
 | 
				
			||||||
    public async Task<ActionResult<AuthSession>> UpdateCurrentSessionLabel([FromBody] string label)
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<AuthSession>> UpdateCurrentDeviceLabel([FromBody] string label)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
 | 
				
			||||||
            HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
 | 
					            HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var device = await db.AuthClients.FirstOrDefaultAsync(d => d.Id == currentSession.Challenge.ClientId);
 | 
				
			||||||
 | 
					        if (device is null) return NotFound();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        try
 | 
					        try
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            await accounts.UpdateSessionLabel(currentUser, currentSession.Id, label);
 | 
					            await accounts.UpdateDeviceName(currentUser, device.DeviceId, label);
 | 
				
			||||||
            return NoContent();
 | 
					            return NoContent();
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        catch (Exception ex)
 | 
					        catch (Exception ex)
 | 
				
			||||||
@@ -652,6 +778,50 @@ public class AccountCurrentController(
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost("contacts/{id:guid}/public")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<AccountContact>> SetPublicContact(Guid id)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var contact = await db.AccountContacts
 | 
				
			||||||
 | 
					            .Where(c => c.AccountId == currentUser.Id && c.Id == id)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					        if (contact is null) return NotFound();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            contact = await accounts.SetContactMethodPublic(currentUser, contact, true);
 | 
				
			||||||
 | 
					            return Ok(contact);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(ex.Message);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpDelete("contacts/{id:guid}/public")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<ActionResult<AccountContact>> UnsetPublicContact(Guid id)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var contact = await db.AccountContacts
 | 
				
			||||||
 | 
					            .Where(c => c.AccountId == currentUser.Id && c.Id == id)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					        if (contact is null) return NotFound();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            contact = await accounts.SetContactMethodPublic(currentUser, contact, false);
 | 
				
			||||||
 | 
					            return Ok(contact);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return BadRequest(ex.Message);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [HttpDelete("contacts/{id:guid}")]
 | 
					    [HttpDelete("contacts/{id:guid}")]
 | 
				
			||||||
    [Authorize]
 | 
					    [Authorize]
 | 
				
			||||||
    public async Task<ActionResult<AccountContact>> DeleteContact(Guid id)
 | 
					    public async Task<ActionResult<AccountContact>> DeleteContact(Guid id)
 | 
				
			||||||
@@ -703,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,7 +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,
 | 
				
			||||||
 | 
					    Pass.Leveling.ExperienceService experienceService
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    private static readonly Random Random = new();
 | 
					    private static readonly Random Random = new();
 | 
				
			||||||
@@ -172,6 +174,9 @@ public class AccountEventService(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    public async Task<bool> CheckInDailyDoAskCaptcha(Account user)
 | 
					    public async Task<bool> CheckInDailyDoAskCaptcha(Account user)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
 | 
					        var perkSubscription = await subscriptions.GetPerkSubscriptionAsync(user.Id);
 | 
				
			||||||
 | 
					        if (perkSubscription is not null) return false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var cacheKey = $"{CaptchaCacheKey}{user.Id}";
 | 
					        var cacheKey = $"{CaptchaCacheKey}{user.Id}";
 | 
				
			||||||
        var needsCaptcha = await cache.GetAsync<bool?>(cacheKey);
 | 
					        var needsCaptcha = await cache.GetAsync<bool?>(cacheKey);
 | 
				
			||||||
        if (needsCaptcha is not null)
 | 
					        if (needsCaptcha is not null)
 | 
				
			||||||
@@ -323,13 +328,15 @@ public class AccountEventService(
 | 
				
			|||||||
            result.RewardPoints = null;
 | 
					            result.RewardPoints = null;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        await db.AccountProfiles
 | 
					 | 
				
			||||||
            .Where(p => p.AccountId == user.Id)
 | 
					 | 
				
			||||||
            .ExecuteUpdateAsync(s =>
 | 
					 | 
				
			||||||
                s.SetProperty(b => b.Experience, b => b.Experience + result.RewardExperience)
 | 
					 | 
				
			||||||
            );
 | 
					 | 
				
			||||||
        db.AccountCheckInResults.Add(result);
 | 
					        db.AccountCheckInResults.Add(result);
 | 
				
			||||||
        await db.SaveChangesAsync(); // Don't forget to save changes to the database
 | 
					        await db.SaveChangesAsync(); // Remember to save changes to the database
 | 
				
			||||||
 | 
					        if (result.RewardExperience is not null)
 | 
				
			||||||
 | 
					            await experienceService.AddRecord(
 | 
				
			||||||
 | 
					                "check-in",
 | 
				
			||||||
 | 
					                $"Check-in reward on {now:yyyy/MM/dd}",
 | 
				
			||||||
 | 
					                result.RewardExperience.Value,
 | 
				
			||||||
 | 
					                user.Id
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // The lock will be automatically released by the await using statement
 | 
					        // The lock will be automatically released by the await using statement
 | 
				
			||||||
        return result;
 | 
					        return result;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,29 +1,39 @@
 | 
				
			|||||||
using System.Globalization;
 | 
					using System.Globalization;
 | 
				
			||||||
 | 
					using System.Text.Json;
 | 
				
			||||||
using DysonNetwork.Pass.Auth;
 | 
					using DysonNetwork.Pass.Auth;
 | 
				
			||||||
using DysonNetwork.Pass.Auth.OpenId;
 | 
					using DysonNetwork.Pass.Auth.OpenId;
 | 
				
			||||||
using DysonNetwork.Pass.Email;
 | 
					using DysonNetwork.Pass.Email;
 | 
				
			||||||
using DysonNetwork.Pass.Localization;
 | 
					using DysonNetwork.Pass.Localization;
 | 
				
			||||||
using DysonNetwork.Pass.Permission;
 | 
					using DysonNetwork.Pass.Permission;
 | 
				
			||||||
using DysonNetwork.Shared.Cache;
 | 
					using DysonNetwork.Shared.Cache;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
using DysonNetwork.Shared.Proto;
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Stream;
 | 
				
			||||||
using EFCore.BulkExtensions;
 | 
					using EFCore.BulkExtensions;
 | 
				
			||||||
using Microsoft.EntityFrameworkCore;
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
using Microsoft.Extensions.Localization;
 | 
					using Microsoft.Extensions.Localization;
 | 
				
			||||||
 | 
					using NATS.Client.Core;
 | 
				
			||||||
 | 
					using NATS.Client.JetStream;
 | 
				
			||||||
 | 
					using NATS.Net;
 | 
				
			||||||
using NodaTime;
 | 
					using NodaTime;
 | 
				
			||||||
using OtpNet;
 | 
					using OtpNet;
 | 
				
			||||||
using AuthSession = DysonNetwork.Pass.Auth.AuthSession;
 | 
					using AuthService = DysonNetwork.Pass.Auth.AuthService;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace DysonNetwork.Pass.Account;
 | 
					namespace DysonNetwork.Pass.Account;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public class AccountService(
 | 
					public class AccountService(
 | 
				
			||||||
    AppDatabase db,
 | 
					    AppDatabase db,
 | 
				
			||||||
    MagicSpellService spells,
 | 
					    MagicSpellService spells,
 | 
				
			||||||
 | 
					    FileService.FileServiceClient files,
 | 
				
			||||||
 | 
					    FileReferenceService.FileReferenceServiceClient fileRefs,
 | 
				
			||||||
    AccountUsernameService uname,
 | 
					    AccountUsernameService uname,
 | 
				
			||||||
    EmailService mailer,
 | 
					    EmailService mailer,
 | 
				
			||||||
    PusherService.PusherServiceClient pusher,
 | 
					    RingService.RingServiceClient pusher,
 | 
				
			||||||
    IStringLocalizer<NotificationResource> localizer,
 | 
					    IStringLocalizer<NotificationResource> localizer,
 | 
				
			||||||
 | 
					    IStringLocalizer<EmailResource> emailLocalizer,
 | 
				
			||||||
    ICacheService cache,
 | 
					    ICacheService cache,
 | 
				
			||||||
    ILogger<AccountService> logger
 | 
					    ILogger<AccountService> logger,
 | 
				
			||||||
 | 
					    INatsConnection nats
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    public static void SetCultureInfo(Account account)
 | 
					    public static void SetCultureInfo(Account account)
 | 
				
			||||||
@@ -80,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
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
@@ -88,11 +99,18 @@ public class AccountService(
 | 
				
			|||||||
        if (dupeNameCount > 0)
 | 
					        if (dupeNameCount > 0)
 | 
				
			||||||
            throw new InvalidOperationException("Account name has already been taken.");
 | 
					            throw new InvalidOperationException("Account name has already been taken.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var dupeEmailCount = await db.AccountContacts
 | 
				
			||||||
 | 
					            .Where(c => c.Content == email && c.Type == AccountContactType.Email
 | 
				
			||||||
 | 
					            ).CountAsync();
 | 
				
			||||||
 | 
					        if (dupeEmailCount > 0)
 | 
				
			||||||
 | 
					            throw new InvalidOperationException("Account email has already been used.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var account = new Account
 | 
					        var account = new Account
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            Name = name,
 | 
					            Name = name,
 | 
				
			||||||
            Nick = nick,
 | 
					            Nick = nick,
 | 
				
			||||||
            Language = language,
 | 
					            Language = language,
 | 
				
			||||||
 | 
					            Region = region,
 | 
				
			||||||
            Contacts = new List<AccountContact>
 | 
					            Contacts = new List<AccountContact>
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                new()
 | 
					                new()
 | 
				
			||||||
@@ -167,11 +185,66 @@ public class AccountService(
 | 
				
			|||||||
            userInfo.Email,
 | 
					            userInfo.Email,
 | 
				
			||||||
            null,
 | 
					            null,
 | 
				
			||||||
            "en-US",
 | 
					            "en-US",
 | 
				
			||||||
 | 
					            "en",
 | 
				
			||||||
            userInfo.EmailVerified,
 | 
					            userInfo.EmailVerified,
 | 
				
			||||||
            userInfo.EmailVerified
 | 
					            userInfo.EmailVerified
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<Account> CreateBotAccount(Account account, Guid automatedId, string? pictureId,
 | 
				
			||||||
 | 
					        string? backgroundId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var dupeAutomateCount = await db.Accounts.Where(a => a.AutomatedId == automatedId).CountAsync();
 | 
				
			||||||
 | 
					        if (dupeAutomateCount > 0)
 | 
				
			||||||
 | 
					            throw new InvalidOperationException("Automated ID has already been used.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var dupeNameCount = await db.Accounts.Where(a => a.Name == account.Name).CountAsync();
 | 
				
			||||||
 | 
					        if (dupeNameCount > 0)
 | 
				
			||||||
 | 
					            throw new InvalidOperationException("Account name has already been taken.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        account.AutomatedId = automatedId;
 | 
				
			||||||
 | 
					        account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
 | 
				
			||||||
 | 
					        account.IsSuperuser = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!string.IsNullOrEmpty(pictureId))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var file = await files.GetFileAsync(new GetFileRequest { Id = pictureId });
 | 
				
			||||||
 | 
					            await fileRefs.CreateReferenceAsync(
 | 
				
			||||||
 | 
					                new CreateReferenceRequest
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    ResourceId = account.Profile.ResourceIdentifier,
 | 
				
			||||||
 | 
					                    FileId = pictureId,
 | 
				
			||||||
 | 
					                    Usage = "profile.picture"
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					            account.Profile.Picture = CloudFileReferenceObject.FromProtoValue(file);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!string.IsNullOrEmpty(backgroundId))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var file = await files.GetFileAsync(new GetFileRequest { Id = backgroundId });
 | 
				
			||||||
 | 
					            await fileRefs.CreateReferenceAsync(
 | 
				
			||||||
 | 
					                new CreateReferenceRequest
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    ResourceId = account.Profile.ResourceIdentifier,
 | 
				
			||||||
 | 
					                    FileId = backgroundId,
 | 
				
			||||||
 | 
					                    Usage = "profile.background"
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					            account.Profile.Background = CloudFileReferenceObject.FromProtoValue(file);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        db.Accounts.Add(account);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return account;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<Account?> GetBotAccount(Guid automatedId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return await db.Accounts.FirstOrDefaultAsync(a => a.AutomatedId == automatedId);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async Task RequestAccountDeletion(Account account)
 | 
					    public async Task RequestAccountDeletion(Account account)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var spell = await spells.CreateMagicSpell(
 | 
					        var spell = await spells.CreateMagicSpell(
 | 
				
			||||||
@@ -330,12 +403,10 @@ public class AccountService(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    /// <summary>
 | 
					    /// <summary>
 | 
				
			||||||
    /// Send the auth factor verification code to users, for factors like in-app code and email.
 | 
					    /// Send the auth factor verification code to users, for factors like in-app code and email.
 | 
				
			||||||
    /// Sometimes it requires a hint, like a part of the user's email address to ensure the user is who own the account.
 | 
					 | 
				
			||||||
    /// </summary>
 | 
					    /// </summary>
 | 
				
			||||||
    /// <param name="account">The owner of the auth factor</param>
 | 
					    /// <param name="account">The owner of the auth factor</param>
 | 
				
			||||||
    /// <param name="factor">The auth factor needed to send code</param>
 | 
					    /// <param name="factor">The auth factor needed to send code</param>
 | 
				
			||||||
    /// <param name="hint">The part of the contact method for verification</param>
 | 
					    public async Task SendFactorCode(Account account, AccountAuthFactor factor)
 | 
				
			||||||
    public async Task SendFactorCode(Account account, AccountAuthFactor factor, string? hint = null)
 | 
					 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var code = new Random().Next(100000, 999999).ToString("000000");
 | 
					        var code = new Random().Next(100000, 999999).ToString("000000");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -364,30 +435,18 @@ public class AccountService(
 | 
				
			|||||||
                if (await _GetFactorCode(factor) is not null)
 | 
					                if (await _GetFactorCode(factor) is not null)
 | 
				
			||||||
                    throw new InvalidOperationException("A factor code has been sent and in active duration.");
 | 
					                    throw new InvalidOperationException("A factor code has been sent and in active duration.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                ArgumentNullException.ThrowIfNull(hint);
 | 
					 | 
				
			||||||
                hint = hint.Replace("@", "").Replace(".", "").Replace("+", "").Replace("%", "");
 | 
					 | 
				
			||||||
                if (string.IsNullOrWhiteSpace(hint))
 | 
					 | 
				
			||||||
                {
 | 
					 | 
				
			||||||
                    logger.LogWarning(
 | 
					 | 
				
			||||||
                        "Unable to send factor code to #{FactorId} with hint {Hint}, due to invalid hint...",
 | 
					 | 
				
			||||||
                        factor.Id,
 | 
					 | 
				
			||||||
                        hint
 | 
					 | 
				
			||||||
                    );
 | 
					 | 
				
			||||||
                    return;
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                var contact = await db.AccountContacts
 | 
					                var contact = await db.AccountContacts
 | 
				
			||||||
                    .Where(c => c.Type == AccountContactType.Email)
 | 
					                    .Where(c => c.Type == AccountContactType.Email)
 | 
				
			||||||
                    .Where(c => c.VerifiedAt != null)
 | 
					                    .Where(c => c.VerifiedAt != null)
 | 
				
			||||||
                    .Where(c => EF.Functions.ILike(c.Content, $"%{hint}%"))
 | 
					                    .Where(c => c.IsPrimary)
 | 
				
			||||||
 | 
					                    .Where(c => c.AccountId == account.Id)
 | 
				
			||||||
                    .Include(c => c.Account)
 | 
					                    .Include(c => c.Account)
 | 
				
			||||||
                    .FirstOrDefaultAsync();
 | 
					                    .FirstOrDefaultAsync();
 | 
				
			||||||
                if (contact is null)
 | 
					                if (contact is null)
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
                    logger.LogWarning(
 | 
					                    logger.LogWarning(
 | 
				
			||||||
                        "Unable to send factor code to #{FactorId} with hint {Hint}, due to no contact method found according to hint...",
 | 
					                        "Unable to send factor code to #{FactorId} with, due to no contact method was found...",
 | 
				
			||||||
                        factor.Id,
 | 
					                        factor.Id
 | 
				
			||||||
                        hint
 | 
					 | 
				
			||||||
                    );
 | 
					                    );
 | 
				
			||||||
                    return;
 | 
					                    return;
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
@@ -396,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,
 | 
				
			||||||
@@ -450,60 +509,90 @@ public class AccountService(
 | 
				
			|||||||
        );
 | 
					        );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async Task<AuthSession> UpdateSessionLabel(Account account, Guid sessionId, string label)
 | 
					    private async Task<bool> IsDeviceActive(Guid id)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var session = await db.AuthSessions
 | 
					        return await db.AuthSessions
 | 
				
			||||||
            .Include(s => s.Challenge)
 | 
					            .Include(s => s.Challenge)
 | 
				
			||||||
            .Where(s => s.Id == sessionId && s.AccountId == account.Id)
 | 
					            .AnyAsync(s => s.Challenge.ClientId == id);
 | 
				
			||||||
            .FirstOrDefaultAsync();
 | 
					    }
 | 
				
			||||||
        if (session is null) throw new InvalidOperationException("Session was not found.");
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        await db.AuthSessions
 | 
					    public async Task<AuthClient> UpdateDeviceName(Account account, string deviceId, string label)
 | 
				
			||||||
            .Include(s => s.Challenge)
 | 
					    {
 | 
				
			||||||
            .Where(s => s.Challenge.DeviceId == session.Challenge.DeviceId)
 | 
					        var device = await db.AuthClients.FirstOrDefaultAsync(c => c.DeviceId == deviceId && c.AccountId == account.Id
 | 
				
			||||||
            .ExecuteUpdateAsync(p => p.SetProperty(s => s.Label, label));
 | 
					        );
 | 
				
			||||||
 | 
					        if (device is null) throw new InvalidOperationException("Device was not found.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var sessions = await db.AuthSessions
 | 
					        device.DeviceLabel = label;
 | 
				
			||||||
            .Include(s => s.Challenge)
 | 
					        db.Update(device);
 | 
				
			||||||
            .Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId)
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
            .ToListAsync();
 | 
					 | 
				
			||||||
        foreach (var item in sessions)
 | 
					 | 
				
			||||||
            await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}");
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return session;
 | 
					        return device;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async Task DeleteSession(Account account, Guid sessionId)
 | 
					    public async Task DeleteSession(Account account, Guid sessionId)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var session = await db.AuthSessions
 | 
					        var session = await db.AuthSessions
 | 
				
			||||||
            .Include(s => s.Challenge)
 | 
					            .Include(s => s.Challenge)
 | 
				
			||||||
 | 
					            .ThenInclude(s => s.Client)
 | 
				
			||||||
            .Where(s => s.Id == sessionId && s.AccountId == account.Id)
 | 
					            .Where(s => s.Id == sessionId && s.AccountId == account.Id)
 | 
				
			||||||
            .FirstOrDefaultAsync();
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
        if (session is null) throw new InvalidOperationException("Session was not found.");
 | 
					        if (session is null) throw new InvalidOperationException("Session was not found.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // The current session should be included in the sessions' list
 | 
				
			||||||
 | 
					        db.AuthSessions.Remove(session);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (session.Challenge.ClientId.HasValue)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            if (!await IsDeviceActive(session.Challenge.ClientId.Value))
 | 
				
			||||||
 | 
					                await pusher.UnsubscribePushNotificationsAsync(new UnsubscribePushNotificationsRequest()
 | 
				
			||||||
 | 
					                    { DeviceId = session.Challenge.Client!.DeviceId }
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        logger.LogInformation("Deleted session #{SessionId}", session.Id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{session.Id}");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task DeleteDevice(Account account, string deviceId)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var device = await db.AuthClients.FirstOrDefaultAsync(c => c.DeviceId == deviceId && c.AccountId == account.Id
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        if (device is null)
 | 
				
			||||||
 | 
					            throw new InvalidOperationException("Device not found.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await pusher.UnsubscribePushNotificationsAsync(
 | 
				
			||||||
 | 
					            new UnsubscribePushNotificationsRequest { DeviceId = device.DeviceId }
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var sessions = await db.AuthSessions
 | 
					        var sessions = await db.AuthSessions
 | 
				
			||||||
            .Include(s => s.Challenge)
 | 
					            .Include(s => s.Challenge)
 | 
				
			||||||
            .Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId)
 | 
					            .Where(s => s.Challenge.ClientId == device.Id && s.AccountId == account.Id)
 | 
				
			||||||
            .ToListAsync();
 | 
					            .ToListAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (session.Challenge.DeviceId is not null)
 | 
					 | 
				
			||||||
            await pusher.UnsubscribePushNotificationsAsync(new UnsubscribePushNotificationsRequest()
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                DeviceId = session.Challenge.DeviceId
 | 
					 | 
				
			||||||
            });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        // The current session should be included in the sessions' list
 | 
					        // The current session should be included in the sessions' list
 | 
				
			||||||
 | 
					        var now = SystemClock.Instance.GetCurrentInstant();
 | 
				
			||||||
        await db.AuthSessions
 | 
					        await db.AuthSessions
 | 
				
			||||||
            .Include(s => s.Challenge)
 | 
					            .Include(s => s.Challenge)
 | 
				
			||||||
            .Where(s => s.Challenge.DeviceId == session.Challenge.DeviceId)
 | 
					            .Where(s => s.Challenge.ClientId == device.Id)
 | 
				
			||||||
            .ExecuteDeleteAsync();
 | 
					            .ExecuteUpdateAsync(p => p.SetProperty(s => s.DeletedAt, s => now));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        db.AuthClients.Remove(device);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        foreach (var item in sessions)
 | 
					        foreach (var item in sessions)
 | 
				
			||||||
            await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}");
 | 
					            await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{item.Id}");
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async Task<AccountContact> CreateContactMethod(Account account, AccountContactType type, string content)
 | 
					    public async Task<AccountContact> CreateContactMethod(Account account, AccountContactType type, string content)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
 | 
					        var isExists = await db.AccountContacts
 | 
				
			||||||
 | 
					            .Where(x => x.AccountId == account.Id && x.Type == type && x.Content == content)
 | 
				
			||||||
 | 
					            .AnyAsync();
 | 
				
			||||||
 | 
					        if (isExists)
 | 
				
			||||||
 | 
					            throw new InvalidOperationException("Contact method already exists.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var contact = new AccountContact
 | 
					        var contact = new AccountContact
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            Type = type,
 | 
					            Type = type,
 | 
				
			||||||
@@ -558,6 +647,14 @@ public class AccountService(
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<AccountContact> SetContactMethodPublic(Account account, AccountContact contact, bool isPublic)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        contact.IsPublic = isPublic;
 | 
				
			||||||
 | 
					        db.AccountContacts.Update(contact);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					        return contact;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async Task DeleteContactMethod(Account account, AccountContact contact)
 | 
					    public async Task DeleteContactMethod(Account account, AccountContact contact)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        if (contact.AccountId != account.Id)
 | 
					        if (contact.AccountId != account.Id)
 | 
				
			||||||
@@ -637,21 +734,23 @@ public class AccountService(
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /// <summary>
 | 
					    public async Task DeleteAccount(Account account)
 | 
				
			||||||
    /// The maintenance method for server administrator.
 | 
					 | 
				
			||||||
    /// To check every user has an account profile and to create them if it isn't having one.
 | 
					 | 
				
			||||||
    /// </summary>
 | 
					 | 
				
			||||||
    public async Task EnsureAccountProfileCreated()
 | 
					 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var accountsId = await db.Accounts.Select(a => a.Id).ToListAsync();
 | 
					        await db.AuthSessions
 | 
				
			||||||
        var existingId = await db.AccountProfiles.Select(p => p.AccountId).ToListAsync();
 | 
					            .Where(s => s.AccountId == account.Id)
 | 
				
			||||||
        var missingId = accountsId.Except(existingId).ToList();
 | 
					            .ExecuteDeleteAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (missingId.Count != 0)
 | 
					        db.Accounts.Remove(account);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var js = nats.CreateJetStreamContext();
 | 
				
			||||||
 | 
					        await js.PublishAsync(
 | 
				
			||||||
 | 
					            AccountDeletedEvent.Type,
 | 
				
			||||||
 | 
					            GrpcTypeHelper.ConvertObjectToByteString(new AccountDeletedEvent
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
            var newProfiles = missingId.Select(id => new AccountProfile { Id = Guid.NewGuid(), AccountId = id })
 | 
					                AccountId = account.Id,
 | 
				
			||||||
                .ToList();
 | 
					                DeletedAt = SystemClock.Instance.GetCurrentInstant()
 | 
				
			||||||
            await db.BulkInsertAsync(newProfiles);
 | 
					            }).ToByteArray()
 | 
				
			||||||
        }
 | 
					        );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -9,6 +9,7 @@ namespace DysonNetwork.Pass.Account;
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
public class AccountServiceGrpc(
 | 
					public class AccountServiceGrpc(
 | 
				
			||||||
    AppDatabase db,
 | 
					    AppDatabase db,
 | 
				
			||||||
 | 
					    AccountEventService accountEvents,
 | 
				
			||||||
    RelationshipService relationships,
 | 
					    RelationshipService relationships,
 | 
				
			||||||
    SubscriptionService subscriptions,
 | 
					    SubscriptionService subscriptions,
 | 
				
			||||||
    IClock clock,
 | 
					    IClock clock,
 | 
				
			||||||
@@ -41,6 +42,26 @@ public class AccountServiceGrpc(
 | 
				
			|||||||
        return account.ToProtoValue();
 | 
					        return account.ToProtoValue();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public override async Task<Shared.Proto.Account> GetBotAccount(GetBotAccountRequest request,
 | 
				
			||||||
 | 
					        ServerCallContext context)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (!Guid.TryParse(request.AutomatedId, out var automatedId))
 | 
				
			||||||
 | 
					            throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid automated ID format"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var account = await _db.Accounts
 | 
				
			||||||
 | 
					            .AsNoTracking()
 | 
				
			||||||
 | 
					            .Include(a => a.Profile)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync(a => a.AutomatedId == automatedId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (account == null)
 | 
				
			||||||
 | 
					            throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Account with automated ID {request.AutomatedId} not found"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id);
 | 
				
			||||||
 | 
					        account.PerkSubscription = perk?.ToReference();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return account.ToProtoValue();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public override async Task<GetAccountBatchResponse> GetAccountBatch(GetAccountBatchRequest request,
 | 
					    public override async Task<GetAccountBatchResponse> GetAccountBatch(GetAccountBatchRequest request,
 | 
				
			||||||
        ServerCallContext context)
 | 
					        ServerCallContext context)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
@@ -68,6 +89,55 @@ public class AccountServiceGrpc(
 | 
				
			|||||||
        return response;
 | 
					        return response;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    public override async Task<GetAccountBatchResponse> GetBotAccountBatch(GetBotAccountBatchRequest request,
 | 
				
			||||||
 | 
					        ServerCallContext context)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var automatedIds = request.AutomatedId
 | 
				
			||||||
 | 
					            .Select(id => Guid.TryParse(id, out var automatedId) ? automatedId : (Guid?)null)
 | 
				
			||||||
 | 
					            .Where(id => id.HasValue)
 | 
				
			||||||
 | 
					            .Select(id => id!.Value)
 | 
				
			||||||
 | 
					            .ToList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var accounts = await _db.Accounts
 | 
				
			||||||
 | 
					            .AsNoTracking()
 | 
				
			||||||
 | 
					            .Where(a => a.AutomatedId != null && automatedIds.Contains(a.AutomatedId.Value))
 | 
				
			||||||
 | 
					            .Include(a => a.Profile)
 | 
				
			||||||
 | 
					            .ToListAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var perks = await subscriptions.GetPerkSubscriptionsAsync(
 | 
				
			||||||
 | 
					            accounts.Select(x => x.Id).ToList()
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        foreach (var account in accounts)
 | 
				
			||||||
 | 
					            if (perks.TryGetValue(account.Id, out var perk))
 | 
				
			||||||
 | 
					                account.PerkSubscription = perk?.ToReference();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var response = new GetAccountBatchResponse();
 | 
				
			||||||
 | 
					        response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue()));
 | 
				
			||||||
 | 
					        return response;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public override async Task<AccountStatus> GetAccountStatus(GetAccountRequest request, ServerCallContext context)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var accountId = Guid.Parse(request.Id);
 | 
				
			||||||
 | 
					        var status = await accountEvents.GetStatus(accountId);
 | 
				
			||||||
 | 
					        return status.ToProtoValue();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public override async Task<GetAccountStatusBatchResponse> GetAccountStatusBatch(GetAccountBatchRequest request,
 | 
				
			||||||
 | 
					        ServerCallContext context)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var accountIds = request.Id
 | 
				
			||||||
 | 
					            .Select(id => Guid.TryParse(id, out var accountId) ? accountId : (Guid?)null)
 | 
				
			||||||
 | 
					            .Where(id => id.HasValue)
 | 
				
			||||||
 | 
					            .Select(id => id!.Value)
 | 
				
			||||||
 | 
					            .ToList();
 | 
				
			||||||
 | 
					        var statuses = await accountEvents.GetStatuses(accountIds);
 | 
				
			||||||
 | 
					        var response = new GetAccountStatusBatchResponse();
 | 
				
			||||||
 | 
					        response.Statuses.AddRange(statuses.Select(s => s.Value.ToProtoValue()));
 | 
				
			||||||
 | 
					        return response;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public override async Task<GetAccountBatchResponse> LookupAccountBatch(LookupAccountBatchRequest request,
 | 
					    public override async Task<GetAccountBatchResponse> LookupAccountBatch(LookupAccountBatchRequest request,
 | 
				
			||||||
        ServerCallContext context)
 | 
					        ServerCallContext context)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,8 @@
 | 
				
			|||||||
using System.ComponentModel.DataAnnotations;
 | 
					using System.ComponentModel.DataAnnotations;
 | 
				
			||||||
using System.ComponentModel.DataAnnotations.Schema;
 | 
					using System.ComponentModel.DataAnnotations.Schema;
 | 
				
			||||||
 | 
					using System.Text.Json.Serialization;
 | 
				
			||||||
using DysonNetwork.Shared.Data;
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.GeoIp;
 | 
				
			||||||
using DysonNetwork.Shared.Proto;
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
using NodaTime.Serialization.Protobuf;
 | 
					using NodaTime.Serialization.Protobuf;
 | 
				
			||||||
using Point = NetTopologySuite.Geometries.Point;
 | 
					using Point = NetTopologySuite.Geometries.Point;
 | 
				
			||||||
@@ -14,7 +16,7 @@ public class ActionLog : ModelBase
 | 
				
			|||||||
    [Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
 | 
					    [Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
 | 
				
			||||||
    [MaxLength(512)] public string? UserAgent { get; set; }
 | 
					    [MaxLength(512)] public string? UserAgent { get; set; }
 | 
				
			||||||
    [MaxLength(128)] public string? IpAddress { get; set; }
 | 
					    [MaxLength(128)] public string? IpAddress { get; set; }
 | 
				
			||||||
    public Point? Location { get; set; }
 | 
					    [Column(TypeName = "jsonb")] public GeoPoint? Location { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public Guid AccountId { get; set; }
 | 
					    public Guid AccountId { get; set; }
 | 
				
			||||||
    public Account Account { get; set; } = null!;
 | 
					    public Account Account { get; set; } = null!;
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										218
									
								
								DysonNetwork.Pass/Account/BotAccountReceiverGrpc.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										218
									
								
								DysonNetwork.Pass/Account/BotAccountReceiverGrpc.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,218 @@
 | 
				
			|||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
 | 
					using Grpc.Core;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					using NodaTime.Serialization.Protobuf;
 | 
				
			||||||
 | 
					using ApiKey = DysonNetwork.Shared.Proto.ApiKey;
 | 
				
			||||||
 | 
					using AuthService = DysonNetwork.Pass.Auth.AuthService;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Pass.Account;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class BotAccountReceiverGrpc(
 | 
				
			||||||
 | 
					    AppDatabase db,
 | 
				
			||||||
 | 
					    AccountService accounts,
 | 
				
			||||||
 | 
					    FileService.FileServiceClient files,
 | 
				
			||||||
 | 
					    FileReferenceService.FileReferenceServiceClient fileRefs,
 | 
				
			||||||
 | 
					    AuthService authService
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					    : BotAccountReceiverService.BotAccountReceiverServiceBase
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public override async Task<CreateBotAccountResponse> CreateBotAccount(
 | 
				
			||||||
 | 
					        CreateBotAccountRequest request,
 | 
				
			||||||
 | 
					        ServerCallContext context
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var account = Account.FromProtoValue(request.Account);
 | 
				
			||||||
 | 
					        account = await accounts.CreateBotAccount(
 | 
				
			||||||
 | 
					            account,
 | 
				
			||||||
 | 
					            Guid.Parse(request.AutomatedId),
 | 
				
			||||||
 | 
					            request.PictureId,
 | 
				
			||||||
 | 
					            request.BackgroundId
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return new CreateBotAccountResponse
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Bot = new BotAccount
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Account = account.ToProtoValue(),
 | 
				
			||||||
 | 
					                AutomatedId = account.Id.ToString(),
 | 
				
			||||||
 | 
					                CreatedAt = account.CreatedAt.ToTimestamp(),
 | 
				
			||||||
 | 
					                UpdatedAt = account.UpdatedAt.ToTimestamp(),
 | 
				
			||||||
 | 
					                IsActive = true
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public override async Task<UpdateBotAccountResponse> UpdateBotAccount(
 | 
				
			||||||
 | 
					        UpdateBotAccountRequest request,
 | 
				
			||||||
 | 
					        ServerCallContext context
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var account = Account.FromProtoValue(request.Account);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (request.PictureId is not null)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var file = await files.GetFileAsync(new GetFileRequest { Id = request.PictureId });
 | 
				
			||||||
 | 
					            if (account.Profile.Picture is not null)
 | 
				
			||||||
 | 
					                await fileRefs.DeleteResourceReferencesAsync(
 | 
				
			||||||
 | 
					                    new DeleteResourceReferencesRequest { ResourceId = account.Profile.ResourceIdentifier }
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					            await fileRefs.CreateReferenceAsync(
 | 
				
			||||||
 | 
					                new CreateReferenceRequest
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    ResourceId = account.Profile.ResourceIdentifier,
 | 
				
			||||||
 | 
					                    FileId = request.PictureId,
 | 
				
			||||||
 | 
					                    Usage = "profile.picture"
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					            account.Profile.Picture = CloudFileReferenceObject.FromProtoValue(file);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (request.BackgroundId is not null)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var file = await files.GetFileAsync(new GetFileRequest { Id = request.BackgroundId });
 | 
				
			||||||
 | 
					            if (account.Profile.Background is not null)
 | 
				
			||||||
 | 
					                await fileRefs.DeleteResourceReferencesAsync(
 | 
				
			||||||
 | 
					                    new DeleteResourceReferencesRequest { ResourceId = account.Profile.ResourceIdentifier }
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					            await fileRefs.CreateReferenceAsync(
 | 
				
			||||||
 | 
					                new CreateReferenceRequest
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    ResourceId = account.Profile.ResourceIdentifier,
 | 
				
			||||||
 | 
					                    FileId = request.BackgroundId,
 | 
				
			||||||
 | 
					                    Usage = "profile.background"
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					            account.Profile.Background = CloudFileReferenceObject.FromProtoValue(file);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        db.Accounts.Update(account);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return new UpdateBotAccountResponse
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Bot = new BotAccount
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Account = account.ToProtoValue(),
 | 
				
			||||||
 | 
					                AutomatedId = account.Id.ToString(),
 | 
				
			||||||
 | 
					                CreatedAt = account.CreatedAt.ToTimestamp(),
 | 
				
			||||||
 | 
					                UpdatedAt = account.UpdatedAt.ToTimestamp(),
 | 
				
			||||||
 | 
					                IsActive = true
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public override async Task<DeleteBotAccountResponse> DeleteBotAccount(
 | 
				
			||||||
 | 
					        DeleteBotAccountRequest request,
 | 
				
			||||||
 | 
					        ServerCallContext context
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var automatedId = Guid.Parse(request.AutomatedId);
 | 
				
			||||||
 | 
					        var account = await accounts.GetBotAccount(automatedId);
 | 
				
			||||||
 | 
					        if (account is null)
 | 
				
			||||||
 | 
					            throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.NotFound, "Account not found"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await accounts.DeleteAccount(account);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return new DeleteBotAccountResponse();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public override async Task<ApiKey> GetApiKey(GetApiKeyRequest request, ServerCallContext context)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var keyId = Guid.Parse(request.Id);
 | 
				
			||||||
 | 
					        var key = await db.ApiKeys
 | 
				
			||||||
 | 
					            .Include(k => k.Account)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync(k => k.Id == keyId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (key == null)
 | 
				
			||||||
 | 
					            throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return key.ToProtoValue();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public override async Task<GetApiKeyBatchResponse> ListApiKey(ListApiKeyRequest request, ServerCallContext context)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var automatedId = Guid.Parse(request.AutomatedId);
 | 
				
			||||||
 | 
					        var account = await accounts.GetBotAccount(automatedId);
 | 
				
			||||||
 | 
					        if (account == null)
 | 
				
			||||||
 | 
					            throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var keys = await db.ApiKeys
 | 
				
			||||||
 | 
					            .Where(k => k.AccountId == account.Id)
 | 
				
			||||||
 | 
					            .Select(k => k.ToProtoValue())
 | 
				
			||||||
 | 
					            .ToListAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var response = new GetApiKeyBatchResponse();
 | 
				
			||||||
 | 
					        response.Data.AddRange(keys);
 | 
				
			||||||
 | 
					        return response;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public override async Task<ApiKey> CreateApiKey(ApiKey request, ServerCallContext context)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var accountId = Guid.Parse(request.AccountId);
 | 
				
			||||||
 | 
					        var account = await accounts.GetBotAccount(accountId);
 | 
				
			||||||
 | 
					        if (account == null)
 | 
				
			||||||
 | 
					            throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (string.IsNullOrWhiteSpace(request.Label))
 | 
				
			||||||
 | 
					            throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Label is required"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var key = await authService.CreateApiKey(account.Id, request.Label, null);
 | 
				
			||||||
 | 
					        key.Key = await authService.IssueApiKeyToken(key);
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        return key.ToProtoValue();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public override async Task<ApiKey> UpdateApiKey(ApiKey request, ServerCallContext context)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var keyId = Guid.Parse(request.Id);
 | 
				
			||||||
 | 
					        var accountId = Guid.Parse(request.AccountId);
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        var key = await db.ApiKeys
 | 
				
			||||||
 | 
					            .Include(k => k.Session)
 | 
				
			||||||
 | 
					            .Where(k => k.Id == keyId && k.AccountId == accountId)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					        if (key == null)
 | 
				
			||||||
 | 
					            throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Only update the label if provided
 | 
				
			||||||
 | 
					        if (string.IsNullOrWhiteSpace(request.Label)) return key.ToProtoValue();
 | 
				
			||||||
 | 
					        key.Label = request.Label;
 | 
				
			||||||
 | 
					        db.ApiKeys.Update(key);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return key.ToProtoValue();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public override async Task<ApiKey> RotateApiKey(GetApiKeyRequest request, ServerCallContext context)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var keyId = Guid.Parse(request.Id);
 | 
				
			||||||
 | 
					        var key = await db.ApiKeys
 | 
				
			||||||
 | 
					            .Include(k => k.Session)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync(k => k.Id == keyId);
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					        if (key == null)
 | 
				
			||||||
 | 
					            throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        key = await authService.RotateApiKeyToken(key);
 | 
				
			||||||
 | 
					        key.Key = await authService.IssueApiKeyToken(key);
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        return key.ToProtoValue();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public override async Task<DeleteApiKeyResponse> DeleteApiKey(GetApiKeyRequest request, ServerCallContext context)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var keyId = Guid.Parse(request.Id);
 | 
				
			||||||
 | 
					        var key = await db.ApiKeys
 | 
				
			||||||
 | 
					            .Include(k => k.Session)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync(k => k.Id == keyId);
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					        if (key == null)
 | 
				
			||||||
 | 
					            throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await authService.RevokeApiKeyToken(key);
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        return new DeleteApiKeyResponse { Success = true };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
 | 
				
			|||||||
using System.ComponentModel.DataAnnotations.Schema;
 | 
					using System.ComponentModel.DataAnnotations.Schema;
 | 
				
			||||||
using DysonNetwork.Shared.Data;
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
using NodaTime;
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					using NodaTime.Serialization.Protobuf;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace DysonNetwork.Pass.Account;
 | 
					namespace DysonNetwork.Pass.Account;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -22,9 +23,63 @@ public class Status : ModelBase
 | 
				
			|||||||
    public bool IsNotDisturb { get; set; }
 | 
					    public bool IsNotDisturb { get; set; }
 | 
				
			||||||
    [MaxLength(1024)] public string? Label { get; set; }
 | 
					    [MaxLength(1024)] public string? Label { get; set; }
 | 
				
			||||||
    public Instant? ClearedAt { get; set; }
 | 
					    public Instant? ClearedAt { get; set; }
 | 
				
			||||||
 | 
					    [MaxLength(4096)] public string? AppIdentifier { get; set; }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    /// <summary>
 | 
				
			||||||
 | 
					    /// Indicates this status is created based on running process or rich presence
 | 
				
			||||||
 | 
					    /// </summary>
 | 
				
			||||||
 | 
					    public bool IsAutomated { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public Guid AccountId { get; set; }
 | 
					    public Guid AccountId { get; set; }
 | 
				
			||||||
    public Account Account { get; set; } = null!;
 | 
					    public Account Account { get; set; } = null!;
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    public Shared.Proto.AccountStatus ToProtoValue()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var proto = new Shared.Proto.AccountStatus
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Id = Id.ToString(),
 | 
				
			||||||
 | 
					            Attitude = Attitude switch
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                StatusAttitude.Positive => Shared.Proto.StatusAttitude.Positive,
 | 
				
			||||||
 | 
					                StatusAttitude.Negative => Shared.Proto.StatusAttitude.Negative,
 | 
				
			||||||
 | 
					                StatusAttitude.Neutral => Shared.Proto.StatusAttitude.Neutral,
 | 
				
			||||||
 | 
					                _ => Shared.Proto.StatusAttitude.Unspecified
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            IsOnline = IsOnline,
 | 
				
			||||||
 | 
					            IsCustomized = IsCustomized,
 | 
				
			||||||
 | 
					            IsInvisible = IsInvisible,
 | 
				
			||||||
 | 
					            IsNotDisturb = IsNotDisturb,
 | 
				
			||||||
 | 
					            Label = Label ?? string.Empty,
 | 
				
			||||||
 | 
					            ClearedAt = ClearedAt?.ToTimestamp(),
 | 
				
			||||||
 | 
					            AccountId = AccountId.ToString()
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return proto;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static Status FromProtoValue(Shared.Proto.AccountStatus proto)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var status = new Status
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Id = Guid.Parse(proto.Id),
 | 
				
			||||||
 | 
					            Attitude = proto.Attitude switch
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Shared.Proto.StatusAttitude.Positive => StatusAttitude.Positive,
 | 
				
			||||||
 | 
					                Shared.Proto.StatusAttitude.Negative => StatusAttitude.Negative,
 | 
				
			||||||
 | 
					                Shared.Proto.StatusAttitude.Neutral => StatusAttitude.Neutral,
 | 
				
			||||||
 | 
					                _ => StatusAttitude.Neutral
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            IsOnline = proto.IsOnline,
 | 
				
			||||||
 | 
					            IsCustomized = proto.IsCustomized,
 | 
				
			||||||
 | 
					            IsInvisible = proto.IsInvisible,
 | 
				
			||||||
 | 
					            IsNotDisturb = proto.IsNotDisturb,
 | 
				
			||||||
 | 
					            Label = proto.Label,
 | 
				
			||||||
 | 
					            ClearedAt = proto.ClearedAt?.ToInstant(),
 | 
				
			||||||
 | 
					            AccountId = Guid.Parse(proto.AccountId)
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return status;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public enum CheckInResultLevel
 | 
					public enum CheckInResultLevel
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										28
									
								
								DysonNetwork.Pass/Account/Punishment.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								DysonNetwork.Pass/Account/Punishment.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
				
			|||||||
 | 
					using System.ComponentModel.DataAnnotations;
 | 
				
			||||||
 | 
					using System.ComponentModel.DataAnnotations.Schema;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Pass.Account;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public enum PunishmentType
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    // TODO: impl the permission modification
 | 
				
			||||||
 | 
					    PermissionModification,
 | 
				
			||||||
 | 
					    BlockLogin,
 | 
				
			||||||
 | 
					    DisableAccount,
 | 
				
			||||||
 | 
					    Strike
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class Punishment : ModelBase
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public Guid Id { get; set; } = Guid.NewGuid();
 | 
				
			||||||
 | 
					    [MaxLength(8192)] public string Reason { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					    public Instant? ExpiredAt { get; set; }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    public PunishmentType Type { get; set; }
 | 
				
			||||||
 | 
					    [Column(TypeName = "jsonb")] public List<string>? BlockedPermissions { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public Guid AccountId { get; set; }
 | 
				
			||||||
 | 
					    public Account Account { get; set; } = null!;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,10 +1,18 @@
 | 
				
			|||||||
 | 
					using DysonNetwork.Pass.Localization;
 | 
				
			||||||
using DysonNetwork.Shared.Cache;
 | 
					using DysonNetwork.Shared.Cache;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
using Microsoft.EntityFrameworkCore;
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					using Microsoft.Extensions.Localization;
 | 
				
			||||||
using NodaTime;
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace DysonNetwork.Pass.Account;
 | 
					namespace DysonNetwork.Pass.Account;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public class RelationshipService(AppDatabase db, ICacheService cache)
 | 
					public class RelationshipService(
 | 
				
			||||||
 | 
					    AppDatabase db,
 | 
				
			||||||
 | 
					    ICacheService cache,
 | 
				
			||||||
 | 
					    RingService.RingServiceClient pusher,
 | 
				
			||||||
 | 
					    IStringLocalizer<NotificationResource> localizer
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    private const string UserFriendsCacheKeyPrefix = "accounts:friends:";
 | 
					    private const string UserFriendsCacheKeyPrefix = "accounts:friends:";
 | 
				
			||||||
    private const string UserBlockedCacheKeyPrefix = "accounts:blocked:";
 | 
					    private const string UserBlockedCacheKeyPrefix = "accounts:blocked:";
 | 
				
			||||||
@@ -92,6 +100,19 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
 | 
				
			|||||||
        db.AccountRelationships.Add(relationship);
 | 
					        db.AccountRelationships.Add(relationship);
 | 
				
			||||||
        await db.SaveChangesAsync();
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await pusher.SendPushNotificationToUserAsync(new SendPushNotificationToUserRequest
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            UserId = target.Id.ToString(),
 | 
				
			||||||
 | 
					            Notification = new PushNotification
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Topic = "relationships.friends.request",
 | 
				
			||||||
 | 
					                Title = localizer["FriendRequestTitle", sender.Nick],
 | 
				
			||||||
 | 
					                Body = localizer["FriendRequestBody"],
 | 
				
			||||||
 | 
					                ActionUri = "/account/relationships",
 | 
				
			||||||
 | 
					                IsSavable = true
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return relationship;
 | 
					        return relationship;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,8 +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.Developer;
 | 
					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;
 | 
				
			||||||
@@ -19,44 +22,53 @@ public class AppDatabase(
 | 
				
			|||||||
    IConfiguration configuration
 | 
					    IConfiguration configuration
 | 
				
			||||||
) : DbContext(options)
 | 
					) : DbContext(options)
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    public DbSet<PermissionNode> PermissionNodes { get; set; }
 | 
					    public DbSet<PermissionNode> PermissionNodes { get; set; } = null!;
 | 
				
			||||||
    public DbSet<PermissionGroup> PermissionGroups { get; set; }
 | 
					    public DbSet<PermissionGroup> PermissionGroups { get; set; } = null!;
 | 
				
			||||||
    public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; }
 | 
					    public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; } = null!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public DbSet<MagicSpell> MagicSpells { get; set; }
 | 
					    public DbSet<MagicSpell> MagicSpells { get; set; } = null!;
 | 
				
			||||||
    public DbSet<Account.Account> Accounts { get; set; }
 | 
					    public DbSet<Account.Account> Accounts { get; set; } = null!;
 | 
				
			||||||
    public DbSet<AccountConnection> AccountConnections { get; set; }
 | 
					    public DbSet<AccountConnection> AccountConnections { get; set; } = null!;
 | 
				
			||||||
    public DbSet<AccountProfile> AccountProfiles { get; set; }
 | 
					    public DbSet<AccountProfile> AccountProfiles { get; set; } = null!;
 | 
				
			||||||
    public DbSet<AccountContact> AccountContacts { get; set; }
 | 
					    public DbSet<AccountContact> AccountContacts { get; set; } = null!;
 | 
				
			||||||
    public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; }
 | 
					    public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; } = null!;
 | 
				
			||||||
    public DbSet<Relationship> AccountRelationships { get; set; }
 | 
					    public DbSet<Relationship> AccountRelationships { get; set; } = null!;
 | 
				
			||||||
    public DbSet<Status> AccountStatuses { get; set; }
 | 
					    public DbSet<Status> AccountStatuses { get; set; } = null!;
 | 
				
			||||||
    public DbSet<CheckInResult> AccountCheckInResults { get; set; }
 | 
					    public DbSet<CheckInResult> AccountCheckInResults { get; set; } = null!;
 | 
				
			||||||
    public DbSet<AccountBadge> Badges { get; set; }
 | 
					    public DbSet<AccountBadge> Badges { get; set; } = null!;
 | 
				
			||||||
    public DbSet<ActionLog> ActionLogs { get; set; }
 | 
					    public DbSet<ActionLog> ActionLogs { get; set; } = null!;
 | 
				
			||||||
    public DbSet<AbuseReport> AbuseReports { get; set; }
 | 
					    public DbSet<AbuseReport> AbuseReports { get; set; } = null!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public DbSet<AuthSession> AuthSessions { get; set; }
 | 
					    public DbSet<AuthSession> AuthSessions { get; set; } = null!;
 | 
				
			||||||
    public DbSet<AuthChallenge> AuthChallenges { get; set; }
 | 
					    public DbSet<AuthChallenge> AuthChallenges { get; set; } = null!;
 | 
				
			||||||
 | 
					    public DbSet<AuthClient> AuthClients { get; set; } = null!;
 | 
				
			||||||
 | 
					    public DbSet<ApiKey> ApiKeys { get; set; } = null!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public DbSet<Wallet.Wallet> Wallets { get; set; }
 | 
					    public DbSet<Wallet.Wallet> Wallets { get; set; } = null!;
 | 
				
			||||||
    public DbSet<WalletPocket> WalletPockets { get; set; }
 | 
					    public DbSet<WalletPocket> WalletPockets { get; set; } = null!;
 | 
				
			||||||
    public DbSet<Order> PaymentOrders { get; set; }
 | 
					    public DbSet<Order> PaymentOrders { get; set; } = null!;
 | 
				
			||||||
    public DbSet<Transaction> PaymentTransactions { get; set; }
 | 
					    public DbSet<Transaction> PaymentTransactions { get; set; } = null!;
 | 
				
			||||||
    public DbSet<Subscription> WalletSubscriptions { get; set; }
 | 
					    public DbSet<Subscription> WalletSubscriptions { get; set; } = null!;
 | 
				
			||||||
    public DbSet<Coupon> WalletCoupons { get; set; }
 | 
					    public DbSet<Coupon> WalletCoupons { get; set; } = null!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public DbSet<CustomApp> CustomApps { get; set; }
 | 
					    public DbSet<Punishment> Punishments { get; set; } = null!;
 | 
				
			||||||
    public DbSet<CustomAppSecret> CustomAppSecrets { get; set; }
 | 
					
 | 
				
			||||||
 | 
					    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();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -90,7 +102,7 @@ public class AppDatabase(
 | 
				
			|||||||
            }
 | 
					            }
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        optionsBuilder.UseSeeding((context, _) => {});
 | 
					        optionsBuilder.UseSeeding((context, _) => { });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        base.OnConfiguring(optionsBuilder);
 | 
					        base.OnConfiguring(optionsBuilder);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										50
									
								
								DysonNetwork.Pass/Auth/ApiKey.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								DysonNetwork.Pass/Auth/ApiKey.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,50 @@
 | 
				
			|||||||
 | 
					using System.ComponentModel.DataAnnotations;
 | 
				
			||||||
 | 
					using System.ComponentModel.DataAnnotations.Schema;
 | 
				
			||||||
 | 
					using System.Text.Json.Serialization;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
 | 
					using NodaTime.Serialization.Protobuf;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Pass.Auth;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class ApiKey : ModelBase
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public Guid Id { get; set; } = Guid.NewGuid();
 | 
				
			||||||
 | 
					    [MaxLength(1024)] public string Label { get; set; } = null!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public Guid AccountId { get; set; }
 | 
				
			||||||
 | 
					    public Account.Account Account { get; set; } = null!;
 | 
				
			||||||
 | 
					    public Guid SessionId { get; set; }
 | 
				
			||||||
 | 
					    public AuthSession Session { get; set; } = null!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [NotMapped]
 | 
				
			||||||
 | 
					    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
 | 
				
			||||||
 | 
					    public string? Key { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public DysonNetwork.Shared.Proto.ApiKey ToProtoValue()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return new DysonNetwork.Shared.Proto.ApiKey
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Id = Id.ToString(),
 | 
				
			||||||
 | 
					            Label = Label,
 | 
				
			||||||
 | 
					            AccountId = AccountId.ToString(),
 | 
				
			||||||
 | 
					            SessionId = SessionId.ToString(),
 | 
				
			||||||
 | 
					            Key = Key,
 | 
				
			||||||
 | 
					            CreatedAt = CreatedAt.ToTimestamp(),
 | 
				
			||||||
 | 
					            UpdatedAt = UpdatedAt.ToTimestamp()
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static ApiKey FromProtoValue(DysonNetwork.Shared.Proto.ApiKey proto)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return new ApiKey
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Id = Guid.Parse(proto.Id),
 | 
				
			||||||
 | 
					            AccountId = Guid.Parse(proto.AccountId),
 | 
				
			||||||
 | 
					            SessionId = Guid.Parse(proto.SessionId),
 | 
				
			||||||
 | 
					            Label = proto.Label,
 | 
				
			||||||
 | 
					            Key = proto.Key,
 | 
				
			||||||
 | 
					            CreatedAt = proto.CreatedAt.ToInstant(),
 | 
				
			||||||
 | 
					            UpdatedAt = proto.UpdatedAt.ToInstant()
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										90
									
								
								DysonNetwork.Pass/Auth/ApiKeyController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								DysonNetwork.Pass/Auth/ApiKeyController.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,90 @@
 | 
				
			|||||||
 | 
					using System.ComponentModel.DataAnnotations;
 | 
				
			||||||
 | 
					using Microsoft.AspNetCore.Authorization;
 | 
				
			||||||
 | 
					using Microsoft.AspNetCore.Mvc;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Pass.Auth;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[ApiController]
 | 
				
			||||||
 | 
					[Route("/api/auth/keys")]
 | 
				
			||||||
 | 
					public class ApiKeyController(AppDatabase db, AuthService auth) : ControllerBase
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    [HttpGet]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> GetKeys([FromQuery] int offset = 0, [FromQuery] int take = 20)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var query = db.ApiKeys
 | 
				
			||||||
 | 
					            .Where(e => e.AccountId == currentUser.Id)
 | 
				
			||||||
 | 
					            .AsQueryable();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var totalCount = await query.CountAsync();
 | 
				
			||||||
 | 
					        Response.Headers["X-Total"] = totalCount.ToString();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var keys = await query
 | 
				
			||||||
 | 
					            .Skip(offset)
 | 
				
			||||||
 | 
					            .Take(take)
 | 
				
			||||||
 | 
					            .ToListAsync();
 | 
				
			||||||
 | 
					        return Ok(keys);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpGet("{id:guid}")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> GetKey(Guid id)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var key = await db.ApiKeys
 | 
				
			||||||
 | 
					            .Where(e => e.AccountId == currentUser.Id)
 | 
				
			||||||
 | 
					            .Where(e => e.Id == id)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					        if (key == null) return NotFound();
 | 
				
			||||||
 | 
					        return Ok(key);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public class ApiKeyRequest
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        [MaxLength(1024)] public string? Label { get; set; }
 | 
				
			||||||
 | 
					        public Instant? ExpiredAt { get; set; }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> CreateKey([FromBody] ApiKeyRequest request)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (string.IsNullOrWhiteSpace(request.Label))
 | 
				
			||||||
 | 
					            return BadRequest("Label is required");
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var key = await auth.CreateApiKey(currentUser.Id, request.Label, request.ExpiredAt);
 | 
				
			||||||
 | 
					        key.Key = await auth.IssueApiKeyToken(key);
 | 
				
			||||||
 | 
					        return Ok(key);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpPost("{id:guid}/rotate")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> RotateKey(Guid id)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        var key = await auth.GetApiKey(id, currentUser.Id);
 | 
				
			||||||
 | 
					        if(key is null) return NotFound();
 | 
				
			||||||
 | 
					        key = await auth.RotateApiKeyToken(key);
 | 
				
			||||||
 | 
					        key.Key = await auth.IssueApiKeyToken(key);
 | 
				
			||||||
 | 
					        return Ok(key);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [HttpDelete("{id:guid}")]
 | 
				
			||||||
 | 
					    [Authorize]
 | 
				
			||||||
 | 
					    public async Task<IActionResult> DeleteKey(Guid id)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        var key = await auth.GetApiKey(id, currentUser.Id);
 | 
				
			||||||
 | 
					        if(key is null) return NotFound();
 | 
				
			||||||
 | 
					        await auth.RevokeApiKeyToken(key);
 | 
				
			||||||
 | 
					        return NoContent();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,11 +1,7 @@
 | 
				
			|||||||
using System.Security.Claims;
 | 
					using System.Security.Claims;
 | 
				
			||||||
using System.Security.Cryptography;
 | 
					 | 
				
			||||||
using System.Text.Encodings.Web;
 | 
					using System.Text.Encodings.Web;
 | 
				
			||||||
using DysonNetwork.Pass.Account;
 | 
					 | 
				
			||||||
using Microsoft.AspNetCore.Authentication;
 | 
					using Microsoft.AspNetCore.Authentication;
 | 
				
			||||||
using Microsoft.EntityFrameworkCore;
 | 
					 | 
				
			||||||
using Microsoft.Extensions.Options;
 | 
					using Microsoft.Extensions.Options;
 | 
				
			||||||
using DysonNetwork.Pass.Auth.OidcProvider.Services;
 | 
					 | 
				
			||||||
using DysonNetwork.Pass.Handlers;
 | 
					using DysonNetwork.Pass.Handlers;
 | 
				
			||||||
using DysonNetwork.Shared.Cache;
 | 
					using DysonNetwork.Shared.Cache;
 | 
				
			||||||
using SystemClock = NodaTime.SystemClock;
 | 
					using SystemClock = NodaTime.SystemClock;
 | 
				
			||||||
@@ -37,18 +33,13 @@ public class DysonTokenAuthOptions : AuthenticationSchemeOptions;
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
public class DysonTokenAuthHandler(
 | 
					public class DysonTokenAuthHandler(
 | 
				
			||||||
    IOptionsMonitor<DysonTokenAuthOptions> options,
 | 
					    IOptionsMonitor<DysonTokenAuthOptions> options,
 | 
				
			||||||
    IConfiguration configuration,
 | 
					 | 
				
			||||||
    ILoggerFactory logger,
 | 
					    ILoggerFactory logger,
 | 
				
			||||||
    UrlEncoder encoder,
 | 
					    UrlEncoder encoder,
 | 
				
			||||||
    AppDatabase database,
 | 
					    TokenAuthService token,
 | 
				
			||||||
    OidcProviderService oidc,
 | 
					 | 
				
			||||||
    ICacheService cache,
 | 
					 | 
				
			||||||
    FlushBufferService fbs
 | 
					    FlushBufferService fbs
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
    : AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder)
 | 
					    : AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder)
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    public const string AuthCachePrefix = "auth:";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
 | 
					    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var tokenInfo = _ExtractToken(Request);
 | 
					        var tokenInfo = _ExtractToken(Request);
 | 
				
			||||||
@@ -58,44 +49,12 @@ public class DysonTokenAuthHandler(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        try
 | 
					        try
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            var now = SystemClock.Instance.GetCurrentInstant();
 | 
					            // Get client IP address
 | 
				
			||||||
 | 
					            var ipAddress = Context.Connection.RemoteIpAddress?.ToString();
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
            // Validate token and extract session ID
 | 
					            var (valid, session, message) = await token.AuthenticateTokenAsync(tokenInfo.Token, ipAddress);
 | 
				
			||||||
            if (!ValidateToken(tokenInfo.Token, out var sessionId))
 | 
					            if (!valid || session is null)
 | 
				
			||||||
                return AuthenticateResult.Fail("Invalid token.");
 | 
					                return AuthenticateResult.Fail(message ?? "Authentication failed.");
 | 
				
			||||||
 | 
					 | 
				
			||||||
            // Try to get session from cache first
 | 
					 | 
				
			||||||
            var session = await cache.GetAsync<AuthSession>($"{AuthCachePrefix}{sessionId}");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            // If not in cache, load from database
 | 
					 | 
				
			||||||
            if (session is null)
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                session = await database.AuthSessions
 | 
					 | 
				
			||||||
                    .Where(e => e.Id == sessionId)
 | 
					 | 
				
			||||||
                    .Include(e => e.Challenge)
 | 
					 | 
				
			||||||
                    .Include(e => e.Account)
 | 
					 | 
				
			||||||
                    .ThenInclude(e => e.Profile)
 | 
					 | 
				
			||||||
                    .FirstOrDefaultAsync();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                if (session is not null)
 | 
					 | 
				
			||||||
                {
 | 
					 | 
				
			||||||
                    // Store in cache for future requests
 | 
					 | 
				
			||||||
                    await cache.SetWithGroupsAsync(
 | 
					 | 
				
			||||||
                        $"auth:{sessionId}",
 | 
					 | 
				
			||||||
                        session,
 | 
					 | 
				
			||||||
                        [$"{AccountService.AccountCachePrefix}{session.Account.Id}"],
 | 
					 | 
				
			||||||
                        TimeSpan.FromHours(1)
 | 
					 | 
				
			||||||
                    );
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            // Check if the session exists
 | 
					 | 
				
			||||||
            if (session == null)
 | 
					 | 
				
			||||||
                return AuthenticateResult.Fail("Session not found.");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            // Check if the session is expired
 | 
					 | 
				
			||||||
            if (session.ExpiredAt.HasValue && session.ExpiredAt.Value < now)
 | 
					 | 
				
			||||||
                return AuthenticateResult.Fail("Session expired.");
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // Store user and session in the HttpContext.Items for easy access in controllers
 | 
					            // Store user and session in the HttpContext.Items for easy access in controllers
 | 
				
			||||||
            Context.Items["CurrentUser"] = session.Account;
 | 
					            Context.Items["CurrentUser"] = session.Account;
 | 
				
			||||||
@@ -111,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)
 | 
				
			||||||
@@ -139,78 +98,6 @@ public class DysonTokenAuthHandler(
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private bool ValidateToken(string token, out Guid sessionId)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        sessionId = Guid.Empty;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        try
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            var parts = token.Split('.');
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            switch (parts.Length)
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                // Handle JWT tokens (3 parts)
 | 
					 | 
				
			||||||
                case 3:
 | 
					 | 
				
			||||||
                {
 | 
					 | 
				
			||||||
                    var (isValid, jwtResult) = oidc.ValidateToken(token);
 | 
					 | 
				
			||||||
                    if (!isValid) return false;
 | 
					 | 
				
			||||||
                    var jti = jwtResult?.Claims.FirstOrDefault(c => c.Type == "jti")?.Value;
 | 
					 | 
				
			||||||
                    if (jti is null) return false;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                    return Guid.TryParse(jti, out sessionId);
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
                // Handle compact tokens (2 parts)
 | 
					 | 
				
			||||||
                case 2:
 | 
					 | 
				
			||||||
                    // Original compact token validation logic
 | 
					 | 
				
			||||||
                    try
 | 
					 | 
				
			||||||
                    {
 | 
					 | 
				
			||||||
                        // Decode the payload
 | 
					 | 
				
			||||||
                        var payloadBytes = Base64UrlDecode(parts[0]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                        // Extract session ID
 | 
					 | 
				
			||||||
                        sessionId = new Guid(payloadBytes);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                        // Load public key for verification
 | 
					 | 
				
			||||||
                        var publicKeyPem = File.ReadAllText(configuration["AuthToken:PublicKeyPath"]!);
 | 
					 | 
				
			||||||
                        using var rsa = RSA.Create();
 | 
					 | 
				
			||||||
                        rsa.ImportFromPem(publicKeyPem);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                        // Verify signature
 | 
					 | 
				
			||||||
                        var signature = Base64UrlDecode(parts[1]);
 | 
					 | 
				
			||||||
                        return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
 | 
					 | 
				
			||||||
                    }
 | 
					 | 
				
			||||||
                    catch
 | 
					 | 
				
			||||||
                    {
 | 
					 | 
				
			||||||
                        return false;
 | 
					 | 
				
			||||||
                    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                    break;
 | 
					 | 
				
			||||||
                default:
 | 
					 | 
				
			||||||
                    return false;
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        catch (Exception ex)
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            Logger.LogWarning(ex, "Token validation failed");
 | 
					 | 
				
			||||||
            return false;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private static byte[] Base64UrlDecode(string base64Url)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        var padded = base64Url
 | 
					 | 
				
			||||||
            .Replace('-', '+')
 | 
					 | 
				
			||||||
            .Replace('_', '/');
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        switch (padded.Length % 4)
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            case 2: padded += "=="; break;
 | 
					 | 
				
			||||||
            case 3: padded += "="; break;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return Convert.FromBase64String(padded);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private TokenInfo? _ExtractToken(HttpRequest request)
 | 
					    private TokenInfo? _ExtractToken(HttpRequest request)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        // Check for token in query parameters
 | 
					        // Check for token in query parameters
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										8
									
								
								DysonNetwork.Pass/Auth/AuthCacheConstants.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								DysonNetwork.Pass/Auth/AuthCacheConstants.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
				
			|||||||
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace DysonNetwork.Pass.Auth;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public static class AuthCacheConstants
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public const string Prefix = "auth:";
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -3,8 +3,14 @@ using Microsoft.AspNetCore.Mvc;
 | 
				
			|||||||
using NodaTime;
 | 
					using NodaTime;
 | 
				
			||||||
using Microsoft.EntityFrameworkCore;
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
using DysonNetwork.Pass.Account;
 | 
					using DysonNetwork.Pass.Account;
 | 
				
			||||||
 | 
					using DysonNetwork.Pass.Localization;
 | 
				
			||||||
using DysonNetwork.Shared.Data;
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
using DysonNetwork.Shared.GeoIp;
 | 
					using DysonNetwork.Shared.GeoIp;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
 | 
					using Microsoft.Extensions.Localization;
 | 
				
			||||||
 | 
					using AccountAuthFactor = DysonNetwork.Pass.Account.AccountAuthFactor;
 | 
				
			||||||
 | 
					using AccountService = DysonNetwork.Pass.Account.AccountService;
 | 
				
			||||||
 | 
					using ActionLogService = DysonNetwork.Pass.Account.ActionLogService;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace DysonNetwork.Pass.Auth;
 | 
					namespace DysonNetwork.Pass.Auth;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -16,52 +22,76 @@ public class AuthController(
 | 
				
			|||||||
    AuthService auth,
 | 
					    AuthService auth,
 | 
				
			||||||
    GeoIpService geo,
 | 
					    GeoIpService geo,
 | 
				
			||||||
    ActionLogService als,
 | 
					    ActionLogService als,
 | 
				
			||||||
    IConfiguration configuration
 | 
					    RingService.RingServiceClient pusher,
 | 
				
			||||||
 | 
					    IConfiguration configuration,
 | 
				
			||||||
 | 
					    IStringLocalizer<NotificationResource> localizer
 | 
				
			||||||
) : ControllerBase
 | 
					) : ControllerBase
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    private readonly string _cookieDomain = configuration["AuthToken:CookieDomain"]!;
 | 
					    private readonly string _cookieDomain = configuration["AuthToken:CookieDomain"]!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public class ChallengeRequest
 | 
					    public class ChallengeRequest
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        [Required] public ChallengePlatform Platform { get; set; }
 | 
					        [Required] public ClientPlatform Platform { get; set; }
 | 
				
			||||||
        [Required] [MaxLength(256)] public string Account { get; set; } = null!;
 | 
					        [Required] [MaxLength(256)] public string Account { get; set; } = null!;
 | 
				
			||||||
        [Required] [MaxLength(512)] public string DeviceId { get; set; } = null!;
 | 
					        [Required] [MaxLength(512)] public string DeviceId { get; set; } = null!;
 | 
				
			||||||
 | 
					        [MaxLength(1024)] public string? DeviceName { get; set; }
 | 
				
			||||||
        public List<string> Audiences { get; set; } = new();
 | 
					        public List<string> Audiences { get; set; } = new();
 | 
				
			||||||
        public List<string> Scopes { get; set; } = new();
 | 
					        public List<string> Scopes { get; set; } = new();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [HttpPost("challenge")]
 | 
					    [HttpPost("challenge")]
 | 
				
			||||||
    public async Task<ActionResult<AuthChallenge>> StartChallenge([FromBody] ChallengeRequest request)
 | 
					    public async Task<ActionResult<AuthChallenge>> CreateChallenge([FromBody] ChallengeRequest request)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var account = await accounts.LookupAccount(request.Account);
 | 
					        var account = await accounts.LookupAccount(request.Account);
 | 
				
			||||||
        if (account is null) return NotFound("Account was not found.");
 | 
					        if (account is null) return NotFound("Account was not found.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var now = SystemClock.Instance.GetCurrentInstant();
 | 
				
			||||||
 | 
					        var punishment = await db.Punishments
 | 
				
			||||||
 | 
					            .Where(e => e.AccountId == account.Id)
 | 
				
			||||||
 | 
					            .Where(e => e.Type == PunishmentType.BlockLogin || e.Type == PunishmentType.DisableAccount)
 | 
				
			||||||
 | 
					            .Where(e => e.ExpiredAt == null || now < e.ExpiredAt)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					        if (punishment is not null)
 | 
				
			||||||
 | 
					            return StatusCode(
 | 
				
			||||||
 | 
					                423,
 | 
				
			||||||
 | 
					                $"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();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
 | 
					        request.DeviceName ??= userAgent;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var device =
 | 
				
			||||||
 | 
					            await auth.GetOrCreateDeviceAsync(account.Id, request.DeviceId, request.DeviceName, request.Platform);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Trying to pick up challenges from the same IP address and user agent
 | 
					        // Trying to pick up challenges from the same IP address and user agent
 | 
				
			||||||
        var existingChallenge = await db.AuthChallenges
 | 
					        var existingChallenge = await db.AuthChallenges
 | 
				
			||||||
            .Where(e => e.Account == account)
 | 
					            .Where(e => e.AccountId == account.Id)
 | 
				
			||||||
            .Where(e => e.IpAddress == ipAddress)
 | 
					            .Where(e => e.IpAddress == ipAddress)
 | 
				
			||||||
            .Where(e => e.UserAgent == userAgent)
 | 
					            .Where(e => e.UserAgent == userAgent)
 | 
				
			||||||
            .Where(e => e.StepRemain > 0)
 | 
					            .Where(e => e.StepRemain > 0)
 | 
				
			||||||
            .Where(e => e.ExpiredAt != null && now < e.ExpiredAt)
 | 
					            .Where(e => e.ExpiredAt != null && now < e.ExpiredAt)
 | 
				
			||||||
 | 
					            .Where(e => e.Type == ChallengeType.Login)
 | 
				
			||||||
 | 
					            .Where(e => e.ClientId == device.Id)
 | 
				
			||||||
            .FirstOrDefaultAsync();
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
        if (existingChallenge is not null) return existingChallenge;
 | 
					        if (existingChallenge is not null)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var existingSession = await db.AuthSessions.Where(e => e.ChallengeId == existingChallenge.Id)
 | 
				
			||||||
 | 
					                .FirstOrDefaultAsync();
 | 
				
			||||||
 | 
					            if (existingSession is null) return existingChallenge;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var challenge = new AuthChallenge
 | 
					        var challenge = new AuthChallenge
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)),
 | 
					            ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)),
 | 
				
			||||||
            StepTotal = await auth.DetectChallengeRisk(Request, account),
 | 
					            StepTotal = await auth.DetectChallengeRisk(Request, account),
 | 
				
			||||||
            Platform = request.Platform,
 | 
					 | 
				
			||||||
            Audiences = request.Audiences,
 | 
					            Audiences = request.Audiences,
 | 
				
			||||||
            Scopes = request.Scopes,
 | 
					            Scopes = request.Scopes,
 | 
				
			||||||
            IpAddress = ipAddress,
 | 
					            IpAddress = ipAddress,
 | 
				
			||||||
            UserAgent = userAgent,
 | 
					            UserAgent = userAgent,
 | 
				
			||||||
            Location = geo.GetPointFromIp(ipAddress),
 | 
					            Location = geo.GetPointFromIp(ipAddress),
 | 
				
			||||||
            DeviceId = request.DeviceId,
 | 
					            ClientId = device.Id,
 | 
				
			||||||
            AccountId = account.Id
 | 
					            AccountId = account.Id
 | 
				
			||||||
        }.Normalize();
 | 
					        }.Normalize();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -104,8 +134,7 @@ public class AuthController(
 | 
				
			|||||||
    [HttpPost("challenge/{id:guid}/factors/{factorId:guid}")]
 | 
					    [HttpPost("challenge/{id:guid}/factors/{factorId:guid}")]
 | 
				
			||||||
    public async Task<ActionResult> RequestFactorCode(
 | 
					    public async Task<ActionResult> RequestFactorCode(
 | 
				
			||||||
        [FromRoute] Guid id,
 | 
					        [FromRoute] Guid id,
 | 
				
			||||||
        [FromRoute] Guid factorId,
 | 
					        [FromRoute] Guid factorId
 | 
				
			||||||
        [FromBody] string? hint
 | 
					 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var challenge = await db.AuthChallenges
 | 
					        var challenge = await db.AuthChallenges
 | 
				
			||||||
@@ -119,7 +148,7 @@ public class AuthController(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        try
 | 
					        try
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            await accounts.SendFactorCode(challenge.Account, factor, hint);
 | 
					            await accounts.SendFactorCode(challenge.Account, factor);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        catch (Exception ex)
 | 
					        catch (Exception ex)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
@@ -141,18 +170,29 @@ public class AuthController(
 | 
				
			|||||||
        [FromBody] PerformChallengeRequest request
 | 
					        [FromBody] PerformChallengeRequest request
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var challenge = await db.AuthChallenges.Include(e => e.Account).FirstOrDefaultAsync(e => e.Id == id);
 | 
					        var challenge = await db.AuthChallenges
 | 
				
			||||||
 | 
					            .Include(e => e.Account)
 | 
				
			||||||
 | 
					            .Include(authChallenge => authChallenge.Client)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync(e => e.Id == id);
 | 
				
			||||||
        if (challenge is null) return NotFound("Auth challenge was not found.");
 | 
					        if (challenge is null) return NotFound("Auth challenge was not found.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var factor = await db.AccountAuthFactors.FindAsync(request.FactorId);
 | 
					        var factor = await db.AccountAuthFactors
 | 
				
			||||||
 | 
					            .Where(f => f.Id == request.FactorId)
 | 
				
			||||||
 | 
					            .Where(f => f.AccountId == challenge.AccountId)
 | 
				
			||||||
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
        if (factor is null) return NotFound("Auth factor was not found.");
 | 
					        if (factor is null) return NotFound("Auth factor was not found.");
 | 
				
			||||||
        if (factor.EnabledAt is null) return BadRequest("Auth factor is not enabled.");
 | 
					        if (factor.EnabledAt is null) return BadRequest("Auth factor is not enabled.");
 | 
				
			||||||
        if (factor.Trustworthy <= 0) return BadRequest("Auth factor is not trustworthy.");
 | 
					        if (factor.Trustworthy <= 0) return BadRequest("Auth factor is not trustworthy.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (challenge.StepRemain == 0) return challenge;
 | 
					        if (challenge.StepRemain == 0) return challenge;
 | 
				
			||||||
        if (challenge.ExpiredAt.HasValue && challenge.ExpiredAt.Value < Instant.FromDateTimeUtc(DateTime.UtcNow))
 | 
					        var now = SystemClock.Instance.GetCurrentInstant();
 | 
				
			||||||
 | 
					        if (challenge.ExpiredAt.HasValue && now > challenge.ExpiredAt.Value)
 | 
				
			||||||
            return BadRequest();
 | 
					            return BadRequest();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // prevent reusing the same factor in one challenge
 | 
				
			||||||
 | 
					        if (challenge.BlacklistFactors.Contains(factor.Id))
 | 
				
			||||||
 | 
					            return BadRequest("Auth factor already used.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        try
 | 
					        try
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            if (await accounts.VerifyFactorCode(factor, request.Password))
 | 
					            if (await accounts.VerifyFactorCode(factor, request.Password))
 | 
				
			||||||
@@ -191,6 +231,19 @@ public class AuthController(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        if (challenge.StepRemain == 0)
 | 
					        if (challenge.StepRemain == 0)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
 | 
					            AccountService.SetCultureInfo(challenge.Account);
 | 
				
			||||||
 | 
					            await pusher.SendPushNotificationToUserAsync(new SendPushNotificationToUserRequest
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                Notification = new PushNotification()
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    Topic = "auth.login",
 | 
				
			||||||
 | 
					                    Title = localizer["NewLoginTitle"],
 | 
				
			||||||
 | 
					                    Body = localizer["NewLoginBody", challenge.Client?.DeviceName ?? "unknown",
 | 
				
			||||||
 | 
					                        challenge.IpAddress ?? "unknown"],
 | 
				
			||||||
 | 
					                    IsSavable = true
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                UserId = challenge.AccountId.ToString()
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
            als.CreateActionLogFromRequest(ActionLogType.NewLogin,
 | 
					            als.CreateActionLogFromRequest(ActionLogType.NewLogin,
 | 
				
			||||||
                new Dictionary<string, object>
 | 
					                new Dictionary<string, object>
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
@@ -231,37 +284,15 @@ public class AuthController(
 | 
				
			|||||||
                    .FirstOrDefaultAsync();
 | 
					                    .FirstOrDefaultAsync();
 | 
				
			||||||
                if (challenge is null)
 | 
					                if (challenge is null)
 | 
				
			||||||
                    return BadRequest("Authorization code not found or expired.");
 | 
					                    return BadRequest("Authorization code not found or expired.");
 | 
				
			||||||
                if (challenge.StepRemain != 0)
 | 
					                try
 | 
				
			||||||
                    return BadRequest("Challenge not yet completed.");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                var session = await db.AuthSessions
 | 
					 | 
				
			||||||
                    .Where(e => e.Challenge == challenge)
 | 
					 | 
				
			||||||
                    .FirstOrDefaultAsync();
 | 
					 | 
				
			||||||
                if (session is not null)
 | 
					 | 
				
			||||||
                    return BadRequest("Session already exists for this challenge.");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                session = new AuthSession
 | 
					 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
                    LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow),
 | 
					                    var tk = await auth.CreateSessionAndIssueToken(challenge);
 | 
				
			||||||
                    ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)),
 | 
					 | 
				
			||||||
                    Account = challenge.Account,
 | 
					 | 
				
			||||||
                    Challenge = challenge,
 | 
					 | 
				
			||||||
                };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                db.AuthSessions.Add(session);
 | 
					 | 
				
			||||||
                await db.SaveChangesAsync();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                var tk = auth.CreateToken(session);
 | 
					 | 
				
			||||||
                Response.Cookies.Append(AuthConstants.CookieTokenName, tk, new CookieOptions
 | 
					 | 
				
			||||||
                {
 | 
					 | 
				
			||||||
                    HttpOnly = true,
 | 
					 | 
				
			||||||
                    Secure = true,
 | 
					 | 
				
			||||||
                    SameSite = SameSiteMode.Lax,
 | 
					 | 
				
			||||||
                    Domain = _cookieDomain,
 | 
					 | 
				
			||||||
                    Expires = DateTime.UtcNow.AddDays(30)
 | 
					 | 
				
			||||||
                });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                    return Ok(new TokenExchangeResponse { Token = tk });
 | 
					                    return Ok(new TokenExchangeResponse { Token = tk });
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                catch (ArgumentException ex)
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    return BadRequest(ex.Message);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
            default:
 | 
					            default:
 | 
				
			||||||
                // Since we no longer need the refresh token
 | 
					                // Since we no longer need the refresh token
 | 
				
			||||||
                // This case is blank for now, thinking to mock it if the OIDC standard requires it
 | 
					                // This case is blank for now, thinking to mock it if the OIDC standard requires it
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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;
 | 
				
			||||||
@@ -12,10 +13,12 @@ public class AuthService(
 | 
				
			|||||||
    IConfiguration config,
 | 
					    IConfiguration config,
 | 
				
			||||||
    IHttpClientFactory httpClientFactory,
 | 
					    IHttpClientFactory httpClientFactory,
 | 
				
			||||||
    IHttpContextAccessor httpContextAccessor,
 | 
					    IHttpContextAccessor httpContextAccessor,
 | 
				
			||||||
    ICacheService cache
 | 
					    ICacheService cache,
 | 
				
			||||||
 | 
					    ILogger<AuthService> logger
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    private HttpContext HttpContext => httpContextAccessor.HttpContext!;
 | 
					    private HttpContext HttpContext => httpContextAccessor.HttpContext!;
 | 
				
			||||||
 | 
					    public const string AuthCachePrefix = "auth:";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /// <summary>
 | 
					    /// <summary>
 | 
				
			||||||
    /// Detect the risk of the current request to login
 | 
					    /// Detect the risk of the current request to login
 | 
				
			||||||
@@ -50,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;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
@@ -73,7 +76,8 @@ public class AuthService(
 | 
				
			|||||||
        return totalRequiredSteps;
 | 
					        return totalRequiredSteps;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async Task<AuthSession> CreateSessionForOidcAsync(Account.Account account, Instant time, Guid? customAppId = null)
 | 
					    public async Task<AuthSession> CreateSessionForOidcAsync(Account.Account account, Instant time,
 | 
				
			||||||
 | 
					        Guid? customAppId = null)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var challenge = new AuthChallenge
 | 
					        var challenge = new AuthChallenge
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
@@ -101,6 +105,28 @@ public class AuthService(
 | 
				
			|||||||
        return session;
 | 
					        return session;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<AuthClient> GetOrCreateDeviceAsync(
 | 
				
			||||||
 | 
					        Guid accountId,
 | 
				
			||||||
 | 
					        string deviceId,
 | 
				
			||||||
 | 
					        string? deviceName = null,
 | 
				
			||||||
 | 
					        ClientPlatform platform = ClientPlatform.Unidentified
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var device = await db.AuthClients.FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.AccountId == accountId);
 | 
				
			||||||
 | 
					        if (device is not null) return device;
 | 
				
			||||||
 | 
					        device = new AuthClient
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Platform = platform,
 | 
				
			||||||
 | 
					            DeviceId = deviceId,
 | 
				
			||||||
 | 
					            AccountId = accountId
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					        if (deviceName is not null) device.DeviceName = deviceName;
 | 
				
			||||||
 | 
					        db.AuthClients.Add(device);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return device;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async Task<bool> ValidateCaptcha(string token)
 | 
					    public async Task<bool> ValidateCaptcha(string token)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        if (string.IsNullOrWhiteSpace(token)) return false;
 | 
					        if (string.IsNullOrWhiteSpace(token)) return false;
 | 
				
			||||||
@@ -112,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
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
@@ -165,6 +192,52 @@ public class AuthService(
 | 
				
			|||||||
        return CreateCompactToken(session.Id, rsa);
 | 
					        return CreateCompactToken(session.Id, rsa);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// <summary>
 | 
				
			||||||
 | 
					    /// Create a session for a completed challenge, persist it, issue a token, and set the auth cookie.
 | 
				
			||||||
 | 
					    /// Keeps behavior identical to previous controller implementation.
 | 
				
			||||||
 | 
					    /// </summary>
 | 
				
			||||||
 | 
					    /// <param name="challenge">Completed challenge</param>
 | 
				
			||||||
 | 
					    /// <returns>Signed compact token</returns>
 | 
				
			||||||
 | 
					    /// <exception cref="ArgumentException">If challenge not completed or session already exists</exception>
 | 
				
			||||||
 | 
					    public async Task<string> CreateSessionAndIssueToken(AuthChallenge challenge)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (challenge.StepRemain != 0)
 | 
				
			||||||
 | 
					            throw new ArgumentException("Challenge not yet completed.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var hasSession = await db.AuthSessions
 | 
				
			||||||
 | 
					            .AnyAsync(e => e.ChallengeId == challenge.Id);
 | 
				
			||||||
 | 
					        if (hasSession)
 | 
				
			||||||
 | 
					            throw new ArgumentException("Session already exists for this challenge.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var now = SystemClock.Instance.GetCurrentInstant();
 | 
				
			||||||
 | 
					        var session = new AuthSession
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            LastGrantedAt = now,
 | 
				
			||||||
 | 
					            ExpiredAt = now.Plus(Duration.FromDays(7)),
 | 
				
			||||||
 | 
					            AccountId = challenge.AccountId,
 | 
				
			||||||
 | 
					            ChallengeId = challenge.Id
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        db.AuthSessions.Add(session);
 | 
				
			||||||
 | 
					        await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var tk = CreateToken(session);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Set cookie using HttpContext
 | 
				
			||||||
 | 
					        var cookieDomain = config["AuthToken:CookieDomain"]!;
 | 
				
			||||||
 | 
					        HttpContext.Response.Cookies.Append(AuthConstants.CookieTokenName, tk, new CookieOptions
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            HttpOnly = true,
 | 
				
			||||||
 | 
					            Secure = true,
 | 
				
			||||||
 | 
					            SameSite = SameSiteMode.Lax,
 | 
				
			||||||
 | 
					            Domain = cookieDomain,
 | 
				
			||||||
 | 
					            // Effectively never expire client-side (20 years)
 | 
				
			||||||
 | 
					            Expires = DateTime.UtcNow.AddYears(20)
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return tk;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private string CreateCompactToken(Guid sessionId, RSA rsa)
 | 
					    private string CreateCompactToken(Guid sessionId, RSA rsa)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        // Create the payload: just the session ID
 | 
					        // Create the payload: just the session ID
 | 
				
			||||||
@@ -246,35 +319,84 @@ public class AuthService(
 | 
				
			|||||||
        return factor.VerifyPassword(pinCode);
 | 
					        return factor.VerifyPassword(pinCode);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public bool ValidateToken(string token, out Guid sessionId)
 | 
					    public async Task<ApiKey?> GetApiKey(Guid id, Guid? accountId = null)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        sessionId = Guid.Empty;
 | 
					        var key = await db.ApiKeys
 | 
				
			||||||
 | 
					            .Include(e => e.Session)
 | 
				
			||||||
 | 
					            .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
 | 
					        try
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            // Split the token
 | 
					            var oldSessionId = key.SessionId;
 | 
				
			||||||
            var parts = token.Split('.');
 | 
					 | 
				
			||||||
            if (parts.Length != 2)
 | 
					 | 
				
			||||||
                return false;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // Decode the payload
 | 
					            // Create new session
 | 
				
			||||||
            var payloadBytes = Base64UrlDecode(parts[0]);
 | 
					            var newSession = new AuthSession
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                AccountId = key.AccountId,
 | 
				
			||||||
 | 
					                ExpiredAt = key.Session?.ExpiredAt
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // Extract session ID
 | 
					            db.AuthSessions.Add(newSession);
 | 
				
			||||||
            sessionId = new Guid(payloadBytes);
 | 
					            await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // Load public key for verification
 | 
					            // Update ApiKey to point to new session
 | 
				
			||||||
            var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!);
 | 
					            key.SessionId = newSession.Id;
 | 
				
			||||||
            using var rsa = RSA.Create();
 | 
					            key.Session = newSession;
 | 
				
			||||||
            rsa.ImportFromPem(publicKeyPem);
 | 
					            db.ApiKeys.Update(key);
 | 
				
			||||||
 | 
					            await db.SaveChangesAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // Verify signature
 | 
					            // Delete old session
 | 
				
			||||||
            var signature = Base64UrlDecode(parts[1]);
 | 
					            await db.AuthSessions.Where(s => s.Id == oldSessionId).ExecuteDeleteAsync();
 | 
				
			||||||
            return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
 | 
					
 | 
				
			||||||
 | 
					            await transaction.CommitAsync();
 | 
				
			||||||
 | 
					            return key;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        catch
 | 
					        catch
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            return false;
 | 
					            await transaction.RollbackAsync();
 | 
				
			||||||
 | 
					            throw;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -289,7 +411,7 @@ public class AuthService(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    private static byte[] Base64UrlDecode(string base64Url)
 | 
					    private static byte[] Base64UrlDecode(string base64Url)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        string padded = base64Url
 | 
					        var padded = base64Url
 | 
				
			||||||
            .Replace('-', '+')
 | 
					            .Replace('-', '+')
 | 
				
			||||||
            .Replace('_', '/');
 | 
					            .Replace('_', '/');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,17 +1,11 @@
 | 
				
			|||||||
using DysonNetwork.Pass.Wallet;
 | 
					 | 
				
			||||||
using DysonNetwork.Shared.Cache;
 | 
					 | 
				
			||||||
using DysonNetwork.Shared.Proto;
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
using Grpc.Core;
 | 
					using Grpc.Core;
 | 
				
			||||||
using Microsoft.EntityFrameworkCore;
 | 
					 | 
				
			||||||
using NodaTime;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace DysonNetwork.Pass.Auth;
 | 
					namespace DysonNetwork.Pass.Auth;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public class AuthServiceGrpc(
 | 
					public class AuthServiceGrpc(
 | 
				
			||||||
    AuthService authService,
 | 
					    TokenAuthService token,
 | 
				
			||||||
    SubscriptionService subscriptions,
 | 
					    AuthService auth
 | 
				
			||||||
    ICacheService cache,
 | 
					 | 
				
			||||||
    AppDatabase db
 | 
					 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
    : Shared.Proto.AuthService.AuthServiceBase
 | 
					    : Shared.Proto.AuthService.AuthServiceBase
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
@@ -20,34 +14,9 @@ public class AuthServiceGrpc(
 | 
				
			|||||||
        ServerCallContext context
 | 
					        ServerCallContext context
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        if (!authService.ValidateToken(request.Token, out var sessionId))
 | 
					        var (valid, session, message) = await token.AuthenticateTokenAsync(request.Token, request.IpAddress);
 | 
				
			||||||
            return new AuthenticateResponse { Valid = false, Message = "Invalid token." };
 | 
					        if (!valid || session is null)
 | 
				
			||||||
 | 
					            return new AuthenticateResponse { Valid = false, Message = message ?? "Authentication failed." };
 | 
				
			||||||
        var session = await cache.GetAsync<AuthSession>($"{DysonTokenAuthHandler.AuthCachePrefix}{sessionId}");
 | 
					 | 
				
			||||||
        if (session is not null)
 | 
					 | 
				
			||||||
            return new AuthenticateResponse { Valid = true, Session = session.ToProtoValue() };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        session = await db.AuthSessions
 | 
					 | 
				
			||||||
            .AsNoTracking()
 | 
					 | 
				
			||||||
            .Include(e => e.Challenge)
 | 
					 | 
				
			||||||
            .Include(e => e.Account)
 | 
					 | 
				
			||||||
            .ThenInclude(e => e.Profile)
 | 
					 | 
				
			||||||
            .FirstOrDefaultAsync(s => s.Id == sessionId);
 | 
					 | 
				
			||||||
        if (session == null)
 | 
					 | 
				
			||||||
            return new AuthenticateResponse { Valid = false, Message = "Session was not found." };
 | 
					 | 
				
			||||||
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
					 | 
				
			||||||
        if (session.ExpiredAt.HasValue && session.ExpiredAt < now)
 | 
					 | 
				
			||||||
            return new AuthenticateResponse { Valid = false, Message = "Session has been expired." };
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
        var perk = await subscriptions.GetPerkSubscriptionAsync(session.AccountId);
 | 
					 | 
				
			||||||
        session.Account.PerkSubscription = perk?.ToReference();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        await cache.SetWithGroupsAsync(
 | 
					 | 
				
			||||||
            $"auth:{sessionId}",
 | 
					 | 
				
			||||||
            session,
 | 
					 | 
				
			||||||
            [$"{Account.AccountService.AccountCachePrefix}{session.Account.Id}"],
 | 
					 | 
				
			||||||
            TimeSpan.FromHours(1)
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return new AuthenticateResponse { Valid = true, Session = session.ToProtoValue() };
 | 
					        return new AuthenticateResponse { Valid = true, Session = session.ToProtoValue() };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -55,13 +24,13 @@ public class AuthServiceGrpc(
 | 
				
			|||||||
    public override async Task<ValidateResponse> ValidatePin(ValidatePinRequest request, ServerCallContext context)
 | 
					    public override async Task<ValidateResponse> ValidatePin(ValidatePinRequest request, ServerCallContext context)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var accountId = Guid.Parse(request.AccountId);
 | 
					        var accountId = Guid.Parse(request.AccountId);
 | 
				
			||||||
        var valid = await authService.ValidatePinCode(accountId, request.Pin);
 | 
					        var valid = await auth.ValidatePinCode(accountId, request.Pin);
 | 
				
			||||||
        return new ValidateResponse { Valid = valid };
 | 
					        return new ValidateResponse { Valid = valid };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    public override async Task<ValidateResponse> ValidateCaptcha(ValidateCaptchaRequest request, ServerCallContext context)
 | 
					    public override async Task<ValidateResponse> ValidateCaptcha(ValidateCaptchaRequest request, ServerCallContext context)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var valid = await authService.ValidateCaptcha(request.Token);
 | 
					        var valid = await auth.ValidateCaptcha(request.Token);
 | 
				
			||||||
        return new ValidateResponse { Valid = valid };
 | 
					        return new ValidateResponse { Valid = valid };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -1,9 +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 System.Text.Json.Serialization;
 | 
				
			||||||
using DysonNetwork.Pass;
 | 
					 | 
				
			||||||
using DysonNetwork.Pass.Developer;
 | 
					 | 
				
			||||||
using DysonNetwork.Shared.Data;
 | 
					using DysonNetwork.Shared.Data;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.GeoIp;
 | 
				
			||||||
using NodaTime;
 | 
					using NodaTime;
 | 
				
			||||||
using NodaTime.Serialization.Protobuf;
 | 
					using NodaTime.Serialization.Protobuf;
 | 
				
			||||||
using Point = NetTopologySuite.Geometries.Point;
 | 
					using Point = NetTopologySuite.Geometries.Point;
 | 
				
			||||||
@@ -13,27 +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 CustomApp? App { 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()
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -45,7 +45,7 @@ public enum ChallengeType
 | 
				
			|||||||
    Oidc // Trying to connect other platforms
 | 
					    Oidc // Trying to connect other platforms
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public enum ChallengePlatform
 | 
					public enum ClientPlatform
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    Unidentified,
 | 
					    Unidentified,
 | 
				
			||||||
    Web,
 | 
					    Web,
 | 
				
			||||||
@@ -63,19 +63,19 @@ public class AuthChallenge : ModelBase
 | 
				
			|||||||
    public int StepRemain { get; set; }
 | 
					    public int StepRemain { get; set; }
 | 
				
			||||||
    public int StepTotal { get; set; }
 | 
					    public int StepTotal { get; set; }
 | 
				
			||||||
    public int FailedAttempts { get; set; }
 | 
					    public int FailedAttempts { get; set; }
 | 
				
			||||||
    public ChallengePlatform Platform { get; set; } = ChallengePlatform.Unidentified;
 | 
					 | 
				
			||||||
    public ChallengeType Type { get; set; } = ChallengeType.Login;
 | 
					    public ChallengeType Type { get; set; } = ChallengeType.Login;
 | 
				
			||||||
    [Column(TypeName = "jsonb")] public List<Guid> BlacklistFactors { get; set; } = new();
 | 
					    [Column(TypeName = "jsonb")] public List<Guid> BlacklistFactors { get; set; } = new();
 | 
				
			||||||
    [Column(TypeName = "jsonb")] public List<string> Audiences { get; set; } = new();
 | 
					    [Column(TypeName = "jsonb")] public List<string> Audiences { get; set; } = new();
 | 
				
			||||||
    [Column(TypeName = "jsonb")] public List<string> Scopes { get; set; } = new();
 | 
					    [Column(TypeName = "jsonb")] public List<string> Scopes { get; set; } = new();
 | 
				
			||||||
    [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(256)] public string? DeviceId { 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!;
 | 
				
			||||||
 | 
					    public Guid? ClientId { get; set; }
 | 
				
			||||||
 | 
					    public AuthClient? Client { get; set; } = null!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public AuthChallenge Normalize()
 | 
					    public AuthChallenge Normalize()
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
@@ -90,15 +90,44 @@ public class AuthChallenge : ModelBase
 | 
				
			|||||||
        StepRemain = StepRemain,
 | 
					        StepRemain = StepRemain,
 | 
				
			||||||
        StepTotal = StepTotal,
 | 
					        StepTotal = StepTotal,
 | 
				
			||||||
        FailedAttempts = FailedAttempts,
 | 
					        FailedAttempts = FailedAttempts,
 | 
				
			||||||
        Platform = (Shared.Proto.ChallengePlatform)Platform,
 | 
					 | 
				
			||||||
        Type = (Shared.Proto.ChallengeType)Type,
 | 
					        Type = (Shared.Proto.ChallengeType)Type,
 | 
				
			||||||
        BlacklistFactors = { BlacklistFactors.Select(x => x.ToString()) },
 | 
					        BlacklistFactors = { BlacklistFactors.Select(x => x.ToString()) },
 | 
				
			||||||
        Audiences = { Audiences },
 | 
					        Audiences = { Audiences },
 | 
				
			||||||
        Scopes = { Scopes },
 | 
					        Scopes = { Scopes },
 | 
				
			||||||
        IpAddress = IpAddress,
 | 
					        IpAddress = IpAddress,
 | 
				
			||||||
        UserAgent = UserAgent,
 | 
					        UserAgent = UserAgent,
 | 
				
			||||||
        DeviceId = DeviceId,
 | 
					        DeviceId = Client!.DeviceId,
 | 
				
			||||||
        Nonce = Nonce,
 | 
					        Nonce = Nonce,
 | 
				
			||||||
        AccountId = AccountId.ToString()
 | 
					        AccountId = AccountId.ToString()
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class AuthClient : ModelBase
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public Guid Id { get; set; } = Guid.NewGuid();
 | 
				
			||||||
 | 
					    public ClientPlatform Platform { get; set; } = ClientPlatform.Unidentified;
 | 
				
			||||||
 | 
					    [MaxLength(1024)] public string DeviceName { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					    [MaxLength(1024)] public string? DeviceLabel { get; set; }
 | 
				
			||||||
 | 
					    [MaxLength(1024)] public string DeviceId { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public Guid AccountId { get; set; }
 | 
				
			||||||
 | 
					    [JsonIgnore] public Account.Account Account { get; set; } = null!;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class AuthClientWithChallenge : AuthClient
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public List<AuthChallenge> Challenges { get; set; } = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static AuthClientWithChallenge FromClient(AuthClient client)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return new AuthClientWithChallenge
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Id = client.Id,
 | 
				
			||||||
 | 
					            Platform = client.Platform,
 | 
				
			||||||
 | 
					            DeviceName = client.DeviceName,
 | 
				
			||||||
 | 
					            DeviceLabel = client.DeviceLabel,
 | 
				
			||||||
 | 
					            DeviceId = client.DeviceId,
 | 
				
			||||||
 | 
					            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;
 | 
				
			||||||
@@ -21,9 +23,197 @@ public class OidcProviderController(
 | 
				
			|||||||
    IConfiguration configuration,
 | 
					    IConfiguration configuration,
 | 
				
			||||||
    IOptions<OidcProviderOptions> options,
 | 
					    IOptions<OidcProviderOptions> options,
 | 
				
			||||||
    ILogger<OidcProviderController> logger
 | 
					    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)
 | 
				
			||||||
@@ -37,15 +227,15 @@ public class OidcProviderController(
 | 
				
			|||||||
                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
 | 
				
			||||||
@@ -67,7 +257,7 @@ public class OidcProviderController(
 | 
				
			|||||||
                    // 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?.App is null || session.ExpiredAt < now)
 | 
					                    if (session?.AppId is null || session.ExpiredAt < now)
 | 
				
			||||||
                    {
 | 
					                    {
 | 
				
			||||||
                        return BadRequest(new ErrorResponse
 | 
					                        return BadRequest(new ErrorResponse
 | 
				
			||||||
                        {
 | 
					                        {
 | 
				
			||||||
@@ -77,7 +267,7 @@ public class OidcProviderController(
 | 
				
			|||||||
                    }
 | 
					                    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    // Get the client
 | 
					                    // Get the client
 | 
				
			||||||
                    var client = session.App;
 | 
					                    var client = await oidcService.FindClientByIdAsync(session.AppId.Value);
 | 
				
			||||||
                    if (client == null)
 | 
					                    if (client == null)
 | 
				
			||||||
                    {
 | 
					                    {
 | 
				
			||||||
                        return BadRequest(new ErrorResponse
 | 
					                        return BadRequest(new ErrorResponse
 | 
				
			||||||
@@ -117,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>
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
@@ -151,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[]
 | 
				
			||||||
@@ -221,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")]
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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; }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,18 +5,20 @@ using System.Text;
 | 
				
			|||||||
using DysonNetwork.Pass.Auth.OidcProvider.Models;
 | 
					using DysonNetwork.Pass.Auth.OidcProvider.Models;
 | 
				
			||||||
using DysonNetwork.Pass.Auth.OidcProvider.Options;
 | 
					using DysonNetwork.Pass.Auth.OidcProvider.Options;
 | 
				
			||||||
using DysonNetwork.Pass.Auth.OidcProvider.Responses;
 | 
					using DysonNetwork.Pass.Auth.OidcProvider.Responses;
 | 
				
			||||||
using DysonNetwork.Pass.Developer;
 | 
					 | 
				
			||||||
using DysonNetwork.Shared.Cache;
 | 
					using DysonNetwork.Shared.Cache;
 | 
				
			||||||
 | 
					using DysonNetwork.Shared.Proto;
 | 
				
			||||||
using Microsoft.EntityFrameworkCore;
 | 
					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;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public class OidcProviderService(
 | 
					public class OidcProviderService(
 | 
				
			||||||
    AppDatabase db,
 | 
					    AppDatabase db,
 | 
				
			||||||
    AuthService auth,
 | 
					    AuthService auth,
 | 
				
			||||||
 | 
					    CustomAppService.CustomAppServiceClient customApps,
 | 
				
			||||||
    ICacheService cache,
 | 
					    ICacheService cache,
 | 
				
			||||||
    IOptions<OidcProviderOptions> options,
 | 
					    IOptions<OidcProviderOptions> options,
 | 
				
			||||||
    ILogger<OidcProviderService> logger
 | 
					    ILogger<OidcProviderService> logger
 | 
				
			||||||
@@ -26,27 +28,35 @@ public class OidcProviderService(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    public async Task<CustomApp?> FindClientByIdAsync(Guid clientId)
 | 
					    public async Task<CustomApp?> FindClientByIdAsync(Guid clientId)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        return await db.CustomApps
 | 
					        var resp = await customApps.GetCustomAppAsync(new GetCustomAppRequest { Id = clientId.ToString() });
 | 
				
			||||||
            .Include(c => c.Secrets)
 | 
					        return resp.App ?? null;
 | 
				
			||||||
            .FirstOrDefaultAsync(c => c.Id == clientId);
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async Task<CustomApp?> FindClientByAppIdAsync(Guid appId)
 | 
					    public async Task<CustomApp?> FindClientBySlugAsync(string slug)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        return await db.CustomApps
 | 
					        var resp = await customApps.GetCustomAppAsync(new GetCustomAppRequest { Slug = slug });
 | 
				
			||||||
            .Include(c => c.Secrets)
 | 
					        return resp.App ?? null;
 | 
				
			||||||
            .FirstOrDefaultAsync(c => c.Id == appId);
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async Task<AuthSession?> FindValidSessionAsync(Guid accountId, Guid clientId)
 | 
					    public async Task<AuthSession?> FindValidSessionAsync(Guid accountId, Guid clientId, bool withAccount = false)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
					        var now = SystemClock.Instance.GetCurrentInstant();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return await db.AuthSessions
 | 
					        var queryable = db.AuthSessions
 | 
				
			||||||
            .Include(s => s.Challenge)
 | 
					            .Include(s => s.Challenge)
 | 
				
			||||||
 | 
					            .AsQueryable();
 | 
				
			||||||
 | 
					        if (withAccount)
 | 
				
			||||||
 | 
					            queryable = queryable
 | 
				
			||||||
 | 
					                .Include(s => s.Account)
 | 
				
			||||||
 | 
					                .ThenInclude(a => a.Profile)
 | 
				
			||||||
 | 
					                .Include(a => a.Account.Contacts)
 | 
				
			||||||
 | 
					                .AsQueryable();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return await queryable
 | 
				
			||||||
            .Where(s => s.AccountId == accountId &&
 | 
					            .Where(s => s.AccountId == accountId &&
 | 
				
			||||||
                        s.AppId == clientId &&
 | 
					                        s.AppId == clientId &&
 | 
				
			||||||
                        (s.ExpiredAt == null || s.ExpiredAt > now) &&
 | 
					                        (s.ExpiredAt == null || s.ExpiredAt > now) &&
 | 
				
			||||||
 | 
					                        s.Challenge != null &&
 | 
				
			||||||
                        s.Challenge.Type == ChallengeType.OAuth)
 | 
					                        s.Challenge.Type == ChallengeType.OAuth)
 | 
				
			||||||
            .OrderByDescending(s => s.CreatedAt)
 | 
					            .OrderByDescending(s => s.CreatedAt)
 | 
				
			||||||
            .FirstOrDefaultAsync();
 | 
					            .FirstOrDefaultAsync();
 | 
				
			||||||
@@ -54,15 +64,156 @@ public class OidcProviderService(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    public async Task<bool> ValidateClientCredentialsAsync(Guid clientId, string clientSecret)
 | 
					    public async Task<bool> ValidateClientCredentialsAsync(Guid clientId, string clientSecret)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
 | 
					        var resp = await customApps.CheckCustomAppSecretAsync(new CheckCustomAppSecretRequest
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            AppId = clientId.ToString(),
 | 
				
			||||||
 | 
					            Secret = clientSecret,
 | 
				
			||||||
 | 
					            IsOidc = true
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        return resp.Valid;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<bool> ValidateRedirectUriAsync(Guid clientId, string redirectUri)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (string.IsNullOrEmpty(redirectUri))
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var client = await FindClientByIdAsync(clientId);
 | 
					        var client = await FindClientByIdAsync(clientId);
 | 
				
			||||||
        if (client == null) return false;
 | 
					        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 clock = SystemClock.Instance;
 | 
				
			||||||
        var secret = client.Secrets
 | 
					        var now = clock.GetCurrentInstant();
 | 
				
			||||||
            .Where(s => s.IsOidc && (s.ExpiredAt == null || s.ExpiredAt > clock.GetCurrentInstant()))
 | 
					 | 
				
			||||||
            .FirstOrDefault(s => s.Secret == clientSecret); // In production, use proper hashing
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return secret != null;
 | 
					        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(
 | 
				
			||||||
@@ -80,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, client.Id);
 | 
					 | 
				
			||||||
            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)
 | 
				
			||||||
@@ -111,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,
 | 
				
			||||||
@@ -143,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.ToString())
 | 
					 | 
				
			||||||
            ]),
 | 
					            ]),
 | 
				
			||||||
            Expires = expiresAt.ToDateTimeUtc(),
 | 
					            Expires = expiresAt.ToDateTimeUtc(),
 | 
				
			||||||
            Issuer = _options.IssuerUri,
 | 
					            Issuer = _options.IssuerUri,
 | 
				
			||||||
            Audience = client.Id.ToString()
 | 
					            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
 | 
				
			||||||
@@ -205,7 +376,6 @@ public class OidcProviderService(
 | 
				
			|||||||
        return await db.AuthSessions
 | 
					        return await db.AuthSessions
 | 
				
			||||||
            .Include(s => s.Account)
 | 
					            .Include(s => s.Account)
 | 
				
			||||||
            .Include(s => s.Challenge)
 | 
					            .Include(s => s.Challenge)
 | 
				
			||||||
            .Include(s => s.App)
 | 
					 | 
				
			||||||
            .FirstOrDefaultAsync(s => s.Id == sessionId);
 | 
					            .FirstOrDefaultAsync(s => s.Id == sessionId);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -214,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,
 | 
				
			||||||
@@ -288,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);
 | 
				
			||||||
@@ -302,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)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -40,7 +40,7 @@ public class AfdianOidcService(
 | 
				
			|||||||
        return Task.FromResult(new OidcDiscoveryDocument
 | 
					        return Task.FromResult(new OidcDiscoveryDocument
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            AuthorizationEndpoint = "https://afdian.com/oauth2/authorize",
 | 
					            AuthorizationEndpoint = "https://afdian.com/oauth2/authorize",
 | 
				
			||||||
            TokenEndpoint = "https://afdian.com/oauth2/access_token",
 | 
					            TokenEndpoint = "https://afdian.com/api/oauth2/access_token",
 | 
				
			||||||
            UserinfoEndpoint = null,
 | 
					            UserinfoEndpoint = null,
 | 
				
			||||||
            JwksUri = null
 | 
					            JwksUri = null
 | 
				
			||||||
        })!;
 | 
					        })!;
 | 
				
			||||||
@@ -61,7 +61,7 @@ public class AfdianOidcService(
 | 
				
			|||||||
            });
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            var client = HttpClientFactory.CreateClient();
 | 
					            var client = HttpClientFactory.CreateClient();
 | 
				
			||||||
            var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/oauth2/access_token");
 | 
					            var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/oauth2/access_token");
 | 
				
			||||||
            request.Content = content;
 | 
					            request.Content = content;
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
            var response = await client.SendAsync(request);
 | 
					            var response = await client.SendAsync(request);
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -14,6 +14,7 @@ public class AppleMobileConnectRequest
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
public class AppleMobileSignInRequest : AppleMobileConnectRequest
 | 
					public class AppleMobileSignInRequest : AppleMobileConnectRequest
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    [Required]
 | 
					    [Required] [MaxLength(512)]
 | 
				
			||||||
    public required string DeviceId { get; set; }
 | 
					    public required string DeviceId { get; set; }
 | 
				
			||||||
 | 
					    [MaxLength(1024)] public string? DeviceName { get; set; }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -126,43 +126,6 @@ public class ConnectionController(
 | 
				
			|||||||
        public string? ReturnUrl { get; set; }
 | 
					        public string? ReturnUrl { get; set; }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /// <summary>
 | 
					 | 
				
			||||||
    /// Initiates manual connection to an OAuth provider for the current user
 | 
					 | 
				
			||||||
    /// </summary>
 | 
					 | 
				
			||||||
    [HttpPost("connect")]
 | 
					 | 
				
			||||||
    public async Task<ActionResult<object>> InitiateConnection([FromBody] ConnectProviderRequest request)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
 | 
					 | 
				
			||||||
            return Unauthorized();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        var oidcService = GetOidcService(request.Provider);
 | 
					 | 
				
			||||||
        if (oidcService == null)
 | 
					 | 
				
			||||||
            return BadRequest($"Provider '{request.Provider}' is not supported");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        var existingConnection = await db.AccountConnections
 | 
					 | 
				
			||||||
            .AnyAsync(c => c.AccountId == currentUser.Id && c.Provider == oidcService.ProviderName);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if (existingConnection)
 | 
					 | 
				
			||||||
            return BadRequest($"You already have a {request.Provider} connection");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        var state = Guid.NewGuid().ToString("N");
 | 
					 | 
				
			||||||
        var nonce = Guid.NewGuid().ToString("N");
 | 
					 | 
				
			||||||
        var stateValue = $"{currentUser.Id}|{request.Provider}|{nonce}";
 | 
					 | 
				
			||||||
        var finalReturnUrl = !string.IsNullOrEmpty(request.ReturnUrl) ? request.ReturnUrl : "/settings/connections";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        // Store state and return URL in cache
 | 
					 | 
				
			||||||
        await cache.SetAsync($"{StateCachePrefix}{state}", stateValue, StateExpiration);
 | 
					 | 
				
			||||||
        await cache.SetAsync($"{ReturnUrlCachePrefix}{state}", finalReturnUrl, StateExpiration);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        var authUrl = oidcService.GetAuthorizationUrl(state, nonce);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return Ok(new
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            authUrl,
 | 
					 | 
				
			||||||
            message = $"Redirect to this URL to connect your {request.Provider} account"
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    [AllowAnonymous]
 | 
					    [AllowAnonymous]
 | 
				
			||||||
    [Route("/auth/callback/{provider}")]
 | 
					    [Route("/auth/callback/{provider}")]
 | 
				
			||||||
    [HttpGet, HttpPost]
 | 
					    [HttpGet, HttpPost]
 | 
				
			||||||
@@ -194,7 +157,7 @@ public class ConnectionController(
 | 
				
			|||||||
        await cache.RemoveAsync(stateKey);
 | 
					        await cache.RemoveAsync(stateKey);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Handle the flow based on state type
 | 
					        // Handle the flow based on state type
 | 
				
			||||||
        if (oidcState.FlowType == OidcFlowType.Connect && oidcState.AccountId.HasValue)
 | 
					        if (oidcState is { FlowType: OidcFlowType.Connect, AccountId: not null })
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            // Connection flow
 | 
					            // Connection flow
 | 
				
			||||||
            if (oidcState.DeviceId != null)
 | 
					            if (oidcState.DeviceId != null)
 | 
				
			||||||
@@ -212,11 +175,10 @@ public class ConnectionController(
 | 
				
			|||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // Store return URL if provided
 | 
					            // Store return URL if provided
 | 
				
			||||||
            if (!string.IsNullOrEmpty(oidcState.ReturnUrl) && oidcState.ReturnUrl != "/")
 | 
					            if (string.IsNullOrEmpty(oidcState.ReturnUrl) || oidcState.ReturnUrl == "/")
 | 
				
			||||||
            {
 | 
					                return await HandleLoginOrRegistration(provider, oidcService, callbackData);
 | 
				
			||||||
            var returnUrlKey = $"{ReturnUrlCachePrefix}{callbackData.State}";
 | 
					            var returnUrlKey = $"{ReturnUrlCachePrefix}{callbackData.State}";
 | 
				
			||||||
            await cache.SetAsync(returnUrlKey, oidcState.ReturnUrl, StateExpiration);
 | 
					            await cache.SetAsync(returnUrlKey, oidcState.ReturnUrl, StateExpiration);
 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
            return await HandleLoginOrRegistration(provider, oidcService, callbackData);
 | 
					            return await HandleLoginOrRegistration(provider, oidcService, callbackData);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
@@ -378,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)
 | 
				
			||||||
 
 | 
				
			|||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user