Compare commits
	
		
			367 Commits
		
	
	
		
			162967e68b
			...
			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 | |||
| 3e7376c1f7 | |||
| fd81e8389c | |||
| 00dda8faf9 | |||
| 6b1dda41bc | |||
| fd1c47196d | |||
| 7383a5cff8 | |||
| 49fe70b0aa | |||
| 8e6e3e6289 | |||
| cb681681e1 | |||
| 1e25982c08 | |||
| e243b0f47a | |||
| 6f0a42820b | |||
| c1fc6837db | |||
| 51697c31cb | |||
| 409c83b030 | |||
| acb293ec8f | 
							
								
								
									
										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); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return await query.FirstOrDefaultAsync(a => a.Id == id); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public async Task<List<CustomApp>> GetAppsByPublisherAsync(Guid publisherId) |     public async Task<List<CustomAppSecret>> GetAppSecretsAsync(Guid appId) | ||||||
|     { |     { | ||||||
|         return await db.CustomApps.Where(a => a.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; | ||||||
|  |  | ||||||
| @@ -140,6 +137,8 @@ public static class ServiceCollectionExtensions | |||||||
|         services.AddScoped<Storage.FileReferenceService>(); |         services.AddScoped<Storage.FileReferenceService>(); | ||||||
|         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. | ||||||
| @@ -62,7 +41,7 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource | |||||||
|  |  | ||||||
|     /// The object name which stored remotely, |     /// The object name which stored remotely, | ||||||
|     /// multiple cloud file may have same storage id to indicate they are the same file |     /// multiple cloud file may have same storage id to indicate they are the same file | ||||||
|     ///  |     /// | ||||||
|     /// If the storage id was null and the uploaded at is not null, means it is an embedding file, |     /// If the storage id was null and the uploaded at is not null, means it is an embedding file, | ||||||
|     /// The embedding file means the file is store on another site, |     /// The embedding file means the file is store on another site, | ||||||
|     /// or it is a webpage (based on mimetype) |     /// or it is a webpage (based on mimetype) | ||||||
| @@ -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 | ||||||
| @@ -22,6 +24,7 @@ public class FileController( | |||||||
|         string id, |         string id, | ||||||
|         [FromQuery] bool download = false, |         [FromQuery] bool download = false, | ||||||
|         [FromQuery] bool original = false, |         [FromQuery] bool original = false, | ||||||
|  |         [FromQuery] bool thumbnail = false, | ||||||
|         [FromQuery] string? overrideMimeType = null, |         [FromQuery] string? overrideMimeType = null, | ||||||
|         [FromQuery] string? passcode = null |         [FromQuery] string? passcode = null | ||||||
|     ) |     ) | ||||||
| @@ -51,7 +54,7 @@ public class FileController( | |||||||
|             if (!System.IO.File.Exists(filePath)) return new NotFoundResult(); |             if (!System.IO.File.Exists(filePath)) return new NotFoundResult(); | ||||||
|             return PhysicalFile(filePath, file.MimeType ?? "application/octet-stream", file.Name); |             return PhysicalFile(filePath, file.MimeType ?? "application/octet-stream", file.Name); | ||||||
|         } |         } | ||||||
|          |  | ||||||
|         var pool = await fs.GetPoolAsync(file.PoolId.Value); |         var pool = await fs.GetPoolAsync(file.PoolId.Value); | ||||||
|         if (pool is null) |         if (pool is null) | ||||||
|             return StatusCode(StatusCodes.Status410Gone, "The pool of the file no longer exists or not accessible."); |             return StatusCode(StatusCodes.Status410Gone, "The pool of the file no longer exists or not accessible."); | ||||||
| @@ -64,6 +67,15 @@ public class FileController( | |||||||
|  |  | ||||||
|         var fileName = string.IsNullOrWhiteSpace(file.StorageId) ? file.Id : file.StorageId; |         var fileName = string.IsNullOrWhiteSpace(file.StorageId) ? file.Id : file.StorageId; | ||||||
|  |  | ||||||
|  |         switch (thumbnail) | ||||||
|  |         { | ||||||
|  |             case true when file.HasThumbnail: | ||||||
|  |                 fileName += ".thumbnail"; | ||||||
|  |                 break; | ||||||
|  |             case true when !file.HasThumbnail: | ||||||
|  |                 return NotFound(); | ||||||
|  |         } | ||||||
|  |          | ||||||
|         if (!original && file.HasCompression) |         if (!original && file.HasCompression) | ||||||
|             fileName += ".compressed"; |             fileName += ".compressed"; | ||||||
|  |  | ||||||
| @@ -88,7 +100,8 @@ public class FileController( | |||||||
|             var client = fs.CreateMinioClient(dest); |             var client = fs.CreateMinioClient(dest); | ||||||
|             if (client is null) |             if (client is null) | ||||||
|                 return BadRequest( |                 return BadRequest( | ||||||
|                     "Failed to configure client for remote destination, file got an invalid storage remote."); |                     "Failed to configure client for remote destination, file got an invalid storage remote." | ||||||
|  |                 ); | ||||||
|  |  | ||||||
|             var headers = new Dictionary<string, string>(); |             var headers = new Dictionary<string, string>(); | ||||||
|             if (fileExtension is not null) |             if (fileExtension is not null) | ||||||
| @@ -137,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( | ||||||
| @@ -182,11 +242,9 @@ public class FileController( | |||||||
|             .FirstOrDefaultAsync(); |             .FirstOrDefaultAsync(); | ||||||
|         if (file is null) return NotFound(); |         if (file is null) return NotFound(); | ||||||
|  |  | ||||||
|  |         await fs.DeleteFileDataAsync(file, force: true); | ||||||
|         await fs.DeleteFileAsync(file); |         await fs.DeleteFileAsync(file); | ||||||
|  |  | ||||||
|         db.Files.Remove(file); |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|         return NoContent(); |         return NoContent(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -200,7 +258,7 @@ public class FileController( | |||||||
|         var count = await fs.DeleteAccountRecycledFilesAsync(accountId); |         var count = await fs.DeleteAccountRecycledFilesAsync(accountId); | ||||||
|         return Ok(new { Count = count }); |         return Ok(new { Count = count }); | ||||||
|     } |     } | ||||||
|      |  | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     [HttpDelete("recycle")] |     [HttpDelete("recycle")] | ||||||
|     [RequiredPermission("maintenance", "files.delete.recycle")] |     [RequiredPermission("maintenance", "files.delete.recycle")] | ||||||
| @@ -209,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(); | ||||||
|  |  | ||||||
| @@ -207,6 +205,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 | ||||||
|   | |||||||
| @@ -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 | ||||||
| @@ -93,6 +93,18 @@ namespace DysonNetwork.Drive.Storage | |||||||
|                     await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId, request.Usage!); |                     await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId, request.Usage!); | ||||||
|             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) | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | using System.Drawing; | ||||||
| using System.Globalization; | using System.Globalization; | ||||||
| using FFMpegCore; | using FFMpegCore; | ||||||
| using System.Security.Cryptography; | using System.Security.Cryptography; | ||||||
| @@ -11,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; | ||||||
| @@ -18,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 | ||||||
| @@ -100,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, | ||||||
| @@ -115,7 +119,7 @@ public class FileService( | |||||||
|     ) |     ) | ||||||
|     { |     { | ||||||
|         var accountId = Guid.Parse(account.Id); |         var accountId = Guid.Parse(account.Id); | ||||||
|          |  | ||||||
|         var pool = await GetPoolAsync(Guid.Parse(filePool)); |         var pool = await GetPoolAsync(Guid.Parse(filePool)); | ||||||
|         if (pool is null) throw new InvalidOperationException("Pool not found"); |         if (pool is null) throw new InvalidOperationException("Pool not found"); | ||||||
|  |  | ||||||
| @@ -127,7 +131,7 @@ public class FileService( | |||||||
|                 : expectedExpiration; |                 : expectedExpiration; | ||||||
|             expiredAt = SystemClock.Instance.GetCurrentInstant() + effectiveExpiration; |             expiredAt = SystemClock.Instance.GetCurrentInstant() + effectiveExpiration; | ||||||
|         } |         } | ||||||
|          |  | ||||||
|         var bundle = fileBundleId is not null |         var bundle = fileBundleId is not null | ||||||
|             ? await GetBundleAsync(Guid.Parse(fileBundleId), accountId) |             ? await GetBundleAsync(Guid.Parse(fileBundleId), accountId) | ||||||
|             : null; |             : null; | ||||||
| @@ -135,8 +139,8 @@ public class FileService( | |||||||
|         { |         { | ||||||
|             throw new InvalidOperationException("Bundle not found"); |             throw new InvalidOperationException("Bundle not found"); | ||||||
|         } |         } | ||||||
|          |  | ||||||
|         if (bundle?.ExpiredAt != null)  |         if (bundle?.ExpiredAt != null) | ||||||
|             expiredAt = bundle.ExpiredAt.Value; |             expiredAt = bundle.ExpiredAt.Value; | ||||||
|  |  | ||||||
|         var ogFilePath = Path.GetFullPath(Path.Join(configuration.GetValue<string>("Tus:StorePath"), fileId)); |         var ogFilePath = Path.GetFullPath(Path.Join(configuration.GetValue<string>("Tus:StorePath"), fileId)); | ||||||
| @@ -169,33 +173,13 @@ public class FileService( | |||||||
|             IsEncrypted = !string.IsNullOrWhiteSpace(encryptPassword) && pool.PolicyConfig.AllowEncryption |             IsEncrypted = !string.IsNullOrWhiteSpace(encryptPassword) && pool.PolicyConfig.AllowEncryption | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         // TODO: Enable the feature later |  | ||||||
|         // var existingFile = await db.Files.AsNoTracking().FirstOrDefaultAsync(f => f.Hash == hash); |  | ||||||
|         // file.StorageId = existingFile?.StorageId ?? file.Id; |  | ||||||
|         // |  | ||||||
|         // if (existingFile is not null) |  | ||||||
|         // { |  | ||||||
|         //     file.FileMeta = existingFile.FileMeta; |  | ||||||
|         //     file.HasCompression = existingFile.HasCompression; |  | ||||||
|         //     file.SensitiveMarks = existingFile.SensitiveMarks; |  | ||||||
|         //     file.MimeType = existingFile.MimeType; |  | ||||||
|         //     file.UploadedAt = existingFile.UploadedAt; |  | ||||||
|         //     file.PoolId = existingFile.PoolId; |  | ||||||
|         // |  | ||||||
|         //     db.Files.Add(file); |  | ||||||
|         //     await db.SaveChangesAsync(); |  | ||||||
|         //     // Since the file content is a duplicate, we can delete the new upload and we are done. |  | ||||||
|         //     await stream.DisposeAsync(); |  | ||||||
|         //     return file; |  | ||||||
|         // } |  | ||||||
|  |  | ||||||
|         // Extract metadata on the current thread for a faster initial response |         // Extract metadata on the current thread for a faster initial response | ||||||
|         if (!pool.PolicyConfig.NoMetadata) |         if (!pool.PolicyConfig.NoMetadata) | ||||||
|             await ExtractMetadataAsync(file, ogFilePath, stream); |             await ExtractMetadataAsync(file, ogFilePath, stream); | ||||||
|  |  | ||||||
|         db.Files.Add(file); |         db.Files.Add(file); | ||||||
|         await db.SaveChangesAsync(); |         await db.SaveChangesAsync(); | ||||||
|          |  | ||||||
|         file.StorageId ??= file.Id; |         file.StorageId ??= file.Id; | ||||||
|  |  | ||||||
|         // Offload optimization (image conversion, thumbnailing) and uploading to a background task |         // Offload optimization (image conversion, thumbnailing) and uploading to a background task | ||||||
| @@ -272,6 +256,8 @@ public class FileService( | |||||||
|                     var mediaInfo = await FFProbe.AnalyseAsync(filePath); |                     var mediaInfo = await FFProbe.AnalyseAsync(filePath); | ||||||
|                     file.FileMeta = new Dictionary<string, object?> |                     file.FileMeta = new Dictionary<string, object?> | ||||||
|                     { |                     { | ||||||
|  |                         ["width"] = mediaInfo.PrimaryVideoStream?.Width, | ||||||
|  |                         ["height"] = mediaInfo.PrimaryVideoStream?.Height, | ||||||
|                         ["duration"] = mediaInfo.Duration.TotalSeconds, |                         ["duration"] = mediaInfo.Duration.TotalSeconds, | ||||||
|                         ["format_name"] = mediaInfo.Format.FormatName, |                         ["format_name"] = mediaInfo.Format.FormatName, | ||||||
|                         ["format_long_name"] = mediaInfo.Format.FormatLongName, |                         ["format_long_name"] = mediaInfo.Format.FormatLongName, | ||||||
| @@ -282,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, | ||||||
|                         }).ToList(), |                             s.CodecName, | ||||||
|  |                             s.Duration, | ||||||
|  |                             s.Height, | ||||||
|  |                             s.Width, | ||||||
|  |                             s.Language, | ||||||
|  |                             s.PixelFormat, | ||||||
|  |                             s.Rotation | ||||||
|  |                         }).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(), | ||||||
| @@ -334,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)) | ||||||
|                         { |                         { | ||||||
| @@ -375,19 +382,24 @@ public class FileService( | |||||||
|  |  | ||||||
|                     case "video": |                     case "video": | ||||||
|                         uploads.Add((originalFilePath, string.Empty, contentType, false)); |                         uploads.Add((originalFilePath, string.Empty, contentType, false)); | ||||||
|                         var thumbnailPath = Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{fileId}.thumbnail.webp"); |  | ||||||
|  |                         var thumbnailPath = Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{fileId}.thumbnail.jpg"); | ||||||
|                         try |                         try | ||||||
|                         { |                         { | ||||||
|                             var mediaInfo = await FFProbe.AnalyseAsync(originalFilePath); |                             await FFMpegArguments | ||||||
|                             var snapshotTime = mediaInfo.Duration > TimeSpan.FromSeconds(5) |                                 .FromFileInput(originalFilePath, verifyExists: true) | ||||||
|                                 ? TimeSpan.FromSeconds(5) |                                 .OutputToFile(thumbnailPath, overwrite: true, options => options | ||||||
|                                 : TimeSpan.FromSeconds(1); |                                     .Seek(TimeSpan.FromSeconds(0)) | ||||||
|  |                                     .WithFrameOutputCount(1) | ||||||
|                             await FFMpeg.SnapshotAsync(originalFilePath, thumbnailPath, captureTime: snapshotTime); |                                     .WithCustomArgument("-q:v 2") | ||||||
|  |                                 ) | ||||||
|  |                                 .NotifyOnOutput(line => logger.LogInformation("[FFmpeg] {Line}", line)) | ||||||
|  |                                 .NotifyOnError(line => logger.LogWarning("[FFmpeg] {Line}", line)) | ||||||
|  |                                 .ProcessAsynchronously(); | ||||||
|  |  | ||||||
|                             if (File.Exists(thumbnailPath)) |                             if (File.Exists(thumbnailPath)) | ||||||
|                             { |                             { | ||||||
|                                 uploads.Add((thumbnailPath, ".thumbnail", "image/webp", true)); |                                 uploads.Add((thumbnailPath, ".thumbnail", "image/jpeg", true)); | ||||||
|                                 hasThumbnail = true; |                                 hasThumbnail = true; | ||||||
|                             } |                             } | ||||||
|                             else |                             else | ||||||
| @@ -456,7 +468,7 @@ public class FileService( | |||||||
|         var fileInfo = new FileInfo(filePath); |         var fileInfo = new FileInfo(filePath); | ||||||
|         if (fileInfo.Length > chunkSize * 1024 * 5) |         if (fileInfo.Length > chunkSize * 1024 * 5) | ||||||
|             return await HashFastApproximateAsync(filePath, chunkSize); |             return await HashFastApproximateAsync(filePath, chunkSize); | ||||||
|          |  | ||||||
|         await using var stream = File.OpenRead(filePath); |         await using var stream = File.OpenRead(filePath); | ||||||
|         using var md5 = MD5.Create(); |         using var md5 = MD5.Create(); | ||||||
|         var hashBytes = await md5.ComputeHashAsync(stream); |         var hashBytes = await md5.ComputeHashAsync(stream); | ||||||
| @@ -483,16 +495,27 @@ public class FileService( | |||||||
|         return Convert.ToHexString(hash).ToLowerInvariant(); |         return Convert.ToHexString(hash).ToLowerInvariant(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task UploadFileToRemoteAsync(string storageId, Guid targetRemote, string filePath, |     private async Task UploadFileToRemoteAsync( | ||||||
|         string? suffix = null, string? contentType = null, bool selfDestruct = false) |         string storageId, | ||||||
|  |         Guid targetRemote, | ||||||
|  |         string filePath, | ||||||
|  |         string? suffix = null, | ||||||
|  |         string? contentType = null, | ||||||
|  |         bool selfDestruct = false | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         await using var fileStream = File.OpenRead(filePath); |         await using var fileStream = File.OpenRead(filePath); | ||||||
|         await UploadFileToRemoteAsync(storageId, targetRemote, fileStream, suffix, contentType); |         await UploadFileToRemoteAsync(storageId, targetRemote, fileStream, suffix, contentType); | ||||||
|         if (selfDestruct) File.Delete(filePath); |         if (selfDestruct) File.Delete(filePath); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task UploadFileToRemoteAsync(string storageId, Guid targetRemote, Stream stream, |     private async Task UploadFileToRemoteAsync( | ||||||
|         string? suffix = null, string? contentType = null) |         string storageId, | ||||||
|  |         Guid targetRemote, | ||||||
|  |         Stream stream, | ||||||
|  |         string? suffix = null, | ||||||
|  |         string? contentType = null | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var dest = await GetRemoteStorageConfig(targetRemote); |         var dest = await GetRemoteStorageConfig(targetRemote); | ||||||
|         if (dest is null) |         if (dest is null) | ||||||
| @@ -564,9 +587,8 @@ public class FileService( | |||||||
|         await DeleteFileDataAsync(file); |         await DeleteFileDataAsync(file); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private async Task DeleteFileDataAsync(CloudFile file, bool force = false) |     public async Task DeleteFileDataAsync(CloudFile file, bool force = false) | ||||||
|     { |     { | ||||||
|         if (file.StorageId is null) return; |  | ||||||
|         if (!file.PoolId.HasValue) return; |         if (!file.PoolId.HasValue) return; | ||||||
|  |  | ||||||
|         if (!force) |         if (!force) | ||||||
| @@ -629,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) | ||||||
| @@ -822,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,13 +228,33 @@ public abstract class TusService | |||||||
|                         ); |                         ); | ||||||
|                         rejected = true; |                         rejected = true; | ||||||
|                     } |                     } | ||||||
|                     else if (!policy.AcceptTypes.Contains(contentType)) |                     else | ||||||
|                     { |                     { | ||||||
|                         eventContext.FailRequest( |                         var foundMatch = false; | ||||||
|                             HttpStatusCode.Forbidden, |                         foreach (var acceptType in policy.AcceptTypes) | ||||||
|                             $"Content type {contentType} is not allowed by the pool's policy" |                         { | ||||||
|                         ); |                             if (acceptType.EndsWith("/*", StringComparison.OrdinalIgnoreCase)) | ||||||
|                         rejected = true; |                             { | ||||||
|  |                                 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( | ||||||
|  |                                 HttpStatusCode.Forbidden, | ||||||
|  |                                 $"Content type {contentType} is not allowed by the pool's policy" | ||||||
|  |                             ); | ||||||
|  |                             rejected = true; | ||||||
|  |                         } | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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,13 +30,14 @@ 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(); | ||||||
|          |  | ||||||
|         return account; |         return account; | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -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(); | ||||||
| @@ -38,7 +41,7 @@ public class AccountCurrentController( | |||||||
|             .Include(e => e.Profile) |             .Include(e => e.Profile) | ||||||
|             .Where(e => e.Id == userId) |             .Where(e => e.Id == userId) | ||||||
|             .FirstOrDefaultAsync(); |             .FirstOrDefaultAsync(); | ||||||
|          |  | ||||||
|         var perk = await subscriptions.GetPerkSubscriptionAsync(account!.Id); |         var perk = await subscriptions.GetPerkSubscriptionAsync(account!.Id); | ||||||
|         account.PerkSubscription = perk?.ToReference(); |         account.PerkSubscription = perk?.ToReference(); | ||||||
|  |  | ||||||
| @@ -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) | ||||||
|         { |         { | ||||||
| @@ -120,6 +134,7 @@ public class AccountCurrentController( | |||||||
|             ); |             ); | ||||||
|             profile.Picture = CloudFileReferenceObject.FromProtoValue(file); |             profile.Picture = CloudFileReferenceObject.FromProtoValue(file); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if (request.BackgroundId is not null) |         if (request.BackgroundId is not null) | ||||||
|         { |         { | ||||||
|             var file = await files.GetFileAsync(new GetFileRequest { Id = request.BackgroundId }); |             var file = await files.GetFileAsync(new GetFileRequest { Id = request.BackgroundId }); | ||||||
| @@ -157,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(); | ||||||
| @@ -176,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 | ||||||
| @@ -183,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); | ||||||
| @@ -204,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(); | ||||||
|  |  | ||||||
| @@ -251,17 +315,48 @@ 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")] | ||||||
|     public async Task<ActionResult<CheckInResult>> DoCheckIn([FromBody] string? captchaToken) |     public async Task<ActionResult<CheckInResult>> DoCheckIn( | ||||||
|  |         [FromBody] string? captchaToken, | ||||||
|  |         [FromQuery] Instant? backdated = null | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         var isAvailable = await events.CheckInDailyIsAvailable(currentUser); |         if (backdated is null) | ||||||
|         if (!isAvailable) |         { | ||||||
|             return BadRequest("Check-in is not available for today."); |             var isAvailable = await events.CheckInDailyIsAvailable(currentUser); | ||||||
|  |             if (!isAvailable) | ||||||
|  |                 return BadRequest(new ApiError | ||||||
|  |                 { | ||||||
|  |                     Code = "BAD_REQUEST", | ||||||
|  |                     Message = "Check-in is not available for today.", | ||||||
|  |                     Status = 400, | ||||||
|  |                     TraceId = HttpContext.TraceIdentifier | ||||||
|  |                 }); | ||||||
|  |         } | ||||||
|  |         else | ||||||
|  |         { | ||||||
|  |             if (currentUser.PerkSubscription is null) | ||||||
|  |                 return StatusCode(403, ApiError.Unauthorized( | ||||||
|  |                     message: "You need to have a subscription to check-in backdated.", | ||||||
|  |                     forbidden: true, | ||||||
|  |                     traceId: HttpContext.TraceIdentifier)); | ||||||
|  |             var isAvailable = await events.CheckInBackdatedIsAvailable(currentUser, backdated.Value); | ||||||
|  |             if (!isAvailable) | ||||||
|  |                 return BadRequest(new ApiError | ||||||
|  |                 { | ||||||
|  |                     Code = "BAD_REQUEST", | ||||||
|  |                     Message = "Check-in is not available for this date.", | ||||||
|  |                     Status = 400, | ||||||
|  |                     TraceId = HttpContext.TraceIdentifier | ||||||
|  |                 }); | ||||||
|  |         } | ||||||
|  |  | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
| @@ -269,14 +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 | ||||||
|                 true when !await auth.ValidateCaptcha(captchaToken!) => BadRequest("Invalid captcha token."), |                     { | ||||||
|                 _ => await events.CheckInDaily(currentUser) |                         Code = "CAPTCHA_REQUIRED", | ||||||
|  |                         Message = "Captcha is required for this check-in.", | ||||||
|  |                         Status = 423, | ||||||
|  |                         TraceId = HttpContext.TraceIdentifier | ||||||
|  |                     } | ||||||
|  |                 ), | ||||||
|  |                 true when !await auth.ValidateCaptcha(captchaToken!) => BadRequest(ApiError.Validation( | ||||||
|  |                     new Dictionary<string, string[]> | ||||||
|  |                     { | ||||||
|  |                         ["captchaToken"] = new[] { "Invalid captcha token." } | ||||||
|  |                     }, traceId: HttpContext.TraceIdentifier)), | ||||||
|  |                 _ => await events.CheckInDaily(currentUser, backdated) | ||||||
|             }; |             }; | ||||||
|         } |         } | ||||||
|         catch (InvalidOperationException ex) |         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 | ||||||
|  |             }); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -290,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); | ||||||
| @@ -347,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); | ||||||
| @@ -362,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 | ||||||
|         { |         { | ||||||
| @@ -371,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 | ||||||
|  |             }); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -419,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")] | ||||||
| @@ -505,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() | ||||||
| @@ -523,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) | ||||||
| @@ -539,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) | ||||||
| @@ -636,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) | ||||||
| @@ -687,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); | ||||||
|  |     } | ||||||
| } | } | ||||||
| @@ -5,6 +5,7 @@ using DysonNetwork.Shared.Proto; | |||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.Extensions.Localization; | using Microsoft.Extensions.Localization; | ||||||
| using NodaTime; | using NodaTime; | ||||||
|  | using NodaTime.Extensions; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Pass.Account; | namespace DysonNetwork.Pass.Account; | ||||||
|  |  | ||||||
| @@ -13,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(); | ||||||
| @@ -166,11 +169,14 @@ public class AccountEventService( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     private const int FortuneTipCount = 14; // This will be the max index for each type (positive/negative) |     private const int FortuneTipCount = 14; // This will be the max index for each type (positive/negative) | ||||||
|     private const string CaptchaCacheKey = "CheckInCaptcha_"; |     private const string CaptchaCacheKey = "checkin:captcha:"; | ||||||
|     private const int CaptchaProbabilityPercent = 20; |     private const int CaptchaProbabilityPercent = 20; | ||||||
|  |  | ||||||
|     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) | ||||||
| @@ -198,9 +204,55 @@ public class AccountEventService( | |||||||
|         return lastDate < currentDate; |         return lastDate < currentDate; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public const string CheckInLockKey = "CheckInLock_"; |     public async Task<bool> CheckInBackdatedIsAvailable(Account user, Instant backdated) | ||||||
|  |     { | ||||||
|  |         var aDay = Duration.FromDays(1); | ||||||
|  |         var backdatedStart = backdated.ToDateTimeUtc().Date.ToInstant(); | ||||||
|  |         var backdatedEnd = backdated.Plus(aDay).ToDateTimeUtc().Date.ToInstant(); | ||||||
|  |  | ||||||
|     public async Task<CheckInResult> CheckInDaily(Account user) |         var backdatedDate = backdated.ToDateTimeUtc(); | ||||||
|  |         var backdatedMonthStart = new DateTime( | ||||||
|  |             backdatedDate.Year, | ||||||
|  |             backdatedDate.Month, | ||||||
|  |             1, | ||||||
|  |             0, | ||||||
|  |             0, | ||||||
|  |             0 | ||||||
|  |         ).ToInstant(); | ||||||
|  |         var backdatedMonthEnd = | ||||||
|  |             new DateTime( | ||||||
|  |                 backdatedDate.Year, | ||||||
|  |                 backdatedDate.Month, | ||||||
|  |                 DateTime.DaysInMonth( | ||||||
|  |                     backdatedDate.Year, | ||||||
|  |                     backdatedDate.Month | ||||||
|  |                 ), | ||||||
|  |                 23, | ||||||
|  |                 59, | ||||||
|  |                 59 | ||||||
|  |             ).ToInstant(); | ||||||
|  |  | ||||||
|  |         // The first check, if that day already has a check-in | ||||||
|  |         var lastCheckIn = await db.AccountCheckInResults | ||||||
|  |             .Where(x => x.AccountId == user.Id) | ||||||
|  |             .Where(x => x.CreatedAt >= backdatedStart && x.CreatedAt < backdatedEnd) | ||||||
|  |             .OrderByDescending(x => x.CreatedAt) | ||||||
|  |             .FirstOrDefaultAsync(); | ||||||
|  |         if (lastCheckIn is not null) return false; | ||||||
|  |  | ||||||
|  |         // The second check, is the user reached the max backdated check-ins limit, | ||||||
|  |         // which is once a week, which is 4 times a month | ||||||
|  |         var backdatedCheckInMonths = await db.AccountCheckInResults | ||||||
|  |             .Where(x => x.AccountId == user.Id) | ||||||
|  |             .Where(x => x.CreatedAt >= backdatedMonthStart && x.CreatedAt < backdatedMonthEnd) | ||||||
|  |             .Where(x => x.BackdatedFrom != null) | ||||||
|  |             .CountAsync(); | ||||||
|  |         return backdatedCheckInMonths < 4; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public const string CheckInLockKey = "checkin:lock:"; | ||||||
|  |  | ||||||
|  |     public async Task<CheckInResult> CheckInDaily(Account user, Instant? backdated = null) | ||||||
|     { |     { | ||||||
|         var lockKey = $"{CheckInLockKey}{user.Id}"; |         var lockKey = $"{CheckInLockKey}{user.Id}"; | ||||||
|  |  | ||||||
| @@ -254,7 +306,9 @@ public class AccountEventService( | |||||||
|             Level = (CheckInResultLevel)Random.Next(Enum.GetValues<CheckInResultLevel>().Length), |             Level = (CheckInResultLevel)Random.Next(Enum.GetValues<CheckInResultLevel>().Length), | ||||||
|             AccountId = user.Id, |             AccountId = user.Id, | ||||||
|             RewardExperience = 100, |             RewardExperience = 100, | ||||||
|             RewardPoints = 10, |             RewardPoints = backdated.HasValue ? null : 10, | ||||||
|  |             BackdatedFrom = backdated.HasValue ? SystemClock.Instance.GetCurrentInstant() : null, | ||||||
|  |             CreatedAt = backdated ?? SystemClock.Instance.GetCurrentInstant(), | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         var now = SystemClock.Instance.GetCurrentInstant().InUtc().Date; |         var now = SystemClock.Instance.GetCurrentInstant().InUtc().Date; | ||||||
| @@ -274,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; | ||||||
| @@ -298,7 +354,7 @@ public class AccountEventService( | |||||||
|  |  | ||||||
|         var statuses = await db.AccountStatuses |         var statuses = await db.AccountStatuses | ||||||
|             .AsNoTracking() |             .AsNoTracking() | ||||||
|             .TagWith("GetEventCalendar_Statuses") |             .TagWith("eventcal:statuses") | ||||||
|             .Where(x => x.AccountId == user.Id && x.CreatedAt >= startOfMonth && x.CreatedAt < endOfMonth) |             .Where(x => x.AccountId == user.Id && x.CreatedAt >= startOfMonth && x.CreatedAt < endOfMonth) | ||||||
|             .Select(x => new Status |             .Select(x => new Status | ||||||
|             { |             { | ||||||
| @@ -316,7 +372,7 @@ public class AccountEventService( | |||||||
|  |  | ||||||
|         var checkIn = await db.AccountCheckInResults |         var checkIn = await db.AccountCheckInResults | ||||||
|             .AsNoTracking() |             .AsNoTracking() | ||||||
|             .TagWith("GetEventCalendar_CheckIn") |             .TagWith("eventcal:checkin") | ||||||
|             .Where(x => x.AccountId == user.Id && x.CreatedAt >= startOfMonth && x.CreatedAt < endOfMonth) |             .Where(x => x.AccountId == user.Id && x.CreatedAt >= startOfMonth && x.CreatedAt < endOfMonth) | ||||||
|             .ToListAsync(); |             .ToListAsync(); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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() | ||||||
| @@ -135,7 +153,7 @@ public class AccountService( | |||||||
|         await db.SaveChangesAsync(); |         await db.SaveChangesAsync(); | ||||||
|  |  | ||||||
|         if (isActivated) return account; |         if (isActivated) return account; | ||||||
|          |  | ||||||
|         var spell = await spells.CreateMagicSpell( |         var spell = await spells.CreateMagicSpell( | ||||||
|             account, |             account, | ||||||
|             MagicSpellType.AccountActivation, |             MagicSpellType.AccountActivation, | ||||||
| @@ -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 newProfiles = missingId.Select(id => new AccountProfile { Id = Guid.NewGuid(), AccountId = id }) |  | ||||||
|                 .ToList(); |         var js = nats.CreateJetStreamContext(); | ||||||
|             await db.BulkInsertAsync(newProfiles); |         await js.PublishAsync( | ||||||
|         } |             AccountDeletedEvent.Type, | ||||||
|  |             GrpcTypeHelper.ConvertObjectToByteString(new AccountDeletedEvent | ||||||
|  |             { | ||||||
|  |                 AccountId = account.Id, | ||||||
|  |                 DeletedAt = SystemClock.Instance.GetCurrentInstant() | ||||||
|  |             }).ToByteArray() | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -9,6 +9,7 @@ namespace DysonNetwork.Pass.Account; | |||||||
|  |  | ||||||
| public class AccountServiceGrpc( | 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) | ||||||
|     { |     { | ||||||
| @@ -55,7 +76,35 @@ public class AccountServiceGrpc( | |||||||
|             .Where(a => accountIds.Contains(a.Id)) |             .Where(a => accountIds.Contains(a.Id)) | ||||||
|             .Include(a => a.Profile) |             .Include(a => a.Profile) | ||||||
|             .ToListAsync(); |             .ToListAsync(); | ||||||
|          |  | ||||||
|  |         var perks = await subscriptions.GetPerkSubscriptionsAsync( | ||||||
|  |             accounts.Select(x => x.Id).ToList() | ||||||
|  |         ); | ||||||
|  |         foreach (var account in accounts) | ||||||
|  |             if (perks.TryGetValue(account.Id, out var perk)) | ||||||
|  |                 account.PerkSubscription = perk?.ToReference(); | ||||||
|  |  | ||||||
|  |         var response = new GetAccountBatchResponse(); | ||||||
|  |         response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue())); | ||||||
|  |         return response; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |      | ||||||
|  |     public override async Task<GetAccountBatchResponse> GetBotAccountBatch(GetBotAccountBatchRequest request, | ||||||
|  |         ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         var automatedIds = request.AutomatedId | ||||||
|  |             .Select(id => Guid.TryParse(id, out var automatedId) ? automatedId : (Guid?)null) | ||||||
|  |             .Where(id => id.HasValue) | ||||||
|  |             .Select(id => id!.Value) | ||||||
|  |             .ToList(); | ||||||
|  |  | ||||||
|  |         var accounts = await _db.Accounts | ||||||
|  |             .AsNoTracking() | ||||||
|  |             .Where(a => a.AutomatedId != null && automatedIds.Contains(a.AutomatedId.Value)) | ||||||
|  |             .Include(a => a.Profile) | ||||||
|  |             .ToListAsync(); | ||||||
|  |  | ||||||
|         var perks = await subscriptions.GetPerkSubscriptionsAsync( |         var perks = await subscriptions.GetPerkSubscriptionsAsync( | ||||||
|             accounts.Select(x => x.Id).ToList() |             accounts.Select(x => x.Id).ToList() | ||||||
|         ); |         ); | ||||||
| @@ -68,6 +117,27 @@ public class AccountServiceGrpc( | |||||||
|         return response; |         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) | ||||||
|     { |     { | ||||||
| @@ -77,14 +147,14 @@ public class AccountServiceGrpc( | |||||||
|             .Where(a => accountNames.Contains(a.Name)) |             .Where(a => accountNames.Contains(a.Name)) | ||||||
|             .Include(a => a.Profile) |             .Include(a => a.Profile) | ||||||
|             .ToListAsync(); |             .ToListAsync(); | ||||||
|          |  | ||||||
|         var perks = await subscriptions.GetPerkSubscriptionsAsync( |         var perks = await subscriptions.GetPerkSubscriptionsAsync( | ||||||
|             accounts.Select(x => x.Id).ToList() |             accounts.Select(x => x.Id).ToList() | ||||||
|         ); |         ); | ||||||
|         foreach (var account in accounts) |         foreach (var account in accounts) | ||||||
|             if (perks.TryGetValue(account.Id, out var perk)) |             if (perks.TryGetValue(account.Id, out var perk)) | ||||||
|                 account.PerkSubscription = perk?.ToReference(); |                 account.PerkSubscription = perk?.ToReference(); | ||||||
|          |  | ||||||
|         var response = new GetAccountBatchResponse(); |         var response = new GetAccountBatchResponse(); | ||||||
|         response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue())); |         response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue())); | ||||||
|         return response; |         return response; | ||||||
|   | |||||||
| @@ -1,6 +1,8 @@ | |||||||
| using System.ComponentModel.DataAnnotations; | using System.ComponentModel.DataAnnotations; | ||||||
| using System.ComponentModel.DataAnnotations.Schema; | using System.ComponentModel.DataAnnotations.Schema; | ||||||
|  | using System.Text.Json.Serialization; | ||||||
| using DysonNetwork.Shared.Data; | using DysonNetwork.Shared.Data; | ||||||
|  | using DysonNetwork.Shared.GeoIp; | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
| using NodaTime.Serialization.Protobuf; | using NodaTime.Serialization.Protobuf; | ||||||
| using Point = NetTopologySuite.Geometries.Point; | using Point = NetTopologySuite.Geometries.Point; | ||||||
| @@ -14,7 +16,7 @@ public class ActionLog : ModelBase | |||||||
|     [Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new(); |     [Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new(); | ||||||
|     [MaxLength(512)] public string? UserAgent { get; set; } |     [MaxLength(512)] public string? UserAgent { get; set; } | ||||||
|     [MaxLength(128)] public string? IpAddress { get; set; } |     [MaxLength(128)] public string? IpAddress { get; set; } | ||||||
|     public Point? Location { get; set; } |     [Column(TypeName = "jsonb")] public GeoPoint? Location { get; set; } | ||||||
|  |  | ||||||
|     public Guid AccountId { get; set; } |     public Guid AccountId { get; set; } | ||||||
|     public Account Account { get; set; } = null!; |     public Account Account { get; set; } = null!; | ||||||
|   | |||||||
							
								
								
									
										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 | ||||||
| @@ -43,9 +98,11 @@ public class CheckInResult : ModelBase | |||||||
|     public decimal? RewardPoints { get; set; } |     public decimal? RewardPoints { get; set; } | ||||||
|     public int? RewardExperience { get; set; } |     public int? RewardExperience { get; set; } | ||||||
|     [Column(TypeName = "jsonb")] public ICollection<FortuneTip> Tips { get; set; } = new List<FortuneTip>(); |     [Column(TypeName = "jsonb")] public ICollection<FortuneTip> Tips { get; set; } = new List<FortuneTip>(); | ||||||
|      |  | ||||||
|     public Guid AccountId { get; set; } |     public Guid AccountId { get; set; } | ||||||
|     public Account Account { get; set; } = null!; |     public Account Account { get; set; } = null!; | ||||||
|  |  | ||||||
|  |     public Instant? BackdatedFrom { get; set; } | ||||||
| } | } | ||||||
|  |  | ||||||
| public class FortuneTip | public class FortuneTip | ||||||
| @@ -63,4 +120,4 @@ public class DailyEventResponse | |||||||
|     public Instant Date { get; set; } |     public Instant Date { get; set; } | ||||||
|     public CheckInResult? CheckInResult { get; set; } |     public CheckInResult? CheckInResult { get; set; } | ||||||
|     public ICollection<Status> Statuses { get; set; } = new List<Status>(); |     public ICollection<Status> Statuses { get; set; } = new List<Status>(); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -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,14 +1,22 @@ | |||||||
|  | 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:"; | ||||||
|      |  | ||||||
|     public async Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId) |     public async Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId) | ||||||
|     { |     { | ||||||
|         var count = await db.AccountRelationships |         var count = await db.AccountRelationships | ||||||
| @@ -51,7 +59,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache) | |||||||
|  |  | ||||||
|         db.AccountRelationships.Add(relationship); |         db.AccountRelationships.Add(relationship); | ||||||
|         await db.SaveChangesAsync(); |         await db.SaveChangesAsync(); | ||||||
|          |  | ||||||
|         await PurgeRelationshipCache(sender.Id, target.Id); |         await PurgeRelationshipCache(sender.Id, target.Id); | ||||||
|  |  | ||||||
|         return relationship; |         return relationship; | ||||||
| @@ -63,16 +71,16 @@ public class RelationshipService(AppDatabase db, ICacheService cache) | |||||||
|             return await UpdateRelationship(sender.Id, target.Id, RelationshipStatus.Blocked); |             return await UpdateRelationship(sender.Id, target.Id, RelationshipStatus.Blocked); | ||||||
|         return await CreateRelationship(sender, target, RelationshipStatus.Blocked); |         return await CreateRelationship(sender, target, RelationshipStatus.Blocked); | ||||||
|     } |     } | ||||||
|      |  | ||||||
|     public async Task<Relationship> UnblockAccount(Account sender, Account target) |     public async Task<Relationship> UnblockAccount(Account sender, Account target) | ||||||
|     { |     { | ||||||
|         var relationship = await GetRelationship(sender.Id, target.Id, RelationshipStatus.Blocked); |         var relationship = await GetRelationship(sender.Id, target.Id, RelationshipStatus.Blocked); | ||||||
|         if (relationship is null) throw new ArgumentException("There is no relationship between you and the user."); |         if (relationship is null) throw new ArgumentException("There is no relationship between you and the user."); | ||||||
|         db.Remove(relationship); |         db.Remove(relationship); | ||||||
|         await db.SaveChangesAsync(); |         await db.SaveChangesAsync(); | ||||||
|          |  | ||||||
|         await PurgeRelationshipCache(sender.Id, target.Id); |         await PurgeRelationshipCache(sender.Id, target.Id); | ||||||
|          |  | ||||||
|         return relationship; |         return relationship; | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -92,21 +100,34 @@ 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; | ||||||
|     } |     } | ||||||
|      |  | ||||||
|     public async Task DeleteFriendRequest(Guid accountId, Guid relatedId) |     public async Task DeleteFriendRequest(Guid accountId, Guid relatedId) | ||||||
|     { |     { | ||||||
|         var relationship = await GetRelationship(accountId, relatedId, RelationshipStatus.Pending); |         var relationship = await GetRelationship(accountId, relatedId, RelationshipStatus.Pending); | ||||||
|         if (relationship is null) throw new ArgumentException("Friend request was not found."); |         if (relationship is null) throw new ArgumentException("Friend request was not found."); | ||||||
|      |  | ||||||
|         await db.AccountRelationships |         await db.AccountRelationships | ||||||
|             .Where(r => r.AccountId == accountId && r.RelatedId == relatedId && r.Status == RelationshipStatus.Pending) |             .Where(r => r.AccountId == accountId && r.RelatedId == relatedId && r.Status == RelationshipStatus.Pending) | ||||||
|             .ExecuteDeleteAsync(); |             .ExecuteDeleteAsync(); | ||||||
|          |  | ||||||
|         await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId); |         await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId); | ||||||
|     } |     } | ||||||
|      |  | ||||||
|     public async Task<Relationship> AcceptFriendRelationship( |     public async Task<Relationship> AcceptFriendRelationship( | ||||||
|         Relationship relationship, |         Relationship relationship, | ||||||
|         RelationshipStatus status = RelationshipStatus.Friends |         RelationshipStatus status = RelationshipStatus.Friends | ||||||
| @@ -146,9 +167,9 @@ public class RelationshipService(AppDatabase db, ICacheService cache) | |||||||
|         relationship.Status = status; |         relationship.Status = status; | ||||||
|         db.Update(relationship); |         db.Update(relationship); | ||||||
|         await db.SaveChangesAsync(); |         await db.SaveChangesAsync(); | ||||||
|          |  | ||||||
|         await PurgeRelationshipCache(accountId, relatedId); |         await PurgeRelationshipCache(accountId, relatedId); | ||||||
|          |  | ||||||
|         return relationship; |         return relationship; | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -161,7 +182,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache) | |||||||
|     { |     { | ||||||
|         var cacheKey = $"{UserFriendsCacheKeyPrefix}{accountId}"; |         var cacheKey = $"{UserFriendsCacheKeyPrefix}{accountId}"; | ||||||
|         var friends = await cache.GetAsync<List<Guid>>(cacheKey); |         var friends = await cache.GetAsync<List<Guid>>(cacheKey); | ||||||
|          |  | ||||||
|         if (friends == null) |         if (friends == null) | ||||||
|         { |         { | ||||||
|             friends = await db.AccountRelationships |             friends = await db.AccountRelationships | ||||||
| @@ -169,23 +190,23 @@ public class RelationshipService(AppDatabase db, ICacheService cache) | |||||||
|                 .Where(r => r.Status == RelationshipStatus.Friends) |                 .Where(r => r.Status == RelationshipStatus.Friends) | ||||||
|                 .Select(r => r.AccountId) |                 .Select(r => r.AccountId) | ||||||
|                 .ToListAsync(); |                 .ToListAsync(); | ||||||
|                  |  | ||||||
|             await cache.SetAsync(cacheKey, friends, TimeSpan.FromHours(1)); |             await cache.SetAsync(cacheKey, friends, TimeSpan.FromHours(1)); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         return friends ?? []; |         return friends ?? []; | ||||||
|     } |     } | ||||||
|      |  | ||||||
|     public async Task<List<Guid>> ListAccountBlocked(Account account) |     public async Task<List<Guid>> ListAccountBlocked(Account account) | ||||||
|     { |     { | ||||||
|         return await ListAccountBlocked(account.Id); |         return await ListAccountBlocked(account.Id); | ||||||
|     } |     } | ||||||
|      |  | ||||||
|     public async Task<List<Guid>> ListAccountBlocked(Guid accountId) |     public async Task<List<Guid>> ListAccountBlocked(Guid accountId) | ||||||
|     { |     { | ||||||
|         var cacheKey = $"{UserBlockedCacheKeyPrefix}{accountId}"; |         var cacheKey = $"{UserBlockedCacheKeyPrefix}{accountId}"; | ||||||
|         var blocked = await cache.GetAsync<List<Guid>>(cacheKey); |         var blocked = await cache.GetAsync<List<Guid>>(cacheKey); | ||||||
|          |  | ||||||
|         if (blocked == null) |         if (blocked == null) | ||||||
|         { |         { | ||||||
|             blocked = await db.AccountRelationships |             blocked = await db.AccountRelationships | ||||||
| @@ -193,7 +214,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache) | |||||||
|                 .Where(r => r.Status == RelationshipStatus.Blocked) |                 .Where(r => r.Status == RelationshipStatus.Blocked) | ||||||
|                 .Select(r => r.AccountId) |                 .Select(r => r.AccountId) | ||||||
|                 .ToListAsync(); |                 .ToListAsync(); | ||||||
|                  |  | ||||||
|             await cache.SetAsync(cacheKey, blocked, TimeSpan.FromHours(1)); |             await cache.SetAsync(cacheKey, blocked, TimeSpan.FromHours(1)); | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -206,7 +227,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache) | |||||||
|         var relationship = await GetRelationship(accountId, relatedId, status); |         var relationship = await GetRelationship(accountId, relatedId, status); | ||||||
|         return relationship is not null; |         return relationship is not null; | ||||||
|     } |     } | ||||||
|      |  | ||||||
|     private async Task PurgeRelationshipCache(Guid accountId, Guid relatedId) |     private async Task PurgeRelationshipCache(Guid accountId, Guid relatedId) | ||||||
|     { |     { | ||||||
|         await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}"); |         await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}"); | ||||||
|   | |||||||
| @@ -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<Wallet.Wallet> Wallets { get; set; } |     public DbSet<ApiKey> ApiKeys { get; set; } = null!; | ||||||
|     public DbSet<WalletPocket> WalletPockets { get; set; } |  | ||||||
|     public DbSet<Order> PaymentOrders { get; set; } |     public DbSet<Wallet.Wallet> Wallets { get; set; } = null!; | ||||||
|     public DbSet<Transaction> PaymentTransactions { get; set; } |     public DbSet<WalletPocket> WalletPockets { get; set; } = null!; | ||||||
|     public DbSet<Subscription> WalletSubscriptions { get; set; } |     public DbSet<Order> PaymentOrders { get; set; } = null!; | ||||||
|     public DbSet<Coupon> WalletCoupons { get; set; } |     public DbSet<Transaction> PaymentTransactions { get; set; } = null!; | ||||||
|      |     public DbSet<Subscription> WalletSubscriptions { get; set; } = null!; | ||||||
|     public DbSet<CustomApp> CustomApps { get; set; } |     public DbSet<Coupon> WalletCoupons { get; set; } = null!; | ||||||
|     public DbSet<CustomAppSecret> CustomAppSecrets { get; set; } |  | ||||||
|  |     public DbSet<Punishment> Punishments { get; set; } = null!; | ||||||
|  |  | ||||||
|  |     public DbSet<SocialCreditRecord> SocialCreditRecords { get; set; } = null!; | ||||||
|  |     public DbSet<ExperienceRecord> ExperienceRecords { get; set; } = null!; | ||||||
|  |  | ||||||
|     protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) |     protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) | ||||||
|     { |     { | ||||||
|         optionsBuilder.UseNpgsql( |         optionsBuilder.UseNpgsql( | ||||||
|             configuration.GetConnectionString("App"), |             configuration.GetConnectionString("App"), | ||||||
|             opt => opt |             opt => opt | ||||||
|                 .ConfigureDataSource(optSource => optSource.EnableDynamicJson()) |                 .ConfigureDataSource(optSource => optSource | ||||||
|  |                     .EnableDynamicJson() | ||||||
|  |                     .ConfigureJsonOptions(new JsonSerializerOptions() | ||||||
|  |                     { | ||||||
|  |                         NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, | ||||||
|  |                     }) | ||||||
|  |                 ) | ||||||
|                 .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) |                 .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) | ||||||
|                 .UseNetTopologySuite() |  | ||||||
|                 .UseNodaTime() |                 .UseNodaTime() | ||||||
|         ).UseSnakeCaseNamingConvention(); |         ).UseSnakeCaseNamingConvention(); | ||||||
|  |  | ||||||
| @@ -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 |              | ||||||
|             if (!ValidateToken(tokenInfo.Token, out var sessionId)) |             var (valid, session, message) = await token.AuthenticateTokenAsync(tokenInfo.Token, ipAddress); | ||||||
|                 return AuthenticateResult.Fail("Invalid token."); |             if (!valid || session is null) | ||||||
|  |                 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 | ||||||
| @@ -232,7 +119,7 @@ public class DysonTokenAuthHandler( | |||||||
|             { |             { | ||||||
|                 var token = authHeader["Bearer ".Length..].Trim(); |                 var token = authHeader["Bearer ".Length..].Trim(); | ||||||
|                 var parts = token.Split('.'); |                 var parts = token.Split('.'); | ||||||
|                  |  | ||||||
|                 return new TokenInfo |                 return new TokenInfo | ||||||
|                 { |                 { | ||||||
|                     Token = token, |                     Token = token, | ||||||
| @@ -270,4 +157,4 @@ public class DysonTokenAuthHandler( | |||||||
|  |  | ||||||
|         return null; |         return null; | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										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)), |                     return Ok(new TokenExchangeResponse { Token = tk }); | ||||||
|                     Account = challenge.Account, |                 } | ||||||
|                     Challenge = challenge, |                 catch (ArgumentException ex) | ||||||
|                 }; |  | ||||||
|  |  | ||||||
|                 db.AuthSessions.Add(session); |  | ||||||
|                 await db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|                 var tk = auth.CreateToken(session); |  | ||||||
|                 Response.Cookies.Append(AuthConstants.CookieTokenName, tk, new CookieOptions |  | ||||||
|                 { |                 { | ||||||
|                     HttpOnly = true, |                     return BadRequest(ex.Message); | ||||||
|                     Secure = true, |                 } | ||||||
|                     SameSite = SameSiteMode.Lax, |  | ||||||
|                     Domain = CookieDomain, |  | ||||||
|                     Expires = DateTime.UtcNow.AddDays(30) |  | ||||||
|                 }); |  | ||||||
|  |  | ||||||
|                 return Ok(new TokenExchangeResponse { Token = tk }); |  | ||||||
|             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 | ||||||
| @@ -281,7 +312,7 @@ public class AuthController( | |||||||
|     { |     { | ||||||
|         Response.Cookies.Delete(AuthConstants.CookieTokenName, new CookieOptions |         Response.Cookies.Delete(AuthConstants.CookieTokenName, new CookieOptions | ||||||
|         { |         { | ||||||
|             Domain = CookieDomain, |             Domain = _cookieDomain, | ||||||
|             HttpOnly = true, |             HttpOnly = true, | ||||||
|             Secure = true, |             Secure = true, | ||||||
|             SameSite = SameSiteMode.Lax |             SameSite = SameSiteMode.Lax | ||||||
|   | |||||||
| @@ -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 | ||||||
| @@ -188,43 +261,43 @@ public class AuthService( | |||||||
|         // Check if the session is already in sudo mode (cached) |         // Check if the session is already in sudo mode (cached) | ||||||
|         var sudoModeKey = $"accounts:{session.Id}:sudo"; |         var sudoModeKey = $"accounts:{session.Id}:sudo"; | ||||||
|         var (found, _) = await cache.GetAsyncWithStatus<bool>(sudoModeKey); |         var (found, _) = await cache.GetAsyncWithStatus<bool>(sudoModeKey); | ||||||
|          |  | ||||||
|         if (found) |         if (found) | ||||||
|         { |         { | ||||||
|             // Session is already in sudo mode |             // Session is already in sudo mode | ||||||
|             return true; |             return true; | ||||||
|         } |         } | ||||||
|          |  | ||||||
|         // Check if the user has a pin code |         // Check if the user has a pin code | ||||||
|         var hasPinCode = await db.AccountAuthFactors |         var hasPinCode = await db.AccountAuthFactors | ||||||
|             .Where(f => f.AccountId == session.AccountId) |             .Where(f => f.AccountId == session.AccountId) | ||||||
|             .Where(f => f.EnabledAt != null) |             .Where(f => f.EnabledAt != null) | ||||||
|             .Where(f => f.Type == AccountAuthFactorType.PinCode) |             .Where(f => f.Type == AccountAuthFactorType.PinCode) | ||||||
|             .AnyAsync(); |             .AnyAsync(); | ||||||
|              |  | ||||||
|         if (!hasPinCode) |         if (!hasPinCode) | ||||||
|         { |         { | ||||||
|             // User doesn't have a pin code, no validation needed |             // User doesn't have a pin code, no validation needed | ||||||
|             return true; |             return true; | ||||||
|         } |         } | ||||||
|          |  | ||||||
|         // If pin code is not provided, we can't validate |         // If pin code is not provided, we can't validate | ||||||
|         if (string.IsNullOrEmpty(pinCode)) |         if (string.IsNullOrEmpty(pinCode)) | ||||||
|         { |         { | ||||||
|             return false; |             return false; | ||||||
|         } |         } | ||||||
|          |  | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
|             // Validate the pin code |             // Validate the pin code | ||||||
|             var isValid = await ValidatePinCode(session.AccountId, pinCode); |             var isValid = await ValidatePinCode(session.AccountId, pinCode); | ||||||
|              |  | ||||||
|             if (isValid) |             if (isValid) | ||||||
|             { |             { | ||||||
|                 // Set session in sudo mode for 5 minutes |                 // Set session in sudo mode for 5 minutes | ||||||
|                 await cache.SetAsync(sudoModeKey, true, TimeSpan.FromMinutes(5)); |                 await cache.SetAsync(sudoModeKey, true, TimeSpan.FromMinutes(5)); | ||||||
|             } |             } | ||||||
|              |  | ||||||
|             return isValid; |             return isValid; | ||||||
|         } |         } | ||||||
|         catch (InvalidOperationException) |         catch (InvalidOperationException) | ||||||
| @@ -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