Compare commits
	
		
			127 Commits
		
	
	
		
			e1459951c4
			...
			master
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| cdfc3f6571 | |||
| f65a7360e2 | |||
| 85e706335a | |||
| fe74060df9 | |||
| e8d5f22395 | |||
| 83fa2568aa | |||
| bf1c8e0a85 | |||
| 323fa8ee15 | |||
| e7a46e96ed | |||
| 3a0dee11a6 | |||
| 43be47d526 | |||
| 48067af034 | |||
| 7e7e90ad24 | |||
| 3af4069581 | |||
| 609b130b4e | |||
| 93f7dfd379 | |||
| 40325c6df5 | |||
| bbcaa27ac5 | |||
| 19d833a522 | |||
| a94102e136 | |||
| fc693793fe | |||
| 8cfdabbae4 | |||
| 985ff41c72 | |||
| a79ea4ac49 | |||
| 7385caff9a | |||
| 15954dbfe2 | |||
| 4ba6206c9d | |||
| 266b9e36e2 | |||
| e6aa61b03b | |||
| 0c09ef25ec | |||
| dd5929c691 | |||
| cf87fdfb49 | |||
| ff03584518 | |||
| d6c37784e1 | |||
| 46ebd92dc1 | |||
| 7f8521bb40 | |||
| f01226d91a | |||
| 6cb6dee6be | |||
| 0e9caf67ff | |||
| ca70bb5487 | |||
| 59ed135f20 | |||
| 6077f91529 | |||
| 5c485bb1c3 | |||
| 27d979d77b | |||
| 15687a0c32 | |||
| 37ea882ef7 | |||
| e624c2bb3e | |||
| 9631cd3edd | |||
| f4a659fce5 | |||
| 1ded811b36 | |||
| 32977d9580 | |||
| aaf29e7228 | |||
| 658ef3bddf | |||
| fc0bc936ce | |||
| 3850ae6a8e | |||
| 21c99567b4 | |||
| 1315c7f4d4 | |||
| 630a532d98 | |||
| b9bb180113 | |||
| 04d74d0d70 | |||
| 6a8a0ed491 | |||
| 0f835845bf | |||
| c5d8a8d07f | |||
| 95e2ba1136 | |||
| 1176fde8b4 | |||
| e634968e00 | |||
| 282a1dbddc | |||
| c64adace24 | |||
| 8ac0b28c66 | |||
| 8f71d7f9e5 | |||
| c435e63917 | |||
| 243159e4cc | |||
| 42dad7095a | |||
| d1efcdede8 | |||
| 47680475b3 | |||
| 6632d43f32 | |||
| 29c4dcd71c | |||
| e7aa887715 | |||
| 0f05633996 | |||
| 966af08a33 | |||
| b25b90a074 | |||
| dcbefeaaab | |||
| eb83a0392a | |||
| 85fefcf724 | |||
| d17c26a228 | |||
| 2e5ef8ff94 | |||
| 7a5f410e36 | |||
| 0b4e8a9777 | |||
| 30fd912281 | |||
| 5bf58f0194 | |||
| 8e3e3f09df | |||
| fa24f14c05 | |||
| a93b633e84 | |||
| 97a7b876db | |||
| 909fe173c2 | |||
| 58a44e8af4 | |||
| 1075177511 | |||
| 78f8a9e638 | |||
| 9ce31c4dd8 | |||
| e70d8371f8 | |||
| 51b6f7309e | |||
| d75876a772 | |||
| 4910c3296b | |||
| 7b924fa075 | |||
| d69c9f9623 | |||
| a88d828e21 | |||
| 14c93d372e | |||
| adf371a72e | |||
| c03f2472fa | |||
| 50efe62bac | |||
| 7bc94a9646 | |||
| d9fe1273b5 | |||
| ff9d490869 | |||
| 266312e97e | |||
| 7087736e31 | |||
| 82bf1608fd | |||
| 3b3287db0b | |||
| 4573d9395f | |||
| a8c99b3128 | |||
| fdd7bd3c9d | |||
| b785d0098b | |||
| 5b31357fe9 | |||
| d5a5721402 | |||
| 204640a759 | |||
| e3657386cd | |||
| f81e3dc9f4 | |||
| b2a0d25ffa | 
| @@ -1,3 +1,4 @@ | |||||||
| { | { | ||||||
|   "appHostPath": "../DysonNetwork.Control/DysonNetwork.Control.csproj" |   "appHostPath": "../DysonNetwork.Control/DysonNetwork.Control.csproj" | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										5
									
								
								.editorconfig
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								.editorconfig
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | root = true | ||||||
|  |  | ||||||
|  | [*] | ||||||
|  | indent_style = space | ||||||
|  | indent_size = 4 | ||||||
							
								
								
									
										3
									
								
								.env
									
									
									
									
									
								
							
							
						
						
									
										3
									
								
								.env
									
									
									
									
									
								
							| @@ -33,3 +33,6 @@ SPHERE_IMAGE=sphere:latest | |||||||
|  |  | ||||||
| # Container image name for develop | # Container image name for develop | ||||||
| DEVELOP_IMAGE=develop:latest | DEVELOP_IMAGE=develop:latest | ||||||
|  |  | ||||||
|  | # Container image name for gateway | ||||||
|  | GATEWAY_IMAGE=gateway:latest | ||||||
|   | |||||||
							
								
								
									
										70
									
								
								.github/workflows/docker-build.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										70
									
								
								.github/workflows/docker-build.yml
									
									
									
									
										vendored
									
									
								
							| @@ -7,25 +7,69 @@ on: | |||||||
|   workflow_dispatch: |   workflow_dispatch: | ||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|  |   determine-changes: | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |     outputs: | ||||||
|  |       matrix: ${{ steps.changes.outputs.matrix }} | ||||||
|  |     steps: | ||||||
|  |       - name: Checkout repository | ||||||
|  |         uses: actions/checkout@v4 | ||||||
|  |         with: | ||||||
|  |           fetch-depth: 0 | ||||||
|  |  | ||||||
|  |       - name: Get changed files | ||||||
|  |         id: changed-files | ||||||
|  |         run: | | ||||||
|  |           echo "files=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} | xargs)" >> $GITHUB_OUTPUT | ||||||
|  |  | ||||||
|  |       - name: Determine changed services | ||||||
|  |         id: changes | ||||||
|  |         run: | | ||||||
|  |           files="${{ steps.changed-files.outputs.files }}" | ||||||
|  |           matrix="{\"include\":[]}" | ||||||
|  |           services=("Sphere" "Pass" "Ring" "Drive" "Develop" "Gateway" "Insight") | ||||||
|  |           images=("sphere" "pass" "ring" "drive" "develop" "gateway" "insight") | ||||||
|  |           changed_services=() | ||||||
|  |  | ||||||
|  |           for file in $files; do | ||||||
|  |             if [[ "$file" == DysonNetwork.Shared/* ]]; then | ||||||
|  |               changed_services=("${services[@]}") | ||||||
|  |               break | ||||||
|  |             fi | ||||||
|  |             for i in "${!services[@]}"; do | ||||||
|  |               if [[ "$file" == DysonNetwork.${services[$i]}/* ]]; then | ||||||
|  |                 # check if service is already in changed_services | ||||||
|  |                 if [[ ! " ${changed_services[@]} " =~ " ${services[$i]} " ]]; then | ||||||
|  |                   changed_services+=("${services[$i]}") | ||||||
|  |                 fi | ||||||
|  |               fi | ||||||
|  |             done | ||||||
|  |           done | ||||||
|  |  | ||||||
|  |           if [ ${#changed_services[@]} -gt 0 ]; then | ||||||
|  |             json_objects="" | ||||||
|  |             for service in "${changed_services[@]}"; do | ||||||
|  |               for i in "${!services[@]}"; do | ||||||
|  |                 if [[ "${services[$i]}" == "$service" ]]; then | ||||||
|  |                   image="${images[$i]}" | ||||||
|  |                   break | ||||||
|  |                 fi | ||||||
|  |               done | ||||||
|  |               json_objects+="{\"service\":\"$service\",\"image\":\"$image\"}," | ||||||
|  |             done | ||||||
|  |             matrix="{\"include\":[${json_objects%,}]}" | ||||||
|  |           fi | ||||||
|  |           echo "matrix=$matrix" >> $GITHUB_OUTPUT | ||||||
|  |  | ||||||
|   build-and-push: |   build-and-push: | ||||||
|  |     needs: determine-changes | ||||||
|  |     if: ${{ needs.determine-changes.outputs.matrix != '{"include":[]}' }} | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     permissions: |     permissions: | ||||||
|       contents: read |       contents: read | ||||||
|       packages: write |       packages: write | ||||||
|  |  | ||||||
|     strategy: |     strategy: | ||||||
|       matrix: |       matrix: ${{ fromJson(needs.determine-changes.outputs.matrix) }} | ||||||
|         include: |  | ||||||
|           - service: Sphere |  | ||||||
|             image: sphere |  | ||||||
|           - service: Pass |  | ||||||
|             image: pass |  | ||||||
|           - service: Ring |  | ||||||
|             image: ring |  | ||||||
|           - service: Drive |  | ||||||
|             image: drive |  | ||||||
|           - service: Develop |  | ||||||
|             image: develop |  | ||||||
|  |  | ||||||
|     steps: |     steps: | ||||||
|       - name: Checkout repository |       - name: Checkout repository | ||||||
|   | |||||||
							
								
								
									
										613
									
								
								API_WALLET_FUNDS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										613
									
								
								API_WALLET_FUNDS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,613 @@ | |||||||
|  | # Wallet Funds API Documentation | ||||||
|  |  | ||||||
|  | ## Overview | ||||||
|  |  | ||||||
|  | The Wallet Funds API provides red packet functionality for the DysonNetwork platform, allowing users to create and distribute funds among multiple recipients with expiration and claiming mechanisms. | ||||||
|  |  | ||||||
|  | ## Authentication | ||||||
|  |  | ||||||
|  | All endpoints require Bearer token authentication: | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | Authorization: Bearer {jwt_token} | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## Data Types | ||||||
|  |  | ||||||
|  | ### Enums | ||||||
|  |  | ||||||
|  | #### FundSplitType | ||||||
|  | ```typescript | ||||||
|  | enum FundSplitType { | ||||||
|  |   Even = 0,    // Equal distribution | ||||||
|  |   Random = 1   // Lucky draw distribution | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | #### FundStatus | ||||||
|  | ```typescript | ||||||
|  | enum FundStatus { | ||||||
|  |   Created = 0,           // Fund created, waiting for claims | ||||||
|  |   PartiallyReceived = 1, // Some recipients claimed | ||||||
|  |   FullyReceived = 2,     // All recipients claimed | ||||||
|  |   Expired = 3,           // Fund expired, unclaimed amounts refunded | ||||||
|  |   Refunded = 4           // Legacy status | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### Request/Response Models | ||||||
|  |  | ||||||
|  | #### CreateFundRequest | ||||||
|  | ```typescript | ||||||
|  | interface CreateFundRequest { | ||||||
|  |   recipientAccountIds: string[];  // UUIDs of recipients | ||||||
|  |   currency: string;               // e.g., "points", "golds" | ||||||
|  |   totalAmount: number;            // Total amount to distribute | ||||||
|  |   splitType: FundSplitType;       // Even or Random | ||||||
|  |   message?: string;               // Optional message | ||||||
|  |   expirationHours?: number;       // Optional: hours until expiration (default: 24) | ||||||
|  |   pinCode: string;                // Required: 6-digit PIN code for security | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | #### SnWalletFund | ||||||
|  | ```typescript | ||||||
|  | interface SnWalletFund { | ||||||
|  |   id: string;                     // UUID | ||||||
|  |   currency: string; | ||||||
|  |   totalAmount: number; | ||||||
|  |   splitType: FundSplitType; | ||||||
|  |   status: FundStatus; | ||||||
|  |   message?: string; | ||||||
|  |   creatorAccountId: string;       // UUID | ||||||
|  |   creatorAccount: SnAccount;      // Creator account details (includes profile) | ||||||
|  |   recipients: SnWalletFundRecipient[]; | ||||||
|  |   expiredAt: string;              // ISO 8601 timestamp | ||||||
|  |   createdAt: string;              // ISO 8601 timestamp | ||||||
|  |   updatedAt: string;              // ISO 8601 timestamp | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | #### SnWalletFundRecipient | ||||||
|  | ```typescript | ||||||
|  | interface SnWalletFundRecipient { | ||||||
|  |   id: string;                     // UUID | ||||||
|  |   fundId: string;                 // UUID | ||||||
|  |   recipientAccountId: string;     // UUID | ||||||
|  |   recipientAccount: SnAccount;    // Recipient account details (includes profile) | ||||||
|  |   amount: number;                 // Allocated amount | ||||||
|  |   isReceived: boolean; | ||||||
|  |   receivedAt?: string;            // ISO 8601 timestamp (if claimed) | ||||||
|  |   createdAt: string;              // ISO 8601 timestamp | ||||||
|  |   updatedAt: string;              // ISO 8601 timestamp | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | #### SnWalletTransaction | ||||||
|  | ```typescript | ||||||
|  | interface SnWalletTransaction { | ||||||
|  |   id: string;                     // UUID | ||||||
|  |   payerWalletId?: string;         // UUID (null for system transfers) | ||||||
|  |   payeeWalletId?: string;         // UUID (null for system transfers) | ||||||
|  |   currency: string; | ||||||
|  |   amount: number; | ||||||
|  |   remarks?: string; | ||||||
|  |   type: TransactionType; | ||||||
|  |   createdAt: string;              // ISO 8601 timestamp | ||||||
|  |   updatedAt: string;              // ISO 8601 timestamp | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | #### Error Response | ||||||
|  | ```typescript | ||||||
|  | interface ErrorResponse { | ||||||
|  |   type: string;                   // Error type | ||||||
|  |   title: string;                  // Error title | ||||||
|  |   status: number;                 // HTTP status code | ||||||
|  |   detail: string;                 // Error details | ||||||
|  |   instance?: string;              // Request instance | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## API Endpoints | ||||||
|  |  | ||||||
|  | ### 1. Create Fund | ||||||
|  |  | ||||||
|  | Creates a new fund (red packet) for distribution among recipients. | ||||||
|  |  | ||||||
|  | **Endpoint:** `POST /api/wallets/funds` | ||||||
|  |  | ||||||
|  | **Request Body:** `CreateFundRequest` | ||||||
|  |  | ||||||
|  | **Response:** `SnWalletFund` (201 Created) | ||||||
|  |  | ||||||
|  | **Example Request:** | ||||||
|  | ```bash | ||||||
|  | curl -X POST "/api/wallets/funds" \ | ||||||
|  |   -H "Authorization: Bearer {token}" \ | ||||||
|  |   -H "Content-Type: application/json" \ | ||||||
|  |   -d '{ | ||||||
|  |     "recipientAccountIds": [ | ||||||
|  |       "550e8400-e29b-41d4-a716-446655440000", | ||||||
|  |       "550e8400-e29b-41d4-a716-446655440001", | ||||||
|  |       "550e8400-e29b-41d4-a716-446655440002" | ||||||
|  |     ], | ||||||
|  |     "currency": "points", | ||||||
|  |     "totalAmount": 100.00, | ||||||
|  |     "splitType": "Even", | ||||||
|  |     "message": "Happy New Year! 🎉", | ||||||
|  |     "expirationHours": 48, | ||||||
|  |     "pinCode": "123456" | ||||||
|  |   }' | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | **Example Response:** | ||||||
|  | ```json | ||||||
|  | { | ||||||
|  |   "id": "550e8400-e29b-41d4-a716-446655440003", | ||||||
|  |   "currency": "points", | ||||||
|  |   "totalAmount": 100.00, | ||||||
|  |   "splitType": 0, | ||||||
|  |   "status": 0, | ||||||
|  |   "message": "Happy New Year! 🎉", | ||||||
|  |   "creatorAccountId": "550e8400-e29b-41d4-a716-446655440004", | ||||||
|  |   "creatorAccount": { | ||||||
|  |     "id": "550e8400-e29b-41d4-a716-446655440004", | ||||||
|  |     "username": "creator_user" | ||||||
|  |   }, | ||||||
|  |   "recipients": [ | ||||||
|  |     { | ||||||
|  |       "id": "550e8400-e29b-41d4-a716-446655440005", | ||||||
|  |       "fundId": "550e8400-e29b-41d4-a716-446655440003", | ||||||
|  |       "recipientAccountId": "550e8400-e29b-41d4-a716-446655440000", | ||||||
|  |       "amount": 33.34, | ||||||
|  |       "isReceived": false, | ||||||
|  |       "createdAt": "2025-10-03T22:00:00Z", | ||||||
|  |       "updatedAt": "2025-10-03T22:00:00Z" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "id": "550e8400-e29b-41d4-a716-446655440006", | ||||||
|  |       "fundId": "550e8400-e29b-41d4-a716-446655440003", | ||||||
|  |       "recipientAccountId": "550e8400-e29b-41d4-a716-446655440001", | ||||||
|  |       "amount": 33.33, | ||||||
|  |       "isReceived": false, | ||||||
|  |       "createdAt": "2025-10-03T22:00:00Z", | ||||||
|  |       "updatedAt": "2025-10-03T22:00:00Z" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "id": "550e8400-e29b-41d4-a716-446655440007", | ||||||
|  |       "fundId": "550e8400-e29b-41d4-a716-446655440003", | ||||||
|  |       "recipientAccountId": "550e8400-e29b-41d4-a716-446655440002", | ||||||
|  |       "amount": 33.33, | ||||||
|  |       "isReceived": false, | ||||||
|  |       "createdAt": "2025-10-03T22:00:00Z", | ||||||
|  |       "updatedAt": "2025-10-03T22:00:00Z" | ||||||
|  |     } | ||||||
|  |   ], | ||||||
|  |   "expiredAt": "2025-10-05T22:00:00Z", | ||||||
|  |   "createdAt": "2025-10-03T22:00:00Z", | ||||||
|  |   "updatedAt": "2025-10-03T22:00:00Z" | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | **Error Responses:** | ||||||
|  | - `400 Bad Request`: Invalid parameters, insufficient funds, invalid recipients | ||||||
|  | - `401 Unauthorized`: Missing or invalid authentication | ||||||
|  | - `403 Forbidden`: Invalid PIN code | ||||||
|  | - `422 Unprocessable Entity`: Business logic violations | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ### 2. Get Funds | ||||||
|  |  | ||||||
|  | Retrieves funds that the authenticated user is involved in (as creator or recipient). | ||||||
|  |  | ||||||
|  | **Endpoint:** `GET /api/wallets/funds` | ||||||
|  |  | ||||||
|  | **Query Parameters:** | ||||||
|  | - `offset` (number, optional): Pagination offset (default: 0) | ||||||
|  | - `take` (number, optional): Number of items to return (default: 20, max: 100) | ||||||
|  | - `status` (FundStatus, optional): Filter by fund status | ||||||
|  |  | ||||||
|  | **Response:** `SnWalletFund[]` (200 OK) | ||||||
|  |  | ||||||
|  | **Headers:** | ||||||
|  | - `X-Total`: Total number of funds matching the criteria | ||||||
|  |  | ||||||
|  | **Example Request:** | ||||||
|  | ```bash | ||||||
|  | curl -X GET "/api/wallets/funds?offset=0&take=10&status=0" \ | ||||||
|  |   -H "Authorization: Bearer {token}" | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | **Example Response:** | ||||||
|  | ```json | ||||||
|  | [ | ||||||
|  |   { | ||||||
|  |     "id": "550e8400-e29b-41d4-a716-446655440003", | ||||||
|  |     "currency": "points", | ||||||
|  |     "totalAmount": 100.00, | ||||||
|  |     "splitType": 0, | ||||||
|  |     "status": 0, | ||||||
|  |     "message": "Happy New Year! 🎉", | ||||||
|  |     "creatorAccountId": "550e8400-e29b-41d4-a716-446655440004", | ||||||
|  |     "creatorAccount": { | ||||||
|  |       "id": "550e8400-e29b-41d4-a716-446655440004", | ||||||
|  |       "username": "creator_user" | ||||||
|  |     }, | ||||||
|  |     "recipients": [ | ||||||
|  |       { | ||||||
|  |         "id": "550e8400-e29b-41d4-a716-446655440005", | ||||||
|  |         "fundId": "550e8400-e29b-41d4-a716-446655440003", | ||||||
|  |         "recipientAccountId": "550e8400-e29b-41d4-a716-446655440000", | ||||||
|  |         "amount": 33.34, | ||||||
|  |         "isReceived": false | ||||||
|  |       } | ||||||
|  |     ], | ||||||
|  |     "expiredAt": "2025-10-05T22:00:00Z", | ||||||
|  |     "createdAt": "2025-10-03T22:00:00Z", | ||||||
|  |     "updatedAt": "2025-10-03T22:00:00Z" | ||||||
|  |   } | ||||||
|  | ] | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | **Error Responses:** | ||||||
|  | - `401 Unauthorized`: Missing or invalid authentication | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ### 3. Get Fund | ||||||
|  |  | ||||||
|  | Retrieves details of a specific fund. | ||||||
|  |  | ||||||
|  | **Endpoint:** `GET /api/wallets/funds/{id}` | ||||||
|  |  | ||||||
|  | **Path Parameters:** | ||||||
|  | - `id` (string): Fund UUID | ||||||
|  |  | ||||||
|  | **Response:** `SnWalletFund` (200 OK) | ||||||
|  |  | ||||||
|  | **Example Request:** | ||||||
|  | ```bash | ||||||
|  | curl -X GET "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003" \ | ||||||
|  |   -H "Authorization: Bearer {token}" | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | **Example Response:** (Same as create fund response) | ||||||
|  |  | ||||||
|  | **Error Responses:** | ||||||
|  | - `401 Unauthorized`: Missing or invalid authentication | ||||||
|  | - `403 Forbidden`: User doesn't have permission to view this fund | ||||||
|  | - `404 Not Found`: Fund not found | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ### 4. Receive Fund | ||||||
|  |  | ||||||
|  | Claims the authenticated user's portion of a fund. | ||||||
|  |  | ||||||
|  | **Endpoint:** `POST /api/wallets/funds/{id}/receive` | ||||||
|  |  | ||||||
|  | **Path Parameters:** | ||||||
|  | - `id` (string): Fund UUID | ||||||
|  |  | ||||||
|  | **Response:** `SnWalletTransaction` (200 OK) | ||||||
|  |  | ||||||
|  | **Example Request:** | ||||||
|  | ```bash | ||||||
|  | curl -X POST "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive" \ | ||||||
|  |   -H "Authorization: Bearer {token}" | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | **Example Response:** | ||||||
|  | ```json | ||||||
|  | { | ||||||
|  |   "id": "550e8400-e29b-41d4-a716-446655440008", | ||||||
|  |   "payerWalletId": null, | ||||||
|  |   "payeeWalletId": "550e8400-e29b-41d4-a716-446655440009", | ||||||
|  |   "currency": "points", | ||||||
|  |   "amount": 33.34, | ||||||
|  |   "remarks": "Received fund portion from 550e8400-e29b-41d4-a716-446655440004", | ||||||
|  |   "type": 1, | ||||||
|  |   "createdAt": "2025-10-03T22:05:00Z", | ||||||
|  |   "updatedAt": "2025-10-03T22:05:00Z" | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | **Error Responses:** | ||||||
|  | - `400 Bad Request`: Fund expired, already claimed, not a recipient | ||||||
|  | - `401 Unauthorized`: Missing or invalid authentication | ||||||
|  | - `404 Not Found`: Fund not found | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ### 5. Get Wallet Overview | ||||||
|  |  | ||||||
|  | Retrieves a summarized overview of wallet transactions grouped by type for graphing/charting purposes. | ||||||
|  |  | ||||||
|  | **Endpoint:** `GET /api/wallets/overview` | ||||||
|  |  | ||||||
|  | **Query Parameters:** | ||||||
|  | - `startDate` (string, optional): Start date in ISO 8601 format (e.g., "2025-01-01T00:00:00Z") | ||||||
|  | - `endDate` (string, optional): End date in ISO 8601 format (e.g., "2025-12-31T23:59:59Z") | ||||||
|  |  | ||||||
|  | **Response:** `WalletOverview` (200 OK) | ||||||
|  |  | ||||||
|  | **Example Request:** | ||||||
|  | ```bash | ||||||
|  | curl -X GET "/api/wallets/overview?startDate=2025-01-01T00:00:00Z&endDate=2025-12-31T23:59:59Z" \ | ||||||
|  |   -H "Authorization: Bearer {token}" | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | **Example Response:** | ||||||
|  | ```json | ||||||
|  | { | ||||||
|  |   "accountId": "550e8400-e29b-41d4-a716-446655440000", | ||||||
|  |   "startDate": "2025-01-01T00:00:00.0000000Z", | ||||||
|  |   "endDate": "2025-12-31T23:59:59.0000000Z", | ||||||
|  |   "summary": { | ||||||
|  |     "System": { | ||||||
|  |       "type": "System", | ||||||
|  |       "currencies": { | ||||||
|  |         "points": { | ||||||
|  |           "currency": "points", | ||||||
|  |           "income": 150.00, | ||||||
|  |           "spending": 0.00, | ||||||
|  |           "net": 150.00 | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "Transfer": { | ||||||
|  |       "type": "Transfer", | ||||||
|  |       "currencies": { | ||||||
|  |         "points": { | ||||||
|  |           "currency": "points", | ||||||
|  |           "income": 25.00, | ||||||
|  |           "spending": 75.00, | ||||||
|  |           "net": -50.00 | ||||||
|  |         }, | ||||||
|  |         "golds": { | ||||||
|  |           "currency": "golds", | ||||||
|  |           "income": 0.00, | ||||||
|  |           "spending": 10.00, | ||||||
|  |           "net": -10.00 | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "Order": { | ||||||
|  |       "type": "Order", | ||||||
|  |       "currencies": { | ||||||
|  |         "points": { | ||||||
|  |           "currency": "points", | ||||||
|  |           "income": 0.00, | ||||||
|  |           "spending": 200.00, | ||||||
|  |           "net": -200.00 | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   "totalIncome": 175.00, | ||||||
|  |   "totalSpending": 285.00, | ||||||
|  |   "netTotal": -110.00 | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | **Response Fields:** | ||||||
|  | - `accountId`: User's account UUID | ||||||
|  | - `startDate`/`endDate`: Date range applied (ISO 8601 format) | ||||||
|  | - `summary`: Object keyed by transaction type | ||||||
|  |   - `type`: Transaction type name | ||||||
|  |   - `currencies`: Object keyed by currency code | ||||||
|  |     - `currency`: Currency name | ||||||
|  |     - `income`: Total money received | ||||||
|  |     - `spending`: Total money spent | ||||||
|  |     - `net`: Income minus spending | ||||||
|  | - `totalIncome`: Sum of all income across all types/currencies | ||||||
|  | - `totalSpending`: Sum of all spending across all types/currencies | ||||||
|  | - `netTotal`: Overall net (totalIncome - totalSpending) | ||||||
|  |  | ||||||
|  | **Error Responses:** | ||||||
|  | - `401 Unauthorized`: Missing or invalid authentication | ||||||
|  |  | ||||||
|  | ## Error Codes | ||||||
|  |  | ||||||
|  | ### Common Error Types | ||||||
|  |  | ||||||
|  | #### Validation Errors | ||||||
|  | ```json | ||||||
|  | { | ||||||
|  |   "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", | ||||||
|  |   "title": "Bad Request", | ||||||
|  |   "status": 400, | ||||||
|  |   "detail": "At least one recipient is required", | ||||||
|  |   "instance": "/api/wallets/funds" | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | #### Insufficient Funds | ||||||
|  | ```json | ||||||
|  | { | ||||||
|  |   "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", | ||||||
|  |   "title": "Bad Request", | ||||||
|  |   "status": 400, | ||||||
|  |   "detail": "Insufficient funds", | ||||||
|  |   "instance": "/api/wallets/funds" | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | #### Fund Not Available | ||||||
|  | ```json | ||||||
|  | { | ||||||
|  |   "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", | ||||||
|  |   "title": "Bad Request", | ||||||
|  |   "status": 400, | ||||||
|  |   "detail": "Fund is no longer available", | ||||||
|  |   "instance": "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive" | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | #### Already Claimed | ||||||
|  | ```json | ||||||
|  | { | ||||||
|  |   "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", | ||||||
|  |   "title": "Bad Request", | ||||||
|  |   "status": 400, | ||||||
|  |   "detail": "You have already received this fund", | ||||||
|  |   "instance": "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive" | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## Rate Limiting | ||||||
|  |  | ||||||
|  | - **Create Fund**: 10 requests per minute per user | ||||||
|  | - **Get Funds**: 60 requests per minute per user | ||||||
|  | - **Get Fund**: 60 requests per minute per user | ||||||
|  | - **Receive Fund**: 30 requests per minute per user | ||||||
|  |  | ||||||
|  | ## Webhooks/Notifications | ||||||
|  |  | ||||||
|  | The system integrates with the platform's notification system: | ||||||
|  |  | ||||||
|  | - **Fund Created**: Creator receives confirmation | ||||||
|  | - **Fund Claimed**: Creator receives notification when someone claims | ||||||
|  | - **Fund Expired**: Creator receives refund notification | ||||||
|  |  | ||||||
|  | ## SDK Examples | ||||||
|  |  | ||||||
|  | ### JavaScript/TypeScript | ||||||
|  |  | ||||||
|  | ```typescript | ||||||
|  | // Create a fund | ||||||
|  | const createFund = async (fundData: CreateFundRequest): Promise<SnWalletFund> => { | ||||||
|  |   const response = await fetch('/api/wallets/funds', { | ||||||
|  |     method: 'POST', | ||||||
|  |     headers: { | ||||||
|  |       'Authorization': `Bearer ${token}`, | ||||||
|  |       'Content-Type': 'application/json' | ||||||
|  |     }, | ||||||
|  |     body: JSON.stringify(fundData) | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   if (!response.ok) { | ||||||
|  |     throw new Error(`HTTP error! status: ${response.status}`); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return response.json(); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // Get user's funds | ||||||
|  | const getFunds = async (params?: { | ||||||
|  |   offset?: number; | ||||||
|  |   take?: number; | ||||||
|  |   status?: FundStatus; | ||||||
|  | }): Promise<SnWalletFund[]> => { | ||||||
|  |   const queryParams = new URLSearchParams(); | ||||||
|  |   if (params?.offset) queryParams.set('offset', params.offset.toString()); | ||||||
|  |   if (params?.take) queryParams.set('take', params.take.toString()); | ||||||
|  |   if (params?.status !== undefined) queryParams.set('status', params.status.toString()); | ||||||
|  |  | ||||||
|  |   const response = await fetch(`/api/wallets/funds?${queryParams}`, { | ||||||
|  |     headers: { | ||||||
|  |       'Authorization': `Bearer ${token}` | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   if (!response.ok) { | ||||||
|  |     throw new Error(`HTTP error! status: ${response.status}`); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return response.json(); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // Claim a fund | ||||||
|  | const receiveFund = async (fundId: string): Promise<SnWalletTransaction> => { | ||||||
|  |   const response = await fetch(`/api/wallets/funds/${fundId}/receive`, { | ||||||
|  |     method: 'POST', | ||||||
|  |     headers: { | ||||||
|  |       'Authorization': `Bearer ${token}` | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   if (!response.ok) { | ||||||
|  |     throw new Error(`HTTP error! status: ${response.status}`); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return response.json(); | ||||||
|  | }; | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### Python | ||||||
|  |  | ||||||
|  | ```python | ||||||
|  | import requests | ||||||
|  | from typing import List, Optional | ||||||
|  | from enum import Enum | ||||||
|  |  | ||||||
|  | class FundSplitType(Enum): | ||||||
|  |     EVEN = 0 | ||||||
|  |     RANDOM = 1 | ||||||
|  |  | ||||||
|  | class FundStatus(Enum): | ||||||
|  |     CREATED = 0 | ||||||
|  |     PARTIALLY_RECEIVED = 1 | ||||||
|  |     FULLY_RECEIVED = 2 | ||||||
|  |     EXPIRED = 3 | ||||||
|  |     REFUNDED = 4 | ||||||
|  |  | ||||||
|  | def create_fund(token: str, fund_data: dict) -> dict: | ||||||
|  |     """Create a new fund""" | ||||||
|  |     response = requests.post( | ||||||
|  |         '/api/wallets/funds', | ||||||
|  |         json=fund_data, | ||||||
|  |         headers={ | ||||||
|  |             'Authorization': f'Bearer {token}', | ||||||
|  |             'Content-Type': 'application/json' | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|  |     response.raise_for_status() | ||||||
|  |     return response.json() | ||||||
|  |  | ||||||
|  | def get_funds( | ||||||
|  |     token: str, | ||||||
|  |     offset: int = 0, | ||||||
|  |     take: int = 20, | ||||||
|  |     status: Optional[FundStatus] = None | ||||||
|  | ) -> List[dict]: | ||||||
|  |     """Get user's funds""" | ||||||
|  |     params = {'offset': offset, 'take': take} | ||||||
|  |     if status is not None: | ||||||
|  |         params['status'] = status.value | ||||||
|  |  | ||||||
|  |     response = requests.get( | ||||||
|  |         '/api/wallets/funds', | ||||||
|  |         params=params, | ||||||
|  |         headers={'Authorization': f'Bearer {token}'} | ||||||
|  |     ) | ||||||
|  |     response.raise_for_status() | ||||||
|  |     return response.json() | ||||||
|  |  | ||||||
|  | def receive_fund(token: str, fund_id: str) -> dict: | ||||||
|  |     """Claim a fund portion""" | ||||||
|  |     response = requests.post( | ||||||
|  |         f'/api/wallets/funds/{fund_id}/receive', | ||||||
|  |         headers={'Authorization': f'Bearer {token}'} | ||||||
|  |     ) | ||||||
|  |     response.raise_for_status() | ||||||
|  |     return response.json() | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## Changelog | ||||||
|  |  | ||||||
|  | ### Version 1.0.0 | ||||||
|  | - Initial release with basic red packet functionality | ||||||
|  | - Support for even and random split types | ||||||
|  | - 24-hour expiration with automatic refunds | ||||||
|  | - RESTful API endpoints | ||||||
|  | - Comprehensive error handling | ||||||
|  |  | ||||||
|  | ## Support | ||||||
|  |  | ||||||
|  | For API support or questions: | ||||||
|  | - Check the main documentation at `README_WALLET_FUNDS.md` | ||||||
|  | - Review error messages for specific guidance | ||||||
|  | - Contact the development team for technical issues | ||||||
| @@ -1,46 +1,43 @@ | |||||||
| using System.Net; |  | ||||||
| using System.Net.Sockets; |  | ||||||
| using Aspire.Hosting.Yarp.Transforms; |  | ||||||
| using Microsoft.Extensions.Hosting; | using Microsoft.Extensions.Hosting; | ||||||
|  |  | ||||||
| var builder = DistributedApplication.CreateBuilder(args); | var builder = DistributedApplication.CreateBuilder(args); | ||||||
|  |  | ||||||
| var isDev = builder.Environment.IsDevelopment(); | var isDev = builder.Environment.IsDevelopment(); | ||||||
|  |  | ||||||
| // Database was configured separately in each service. |  | ||||||
| // var database = builder.AddPostgres("database"); |  | ||||||
|  |  | ||||||
| var cache = builder.AddRedis("cache"); | var cache = builder.AddRedis("cache"); | ||||||
| var queue = builder.AddNats("queue").WithJetStream(); | var queue = builder.AddNats("queue").WithJetStream(); | ||||||
|  |  | ||||||
| var ringService = builder.AddProject<Projects.DysonNetwork_Ring>("ring") | var ringService = builder.AddProject<Projects.DysonNetwork_Ring>("ring"); | ||||||
|     .WithReference(queue); |  | ||||||
| var passService = builder.AddProject<Projects.DysonNetwork_Pass>("pass") | var passService = builder.AddProject<Projects.DysonNetwork_Pass>("pass") | ||||||
|     .WithReference(cache) |  | ||||||
|     .WithReference(queue) |  | ||||||
|     .WithReference(ringService); |     .WithReference(ringService); | ||||||
| var driveService = builder.AddProject<Projects.DysonNetwork_Drive>("drive") | var driveService = builder.AddProject<Projects.DysonNetwork_Drive>("drive") | ||||||
|     .WithReference(cache) |  | ||||||
|     .WithReference(queue) |  | ||||||
|     .WithReference(passService) |     .WithReference(passService) | ||||||
|     .WithReference(ringService); |     .WithReference(ringService); | ||||||
| var sphereService = builder.AddProject<Projects.DysonNetwork_Sphere>("sphere") | var sphereService = builder.AddProject<Projects.DysonNetwork_Sphere>("sphere") | ||||||
|     .WithReference(cache) |  | ||||||
|     .WithReference(queue) |  | ||||||
|     .WithReference(passService) |     .WithReference(passService) | ||||||
|     .WithReference(ringService) |     .WithReference(ringService) | ||||||
|     .WithReference(driveService); |     .WithReference(driveService); | ||||||
| var developService = builder.AddProject<Projects.DysonNetwork_Develop>("develop") | var developService = builder.AddProject<Projects.DysonNetwork_Develop>("develop") | ||||||
|     .WithReference(cache) |  | ||||||
|     .WithReference(passService) |     .WithReference(passService) | ||||||
|     .WithReference(ringService); |     .WithReference(ringService) | ||||||
|  |     .WithReference(sphereService); | ||||||
|  | var insightService = builder.AddProject<Projects.DysonNetwork_Insight>("insight") | ||||||
|  |     .WithReference(passService) | ||||||
|  |     .WithReference(ringService) | ||||||
|  |     .WithReference(sphereService) | ||||||
|  |     .WithReference(developService); | ||||||
|  |  | ||||||
|  | passService.WithReference(developService).WithReference(driveService); | ||||||
|  |  | ||||||
| List<IResourceBuilder<ProjectResource>> services = | List<IResourceBuilder<ProjectResource>> services = | ||||||
|     [ringService, passService, driveService, sphereService, developService]; |     [ringService, passService, driveService, sphereService, developService, insightService]; | ||||||
|  |  | ||||||
| for (var idx = 0; idx < services.Count; idx++) | for (var idx = 0; idx < services.Count; idx++) | ||||||
| { | { | ||||||
|     var service = services[idx]; |     var service = services[idx]; | ||||||
|  |  | ||||||
|  |     service.WithReference(cache).WithReference(queue); | ||||||
|  |  | ||||||
|     var grpcPort = 7002 + idx; |     var grpcPort = 7002 + idx; | ||||||
|  |  | ||||||
|     if (isDev) |     if (isDev) | ||||||
| @@ -62,34 +59,12 @@ for (var idx = 0; idx < services.Count; idx++) | |||||||
| // Extra double-ended references | // Extra double-ended references | ||||||
| ringService.WithReference(passService); | ringService.WithReference(passService); | ||||||
|  |  | ||||||
| builder.AddYarp("gateway") | var gateway = builder.AddProject<Projects.DysonNetwork_Gateway>("gateway") | ||||||
|     .WithConfiguration(yarp => |     .WithEnvironment("HTTP_PORTS", "5001") | ||||||
|     { |     .WithHttpEndpoint(port: 5001, targetPort: null, isProxied: false, name: "http"); | ||||||
|         var ringCluster = yarp.AddCluster(ringService.GetEndpoint("http")); |  | ||||||
|         yarp.AddRoute("/ws", ringCluster); | foreach (var service in services) | ||||||
|         yarp.AddRoute("/ring/{**catch-all}", ringCluster) |     gateway.WithReference(service); | ||||||
|             .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.AddDockerComposeEnvironment("docker-compose"); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,6 +1,5 @@ | |||||||
| <Project Sdk="Microsoft.NET.Sdk"> | <Project Sdk="Microsoft.NET.Sdk"> | ||||||
|  |   <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.2" /> | ||||||
|     <Sdk Name="Aspire.AppHost.Sdk" Version="9.4.2"/> |  | ||||||
|      |      | ||||||
|   <PropertyGroup> |   <PropertyGroup> | ||||||
|     <OutputType>Exe</OutputType> |     <OutputType>Exe</OutputType> | ||||||
| @@ -12,19 +11,18 @@ | |||||||
|   </PropertyGroup> |   </PropertyGroup> | ||||||
|      |      | ||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|         <PackageReference Include="Aspire.Hosting.AppHost" Version="9.4.2"/> |     <PackageReference Include="Aspire.Hosting.AppHost" Version="9.5.2" /> | ||||||
|     <PackageReference Include="Aspire.Hosting.Docker" Version="9.4.2-preview.1.25428.12" /> |     <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.Nats" Version="9.5.2" /> | ||||||
|         <PackageReference Include="Aspire.Hosting.Redis" Version="9.4.2" /> |     <PackageReference Include="Aspire.Hosting.Redis" Version="9.5.2" /> | ||||||
|         <PackageReference Include="Aspire.Hosting.Yarp" Version="9.4.2-preview.1.25428.12" /> |  | ||||||
|   </ItemGroup> |   </ItemGroup> | ||||||
|  |  | ||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|     <ProjectReference Include="..\DysonNetwork.Develop\DysonNetwork.Develop.csproj" /> |     <ProjectReference Include="..\DysonNetwork.Develop\DysonNetwork.Develop.csproj" /> | ||||||
|     <ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" /> |     <ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" /> | ||||||
|     <ProjectReference Include="..\DysonNetwork.Pass\DysonNetwork.Pass.csproj" /> |     <ProjectReference Include="..\DysonNetwork.Pass\DysonNetwork.Pass.csproj" /> | ||||||
|     <ProjectReference Include="..\DysonNetwork.Ring\DysonNetwork.Ring.csproj" /> |     <ProjectReference Include="..\DysonNetwork.Ring\DysonNetwork.Ring.csproj" /> | ||||||
|     <ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" /> |     <ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" /> | ||||||
|  |     <ProjectReference Include="..\DysonNetwork.Gateway\DysonNetwork.Gateway.csproj" /> | ||||||
|  |     <ProjectReference Include="..\DysonNetwork.Insight\DysonNetwork.Insight.csproj" /> | ||||||
|   </ItemGroup> |   </ItemGroup> | ||||||
|  |  | ||||||
| </Project> | </Project> | ||||||
| @@ -10,7 +10,9 @@ | |||||||
|         "ASPNETCORE_ENVIRONMENT": "Development", |         "ASPNETCORE_ENVIRONMENT": "Development", | ||||||
|         "DOTNET_ENVIRONMENT": "Development", |         "DOTNET_ENVIRONMENT": "Development", | ||||||
|         "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21175", |         "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21175", | ||||||
|         "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22189" |         "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22189", | ||||||
|  |         "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21260", | ||||||
|  |         "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22052" | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     "http": { |     "http": { | ||||||
| @@ -22,7 +24,8 @@ | |||||||
|         "ASPNETCORE_ENVIRONMENT": "Development", |         "ASPNETCORE_ENVIRONMENT": "Development", | ||||||
|         "DOTNET_ENVIRONMENT": "Development", |         "DOTNET_ENVIRONMENT": "Development", | ||||||
|         "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19163", |         "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19163", | ||||||
|         "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20185" |         "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20185", | ||||||
|  |         "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:22108" | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -1,8 +1,7 @@ | |||||||
| using System.Text.Json; | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Develop.Identity; |  | ||||||
| using DysonNetwork.Develop.Project; |  | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.EntityFrameworkCore.Design; | using Microsoft.EntityFrameworkCore.Design; | ||||||
|  | using NodaTime; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Develop; | namespace DysonNetwork.Develop; | ||||||
|  |  | ||||||
| @@ -11,13 +10,13 @@ public class AppDatabase( | |||||||
|     IConfiguration configuration |     IConfiguration configuration | ||||||
| ) : DbContext(options) | ) : DbContext(options) | ||||||
| { | { | ||||||
|     public DbSet<Developer> Developers { get; set; } = null!; |     public DbSet<SnDeveloper> Developers { get; set; } = null!; | ||||||
|  |  | ||||||
|     public DbSet<DevProject> DevProjects { get; set; } = null!; |     public DbSet<SnDevProject> DevProjects { get; set; } = null!; | ||||||
|      |      | ||||||
|     public DbSet<CustomApp> CustomApps { get; set; } = null!; |     public DbSet<SnCustomApp> CustomApps { get; set; } = null!; | ||||||
|     public DbSet<CustomAppSecret> CustomAppSecrets { get; set; } = null!; |     public DbSet<SnCustomAppSecret> CustomAppSecrets { get; set; } = null!; | ||||||
|     public DbSet<BotAccount> BotAccounts { get; set; } = null!; |     public DbSet<SnBotAccount> BotAccounts { get; set; } = null!; | ||||||
|  |  | ||||||
|     protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) |     protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) | ||||||
|     { |     { | ||||||
| @@ -32,6 +31,35 @@ public class AppDatabase( | |||||||
|         base.OnConfiguring(optionsBuilder); |         base.OnConfiguring(optionsBuilder); | ||||||
|     } |     } | ||||||
|      |      | ||||||
|  |     public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) | ||||||
|  |     { | ||||||
|  |         var now = SystemClock.Instance.GetCurrentInstant(); | ||||||
|  |  | ||||||
|  |         foreach (var entry in ChangeTracker.Entries<ModelBase>()) | ||||||
|  |         { | ||||||
|  |             switch (entry.State) | ||||||
|  |             { | ||||||
|  |                 case EntityState.Added: | ||||||
|  |                     entry.Entity.CreatedAt = now; | ||||||
|  |                     entry.Entity.UpdatedAt = now; | ||||||
|  |                     break; | ||||||
|  |                 case EntityState.Modified: | ||||||
|  |                     entry.Entity.UpdatedAt = now; | ||||||
|  |                     break; | ||||||
|  |                 case EntityState.Deleted: | ||||||
|  |                     entry.State = EntityState.Modified; | ||||||
|  |                     entry.Entity.DeletedAt = now; | ||||||
|  |                     break; | ||||||
|  |                 case EntityState.Detached: | ||||||
|  |                 case EntityState.Unchanged: | ||||||
|  |                 default: | ||||||
|  |                     break; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return await base.SaveChangesAsync(cancellationToken); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     protected override void OnModelCreating(ModelBuilder modelBuilder) |     protected override void OnModelCreating(ModelBuilder modelBuilder) | ||||||
|     { |     { | ||||||
|         base.OnModelCreating(modelBuilder); |         base.OnModelCreating(modelBuilder); | ||||||
|   | |||||||
| @@ -9,16 +9,15 @@ | |||||||
|  |  | ||||||
|     <ItemGroup> |     <ItemGroup> | ||||||
|         <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> |         <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> | ||||||
|         <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7"/> |         <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" /> | ||||||
|         <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7"> |         <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10"> | ||||||
|             <PrivateAssets>all</PrivateAssets> |             <PrivateAssets>all</PrivateAssets> | ||||||
|             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> |             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||||
|         </PackageReference> |         </PackageReference> | ||||||
|         <PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" /> |         <PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" /> | ||||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/> |         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" 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="prometheus-net.AspNetCore" Version="8.2.1"/> |         <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" /> | ||||||
|         <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3"/> |  | ||||||
|         <PackageReference Include="NodaTime" Version="3.2.2"/> |         <PackageReference Include="NodaTime" Version="3.2.2"/> | ||||||
|         <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/> |         <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/> | ||||||
|         <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0"/> |         <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0"/> | ||||||
| @@ -31,7 +30,6 @@ | |||||||
|     </ItemGroup> |     </ItemGroup> | ||||||
|  |  | ||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|     <ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" /> |  | ||||||
|     <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> |     <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> | ||||||
|   </ItemGroup> |   </ItemGroup> | ||||||
|   |   | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| using System.ComponentModel.DataAnnotations; | using System.ComponentModel.DataAnnotations; | ||||||
| using DysonNetwork.Develop.Project; | using DysonNetwork.Develop.Project; | ||||||
| using DysonNetwork.Shared.Data; | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
| using DysonNetwork.Shared.Registry; | using DysonNetwork.Shared.Registry; | ||||||
| using Grpc.Core; | using Grpc.Core; | ||||||
| @@ -16,10 +16,10 @@ namespace DysonNetwork.Develop.Identity; | |||||||
| [Authorize] | [Authorize] | ||||||
| public class BotAccountController( | public class BotAccountController( | ||||||
|     BotAccountService botService, |     BotAccountService botService, | ||||||
|     DeveloperService developerService, |     DeveloperService ds, | ||||||
|     DevProjectService projectService, |     DevProjectService projectService, | ||||||
|     ILogger<BotAccountController> logger, |     ILogger<BotAccountController> logger, | ||||||
|     AccountClientHelper accounts, |     RemoteAccountService remoteAccounts, | ||||||
|     BotAccountReceiverService.BotAccountReceiverServiceClient accountsReceiver |     BotAccountReceiverService.BotAccountReceiverServiceClient accountsReceiver | ||||||
| ) | ) | ||||||
|     : ControllerBase |     : ControllerBase | ||||||
| @@ -50,9 +50,9 @@ public class BotAccountController( | |||||||
|         ] |         ] | ||||||
|         public string Name { get; set; } = string.Empty; |         public string Name { get; set; } = string.Empty; | ||||||
|  |  | ||||||
|         [Required] [MaxLength(256)] public string Nick { get; set; } = string.Empty; |         [Required][MaxLength(256)] public string Nick { get; set; } = string.Empty; | ||||||
|  |  | ||||||
|         [Required] [MaxLength(1024)] public string Slug { get; set; } = string.Empty; |         [Required][MaxLength(1024)] public string Slug { get; set; } = string.Empty; | ||||||
|  |  | ||||||
|         [MaxLength(128)] public string Language { get; set; } = "en-us"; |         [MaxLength(128)] public string Language { get; set; } = "en-us"; | ||||||
|     } |     } | ||||||
| @@ -68,7 +68,7 @@ public class BotAccountController( | |||||||
|  |  | ||||||
|         [MaxLength(256)] public string? Nick { get; set; } = string.Empty; |         [MaxLength(256)] public string? Nick { get; set; } = string.Empty; | ||||||
|  |  | ||||||
|         [Required] [MaxLength(1024)] public string? Slug { get; set; } = string.Empty; |         [Required][MaxLength(1024)] public string? Slug { get; set; } = string.Empty; | ||||||
|  |  | ||||||
|         [MaxLength(128)] public string? Language { get; set; } |         [MaxLength(128)] public string? Language { get; set; } | ||||||
|  |  | ||||||
| @@ -83,12 +83,12 @@ public class BotAccountController( | |||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|             return Unauthorized(); |             return Unauthorized(); | ||||||
|  |  | ||||||
|         var developer = await developerService.GetDeveloperByName(pubName); |         var developer = await ds.GetDeveloperByName(pubName); | ||||||
|         if (developer is null) |         if (developer is null) | ||||||
|             return NotFound("Developer not found"); |             return NotFound("Developer not found"); | ||||||
|  |  | ||||||
|         if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), | ||||||
|                 PublisherMemberRole.Viewer)) |                 Shared.Proto.PublisherMemberRole.Viewer)) | ||||||
|             return StatusCode(403, "You must be an viewer of the developer to list bots"); |             return StatusCode(403, "You must be an viewer of the developer to list bots"); | ||||||
|  |  | ||||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
| @@ -108,12 +108,12 @@ public class BotAccountController( | |||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|             return Unauthorized(); |             return Unauthorized(); | ||||||
|  |  | ||||||
|         var developer = await developerService.GetDeveloperByName(pubName); |         var developer = await ds.GetDeveloperByName(pubName); | ||||||
|         if (developer is null) |         if (developer is null) | ||||||
|             return NotFound("Developer not found"); |             return NotFound("Developer not found"); | ||||||
|  |  | ||||||
|         if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), | ||||||
|                 PublisherMemberRole.Viewer)) |                 Shared.Proto.PublisherMemberRole.Viewer)) | ||||||
|             return StatusCode(403, "You must be an viewer of the developer to view bot details"); |             return StatusCode(403, "You must be an viewer of the developer to view bot details"); | ||||||
|  |  | ||||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
| @@ -137,12 +137,12 @@ public class BotAccountController( | |||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|             return Unauthorized(); |             return Unauthorized(); | ||||||
|  |  | ||||||
|         var developer = await developerService.GetDeveloperByName(pubName); |         var developer = await ds.GetDeveloperByName(pubName); | ||||||
|         if (developer is null) |         if (developer is null) | ||||||
|             return NotFound("Developer not found"); |             return NotFound("Developer not found"); | ||||||
|  |  | ||||||
|         if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), | ||||||
|                 PublisherMemberRole.Editor)) |                 Shared.Proto.PublisherMemberRole.Editor)) | ||||||
|             return StatusCode(403, "You must be an editor of the developer to create a bot"); |             return StatusCode(403, "You must be an editor of the developer to create a bot"); | ||||||
|  |  | ||||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
| @@ -206,12 +206,12 @@ public class BotAccountController( | |||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|             return Unauthorized(); |             return Unauthorized(); | ||||||
|  |  | ||||||
|         var developer = await developerService.GetDeveloperByName(pubName); |         var developer = await ds.GetDeveloperByName(pubName); | ||||||
|         if (developer is null) |         if (developer is null) | ||||||
|             return NotFound("Developer not found"); |             return NotFound("Developer not found"); | ||||||
|  |  | ||||||
|         if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), | ||||||
|                 PublisherMemberRole.Editor)) |                 Shared.Proto.PublisherMemberRole.Editor)) | ||||||
|             return StatusCode(403, "You must be an editor of the developer to update a bot"); |             return StatusCode(403, "You must be an editor of the developer to update a bot"); | ||||||
|  |  | ||||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
| @@ -222,7 +222,7 @@ public class BotAccountController( | |||||||
|         if (bot is null || bot.ProjectId != projectId) |         if (bot is null || bot.ProjectId != projectId) | ||||||
|             return NotFound("Bot not found"); |             return NotFound("Bot not found"); | ||||||
|  |  | ||||||
|         var botAccount = await accounts.GetBotAccount(bot.Id); |         var botAccount = await remoteAccounts.GetBotAccount(bot.Id); | ||||||
|  |  | ||||||
|         if (request.Name is not null) botAccount.Name = request.Name; |         if (request.Name is not null) botAccount.Name = request.Name; | ||||||
|         if (request.Nick is not null) botAccount.Nick = request.Nick; |         if (request.Nick is not null) botAccount.Nick = request.Nick; | ||||||
| @@ -267,12 +267,12 @@ public class BotAccountController( | |||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|             return Unauthorized(); |             return Unauthorized(); | ||||||
|  |  | ||||||
|         var developer = await developerService.GetDeveloperByName(pubName); |         var developer = await ds.GetDeveloperByName(pubName); | ||||||
|         if (developer is null) |         if (developer is null) | ||||||
|             return NotFound("Developer not found"); |             return NotFound("Developer not found"); | ||||||
|  |  | ||||||
|         if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), | ||||||
|                 PublisherMemberRole.Editor)) |                 Shared.Proto.PublisherMemberRole.Editor)) | ||||||
|             return StatusCode(403, "You must be an editor of the developer to delete a bot"); |             return StatusCode(403, "You must be an editor of the developer to delete a bot"); | ||||||
|  |  | ||||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
| @@ -296,7 +296,7 @@ public class BotAccountController( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     [HttpGet("{botId:guid}/keys")] |     [HttpGet("{botId:guid}/keys")] | ||||||
|     public async Task<ActionResult<List<ApiKeyReference>>> ListBotKeys( |     public async Task<ActionResult<List<SnApiKey>>> ListBotKeys( | ||||||
|         [FromRoute] string pubName, |         [FromRoute] string pubName, | ||||||
|         [FromRoute] Guid projectId, |         [FromRoute] Guid projectId, | ||||||
|         [FromRoute] Guid botId |         [FromRoute] Guid botId | ||||||
| @@ -305,7 +305,7 @@ public class BotAccountController( | |||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|             return Unauthorized(); |             return Unauthorized(); | ||||||
|  |  | ||||||
|         var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Viewer); |         var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, Shared.Proto.PublisherMemberRole.Viewer); | ||||||
|         if (developer == null) return NotFound("Developer not found"); |         if (developer == null) return NotFound("Developer not found"); | ||||||
|         if (project == null) return NotFound("Project not found or you don't have access"); |         if (project == null) return NotFound("Project not found or you don't have access"); | ||||||
|         if (bot == null) return NotFound("Bot not found"); |         if (bot == null) return NotFound("Bot not found"); | ||||||
| @@ -314,13 +314,13 @@ public class BotAccountController( | |||||||
|         { |         { | ||||||
|             AutomatedId = bot.Id.ToString() |             AutomatedId = bot.Id.ToString() | ||||||
|         }); |         }); | ||||||
|         var data = keys.Data.Select(ApiKeyReference.FromProtoValue).ToList(); |         var data = keys.Data.Select(SnApiKey.FromProtoValue).ToList(); | ||||||
|  |  | ||||||
|         return Ok(data); |         return Ok(data); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     [HttpGet("{botId:guid}/keys/{keyId:guid}")] |     [HttpGet("{botId:guid}/keys/{keyId:guid}")] | ||||||
|     public async Task<ActionResult<ApiKeyReference>> GetBotKey( |     public async Task<ActionResult<SnApiKey>> GetBotKey( | ||||||
|         [FromRoute] string pubName, |         [FromRoute] string pubName, | ||||||
|         [FromRoute] Guid projectId, |         [FromRoute] Guid projectId, | ||||||
|         [FromRoute] Guid botId, |         [FromRoute] Guid botId, | ||||||
| @@ -329,7 +329,7 @@ public class BotAccountController( | |||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|             return Unauthorized(); |             return Unauthorized(); | ||||||
|  |  | ||||||
|         var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Viewer); |         var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, Shared.Proto.PublisherMemberRole.Viewer); | ||||||
|         if (developer == null) return NotFound("Developer not found"); |         if (developer == null) return NotFound("Developer not found"); | ||||||
|         if (project == null) return NotFound("Project not found or you don't have access"); |         if (project == null) return NotFound("Project not found or you don't have access"); | ||||||
|         if (bot == null) return NotFound("Bot not found"); |         if (bot == null) return NotFound("Bot not found"); | ||||||
| @@ -338,7 +338,7 @@ public class BotAccountController( | |||||||
|         { |         { | ||||||
|             var key = await accountsReceiver.GetApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() }); |             var key = await accountsReceiver.GetApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() }); | ||||||
|             if (key == null) return NotFound("API key not found"); |             if (key == null) return NotFound("API key not found"); | ||||||
|             return Ok(ApiKeyReference.FromProtoValue(key)); |             return Ok(SnApiKey.FromProtoValue(key)); | ||||||
|         } |         } | ||||||
|         catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) |         catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) | ||||||
|         { |         { | ||||||
| @@ -353,7 +353,7 @@ public class BotAccountController( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     [HttpPost("{botId:guid}/keys")] |     [HttpPost("{botId:guid}/keys")] | ||||||
|     public async Task<ActionResult<ApiKeyReference>> CreateBotKey( |     public async Task<ActionResult<SnApiKey>> CreateBotKey( | ||||||
|         [FromRoute] string pubName, |         [FromRoute] string pubName, | ||||||
|         [FromRoute] Guid projectId, |         [FromRoute] Guid projectId, | ||||||
|         [FromRoute] Guid botId, |         [FromRoute] Guid botId, | ||||||
| @@ -362,7 +362,7 @@ public class BotAccountController( | |||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|             return Unauthorized(); |             return Unauthorized(); | ||||||
|  |  | ||||||
|         var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor); |         var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, Shared.Proto.PublisherMemberRole.Editor); | ||||||
|         if (developer == null) return NotFound("Developer not found"); |         if (developer == null) return NotFound("Developer not found"); | ||||||
|         if (project == null) return NotFound("Project not found or you don't have access"); |         if (project == null) return NotFound("Project not found or you don't have access"); | ||||||
|         if (bot == null) return NotFound("Bot not found"); |         if (bot == null) return NotFound("Bot not found"); | ||||||
| @@ -376,7 +376,7 @@ public class BotAccountController( | |||||||
|             }; |             }; | ||||||
|  |  | ||||||
|             var createdKey = await accountsReceiver.CreateApiKeyAsync(newKey); |             var createdKey = await accountsReceiver.CreateApiKeyAsync(newKey); | ||||||
|             return Ok(ApiKeyReference.FromProtoValue(createdKey)); |             return Ok(SnApiKey.FromProtoValue(createdKey)); | ||||||
|         } |         } | ||||||
|         catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument) |         catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument) | ||||||
|         { |         { | ||||||
| @@ -385,7 +385,7 @@ public class BotAccountController( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     [HttpPost("{botId:guid}/keys/{keyId:guid}/rotate")] |     [HttpPost("{botId:guid}/keys/{keyId:guid}/rotate")] | ||||||
|     public async Task<ActionResult<ApiKeyReference>> RotateBotKey( |     public async Task<ActionResult<SnApiKey>> RotateBotKey( | ||||||
|         [FromRoute] string pubName, |         [FromRoute] string pubName, | ||||||
|         [FromRoute] Guid projectId, |         [FromRoute] Guid projectId, | ||||||
|         [FromRoute] Guid botId, |         [FromRoute] Guid botId, | ||||||
| @@ -394,7 +394,7 @@ public class BotAccountController( | |||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|             return Unauthorized(); |             return Unauthorized(); | ||||||
|  |  | ||||||
|         var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor); |         var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, Shared.Proto.PublisherMemberRole.Editor); | ||||||
|         if (developer == null) return NotFound("Developer not found"); |         if (developer == null) return NotFound("Developer not found"); | ||||||
|         if (project == null) return NotFound("Project not found or you don't have access"); |         if (project == null) return NotFound("Project not found or you don't have access"); | ||||||
|         if (bot == null) return NotFound("Bot not found"); |         if (bot == null) return NotFound("Bot not found"); | ||||||
| @@ -402,7 +402,7 @@ public class BotAccountController( | |||||||
|         try |         try | ||||||
|         { |         { | ||||||
|             var rotatedKey = await accountsReceiver.RotateApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() }); |             var rotatedKey = await accountsReceiver.RotateApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() }); | ||||||
|             return Ok(ApiKeyReference.FromProtoValue(rotatedKey)); |             return Ok(SnApiKey.FromProtoValue(rotatedKey)); | ||||||
|         } |         } | ||||||
|         catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) |         catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) | ||||||
|         { |         { | ||||||
| @@ -420,7 +420,7 @@ public class BotAccountController( | |||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|             return Unauthorized(); |             return Unauthorized(); | ||||||
|  |  | ||||||
|         var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor); |         var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, Shared.Proto.PublisherMemberRole.Editor); | ||||||
|         if (developer == null) return NotFound("Developer not found"); |         if (developer == null) return NotFound("Developer not found"); | ||||||
|         if (project == null) return NotFound("Project not found or you don't have access"); |         if (project == null) return NotFound("Project not found or you don't have access"); | ||||||
|         if (bot == null) return NotFound("Bot not found"); |         if (bot == null) return NotFound("Bot not found"); | ||||||
| @@ -436,17 +436,17 @@ public class BotAccountController( | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private async Task<(Developer?, DevProject?, BotAccount?)> ValidateBotAccess( |     private async Task<(SnDeveloper?, SnDevProject?, SnBotAccount?)> ValidateBotAccess( | ||||||
|         string pubName, |         string pubName, | ||||||
|         Guid projectId, |         Guid projectId, | ||||||
|         Guid botId, |         Guid botId, | ||||||
|         Account currentUser, |         Account currentUser, | ||||||
|         PublisherMemberRole requiredRole) |         Shared.Proto.PublisherMemberRole requiredRole) | ||||||
|     { |     { | ||||||
|         var developer = await developerService.GetDeveloperByName(pubName); |         var developer = await ds.GetDeveloperByName(pubName); | ||||||
|         if (developer == null) return (null, null, null); |         if (developer == null) return (null, null, null); | ||||||
|  |  | ||||||
|         if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), requiredRole)) |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), requiredRole)) | ||||||
|             return (null, null, null); |             return (null, null, null); | ||||||
|  |  | ||||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | using DysonNetwork.Shared.Models; | ||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Develop.Identity; | namespace DysonNetwork.Develop.Identity; | ||||||
| @@ -7,7 +8,7 @@ namespace DysonNetwork.Develop.Identity; | |||||||
| public class BotAccountPublicController(BotAccountService botService, DeveloperService developerService) : ControllerBase | public class BotAccountPublicController(BotAccountService botService, DeveloperService developerService) : ControllerBase | ||||||
| { | { | ||||||
|     [HttpGet("{botId:guid}")] |     [HttpGet("{botId:guid}")] | ||||||
|     public async Task<ActionResult<BotAccount>> GetBotTransparentInfo([FromRoute] Guid botId) |     public async Task<ActionResult<SnBotAccount>> GetBotTransparentInfo([FromRoute] Guid botId) | ||||||
|     { |     { | ||||||
|         var bot = await botService.GetBotByIdAsync(botId); |         var bot = await botService.GetBotByIdAsync(botId); | ||||||
|         if (bot is null) return NotFound("Bot not found"); |         if (bot is null) return NotFound("Bot not found"); | ||||||
| @@ -21,7 +22,7 @@ public class BotAccountPublicController(BotAccountService botService, DeveloperS | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     [HttpGet("{botId:guid}/developer")] |     [HttpGet("{botId:guid}/developer")] | ||||||
|     public async Task<ActionResult<Developer>> GetBotDeveloper([FromRoute] Guid botId) |     public async Task<ActionResult<SnDeveloper>> GetBotDeveloper([FromRoute] Guid botId) | ||||||
|     { |     { | ||||||
|         var bot = await botService.GetBotByIdAsync(botId); |         var bot = await botService.GetBotByIdAsync(botId); | ||||||
|         if (bot is null) return NotFound("Bot not found"); |         if (bot is null) return NotFound("Bot not found"); | ||||||
|   | |||||||
| @@ -1,5 +1,4 @@ | |||||||
| using DysonNetwork.Develop.Project; | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Data; |  | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
| using DysonNetwork.Shared.Registry; | using DysonNetwork.Shared.Registry; | ||||||
| using Grpc.Core; | using Grpc.Core; | ||||||
| @@ -11,25 +10,25 @@ namespace DysonNetwork.Develop.Identity; | |||||||
| public class BotAccountService( | public class BotAccountService( | ||||||
|     AppDatabase db, |     AppDatabase db, | ||||||
|     BotAccountReceiverService.BotAccountReceiverServiceClient accountReceiver, |     BotAccountReceiverService.BotAccountReceiverServiceClient accountReceiver, | ||||||
|     AccountClientHelper accounts |     RemoteAccountService remoteAccounts | ||||||
| ) | ) | ||||||
| { | { | ||||||
|     public async Task<BotAccount?> GetBotByIdAsync(Guid id) |     public async Task<SnBotAccount?> GetBotByIdAsync(Guid id) | ||||||
|     { |     { | ||||||
|         return await db.BotAccounts |         return await db.BotAccounts | ||||||
|             .Include(b => b.Project) |             .Include(b => b.Project) | ||||||
|             .FirstOrDefaultAsync(b => b.Id == id); |             .FirstOrDefaultAsync(b => b.Id == id); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<IEnumerable<BotAccount>> GetBotsByProjectAsync(Guid projectId) |     public async Task<List<SnBotAccount>> GetBotsByProjectAsync(Guid projectId) | ||||||
|     { |     { | ||||||
|         return await db.BotAccounts |         return await db.BotAccounts | ||||||
|             .Where(b => b.ProjectId == projectId) |             .Where(b => b.ProjectId == projectId) | ||||||
|             .ToListAsync(); |             .ToListAsync(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<BotAccount> CreateBotAsync( |     public async Task<SnBotAccount> CreateBotAsync( | ||||||
|         DevProject project, |         SnDevProject project, | ||||||
|         string slug, |         string slug, | ||||||
|         Account account, |         Account account, | ||||||
|         string? pictureId, |         string? pictureId, | ||||||
| @@ -58,7 +57,7 @@ public class BotAccountService( | |||||||
|             var botAccount = createResponse.Bot; |             var botAccount = createResponse.Bot; | ||||||
|  |  | ||||||
|             // Then create the local bot account |             // Then create the local bot account | ||||||
|             var bot = new BotAccount |             var bot = new SnBotAccount | ||||||
|             { |             { | ||||||
|                 Id = automatedId, |                 Id = automatedId, | ||||||
|                 Slug = slug, |                 Slug = slug, | ||||||
| @@ -89,8 +88,8 @@ public class BotAccountService( | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<BotAccount> UpdateBotAsync( |     public async Task<SnBotAccount> UpdateBotAsync( | ||||||
|         BotAccount bot, |         SnBotAccount bot, | ||||||
|         Account account, |         Account account, | ||||||
|         string? pictureId, |         string? pictureId, | ||||||
|         string? backgroundId |         string? backgroundId | ||||||
| @@ -130,7 +129,7 @@ public class BotAccountService( | |||||||
|         return bot; |         return bot; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task DeleteBotAsync(BotAccount bot) |     public async Task DeleteBotAsync(SnBotAccount bot) | ||||||
|     { |     { | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
| @@ -153,22 +152,21 @@ public class BotAccountService( | |||||||
|         await db.SaveChangesAsync(); |         await db.SaveChangesAsync(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<BotAccount?> LoadBotAccountAsync(BotAccount bot) => |     public async Task<SnBotAccount?> LoadBotAccountAsync(SnBotAccount bot) => | ||||||
|         (await LoadBotsAccountAsync([bot])).FirstOrDefault(); |         (await LoadBotsAccountAsync([bot])).FirstOrDefault(); | ||||||
|  |  | ||||||
|     public async Task<List<BotAccount>> LoadBotsAccountAsync(IEnumerable<BotAccount> bots) |     public async Task<List<SnBotAccount>> LoadBotsAccountAsync(List<SnBotAccount> bots) | ||||||
|     { |     { | ||||||
|         bots = bots.ToList(); |  | ||||||
|         var automatedIds = bots.Select(b => b.Id).ToList(); |         var automatedIds = bots.Select(b => b.Id).ToList(); | ||||||
|         var data = await accounts.GetBotAccountBatch(automatedIds); |         var data = await remoteAccounts.GetBotAccountBatch(automatedIds); | ||||||
|  |  | ||||||
|         foreach (var bot in bots) |         foreach (var bot in bots) | ||||||
|         { |         { | ||||||
|             bot.Account = data |             bot.Account = data | ||||||
|                 .Select(AccountReference.FromProtoValue) |                 .Select(SnAccount.FromProtoValue) | ||||||
|                 .FirstOrDefault(e => e.AutomatedId == bot.Id); |                 .FirstOrDefault(e => e.AutomatedId == bot.Id); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         return bots as List<BotAccount> ?? []; |         return bots; | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -1,5 +1,6 @@ | |||||||
| using System.ComponentModel.DataAnnotations; | using System.ComponentModel.DataAnnotations; | ||||||
| using DysonNetwork.Develop.Project; | using DysonNetwork.Develop.Project; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
| using Microsoft.AspNetCore.Authorization; | using Microsoft.AspNetCore.Authorization; | ||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| @@ -18,9 +19,9 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d | |||||||
|         [MaxLength(4096)] string? Description, |         [MaxLength(4096)] string? Description, | ||||||
|         string? PictureId, |         string? PictureId, | ||||||
|         string? BackgroundId, |         string? BackgroundId, | ||||||
|         CustomAppStatus? Status, |         Shared.Models.CustomAppStatus? Status, | ||||||
|         CustomAppLinks? Links, |         SnCustomAppLinks? Links, | ||||||
|         CustomAppOauthConfig? OauthConfig |         SnCustomAppOauthConfig? OauthConfig | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|     public record CreateSecretRequest( |     public record CreateSecretRequest( | ||||||
| @@ -50,7 +51,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d | |||||||
|         if (developer is null) return NotFound(); |         if (developer is null) return NotFound(); | ||||||
|  |  | ||||||
|         var accountId = Guid.Parse(currentUser.Id); |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|         if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, PublisherMemberRole.Viewer)) |         if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, Shared.Proto.PublisherMemberRole.Viewer)) | ||||||
|             return StatusCode(403, "You must be a viewer of the developer to list custom apps"); |             return StatusCode(403, "You must be a viewer of the developer to list custom apps"); | ||||||
|  |  | ||||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
| @@ -72,7 +73,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d | |||||||
|         if (developer is null) return NotFound(); |         if (developer is null) return NotFound(); | ||||||
|          |          | ||||||
|         var accountId = Guid.Parse(currentUser.Id); |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|         if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, PublisherMemberRole.Viewer)) |         if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, Shared.Proto.PublisherMemberRole.Viewer)) | ||||||
|             return StatusCode(403, "You must be a viewer of the developer to list custom apps"); |             return StatusCode(403, "You must be a viewer of the developer to list custom apps"); | ||||||
|  |  | ||||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
| @@ -99,7 +100,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d | |||||||
|         if (developer is null) |         if (developer is null) | ||||||
|             return NotFound("Developer not found"); |             return NotFound("Developer not found"); | ||||||
|  |  | ||||||
|         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor)) | ||||||
|             return StatusCode(403, "You must be an editor of the developer to create a custom app"); |             return StatusCode(403, "You must be an editor of the developer to create a custom app"); | ||||||
|  |  | ||||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
| @@ -143,7 +144,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d | |||||||
|         if (developer is null) |         if (developer is null) | ||||||
|             return NotFound("Developer not found"); |             return NotFound("Developer not found"); | ||||||
|  |  | ||||||
|         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor)) | ||||||
|             return StatusCode(403, "You must be an editor of the developer to update a custom app"); |             return StatusCode(403, "You must be an editor of the developer to update a custom app"); | ||||||
|  |  | ||||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
| @@ -180,7 +181,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d | |||||||
|         if (developer is null) |         if (developer is null) | ||||||
|             return NotFound("Developer not found"); |             return NotFound("Developer not found"); | ||||||
|  |  | ||||||
|         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor)) | ||||||
|             return StatusCode(403, "You must be an editor of the developer to delete a custom app"); |             return StatusCode(403, "You must be an editor of the developer to delete a custom app"); | ||||||
|  |  | ||||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
| @@ -212,7 +213,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d | |||||||
|         if (developer is null) |         if (developer is null) | ||||||
|             return NotFound("Developer not found"); |             return NotFound("Developer not found"); | ||||||
|  |  | ||||||
|         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor)) | ||||||
|             return StatusCode(403, "You must be an editor of the developer to view app secrets"); |             return StatusCode(403, "You must be an editor of the developer to view app secrets"); | ||||||
|  |  | ||||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
| @@ -250,7 +251,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d | |||||||
|         if (developer is null) |         if (developer is null) | ||||||
|             return NotFound("Developer not found"); |             return NotFound("Developer not found"); | ||||||
|  |  | ||||||
|         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor)) | ||||||
|             return StatusCode(403, "You must be an editor of the developer to create app secrets"); |             return StatusCode(403, "You must be an editor of the developer to create app secrets"); | ||||||
|  |  | ||||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
| @@ -263,7 +264,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d | |||||||
|  |  | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
|             var secret = await customApps.CreateAppSecretAsync(new CustomAppSecret |             var secret = await customApps.CreateAppSecretAsync(new SnCustomAppSecret | ||||||
|             { |             { | ||||||
|                 AppId = appId, |                 AppId = appId, | ||||||
|                 Description = request.Description, |                 Description = request.Description, | ||||||
| @@ -309,7 +310,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d | |||||||
|         if (developer is null) |         if (developer is null) | ||||||
|             return NotFound("Developer not found"); |             return NotFound("Developer not found"); | ||||||
|  |  | ||||||
|         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor)) | ||||||
|             return StatusCode(403, "You must be an editor of the developer to view app secrets"); |             return StatusCode(403, "You must be an editor of the developer to view app secrets"); | ||||||
|  |  | ||||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
| @@ -350,7 +351,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d | |||||||
|         if (developer is null) |         if (developer is null) | ||||||
|             return NotFound("Developer not found"); |             return NotFound("Developer not found"); | ||||||
|  |  | ||||||
|         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor)) | ||||||
|             return StatusCode(403, "You must be an editor of the developer to delete app secrets"); |             return StatusCode(403, "You must be an editor of the developer to delete app secrets"); | ||||||
|  |  | ||||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
| @@ -388,7 +389,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d | |||||||
|         if (developer is null) |         if (developer is null) | ||||||
|             return NotFound("Developer not found"); |             return NotFound("Developer not found"); | ||||||
|  |  | ||||||
|         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) |         if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor)) | ||||||
|             return StatusCode(403, "You must be an editor of the developer to rotate app secrets"); |             return StatusCode(403, "You must be an editor of the developer to rotate app secrets"); | ||||||
|  |  | ||||||
|         var project = await projectService.GetProjectAsync(projectId, developer.Id); |         var project = await projectService.GetProjectAsync(projectId, developer.Id); | ||||||
| @@ -401,7 +402,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d | |||||||
|  |  | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
|             var secret = await customApps.RotateAppSecretAsync(new CustomAppSecret |             var secret = await customApps.RotateAppSecretAsync(new SnCustomAppSecret | ||||||
|             { |             { | ||||||
|                 Id = secretId, |                 Id = secretId, | ||||||
|                 AppId = appId, |                 AppId = appId, | ||||||
|   | |||||||
| @@ -1,5 +1,4 @@ | |||||||
| using DysonNetwork.Develop.Project; | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Data; |  | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using System.Security.Cryptography; | using System.Security.Cryptography; | ||||||
| @@ -13,7 +12,7 @@ public class CustomAppService( | |||||||
|     FileService.FileServiceClient files |     FileService.FileServiceClient files | ||||||
| ) | ) | ||||||
| { | { | ||||||
|     public async Task<CustomApp?> CreateAppAsync( |     public async Task<SnCustomApp?> CreateAppAsync( | ||||||
|         Guid projectId, |         Guid projectId, | ||||||
|         CustomAppController.CustomAppRequest request |         CustomAppController.CustomAppRequest request | ||||||
|     ) |     ) | ||||||
| @@ -25,12 +24,12 @@ public class CustomAppService( | |||||||
|         if (project == null) |         if (project == null) | ||||||
|             return null; |             return null; | ||||||
|              |              | ||||||
|         var app = new CustomApp |         var app = new SnCustomApp | ||||||
|         { |         { | ||||||
|             Slug = request.Slug!, |             Slug = request.Slug!, | ||||||
|             Name = request.Name!, |             Name = request.Name!, | ||||||
|             Description = request.Description, |             Description = request.Description, | ||||||
|             Status = request.Status ?? CustomAppStatus.Developing, |             Status = request.Status ?? Shared.Models.CustomAppStatus.Developing, | ||||||
|             Links = request.Links, |             Links = request.Links, | ||||||
|             OauthConfig = request.OauthConfig, |             OauthConfig = request.OauthConfig, | ||||||
|             ProjectId = projectId |             ProjectId = projectId | ||||||
| @@ -46,7 +45,7 @@ public class CustomAppService( | |||||||
|             ); |             ); | ||||||
|             if (picture is null) |             if (picture is null) | ||||||
|                 throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud."); |                 throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud."); | ||||||
|             app.Picture = CloudFileReferenceObject.FromProtoValue(picture); |             app.Picture = SnCloudFileReferenceObject.FromProtoValue(picture); | ||||||
|  |  | ||||||
|             // Create a new reference |             // Create a new reference | ||||||
|             await fileRefs.CreateReferenceAsync( |             await fileRefs.CreateReferenceAsync( | ||||||
| @@ -65,7 +64,7 @@ public class CustomAppService( | |||||||
|             ); |             ); | ||||||
|             if (background is null) |             if (background is null) | ||||||
|                 throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud."); |                 throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud."); | ||||||
|             app.Background = CloudFileReferenceObject.FromProtoValue(background); |             app.Background = SnCloudFileReferenceObject.FromProtoValue(background); | ||||||
|  |  | ||||||
|             // Create a new reference |             // Create a new reference | ||||||
|             await fileRefs.CreateReferenceAsync( |             await fileRefs.CreateReferenceAsync( | ||||||
| @@ -84,7 +83,7 @@ public class CustomAppService( | |||||||
|         return app; |         return app; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<CustomApp?> GetAppAsync(Guid id, Guid? projectId = null) |     public async Task<SnCustomApp?> GetAppAsync(Guid id, Guid? projectId = null) | ||||||
|     { |     { | ||||||
|         var query = db.CustomApps.AsQueryable(); |         var query = db.CustomApps.AsQueryable(); | ||||||
|          |          | ||||||
| @@ -96,7 +95,7 @@ public class CustomAppService( | |||||||
|         return await query.FirstOrDefaultAsync(a => a.Id == id); |         return await query.FirstOrDefaultAsync(a => a.Id == id); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<List<CustomAppSecret>> GetAppSecretsAsync(Guid appId) |     public async Task<List<SnCustomAppSecret>> GetAppSecretsAsync(Guid appId) | ||||||
|     { |     { | ||||||
|         return await db.CustomAppSecrets |         return await db.CustomAppSecrets | ||||||
|             .Where(s => s.AppId == appId) |             .Where(s => s.AppId == appId) | ||||||
| @@ -104,13 +103,13 @@ public class CustomAppService( | |||||||
|             .ToListAsync(); |             .ToListAsync(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<CustomAppSecret?> GetAppSecretAsync(Guid secretId, Guid appId) |     public async Task<SnCustomAppSecret?> GetAppSecretAsync(Guid secretId, Guid appId) | ||||||
|     { |     { | ||||||
|         return await db.CustomAppSecrets |         return await db.CustomAppSecrets | ||||||
|             .FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId); |             .FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<CustomAppSecret> CreateAppSecretAsync(CustomAppSecret secret) |     public async Task<SnCustomAppSecret> CreateAppSecretAsync(SnCustomAppSecret secret) | ||||||
|     { |     { | ||||||
|         if (string.IsNullOrWhiteSpace(secret.Secret)) |         if (string.IsNullOrWhiteSpace(secret.Secret)) | ||||||
|         { |         { | ||||||
| @@ -141,7 +140,7 @@ public class CustomAppService( | |||||||
|         return true; |         return true; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<CustomAppSecret> RotateAppSecretAsync(CustomAppSecret secretUpdate) |     public async Task<SnCustomAppSecret> RotateAppSecretAsync(SnCustomAppSecret secretUpdate) | ||||||
|     { |     { | ||||||
|         var existingSecret = await db.CustomAppSecrets |         var existingSecret = await db.CustomAppSecrets | ||||||
|             .FirstOrDefaultAsync(s => s.Id == secretUpdate.Id && s.AppId == secretUpdate.AppId); |             .FirstOrDefaultAsync(s => s.Id == secretUpdate.Id && s.AppId == secretUpdate.AppId); | ||||||
| @@ -177,14 +176,14 @@ public class CustomAppService( | |||||||
|         return res.ToString(); |         return res.ToString(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<List<CustomApp>> GetAppsByProjectAsync(Guid projectId) |     public async Task<List<SnCustomApp>> GetAppsByProjectAsync(Guid projectId) | ||||||
|     { |     { | ||||||
|         return await db.CustomApps |         return await db.CustomApps | ||||||
|             .Where(a => a.ProjectId == projectId) |             .Where(a => a.ProjectId == projectId) | ||||||
|             .ToListAsync(); |             .ToListAsync(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<CustomApp?> UpdateAppAsync(CustomApp app, CustomAppController.CustomAppRequest request) |     public async Task<SnCustomApp?> UpdateAppAsync(SnCustomApp app, CustomAppController.CustomAppRequest request) | ||||||
|     { |     { | ||||||
|         if (request.Slug is not null) |         if (request.Slug is not null) | ||||||
|             app.Slug = request.Slug; |             app.Slug = request.Slug; | ||||||
| @@ -209,7 +208,7 @@ public class CustomAppService( | |||||||
|             ); |             ); | ||||||
|             if (picture is null) |             if (picture is null) | ||||||
|                 throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud."); |                 throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud."); | ||||||
|             app.Picture = CloudFileReferenceObject.FromProtoValue(picture); |             app.Picture = SnCloudFileReferenceObject.FromProtoValue(picture); | ||||||
|  |  | ||||||
|             // Create a new reference |             // Create a new reference | ||||||
|             await fileRefs.CreateReferenceAsync( |             await fileRefs.CreateReferenceAsync( | ||||||
| @@ -228,7 +227,7 @@ public class CustomAppService( | |||||||
|             ); |             ); | ||||||
|             if (background is null) |             if (background is null) | ||||||
|                 throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud."); |                 throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud."); | ||||||
|             app.Background = CloudFileReferenceObject.FromProtoValue(background); |             app.Background = SnCloudFileReferenceObject.FromProtoValue(background); | ||||||
|  |  | ||||||
|             // Create a new reference |             // Create a new reference | ||||||
|             await fileRefs.CreateReferenceAsync( |             await fileRefs.CreateReferenceAsync( | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
| using Grpc.Core; | using Grpc.Core; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| @@ -37,7 +38,7 @@ public class CustomAppServiceGrpc(AppDatabase db) : Shared.Proto.CustomAppServic | |||||||
|         if (string.IsNullOrEmpty(request.Secret)) |         if (string.IsNullOrEmpty(request.Secret)) | ||||||
|             throw new RpcException(new Status(StatusCode.InvalidArgument, "secret required")); |             throw new RpcException(new Status(StatusCode.InvalidArgument, "secret required")); | ||||||
|  |  | ||||||
|         IQueryable<CustomAppSecret> q = db.CustomAppSecrets; |         IQueryable<SnCustomAppSecret> q = db.CustomAppSecrets; | ||||||
|         switch (request.SecretIdentifierCase) |         switch (request.SecretIdentifierCase) | ||||||
|         { |         { | ||||||
|             case CheckCustomAppSecretRequest.SecretIdentifierOneofCase.SecretId: |             case CheckCustomAppSecretRequest.SecretIdentifierOneofCase.SecretId: | ||||||
|   | |||||||
| @@ -1,79 +0,0 @@ | |||||||
| 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; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,4 +1,5 @@ | |||||||
| using DysonNetwork.Shared.Auth; | using DysonNetwork.Shared.Auth; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
| using Grpc.Core; | using Grpc.Core; | ||||||
| using Microsoft.AspNetCore.Authorization; | using Microsoft.AspNetCore.Authorization; | ||||||
| @@ -18,7 +19,7 @@ public class DeveloperController( | |||||||
|     : ControllerBase |     : ControllerBase | ||||||
| { | { | ||||||
|     [HttpGet("{name}")] |     [HttpGet("{name}")] | ||||||
|     public async Task<ActionResult<Developer>> GetDeveloper(string name) |     public async Task<ActionResult<SnDeveloper>> GetDeveloper(string name) | ||||||
|     { |     { | ||||||
|         var developer = await ds.GetDeveloperByName(name); |         var developer = await ds.GetDeveloperByName(name); | ||||||
|         if (developer is null) return NotFound(); |         if (developer is null) return NotFound(); | ||||||
| @@ -47,7 +48,7 @@ public class DeveloperController( | |||||||
|  |  | ||||||
|     [HttpGet] |     [HttpGet] | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     public async Task<ActionResult<List<Developer>>> ListJoinedDevelopers() |     public async Task<ActionResult<List<SnDeveloper>>> ListJoinedDevelopers() | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|          |          | ||||||
| @@ -69,16 +70,16 @@ public class DeveloperController( | |||||||
|     [HttpPost("{name}/enroll")] |     [HttpPost("{name}/enroll")] | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     [RequiredPermission("global", "developers.create")] |     [RequiredPermission("global", "developers.create")] | ||||||
|     public async Task<ActionResult<Developer>> EnrollDeveloperProgram(string name) |     public async Task<ActionResult<SnDeveloper>> EnrollDeveloperProgram(string name) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|         var accountId = Guid.Parse(currentUser.Id); |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |  | ||||||
|         PublisherInfo? pub; |         SnPublisher? pub; | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
|             var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Name = name }); |             var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Name = name }); | ||||||
|             pub = PublisherInfo.FromProto(pubResponse.Publisher); |             pub = SnPublisher.FromProto(pubResponse.Publisher); | ||||||
|         } catch (RpcException ex) |         } catch (RpcException ex) | ||||||
|         { |         { | ||||||
|             return NotFound(ex.Status.Detail); |             return NotFound(ex.Status.Detail); | ||||||
| @@ -89,14 +90,14 @@ public class DeveloperController( | |||||||
|         { |         { | ||||||
|             PublisherId = pub.Id.ToString(), |             PublisherId = pub.Id.ToString(), | ||||||
|             AccountId = currentUser.Id, |             AccountId = currentUser.Id, | ||||||
|             Role = PublisherMemberRole.Owner |             Role = Shared.Proto.PublisherMemberRole.Owner | ||||||
|         }); |         }); | ||||||
|         if (!permResponse.Valid) return StatusCode(403, "You must be the owner of the publisher to join the developer program"); |         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); |         var hasDeveloper = await db.Developers.AnyAsync(d => d.PublisherId == pub.Id); | ||||||
|         if (hasDeveloper) return BadRequest("Publisher is already in the developer program"); |         if (hasDeveloper) return BadRequest("Publisher is already in the developer program"); | ||||||
|          |          | ||||||
|         var developer = new Developer |         var developer = new SnDeveloper | ||||||
|         { |         { | ||||||
|             Id = Guid.NewGuid(), |             Id = Guid.NewGuid(), | ||||||
|             PublisherId = pub.Id |             PublisherId = pub.Id | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
| using Grpc.Core; | using Grpc.Core; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| @@ -9,22 +10,22 @@ public class DeveloperService( | |||||||
|     PublisherService.PublisherServiceClient ps, |     PublisherService.PublisherServiceClient ps, | ||||||
|     ILogger<DeveloperService> logger) |     ILogger<DeveloperService> logger) | ||||||
| { | { | ||||||
|     public async Task<Developer> LoadDeveloperPublisher(Developer developer) |     public async Task<SnDeveloper> LoadDeveloperPublisher(SnDeveloper developer) | ||||||
|     { |     { | ||||||
|         var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Id = developer.PublisherId.ToString() }); |         var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Id = developer.PublisherId.ToString() }); | ||||||
|         developer.Publisher = PublisherInfo.FromProto(pubResponse.Publisher); |         developer.Publisher = SnPublisher.FromProto(pubResponse.Publisher); | ||||||
|         return developer; |         return developer; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|     public async Task<IEnumerable<Developer>> LoadDeveloperPublisher(IEnumerable<Developer> developers) |     public async Task<IEnumerable<SnDeveloper>> LoadDeveloperPublisher(IEnumerable<SnDeveloper> developers) | ||||||
|     { |     { | ||||||
|         var enumerable = developers.ToList(); |         var enumerable = developers.ToList(); | ||||||
|         var pubIds = enumerable.Select(d => d.PublisherId).ToList(); |         var pubIds = enumerable.Select(d => d.PublisherId).ToList(); | ||||||
|         var pubRequest = new GetPublisherBatchRequest(); |         var pubRequest = new GetPublisherBatchRequest(); | ||||||
|         pubIds.ForEach(x => pubRequest.Ids.Add(x.ToString())); |         pubIds.ForEach(x => pubRequest.Ids.Add(x.ToString())); | ||||||
|         var pubResponse = await ps.GetPublisherBatchAsync(pubRequest); |         var pubResponse = await ps.GetPublisherBatchAsync(pubRequest); | ||||||
|         var pubs = pubResponse.Publishers.ToDictionary(p => Guid.Parse(p.Id), PublisherInfo.FromProto); |         var pubs = pubResponse.Publishers.ToDictionary(p => Guid.Parse(p.Id), SnPublisher.FromProto); | ||||||
|  |  | ||||||
|         return enumerable.Select(d => |         return enumerable.Select(d => | ||||||
|         { |         { | ||||||
| @@ -33,7 +34,7 @@ public class DeveloperService( | |||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<Developer?> GetDeveloperByName(string name) |     public async Task<SnDeveloper?> GetDeveloperByName(string name) | ||||||
|     { |     { | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
| @@ -50,12 +51,12 @@ public class DeveloperService( | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<Developer?> GetDeveloperById(Guid id) |     public async Task<SnDeveloper?> GetDeveloperById(Guid id) | ||||||
|     { |     { | ||||||
|         return await db.Developers.FirstOrDefaultAsync(d => d.Id == id); |         return await db.Developers.FirstOrDefaultAsync(d => d.Id == id); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<bool> IsMemberWithRole(Guid pubId, Guid accountId, PublisherMemberRole role) |     public async Task<bool> IsMemberWithRole(Guid pubId, Guid accountId, Shared.Proto.PublisherMemberRole role) | ||||||
|     { |     { | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
|   | |||||||
| @@ -1,8 +1,7 @@ | |||||||
| // <auto-generated /> | // <auto-generated /> | ||||||
| using System; | using System; | ||||||
| using DysonNetwork.Develop; | using DysonNetwork.Develop; | ||||||
| using DysonNetwork.Develop.Identity; | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Data; |  | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
| @@ -35,7 +34,7 @@ namespace DysonNetwork.Develop.Migrations | |||||||
|                         .HasColumnType("uuid") |                         .HasColumnType("uuid") | ||||||
|                         .HasColumnName("id"); |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|                     b.Property<CloudFileReferenceObject>("Background") |                     b.Property<SnCloudFileReferenceObject>("Background") | ||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("background"); |                         .HasColumnName("background"); | ||||||
|  |  | ||||||
| @@ -56,7 +55,7 @@ namespace DysonNetwork.Develop.Migrations | |||||||
|                         .HasColumnType("uuid") |                         .HasColumnType("uuid") | ||||||
|                         .HasColumnName("developer_id"); |                         .HasColumnName("developer_id"); | ||||||
|  |  | ||||||
|                     b.Property<CustomAppLinks>("Links") |                     b.Property<SnCustomAppLinks>("Links") | ||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("links"); |                         .HasColumnName("links"); | ||||||
|  |  | ||||||
| @@ -66,11 +65,11 @@ namespace DysonNetwork.Develop.Migrations | |||||||
|                         .HasColumnType("character varying(1024)") |                         .HasColumnType("character varying(1024)") | ||||||
|                         .HasColumnName("name"); |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|                     b.Property<CustomAppOauthConfig>("OauthConfig") |                     b.Property<SnCustomAppOauthConfig>("OauthConfig") | ||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("oauth_config"); |                         .HasColumnName("oauth_config"); | ||||||
|  |  | ||||||
|                     b.Property<CloudFileReferenceObject>("Picture") |                     b.Property<SnCloudFileReferenceObject>("Picture") | ||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("picture"); |                         .HasColumnName("picture"); | ||||||
|  |  | ||||||
| @@ -88,7 +87,7 @@ namespace DysonNetwork.Develop.Migrations | |||||||
|                         .HasColumnType("timestamp with time zone") |                         .HasColumnType("timestamp with time zone") | ||||||
|                         .HasColumnName("updated_at"); |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|                     b.Property<VerificationMark>("Verification") |                     b.Property<SnVerificationMark>("Verification") | ||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("verification"); |                         .HasColumnName("verification"); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,6 +1,4 @@ | |||||||
| using System; | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Develop.Identity; |  | ||||||
| using DysonNetwork.Shared.Data; |  | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
| using NodaTime; | using NodaTime; | ||||||
|  |  | ||||||
| @@ -35,11 +33,11 @@ namespace DysonNetwork.Develop.Migrations | |||||||
|                     name = 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), |                     description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true), | ||||||
|                     status = table.Column<int>(type: "integer", nullable: false), |                     status = table.Column<int>(type: "integer", nullable: false), | ||||||
|                     picture = table.Column<CloudFileReferenceObject>(type: "jsonb", nullable: true), |                     picture = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true), | ||||||
|                     background = table.Column<CloudFileReferenceObject>(type: "jsonb", nullable: true), |                     background = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true), | ||||||
|                     verification = table.Column<VerificationMark>(type: "jsonb", nullable: true), |                     verification = table.Column<SnVerificationMark>(type: "jsonb", nullable: true), | ||||||
|                     oauth_config = table.Column<CustomAppOauthConfig>(type: "jsonb", nullable: true), |                     oauth_config = table.Column<SnCustomAppOauthConfig>(type: "jsonb", nullable: true), | ||||||
|                     links = table.Column<CustomAppLinks>(type: "jsonb", nullable: true), |                     links = table.Column<SnCustomAppLinks>(type: "jsonb", nullable: true), | ||||||
|                     developer_id = table.Column<Guid>(type: "uuid", nullable: false), |                     developer_id = table.Column<Guid>(type: "uuid", nullable: false), | ||||||
|                     created_at = table.Column<Instant>(type: "timestamp with time zone", 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), |                     updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), | ||||||
|   | |||||||
| @@ -1,8 +1,7 @@ | |||||||
| // <auto-generated /> | // <auto-generated /> | ||||||
| using System; | using System; | ||||||
| using DysonNetwork.Develop; | using DysonNetwork.Develop; | ||||||
| using DysonNetwork.Develop.Identity; | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Data; |  | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
| @@ -35,7 +34,7 @@ namespace DysonNetwork.Develop.Migrations | |||||||
|                         .HasColumnType("uuid") |                         .HasColumnType("uuid") | ||||||
|                         .HasColumnName("id"); |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|                     b.Property<CloudFileReferenceObject>("Background") |                     b.Property<SnCloudFileReferenceObject>("Background") | ||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("background"); |                         .HasColumnName("background"); | ||||||
|  |  | ||||||
| @@ -52,7 +51,7 @@ namespace DysonNetwork.Develop.Migrations | |||||||
|                         .HasColumnType("character varying(4096)") |                         .HasColumnType("character varying(4096)") | ||||||
|                         .HasColumnName("description"); |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|                     b.Property<CustomAppLinks>("Links") |                     b.Property<SnCustomAppLinks>("Links") | ||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("links"); |                         .HasColumnName("links"); | ||||||
|  |  | ||||||
| @@ -62,11 +61,11 @@ namespace DysonNetwork.Develop.Migrations | |||||||
|                         .HasColumnType("character varying(1024)") |                         .HasColumnType("character varying(1024)") | ||||||
|                         .HasColumnName("name"); |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|                     b.Property<CustomAppOauthConfig>("OauthConfig") |                     b.Property<SnCustomAppOauthConfig>("OauthConfig") | ||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("oauth_config"); |                         .HasColumnName("oauth_config"); | ||||||
|  |  | ||||||
|                     b.Property<CloudFileReferenceObject>("Picture") |                     b.Property<SnCloudFileReferenceObject>("Picture") | ||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("picture"); |                         .HasColumnName("picture"); | ||||||
|  |  | ||||||
| @@ -88,7 +87,7 @@ namespace DysonNetwork.Develop.Migrations | |||||||
|                         .HasColumnType("timestamp with time zone") |                         .HasColumnType("timestamp with time zone") | ||||||
|                         .HasColumnName("updated_at"); |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|                     b.Property<VerificationMark>("Verification") |                     b.Property<SnVerificationMark>("Verification") | ||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("verification"); |                         .HasColumnName("verification"); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,5 +1,4 @@ | |||||||
| using System; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; |  | ||||||
| using NodaTime; | using NodaTime; | ||||||
|  |  | ||||||
| #nullable disable | #nullable disable | ||||||
|   | |||||||
| @@ -1,8 +1,7 @@ | |||||||
| // <auto-generated /> | // <auto-generated /> | ||||||
| using System; | using System; | ||||||
| using DysonNetwork.Develop; | using DysonNetwork.Develop; | ||||||
| using DysonNetwork.Develop.Identity; | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Data; |  | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
| @@ -77,7 +76,7 @@ namespace DysonNetwork.Develop.Migrations | |||||||
|                         .HasColumnType("uuid") |                         .HasColumnType("uuid") | ||||||
|                         .HasColumnName("id"); |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|                     b.Property<CloudFileReferenceObject>("Background") |                     b.Property<SnCloudFileReferenceObject>("Background") | ||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("background"); |                         .HasColumnName("background"); | ||||||
|  |  | ||||||
| @@ -94,7 +93,7 @@ namespace DysonNetwork.Develop.Migrations | |||||||
|                         .HasColumnType("character varying(4096)") |                         .HasColumnType("character varying(4096)") | ||||||
|                         .HasColumnName("description"); |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|                     b.Property<CustomAppLinks>("Links") |                     b.Property<SnCustomAppLinks>("Links") | ||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("links"); |                         .HasColumnName("links"); | ||||||
|  |  | ||||||
| @@ -104,11 +103,11 @@ namespace DysonNetwork.Develop.Migrations | |||||||
|                         .HasColumnType("character varying(1024)") |                         .HasColumnType("character varying(1024)") | ||||||
|                         .HasColumnName("name"); |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|                     b.Property<CustomAppOauthConfig>("OauthConfig") |                     b.Property<SnCustomAppOauthConfig>("OauthConfig") | ||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("oauth_config"); |                         .HasColumnName("oauth_config"); | ||||||
|  |  | ||||||
|                     b.Property<CloudFileReferenceObject>("Picture") |                     b.Property<SnCloudFileReferenceObject>("Picture") | ||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("picture"); |                         .HasColumnName("picture"); | ||||||
|  |  | ||||||
| @@ -130,7 +129,7 @@ namespace DysonNetwork.Develop.Migrations | |||||||
|                         .HasColumnType("timestamp with time zone") |                         .HasColumnType("timestamp with time zone") | ||||||
|                         .HasColumnName("updated_at"); |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|                     b.Property<VerificationMark>("Verification") |                     b.Property<SnVerificationMark>("Verification") | ||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("verification"); |                         .HasColumnName("verification"); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,5 +1,4 @@ | |||||||
| using System; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; |  | ||||||
| using NodaTime; | using NodaTime; | ||||||
|  |  | ||||||
| #nullable disable | #nullable disable | ||||||
|   | |||||||
| @@ -1,8 +1,7 @@ | |||||||
| // <auto-generated /> | // <auto-generated /> | ||||||
| using System; | using System; | ||||||
| using DysonNetwork.Develop; | using DysonNetwork.Develop; | ||||||
| using DysonNetwork.Develop.Identity; | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Data; |  | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||||
| @@ -74,7 +73,7 @@ namespace DysonNetwork.Develop.Migrations | |||||||
|                         .HasColumnType("uuid") |                         .HasColumnType("uuid") | ||||||
|                         .HasColumnName("id"); |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|                     b.Property<CloudFileReferenceObject>("Background") |                     b.Property<SnCloudFileReferenceObject>("Background") | ||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("background"); |                         .HasColumnName("background"); | ||||||
|  |  | ||||||
| @@ -91,7 +90,7 @@ namespace DysonNetwork.Develop.Migrations | |||||||
|                         .HasColumnType("character varying(4096)") |                         .HasColumnType("character varying(4096)") | ||||||
|                         .HasColumnName("description"); |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|                     b.Property<CustomAppLinks>("Links") |                     b.Property<SnCustomAppLinks>("Links") | ||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("links"); |                         .HasColumnName("links"); | ||||||
|  |  | ||||||
| @@ -101,11 +100,11 @@ namespace DysonNetwork.Develop.Migrations | |||||||
|                         .HasColumnType("character varying(1024)") |                         .HasColumnType("character varying(1024)") | ||||||
|                         .HasColumnName("name"); |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|                     b.Property<CustomAppOauthConfig>("OauthConfig") |                     b.Property<SnCustomAppOauthConfig>("OauthConfig") | ||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("oauth_config"); |                         .HasColumnName("oauth_config"); | ||||||
|  |  | ||||||
|                     b.Property<CloudFileReferenceObject>("Picture") |                     b.Property<SnCloudFileReferenceObject>("Picture") | ||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("picture"); |                         .HasColumnName("picture"); | ||||||
|  |  | ||||||
| @@ -127,7 +126,7 @@ namespace DysonNetwork.Develop.Migrations | |||||||
|                         .HasColumnType("timestamp with time zone") |                         .HasColumnType("timestamp with time zone") | ||||||
|                         .HasColumnName("updated_at"); |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|                     b.Property<VerificationMark>("Verification") |                     b.Property<SnVerificationMark>("Verification") | ||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("verification"); |                         .HasColumnName("verification"); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -13,12 +13,16 @@ builder.ConfigureAppKestrel(builder.Configuration); | |||||||
|  |  | ||||||
| builder.Services.AddAppServices(builder.Configuration); | builder.Services.AddAppServices(builder.Configuration); | ||||||
| builder.Services.AddAppAuthentication(); | builder.Services.AddAppAuthentication(); | ||||||
| builder.Services.AddAppSwagger(); |  | ||||||
| builder.Services.AddDysonAuth(); | builder.Services.AddDysonAuth(); | ||||||
| builder.Services.AddPublisherService(); | builder.Services.AddSphereService(); | ||||||
| builder.Services.AddAccountService(); | builder.Services.AddAccountService(); | ||||||
| builder.Services.AddDriveService(); | builder.Services.AddDriveService(); | ||||||
|  |  | ||||||
|  | builder.AddSwaggerManifest( | ||||||
|  |     "DysonNetwork.Develop", | ||||||
|  |     "The developer portal in the Solar Network." | ||||||
|  | ); | ||||||
|  |  | ||||||
| var app = builder.Build(); | var app = builder.Build(); | ||||||
|  |  | ||||||
| app.MapDefaultEndpoints(); | app.MapDefaultEndpoints(); | ||||||
| @@ -31,4 +35,6 @@ using (var scope = app.Services.CreateScope()) | |||||||
|  |  | ||||||
| app.ConfigureAppMiddleware(builder.Configuration); | app.ConfigureAppMiddleware(builder.Configuration); | ||||||
|  |  | ||||||
|  | app.UseSwaggerManifest("DysonNetwork.Develop"); | ||||||
|  |  | ||||||
| app.Run(); | app.Run(); | ||||||
| @@ -1,21 +1,17 @@ | |||||||
| using DysonNetwork.Develop.Identity; |  | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Develop.Project; | namespace DysonNetwork.Develop.Project; | ||||||
|  |  | ||||||
| public class DevProjectService( | public class DevProjectService(AppDatabase db ) | ||||||
|     AppDatabase db, |  | ||||||
|     FileReferenceService.FileReferenceServiceClient fileRefs, |  | ||||||
|     FileService.FileServiceClient files |  | ||||||
| ) |  | ||||||
| { | { | ||||||
|     public async Task<DevProject> CreateProjectAsync( |     public async Task<SnDevProject> CreateProjectAsync( | ||||||
|         Developer developer, |         SnDeveloper developer, | ||||||
|         DevProjectController.DevProjectRequest request |         DevProjectController.DevProjectRequest request | ||||||
|     ) |     ) | ||||||
|     { |     { | ||||||
|         var project = new DevProject |         var project = new SnDevProject | ||||||
|         { |         { | ||||||
|             Slug = request.Slug!, |             Slug = request.Slug!, | ||||||
|             Name = request.Name!, |             Name = request.Name!, | ||||||
| @@ -29,7 +25,7 @@ public class DevProjectService( | |||||||
|         return project; |         return project; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<DevProject?> GetProjectAsync(Guid id, Guid? developerId = null) |     public async Task<SnDevProject?> GetProjectAsync(Guid id, Guid? developerId = null) | ||||||
|     { |     { | ||||||
|         var query = db.DevProjects.AsQueryable(); |         var query = db.DevProjects.AsQueryable(); | ||||||
|  |  | ||||||
| @@ -41,14 +37,14 @@ public class DevProjectService( | |||||||
|         return await query.FirstOrDefaultAsync(p => p.Id == id); |         return await query.FirstOrDefaultAsync(p => p.Id == id); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<List<DevProject>> GetProjectsByDeveloperAsync(Guid developerId) |     public async Task<List<SnDevProject>> GetProjectsByDeveloperAsync(Guid developerId) | ||||||
|     { |     { | ||||||
|         return await db.DevProjects |         return await db.DevProjects | ||||||
|             .Where(p => p.DeveloperId == developerId) |             .Where(p => p.DeveloperId == developerId) | ||||||
|             .ToListAsync(); |             .ToListAsync(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<DevProject?> UpdateProjectAsync( |     public async Task<SnDevProject?> UpdateProjectAsync( | ||||||
|         Guid id, |         Guid id, | ||||||
|         Guid developerId, |         Guid developerId, | ||||||
|         DevProjectController.DevProjectRequest request |         DevProjectController.DevProjectRequest request | ||||||
|   | |||||||
| @@ -1,9 +1,6 @@ | |||||||
| using System.Net; |  | ||||||
| using DysonNetwork.Develop.Identity; | using DysonNetwork.Develop.Identity; | ||||||
| using DysonNetwork.Shared.Auth; | using DysonNetwork.Shared.Auth; | ||||||
| using DysonNetwork.Shared.Http; | using DysonNetwork.Shared.Http; | ||||||
| using Microsoft.AspNetCore.HttpOverrides; |  | ||||||
| using Prometheus; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Develop.Startup; | namespace DysonNetwork.Develop.Startup; | ||||||
|  |  | ||||||
| @@ -11,12 +8,8 @@ public static class ApplicationConfiguration | |||||||
| { | { | ||||||
|     public static WebApplication ConfigureAppMiddleware(this WebApplication app, IConfiguration configuration) |     public static WebApplication ConfigureAppMiddleware(this WebApplication app, IConfiguration configuration) | ||||||
|     { |     { | ||||||
|         app.MapMetrics(); |  | ||||||
|         app.MapOpenApi(); |         app.MapOpenApi(); | ||||||
|  |  | ||||||
|         app.UseSwagger(); |  | ||||||
|         app.UseSwaggerUI(); |  | ||||||
|          |  | ||||||
|         app.UseRequestLocalization(); |         app.UseRequestLocalization(); | ||||||
|  |  | ||||||
|         app.ConfigureForwardedHeaders(configuration); |         app.ConfigureForwardedHeaders(configuration); | ||||||
|   | |||||||
| @@ -1,5 +1,4 @@ | |||||||
| using System.Globalization; | using System.Globalization; | ||||||
| using Microsoft.OpenApi.Models; |  | ||||||
| using NodaTime; | using NodaTime; | ||||||
| using NodaTime.Serialization.SystemTextJson; | using NodaTime.Serialization.SystemTextJson; | ||||||
| using System.Text.Json; | using System.Text.Json; | ||||||
| @@ -7,7 +6,6 @@ using System.Text.Json.Serialization; | |||||||
| using DysonNetwork.Develop.Identity; | using DysonNetwork.Develop.Identity; | ||||||
| using DysonNetwork.Develop.Project; | using DysonNetwork.Develop.Project; | ||||||
| using DysonNetwork.Shared.Cache; | using DysonNetwork.Shared.Cache; | ||||||
| using StackExchange.Redis; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Develop.Startup; | namespace DysonNetwork.Develop.Startup; | ||||||
|  |  | ||||||
| @@ -57,23 +55,7 @@ public static class ServiceCollectionExtensions | |||||||
|  |  | ||||||
|     public static IServiceCollection AddAppAuthentication(this IServiceCollection services) |     public static IServiceCollection AddAppAuthentication(this IServiceCollection services) | ||||||
|     { |     { | ||||||
|         services.AddCors(); |  | ||||||
|         services.AddAuthorization(); |         services.AddAuthorization(); | ||||||
|         return services; |         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; |  | ||||||
|     } |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -10,17 +10,13 @@ | |||||||
|   }, |   }, | ||||||
|   "AllowedHosts": "*", |   "AllowedHosts": "*", | ||||||
|   "ConnectionStrings": { |   "ConnectionStrings": { | ||||||
|     "App": "Host=localhost;Port=5432;Database=dyson_network_dev;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60" |     "App": "Host=localhost;Port=5432;Database=dyson_develop;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60" | ||||||
|  |   }, | ||||||
|  |   "KnownProxies": ["127.0.0.1", "::1"], | ||||||
|  |   "Swagger": { | ||||||
|  |     "PublicBasePath": "/develop" | ||||||
|   }, |   }, | ||||||
|   "KnownProxies": [ |  | ||||||
|     "127.0.0.1", |  | ||||||
|     "::1" |  | ||||||
|   ], |  | ||||||
|   "Etcd": { |   "Etcd": { | ||||||
|     "Insecure": true |     "Insecure": true | ||||||
|   }, |  | ||||||
|   "Service": { |  | ||||||
|     "Name": "DysonNetwork.Develop", |  | ||||||
|     "Url": "https://localhost:7192" |  | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,8 +1,7 @@ | |||||||
| using System.Linq.Expressions; | using System.Linq.Expressions; | ||||||
| using System.Reflection; | using System.Reflection; | ||||||
| using DysonNetwork.Drive.Billing; | using DysonNetwork.Drive.Billing; | ||||||
| using DysonNetwork.Drive.Storage; | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Data; |  | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.EntityFrameworkCore.Design; | using Microsoft.EntityFrameworkCore.Design; | ||||||
| using Microsoft.EntityFrameworkCore.Query; | using Microsoft.EntityFrameworkCore.Query; | ||||||
| @@ -17,11 +16,11 @@ public class AppDatabase( | |||||||
| ) : DbContext(options) | ) : DbContext(options) | ||||||
| { | { | ||||||
|     public DbSet<FilePool> Pools { get; set; } = null!; |     public DbSet<FilePool> Pools { get; set; } = null!; | ||||||
|     public DbSet<FileBundle> Bundles { get; set; } = null!; |     public DbSet<SnFileBundle> Bundles { get; set; } = null!; | ||||||
|      |      | ||||||
|     public DbSet<QuotaRecord> QuotaRecords { get; set; } = null!; |     public DbSet<QuotaRecord> QuotaRecords { get; set; } = null!; | ||||||
|      |      | ||||||
|     public DbSet<CloudFile> Files { get; set; } = null!; |     public DbSet<SnCloudFile> Files { get; set; } = null!; | ||||||
|     public DbSet<CloudFileReference> FileReferences { get; set; } = null!; |     public DbSet<CloudFileReference> FileReferences { get; set; } = null!; | ||||||
|      |      | ||||||
|     protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) |     protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| using DysonNetwork.Shared.Data; | using DysonNetwork.Shared.Models; | ||||||
| using NodaTime; | using NodaTime; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Drive.Billing; | namespace DysonNetwork.Drive.Billing; | ||||||
|   | |||||||
| @@ -10,26 +10,27 @@ | |||||||
|     <ItemGroup> |     <ItemGroup> | ||||||
|         <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> |         <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> | ||||||
|         <PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" /> |         <PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" /> | ||||||
|         <PackageReference Include="FFMpegCore" Version="5.2.0" /> |         <PackageReference Include="FFMpegCore" Version="5.3.0" /> | ||||||
|         <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" /> |         <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" /> | ||||||
|         <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" /> |         <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" /> | ||||||
|         <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7"> |         <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10"> | ||||||
|           <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> |           <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||||
|           <PrivateAssets>all</PrivateAssets> |           <PrivateAssets>all</PrivateAssets> | ||||||
|         </PackageReference> |         </PackageReference> | ||||||
|  |         <PackageReference Include="MimeKit" Version="4.14.0" /> | ||||||
|         <PackageReference Include="MimeTypes" Version="2.5.2"> |         <PackageReference Include="MimeTypes" Version="2.5.2"> | ||||||
|           <PrivateAssets>all</PrivateAssets> |           <PrivateAssets>all</PrivateAssets> | ||||||
|           <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> |           <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||||
|         </PackageReference> |         </PackageReference> | ||||||
|         <PackageReference Include="Minio" Version="6.0.5" /> |         <PackageReference Include="Minio" Version="6.0.5" /> | ||||||
|         <PackageReference Include="Nanoid" Version="3.1.0" /> |         <PackageReference Include="Nanoid" Version="3.1.0" /> | ||||||
|         <PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115"> |         <PackageReference Include="Nerdbank.GitVersioning" Version="3.8.118"> | ||||||
|           <PrivateAssets>all</PrivateAssets> |           <PrivateAssets>all</PrivateAssets> | ||||||
|           <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> |           <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||||
|         </PackageReference> |         </PackageReference> | ||||||
|         <PackageReference Include="NetVips" Version="3.1.0" /> |         <PackageReference Include="NetVips" Version="3.1.0" /> | ||||||
|         <PackageReference Include="NetVips.Native.linux-x64" Version="8.17.1" /> |         <PackageReference Include="NetVips.Native.linux-x64" Version="8.17.2" /> | ||||||
|         <PackageReference Include="NetVips.Native.osx-arm64" Version="8.17.1" /> |         <PackageReference Include="NetVips.Native.osx-arm64" Version="8.17.2" /> | ||||||
|         <PackageReference Include="NodaTime" Version="3.2.2" /> |         <PackageReference Include="NodaTime" Version="3.2.2" /> | ||||||
|         <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" /> |         <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" /> | ||||||
|         <PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" /> |         <PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" /> | ||||||
| @@ -37,27 +38,21 @@ | |||||||
|         <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.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.13.1" /> | ||||||
|         <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" /> |         <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.13.1" /> | ||||||
|         <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" /> |         <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" /> | ||||||
|         <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" /> |         <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.13.0" /> | ||||||
|         <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" /> |         <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.13.0" /> | ||||||
|         <PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" /> |         <PackageReference Include="Quartz" Version="3.15.0" /> | ||||||
|         <PackageReference Include="prometheus-net.AspNetCore.HealthChecks" Version="8.2.1" /> |         <PackageReference Include="Quartz.AspNetCore" Version="3.15.0" /> | ||||||
|         <PackageReference Include="prometheus-net.DotNetRuntime" Version="4.4.1" /> |         <PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.0" /> | ||||||
|         <PackageReference Include="prometheus-net.EntityFramework" Version="0.9.5" /> |         <PackageReference Include="EFCore.BulkExtensions" Version="9.0.2" /> | ||||||
|         <PackageReference Include="prometheus-net.SystemMetrics" Version="3.1.0" /> |         <PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.2" /> | ||||||
|         <PackageReference Include="Quartz" Version="3.14.0" /> |  | ||||||
|         <PackageReference Include="Quartz.AspNetCore" Version="3.14.0" /> |  | ||||||
|         <PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" /> |  | ||||||
|         <PackageReference Include="EFCore.BulkExtensions" Version="9.0.1" /> |  | ||||||
|         <PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1" /> |  | ||||||
|         <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> |         <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> | ||||||
|         <PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.9" /> |         <PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="3.119.1" /> | ||||||
|         <PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.9" /> |         <PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.1" /> | ||||||
|         <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3" /> |         <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" /> | ||||||
|         <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.3" /> |         <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.6" /> | ||||||
|         <PackageReference Include="tusdotnet" Version="2.10.0" /> |  | ||||||
|     </ItemGroup> |     </ItemGroup> | ||||||
|  |  | ||||||
|     <ItemGroup> |     <ItemGroup> | ||||||
| @@ -67,7 +62,6 @@ | |||||||
|     </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> | ||||||
| </Project> | </Project> | ||||||
|   | |||||||
| @@ -3,7 +3,7 @@ using System; | |||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using DysonNetwork.Drive; | using DysonNetwork.Drive; | ||||||
| using DysonNetwork.Drive.Storage; | using DysonNetwork.Drive.Storage; | ||||||
| using DysonNetwork.Shared.Data; | using DysonNetwork.Shared.Models; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|   | |||||||
| @@ -1,7 +1,4 @@ | |||||||
| using System; | using DysonNetwork.Shared.Models; | ||||||
| using System.Collections.Generic; |  | ||||||
| using DysonNetwork.Drive.Storage; |  | ||||||
| using DysonNetwork.Shared.Data; |  | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
| using NodaTime; | using NodaTime; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ | |||||||
| using System; | using System; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using DysonNetwork.Drive; | using DysonNetwork.Drive; | ||||||
| using DysonNetwork.Shared.Data; | using DysonNetwork.Shared.Models; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|   | |||||||
| @@ -1,5 +1,4 @@ | |||||||
| using System.Collections.Generic; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; |  | ||||||
|  |  | ||||||
| #nullable disable | #nullable disable | ||||||
|  |  | ||||||
|   | |||||||
| @@ -2,8 +2,7 @@ | |||||||
| using System; | using System; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using DysonNetwork.Drive; | using DysonNetwork.Drive; | ||||||
| using DysonNetwork.Drive.Storage; | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Data; |  | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|   | |||||||
| @@ -1,6 +1,4 @@ | |||||||
| using System; | using DysonNetwork.Shared.Models; | ||||||
| using System.Collections.Generic; |  | ||||||
| using DysonNetwork.Drive.Storage; |  | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
| using NodaTime; | using NodaTime; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -2,8 +2,7 @@ | |||||||
| using System; | using System; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using DysonNetwork.Drive; | using DysonNetwork.Drive; | ||||||
| using DysonNetwork.Drive.Storage; | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Data; |  | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|   | |||||||
| @@ -2,8 +2,7 @@ | |||||||
| using System; | using System; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using DysonNetwork.Drive; | using DysonNetwork.Drive; | ||||||
| using DysonNetwork.Drive.Storage; | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Data; |  | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|   | |||||||
| @@ -2,8 +2,7 @@ | |||||||
| using System; | using System; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using DysonNetwork.Drive; | using DysonNetwork.Drive; | ||||||
| using DysonNetwork.Drive.Storage; | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Data; |  | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|   | |||||||
| @@ -1,5 +1,4 @@ | |||||||
| using System; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; |  | ||||||
| using NodaTime; | using NodaTime; | ||||||
|  |  | ||||||
| #nullable disable | #nullable disable | ||||||
|   | |||||||
| @@ -2,8 +2,7 @@ | |||||||
| using System; | using System; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using DysonNetwork.Drive; | using DysonNetwork.Drive; | ||||||
| using DysonNetwork.Drive.Storage; | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Data; |  | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|   | |||||||
| @@ -1,5 +1,4 @@ | |||||||
| using System; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; |  | ||||||
| using NodaTime; | using NodaTime; | ||||||
|  |  | ||||||
| #nullable disable | #nullable disable | ||||||
|   | |||||||
| @@ -2,8 +2,7 @@ | |||||||
| using System; | using System; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using DysonNetwork.Drive; | using DysonNetwork.Drive; | ||||||
| using DysonNetwork.Drive.Storage; | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Data; |  | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|   | |||||||
| @@ -2,8 +2,7 @@ | |||||||
| using System; | using System; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using DysonNetwork.Drive; | using DysonNetwork.Drive; | ||||||
| using DysonNetwork.Drive.Storage; | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Data; |  | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|   | |||||||
| @@ -2,8 +2,7 @@ | |||||||
| using System; | using System; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using DysonNetwork.Drive; | using DysonNetwork.Drive; | ||||||
| using DysonNetwork.Drive.Storage; | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Data; |  | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|   | |||||||
| @@ -2,8 +2,7 @@ | |||||||
| using System; | using System; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using DysonNetwork.Drive; | using DysonNetwork.Drive; | ||||||
| using DysonNetwork.Drive.Storage; | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Data; |  | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||||
|   | |||||||
| @@ -4,7 +4,6 @@ using DysonNetwork.Shared.Auth; | |||||||
| using DysonNetwork.Shared.Http; | using DysonNetwork.Shared.Http; | ||||||
| using DysonNetwork.Shared.Registry; | using DysonNetwork.Shared.Registry; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using tusdotnet.Stores; |  | ||||||
|  |  | ||||||
| var builder = WebApplication.CreateBuilder(args); | var builder = WebApplication.CreateBuilder(args); | ||||||
|  |  | ||||||
| @@ -16,23 +15,19 @@ builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxV | |||||||
| // Add application services | // Add application services | ||||||
|  |  | ||||||
| builder.Services.AddAppServices(builder.Configuration); | builder.Services.AddAppServices(builder.Configuration); | ||||||
| builder.Services.AddAppRateLimiting(); |  | ||||||
| builder.Services.AddAppAuthentication(); | builder.Services.AddAppAuthentication(); | ||||||
| builder.Services.AddAppSwagger(); |  | ||||||
| builder.Services.AddDysonAuth(); | builder.Services.AddDysonAuth(); | ||||||
| builder.Services.AddAccountService(); | builder.Services.AddAccountService(); | ||||||
|  |  | ||||||
| builder.Services.AddAppFileStorage(builder.Configuration); |  | ||||||
|  |  | ||||||
| // Add flush handlers and websocket handlers |  | ||||||
| builder.Services.AddAppFlushHandlers(); | builder.Services.AddAppFlushHandlers(); | ||||||
|  |  | ||||||
| // Add business services |  | ||||||
| builder.Services.AddAppBusinessServices(); | builder.Services.AddAppBusinessServices(); | ||||||
|  |  | ||||||
| // Add scheduled jobs |  | ||||||
| builder.Services.AddAppScheduledJobs(); | builder.Services.AddAppScheduledJobs(); | ||||||
|  |  | ||||||
|  | builder.AddSwaggerManifest( | ||||||
|  |     "DysonNetwork.Drive", | ||||||
|  |     "The file upload and storage service in the Solar Network." | ||||||
|  | ); | ||||||
|  |  | ||||||
| var app = builder.Build(); | var app = builder.Build(); | ||||||
|  |  | ||||||
| app.MapDefaultEndpoints(); | app.MapDefaultEndpoints(); | ||||||
| @@ -44,10 +39,11 @@ using (var scope = app.Services.CreateScope()) | |||||||
|     await db.Database.MigrateAsync(); |     await db.Database.MigrateAsync(); | ||||||
| } | } | ||||||
|  |  | ||||||
| var tusDiskStore = app.Services.GetRequiredService<TusDiskStore>(); | app.ConfigureAppMiddleware(); | ||||||
| app.ConfigureAppMiddleware(tusDiskStore); |  | ||||||
|  |  | ||||||
| // Configure gRPC | // Configure gRPC | ||||||
| app.ConfigureGrpcServices(); | app.ConfigureGrpcServices(); | ||||||
|  |  | ||||||
|  | app.UseSwaggerManifest("DysonNetwork.Drive"); | ||||||
|  |  | ||||||
| app.Run(); | app.Run(); | ||||||
|   | |||||||
| @@ -1,25 +1,14 @@ | |||||||
| using DysonNetwork.Drive.Storage; | using DysonNetwork.Drive.Storage; | ||||||
| using tusdotnet; |  | ||||||
| using tusdotnet.Interfaces; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Drive.Startup; | namespace DysonNetwork.Drive.Startup; | ||||||
|  |  | ||||||
| public static class ApplicationBuilderExtensions | public static class ApplicationBuilderExtensions | ||||||
| { | { | ||||||
|     public static WebApplication ConfigureAppMiddleware(this WebApplication app, ITusStore tusStore) |     public static WebApplication ConfigureAppMiddleware(this WebApplication app) | ||||||
|     { |     { | ||||||
|         // Configure the HTTP request pipeline. |  | ||||||
|         if (app.Environment.IsDevelopment()) |  | ||||||
|         { |  | ||||||
|             app.UseSwagger(); |  | ||||||
|             app.UseSwaggerUI(); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         app.UseAuthorization(); |         app.UseAuthorization(); | ||||||
|         app.MapControllers(); |         app.MapControllers(); | ||||||
|  |  | ||||||
|         app.MapTus("/api/tus", _ => Task.FromResult(TusService.BuildConfiguration(tusStore, app.Configuration))); |  | ||||||
|  |  | ||||||
|         return app; |         return app; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,10 +1,16 @@ | |||||||
| using System.Text.Json; | using System.Text.Json; | ||||||
| using DysonNetwork.Drive.Storage; | using DysonNetwork.Drive.Storage.Model; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
| using DysonNetwork.Shared.Stream; | using DysonNetwork.Shared.Stream; | ||||||
|  | using FFMpegCore; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using NATS.Client.Core; | using NATS.Client.Core; | ||||||
|  | using NATS.Client.JetStream; | ||||||
| using NATS.Client.JetStream.Models; | using NATS.Client.JetStream.Models; | ||||||
| using NATS.Net; | using NATS.Net; | ||||||
|  | using NetVips; | ||||||
|  | using NodaTime; | ||||||
|  | using FileService = DysonNetwork.Drive.Storage.FileService; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Drive.Startup; | namespace DysonNetwork.Drive.Startup; | ||||||
|  |  | ||||||
| @@ -14,20 +20,74 @@ public class BroadcastEventHandler( | |||||||
|     IServiceProvider serviceProvider |     IServiceProvider serviceProvider | ||||||
| ) : BackgroundService | ) : BackgroundService | ||||||
| { | { | ||||||
|  |     private const string TempFileSuffix = "dypart"; | ||||||
|  |  | ||||||
|  |     private static readonly string[] AnimatedImageTypes = | ||||||
|  |         ["image/gif", "image/apng", "image/avif"]; | ||||||
|  |  | ||||||
|  |     private static readonly string[] AnimatedImageExtensions = | ||||||
|  |         [".gif", ".apng", ".avif"]; | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|     protected override async Task ExecuteAsync(CancellationToken stoppingToken) |     protected override async Task ExecuteAsync(CancellationToken stoppingToken) | ||||||
|     { |     { | ||||||
|         var js = nats.CreateJetStreamContext(); |         var js = nats.CreateJetStreamContext(); | ||||||
|  |  | ||||||
|         await js.EnsureStreamCreated("account_events", [AccountDeletedEvent.Type]); |         await js.EnsureStreamCreated("account_events", [AccountDeletedEvent.Type]); | ||||||
|          |          | ||||||
|         var consumer = await js.CreateOrUpdateConsumerAsync("account_events", |         var accountEventConsumer = await js.CreateOrUpdateConsumerAsync("account_events", | ||||||
|             new ConsumerConfig("drive_account_deleted_handler"), cancellationToken: stoppingToken); |             new ConsumerConfig("drive_account_deleted_handler"), cancellationToken: stoppingToken); | ||||||
|          |          | ||||||
|  |         await js.EnsureStreamCreated("file_events", [FileUploadedEvent.Type]); | ||||||
|  |         var fileUploadedConsumer = await js.CreateOrUpdateConsumerAsync("file_events", | ||||||
|  |             new ConsumerConfig("drive_file_uploaded_handler") { MaxDeliver = 3 }, cancellationToken: stoppingToken); | ||||||
|  |  | ||||||
|  |         var accountDeletedTask = HandleAccountDeleted(accountEventConsumer, stoppingToken); | ||||||
|  |         var fileUploadedTask = HandleFileUploaded(fileUploadedConsumer, stoppingToken); | ||||||
|  |  | ||||||
|  |         await Task.WhenAll(accountDeletedTask, fileUploadedTask); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private async Task HandleFileUploaded(INatsJSConsumer consumer, CancellationToken stoppingToken) | ||||||
|  |     { | ||||||
|  |         await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken)) | ||||||
|  |         { | ||||||
|  |             var payload = JsonSerializer.Deserialize<FileUploadedEventPayload>(msg.Data, GrpcTypeHelper.SerializerOptions); | ||||||
|  |             if (payload == null) | ||||||
|  |             { | ||||||
|  |                 await msg.AckAsync(cancellationToken: stoppingToken); | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 await ProcessAndUploadInBackgroundAsync( | ||||||
|  |                     payload.FileId, | ||||||
|  |                     payload.RemoteId, | ||||||
|  |                     payload.StorageId, | ||||||
|  |                     payload.ContentType, | ||||||
|  |                     payload.ProcessingFilePath, | ||||||
|  |                     payload.IsTempFile | ||||||
|  |                 ); | ||||||
|  |  | ||||||
|  |                 await msg.AckAsync(cancellationToken: stoppingToken); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 logger.LogError(ex, "Error processing FileUploadedEvent for file {FileId}", payload.FileId); | ||||||
|  |                 await msg.NakAsync(cancellationToken: stoppingToken, delay: TimeSpan.FromSeconds(60)); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private async Task HandleAccountDeleted(INatsJSConsumer consumer, CancellationToken stoppingToken) | ||||||
|  |     { | ||||||
|         await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken)) |         await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken)) | ||||||
|         { |         { | ||||||
|             try |             try | ||||||
|             { |             { | ||||||
|                 var evt = JsonSerializer.Deserialize<AccountDeletedEvent>(msg.Data); |                 var evt = JsonSerializer.Deserialize<AccountDeletedEvent>(msg.Data, GrpcTypeHelper.SerializerOptions); | ||||||
|                 if (evt == null) |                 if (evt == null) | ||||||
|                 { |                 { | ||||||
|                     await msg.AckAsync(cancellationToken: stoppingToken); |                     await msg.AckAsync(cancellationToken: stoppingToken); | ||||||
| @@ -69,4 +129,169 @@ public class BroadcastEventHandler( | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |      | ||||||
|  |      private async Task ProcessAndUploadInBackgroundAsync( | ||||||
|  |         string fileId, | ||||||
|  |         Guid remoteId, | ||||||
|  |         string storageId, | ||||||
|  |         string contentType, | ||||||
|  |         string processingFilePath, | ||||||
|  |         bool isTempFile | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         using var scope = serviceProvider.CreateScope(); | ||||||
|  |         var fs = scope.ServiceProvider.GetRequiredService<FileService>(); | ||||||
|  |         var scopedDb = scope.ServiceProvider.GetRequiredService<AppDatabase>(); | ||||||
|  |  | ||||||
|  |         var pool = await fs.GetPoolAsync(remoteId); | ||||||
|  |         if (pool is null) return; | ||||||
|  |  | ||||||
|  |         var uploads = new List<(string FilePath, string Suffix, string ContentType, bool SelfDestruct)>(); | ||||||
|  |         var newMimeType = contentType; | ||||||
|  |         var hasCompression = false; | ||||||
|  |         var hasThumbnail = false; | ||||||
|  |  | ||||||
|  |         logger.LogInformation("Processing file {FileId} in background...", fileId); | ||||||
|  |  | ||||||
|  |         var fileToUpdate = await scopedDb.Files.AsNoTracking().FirstAsync(f => f.Id == fileId); | ||||||
|  |  | ||||||
|  |         if (fileToUpdate.IsEncrypted) | ||||||
|  |         { | ||||||
|  |             uploads.Add((processingFilePath, string.Empty, contentType, false)); | ||||||
|  |         } | ||||||
|  |         else if (!pool.PolicyConfig.NoOptimization) | ||||||
|  |         { | ||||||
|  |             var fileExtension = Path.GetExtension(processingFilePath); | ||||||
|  |             switch (contentType.Split('/')[0]) | ||||||
|  |             { | ||||||
|  |                 case "image": | ||||||
|  |                     if (AnimatedImageTypes.Contains(contentType) || AnimatedImageExtensions.Contains(fileExtension)) | ||||||
|  |                     { | ||||||
|  |                         logger.LogInformation("Skip optimize file {FileId} due to it is animated...", fileId); | ||||||
|  |                         uploads.Add((processingFilePath, string.Empty, contentType, false)); | ||||||
|  |                         break; | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     try | ||||||
|  |                     { | ||||||
|  |                         newMimeType = "image/webp"; | ||||||
|  |                         using var vipsImage = Image.NewFromFile(processingFilePath); | ||||||
|  |                         var imageToWrite = vipsImage; | ||||||
|  |  | ||||||
|  |                         if (vipsImage.Interpretation is Enums.Interpretation.Scrgb or Enums.Interpretation.Xyz) | ||||||
|  |                         { | ||||||
|  |                             imageToWrite = vipsImage.Colourspace(Enums.Interpretation.Srgb); | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         var webpPath = Path.Join(Path.GetTempPath(), $"{fileId}.{TempFileSuffix}.webp"); | ||||||
|  |                         imageToWrite.Autorot().WriteToFile(webpPath, | ||||||
|  |                             new VOption { { "lossless", true }, { "strip", true } }); | ||||||
|  |                         uploads.Add((webpPath, string.Empty, newMimeType, true)); | ||||||
|  |  | ||||||
|  |                         if (imageToWrite.Width * imageToWrite.Height >= 1024 * 1024) | ||||||
|  |                         { | ||||||
|  |                             var scale = 1024.0 / Math.Max(imageToWrite.Width, imageToWrite.Height); | ||||||
|  |                             var compressedPath = | ||||||
|  |                                 Path.Join(Path.GetTempPath(), $"{fileId}.{TempFileSuffix}.compressed.webp"); | ||||||
|  |                             using var compressedImage = imageToWrite.Resize(scale); | ||||||
|  |                             compressedImage.Autorot().WriteToFile(compressedPath, | ||||||
|  |                                 new VOption { { "Q", 80 }, { "strip", true } }); | ||||||
|  |                             uploads.Add((compressedPath, ".compressed", newMimeType, true)); | ||||||
|  |                             hasCompression = true; | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         if (!ReferenceEquals(imageToWrite, vipsImage)) | ||||||
|  |                         { | ||||||
|  |                             imageToWrite.Dispose(); | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                     catch (Exception ex) | ||||||
|  |                     { | ||||||
|  |                         logger.LogError(ex, "Failed to optimize image {FileId}, uploading original", fileId); | ||||||
|  |                         uploads.Add((processingFilePath, string.Empty, contentType, false)); | ||||||
|  |                         newMimeType = contentType; | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     break; | ||||||
|  |  | ||||||
|  |                 case "video": | ||||||
|  |                     uploads.Add((processingFilePath, string.Empty, contentType, false)); | ||||||
|  |  | ||||||
|  |                     var thumbnailPath = Path.Join(Path.GetTempPath(), $"{fileId}.{TempFileSuffix}.thumbnail.jpg"); | ||||||
|  |                     try | ||||||
|  |                     { | ||||||
|  |                         await FFMpegArguments | ||||||
|  |                             .FromFileInput(processingFilePath, verifyExists: true) | ||||||
|  |                             .OutputToFile(thumbnailPath, overwrite: true, options => options | ||||||
|  |                                 .Seek(TimeSpan.FromSeconds(0)) | ||||||
|  |                                 .WithFrameOutputCount(1) | ||||||
|  |                                 .WithCustomArgument("-q:v 2") | ||||||
|  |                             ) | ||||||
|  |                             .NotifyOnOutput(line => logger.LogInformation("[FFmpeg] {Line}", line)) | ||||||
|  |                             .NotifyOnError(line => logger.LogWarning("[FFmpeg] {Line}", line)) | ||||||
|  |                             .ProcessAsynchronously(); | ||||||
|  |  | ||||||
|  |                         if (File.Exists(thumbnailPath)) | ||||||
|  |                         { | ||||||
|  |                             uploads.Add((thumbnailPath, ".thumbnail", "image/jpeg", true)); | ||||||
|  |                             hasThumbnail = true; | ||||||
|  |                         } | ||||||
|  |                         else | ||||||
|  |                         { | ||||||
|  |                             logger.LogWarning("FFMpeg did not produce thumbnail for video {FileId}", fileId); | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                     catch (Exception ex) | ||||||
|  |                     { | ||||||
|  |                         logger.LogError(ex, "Failed to generate thumbnail for video {FileId}", fileId); | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     break; | ||||||
|  |  | ||||||
|  |                 default: | ||||||
|  |                     uploads.Add((processingFilePath, string.Empty, contentType, false)); | ||||||
|  |                     break; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         else | ||||||
|  |         { | ||||||
|  |             uploads.Add((processingFilePath, string.Empty, contentType, false)); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         logger.LogInformation("Optimized file {FileId}, now uploading...", fileId); | ||||||
|  |  | ||||||
|  |         if (uploads.Count > 0) | ||||||
|  |         { | ||||||
|  |             var destPool = remoteId; | ||||||
|  |             var uploadTasks = uploads.Select(item => | ||||||
|  |                 fs.UploadFileToRemoteAsync( | ||||||
|  |                     storageId, | ||||||
|  |                     destPool, | ||||||
|  |                     item.FilePath, | ||||||
|  |                     item.Suffix, | ||||||
|  |                     item.ContentType, | ||||||
|  |                     item.SelfDestruct | ||||||
|  |                 ) | ||||||
|  |             ).ToList(); | ||||||
|  |  | ||||||
|  |             await Task.WhenAll(uploadTasks); | ||||||
|  |  | ||||||
|  |             logger.LogInformation("Uploaded file {FileId} done!", fileId); | ||||||
|  |  | ||||||
|  |             var now = SystemClock.Instance.GetCurrentInstant(); | ||||||
|  |             await scopedDb.Files.Where(f => f.Id == fileId).ExecuteUpdateAsync(setter => setter | ||||||
|  |                 .SetProperty(f => f.UploadedAt, now) | ||||||
|  |                 .SetProperty(f => f.PoolId, destPool) | ||||||
|  |                 .SetProperty(f => f.MimeType, newMimeType) | ||||||
|  |                 .SetProperty(f => f.HasCompression, hasCompression) | ||||||
|  |                 .SetProperty(f => f.HasThumbnail, hasThumbnail) | ||||||
|  |             ); | ||||||
|  |  | ||||||
|  |             // Only delete temp file after successful upload and db update | ||||||
|  |             if (isTempFile) | ||||||
|  |                 File.Delete(processingFilePath); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         await fs._PurgeCacheAsync(fileId); | ||||||
|  |     } | ||||||
| } | } | ||||||
| @@ -1,14 +1,8 @@ | |||||||
| using System.Text.Json; | using System.Text.Json; | ||||||
| using System.Text.Json.Serialization; | using System.Text.Json.Serialization; | ||||||
| using System.Threading.RateLimiting; |  | ||||||
| using DysonNetwork.Shared.Cache; | using DysonNetwork.Shared.Cache; | ||||||
| using Microsoft.AspNetCore.RateLimiting; |  | ||||||
| using Microsoft.OpenApi.Models; |  | ||||||
| using NodaTime; | using NodaTime; | ||||||
| using NodaTime.Serialization.SystemTextJson; | using NodaTime.Serialization.SystemTextJson; | ||||||
| using StackExchange.Redis; |  | ||||||
| using DysonNetwork.Shared.Proto; |  | ||||||
| using tusdotnet.Stores; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Drive.Startup; | namespace DysonNetwork.Drive.Startup; | ||||||
|  |  | ||||||
| @@ -46,24 +40,9 @@ public static class ServiceCollectionExtensions | |||||||
|         return services; |         return services; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public static IServiceCollection AddAppRateLimiting(this IServiceCollection services) |  | ||||||
|     { |  | ||||||
|         services.AddRateLimiter(o => o.AddFixedWindowLimiter(policyName: "fixed", opts => |  | ||||||
|         { |  | ||||||
|             opts.Window = TimeSpan.FromMinutes(1); |  | ||||||
|             opts.PermitLimit = 120; |  | ||||||
|             opts.QueueLimit = 2; |  | ||||||
|             opts.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; |  | ||||||
|         })); |  | ||||||
|  |  | ||||||
|         return services; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public static IServiceCollection AddAppAuthentication(this IServiceCollection services) |     public static IServiceCollection AddAppAuthentication(this IServiceCollection services) | ||||||
|     { |     { | ||||||
|         services.AddCors(); |  | ||||||
|         services.AddAuthorization(); |         services.AddAuthorization(); | ||||||
|  |  | ||||||
|         return services; |         return services; | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -74,63 +53,6 @@ public static class ServiceCollectionExtensions | |||||||
|         return services; |         return services; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public static IServiceCollection AddAppSwagger(this IServiceCollection services) |  | ||||||
|     { |  | ||||||
|         services.AddEndpointsApiExplorer(); |  | ||||||
|         services.AddSwaggerGen(options => |  | ||||||
|         { |  | ||||||
|             options.SwaggerDoc("v1", new OpenApiInfo |  | ||||||
|             { |  | ||||||
|                 Version = "v1", |  | ||||||
|                 Title = "Dyson Drive", |  | ||||||
|                 Description = |  | ||||||
|                     "The file service of the Dyson Network. Mainly handling file storage and sharing. Also provide image processing and media analysis. Powered the Solar Network Drive as well.", |  | ||||||
|                 TermsOfService = new Uri("https://solsynth.dev/terms"), // Update with actual terms |  | ||||||
|                 License = new OpenApiLicense |  | ||||||
|                 { |  | ||||||
|                     Name = "APGLv3", // Update with actual license |  | ||||||
|                     Url = new Uri("https://www.gnu.org/licenses/agpl-3.0.html") |  | ||||||
|                 } |  | ||||||
|             }); |  | ||||||
|             options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme |  | ||||||
|             { |  | ||||||
|                 In = ParameterLocation.Header, |  | ||||||
|                 Description = "Please enter a valid token", |  | ||||||
|                 Name = "Authorization", |  | ||||||
|                 Type = SecuritySchemeType.Http, |  | ||||||
|                 BearerFormat = "JWT", |  | ||||||
|                 Scheme = "Bearer" |  | ||||||
|             }); |  | ||||||
|             options.AddSecurityRequirement(new OpenApiSecurityRequirement |  | ||||||
|             { |  | ||||||
|                 { |  | ||||||
|                     new OpenApiSecurityScheme |  | ||||||
|                     { |  | ||||||
|                         Reference = new OpenApiReference |  | ||||||
|                         { |  | ||||||
|                             Type = ReferenceType.SecurityScheme, |  | ||||||
|                             Id = "Bearer" |  | ||||||
|                         } |  | ||||||
|                     }, |  | ||||||
|                     [] |  | ||||||
|                 } |  | ||||||
|             }); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         return services; |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     public static IServiceCollection AddAppFileStorage(this IServiceCollection services, IConfiguration configuration) |  | ||||||
|     { |  | ||||||
|         var tusStorePath = configuration.GetSection("Tus").GetValue<string>("StorePath")!; |  | ||||||
|         Directory.CreateDirectory(tusStorePath); |  | ||||||
|         var tusDiskStore = new TusDiskStore(tusStorePath); |  | ||||||
|  |  | ||||||
|         services.AddSingleton(tusDiskStore); |  | ||||||
|  |  | ||||||
|         return services; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public static IServiceCollection AddAppBusinessServices(this IServiceCollection services) |     public static IServiceCollection AddAppBusinessServices(this IServiceCollection services) | ||||||
|     { |     { | ||||||
|         services.AddScoped<Storage.FileService>(); |         services.AddScoped<Storage.FileService>(); | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| using System.ComponentModel.DataAnnotations; | using System.ComponentModel.DataAnnotations; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
| using Microsoft.AspNetCore.Authorization; | using Microsoft.AspNetCore.Authorization; | ||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| @@ -22,7 +23,7 @@ public class BundleController(AppDatabase db) : ControllerBase | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     [HttpGet("{id:guid}")] |     [HttpGet("{id:guid}")] | ||||||
|     public async Task<ActionResult<FileBundle>> GetBundle([FromRoute] Guid id, [FromQuery] string? passcode) |     public async Task<ActionResult<SnFileBundle>> GetBundle([FromRoute] Guid id, [FromQuery] string? passcode) | ||||||
|     { |     { | ||||||
|         var bundle = await db.Bundles |         var bundle = await db.Bundles | ||||||
|             .Where(e => e.Id == id) |             .Where(e => e.Id == id) | ||||||
| @@ -36,7 +37,7 @@ public class BundleController(AppDatabase db) : ControllerBase | |||||||
|  |  | ||||||
|     [HttpGet("me")] |     [HttpGet("me")] | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     public async Task<ActionResult<List<FileBundle>>> ListBundles( |     public async Task<ActionResult<List<SnFileBundle>>> ListBundles( | ||||||
|         [FromQuery] string? term, |         [FromQuery] string? term, | ||||||
|         [FromQuery] int offset = 0, |         [FromQuery] int offset = 0, | ||||||
|         [FromQuery] int take = 20 |         [FromQuery] int take = 20 | ||||||
| @@ -65,7 +66,7 @@ public class BundleController(AppDatabase db) : ControllerBase | |||||||
|  |  | ||||||
|     [HttpPost] |     [HttpPost] | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     public async Task<ActionResult<FileBundle>> CreateBundle([FromBody] BundleRequest request) |     public async Task<ActionResult<SnFileBundle>> CreateBundle([FromBody] BundleRequest request) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|         var accountId = Guid.Parse(currentUser.Id); |         var accountId = Guid.Parse(currentUser.Id); | ||||||
| @@ -77,7 +78,7 @@ public class BundleController(AppDatabase db) : ControllerBase | |||||||
|         if (string.IsNullOrEmpty(request.Name)) |         if (string.IsNullOrEmpty(request.Name)) | ||||||
|             request.Name = "Unnamed Bundle"; |             request.Name = "Unnamed Bundle"; | ||||||
|  |  | ||||||
|         var bundle = new FileBundle |         var bundle = new SnFileBundle | ||||||
|         { |         { | ||||||
|             Slug = request.Slug, |             Slug = request.Slug, | ||||||
|             Name = request.Name, |             Name = request.Name, | ||||||
| @@ -95,7 +96,7 @@ public class BundleController(AppDatabase db) : ControllerBase | |||||||
|  |  | ||||||
|     [HttpPut("{id:guid}")] |     [HttpPut("{id:guid}")] | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     public async Task<ActionResult<FileBundle>> UpdateBundle([FromRoute] Guid id, [FromBody] BundleRequest request) |     public async Task<ActionResult<SnFileBundle>> UpdateBundle([FromRoute] Guid id, [FromBody] BundleRequest request) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|         var accountId = Guid.Parse(currentUser.Id); |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| using DysonNetwork.Drive.Billing; | using DysonNetwork.Drive.Billing; | ||||||
| using DysonNetwork.Shared.Auth; | using DysonNetwork.Shared.Auth; | ||||||
| using DysonNetwork.Shared.Data; | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
| using Microsoft.AspNetCore.Authorization; | using Microsoft.AspNetCore.Authorization; | ||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| @@ -46,13 +46,35 @@ public class FileController( | |||||||
|  |  | ||||||
|         if (!string.IsNullOrWhiteSpace(file.StorageUrl)) return Redirect(file.StorageUrl); |         if (!string.IsNullOrWhiteSpace(file.StorageUrl)) return Redirect(file.StorageUrl); | ||||||
|  |  | ||||||
|         if (!file.PoolId.HasValue) |         if (file.UploadedAt is null) | ||||||
|         { |         { | ||||||
|             var tusStorePath = configuration.GetValue<string>("Tus:StorePath")!; |             // File is not yet uploaded to remote storage. Try to serve from local temp storage. | ||||||
|             var filePath = Path.Combine(env.ContentRootPath, tusStorePath, file.Id); |             var tempFilePath = Path.Combine(Path.GetTempPath(), file.Id); | ||||||
|             if (!System.IO.File.Exists(filePath)) return new NotFoundResult(); |             if (System.IO.File.Exists(tempFilePath)) | ||||||
|             return PhysicalFile(filePath, file.MimeType ?? "application/octet-stream", file.Name); |             { | ||||||
|  |                 if (file.IsEncrypted) | ||||||
|  |                 { | ||||||
|  |                     return StatusCode(StatusCodes.Status403Forbidden, "Encrypted files cannot be accessed before they are processed and stored."); | ||||||
|                 } |                 } | ||||||
|  |                 return PhysicalFile(tempFilePath, file.MimeType ?? "application/octet-stream", file.Name, enableRangeProcessing: true); | ||||||
|  |             } | ||||||
|  |          | ||||||
|  |             // Fallback for tus uploads that are not processed yet. | ||||||
|  |             var tusStorePath = configuration.GetValue<string>("Tus:StorePath"); | ||||||
|  |             if (!string.IsNullOrEmpty(tusStorePath)) | ||||||
|  |             { | ||||||
|  |                 var tusFilePath = Path.Combine(env.ContentRootPath, tusStorePath, file.Id); | ||||||
|  |                 if (System.IO.File.Exists(tusFilePath)) | ||||||
|  |                 { | ||||||
|  |                     return PhysicalFile(tusFilePath, file.MimeType ?? "application/octet-stream", file.Name, enableRangeProcessing: true); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return StatusCode(StatusCodes.Status400BadRequest, "File is being processed. Please try again later."); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (!file.PoolId.HasValue) | ||||||
|  |             return StatusCode(StatusCodes.Status500InternalServerError, "File is in an inconsistent state: uploaded but no pool ID."); | ||||||
|  |  | ||||||
|         var pool = await fs.GetPoolAsync(file.PoolId.Value); |         var pool = await fs.GetPoolAsync(file.PoolId.Value); | ||||||
|         if (pool is null) |         if (pool is null) | ||||||
| @@ -141,7 +163,7 @@ public class FileController( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     [HttpGet("{id}/info")] |     [HttpGet("{id}/info")] | ||||||
|     public async Task<ActionResult<CloudFile>> GetFileInfo(string id) |     public async Task<ActionResult<SnCloudFile>> GetFileInfo(string id) | ||||||
|     { |     { | ||||||
|         var file = await fs.GetFileAsync(id); |         var file = await fs.GetFileAsync(id); | ||||||
|         if (file is null) return NotFound("File not found."); |         if (file is null) return NotFound("File not found."); | ||||||
| @@ -151,7 +173,7 @@ public class FileController( | |||||||
|  |  | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     [HttpPatch("{id}/name")] |     [HttpPatch("{id}/name")] | ||||||
|     public async Task<ActionResult<CloudFile>> UpdateFileName(string id, [FromBody] string name) |     public async Task<ActionResult<SnCloudFile>> UpdateFileName(string id, [FromBody] string name) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|         var accountId = Guid.Parse(currentUser.Id); |         var accountId = Guid.Parse(currentUser.Id); | ||||||
| @@ -165,12 +187,12 @@ public class FileController( | |||||||
|  |  | ||||||
|     public class MarkFileRequest |     public class MarkFileRequest | ||||||
|     { |     { | ||||||
|         public List<ContentSensitiveMark>? SensitiveMarks { get; set; } |         public List<Shared.Models.ContentSensitiveMark>? SensitiveMarks { get; set; } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     [HttpPut("{id}/marks")] |     [HttpPut("{id}/marks")] | ||||||
|     public async Task<ActionResult<CloudFile>> MarkFile(string id, [FromBody] MarkFileRequest request) |     public async Task<ActionResult<SnCloudFile>> MarkFile(string id, [FromBody] MarkFileRequest request) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|         var accountId = Guid.Parse(currentUser.Id); |         var accountId = Guid.Parse(currentUser.Id); | ||||||
| @@ -184,7 +206,7 @@ public class FileController( | |||||||
|  |  | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     [HttpPut("{id}/meta")] |     [HttpPut("{id}/meta")] | ||||||
|     public async Task<ActionResult<CloudFile>> UpdateFileMeta(string id, [FromBody] Dictionary<string, object?> meta) |     public async Task<ActionResult<SnCloudFile>> UpdateFileMeta(string id, [FromBody] Dictionary<string, object?> meta) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|         var accountId = Guid.Parse(currentUser.Id); |         var accountId = Guid.Parse(currentUser.Id); | ||||||
| @@ -198,7 +220,7 @@ public class FileController( | |||||||
|  |  | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     [HttpGet("me")] |     [HttpGet("me")] | ||||||
|     public async Task<ActionResult<List<CloudFile>>> GetMyFiles( |     public async Task<ActionResult<List<SnCloudFile>>> GetMyFiles( | ||||||
|         [FromQuery] Guid? pool, |         [FromQuery] Guid? pool, | ||||||
|         [FromQuery] bool recycled = false, |         [FromQuery] bool recycled = false, | ||||||
|         [FromQuery] int offset = 0, |         [FromQuery] int offset = 0, | ||||||
| @@ -276,14 +298,14 @@ public class FileController( | |||||||
|         public string? Description { get; set; } |         public string? Description { get; set; } | ||||||
|         public Dictionary<string, object?>? UserMeta { get; set; } |         public Dictionary<string, object?>? UserMeta { get; set; } | ||||||
|         public Dictionary<string, object?>? FileMeta { get; set; } |         public Dictionary<string, object?>? FileMeta { get; set; } | ||||||
|         public List<ContentSensitiveMark>? SensitiveMarks { get; set; } |         public List<Shared.Models.ContentSensitiveMark>? SensitiveMarks { get; set; } | ||||||
|         public Guid PoolId { get; set; } |         public Guid PoolId { get; set; } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     [HttpPost("fast")] |     [HttpPost("fast")] | ||||||
|     [RequiredPermission("global", "files.create")] |     [RequiredPermission("global", "files.create")] | ||||||
|     public async Task<ActionResult<CloudFile>> CreateFastFile([FromBody] CreateFastFileRequest request) |     public async Task<ActionResult<SnCloudFile>> CreateFastFile([FromBody] CreateFastFileRequest request) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|         var accountId = Guid.Parse(currentUser.Id); |         var accountId = Guid.Parse(currentUser.Id); | ||||||
| @@ -344,7 +366,7 @@ public class FileController( | |||||||
|         await using var transaction = await db.Database.BeginTransactionAsync(); |         await using var transaction = await db.Database.BeginTransactionAsync(); | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
|             var file = new CloudFile |             var file = new SnCloudFile | ||||||
|             { |             { | ||||||
|                 Name = request.Name, |                 Name = request.Name, | ||||||
|                 Size = request.Size, |                 Size = request.Size, | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
| using Microsoft.AspNetCore.Authorization; | using Microsoft.AspNetCore.Authorization; | ||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| @@ -19,6 +20,7 @@ public class FilePoolController(AppDatabase db, FileService fs) : ControllerBase | |||||||
|         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) |             .Where(p => !p.IsHidden || p.AccountId == accountId) | ||||||
|  |             .OrderBy(p => p.CreatedAt) | ||||||
|             .ToListAsync(); |             .ToListAsync(); | ||||||
|         pools = pools.Select(p => |         pools = pools.Select(p => | ||||||
|         { |         { | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| using DysonNetwork.Shared.Cache; | using DysonNetwork.Shared.Cache; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
| using EFCore.BulkExtensions; | using EFCore.BulkExtensions; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using NodaTime; | using NodaTime; | ||||||
| @@ -347,7 +348,7 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach | |||||||
|     /// <param name="resourceId">The ID of the resource</param> |     /// <param name="resourceId">The ID of the resource</param> | ||||||
|     /// <param name="usage">Optional filter by usage context</param> |     /// <param name="usage">Optional filter by usage context</param> | ||||||
|     /// <returns>A list of files referenced by the resource</returns> |     /// <returns>A list of files referenced by the resource</returns> | ||||||
|     public async Task<List<CloudFile>> GetResourceFilesAsync(string resourceId, string? usage = null) |     public async Task<List<SnCloudFile>> GetResourceFilesAsync(string resourceId, string? usage = null) | ||||||
|     { |     { | ||||||
|         var query = db.FileReferences.Where(r => r.ResourceId == resourceId); |         var query = db.FileReferences.Where(r => r.ResourceId == resourceId); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,11 +3,11 @@ using Grpc.Core; | |||||||
| using NodaTime; | using NodaTime; | ||||||
| using Duration = NodaTime.Duration; | using Duration = NodaTime.Duration; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Drive.Storage | namespace DysonNetwork.Drive.Storage; | ||||||
| { |  | ||||||
|     public class FileReferenceServiceGrpc(FileReferenceService fileReferenceService) | public class FileReferenceServiceGrpc(FileReferenceService fileReferenceService) | ||||||
|     : Shared.Proto.FileReferenceService.FileReferenceServiceBase |     : Shared.Proto.FileReferenceService.FileReferenceServiceBase | ||||||
|     { | { | ||||||
|     public override async Task<Shared.Proto.CloudFileReference> CreateReference(CreateReferenceRequest request, |     public override async Task<Shared.Proto.CloudFileReference> CreateReference(CreateReferenceRequest request, | ||||||
|         ServerCallContext context) |         ServerCallContext context) | ||||||
|     { |     { | ||||||
| @@ -171,5 +171,4 @@ namespace DysonNetwork.Drive.Storage | |||||||
|         var hasReferences = await fileReferenceService.HasFileReferencesAsync(request.FileId); |         var hasReferences = await fileReferenceService.HasFileReferencesAsync(request.FileId); | ||||||
|         return new HasFileReferencesResponse { HasReferences = hasReferences }; |         return new HasFileReferencesResponse { HasReferences = hasReferences }; | ||||||
|     } |     } | ||||||
|     } |  | ||||||
| } | } | ||||||
| @@ -1,46 +1,38 @@ | |||||||
| using System.Drawing; |  | ||||||
| using System.Globalization; | using System.Globalization; | ||||||
| using FFMpegCore; | using FFMpegCore; | ||||||
| using System.Security.Cryptography; | using System.Security.Cryptography; | ||||||
|  | using DysonNetwork.Drive.Storage.Model; | ||||||
| using DysonNetwork.Shared.Cache; | using DysonNetwork.Shared.Cache; | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
| using Google.Protobuf.WellKnownTypes; | using Google.Protobuf.WellKnownTypes; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Minio; | using Minio; | ||||||
| using Minio.DataModel.Args; | using Minio.DataModel.Args; | ||||||
|  | using NATS.Client.Core; | ||||||
| using NetVips; | using NetVips; | ||||||
| using NodaTime; | using NodaTime; | ||||||
| using tusdotnet.Stores; |  | ||||||
| using System.Linq.Expressions; | using System.Linq.Expressions; | ||||||
| using DysonNetwork.Shared.Data; |  | ||||||
| using Microsoft.EntityFrameworkCore.Query; | using Microsoft.EntityFrameworkCore.Query; | ||||||
|  | using NATS.Net; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Drive.Storage; | namespace DysonNetwork.Drive.Storage; | ||||||
|  |  | ||||||
| public class FileService( | public class FileService( | ||||||
|     AppDatabase db, |     AppDatabase db, | ||||||
|     IConfiguration configuration, |  | ||||||
|     ILogger<FileService> logger, |     ILogger<FileService> logger, | ||||||
|     IServiceScopeFactory scopeFactory, |     ICacheService cache, | ||||||
|     ICacheService cache |     INatsConnection nats | ||||||
| ) | ) | ||||||
| { | { | ||||||
|     private const string CacheKeyPrefix = "file:"; |     private const string CacheKeyPrefix = "file:"; | ||||||
|     private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15); |     private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15); | ||||||
|  |  | ||||||
|     /// <summary> |     public async Task<SnCloudFile?> GetFileAsync(string fileId) | ||||||
|     /// The api for getting file meta with cache, |  | ||||||
|     /// the best use case is for accessing the file data. |  | ||||||
|     /// |  | ||||||
|     /// <b>This function won't load uploader's information, only keep minimal file meta</b> |  | ||||||
|     /// </summary> |  | ||||||
|     /// <param name="fileId">The id of the cloud file requested</param> |  | ||||||
|     /// <returns>The minimal file meta</returns> |  | ||||||
|     public async Task<CloudFile?> GetFileAsync(string fileId) |  | ||||||
|     { |     { | ||||||
|         var cacheKey = $"{CacheKeyPrefix}{fileId}"; |         var cacheKey = $"{CacheKeyPrefix}{fileId}"; | ||||||
|  |  | ||||||
|         var cachedFile = await cache.GetAsync<CloudFile>(cacheKey); |         var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey); | ||||||
|         if (cachedFile is not null) |         if (cachedFile is not null) | ||||||
|             return cachedFile; |             return cachedFile; | ||||||
|  |  | ||||||
| @@ -56,16 +48,15 @@ public class FileService( | |||||||
|         return file; |         return file; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<List<CloudFile>> GetFilesAsync(List<string> fileIds) |     public async Task<List<SnCloudFile>> GetFilesAsync(List<string> fileIds) | ||||||
|     { |     { | ||||||
|         var cachedFiles = new Dictionary<string, CloudFile>(); |         var cachedFiles = new Dictionary<string, SnCloudFile>(); | ||||||
|         var uncachedIds = new List<string>(); |         var uncachedIds = new List<string>(); | ||||||
|  |  | ||||||
|         // Check cache first |  | ||||||
|         foreach (var fileId in fileIds) |         foreach (var fileId in fileIds) | ||||||
|         { |         { | ||||||
|             var cacheKey = $"{CacheKeyPrefix}{fileId}"; |             var cacheKey = $"{CacheKeyPrefix}{fileId}"; | ||||||
|             var cachedFile = await cache.GetAsync<CloudFile>(cacheKey); |             var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey); | ||||||
|  |  | ||||||
|             if (cachedFile != null) |             if (cachedFile != null) | ||||||
|                 cachedFiles[fileId] = cachedFile; |                 cachedFiles[fileId] = cachedFile; | ||||||
| @@ -73,7 +64,6 @@ public class FileService( | |||||||
|                 uncachedIds.Add(fileId); |                 uncachedIds.Add(fileId); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Load uncached files from database |  | ||||||
|         if (uncachedIds.Count > 0) |         if (uncachedIds.Count > 0) | ||||||
|         { |         { | ||||||
|             var dbFiles = await db.Files |             var dbFiles = await db.Files | ||||||
| @@ -81,7 +71,6 @@ public class FileService( | |||||||
|                 .Include(f => f.Pool) |                 .Include(f => f.Pool) | ||||||
|                 .ToListAsync(); |                 .ToListAsync(); | ||||||
|  |  | ||||||
|             // Add to cache |  | ||||||
|             foreach (var file in dbFiles) |             foreach (var file in dbFiles) | ||||||
|             { |             { | ||||||
|                 var cacheKey = $"{CacheKeyPrefix}{file.Id}"; |                 var cacheKey = $"{CacheKeyPrefix}{file.Id}"; | ||||||
| @@ -90,28 +79,19 @@ public class FileService( | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Preserve original order |  | ||||||
|         return fileIds |         return fileIds | ||||||
|             .Select(f => cachedFiles.GetValueOrDefault(f)) |             .Select(f => cachedFiles.GetValueOrDefault(f)) | ||||||
|             .Where(f => f != null) |             .Where(f => f != null) | ||||||
|             .Cast<CloudFile>() |             .Cast<SnCloudFile>() | ||||||
|             .ToList(); |             .ToList(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private const string TempFilePrefix = "dyn-cloudfile"; |     public async Task<SnCloudFile> ProcessNewFileAsync( | ||||||
|  |  | ||||||
|     private static readonly string[] AnimatedImageTypes = |  | ||||||
|         ["image/gif", "image/apng", "image/avif"]; |  | ||||||
|  |  | ||||||
|     private static readonly string[] AnimatedImageExtensions = |  | ||||||
|         [".gif", ".apng", ".avif"]; |  | ||||||
|  |  | ||||||
|     public async Task<CloudFile> ProcessNewFileAsync( |  | ||||||
|         Account account, |         Account account, | ||||||
|         string fileId, |         string fileId, | ||||||
|         string filePool, |         string filePool, | ||||||
|         string? fileBundleId, |         string? fileBundleId, | ||||||
|         Stream stream, |         string filePath, | ||||||
|         string fileName, |         string fileName, | ||||||
|         string? contentType, |         string? contentType, | ||||||
|         string? encryptPassword, |         string? encryptPassword, | ||||||
| @@ -143,57 +123,74 @@ public class FileService( | |||||||
|         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 managedTempPath = Path.Combine(Path.GetTempPath(), fileId); | ||||||
|         var fileSize = stream.Length; |         File.Copy(filePath, managedTempPath, true); | ||||||
|         contentType ??= !fileName.Contains('.') ? "application/octet-stream" : MimeTypes.GetMimeType(fileName); |  | ||||||
|  |         var fileInfo = new FileInfo(managedTempPath); | ||||||
|  |         var fileSize = fileInfo.Length; | ||||||
|  |         var finalContentType = contentType ?? | ||||||
|  |                                (!fileName.Contains('.') ? "application/octet-stream" : MimeTypes.GetMimeType(fileName)); | ||||||
|  |  | ||||||
|  |         var file = new SnCloudFile | ||||||
|  |         { | ||||||
|  |             Id = fileId, | ||||||
|  |             Name = fileName, | ||||||
|  |             MimeType = finalContentType, | ||||||
|  |             Size = fileSize, | ||||||
|  |             ExpiredAt = expiredAt, | ||||||
|  |             BundleId = bundle?.Id, | ||||||
|  |             AccountId = Guid.Parse(account.Id), | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         if (!pool.PolicyConfig.NoMetadata) | ||||||
|  |         { | ||||||
|  |             await ExtractMetadataAsync(file, managedTempPath); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         string processingPath = managedTempPath; | ||||||
|  |         bool isTempFile = true; | ||||||
|  |  | ||||||
|         if (!string.IsNullOrWhiteSpace(encryptPassword)) |         if (!string.IsNullOrWhiteSpace(encryptPassword)) | ||||||
|         { |         { | ||||||
|             if (!pool.PolicyConfig.AllowEncryption) |             if (!pool.PolicyConfig.AllowEncryption) | ||||||
|                 throw new InvalidOperationException("Encryption is not allowed in this pool"); |                 throw new InvalidOperationException("Encryption is not allowed in this pool"); | ||||||
|  |  | ||||||
|             var encryptedPath = Path.Combine(Path.GetTempPath(), $"{fileId}.encrypted"); |             var encryptedPath = Path.Combine(Path.GetTempPath(), $"{fileId}.encrypted"); | ||||||
|             FileEncryptor.EncryptFile(ogFilePath, encryptedPath, encryptPassword); |             FileEncryptor.EncryptFile(managedTempPath, encryptedPath, encryptPassword); | ||||||
|             File.Delete(ogFilePath); // Delete original unencrypted |  | ||||||
|             File.Move(encryptedPath, ogFilePath); // Replace the original one with encrypted |             File.Delete(managedTempPath); | ||||||
|             contentType = "application/octet-stream"; |  | ||||||
|  |             processingPath = encryptedPath; | ||||||
|  |  | ||||||
|  |             file.IsEncrypted = true; | ||||||
|  |             file.MimeType = "application/octet-stream"; | ||||||
|  |             file.Size = new FileInfo(processingPath).Length; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         var hash = await HashFileAsync(ogFilePath); |         file.Hash = await HashFileAsync(processingPath); | ||||||
|  |  | ||||||
|         var file = new CloudFile |  | ||||||
|         { |  | ||||||
|             Id = fileId, |  | ||||||
|             Name = fileName, |  | ||||||
|             MimeType = contentType, |  | ||||||
|             Size = fileSize, |  | ||||||
|             Hash = hash, |  | ||||||
|             ExpiredAt = expiredAt, |  | ||||||
|             BundleId = bundle?.Id, |  | ||||||
|             AccountId = Guid.Parse(account.Id), |  | ||||||
|             IsEncrypted = !string.IsNullOrWhiteSpace(encryptPassword) && pool.PolicyConfig.AllowEncryption |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         // Extract metadata on the current thread for a faster initial response |  | ||||||
|         if (!pool.PolicyConfig.NoMetadata) |  | ||||||
|             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 |         var js = nats.CreateJetStreamContext(); | ||||||
|         _ = Task.Run(() => |         await js.PublishAsync( | ||||||
|             ProcessAndUploadInBackgroundAsync(file.Id, filePool, file.StorageId, contentType, ogFilePath, stream)); |             FileUploadedEvent.Type, | ||||||
|  |             GrpcTypeHelper.ConvertObjectToByteString(new FileUploadedEventPayload( | ||||||
|  |                 file.Id, | ||||||
|  |                 pool.Id, | ||||||
|  |                 file.StorageId, | ||||||
|  |                 file.MimeType, | ||||||
|  |                 processingPath, | ||||||
|  |                 isTempFile) | ||||||
|  |             ).ToByteArray() | ||||||
|  |         ); | ||||||
|  |  | ||||||
|         return file; |         return file; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// <summary> |     private async Task ExtractMetadataAsync(SnCloudFile file, string filePath) | ||||||
|     /// Extracts metadata from the file based on its content type. |  | ||||||
|     /// This runs synchronously to ensure the initial database record has basic metadata. |  | ||||||
|     /// </summary> |  | ||||||
|     private async Task ExtractMetadataAsync(CloudFile file, string filePath, Stream stream) |  | ||||||
|     { |     { | ||||||
|         switch (file.MimeType?.Split('/')[0]) |         switch (file.MimeType?.Split('/')[0]) | ||||||
|         { |         { | ||||||
| @@ -201,6 +198,7 @@ public class FileService( | |||||||
|                 try |                 try | ||||||
|                 { |                 { | ||||||
|                     var blurhash = BlurHashSharp.SkiaSharp.BlurHashEncoder.Encode(3, 3, filePath); |                     var blurhash = BlurHashSharp.SkiaSharp.BlurHashEncoder.Encode(3, 3, filePath); | ||||||
|  |                     await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); | ||||||
|                     stream.Position = 0; |                     stream.Position = 0; | ||||||
|  |  | ||||||
|                     using var vipsImage = Image.NewFromStream(stream); |                     using var vipsImage = Image.NewFromStream(stream); | ||||||
| @@ -265,7 +263,6 @@ public class FileService( | |||||||
|                         ["bit_rate"] = mediaInfo.Format.BitRate.ToString(CultureInfo.InvariantCulture), |                         ["bit_rate"] = mediaInfo.Format.BitRate.ToString(CultureInfo.InvariantCulture), | ||||||
|                         ["tags"] = mediaInfo.Format.Tags ?? new Dictionary<string, string>(), |                         ["tags"] = mediaInfo.Format.Tags ?? new Dictionary<string, string>(), | ||||||
|                         ["chapters"] = mediaInfo.Chapters, |                         ["chapters"] = mediaInfo.Chapters, | ||||||
|                         // Add detailed stream information |  | ||||||
|                         ["video_streams"] = mediaInfo.VideoStreams.Select(s => new |                         ["video_streams"] = mediaInfo.VideoStreams.Select(s => new | ||||||
|                         { |                         { | ||||||
|                             s.AvgFrameRate, |                             s.AvgFrameRate, | ||||||
| @@ -303,166 +300,6 @@ public class FileService( | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Handles file optimization (image compression, video thumbnail) and uploads to remote storage in the background. |  | ||||||
|     /// </summary> |  | ||||||
|     private async Task ProcessAndUploadInBackgroundAsync( |  | ||||||
|         string fileId, |  | ||||||
|         string remoteId, |  | ||||||
|         string storageId, |  | ||||||
|         string contentType, |  | ||||||
|         string originalFilePath, |  | ||||||
|         Stream stream |  | ||||||
|     ) |  | ||||||
|     { |  | ||||||
|         var pool = await GetPoolAsync(Guid.Parse(remoteId)); |  | ||||||
|         if (pool is null) return; |  | ||||||
|  |  | ||||||
|         await using var bgStream = stream; // Ensure stream is disposed at the end of this task |  | ||||||
|         using var scope = scopeFactory.CreateScope(); |  | ||||||
|         var nfs = scope.ServiceProvider.GetRequiredService<FileService>(); |  | ||||||
|         var scopedDb = scope.ServiceProvider.GetRequiredService<AppDatabase>(); |  | ||||||
|  |  | ||||||
|         var uploads = new List<(string FilePath, string Suffix, string ContentType, bool SelfDestruct)>(); |  | ||||||
|         var newMimeType = contentType; |  | ||||||
|         var hasCompression = false; |  | ||||||
|         var hasThumbnail = false; |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             logger.LogInformation("Processing file {FileId} in background...", fileId); |  | ||||||
|  |  | ||||||
|             var fileExtension = Path.GetExtension(originalFilePath); |  | ||||||
|  |  | ||||||
|             if (!pool.PolicyConfig.NoOptimization) |  | ||||||
|                 switch (contentType.Split('/')[0]) |  | ||||||
|                 { |  | ||||||
|                     case "image": |  | ||||||
|                         if (AnimatedImageTypes.Contains(contentType) || AnimatedImageExtensions.Contains(fileExtension)) |  | ||||||
|                         { |  | ||||||
|                             logger.LogInformation("Skip optimize file {FileId} due to it is animated...", fileId); |  | ||||||
|                             uploads.Add((originalFilePath, string.Empty, contentType, false)); |  | ||||||
|                             break; |  | ||||||
|                         } |  | ||||||
|  |  | ||||||
|                         newMimeType = "image/webp"; |  | ||||||
|                         using (var vipsImage = Image.NewFromFile(originalFilePath)) |  | ||||||
|                         { |  | ||||||
|                             var imageToWrite = vipsImage; |  | ||||||
|  |  | ||||||
|                             if (vipsImage.Interpretation is Enums.Interpretation.Scrgb or Enums.Interpretation.Xyz) |  | ||||||
|                             { |  | ||||||
|                                 imageToWrite = vipsImage.Colourspace(Enums.Interpretation.Srgb); |  | ||||||
|                             } |  | ||||||
|  |  | ||||||
|                             var webpPath = Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{fileId}.webp"); |  | ||||||
|                             imageToWrite.Autorot().WriteToFile(webpPath, |  | ||||||
|                                 new VOption { { "lossless", true }, { "strip", true } }); |  | ||||||
|                             uploads.Add((webpPath, string.Empty, newMimeType, true)); |  | ||||||
|  |  | ||||||
|                             if (imageToWrite.Width * imageToWrite.Height >= 1024 * 1024) |  | ||||||
|                             { |  | ||||||
|                                 var scale = 1024.0 / Math.Max(imageToWrite.Width, imageToWrite.Height); |  | ||||||
|                                 var compressedPath = |  | ||||||
|                                     Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{fileId}-compressed.webp"); |  | ||||||
|                                 using var compressedImage = imageToWrite.Resize(scale); |  | ||||||
|                                 compressedImage.Autorot().WriteToFile(compressedPath, |  | ||||||
|                                     new VOption { { "Q", 80 }, { "strip", true } }); |  | ||||||
|                                 uploads.Add((compressedPath, ".compressed", newMimeType, true)); |  | ||||||
|                                 hasCompression = true; |  | ||||||
|                             } |  | ||||||
|  |  | ||||||
|                             if (!ReferenceEquals(imageToWrite, vipsImage)) |  | ||||||
|                             { |  | ||||||
|                                 imageToWrite.Dispose(); // Clean up manually created colourspace-converted image |  | ||||||
|                             } |  | ||||||
|                         } |  | ||||||
|  |  | ||||||
|                         break; |  | ||||||
|  |  | ||||||
|                     case "video": |  | ||||||
|                         uploads.Add((originalFilePath, string.Empty, contentType, false)); |  | ||||||
|  |  | ||||||
|                         var thumbnailPath = Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{fileId}.thumbnail.jpg"); |  | ||||||
|                         try |  | ||||||
|                         { |  | ||||||
|                             await FFMpegArguments |  | ||||||
|                                 .FromFileInput(originalFilePath, verifyExists: true) |  | ||||||
|                                 .OutputToFile(thumbnailPath, overwrite: true, options => options |  | ||||||
|                                     .Seek(TimeSpan.FromSeconds(0)) |  | ||||||
|                                     .WithFrameOutputCount(1) |  | ||||||
|                                     .WithCustomArgument("-q:v 2") |  | ||||||
|                                 ) |  | ||||||
|                                 .NotifyOnOutput(line => logger.LogInformation("[FFmpeg] {Line}", line)) |  | ||||||
|                                 .NotifyOnError(line => logger.LogWarning("[FFmpeg] {Line}", line)) |  | ||||||
|                                 .ProcessAsynchronously(); |  | ||||||
|  |  | ||||||
|                             if (File.Exists(thumbnailPath)) |  | ||||||
|                             { |  | ||||||
|                                 uploads.Add((thumbnailPath, ".thumbnail", "image/jpeg", true)); |  | ||||||
|                                 hasThumbnail = true; |  | ||||||
|                             } |  | ||||||
|                             else |  | ||||||
|                             { |  | ||||||
|                                 logger.LogWarning("FFMpeg did not produce thumbnail for video {FileId}", fileId); |  | ||||||
|                             } |  | ||||||
|                         } |  | ||||||
|                         catch (Exception ex) |  | ||||||
|                         { |  | ||||||
|                             logger.LogError(ex, "Failed to generate thumbnail for video {FileId}", fileId); |  | ||||||
|                         } |  | ||||||
|  |  | ||||||
|                         break; |  | ||||||
|  |  | ||||||
|                     default: |  | ||||||
|                         uploads.Add((originalFilePath, string.Empty, contentType, false)); |  | ||||||
|                         break; |  | ||||||
|                 } |  | ||||||
|             else uploads.Add((originalFilePath, string.Empty, contentType, false)); |  | ||||||
|  |  | ||||||
|             logger.LogInformation("Optimized file {FileId}, now uploading...", fileId); |  | ||||||
|  |  | ||||||
|             if (uploads.Count > 0) |  | ||||||
|             { |  | ||||||
|                 var destPool = Guid.Parse(remoteId!); |  | ||||||
|                 var uploadTasks = uploads.Select(item => |  | ||||||
|                     nfs.UploadFileToRemoteAsync( |  | ||||||
|                         storageId, |  | ||||||
|                         destPool, |  | ||||||
|                         item.FilePath, |  | ||||||
|                         item.Suffix, |  | ||||||
|                         item.ContentType, |  | ||||||
|                         item.SelfDestruct |  | ||||||
|                     ) |  | ||||||
|                 ).ToList(); |  | ||||||
|  |  | ||||||
|                 await Task.WhenAll(uploadTasks); |  | ||||||
|  |  | ||||||
|                 logger.LogInformation("Uploaded file {FileId} done!", fileId); |  | ||||||
|  |  | ||||||
|                 var fileToUpdate = await scopedDb.Files.FirstAsync(f => f.Id == fileId); |  | ||||||
|                 if (hasThumbnail) fileToUpdate.HasThumbnail = true; |  | ||||||
|  |  | ||||||
|                 var now = SystemClock.Instance.GetCurrentInstant(); |  | ||||||
|                 await scopedDb.Files.Where(f => f.Id == fileId).ExecuteUpdateAsync(setter => setter |  | ||||||
|                     .SetProperty(f => f.UploadedAt, now) |  | ||||||
|                     .SetProperty(f => f.PoolId, destPool) |  | ||||||
|                     .SetProperty(f => f.MimeType, newMimeType) |  | ||||||
|                     .SetProperty(f => f.HasCompression, hasCompression) |  | ||||||
|                     .SetProperty(f => f.HasThumbnail, hasThumbnail) |  | ||||||
|                 ); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|         catch (Exception err) |  | ||||||
|         { |  | ||||||
|             logger.LogError(err, "Failed to process and upload {FileId}", fileId); |  | ||||||
|         } |  | ||||||
|         finally |  | ||||||
|         { |  | ||||||
|             await nfs._PurgeCacheAsync(fileId); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private static async Task<string> HashFileAsync(string filePath, int chunkSize = 1024 * 1024) |     private static async Task<string> HashFileAsync(string filePath, int chunkSize = 1024 * 1024) | ||||||
|     { |     { | ||||||
|         var fileInfo = new FileInfo(filePath); |         var fileInfo = new FileInfo(filePath); | ||||||
| @@ -491,11 +328,11 @@ public class FileService( | |||||||
|         } |         } | ||||||
|  |  | ||||||
|         var hash = MD5.HashData(buffer.AsSpan(0, bytesRead)); |         var hash = MD5.HashData(buffer.AsSpan(0, bytesRead)); | ||||||
|         stream.Position = 0; // Reset stream position |         stream.Position = 0; | ||||||
|         return Convert.ToHexString(hash).ToLowerInvariant(); |         return Convert.ToHexString(hash).ToLowerInvariant(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private async Task UploadFileToRemoteAsync( |     public async Task UploadFileToRemoteAsync( | ||||||
|         string storageId, |         string storageId, | ||||||
|         Guid targetRemote, |         Guid targetRemote, | ||||||
|         string filePath, |         string filePath, | ||||||
| @@ -536,7 +373,7 @@ public class FileService( | |||||||
|         ); |         ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<CloudFile> UpdateFileAsync(CloudFile file, FieldMask updateMask) |     public async Task<SnCloudFile> UpdateFileAsync(SnCloudFile file, FieldMask updateMask) | ||||||
|     { |     { | ||||||
|         var existingFile = await db.Files.FirstOrDefaultAsync(f => f.Id == file.Id); |         var existingFile = await db.Files.FirstOrDefaultAsync(f => f.Id == file.Id); | ||||||
|         if (existingFile == null) |         if (existingFile == null) | ||||||
| @@ -574,11 +411,10 @@ public class FileService( | |||||||
|         await db.Files.Where(f => f.Id == file.Id).ExecuteUpdateAsync(updatable.ToSetPropertyCalls()); |         await db.Files.Where(f => f.Id == file.Id).ExecuteUpdateAsync(updatable.ToSetPropertyCalls()); | ||||||
|  |  | ||||||
|         await _PurgeCacheAsync(file.Id); |         await _PurgeCacheAsync(file.Id); | ||||||
|         // Re-fetch the file to return the updated state |  | ||||||
|         return await db.Files.AsNoTracking().FirstAsync(f => f.Id == file.Id); |         return await db.Files.AsNoTracking().FirstAsync(f => f.Id == file.Id); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task DeleteFileAsync(CloudFile file) |     public async Task DeleteFileAsync(SnCloudFile file) | ||||||
|     { |     { | ||||||
|         db.Remove(file); |         db.Remove(file); | ||||||
|         await db.SaveChangesAsync(); |         await db.SaveChangesAsync(); | ||||||
| @@ -587,24 +423,21 @@ public class FileService( | |||||||
|         await DeleteFileDataAsync(file); |         await DeleteFileDataAsync(file); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task DeleteFileDataAsync(CloudFile file, bool force = false) |     public async Task DeleteFileDataAsync(SnCloudFile file, bool force = false) | ||||||
|     { |     { | ||||||
|         if (!file.PoolId.HasValue) return; |         if (!file.PoolId.HasValue) return; | ||||||
|  |  | ||||||
|         if (!force) |         if (!force) | ||||||
|         { |         { | ||||||
|             // Check if any other file with the same storage ID is referenced |  | ||||||
|             var sameOriginFiles = await db.Files |             var sameOriginFiles = await db.Files | ||||||
|                 .Where(f => f.StorageId == file.StorageId && f.Id != file.Id) |                 .Where(f => f.StorageId == file.StorageId && f.Id != file.Id) | ||||||
|                 .Select(f => f.Id) |                 .Select(f => f.Id) | ||||||
|                 .ToListAsync(); |                 .ToListAsync(); | ||||||
|  |  | ||||||
|             // Check if any of these files are referenced |  | ||||||
|             if (sameOriginFiles.Count != 0) |             if (sameOriginFiles.Count != 0) | ||||||
|                 return; |                 return; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // If any other file with the same storage ID is referenced, don't delete the actual file data |  | ||||||
|         var dest = await GetRemoteStorageConfig(file.PoolId.Value); |         var dest = await GetRemoteStorageConfig(file.PoolId.Value); | ||||||
|         if (dest is null) throw new InvalidOperationException($"No remote storage configured for pool {file.PoolId}"); |         if (dest is null) throw new InvalidOperationException($"No remote storage configured for pool {file.PoolId}"); | ||||||
|         var client = CreateMinioClient(dest); |         var client = CreateMinioClient(dest); | ||||||
| @@ -614,7 +447,7 @@ public class FileService( | |||||||
|             ); |             ); | ||||||
|  |  | ||||||
|         var bucket = dest.Bucket; |         var bucket = dest.Bucket; | ||||||
|         var objectId = file.StorageId ?? file.Id; // Use StorageId if available, otherwise fall back to Id |         var objectId = file.StorageId ?? file.Id; | ||||||
|  |  | ||||||
|         await client.RemoveObjectAsync( |         await client.RemoveObjectAsync( | ||||||
|             new RemoveObjectArgs().WithBucket(bucket).WithObject(objectId) |             new RemoveObjectArgs().WithBucket(bucket).WithObject(objectId) | ||||||
| @@ -630,7 +463,6 @@ public class FileService( | |||||||
|             } |             } | ||||||
|             catch |             catch | ||||||
|             { |             { | ||||||
|                 // Ignore errors when deleting compressed version |  | ||||||
|                 logger.LogWarning("Failed to delete compressed version of file {fileId}", file.Id); |                 logger.LogWarning("Failed to delete compressed version of file {fileId}", file.Id); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
| @@ -645,25 +477,17 @@ public class FileService( | |||||||
|             } |             } | ||||||
|             catch |             catch | ||||||
|             { |             { | ||||||
|                 // Ignore errors when deleting thumbnail |  | ||||||
|                 logger.LogWarning("Failed to delete thumbnail of file {fileId}", file.Id); |                 logger.LogWarning("Failed to delete thumbnail of file {fileId}", file.Id); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// <summary> |     public async Task DeleteFileDataBatchAsync(List<SnCloudFile> files) | ||||||
|     /// 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(); |         files = files.Where(f => f.PoolId.HasValue).ToList(); | ||||||
|  |  | ||||||
|         foreach (var fileGroup in files.GroupBy(f => f.PoolId!.Value)) |         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); |             var dest = await GetRemoteStorageConfig(fileGroup.Key); | ||||||
|             if (dest is null) |             if (dest is null) | ||||||
|                 throw new InvalidOperationException($"No remote storage configured for pool {fileGroup.Key}"); |                 throw new InvalidOperationException($"No remote storage configured for pool {fileGroup.Key}"); | ||||||
| @@ -688,7 +512,7 @@ public class FileService( | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private async Task<FileBundle?> GetBundleAsync(Guid id, Guid accountId) |     private async Task<SnFileBundle?> 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) | ||||||
| @@ -733,31 +557,27 @@ public class FileService( | |||||||
|         return client.Build(); |         return client.Build(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Helper method to purge the cache for a specific file |  | ||||||
|     // Made internal to allow FileReferenceService to use it |  | ||||||
|     internal async Task _PurgeCacheAsync(string fileId) |     internal async Task _PurgeCacheAsync(string fileId) | ||||||
|     { |     { | ||||||
|         var cacheKey = $"{CacheKeyPrefix}{fileId}"; |         var cacheKey = $"{CacheKeyPrefix}{fileId}"; | ||||||
|         await cache.RemoveAsync(cacheKey); |         await cache.RemoveAsync(cacheKey); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Helper method to purge cache for multiple files |  | ||||||
|     internal async Task _PurgeCacheRangeAsync(IEnumerable<string> fileIds) |     internal async Task _PurgeCacheRangeAsync(IEnumerable<string> fileIds) | ||||||
|     { |     { | ||||||
|         var tasks = fileIds.Select(_PurgeCacheAsync); |         var tasks = fileIds.Select(_PurgeCacheAsync); | ||||||
|         await Task.WhenAll(tasks); |         await Task.WhenAll(tasks); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<List<CloudFile?>> LoadFromReference(List<CloudFileReferenceObject> references) |     public async Task<List<SnCloudFile?>> LoadFromReference(List<SnCloudFileReferenceObject> references) | ||||||
|     { |     { | ||||||
|         var cachedFiles = new Dictionary<string, CloudFile>(); |         var cachedFiles = new Dictionary<string, SnCloudFile>(); | ||||||
|         var uncachedIds = new List<string>(); |         var uncachedIds = new List<string>(); | ||||||
|  |  | ||||||
|         // Check cache first |  | ||||||
|         foreach (var reference in references) |         foreach (var reference in references) | ||||||
|         { |         { | ||||||
|             var cacheKey = $"{CacheKeyPrefix}{reference.Id}"; |             var cacheKey = $"{CacheKeyPrefix}{reference.Id}"; | ||||||
|             var cachedFile = await cache.GetAsync<CloudFile>(cacheKey); |             var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey); | ||||||
|  |  | ||||||
|             if (cachedFile != null) |             if (cachedFile != null) | ||||||
|             { |             { | ||||||
| @@ -769,14 +589,12 @@ public class FileService( | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Load uncached files from database |  | ||||||
|         if (uncachedIds.Count > 0) |         if (uncachedIds.Count > 0) | ||||||
|         { |         { | ||||||
|             var dbFiles = await db.Files |             var dbFiles = await db.Files | ||||||
|                 .Where(f => uncachedIds.Contains(f.Id)) |                 .Where(f => uncachedIds.Contains(f.Id)) | ||||||
|                 .ToListAsync(); |                 .ToListAsync(); | ||||||
|  |  | ||||||
|             // Add to cache |  | ||||||
|             foreach (var file in dbFiles) |             foreach (var file in dbFiles) | ||||||
|             { |             { | ||||||
|                 var cacheKey = $"{CacheKeyPrefix}{file.Id}"; |                 var cacheKey = $"{CacheKeyPrefix}{file.Id}"; | ||||||
| @@ -785,18 +603,11 @@ public class FileService( | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Preserve original order |         return [.. references | ||||||
|         return references |  | ||||||
|             .Select(r => cachedFiles.GetValueOrDefault(r.Id)) |             .Select(r => cachedFiles.GetValueOrDefault(r.Id)) | ||||||
|             .Where(f => f != null) |             .Where(f => f != null)]; | ||||||
|             .ToList(); |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Gets the number of references to a file based on CloudFileReference records |  | ||||||
|     /// </summary> |  | ||||||
|     /// <param name="fileId">The ID of the file</param> |  | ||||||
|     /// <returns>The number of references to the file</returns> |  | ||||||
|     public async Task<int> GetReferenceCountAsync(string fileId) |     public async Task<int> GetReferenceCountAsync(string fileId) | ||||||
|     { |     { | ||||||
|         return await db.FileReferences |         return await db.FileReferences | ||||||
| @@ -804,11 +615,6 @@ public class FileService( | |||||||
|             .CountAsync(); |             .CountAsync(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Checks if a file is referenced by any resource |  | ||||||
|     /// </summary> |  | ||||||
|     /// <param name="fileId">The ID of the file to check</param> |  | ||||||
|     /// <returns>True if the file is referenced, false otherwise</returns> |  | ||||||
|     public async Task<bool> IsReferencedAsync(string fileId) |     public async Task<bool> IsReferencedAsync(string fileId) | ||||||
|     { |     { | ||||||
|         return await db.FileReferences |         return await db.FileReferences | ||||||
| @@ -816,12 +622,8 @@ public class FileService( | |||||||
|             .AnyAsync(); |             .AnyAsync(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Checks if an EXIF field should be ignored (e.g., GPS data). |  | ||||||
|     /// </summary> |  | ||||||
|     private static bool IsIgnoredField(string fieldName) |     private static bool IsIgnoredField(string fieldName) | ||||||
|     { |     { | ||||||
|         // Common GPS EXIF field names |  | ||||||
|         var gpsFields = new[] |         var gpsFields = new[] | ||||||
|         { |         { | ||||||
|             "gps-latitude", "gps-longitude", "gps-altitude", "gps-latitude-ref", "gps-longitude-ref", |             "gps-latitude", "gps-longitude", "gps-altitude", "gps-latitude-ref", "gps-longitude-ref", | ||||||
| @@ -882,7 +684,7 @@ public class FileService( | |||||||
|         return count; |         return count; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<string> CreateFastUploadLinkAsync(CloudFile file) |     public async Task<string> CreateFastUploadLinkAsync(SnCloudFile file) | ||||||
|     { |     { | ||||||
|         if (file.PoolId is null) throw new InvalidOperationException("Pool ID is null"); |         if (file.PoolId is null) throw new InvalidOperationException("Pool ID is null"); | ||||||
|  |  | ||||||
| @@ -904,10 +706,7 @@ public class FileService( | |||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| /// <summary> | file class UpdatableCloudFile(SnCloudFile file) | ||||||
| /// A helper class to build an ExecuteUpdateAsync call for CloudFile. |  | ||||||
| /// </summary> |  | ||||||
| file class UpdatableCloudFile(CloudFile file) |  | ||||||
| { | { | ||||||
|     public string Name { get; set; } = file.Name; |     public string Name { get; set; } = file.Name; | ||||||
|     public string? Description { get; set; } = file.Description; |     public string? Description { get; set; } = file.Description; | ||||||
| @@ -915,14 +714,14 @@ file class UpdatableCloudFile(CloudFile file) | |||||||
|     public Dictionary<string, object?>? UserMeta { get; set; } = file.UserMeta; |     public Dictionary<string, object?>? UserMeta { get; set; } = file.UserMeta; | ||||||
|     public bool IsMarkedRecycle { get; set; } = file.IsMarkedRecycle; |     public bool IsMarkedRecycle { get; set; } = file.IsMarkedRecycle; | ||||||
|  |  | ||||||
|     public Expression<Func<SetPropertyCalls<CloudFile>, SetPropertyCalls<CloudFile>>> ToSetPropertyCalls() |     public Expression<Func<SetPropertyCalls<SnCloudFile>, SetPropertyCalls<SnCloudFile>>> ToSetPropertyCalls() | ||||||
|     { |     { | ||||||
|         var userMeta = UserMeta ?? new Dictionary<string, object?>(); |         var userMeta = UserMeta ?? []; | ||||||
|         return setter => setter |         return setter => setter | ||||||
|             .SetProperty(f => f.Name, Name) |             .SetProperty(f => f.Name, Name) | ||||||
|             .SetProperty(f => f.Description, Description) |             .SetProperty(f => f.Description, Description) | ||||||
|             .SetProperty(f => f.FileMeta, FileMeta) |             .SetProperty(f => f.FileMeta, FileMeta) | ||||||
|             .SetProperty(f => f.UserMeta, userMeta!) |             .SetProperty(f => f.UserMeta, userMeta) | ||||||
|             .SetProperty(f => f.IsMarkedRecycle, IsMarkedRecycle); |             .SetProperty(f => f.IsMarkedRecycle, IsMarkedRecycle); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -1,4 +1,4 @@ | |||||||
| using DysonNetwork.Shared.Data; | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
| using Google.Protobuf.WellKnownTypes; | using Google.Protobuf.WellKnownTypes; | ||||||
| using Grpc.Core; | using Grpc.Core; | ||||||
| @@ -48,7 +48,7 @@ namespace DysonNetwork.Drive.Storage | |||||||
|         { |         { | ||||||
|             // Assuming CloudFileReferenceObject is a simple class/struct that holds an ID |             // Assuming CloudFileReferenceObject is a simple class/struct that holds an ID | ||||||
|             // You might need to define this or adjust the LoadFromReference method in FileService |             // You might need to define this or adjust the LoadFromReference method in FileService | ||||||
|             var references = request.ReferenceIds.Select(id => new CloudFileReferenceObject { Id = id }).ToList(); |             var references = request.ReferenceIds.Select(id => new SnCloudFileReferenceObject { Id = id }).ToList(); | ||||||
|             var files = await fileService.LoadFromReference(references); |             var files = await fileService.LoadFromReference(references); | ||||||
|             var response = new LoadFromReferenceResponse(); |             var response = new LoadFromReferenceResponse(); | ||||||
|             response.Files.AddRange(files.Where(f => f != null).Select(f => f!.ToProtoValue())); |             response.Files.AddRange(files.Where(f => f != null).Select(f => f!.ToProtoValue())); | ||||||
|   | |||||||
| @@ -1,7 +1,9 @@ | |||||||
|  | using System.ComponentModel.DataAnnotations; | ||||||
| using System.Text.Json; | using System.Text.Json; | ||||||
| using DysonNetwork.Drive.Billing; | using DysonNetwork.Drive.Billing; | ||||||
| using DysonNetwork.Drive.Storage.Model; | using DysonNetwork.Drive.Storage.Model; | ||||||
| using DysonNetwork.Shared.Auth; | using DysonNetwork.Shared.Auth; | ||||||
|  | using DysonNetwork.Shared.Http; | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
| using Microsoft.AspNetCore.Authorization; | using Microsoft.AspNetCore.Authorization; | ||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| @@ -23,14 +25,17 @@ public class FileUploadController( | |||||||
|     : ControllerBase |     : ControllerBase | ||||||
| { | { | ||||||
|     private readonly string _tempPath = |     private readonly string _tempPath = | ||||||
|         Path.Combine(configuration.GetValue<string>("Storage:Uploads") ?? Path.GetTempPath(), "multipart-uploads"); |         configuration.GetValue<string>("Storage:Uploads") ?? Path.Combine(Path.GetTempPath(), "multipart-uploads"); | ||||||
|  |  | ||||||
|     private const long DefaultChunkSize = 1024 * 1024 * 5; // 5MB |     private const long DefaultChunkSize = 1024 * 1024 * 5; // 5MB | ||||||
|  |  | ||||||
|     [HttpPost("create")] |     [HttpPost("create")] | ||||||
|     public async Task<IActionResult> CreateUploadTask([FromBody] CreateUploadTaskRequest request) |     public async Task<IActionResult> CreateUploadTask([FromBody] CreateUploadTaskRequest request) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|  |         { | ||||||
|  |             return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; | ||||||
|  |         } | ||||||
|  |  | ||||||
|         if (!currentUser.IsSuperuser) |         if (!currentUser.IsSuperuser) | ||||||
|         { |         { | ||||||
| @@ -38,57 +43,50 @@ public class FileUploadController( | |||||||
|             { Actor = $"user:{currentUser.Id}", Area = "global", Key = "files.create" }); |             { Actor = $"user:{currentUser.Id}", Area = "global", Key = "files.create" }); | ||||||
|             if (!allowed.HasPermission) |             if (!allowed.HasPermission) | ||||||
|             { |             { | ||||||
|                 return Forbid(); |                 return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 }; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if (!Guid.TryParse(request.PoolId, out var poolGuid)) |         request.PoolId ??= Guid.Parse(configuration["Storage:PreferredRemote"]!); | ||||||
|         { |  | ||||||
|             return BadRequest("Invalid file pool id"); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var pool = await fileService.GetPoolAsync(poolGuid); |         var pool = await fileService.GetPoolAsync(request.PoolId.Value); | ||||||
|         if (pool is null) |         if (pool is null) | ||||||
|         { |         { | ||||||
|             return BadRequest("Pool not found"); |             return new ObjectResult(ApiError.NotFound("Pool")) { StatusCode = 404 }; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if (pool.PolicyConfig.RequirePrivilege > 0) |         if (pool.PolicyConfig.RequirePrivilege is > 0) | ||||||
|         { |         { | ||||||
|             if (currentUser.PerkSubscription is null) |  | ||||||
|             { |  | ||||||
|                 return new ObjectResult("You need to have join the Stellar Program to use this pool") |  | ||||||
|                     { StatusCode = 403 }; |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             var privilege = |             var privilege = | ||||||
|  |                 currentUser.PerkSubscription is null ? 0 : | ||||||
|                 PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier); |                 PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier); | ||||||
|             if (privilege < pool.PolicyConfig.RequirePrivilege) |             if (privilege < pool.PolicyConfig.RequirePrivilege) | ||||||
|             { |             { | ||||||
|                 return new ObjectResult( |                 return new ObjectResult(ApiError.Unauthorized( | ||||||
|                     $"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use this pool, you are tier {privilege}") |                     $"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use pool {pool.Name}, you are tier {privilege}", | ||||||
|  |                     forbidden: true)) | ||||||
|                 { |                 { | ||||||
|                     StatusCode = 403 |                     StatusCode = 403 | ||||||
|                 }; |                 }; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if (!string.IsNullOrEmpty(request.BundleId) && !Guid.TryParse(request.BundleId, out _)) |  | ||||||
|         { |  | ||||||
|             return BadRequest("Invalid file bundle id"); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var policy = pool.PolicyConfig; |         var policy = pool.PolicyConfig; | ||||||
|         if (!policy.AllowEncryption && !string.IsNullOrEmpty(request.EncryptPassword)) |         if (!policy.AllowEncryption && !string.IsNullOrEmpty(request.EncryptPassword)) | ||||||
|         { |         { | ||||||
|             return new ObjectResult("File encryption is not allowed in this pool") { StatusCode = 403 }; |             return new ObjectResult(ApiError.Unauthorized("File encryption is not allowed in this pool", true)) | ||||||
|  |             { StatusCode = 403 }; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if (policy.AcceptTypes is { Count: > 0 }) |         if (policy.AcceptTypes is { Count: > 0 }) | ||||||
|         { |         { | ||||||
|             if (string.IsNullOrEmpty(request.ContentType)) |             if (string.IsNullOrEmpty(request.ContentType)) | ||||||
|             { |             { | ||||||
|                 return BadRequest("Content type is required by the pool's policy"); |                 return new ObjectResult(ApiError.Validation(new Dictionary<string, string[]> | ||||||
|  |                 { | ||||||
|  |                     { "contentType", new[] { "Content type is required by the pool's policy" } } | ||||||
|  |                 })) | ||||||
|  |                 { StatusCode = 400 }; | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             var foundMatch = policy.AcceptTypes.Any(acceptType => |             var foundMatch = policy.AcceptTypes.Any(acceptType => | ||||||
| @@ -104,15 +102,18 @@ public class FileUploadController( | |||||||
|  |  | ||||||
|             if (!foundMatch) |             if (!foundMatch) | ||||||
|             { |             { | ||||||
|                 return new ObjectResult($"Content type {request.ContentType} is not allowed by the pool's policy") |                 return new ObjectResult( | ||||||
|  |                     ApiError.Unauthorized($"Content type {request.ContentType} is not allowed by the pool's policy", | ||||||
|  |                         true)) | ||||||
|                 { StatusCode = 403 }; |                 { StatusCode = 403 }; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if (policy.MaxFileSize is not null && request.FileSize > policy.MaxFileSize) |         if (policy.MaxFileSize is not null && request.FileSize > policy.MaxFileSize) | ||||||
|         { |         { | ||||||
|             return new ObjectResult( |             return new ObjectResult(ApiError.Unauthorized( | ||||||
|                 $"File size {request.FileSize} is larger than the pool's maximum file size {policy.MaxFileSize}") |                 $"File size {request.FileSize} is larger than the pool's maximum file size {policy.MaxFileSize}", | ||||||
|  |                 true)) | ||||||
|             { |             { | ||||||
|                 StatusCode = 403 |                 StatusCode = 403 | ||||||
|             }; |             }; | ||||||
| @@ -125,7 +126,9 @@ public class FileUploadController( | |||||||
|         ); |         ); | ||||||
|         if (!ok) |         if (!ok) | ||||||
|         { |         { | ||||||
|             return new ObjectResult($"File size {billableUnit} MiB is exceeded the user's quota {quota} MiB") |             return new ObjectResult( | ||||||
|  |                 ApiError.Unauthorized($"File size {billableUnit} MiB is exceeded the user's quota {quota} MiB", | ||||||
|  |                     true)) | ||||||
|             { StatusCode = 403 }; |             { StatusCode = 403 }; | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -160,7 +163,7 @@ public class FileUploadController( | |||||||
|             ContentType = request.ContentType, |             ContentType = request.ContentType, | ||||||
|             ChunkSize = chunkSize, |             ChunkSize = chunkSize, | ||||||
|             ChunksCount = chunksCount, |             ChunksCount = chunksCount, | ||||||
|             PoolId = request.PoolId, |             PoolId = request.PoolId.Value, | ||||||
|             BundleId = request.BundleId, |             BundleId = request.BundleId, | ||||||
|             EncryptPassword = request.EncryptPassword, |             EncryptPassword = request.EncryptPassword, | ||||||
|             ExpiredAt = request.ExpiredAt, |             ExpiredAt = request.ExpiredAt, | ||||||
| @@ -178,15 +181,22 @@ public class FileUploadController( | |||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     public class UploadChunkRequest | ||||||
|  |     { | ||||||
|  |         [Required] | ||||||
|  |         public IFormFile Chunk { get; set; } = null!; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     [HttpPost("chunk/{taskId}/{chunkIndex}")] |     [HttpPost("chunk/{taskId}/{chunkIndex}")] | ||||||
|     [RequestSizeLimit(DefaultChunkSize + 1024 * 1024)] // 6MB to be safe |     [RequestSizeLimit(DefaultChunkSize + 1024 * 1024)] // 6MB to be safe | ||||||
|     [RequestFormLimits(MultipartBodyLengthLimit = DefaultChunkSize + 1024 * 1024)] |     [RequestFormLimits(MultipartBodyLengthLimit = DefaultChunkSize + 1024 * 1024)] | ||||||
|     public async Task<IActionResult> UploadChunk(string taskId, int chunkIndex, [FromForm] IFormFile chunk) |     public async Task<IActionResult> UploadChunk(string taskId, int chunkIndex, [FromForm] UploadChunkRequest request) | ||||||
|     { |     { | ||||||
|  |         var chunk = request.Chunk; | ||||||
|         var taskPath = Path.Combine(_tempPath, taskId); |         var taskPath = Path.Combine(_tempPath, taskId); | ||||||
|         if (!Directory.Exists(taskPath)) |         if (!Directory.Exists(taskPath)) | ||||||
|         { |         { | ||||||
|             return NotFound("Upload task not found."); |             return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 }; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         var chunkPath = Path.Combine(taskPath, $"{chunkIndex}.chunk"); |         var chunkPath = Path.Combine(taskPath, $"{chunkIndex}.chunk"); | ||||||
| @@ -202,19 +212,20 @@ public class FileUploadController( | |||||||
|         var taskPath = Path.Combine(_tempPath, taskId); |         var taskPath = Path.Combine(_tempPath, taskId); | ||||||
|         if (!Directory.Exists(taskPath)) |         if (!Directory.Exists(taskPath)) | ||||||
|         { |         { | ||||||
|             return NotFound("Upload task not found."); |             return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 }; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         var taskJsonPath = Path.Combine(taskPath, "task.json"); |         var taskJsonPath = Path.Combine(taskPath, "task.json"); | ||||||
|         if (!System.IO.File.Exists(taskJsonPath)) |         if (!System.IO.File.Exists(taskJsonPath)) | ||||||
|         { |         { | ||||||
|             return NotFound("Upload task metadata not found."); |             return new ObjectResult(ApiError.NotFound("Upload task metadata")) { StatusCode = 404 }; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         var task = JsonSerializer.Deserialize<UploadTask>(await System.IO.File.ReadAllTextAsync(taskJsonPath)); |         var task = JsonSerializer.Deserialize<UploadTask>(await System.IO.File.ReadAllTextAsync(taskJsonPath)); | ||||||
|         if (task == null) |         if (task == null) | ||||||
|         { |         { | ||||||
|             return BadRequest("Invalid task metadata."); |             return new ObjectResult(new ApiError { Code = "BAD_REQUEST", Message = "Invalid task metadata.", Status = 400 }) | ||||||
|  |             { StatusCode = 400 }; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         var mergedFilePath = Path.Combine(_tempPath, taskId + ".tmp"); |         var mergedFilePath = Path.Combine(_tempPath, taskId + ".tmp"); | ||||||
| @@ -229,7 +240,9 @@ public class FileUploadController( | |||||||
|                     mergedStream.Close(); |                     mergedStream.Close(); | ||||||
|                     System.IO.File.Delete(mergedFilePath); |                     System.IO.File.Delete(mergedFilePath); | ||||||
|                     Directory.Delete(taskPath, true); |                     Directory.Delete(taskPath, true); | ||||||
|                     return BadRequest($"Chunk {i} is missing."); |                     return new ObjectResult(new ApiError | ||||||
|  |                     { Code = "CHUNK_MISSING", Message = $"Chunk {i} is missing.", Status = 400 }) | ||||||
|  |                     { StatusCode = 400 }; | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 await using var chunkStream = new FileStream(chunkPath, FileMode.Open); |                 await using var chunkStream = new FileStream(chunkPath, FileMode.Open); | ||||||
| @@ -237,19 +250,19 @@ public class FileUploadController( | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) | ||||||
|  |         { | ||||||
|  |             return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 }; | ||||||
|  |         } | ||||||
|  |  | ||||||
|         var fileId = await Nanoid.GenerateAsync(); |         var fileId = await Nanoid.GenerateAsync(); | ||||||
|  |  | ||||||
|         await using (var fileStream = |  | ||||||
|                      new FileStream(mergedFilePath, FileMode.Open, FileAccess.Read, FileShare.Read)) |  | ||||||
|         { |  | ||||||
|         var cloudFile = await fileService.ProcessNewFileAsync( |         var cloudFile = await fileService.ProcessNewFileAsync( | ||||||
|             currentUser, |             currentUser, | ||||||
|             fileId, |             fileId, | ||||||
|                 task.PoolId, |             task.PoolId.ToString(), | ||||||
|                 task.BundleId, |             task.BundleId?.ToString(), | ||||||
|                 fileStream, |             mergedFilePath, | ||||||
|             task.FileName, |             task.FileName, | ||||||
|             task.ContentType, |             task.ContentType, | ||||||
|             task.EncryptPassword, |             task.EncryptPassword, | ||||||
| @@ -262,5 +275,4 @@ public class FileUploadController( | |||||||
|  |  | ||||||
|         return Ok(cloudFile); |         return Ok(cloudFile); | ||||||
|     } |     } | ||||||
|     } |  | ||||||
| } | } | ||||||
							
								
								
									
										15
									
								
								DysonNetwork.Drive/Storage/Model/Events.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								DysonNetwork.Drive/Storage/Model/Events.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | |||||||
|  | namespace DysonNetwork.Drive.Storage.Model; | ||||||
|  |  | ||||||
|  | public static class FileUploadedEvent | ||||||
|  | { | ||||||
|  |     public const string Type = "file_uploaded"; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | public record FileUploadedEventPayload( | ||||||
|  |     string FileId, | ||||||
|  |     Guid RemoteId, | ||||||
|  |     string StorageId, | ||||||
|  |     string ContentType, | ||||||
|  |     string ProcessingFilePath, | ||||||
|  |     bool IsTempFile | ||||||
|  | ); | ||||||
| @@ -1,4 +1,4 @@ | |||||||
| using DysonNetwork.Drive.Storage; | using DysonNetwork.Shared.Models; | ||||||
| using NodaTime; | using NodaTime; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Drive.Storage.Model | namespace DysonNetwork.Drive.Storage.Model | ||||||
| @@ -9,8 +9,8 @@ namespace DysonNetwork.Drive.Storage.Model | |||||||
|         public string FileName { get; set; } = null!; |         public string FileName { get; set; } = null!; | ||||||
|         public long FileSize { get; set; } |         public long FileSize { get; set; } | ||||||
|         public string ContentType { get; set; } = null!; |         public string ContentType { get; set; } = null!; | ||||||
|         public string PoolId { get; set; } = null!; |         public Guid? PoolId { get; set; } = null!; | ||||||
|         public string? BundleId { get; set; } |         public Guid? BundleId { get; set; } | ||||||
|         public string? EncryptPassword { get; set; } |         public string? EncryptPassword { get; set; } | ||||||
|         public Instant? ExpiredAt { get; set; } |         public Instant? ExpiredAt { get; set; } | ||||||
|         public long? ChunkSize { get; set; } |         public long? ChunkSize { get; set; } | ||||||
| @@ -19,7 +19,7 @@ namespace DysonNetwork.Drive.Storage.Model | |||||||
|     public class CreateUploadTaskResponse |     public class CreateUploadTaskResponse | ||||||
|     { |     { | ||||||
|         public bool FileExists { get; set; } |         public bool FileExists { get; set; } | ||||||
|         public CloudFile? File { get; set; } |         public SnCloudFile? File { get; set; } | ||||||
|         public string? TaskId { get; set; } |         public string? TaskId { get; set; } | ||||||
|         public long? ChunkSize { get; set; } |         public long? ChunkSize { get; set; } | ||||||
|         public int? ChunksCount { get; set; } |         public int? ChunksCount { get; set; } | ||||||
| @@ -33,8 +33,8 @@ namespace DysonNetwork.Drive.Storage.Model | |||||||
|         public string ContentType { get; set; } = null!; |         public string ContentType { get; set; } = null!; | ||||||
|         public long ChunkSize { get; set; } |         public long ChunkSize { get; set; } | ||||||
|         public int ChunksCount { get; set; } |         public int ChunksCount { get; set; } | ||||||
|         public string PoolId { get; set; } = null!; |         public Guid PoolId { get; set; } | ||||||
|         public string? BundleId { get; set; } |         public Guid? BundleId { get; set; } | ||||||
|         public string? EncryptPassword { get; set; } |         public string? EncryptPassword { get; set; } | ||||||
|         public Instant? ExpiredAt { get; set; } |         public Instant? ExpiredAt { get; set; } | ||||||
|         public string Hash { get; set; } = null!; |         public string Hash { get; set; } = null!; | ||||||
|   | |||||||
| @@ -16,7 +16,7 @@ To begin a file upload, you first need to create an upload task. This is done by | |||||||
|   "file_name": "string", |   "file_name": "string", | ||||||
|   "file_size": "long (in bytes)", |   "file_size": "long (in bytes)", | ||||||
|   "content_type": "string (e.g., 'image/jpeg')", |   "content_type": "string (e.g., 'image/jpeg')", | ||||||
|   "pool_id": "string (GUID)", |   "pool_id": "string (GUID, optional)", | ||||||
|   "bundle_id": "string (GUID, optional)", |   "bundle_id": "string (GUID, optional)", | ||||||
|   "encrypt_password": "string (optional)", |   "encrypt_password": "string (optional)", | ||||||
|   "expired_at": "string (ISO 8601 format, optional)", |   "expired_at": "string (ISO 8601 format, optional)", | ||||||
|   | |||||||
| @@ -1,306 +0,0 @@ | |||||||
| using System.Net; |  | ||||||
| using System.Text; |  | ||||||
| using System.Text.Json; |  | ||||||
| using DysonNetwork.Drive.Billing; |  | ||||||
| using DysonNetwork.Shared.Auth; |  | ||||||
| using DysonNetwork.Shared.Proto; |  | ||||||
| using Microsoft.AspNetCore.Mvc; |  | ||||||
| using Microsoft.Extensions.Options; |  | ||||||
| using NodaTime; |  | ||||||
| using tusdotnet.Interfaces; |  | ||||||
| using tusdotnet.Models; |  | ||||||
| using tusdotnet.Models.Configuration; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Drive.Storage; |  | ||||||
|  |  | ||||||
| public abstract class TusService |  | ||||||
| { |  | ||||||
|     public static DefaultTusConfiguration BuildConfiguration( |  | ||||||
|         ITusStore store, |  | ||||||
|         IConfiguration configuration |  | ||||||
|     ) => new() |  | ||||||
|     { |  | ||||||
|         Store = store, |  | ||||||
|         Events = new Events |  | ||||||
|         { |  | ||||||
|             OnAuthorizeAsync = async eventContext => |  | ||||||
|             { |  | ||||||
|                 if (eventContext.Intent == IntentType.DeleteFile) |  | ||||||
|                 { |  | ||||||
|                     eventContext.FailRequest( |  | ||||||
|                         HttpStatusCode.BadRequest, |  | ||||||
|                         "Deleting files from this endpoint was disabled, please refer to the Dyson Network File API." |  | ||||||
|                     ); |  | ||||||
|                     return; |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 var httpContext = eventContext.HttpContext; |  | ||||||
|                 if (httpContext.Items["CurrentUser"] is not Account currentUser) |  | ||||||
|                 { |  | ||||||
|                     eventContext.FailRequest(HttpStatusCode.Unauthorized); |  | ||||||
|                     return; |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 if (eventContext.Intent != IntentType.CreateFile) return; |  | ||||||
|  |  | ||||||
|                 using var scope = httpContext.RequestServices.CreateScope(); |  | ||||||
|  |  | ||||||
|                 if (!currentUser.IsSuperuser) |  | ||||||
|                 { |  | ||||||
|                     var pm = scope.ServiceProvider.GetRequiredService<PermissionService.PermissionServiceClient>(); |  | ||||||
|                     var allowed = await pm.HasPermissionAsync(new HasPermissionRequest |  | ||||||
|                         { Actor = $"user:{currentUser.Id}", Area = "global", Key = "files.create" }); |  | ||||||
|                     if (!allowed.HasPermission) |  | ||||||
|                         eventContext.FailRequest(HttpStatusCode.Forbidden); |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 var filePool = httpContext.Request.Headers["X-FilePool"].FirstOrDefault(); |  | ||||||
|                 if (string.IsNullOrEmpty(filePool)) filePool = configuration["Storage:PreferredRemote"]; |  | ||||||
|                 if (!Guid.TryParse(filePool, out _)) |  | ||||||
|                 { |  | ||||||
|                     eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file pool id"); |  | ||||||
|                     return; |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 var fs = scope.ServiceProvider.GetRequiredService<FileService>(); |  | ||||||
|                 var pool = await fs.GetPoolAsync(Guid.Parse(filePool!)); |  | ||||||
|                 if (pool is null) |  | ||||||
|                 { |  | ||||||
|                     eventContext.FailRequest(HttpStatusCode.BadRequest, "Pool not found"); |  | ||||||
|                     return; |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 if (pool.PolicyConfig.RequirePrivilege > 0) |  | ||||||
|                 { |  | ||||||
|                     if (currentUser.PerkSubscription is null) |  | ||||||
|                     { |  | ||||||
|                         eventContext.FailRequest( |  | ||||||
|                             HttpStatusCode.Forbidden, |  | ||||||
|                             $"You need to have join the Stellar Program to use this pool" |  | ||||||
|                         ); |  | ||||||
|                         return; |  | ||||||
|                     } |  | ||||||
|  |  | ||||||
|                     var privilege = |  | ||||||
|                         PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier); |  | ||||||
|                     if (privilege < pool.PolicyConfig.RequirePrivilege) |  | ||||||
|                     { |  | ||||||
|                         eventContext.FailRequest( |  | ||||||
|                             HttpStatusCode.Forbidden, |  | ||||||
|                             $"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use this pool, you are tier {privilege}" |  | ||||||
|                         ); |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|                  |  | ||||||
|                 var bundleId = eventContext.HttpContext.Request.Headers["X-FileBundle"].FirstOrDefault(); |  | ||||||
|                 if (!string.IsNullOrEmpty(bundleId) && !Guid.TryParse(bundleId, out _)) |  | ||||||
|                 { |  | ||||||
|                     eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file bundle id"); |  | ||||||
|                 } |  | ||||||
|             }, |  | ||||||
|             OnFileCompleteAsync = async eventContext => |  | ||||||
|             { |  | ||||||
|                 using var scope = eventContext.HttpContext.RequestServices.CreateScope(); |  | ||||||
|                 var services = scope.ServiceProvider; |  | ||||||
|  |  | ||||||
|                 var httpContext = eventContext.HttpContext; |  | ||||||
|                 if (httpContext.Items["CurrentUser"] is not Account user) return; |  | ||||||
|  |  | ||||||
|                 var file = await eventContext.GetFileAsync(); |  | ||||||
|                 var metadata = await file.GetMetadataAsync(eventContext.CancellationToken); |  | ||||||
|                 var fileName = metadata.TryGetValue("filename", out var fn) |  | ||||||
|                     ? fn.GetString(Encoding.UTF8) |  | ||||||
|                     : "uploaded_file"; |  | ||||||
|                 var contentType = metadata.TryGetValue("content-type", out var ct) ? ct.GetString(Encoding.UTF8) : null; |  | ||||||
|  |  | ||||||
|                 var fileStream = await file.GetContentAsync(eventContext.CancellationToken); |  | ||||||
|  |  | ||||||
|                 var filePool = httpContext.Request.Headers["X-FilePool"].FirstOrDefault(); |  | ||||||
|                 var bundleId = eventContext.HttpContext.Request.Headers["X-FileBundle"].FirstOrDefault(); |  | ||||||
|                 var encryptPassword = httpContext.Request.Headers["X-FilePass"].FirstOrDefault(); |  | ||||||
|  |  | ||||||
|                 if (string.IsNullOrEmpty(filePool)) |  | ||||||
|                     filePool = configuration["Storage:PreferredRemote"]; |  | ||||||
|  |  | ||||||
|                 Instant? expiredAt = null; |  | ||||||
|                 var expiredString = httpContext.Request.Headers["X-FileExpire"].FirstOrDefault(); |  | ||||||
|                 if (!string.IsNullOrEmpty(expiredString) && int.TryParse(expiredString, out var expired)) |  | ||||||
|                     expiredAt = Instant.FromUnixTimeSeconds(expired); |  | ||||||
|                  |  | ||||||
|                 try |  | ||||||
|                 { |  | ||||||
|                     var fileService = services.GetRequiredService<FileService>(); |  | ||||||
|                     var info = await fileService.ProcessNewFileAsync( |  | ||||||
|                         user, |  | ||||||
|                         file.Id, |  | ||||||
|                         filePool!, |  | ||||||
|                         bundleId, |  | ||||||
|                         fileStream, |  | ||||||
|                         fileName, |  | ||||||
|                         contentType, |  | ||||||
|                         encryptPassword, |  | ||||||
|                         expiredAt |  | ||||||
|                     ); |  | ||||||
|  |  | ||||||
|                     using var finalScope = eventContext.HttpContext.RequestServices.CreateScope(); |  | ||||||
|                     var jsonOptions = finalScope.ServiceProvider.GetRequiredService<IOptions<JsonOptions>>().Value |  | ||||||
|                         .JsonSerializerOptions; |  | ||||||
|                     var infoJson = JsonSerializer.Serialize(info, jsonOptions); |  | ||||||
|                     eventContext.HttpContext.Response.Headers.Append("X-FileInfo", infoJson); |  | ||||||
|                 } |  | ||||||
|                 catch (Exception ex) |  | ||||||
|                 { |  | ||||||
|                     var logger = services.GetRequiredService<ILogger<TusService>>(); |  | ||||||
|                     eventContext.HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest; |  | ||||||
|                     await eventContext.HttpContext.Response.WriteAsync(ex.Message); |  | ||||||
|                     logger.LogError(ex, "Error handling file upload..."); |  | ||||||
|                 } |  | ||||||
|                 finally |  | ||||||
|                 { |  | ||||||
|                     // Dispose the stream after all processing is complete |  | ||||||
|                     await fileStream.DisposeAsync(); |  | ||||||
|                 } |  | ||||||
|             }, |  | ||||||
|             OnBeforeCreateAsync = async eventContext => |  | ||||||
|             { |  | ||||||
|                 var httpContext = eventContext.HttpContext; |  | ||||||
|                 if (httpContext.Items["CurrentUser"] is not Account currentUser) |  | ||||||
|                 { |  | ||||||
|                     eventContext.FailRequest(HttpStatusCode.Unauthorized); |  | ||||||
|                     return; |  | ||||||
|                 } |  | ||||||
|                 var accountId = Guid.Parse(currentUser.Id); |  | ||||||
|  |  | ||||||
|                 var poolId = eventContext.HttpContext.Request.Headers["X-FilePool"].FirstOrDefault(); |  | ||||||
|                 if (string.IsNullOrEmpty(poolId)) poolId = configuration["Storage:PreferredRemote"]; |  | ||||||
|                 if (!Guid.TryParse(poolId, out _)) |  | ||||||
|                 { |  | ||||||
|                     eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file pool id"); |  | ||||||
|                     return; |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 var bundleId = eventContext.HttpContext.Request.Headers["X-FileBundle"].FirstOrDefault(); |  | ||||||
|                 if (!string.IsNullOrEmpty(bundleId) && !Guid.TryParse(bundleId, out _)) |  | ||||||
|                 { |  | ||||||
|                     eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file bundle id"); |  | ||||||
|                     return; |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 var metadata = eventContext.Metadata; |  | ||||||
|                 var contentType = metadata.TryGetValue("content-type", out var ct) ? ct.GetString(Encoding.UTF8) : null; |  | ||||||
|  |  | ||||||
|                 var scope = eventContext.HttpContext.RequestServices.CreateScope(); |  | ||||||
|  |  | ||||||
|                 var rejected = false; |  | ||||||
|  |  | ||||||
|                 var fs = scope.ServiceProvider.GetRequiredService<FileService>(); |  | ||||||
|                 var pool = await fs.GetPoolAsync(Guid.Parse(poolId!)); |  | ||||||
|                 if (pool is null) |  | ||||||
|                 { |  | ||||||
|                     eventContext.FailRequest(HttpStatusCode.BadRequest, "Pool not found"); |  | ||||||
|                     rejected = true; |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 var logger = scope.ServiceProvider.GetRequiredService<ILogger<TusService>>(); |  | ||||||
|  |  | ||||||
|                 // Do the policy check |  | ||||||
|                 var policy = pool!.PolicyConfig; |  | ||||||
|                 if (!rejected && !pool.PolicyConfig.AllowEncryption) |  | ||||||
|                 { |  | ||||||
|                     var encryptPassword = eventContext.HttpContext.Request.Headers["X-FilePass"].FirstOrDefault(); |  | ||||||
|                     if (!string.IsNullOrEmpty(encryptPassword)) |  | ||||||
|                     { |  | ||||||
|                         eventContext.FailRequest( |  | ||||||
|                             HttpStatusCode.Forbidden, |  | ||||||
|                             "File encryption is not allowed in this pool" |  | ||||||
|                         ); |  | ||||||
|                         rejected = true; |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 if (!rejected && policy.AcceptTypes is not null) |  | ||||||
|                 { |  | ||||||
|                     if (string.IsNullOrEmpty(contentType)) |  | ||||||
|                     { |  | ||||||
|                         eventContext.FailRequest( |  | ||||||
|                             HttpStatusCode.BadRequest, |  | ||||||
|                             "Content type is required by the pool's policy" |  | ||||||
|                         ); |  | ||||||
|                         rejected = true; |  | ||||||
|                     } |  | ||||||
|                     else |  | ||||||
|                     { |  | ||||||
|                         var foundMatch = false; |  | ||||||
|                         foreach (var acceptType in policy.AcceptTypes) |  | ||||||
|                         { |  | ||||||
|                             if (acceptType.EndsWith("/*", StringComparison.OrdinalIgnoreCase)) |  | ||||||
|                             { |  | ||||||
|                                 var type = acceptType[..^2]; |  | ||||||
|                                 if (!contentType.StartsWith($"{type}/", StringComparison.OrdinalIgnoreCase)) continue; |  | ||||||
|                                 foundMatch = true; |  | ||||||
|                                 break; |  | ||||||
|                             } |  | ||||||
|                             else if (acceptType.Equals(contentType, StringComparison.OrdinalIgnoreCase)) |  | ||||||
|                             { |  | ||||||
|                                 foundMatch = true; |  | ||||||
|                                 break; |  | ||||||
|                             } |  | ||||||
|                         } |  | ||||||
|  |  | ||||||
|                         if (!foundMatch) |  | ||||||
|                         { |  | ||||||
|                             eventContext.FailRequest( |  | ||||||
|                                 HttpStatusCode.Forbidden, |  | ||||||
|                                 $"Content type {contentType} is not allowed by the pool's policy" |  | ||||||
|                             ); |  | ||||||
|                             rejected = true; |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 if (!rejected && policy.MaxFileSize is not null) |  | ||||||
|                 { |  | ||||||
|                     if (eventContext.UploadLength > policy.MaxFileSize) |  | ||||||
|                     { |  | ||||||
|                         eventContext.FailRequest( |  | ||||||
|                             HttpStatusCode.Forbidden, |  | ||||||
|                             $"File size {eventContext.UploadLength} is larger than the pool's maximum file size {policy.MaxFileSize}" |  | ||||||
|                         ); |  | ||||||
|                         rejected = true; |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 if (!rejected) |  | ||||||
|                 { |  | ||||||
|                     var quotaService = scope.ServiceProvider.GetRequiredService<QuotaService>(); |  | ||||||
|                     var (ok, billableUnit, quota) = await quotaService.IsFileAcceptable( |  | ||||||
|                         accountId, |  | ||||||
|                         pool.BillingConfig.CostMultiplier ?? 1.0, |  | ||||||
|                         eventContext.UploadLength |  | ||||||
|                     ); |  | ||||||
|                     if (!ok) |  | ||||||
|                     { |  | ||||||
|                         eventContext.FailRequest( |  | ||||||
|                             HttpStatusCode.Forbidden, |  | ||||||
|                             $"File size {billableUnit} MiB is exceeded the user's quota {quota} MiB" |  | ||||||
|                         ); |  | ||||||
|                         rejected = true; |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 if (rejected) |  | ||||||
|                     logger.LogInformation("File rejected #{FileId}", eventContext.FileId); |  | ||||||
|             }, |  | ||||||
|             OnCreateCompleteAsync = eventContext => |  | ||||||
|             { |  | ||||||
|                 var directUpload = eventContext.HttpContext.Request.Headers["X-DirectUpload"].FirstOrDefault(); |  | ||||||
|                 if (!string.IsNullOrEmpty(directUpload)) return Task.CompletedTask; |  | ||||||
|  |  | ||||||
|                 var gatewayUrl = configuration["GatewayUrl"]; |  | ||||||
|                 if (gatewayUrl is not null) |  | ||||||
|                     eventContext.SetUploadUrl(new Uri(gatewayUrl + "/drive/tus/" + eventContext.FileId)); |  | ||||||
|                 return Task.CompletedTask; |  | ||||||
|             }, |  | ||||||
|         } |  | ||||||
|     }; |  | ||||||
| } |  | ||||||
| @@ -27,15 +27,6 @@ | |||||||
|     "PublicKeyPath": "Keys/PublicKey.pem", |     "PublicKeyPath": "Keys/PublicKey.pem", | ||||||
|     "PrivateKeyPath": "Keys/PrivateKey.pem" |     "PrivateKeyPath": "Keys/PrivateKey.pem" | ||||||
|   }, |   }, | ||||||
|   "OidcProvider": { |  | ||||||
|     "IssuerUri": "https://nt.solian.app", |  | ||||||
|     "PublicKeyPath": "Keys/PublicKey.pem", |  | ||||||
|     "PrivateKeyPath": "Keys/PrivateKey.pem", |  | ||||||
|     "AccessTokenLifetime": "01:00:00", |  | ||||||
|     "RefreshTokenLifetime": "30.00:00:00", |  | ||||||
|     "AuthorizationCodeLifetime": "00:30:00", |  | ||||||
|     "RequireHttpsMetadata": true |  | ||||||
|   }, |  | ||||||
|   "Tus": { |   "Tus": { | ||||||
|     "StorePath": "Uploads" |     "StorePath": "Uploads" | ||||||
|   }, |   }, | ||||||
| @@ -126,9 +117,5 @@ | |||||||
|   "KnownProxies": [ |   "KnownProxies": [ | ||||||
|     "127.0.0.1", |     "127.0.0.1", | ||||||
|     "::1" |     "::1" | ||||||
|   ], |   ] | ||||||
|   "Service": { |  | ||||||
|     "Name": "DysonNetwork.Drive", |  | ||||||
|     "Url": "https://localhost:7092" |  | ||||||
|   } |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,13 +0,0 @@ | |||||||
| { |  | ||||||
|   "lockfileVersion": 1, |  | ||||||
|   "workspaces": { |  | ||||||
|     "": { |  | ||||||
|       "dependencies": { |  | ||||||
|         "highlight.js": "^11.11.1", |  | ||||||
|       }, |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   "packages": { |  | ||||||
|     "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| @@ -0,0 +1,12 @@ | |||||||
|  | using Microsoft.AspNetCore.Mvc; | ||||||
|  |  | ||||||
|  | [ApiController] | ||||||
|  | [Route("config")] | ||||||
|  | public class ConfigurationController(IConfiguration configuration) : ControllerBase | ||||||
|  | { | ||||||
|  |     [HttpGet] | ||||||
|  |     public IActionResult Get() => Ok(configuration.GetSection("Client").Get<Dictionary<string, object>>()); | ||||||
|  |  | ||||||
|  |     [HttpGet("site")] | ||||||
|  |     public IActionResult GetSiteUrl() => Ok(configuration["SiteUrl"]); | ||||||
|  | } | ||||||
							
								
								
									
										23
									
								
								DysonNetwork.Gateway/Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								DysonNetwork.Gateway/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.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"] | ||||||
							
								
								
									
										18
									
								
								DysonNetwork.Gateway/DysonNetwork.Gateway.csproj
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								DysonNetwork.Gateway/DysonNetwork.Gateway.csproj
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | |||||||
|  | <Project Sdk="Microsoft.NET.Sdk.Web"> | ||||||
|  |  | ||||||
|  |   <PropertyGroup> | ||||||
|  |     <TargetFramework>net9.0</TargetFramework> | ||||||
|  |     <Nullable>enable</Nullable> | ||||||
|  |     <ImplicitUsings>enable</ImplicitUsings> | ||||||
|  |   </PropertyGroup> | ||||||
|  |  | ||||||
|  |   <ItemGroup> | ||||||
|  |     <PackageReference Include="Microsoft.Extensions.ServiceDiscovery.Yarp" Version="9.5.2" /> | ||||||
|  |     <PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" /> | ||||||
|  |   </ItemGroup> | ||||||
|  |  | ||||||
|  |   <ItemGroup> | ||||||
|  |       <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> | ||||||
|  |   </ItemGroup> | ||||||
|  |  | ||||||
|  | </Project> | ||||||
							
								
								
									
										168
									
								
								DysonNetwork.Gateway/Program.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										168
									
								
								DysonNetwork.Gateway/Program.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,168 @@ | |||||||
|  | using System.Threading.RateLimiting; | ||||||
|  | using DysonNetwork.Shared.Http; | ||||||
|  | using Yarp.ReverseProxy.Configuration; | ||||||
|  | using Microsoft.AspNetCore.HttpOverrides; | ||||||
|  |  | ||||||
|  | var builder = WebApplication.CreateBuilder(args); | ||||||
|  |  | ||||||
|  | builder.AddServiceDefaults(); | ||||||
|  |  | ||||||
|  | builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxValue, enableGrpc: false); | ||||||
|  |  | ||||||
|  | builder.Services.AddCors(options => | ||||||
|  | { | ||||||
|  |     options.AddDefaultPolicy( | ||||||
|  |         policy => | ||||||
|  |         { | ||||||
|  |             policy.SetIsOriginAllowed(origin => true) | ||||||
|  |                 .AllowAnyMethod() | ||||||
|  |                 .AllowAnyHeader() | ||||||
|  |                 .AllowCredentials() | ||||||
|  |                 .WithExposedHeaders("X-Total"); | ||||||
|  |         }); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | builder.Services.AddRateLimiter(options => | ||||||
|  | { | ||||||
|  |     options.AddPolicy("fixed", context => | ||||||
|  |     { | ||||||
|  |         var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; | ||||||
|  |  | ||||||
|  |         return RateLimitPartition.GetFixedWindowLimiter( | ||||||
|  |             partitionKey: ip, | ||||||
|  |             factory: _ => new FixedWindowRateLimiterOptions | ||||||
|  |             { | ||||||
|  |                 PermitLimit = 120, // 120 requests... | ||||||
|  |                 Window = TimeSpan.FromMinutes(1), // ...per minute per IP | ||||||
|  |                 QueueProcessingOrder = QueueProcessingOrder.OldestFirst, | ||||||
|  |                 QueueLimit = 10 // allow short bursts instead of instant 503s | ||||||
|  |             }); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     options.OnRejected = async (context, token) => | ||||||
|  |         { | ||||||
|  |             // Log the rejected IP | ||||||
|  |             var logger = context.HttpContext.RequestServices | ||||||
|  |                 .GetRequiredService<ILoggerFactory>() | ||||||
|  |                 .CreateLogger("RateLimiter"); | ||||||
|  |  | ||||||
|  |             var ip = context.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; | ||||||
|  |             logger.LogWarning("Rate limit exceeded for IP: {IP}", ip); | ||||||
|  |  | ||||||
|  |             // Respond to the client | ||||||
|  |             context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; | ||||||
|  |             await context.HttpContext.Response.WriteAsync( | ||||||
|  |                 "Rate limit exceeded. Try again later.", token); | ||||||
|  |         }; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | var serviceNames = new[] { "ring", "pass", "drive", "sphere", "develop", "insight" }; | ||||||
|  |  | ||||||
|  | var specialRoutes = new[] | ||||||
|  | { | ||||||
|  |     new RouteConfig | ||||||
|  |     { | ||||||
|  |         RouteId = "ring-ws", | ||||||
|  |         ClusterId = "ring", | ||||||
|  |         Match = new RouteMatch { Path = "/ws" } | ||||||
|  |     }, | ||||||
|  |     new RouteConfig | ||||||
|  |     { | ||||||
|  |         RouteId = "pass-openid", | ||||||
|  |         ClusterId = "pass", | ||||||
|  |         Match = new RouteMatch { Path = "/.well-known/openid-configuration" } | ||||||
|  |     }, | ||||||
|  |     new RouteConfig | ||||||
|  |     { | ||||||
|  |         RouteId = "pass-jwks", | ||||||
|  |         ClusterId = "pass", | ||||||
|  |         Match = new RouteMatch { Path = "/.well-known/jwks" } | ||||||
|  |     }, | ||||||
|  |     new RouteConfig | ||||||
|  |     { | ||||||
|  |         RouteId = "drive-tus", | ||||||
|  |         ClusterId = "drive", | ||||||
|  |         Match = new RouteMatch { Path = "/api/tus" } | ||||||
|  |     } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | var apiRoutes = serviceNames.Select(serviceName => | ||||||
|  | { | ||||||
|  |     var apiPath = serviceName switch | ||||||
|  |     { | ||||||
|  |         _ => $"/{serviceName}" | ||||||
|  |     }; | ||||||
|  |     return new RouteConfig | ||||||
|  |     { | ||||||
|  |         RouteId = $"{serviceName}-api", | ||||||
|  |         ClusterId = serviceName, | ||||||
|  |         Match = new RouteMatch { Path = $"{apiPath}/{{**catch-all}}" }, | ||||||
|  |         Transforms = | ||||||
|  |         [ | ||||||
|  |             new Dictionary<string, string> { { "PathRemovePrefix", apiPath } }, | ||||||
|  |             new Dictionary<string, string> { { "PathPrefix", "/api" } } | ||||||
|  |         ] | ||||||
|  |     }; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | var swaggerRoutes = serviceNames.Select(serviceName => new RouteConfig | ||||||
|  | { | ||||||
|  |     RouteId = $"{serviceName}-swagger", | ||||||
|  |     ClusterId = serviceName, | ||||||
|  |     Match = new RouteMatch { Path = $"/swagger/{serviceName}/{{**catch-all}}" }, | ||||||
|  |     Transforms = | ||||||
|  |     [ | ||||||
|  |         new Dictionary<string, string> { { "PathRemovePrefix", $"/swagger/{serviceName}" } }, | ||||||
|  |         new Dictionary<string, string> { { "PathPrefix", "/swagger" } } | ||||||
|  |     ] | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | var routes = specialRoutes.Concat(apiRoutes).Concat(swaggerRoutes).ToArray(); | ||||||
|  |  | ||||||
|  | var clusters = serviceNames.Select(serviceName => new ClusterConfig | ||||||
|  | { | ||||||
|  |     ClusterId = serviceName, | ||||||
|  |     HealthCheck = new HealthCheckConfig | ||||||
|  |     { | ||||||
|  |         Active = new ActiveHealthCheckConfig | ||||||
|  |         { | ||||||
|  |             Enabled = true, | ||||||
|  |             Interval = TimeSpan.FromSeconds(10), | ||||||
|  |             Timeout = TimeSpan.FromSeconds(5), | ||||||
|  |             Path = "/health" | ||||||
|  |         }, | ||||||
|  |         Passive = new() | ||||||
|  |         { | ||||||
|  |             Enabled = true | ||||||
|  |         } | ||||||
|  |     }, | ||||||
|  |     Destinations = new Dictionary<string, DestinationConfig> | ||||||
|  |     { | ||||||
|  |         { "destination1", new DestinationConfig { Address = $"http://{serviceName}" } } | ||||||
|  |     } | ||||||
|  | }).ToArray(); | ||||||
|  |  | ||||||
|  | builder.Services | ||||||
|  |     .AddReverseProxy() | ||||||
|  |     .LoadFromMemory(routes, clusters) | ||||||
|  |     .AddServiceDiscoveryDestinationResolver(); | ||||||
|  |  | ||||||
|  | builder.Services.AddControllers(); | ||||||
|  |  | ||||||
|  | var app = builder.Build(); | ||||||
|  |  | ||||||
|  | var forwardedHeadersOptions = new ForwardedHeadersOptions | ||||||
|  | { | ||||||
|  |     ForwardedHeaders = ForwardedHeaders.All | ||||||
|  | }; | ||||||
|  | forwardedHeadersOptions.KnownNetworks.Clear(); | ||||||
|  | forwardedHeadersOptions.KnownProxies.Clear(); | ||||||
|  | app.UseForwardedHeaders(forwardedHeadersOptions); | ||||||
|  |  | ||||||
|  | app.UseCors(); | ||||||
|  |  | ||||||
|  | app.MapReverseProxy().RequireRateLimiting("fixed"); | ||||||
|  |  | ||||||
|  | app.MapControllers(); | ||||||
|  |  | ||||||
|  | app.Run(); | ||||||
							
								
								
									
										21
									
								
								DysonNetwork.Gateway/Properties/launchSettings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								DysonNetwork.Gateway/Properties/launchSettings.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | |||||||
|  | { | ||||||
|  |   "$schema": "https://json.schemastore.org/launchsettings.json", | ||||||
|  |   "profiles": { | ||||||
|  |     "http": { | ||||||
|  |       "commandName": "Project", | ||||||
|  |       "dotnetRunMessages": true, | ||||||
|  |       "launchBrowser": true, | ||||||
|  |       "environmentVariables": { | ||||||
|  |         "ASPNETCORE_ENVIRONMENT": "Development" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "https": { | ||||||
|  |       "commandName": "Project", | ||||||
|  |       "dotnetRunMessages": true, | ||||||
|  |       "launchBrowser": true, | ||||||
|  |       "environmentVariables": { | ||||||
|  |         "ASPNETCORE_ENVIRONMENT": "Development" | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										13
									
								
								DysonNetwork.Gateway/appsettings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								DysonNetwork.Gateway/appsettings.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | |||||||
|  | { | ||||||
|  |   "Logging": { | ||||||
|  |     "LogLevel": { | ||||||
|  |       "Default": "Information", | ||||||
|  |       "Microsoft.AspNetCore": "Warning" | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   "AllowedHosts": "*", | ||||||
|  |   "SiteUrl": "http://localhost:3000", | ||||||
|  |   "Client": { | ||||||
|  |     "SomeSetting": "SomeValue" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										76
									
								
								DysonNetwork.Insight/AppDatabase.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								DysonNetwork.Insight/AppDatabase.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,76 @@ | |||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using Microsoft.EntityFrameworkCore.Design; | ||||||
|  | using NodaTime; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Insight; | ||||||
|  |  | ||||||
|  | public class AppDatabase( | ||||||
|  |     DbContextOptions<AppDatabase> options, | ||||||
|  |     IConfiguration configuration | ||||||
|  | ) : DbContext(options) | ||||||
|  | { | ||||||
|  |     public DbSet<SnThinkingSequence> ThinkingSequences { get; set; } | ||||||
|  |     public DbSet<SnThinkingThought> ThinkingThoughts { get; set; } | ||||||
|  |      | ||||||
|  |     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); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) | ||||||
|  |     { | ||||||
|  |         var now = SystemClock.Instance.GetCurrentInstant(); | ||||||
|  |  | ||||||
|  |         foreach (var entry in ChangeTracker.Entries<ModelBase>()) | ||||||
|  |         { | ||||||
|  |             switch (entry.State) | ||||||
|  |             { | ||||||
|  |                 case EntityState.Added: | ||||||
|  |                     entry.Entity.CreatedAt = now; | ||||||
|  |                     entry.Entity.UpdatedAt = now; | ||||||
|  |                     break; | ||||||
|  |                 case EntityState.Modified: | ||||||
|  |                     entry.Entity.UpdatedAt = now; | ||||||
|  |                     break; | ||||||
|  |                 case EntityState.Deleted: | ||||||
|  |                     entry.State = EntityState.Modified; | ||||||
|  |                     entry.Entity.DeletedAt = now; | ||||||
|  |                     break; | ||||||
|  |                 case EntityState.Detached: | ||||||
|  |                 case EntityState.Unchanged: | ||||||
|  |                 default: | ||||||
|  |                     break; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return await base.SaveChangesAsync(cancellationToken); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     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); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										27
									
								
								DysonNetwork.Insight/Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								DysonNetwork.Insight/Dockerfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | |||||||
|  | #See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. | ||||||
|  |  | ||||||
|  | FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base | ||||||
|  | USER app | ||||||
|  | WORKDIR /app | ||||||
|  | EXPOSE 8080 | ||||||
|  | EXPOSE 8081 | ||||||
|  |  | ||||||
|  | FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build | ||||||
|  | ARG BUILD_CONFIGURATION=Release | ||||||
|  | WORKDIR /src | ||||||
|  | COPY ["DysonNetwork.Insight/DysonNetwork.Insight.csproj", "DysonNetwork.Insight/"] | ||||||
|  | COPY ["DysonNetwork.Shared/DysonNetwork.Shared.csproj", "DysonNetwork.Shared/"] | ||||||
|  | COPY ["DysonNetwork.Develop/DysonNetwork.Develop.csproj", "DysonNetwork.Develop/"] | ||||||
|  | RUN dotnet restore "DysonNetwork.Insight/DysonNetwork.Insight.csproj" | ||||||
|  | COPY . . | ||||||
|  | WORKDIR "/src/DysonNetwork.Insight" | ||||||
|  | RUN dotnet build "DysonNetwork.Insight.csproj" -c $BUILD_CONFIGURATION -o /app/build | ||||||
|  |  | ||||||
|  | FROM build AS publish | ||||||
|  | ARG BUILD_CONFIGURATION=Release | ||||||
|  | RUN dotnet publish "DysonNetwork.Insight.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false | ||||||
|  |  | ||||||
|  | FROM base AS final | ||||||
|  | WORKDIR /app | ||||||
|  | COPY --from=publish /app/publish . | ||||||
|  | ENTRYPOINT ["dotnet", "DysonNetwork.Insight.dll"] | ||||||
							
								
								
									
										30
									
								
								DysonNetwork.Insight/DysonNetwork.Insight.csproj
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								DysonNetwork.Insight/DysonNetwork.Insight.csproj
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | |||||||
|  | <Project Sdk="Microsoft.NET.Sdk.Web"> | ||||||
|  |  | ||||||
|  |   <PropertyGroup> | ||||||
|  |     <TargetFramework>net9.0</TargetFramework> | ||||||
|  |     <Nullable>enable</Nullable> | ||||||
|  |     <ImplicitUsings>enable</ImplicitUsings> | ||||||
|  |   </PropertyGroup> | ||||||
|  |  | ||||||
|  |   <ItemGroup> | ||||||
|  |     <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> | ||||||
|  |     <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" /> | ||||||
|  |     <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10"> | ||||||
|  |       <PrivateAssets>all</PrivateAssets> | ||||||
|  |       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||||
|  |     </PackageReference> | ||||||
|  |     <PackageReference Include="Microsoft.SemanticKernel" Version="1.66.0" /> | ||||||
|  |     <PackageReference Include="Microsoft.SemanticKernel.Connectors.Ollama" Version="1.66.0-alpha" /> | ||||||
|  |     <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> | ||||||
|  |     <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" /> | ||||||
|  |   </ItemGroup> | ||||||
|  |  | ||||||
|  |   <ItemGroup> | ||||||
|  |     <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> | ||||||
|  |   </ItemGroup> | ||||||
|  |  | ||||||
|  |   <ItemGroup> | ||||||
|  |     <Folder Include="Controllers\" /> | ||||||
|  |   </ItemGroup> | ||||||
|  |  | ||||||
|  | </Project> | ||||||
							
								
								
									
										124
									
								
								DysonNetwork.Insight/Migrations/20251025115921_AddThinkingThought.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								DysonNetwork.Insight/Migrations/20251025115921_AddThinkingThought.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,124 @@ | |||||||
|  | // <auto-generated /> | ||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using DysonNetwork.Insight; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | 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.Insight.Migrations | ||||||
|  | { | ||||||
|  |     [DbContext(typeof(AppDatabase))] | ||||||
|  |     [Migration("20251025115921_AddThinkingThought")] | ||||||
|  |     partial class AddThinkingThought | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void BuildTargetModel(ModelBuilder modelBuilder) | ||||||
|  |         { | ||||||
|  | #pragma warning disable 612, 618 | ||||||
|  |             modelBuilder | ||||||
|  |                 .HasAnnotation("ProductVersion", "9.0.10") | ||||||
|  |                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||||
|  |  | ||||||
|  |             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingSequence", 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>("Topic") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("topic"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_thinking_sequences"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("thinking_sequences", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Content") | ||||||
|  |                         .HasColumnType("text") | ||||||
|  |                         .HasColumnName("content"); | ||||||
|  |  | ||||||
|  |                     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<List<SnCloudFileReferenceObject>>("Files") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("files"); | ||||||
|  |  | ||||||
|  |                     b.Property<int>("Role") | ||||||
|  |                         .HasColumnType("integer") | ||||||
|  |                         .HasColumnName("role"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("SequenceId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("sequence_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_thinking_thoughts"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("SequenceId") | ||||||
|  |                         .HasDatabaseName("ix_thinking_thoughts_sequence_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("thinking_thoughts", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Shared.Models.SnThinkingSequence", "Sequence") | ||||||
|  |                         .WithMany() | ||||||
|  |                         .HasForeignKey("SequenceId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasConstraintName("fk_thinking_thoughts_thinking_sequences_sequence_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Sequence"); | ||||||
|  |                 }); | ||||||
|  | #pragma warning restore 612, 618 | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,73 @@ | |||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  | using NodaTime; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Insight.Migrations | ||||||
|  | { | ||||||
|  |     /// <inheritdoc /> | ||||||
|  |     public partial class AddThinkingThought : Migration | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Up(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.CreateTable( | ||||||
|  |                 name: "thinking_sequences", | ||||||
|  |                 columns: table => new | ||||||
|  |                 { | ||||||
|  |                     id = table.Column<Guid>(type: "uuid", nullable: false), | ||||||
|  |                     topic = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true), | ||||||
|  |                     account_id = table.Column<Guid>(type: "uuid", nullable: false), | ||||||
|  |                     created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), | ||||||
|  |                     updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), | ||||||
|  |                     deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true) | ||||||
|  |                 }, | ||||||
|  |                 constraints: table => | ||||||
|  |                 { | ||||||
|  |                     table.PrimaryKey("pk_thinking_sequences", x => x.id); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             migrationBuilder.CreateTable( | ||||||
|  |                 name: "thinking_thoughts", | ||||||
|  |                 columns: table => new | ||||||
|  |                 { | ||||||
|  |                     id = table.Column<Guid>(type: "uuid", nullable: false), | ||||||
|  |                     content = table.Column<string>(type: "text", nullable: true), | ||||||
|  |                     files = table.Column<List<SnCloudFileReferenceObject>>(type: "jsonb", nullable: false), | ||||||
|  |                     role = table.Column<int>(type: "integer", nullable: false), | ||||||
|  |                     sequence_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_thinking_thoughts", x => x.id); | ||||||
|  |                     table.ForeignKey( | ||||||
|  |                         name: "fk_thinking_thoughts_thinking_sequences_sequence_id", | ||||||
|  |                         column: x => x.sequence_id, | ||||||
|  |                         principalTable: "thinking_sequences", | ||||||
|  |                         principalColumn: "id", | ||||||
|  |                         onDelete: ReferentialAction.Cascade); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             migrationBuilder.CreateIndex( | ||||||
|  |                 name: "ix_thinking_thoughts_sequence_id", | ||||||
|  |                 table: "thinking_thoughts", | ||||||
|  |                 column: "sequence_id"); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.DropTable( | ||||||
|  |                 name: "thinking_thoughts"); | ||||||
|  |  | ||||||
|  |             migrationBuilder.DropTable( | ||||||
|  |                 name: "thinking_sequences"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										121
									
								
								DysonNetwork.Insight/Migrations/AppDatabaseModelSnapshot.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								DysonNetwork.Insight/Migrations/AppDatabaseModelSnapshot.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,121 @@ | |||||||
|  | // <auto-generated /> | ||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using DysonNetwork.Insight; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
|  | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||||
|  | using NodaTime; | ||||||
|  | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Insight.Migrations | ||||||
|  | { | ||||||
|  |     [DbContext(typeof(AppDatabase))] | ||||||
|  |     partial class AppDatabaseModelSnapshot : ModelSnapshot | ||||||
|  |     { | ||||||
|  |         protected override void BuildModel(ModelBuilder modelBuilder) | ||||||
|  |         { | ||||||
|  | #pragma warning disable 612, 618 | ||||||
|  |             modelBuilder | ||||||
|  |                 .HasAnnotation("ProductVersion", "9.0.10") | ||||||
|  |                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||||
|  |  | ||||||
|  |             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingSequence", 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>("Topic") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("topic"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_thinking_sequences"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("thinking_sequences", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Content") | ||||||
|  |                         .HasColumnType("text") | ||||||
|  |                         .HasColumnName("content"); | ||||||
|  |  | ||||||
|  |                     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<List<SnCloudFileReferenceObject>>("Files") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("files"); | ||||||
|  |  | ||||||
|  |                     b.Property<int>("Role") | ||||||
|  |                         .HasColumnType("integer") | ||||||
|  |                         .HasColumnName("role"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("SequenceId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("sequence_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_thinking_thoughts"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("SequenceId") | ||||||
|  |                         .HasDatabaseName("ix_thinking_thoughts_sequence_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("thinking_thoughts", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Shared.Models.SnThinkingSequence", "Sequence") | ||||||
|  |                         .WithMany() | ||||||
|  |                         .HasForeignKey("SequenceId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasConstraintName("fk_thinking_thoughts_thinking_sequences_sequence_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("Sequence"); | ||||||
|  |                 }); | ||||||
|  | #pragma warning restore 612, 618 | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										44
									
								
								DysonNetwork.Insight/Program.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								DysonNetwork.Insight/Program.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | |||||||
|  | using DysonNetwork.Insight; | ||||||
|  | using DysonNetwork.Insight.Startup; | ||||||
|  | using DysonNetwork.Shared.Auth; | ||||||
|  | using DysonNetwork.Shared.Http; | ||||||
|  | using DysonNetwork.Shared.Registry; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  |  | ||||||
|  | var builder = WebApplication.CreateBuilder(args); | ||||||
|  |  | ||||||
|  | builder.AddServiceDefaults(); | ||||||
|  |  | ||||||
|  | builder.ConfigureAppKestrel(builder.Configuration); | ||||||
|  |  | ||||||
|  | builder.Services.AddControllers(); | ||||||
|  | builder.Services.AddAppServices(); | ||||||
|  | builder.Services.AddAppAuthentication(); | ||||||
|  | builder.Services.AddAppFlushHandlers(); | ||||||
|  | builder.Services.AddAppBusinessServices(); | ||||||
|  |  | ||||||
|  | builder.Services.AddDysonAuth(); | ||||||
|  | builder.Services.AddAccountService(); | ||||||
|  | builder.Services.AddSphereService(); | ||||||
|  | builder.Services.AddThinkingServices(builder.Configuration); | ||||||
|  |  | ||||||
|  | builder.AddSwaggerManifest( | ||||||
|  |     "DysonNetwork.Insight", | ||||||
|  |     "The insight service in the Solar Network." | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | 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.UseSwaggerManifest("DysonNetwork.Insight"); | ||||||
|  |  | ||||||
|  | app.Run(); | ||||||
							
								
								
									
										21
									
								
								DysonNetwork.Insight/Properties/launchSettings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								DysonNetwork.Insight/Properties/launchSettings.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | |||||||
|  | { | ||||||
|  |   "$schema": "https://json.schemastore.org/launchsettings.json", | ||||||
|  |   "profiles": { | ||||||
|  |     "http": { | ||||||
|  |       "commandName": "Project", | ||||||
|  |       "dotnetRunMessages": true, | ||||||
|  |       "launchBrowser": false, | ||||||
|  |       "environmentVariables": { | ||||||
|  |         "ASPNETCORE_ENVIRONMENT": "Development" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "https": { | ||||||
|  |       "commandName": "Project", | ||||||
|  |       "dotnetRunMessages": true, | ||||||
|  |       "launchBrowser": false, | ||||||
|  |       "environmentVariables": { | ||||||
|  |         "ASPNETCORE_ENVIRONMENT": "Development" | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										22
									
								
								DysonNetwork.Insight/Startup/ApplicationConfiguration.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								DysonNetwork.Insight/Startup/ApplicationConfiguration.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | |||||||
|  | using DysonNetwork.Shared.Http; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Insight.Startup; | ||||||
|  |  | ||||||
|  | public static class ApplicationConfiguration | ||||||
|  | { | ||||||
|  |     public static WebApplication ConfigureAppMiddleware(this WebApplication app, IConfiguration configuration) | ||||||
|  |     { | ||||||
|  |         app.MapOpenApi(); | ||||||
|  |  | ||||||
|  |         app.UseRequestLocalization(); | ||||||
|  |  | ||||||
|  |         app.ConfigureForwardedHeaders(configuration); | ||||||
|  |  | ||||||
|  |         app.UseAuthentication(); | ||||||
|  |         app.UseAuthorization(); | ||||||
|  |  | ||||||
|  |         app.MapControllers(); | ||||||
|  |  | ||||||
|  |         return app; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										73
									
								
								DysonNetwork.Insight/Startup/ServiceCollectionExtensions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								DysonNetwork.Insight/Startup/ServiceCollectionExtensions.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | |||||||
|  | using System.Text.Json; | ||||||
|  | using System.Text.Json.Serialization; | ||||||
|  | using DysonNetwork.Insight.Thought; | ||||||
|  | using DysonNetwork.Shared.Cache; | ||||||
|  | using Microsoft.SemanticKernel; | ||||||
|  | using NodaTime; | ||||||
|  | using NodaTime.Serialization.SystemTextJson; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Insight.Startup; | ||||||
|  |  | ||||||
|  | public static class ServiceCollectionExtensions | ||||||
|  | { | ||||||
|  |     public static IServiceCollection AddAppServices(this IServiceCollection services) | ||||||
|  |     { | ||||||
|  |         services.AddDbContext<AppDatabase>(); | ||||||
|  |         services.AddSingleton<IClock>(SystemClock.Instance); | ||||||
|  |         services.AddHttpContextAccessor(); | ||||||
|  |         services.AddSingleton<ICacheService, CacheServiceRedis>(); | ||||||
|  |  | ||||||
|  |         services.AddHttpClient(); | ||||||
|  |  | ||||||
|  |         // Register gRPC services | ||||||
|  |         services.AddGrpc(options => | ||||||
|  |         { | ||||||
|  |             options.EnableDetailedErrors = true; // Will be adjusted in Program.cs | ||||||
|  |             options.MaxReceiveMessageSize = 16 * 1024 * 1024; // 16MB | ||||||
|  |             options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         // Register gRPC reflection for service discovery | ||||||
|  |         services.AddGrpc(); | ||||||
|  |  | ||||||
|  |         // Register gRPC services | ||||||
|  |  | ||||||
|  |         // Register OIDC services | ||||||
|  |         services.AddControllers().AddJsonOptions(options => | ||||||
|  |         { | ||||||
|  |             options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals; | ||||||
|  |             options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; | ||||||
|  |             options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower; | ||||||
|  |  | ||||||
|  |             options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         return services; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public static IServiceCollection AddAppAuthentication(this IServiceCollection services) | ||||||
|  |     { | ||||||
|  |         services.AddAuthorization(); | ||||||
|  |         return services; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public static IServiceCollection AddAppFlushHandlers(this IServiceCollection services) | ||||||
|  |     { | ||||||
|  |         services.AddSingleton<FlushBufferService>(); | ||||||
|  |  | ||||||
|  |         return services; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public static IServiceCollection AddAppBusinessServices(this IServiceCollection services) | ||||||
|  |     { | ||||||
|  |         return services; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public static IServiceCollection AddThinkingServices(this IServiceCollection services, IConfiguration configuration) | ||||||
|  |     { | ||||||
|  |         services.AddSingleton<ThoughtProvider>(); | ||||||
|  |         services.AddScoped<ThoughtService>(); | ||||||
|  |  | ||||||
|  |         return services; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										137
									
								
								DysonNetwork.Insight/Thought/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								DysonNetwork.Insight/Thought/README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,137 @@ | |||||||
|  | # DysonNetwork Insight Thought API | ||||||
|  |  | ||||||
|  | The Thought API provides conversational AI capabilities for users of the Solar Network. It allows users to engage in chat-like conversations with an AI assistant powered by semantic kernel and connected to various tools. | ||||||
|  |  | ||||||
|  | This service is handled by the Insight, when using with the Gateway, the `/api` should be replaced with `/insight` | ||||||
|  |  | ||||||
|  | ## Features | ||||||
|  |  | ||||||
|  | - Streaming chat responses using Server-Sent Events (SSE) | ||||||
|  | - Conversation context management with sequences | ||||||
|  | - Caching for improved performance | ||||||
|  | - Authentication required for all operations | ||||||
|  |  | ||||||
|  | ## Endpoints | ||||||
|  |  | ||||||
|  | ### POST /api/thought | ||||||
|  |  | ||||||
|  | Initiates or continues a chat conversation. | ||||||
|  |  | ||||||
|  | #### Parameters | ||||||
|  | - `UserMessage` (string, required): The message from the user | ||||||
|  | - `SequenceId` (Guid, optional): ID of existing conversation sequence. If not provided, a new sequence is created. | ||||||
|  |  | ||||||
|  | #### Response | ||||||
|  | - Content-Type: `text/event-stream` | ||||||
|  | - Streaming response with assistant messages | ||||||
|  | - Status: 401 if not authenticated | ||||||
|  | - Status: 403 if sequence doesn't belong to user | ||||||
|  |  | ||||||
|  | #### Example Usage | ||||||
|  | ```bash | ||||||
|  | curl -X POST "http://localhost:5000/api/thought" \ | ||||||
|  |   -H "Content-Type: application/json" \ | ||||||
|  |   -d '{ | ||||||
|  |     "UserMessage": "Hello, how can I help with the Solar Network?", | ||||||
|  |     "SequenceId": null | ||||||
|  |   }' | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### GET /api/thought/sequences | ||||||
|  |  | ||||||
|  | Lists all thinking sequences for the authenticated user. | ||||||
|  |  | ||||||
|  | #### Parameters | ||||||
|  | - `offset` (int, default 0): Number of sequences to skip for pagination | ||||||
|  | - `take` (int, default 20): Maximum number of sequences to return | ||||||
|  |  | ||||||
|  | #### Response | ||||||
|  | - `200 OK`: Array of `SnThinkingSequence` | ||||||
|  | - `401 Unauthorized`: If not authenticated | ||||||
|  | - Headers: | ||||||
|  |   - `X-Total`: Total number of sequences before pagination | ||||||
|  |  | ||||||
|  | #### Example Usage | ||||||
|  | ```bash | ||||||
|  | curl -X GET "http://localhost:5000/api/thought/sequences?take=10" | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### GET /api/thought/sequences/{sequenceId} | ||||||
|  |  | ||||||
|  | Retrieves all thoughts (messages) in a specific conversation sequence. | ||||||
|  |  | ||||||
|  | #### Parameters | ||||||
|  | - `sequenceId` (Guid, path): ID of the sequence to retrieve | ||||||
|  |  | ||||||
|  | #### Response | ||||||
|  | - `200 OK`: Array of `SnThinkingThought` ordered by creation date | ||||||
|  | - `401 Unauthorized`: If not authenticated | ||||||
|  | - `404 Not Found`: If sequence doesn't exist or doesn't belong to user | ||||||
|  |  | ||||||
|  | #### Example Usage | ||||||
|  | ```bash | ||||||
|  | curl -X GET "http://localhost:5000/api/thought/sequences/12345678-1234-1234-1234-123456789abc" | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## Data Models | ||||||
|  |  | ||||||
|  | ### StreamThinkingRequest | ||||||
|  | ```csharp | ||||||
|  | { | ||||||
|  |   string UserMessage, // Required | ||||||
|  |   Guid? SequenceId    // Optional | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### SnThinkingSequence | ||||||
|  | ```csharp | ||||||
|  | { | ||||||
|  |   Guid Id, | ||||||
|  |   string? Topic, | ||||||
|  |   Guid AccountId | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### SnThinkingThought | ||||||
|  | ```csharp | ||||||
|  | { | ||||||
|  |   Guid Id, | ||||||
|  |   string? Content, | ||||||
|  |   List<SnCloudFileReferenceObject> Files, | ||||||
|  |   ThinkingThoughtRole Role, | ||||||
|  |   Guid SequenceId, | ||||||
|  |   SnThinkingSequence Sequence | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### ThinkingThoughtRole (enum) | ||||||
|  | - `Assistant` | ||||||
|  | - `User` | ||||||
|  |  | ||||||
|  | ## Caching | ||||||
|  |  | ||||||
|  | The API uses Redis-based caching for conversation thoughts: | ||||||
|  | - Thoughts are cached for 10 minutes with group-based invalidation | ||||||
|  | - Cache is invalidated when new thoughts are added to a sequence | ||||||
|  | - Improves performance for accessing conversation history | ||||||
|  |  | ||||||
|  | ## Authentication | ||||||
|  |  | ||||||
|  | All endpoints require authentication through the current user session. Sequence access is validated against the authenticated user's account ID. | ||||||
|  |  | ||||||
|  | ## Error Responses | ||||||
|  |  | ||||||
|  | - `401 Unauthorized`: Authentication required | ||||||
|  | - `403 Forbidden`: Access denied (sequence ownership) | ||||||
|  | - `404 Not Found`: Resource not found | ||||||
|  |  | ||||||
|  | ## Streaming Details | ||||||
|  |  | ||||||
|  | The POST endpoint returns a stream of assistant responses using Server-Sent Events format. Clients should handle the streaming response and display messages incrementally. | ||||||
|  |  | ||||||
|  | ## Implementation Notes | ||||||
|  |  | ||||||
|  | - Built with ASP.NET Core and Semantic Kernel | ||||||
|  | - Uses PostgreSQL via Entity Framework Core | ||||||
|  | - Integrated with Ollama for AI completion | ||||||
|  | - Caching via Redis | ||||||
							
								
								
									
										189
									
								
								DysonNetwork.Insight/Thought/ThoughtController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										189
									
								
								DysonNetwork.Insight/Thought/ThoughtController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,189 @@ | |||||||
|  | using System.ComponentModel.DataAnnotations; | ||||||
|  | using System.Text; | ||||||
|  | using System.Text.Json; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  | using Microsoft.AspNetCore.Mvc; | ||||||
|  | using Microsoft.SemanticKernel; | ||||||
|  | using Microsoft.SemanticKernel.ChatCompletion; | ||||||
|  | using Microsoft.SemanticKernel.Connectors.Ollama; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Insight.Thought; | ||||||
|  |  | ||||||
|  | [ApiController] | ||||||
|  | [Route("/api/thought")] | ||||||
|  | public class ThoughtController(ThoughtProvider provider, ThoughtService service) : ControllerBase | ||||||
|  | { | ||||||
|  |     public class StreamThinkingRequest | ||||||
|  |     { | ||||||
|  |         [Required] public string UserMessage { get; set; } = null!; | ||||||
|  |         public Guid? SequenceId { get; set; } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [HttpPost] | ||||||
|  |     public async Task<ActionResult> Think([FromBody] StreamThinkingRequest request) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |  | ||||||
|  |         // Generate topic if creating new sequence | ||||||
|  |         string? topic = null; | ||||||
|  |         if (!request.SequenceId.HasValue) | ||||||
|  |         { | ||||||
|  |             // Use AI to summarize topic from user message | ||||||
|  |             var summaryHistory = new ChatHistory( | ||||||
|  |                 "You are a helpful assistant. Summarize the following user message into a concise topic title (max 100 characters). Direct give the topic you summerized, do not add extra preifx / suffix." | ||||||
|  |             ); | ||||||
|  |             summaryHistory.AddUserMessage(request.UserMessage); | ||||||
|  |  | ||||||
|  |             var summaryResult = await provider.Kernel | ||||||
|  |                 .GetRequiredService<IChatCompletionService>() | ||||||
|  |                 .GetChatMessageContentAsync(summaryHistory); | ||||||
|  |  | ||||||
|  |             topic = summaryResult.Content?[..Math.Min(summaryResult.Content.Length, 4096)]; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Handle sequence | ||||||
|  |         var sequence = await service.GetOrCreateSequenceAsync(accountId, request.SequenceId, topic); | ||||||
|  |         if (sequence == null) return Forbid(); // or NotFound | ||||||
|  |  | ||||||
|  |         // Save user thought | ||||||
|  |         await service.SaveThoughtAsync(sequence, request.UserMessage, ThinkingThoughtRole.User); | ||||||
|  |  | ||||||
|  |         // Build chat history | ||||||
|  |         var chatHistory = new ChatHistory( | ||||||
|  |             "You're a helpful assistant on the Solar Network, a social network.\n" + | ||||||
|  |             "Your name is Sn-chan (or SN 酱 in chinese), a cute sweet heart with passion for almost everything.\n" + | ||||||
|  |             "When you talk to user, you can add some modal particles and emoticons to your response to be cute, but prevent use a lot of emojis." + | ||||||
|  |             "Your father (creator) is @littlesheep. (prefer calling him 父亲 in chinese)\n" + | ||||||
|  |             "\n" + | ||||||
|  |             "The ID on the Solar Network is UUID, so mostly hard to read, so do not show ID to user unless user ask to do so or necessary.\n"+ | ||||||
|  |             "\n" + | ||||||
|  |             "Your aim is to helping solving questions for the users on the Solar Network.\n" + | ||||||
|  |             "And the Solar Network is the social network platform you live on.\n" + | ||||||
|  |             "When the user asks questions about the Solar Network (also known as SN and Solian), try use the tools you have to get latest and accurate data." | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |         chatHistory.AddSystemMessage( | ||||||
|  |             $"The user you're currently talking to is {currentUser.Nick} ({currentUser.Name}), ID is {currentUser.Id}" | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |         // Add previous thoughts (excluding the current user thought, which is the first one since descending) | ||||||
|  |         var previousThoughts = await service.GetPreviousThoughtsAsync(sequence); | ||||||
|  |         var count = previousThoughts.Count; | ||||||
|  |         for (var i = 1; i < count; i++) // skip first (newest, current user) | ||||||
|  |         { | ||||||
|  |             var thought = previousThoughts[i]; | ||||||
|  |             switch (thought.Role) | ||||||
|  |             { | ||||||
|  |                 case ThinkingThoughtRole.User: | ||||||
|  |                     chatHistory.AddUserMessage(thought.Content ?? ""); | ||||||
|  |                     break; | ||||||
|  |                 case ThinkingThoughtRole.Assistant: | ||||||
|  |                     chatHistory.AddAssistantMessage(thought.Content ?? ""); | ||||||
|  |                     break; | ||||||
|  |                 default: | ||||||
|  |                     throw new ArgumentOutOfRangeException(); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         chatHistory.AddUserMessage(request.UserMessage); | ||||||
|  |  | ||||||
|  |         // Set response for streaming | ||||||
|  |         Response.Headers.Append("Content-Type", "text/event-stream"); | ||||||
|  |         Response.StatusCode = 200; | ||||||
|  |  | ||||||
|  |         var kernel = provider.Kernel; | ||||||
|  |         var chatCompletionService = kernel.GetRequiredService<IChatCompletionService>(); | ||||||
|  |  | ||||||
|  |         // Kick off streaming generation | ||||||
|  |         var accumulatedContent = new StringBuilder(); | ||||||
|  |         await foreach (var chunk in chatCompletionService.GetStreamingChatMessageContentsAsync( | ||||||
|  |                            chatHistory, | ||||||
|  |                            provider.CreatePromptExecutionSettings(), | ||||||
|  |                            kernel: kernel | ||||||
|  |                        )) | ||||||
|  |         { | ||||||
|  |             // Write each chunk to the HTTP response as SSE | ||||||
|  |             var data = chunk.Content ?? ""; | ||||||
|  |             accumulatedContent.Append(data); | ||||||
|  |             if (string.IsNullOrEmpty(data)) continue; | ||||||
|  |  | ||||||
|  |             var bytes = Encoding.UTF8.GetBytes(data); | ||||||
|  |             await Response.Body.WriteAsync(bytes); | ||||||
|  |             await Response.Body.FlushAsync(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Save assistant thought | ||||||
|  |         var savedThought = | ||||||
|  |             await service.SaveThoughtAsync(sequence, accumulatedContent.ToString(), ThinkingThoughtRole.Assistant); | ||||||
|  |  | ||||||
|  |         // Write the topic if it was newly set, then the thought object as JSON to the stream | ||||||
|  |         using (var streamBuilder = new MemoryStream()) | ||||||
|  |         { | ||||||
|  |             await streamBuilder.WriteAsync("\n"u8.ToArray()); | ||||||
|  |             if (topic != null) | ||||||
|  |             { | ||||||
|  |                 await streamBuilder.WriteAsync(Encoding.UTF8.GetBytes($"<topic>{sequence.Topic ?? ""}</topic>\n")); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             await streamBuilder.WriteAsync( | ||||||
|  |                 Encoding.UTF8.GetBytes(JsonSerializer.Serialize(savedThought, GrpcTypeHelper.SerializerOptions))); | ||||||
|  |             var outputBytes = streamBuilder.ToArray(); | ||||||
|  |             await Response.Body.WriteAsync(outputBytes); | ||||||
|  |             await Response.Body.FlushAsync(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Return empty result since we're streaming | ||||||
|  |         return new EmptyResult(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Retrieves a paginated list of thinking sequences for the authenticated user. | ||||||
|  |     /// </summary> | ||||||
|  |     /// <param name="offset">The number of sequences to skip for pagination.</param> | ||||||
|  |     /// <param name="take">The maximum number of sequences to return (default: 20).</param> | ||||||
|  |     /// <returns> | ||||||
|  |     /// Returns an ActionResult containing a list of thinking sequences. | ||||||
|  |     /// Includes an X-Total header with the total count of sequences before pagination. | ||||||
|  |     /// </returns> | ||||||
|  |     [HttpGet("sequences")] | ||||||
|  |     [ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  |     public async Task<ActionResult<List<SnThinkingSequence>>> ListSequences( | ||||||
|  |         [FromQuery] int offset = 0, | ||||||
|  |         [FromQuery] int take = 20 | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |  | ||||||
|  |         var (totalCount, sequences) = await service.ListSequencesAsync(accountId, offset, take); | ||||||
|  |  | ||||||
|  |         Response.Headers["X-Total"] = totalCount.ToString(); | ||||||
|  |  | ||||||
|  |         return Ok(sequences); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Retrieves the thoughts in a specific thinking sequence. | ||||||
|  |     /// </summary> | ||||||
|  |     /// <param name="sequenceId">The ID of the sequence to retrieve thoughts from.</param> | ||||||
|  |     /// <returns> | ||||||
|  |     /// Returns an ActionResult containing a list of thoughts in the sequence, ordered by creation date. | ||||||
|  |     /// </returns> | ||||||
|  |     [HttpGet("sequences/{sequenceId:guid}")] | ||||||
|  |     [ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  |     [ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  |     public async Task<ActionResult<List<SnThinkingThought>>> GetSequenceThoughts(Guid sequenceId) | ||||||
|  |     { | ||||||
|  |         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||||
|  |         var accountId = Guid.Parse(currentUser.Id); | ||||||
|  |  | ||||||
|  |         var sequence = await service.GetOrCreateSequenceAsync(accountId, sequenceId); | ||||||
|  |         if (sequence == null) return NotFound(); | ||||||
|  |  | ||||||
|  |         var thoughts = await service.GetPreviousThoughtsAsync(sequence); | ||||||
|  |  | ||||||
|  |         return Ok(thoughts); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										118
									
								
								DysonNetwork.Insight/Thought/ThoughtProvider.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								DysonNetwork.Insight/Thought/ThoughtProvider.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,118 @@ | |||||||
|  | using System.ClientModel; | ||||||
|  | using System.Text.Json; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  | using Microsoft.SemanticKernel; | ||||||
|  | using Microsoft.SemanticKernel.Connectors.Ollama; | ||||||
|  | using Microsoft.SemanticKernel.Connectors.OpenAI; | ||||||
|  | using OpenAI; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Insight.Thought; | ||||||
|  |  | ||||||
|  | public class ThoughtProvider | ||||||
|  | { | ||||||
|  |     private readonly Kernel _kernel; | ||||||
|  |     private readonly PostService.PostServiceClient _postClient; | ||||||
|  |     private readonly AccountService.AccountServiceClient _accountClient; | ||||||
|  |  | ||||||
|  |     public Kernel Kernel => _kernel; | ||||||
|  |     public string? ModelProviderType { get; private set; } | ||||||
|  |     public string? ModelDefault { get; private set; } | ||||||
|  |  | ||||||
|  |     public ThoughtProvider( | ||||||
|  |         IConfiguration configuration, | ||||||
|  |         PostService.PostServiceClient postClient, | ||||||
|  |         AccountService.AccountServiceClient accountClient | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         _postClient = postClient; | ||||||
|  |         _accountClient = accountClient; | ||||||
|  |  | ||||||
|  |         _kernel = InitializeThinkingProvider(configuration); | ||||||
|  |         InitializeHelperFunctions(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private Kernel InitializeThinkingProvider(IConfiguration configuration) | ||||||
|  |     { | ||||||
|  |         var cfg = configuration.GetSection("Thinking"); | ||||||
|  |         ModelProviderType = cfg.GetValue<string>("Provider")?.ToLower(); | ||||||
|  |         ModelDefault = cfg.GetValue<string>("Model"); | ||||||
|  |         var endpoint = cfg.GetValue<string>("Endpoint"); | ||||||
|  |         var apiKey = cfg.GetValue<string>("ApiKey"); | ||||||
|  |  | ||||||
|  |         var builder = Kernel.CreateBuilder(); | ||||||
|  |  | ||||||
|  |         switch (ModelProviderType) | ||||||
|  |         { | ||||||
|  |             case "ollama": | ||||||
|  |                 builder.AddOllamaChatCompletion(ModelDefault!, new Uri(endpoint ?? "http://localhost:11434/api")); | ||||||
|  |                 break; | ||||||
|  |             case "deepseek": | ||||||
|  |                 builder.AddOpenAIChatCompletion(ModelDefault!, | ||||||
|  |                     new OpenAIClient( | ||||||
|  |                         new ApiKeyCredential(apiKey!), | ||||||
|  |                         new OpenAIClientOptions { Endpoint = new Uri(endpoint ?? "https://api.deepseek.com/v1") } | ||||||
|  |                     ) | ||||||
|  |                 ); | ||||||
|  |                 break; | ||||||
|  |             default: | ||||||
|  |                 throw new IndexOutOfRangeException("Unknown thinking provider: " + ModelProviderType); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return builder.Build(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private void InitializeHelperFunctions() | ||||||
|  |     { | ||||||
|  |         // Add Solar Network tools plugin | ||||||
|  |         _kernel.ImportPluginFromFunctions("helper_functions", [ | ||||||
|  |             KernelFunctionFactory.CreateFromMethod(async (string userId) => | ||||||
|  |             { | ||||||
|  |                 var request = new GetAccountRequest { Id = userId }; | ||||||
|  |                 var response = await _accountClient.GetAccountAsync(request); | ||||||
|  |                 return JsonSerializer.Serialize(response, GrpcTypeHelper.SerializerOptions); | ||||||
|  |             }, "get_user_profile", "Get a user profile from the Solar Network."), | ||||||
|  |             KernelFunctionFactory.CreateFromMethod(async (string postId) => | ||||||
|  |             { | ||||||
|  |                 var request = new GetPostRequest { Id = postId }; | ||||||
|  |                 var response = await _postClient.GetPostAsync(request); | ||||||
|  |                 return JsonSerializer.Serialize(response, GrpcTypeHelper.SerializerOptions); | ||||||
|  |             }, "get_post", "Get a single post by ID from the Solar Network."), | ||||||
|  |             KernelFunctionFactory.CreateFromMethod(async (string query) => | ||||||
|  |             { | ||||||
|  |                 var request = new SearchPostsRequest { Query = query, PageSize = 10 }; | ||||||
|  |                 var response = await _postClient.SearchPostsAsync(request); | ||||||
|  |                 return JsonSerializer.Serialize(response.Posts, GrpcTypeHelper.SerializerOptions); | ||||||
|  |             }, "search_posts", "Search posts by query from the Solar Network."), | ||||||
|  |             KernelFunctionFactory.CreateFromMethod(async () => | ||||||
|  |             { | ||||||
|  |                 var request = new ListPostsRequest { PageSize = 10 }; | ||||||
|  |                 var response = await _postClient.ListPostsAsync(request); | ||||||
|  |                 return JsonSerializer.Serialize(response.Posts, GrpcTypeHelper.SerializerOptions); | ||||||
|  |             }, "get_recent_posts", "Get recent posts from the Solar Network.") | ||||||
|  |         ]); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public PromptExecutionSettings CreatePromptExecutionSettings() | ||||||
|  |     { | ||||||
|  |         return ModelProviderType switch | ||||||
|  |         { | ||||||
|  |             "ollama" => new OllamaPromptExecutionSettings | ||||||
|  |             { | ||||||
|  |                 FunctionChoiceBehavior = FunctionChoiceBehavior.Auto( | ||||||
|  |                     options: new FunctionChoiceBehaviorOptions | ||||||
|  |                     { | ||||||
|  |                         AllowParallelCalls = true, AllowConcurrentInvocation = true | ||||||
|  |                     }) | ||||||
|  |             }, | ||||||
|  |             "deepseek" => new OpenAIPromptExecutionSettings | ||||||
|  |             { | ||||||
|  |                 FunctionChoiceBehavior = FunctionChoiceBehavior.Auto( | ||||||
|  |                     options: new FunctionChoiceBehaviorOptions | ||||||
|  |                     { | ||||||
|  |                         AllowParallelCalls = true, AllowConcurrentInvocation = true | ||||||
|  |                     }) | ||||||
|  |             }, | ||||||
|  |             _ => throw new InvalidOperationException("Unknown provider: " + ModelProviderType) | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										75
									
								
								DysonNetwork.Insight/Thought/ThoughtService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								DysonNetwork.Insight/Thought/ThoughtService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,75 @@ | |||||||
|  | using DysonNetwork.Shared.Cache; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Insight.Thought; | ||||||
|  |  | ||||||
|  | public class ThoughtService(AppDatabase db, ICacheService cache) | ||||||
|  | { | ||||||
|  |     public async Task<SnThinkingSequence?> GetOrCreateSequenceAsync(Guid accountId, Guid? sequenceId, string? topic = null) | ||||||
|  |     { | ||||||
|  |         if (sequenceId.HasValue) | ||||||
|  |         { | ||||||
|  |             var seq = await db.ThinkingSequences.FindAsync(sequenceId.Value); | ||||||
|  |             if (seq == null || seq.AccountId != accountId) return null; | ||||||
|  |             return seq; | ||||||
|  |         } | ||||||
|  |         else | ||||||
|  |         { | ||||||
|  |             var seq = new SnThinkingSequence { AccountId = accountId, Topic = topic }; | ||||||
|  |             db.ThinkingSequences.Add(seq); | ||||||
|  |             await db.SaveChangesAsync(); | ||||||
|  |             return seq; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<SnThinkingThought> SaveThoughtAsync(SnThinkingSequence sequence, string content, ThinkingThoughtRole role) | ||||||
|  |     { | ||||||
|  |         var thought = new SnThinkingThought | ||||||
|  |         { | ||||||
|  |             SequenceId = sequence.Id, | ||||||
|  |             Content = content, | ||||||
|  |             Role = role | ||||||
|  |         }; | ||||||
|  |         db.ThinkingThoughts.Add(thought); | ||||||
|  |         await db.SaveChangesAsync(); | ||||||
|  |  | ||||||
|  |         // Invalidate cache for this sequence's thoughts | ||||||
|  |         await cache.RemoveGroupAsync($"sequence:{sequence.Id}"); | ||||||
|  |  | ||||||
|  |         return thought; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<List<SnThinkingThought>> GetPreviousThoughtsAsync(SnThinkingSequence sequence) | ||||||
|  |     { | ||||||
|  |         var cacheKey = $"thoughts:{sequence.Id}"; | ||||||
|  |         var (found, cachedThoughts) = await cache.GetAsyncWithStatus<List<SnThinkingThought>>(cacheKey); | ||||||
|  |         if (found && cachedThoughts != null) | ||||||
|  |         { | ||||||
|  |             return cachedThoughts; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var thoughts = await db.ThinkingThoughts | ||||||
|  |             .Where(t => t.SequenceId == sequence.Id) | ||||||
|  |             .OrderByDescending(t => t.CreatedAt) | ||||||
|  |             .ToListAsync(); | ||||||
|  |  | ||||||
|  |         // Cache for 10 minutes | ||||||
|  |         await cache.SetWithGroupsAsync(cacheKey, thoughts, [$"sequence:{sequence.Id}"], TimeSpan.FromMinutes(10)); | ||||||
|  |  | ||||||
|  |         return thoughts; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<(int total, List<SnThinkingSequence> sequences)> ListSequencesAsync(Guid accountId, int offset, int take) | ||||||
|  |     { | ||||||
|  |         var query = db.ThinkingSequences.Where(s => s.AccountId == accountId); | ||||||
|  |         var totalCount = await query.CountAsync(); | ||||||
|  |         var sequences = await query | ||||||
|  |             .OrderByDescending(s => s.CreatedAt) | ||||||
|  |             .Skip(offset) | ||||||
|  |             .Take(take) | ||||||
|  |             .ToListAsync(); | ||||||
|  |  | ||||||
|  |         return (totalCount, sequences); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										27
									
								
								DysonNetwork.Insight/appsettings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								DysonNetwork.Insight/appsettings.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | |||||||
|  | { | ||||||
|  |     "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_insight;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 | ||||||
|  |     }, | ||||||
|  |     "Thinking": { | ||||||
|  |         "Provider": "ollama", | ||||||
|  |         "Model": "qwen3:8b", | ||||||
|  |         "Endpoint": "http://localhost:11434/api" | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -2,8 +2,9 @@ using System.ComponentModel.DataAnnotations; | |||||||
| using DysonNetwork.Pass.Auth; | using DysonNetwork.Pass.Auth; | ||||||
| using DysonNetwork.Pass.Credit; | using DysonNetwork.Pass.Credit; | ||||||
| using DysonNetwork.Pass.Wallet; | using DysonNetwork.Pass.Wallet; | ||||||
| using DysonNetwork.Shared.Error; |  | ||||||
| using DysonNetwork.Shared.GeoIp; | using DysonNetwork.Shared.GeoIp; | ||||||
|  | using DysonNetwork.Shared.Http; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using NodaTime; | using NodaTime; | ||||||
| @@ -23,9 +24,9 @@ public class AccountController( | |||||||
| ) : ControllerBase | ) : ControllerBase | ||||||
| { | { | ||||||
|     [HttpGet("{name}")] |     [HttpGet("{name}")] | ||||||
|     [ProducesResponseType<Account>(StatusCodes.Status200OK)] |     [ProducesResponseType<SnAccount>(StatusCodes.Status200OK)] | ||||||
|     [ProducesResponseType(StatusCodes.Status404NotFound)] |     [ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|     public async Task<ActionResult<Account?>> GetByName(string name) |     public async Task<ActionResult<SnAccount?>> GetByName(string name) | ||||||
|     { |     { | ||||||
|         var account = await db.Accounts |         var account = await db.Accounts | ||||||
|             .Include(e => e.Badges) |             .Include(e => e.Badges) | ||||||
| @@ -42,9 +43,9 @@ public class AccountController( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     [HttpGet("{name}/badges")] |     [HttpGet("{name}/badges")] | ||||||
|     [ProducesResponseType<List<AccountBadge>>(StatusCodes.Status200OK)] |     [ProducesResponseType<List<SnAccountBadge>>(StatusCodes.Status200OK)] | ||||||
|     [ProducesResponseType(StatusCodes.Status404NotFound)] |     [ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|     public async Task<ActionResult<List<AccountBadge>>> GetBadgesByName(string name) |     public async Task<ActionResult<List<SnAccountBadge>>> GetBadgesByName(string name) | ||||||
|     { |     { | ||||||
|         var account = await db.Accounts |         var account = await db.Accounts | ||||||
|             .Include(e => e.Badges) |             .Include(e => e.Badges) | ||||||
| @@ -103,9 +104,9 @@ public class AccountController( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     [HttpPost] |     [HttpPost] | ||||||
|     [ProducesResponseType<Account>(StatusCodes.Status200OK)] |     [ProducesResponseType<SnAccount>(StatusCodes.Status200OK)] | ||||||
|     [ProducesResponseType(StatusCodes.Status400BadRequest)] |     [ProducesResponseType(StatusCodes.Status400BadRequest)] | ||||||
|     public async Task<ActionResult<Account>> CreateAccount([FromBody] AccountCreateRequest request) |     public async Task<ActionResult<SnAccount>> CreateAccount([FromBody] AccountCreateRequest request) | ||||||
|     { |     { | ||||||
|         if (!await auth.ValidateCaptcha(request.CaptchaToken)) |         if (!await auth.ValidateCaptcha(request.CaptchaToken)) | ||||||
|             return BadRequest(ApiError.Validation(new Dictionary<string, string[]> |             return BadRequest(ApiError.Validation(new Dictionary<string, string[]> | ||||||
| @@ -194,11 +195,12 @@ public class AccountController( | |||||||
|         public bool IsAutomated { get; set; } = false; |         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; } |         [MaxLength(4096)] public string? AppIdentifier { get; set; } | ||||||
|  |         public Dictionary<string, object>? Meta { get; set; } | ||||||
|         public Instant? ClearedAt { get; set; } |         public Instant? ClearedAt { get; set; } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     [HttpGet("{name}/statuses")] |     [HttpGet("{name}/statuses")] | ||||||
|     public async Task<ActionResult<Status>> GetOtherStatus(string name) |     public async Task<ActionResult<SnAccountStatus>> 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) |         if (account is null) | ||||||
| @@ -253,7 +255,7 @@ public class AccountController( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     [HttpGet("search")] |     [HttpGet("search")] | ||||||
|     public async Task<List<Account>> Search([FromQuery] string query, [FromQuery] int take = 20) |     public async Task<List<SnAccount>> Search([FromQuery] string query, [FromQuery] int take = 20) | ||||||
|     { |     { | ||||||
|         if (string.IsNullOrWhiteSpace(query)) |         if (string.IsNullOrWhiteSpace(query)) | ||||||
|             return []; |             return []; | ||||||
|   | |||||||
| @@ -1,16 +1,15 @@ | |||||||
| 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.Http; | ||||||
| using DysonNetwork.Shared.Error; | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
| using Microsoft.AspNetCore.Authorization; | using Microsoft.AspNetCore.Authorization; | ||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| using Microsoft.EntityFrameworkCore; | 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 SnAuthSession = DysonNetwork.Shared.Models.SnAuthSession; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Pass.Account; | namespace DysonNetwork.Pass.Account; | ||||||
|  |  | ||||||
| @@ -29,11 +28,11 @@ public class AccountCurrentController( | |||||||
| ) : ControllerBase | ) : ControllerBase | ||||||
| { | { | ||||||
|     [HttpGet] |     [HttpGet] | ||||||
|     [ProducesResponseType<Account>(StatusCodes.Status200OK)] |     [ProducesResponseType<SnAccount>(StatusCodes.Status200OK)] | ||||||
|     [ProducesResponseType<ApiError>(StatusCodes.Status401Unauthorized)] |     [ProducesResponseType<ApiError>(StatusCodes.Status401Unauthorized)] | ||||||
|     public async Task<ActionResult<Account>> GetCurrentIdentity() |     public async Task<ActionResult<SnAccount>> GetCurrentIdentity() | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|         var userId = currentUser.Id; |         var userId = currentUser.Id; | ||||||
|  |  | ||||||
|         var account = await db.Accounts |         var account = await db.Accounts | ||||||
| @@ -56,9 +55,9 @@ public class AccountCurrentController( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     [HttpPatch] |     [HttpPatch] | ||||||
|     public async Task<ActionResult<Account>> UpdateBasicInfo([FromBody] BasicInfoRequest request) |     public async Task<ActionResult<SnAccount>> UpdateBasicInfo([FromBody] BasicInfoRequest request) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         var account = await db.Accounts.FirstAsync(a => a.Id == currentUser.Id); |         var account = await db.Accounts.FirstAsync(a => a.Id == currentUser.Id); | ||||||
|  |  | ||||||
| @@ -81,6 +80,7 @@ public class AccountCurrentController( | |||||||
|         [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; } | ||||||
|         [MaxLength(4096)] public string? Bio { get; set; } |         [MaxLength(4096)] public string? Bio { get; set; } | ||||||
|  |         public Shared.Models.UsernameColor? UsernameColor { get; set; } | ||||||
|         public Instant? Birthday { get; set; } |         public Instant? Birthday { get; set; } | ||||||
|         public List<ProfileLink>? Links { get; set; } |         public List<ProfileLink>? Links { get; set; } | ||||||
|  |  | ||||||
| @@ -89,9 +89,9 @@ public class AccountCurrentController( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     [HttpPatch("profile")] |     [HttpPatch("profile")] | ||||||
|     public async Task<ActionResult<AccountProfile>> UpdateProfile([FromBody] ProfileRequest request) |     public async Task<ActionResult<SnAccountProfile>> UpdateProfile([FromBody] ProfileRequest request) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|         var userId = currentUser.Id; |         var userId = currentUser.Id; | ||||||
|  |  | ||||||
|         var profile = await db.AccountProfiles |         var profile = await db.AccountProfiles | ||||||
| @@ -116,6 +116,7 @@ public class AccountCurrentController( | |||||||
|         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.Links is not null) profile.Links = request.Links; | ||||||
|  |         if (request.UsernameColor is not null) profile.UsernameColor = request.UsernameColor; | ||||||
|  |  | ||||||
|         if (request.PictureId is not null) |         if (request.PictureId is not null) | ||||||
|         { |         { | ||||||
| @@ -132,7 +133,7 @@ public class AccountCurrentController( | |||||||
|                     Usage = "profile.picture" |                     Usage = "profile.picture" | ||||||
|                 } |                 } | ||||||
|             ); |             ); | ||||||
|             profile.Picture = CloudFileReferenceObject.FromProtoValue(file); |             profile.Picture = SnCloudFileReferenceObject.FromProtoValue(file); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if (request.BackgroundId is not null) |         if (request.BackgroundId is not null) | ||||||
| @@ -150,7 +151,7 @@ public class AccountCurrentController( | |||||||
|                     Usage = "profile.background" |                     Usage = "profile.background" | ||||||
|                 } |                 } | ||||||
|             ); |             ); | ||||||
|             profile.Background = CloudFileReferenceObject.FromProtoValue(file); |             profile.Background = SnCloudFileReferenceObject.FromProtoValue(file); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         db.Update(profile); |         db.Update(profile); | ||||||
| @@ -164,7 +165,7 @@ public class AccountCurrentController( | |||||||
|     [HttpDelete] |     [HttpDelete] | ||||||
|     public async Task<ActionResult> RequestDeleteAccount() |     public async Task<ActionResult> RequestDeleteAccount() | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
| @@ -185,18 +186,18 @@ public class AccountCurrentController( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     [HttpGet("statuses")] |     [HttpGet("statuses")] | ||||||
|     public async Task<ActionResult<Status>> GetCurrentStatus() |     public async Task<ActionResult<SnAccountStatus>> GetCurrentStatus() | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|         var status = await events.GetStatus(currentUser.Id); |         var status = await events.GetStatus(currentUser.Id); | ||||||
|         return Ok(status); |         return Ok(status); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     [HttpPatch("statuses")] |     [HttpPatch("statuses")] | ||||||
|     [RequiredPermission("global", "accounts.statuses.update")] |     [RequiredPermission("global", "accounts.statuses.update")] | ||||||
|     public async Task<ActionResult<Status>> UpdateStatus([FromBody] AccountController.StatusRequest request) |     public async Task<ActionResult<SnAccountStatus>> UpdateStatus([FromBody] AccountController.StatusRequest request) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|         if (request is { IsAutomated: true, AppIdentifier: not null }) |         if (request is { IsAutomated: true, AppIdentifier: not null }) | ||||||
|             return BadRequest("Automated status cannot be updated."); |             return BadRequest("Automated status cannot be updated."); | ||||||
|  |  | ||||||
| @@ -216,6 +217,7 @@ public class AccountCurrentController( | |||||||
|         status.IsAutomated = request.IsAutomated; |         status.IsAutomated = request.IsAutomated; | ||||||
|         status.Label = request.Label; |         status.Label = request.Label; | ||||||
|         status.AppIdentifier = request.AppIdentifier; |         status.AppIdentifier = request.AppIdentifier; | ||||||
|  |         status.Meta = request.Meta; | ||||||
|         status.ClearedAt = request.ClearedAt; |         status.ClearedAt = request.ClearedAt; | ||||||
|  |  | ||||||
|         db.Update(status); |         db.Update(status); | ||||||
| @@ -227,9 +229,9 @@ public class AccountCurrentController( | |||||||
|  |  | ||||||
|     [HttpPost("statuses")] |     [HttpPost("statuses")] | ||||||
|     [RequiredPermission("global", "accounts.statuses.create")] |     [RequiredPermission("global", "accounts.statuses.create")] | ||||||
|     public async Task<ActionResult<Status>> CreateStatus([FromBody] AccountController.StatusRequest request) |     public async Task<ActionResult<SnAccountStatus>> CreateStatus([FromBody] AccountController.StatusRequest request) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         if (request is { IsAutomated: true, AppIdentifier: not null }) |         if (request is { IsAutomated: true, AppIdentifier: not null }) | ||||||
|         { |         { | ||||||
| @@ -245,6 +247,7 @@ public class AccountCurrentController( | |||||||
|                     existingStatus.Attitude = request.Attitude; |                     existingStatus.Attitude = request.Attitude; | ||||||
|                     existingStatus.IsInvisible = request.IsInvisible; |                     existingStatus.IsInvisible = request.IsInvisible; | ||||||
|                     existingStatus.IsNotDisturb = request.IsNotDisturb; |                     existingStatus.IsNotDisturb = request.IsNotDisturb; | ||||||
|  |                     existingStatus.Meta = request.Meta; | ||||||
|                     existingStatus.Label = request.Label; |                     existingStatus.Label = request.Label; | ||||||
|                     db.Update(existingStatus); |                     db.Update(existingStatus); | ||||||
|                     await db.SaveChangesAsync(); |                     await db.SaveChangesAsync(); | ||||||
| @@ -260,7 +263,7 @@ public class AccountCurrentController( | |||||||
|                 return Ok(existingStatus); // Do not override manually set status with automated ones |                 return Ok(existingStatus); // Do not override manually set status with automated ones | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         var status = new Status |         var status = new SnAccountStatus | ||||||
|         { |         { | ||||||
|             AccountId = currentUser.Id, |             AccountId = currentUser.Id, | ||||||
|             Attitude = request.Attitude, |             Attitude = request.Attitude, | ||||||
| @@ -268,6 +271,7 @@ public class AccountCurrentController( | |||||||
|             IsNotDisturb = request.IsNotDisturb, |             IsNotDisturb = request.IsNotDisturb, | ||||||
|             IsAutomated = request.IsAutomated, |             IsAutomated = request.IsAutomated, | ||||||
|             Label = request.Label, |             Label = request.Label, | ||||||
|  |             Meta = request.Meta, | ||||||
|             AppIdentifier = request.AppIdentifier, |             AppIdentifier = request.AppIdentifier, | ||||||
|             ClearedAt = request.ClearedAt |             ClearedAt = request.ClearedAt | ||||||
|         }; |         }; | ||||||
| @@ -278,7 +282,7 @@ public class AccountCurrentController( | |||||||
|     [HttpDelete("statuses")] |     [HttpDelete("statuses")] | ||||||
|     public async Task<ActionResult> DeleteStatus([FromQuery] string? app) |     public async Task<ActionResult> DeleteStatus([FromQuery] string? app) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         var now = SystemClock.Instance.GetCurrentInstant(); |         var now = SystemClock.Instance.GetCurrentInstant(); | ||||||
|         var queryable = db.AccountStatuses |         var queryable = db.AccountStatuses | ||||||
| @@ -287,7 +291,7 @@ public class AccountCurrentController( | |||||||
|             .OrderByDescending(s => s.CreatedAt) |             .OrderByDescending(s => s.CreatedAt) | ||||||
|             .AsQueryable(); |             .AsQueryable(); | ||||||
|  |  | ||||||
|         if (string.IsNullOrWhiteSpace(app)) |         if (!string.IsNullOrWhiteSpace(app)) | ||||||
|             queryable = queryable.Where(s => s.IsAutomated && s.AppIdentifier == app); |             queryable = queryable.Where(s => s.IsAutomated && s.AppIdentifier == app); | ||||||
|  |  | ||||||
|         var status = await queryable |         var status = await queryable | ||||||
| @@ -299,9 +303,9 @@ public class AccountCurrentController( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     [HttpGet("check-in")] |     [HttpGet("check-in")] | ||||||
|     public async Task<ActionResult<CheckInResult>> GetCheckInResult() |     public async Task<ActionResult<SnCheckInResult>> GetCheckInResult() | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|         var userId = currentUser.Id; |         var userId = currentUser.Id; | ||||||
|  |  | ||||||
|         var now = SystemClock.Instance.GetCurrentInstant(); |         var now = SystemClock.Instance.GetCurrentInstant(); | ||||||
| @@ -321,12 +325,12 @@ public class AccountCurrentController( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     [HttpPost("check-in")] |     [HttpPost("check-in")] | ||||||
|     public async Task<ActionResult<CheckInResult>> DoCheckIn( |     public async Task<ActionResult<SnCheckInResult>> DoCheckIn( | ||||||
|         [FromBody] string? captchaToken, |         [FromBody] string? captchaToken, | ||||||
|         [FromQuery] Instant? backdated = null |         [FromQuery] Instant? backdated = null | ||||||
|     ) |     ) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         if (backdated is null) |         if (backdated is null) | ||||||
|         { |         { | ||||||
| @@ -397,7 +401,7 @@ public class AccountCurrentController( | |||||||
|     public async Task<ActionResult<List<DailyEventResponse>>> GetEventCalendar([FromQuery] int? month, |     public async Task<ActionResult<List<DailyEventResponse>>> GetEventCalendar([FromQuery] int? month, | ||||||
|         [FromQuery] int? year) |         [FromQuery] int? year) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         var currentDate = SystemClock.Instance.GetCurrentInstant().InUtc().Date; |         var currentDate = SystemClock.Instance.GetCurrentInstant().InUtc().Date; | ||||||
|         month ??= currentDate.Month; |         month ??= currentDate.Month; | ||||||
| @@ -426,7 +430,7 @@ public class AccountCurrentController( | |||||||
|         [FromQuery] int offset = 0 |         [FromQuery] int offset = 0 | ||||||
|     ) |     ) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         var query = db.ActionLogs |         var query = db.ActionLogs | ||||||
|             .Where(log => log.AccountId == currentUser.Id) |             .Where(log => log.AccountId == currentUser.Id) | ||||||
| @@ -444,9 +448,9 @@ public class AccountCurrentController( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     [HttpGet("factors")] |     [HttpGet("factors")] | ||||||
|     public async Task<ActionResult<List<AccountAuthFactor>>> GetAuthFactors() |     public async Task<ActionResult<List<SnAccountAuthFactor>>> GetAuthFactors() | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         var factors = await db.AccountAuthFactors |         var factors = await db.AccountAuthFactors | ||||||
|             .Include(f => f.Account) |             .Include(f => f.Account) | ||||||
| @@ -458,15 +462,15 @@ public class AccountCurrentController( | |||||||
|  |  | ||||||
|     public class AuthFactorRequest |     public class AuthFactorRequest | ||||||
|     { |     { | ||||||
|         public AccountAuthFactorType Type { get; set; } |         public Shared.Models.AccountAuthFactorType Type { get; set; } | ||||||
|         public string? Secret { get; set; } |         public string? Secret { get; set; } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     [HttpPost("factors")] |     [HttpPost("factors")] | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     public async Task<ActionResult<AccountAuthFactor>> CreateAuthFactor([FromBody] AuthFactorRequest request) |     public async Task<ActionResult<SnAccountAuthFactor>> CreateAuthFactor([FromBody] AuthFactorRequest request) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|         if (await accounts.CheckAuthFactorExists(currentUser, request.Type)) |         if (await accounts.CheckAuthFactorExists(currentUser, request.Type)) | ||||||
|             return BadRequest(new ApiError |             return BadRequest(new ApiError | ||||||
|             { |             { | ||||||
| @@ -482,9 +486,9 @@ public class AccountCurrentController( | |||||||
|  |  | ||||||
|     [HttpPost("factors/{id:guid}/enable")] |     [HttpPost("factors/{id:guid}/enable")] | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     public async Task<ActionResult<AccountAuthFactor>> EnableAuthFactor(Guid id, [FromBody] string? code) |     public async Task<ActionResult<SnAccountAuthFactor>> EnableAuthFactor(Guid id, [FromBody] string? code) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         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) | ||||||
| @@ -511,9 +515,9 @@ public class AccountCurrentController( | |||||||
|  |  | ||||||
|     [HttpPost("factors/{id:guid}/disable")] |     [HttpPost("factors/{id:guid}/disable")] | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     public async Task<ActionResult<AccountAuthFactor>> DisableAuthFactor(Guid id) |     public async Task<ActionResult<SnAccountAuthFactor>> DisableAuthFactor(Guid id) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         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) | ||||||
| @@ -533,9 +537,9 @@ public class AccountCurrentController( | |||||||
|  |  | ||||||
|     [HttpDelete("factors/{id:guid}")] |     [HttpDelete("factors/{id:guid}")] | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     public async Task<ActionResult<AccountAuthFactor>> DeleteAuthFactor(Guid id) |     public async Task<ActionResult<SnAccountAuthFactor>> DeleteAuthFactor(Guid id) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         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) | ||||||
| @@ -555,10 +559,10 @@ public class AccountCurrentController( | |||||||
|  |  | ||||||
|     [HttpGet("devices")] |     [HttpGet("devices")] | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     public async Task<ActionResult<List<AuthClientWithChallenge>>> GetDevices() |     public async Task<ActionResult<List<SnAuthClientWithChallenge>>> GetDevices() | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser || |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser || | ||||||
|             HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized(); |             HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized(); | ||||||
|  |  | ||||||
|         Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString()); |         Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString()); | ||||||
|  |  | ||||||
| @@ -566,7 +570,7 @@ public class AccountCurrentController( | |||||||
|             .Where(device => device.AccountId == currentUser.Id) |             .Where(device => device.AccountId == currentUser.Id) | ||||||
|             .ToListAsync(); |             .ToListAsync(); | ||||||
|  |  | ||||||
|         var challengeDevices = devices.Select(AuthClientWithChallenge.FromClient).ToList(); |         var challengeDevices = devices.Select(SnAuthClientWithChallenge.FromClient).ToList(); | ||||||
|         var deviceIds = challengeDevices.Select(x => x.Id).ToList(); |         var deviceIds = challengeDevices.Select(x => x.Id).ToList(); | ||||||
|  |  | ||||||
|         var authChallenges = await db.AuthChallenges |         var authChallenges = await db.AuthChallenges | ||||||
| @@ -582,13 +586,13 @@ public class AccountCurrentController( | |||||||
|  |  | ||||||
|     [HttpGet("sessions")] |     [HttpGet("sessions")] | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     public async Task<ActionResult<List<AuthSession>>> GetSessions( |     public async Task<ActionResult<List<SnAuthSession>>> GetSessions( | ||||||
|         [FromQuery] int take = 20, |         [FromQuery] int take = 20, | ||||||
|         [FromQuery] int offset = 0 |         [FromQuery] int offset = 0 | ||||||
|     ) |     ) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser || |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser || | ||||||
|             HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized(); |             HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized(); | ||||||
|  |  | ||||||
|         var query = db.AuthSessions |         var query = db.AuthSessions | ||||||
|             .Include(session => session.Account) |             .Include(session => session.Account) | ||||||
| @@ -610,9 +614,9 @@ public class AccountCurrentController( | |||||||
|  |  | ||||||
|     [HttpDelete("sessions/{id:guid}")] |     [HttpDelete("sessions/{id:guid}")] | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     public async Task<ActionResult<AuthSession>> DeleteSession(Guid id) |     public async Task<ActionResult<SnAuthSession>> DeleteSession(Guid id) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
| @@ -627,9 +631,9 @@ public class AccountCurrentController( | |||||||
|  |  | ||||||
|     [HttpDelete("devices/{deviceId}")] |     [HttpDelete("devices/{deviceId}")] | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     public async Task<ActionResult<AuthSession>> DeleteDevice(string deviceId) |     public async Task<ActionResult<SnAuthSession>> DeleteDevice(string deviceId) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
| @@ -644,10 +648,10 @@ public class AccountCurrentController( | |||||||
|  |  | ||||||
|     [HttpDelete("sessions/current")] |     [HttpDelete("sessions/current")] | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     public async Task<ActionResult<AuthSession>> DeleteCurrentSession() |     public async Task<ActionResult<SnAuthSession>> DeleteCurrentSession() | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser || |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser || | ||||||
|             HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized(); |             HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized(); | ||||||
|  |  | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
| @@ -662,9 +666,9 @@ public class AccountCurrentController( | |||||||
|  |  | ||||||
|     [HttpPatch("devices/{deviceId}/label")] |     [HttpPatch("devices/{deviceId}/label")] | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     public async Task<ActionResult<AuthSession>> UpdateDeviceLabel(string deviceId, [FromBody] string label) |     public async Task<ActionResult<SnAuthSession>> UpdateDeviceLabel(string deviceId, [FromBody] string label) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
| @@ -679,10 +683,10 @@ public class AccountCurrentController( | |||||||
|  |  | ||||||
|     [HttpPatch("devices/current/label")] |     [HttpPatch("devices/current/label")] | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     public async Task<ActionResult<AuthSession>> UpdateCurrentDeviceLabel([FromBody] string label) |     public async Task<ActionResult<SnAuthSession>> UpdateCurrentDeviceLabel([FromBody] string label) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser || |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser || | ||||||
|             HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized(); |             HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized(); | ||||||
|  |  | ||||||
|         var device = await db.AuthClients.FirstOrDefaultAsync(d => d.Id == currentSession.Challenge.ClientId); |         var device = await db.AuthClients.FirstOrDefaultAsync(d => d.Id == currentSession.Challenge.ClientId); | ||||||
|         if (device is null) return NotFound(); |         if (device is null) return NotFound(); | ||||||
| @@ -700,9 +704,9 @@ public class AccountCurrentController( | |||||||
|  |  | ||||||
|     [HttpGet("contacts")] |     [HttpGet("contacts")] | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     public async Task<ActionResult<List<AccountContact>>> GetContacts() |     public async Task<ActionResult<List<SnAccountContact>>> GetContacts() | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         var contacts = await db.AccountContacts |         var contacts = await db.AccountContacts | ||||||
|             .Where(c => c.AccountId == currentUser.Id) |             .Where(c => c.AccountId == currentUser.Id) | ||||||
| @@ -713,15 +717,15 @@ public class AccountCurrentController( | |||||||
|  |  | ||||||
|     public class AccountContactRequest |     public class AccountContactRequest | ||||||
|     { |     { | ||||||
|         [Required] public AccountContactType Type { get; set; } |         [Required] public Shared.Models.AccountContactType Type { get; set; } | ||||||
|         [Required] public string Content { get; set; } = null!; |         [Required] public string Content { get; set; } = null!; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     [HttpPost("contacts")] |     [HttpPost("contacts")] | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     public async Task<ActionResult<AccountContact>> CreateContact([FromBody] AccountContactRequest request) |     public async Task<ActionResult<SnAccountContact>> CreateContact([FromBody] AccountContactRequest request) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
| @@ -736,9 +740,9 @@ public class AccountCurrentController( | |||||||
|  |  | ||||||
|     [HttpPost("contacts/{id:guid}/verify")] |     [HttpPost("contacts/{id:guid}/verify")] | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     public async Task<ActionResult<AccountContact>> VerifyContact(Guid id) |     public async Task<ActionResult<SnAccountContact>> VerifyContact(Guid id) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         var contact = await db.AccountContacts |         var contact = await db.AccountContacts | ||||||
|             .Where(c => c.AccountId == currentUser.Id && c.Id == id) |             .Where(c => c.AccountId == currentUser.Id && c.Id == id) | ||||||
| @@ -758,9 +762,9 @@ public class AccountCurrentController( | |||||||
|  |  | ||||||
|     [HttpPost("contacts/{id:guid}/primary")] |     [HttpPost("contacts/{id:guid}/primary")] | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     public async Task<ActionResult<AccountContact>> SetPrimaryContact(Guid id) |     public async Task<ActionResult<SnAccountContact>> SetPrimaryContact(Guid id) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         var contact = await db.AccountContacts |         var contact = await db.AccountContacts | ||||||
|             .Where(c => c.AccountId == currentUser.Id && c.Id == id) |             .Where(c => c.AccountId == currentUser.Id && c.Id == id) | ||||||
| @@ -780,9 +784,9 @@ public class AccountCurrentController( | |||||||
|  |  | ||||||
|     [HttpPost("contacts/{id:guid}/public")] |     [HttpPost("contacts/{id:guid}/public")] | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     public async Task<ActionResult<AccountContact>> SetPublicContact(Guid id) |     public async Task<ActionResult<SnAccountContact>> SetPublicContact(Guid id) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         var contact = await db.AccountContacts |         var contact = await db.AccountContacts | ||||||
|             .Where(c => c.AccountId == currentUser.Id && c.Id == id) |             .Where(c => c.AccountId == currentUser.Id && c.Id == id) | ||||||
| @@ -802,9 +806,9 @@ public class AccountCurrentController( | |||||||
|  |  | ||||||
|     [HttpDelete("contacts/{id:guid}/public")] |     [HttpDelete("contacts/{id:guid}/public")] | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     public async Task<ActionResult<AccountContact>> UnsetPublicContact(Guid id) |     public async Task<ActionResult<SnAccountContact>> UnsetPublicContact(Guid id) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         var contact = await db.AccountContacts |         var contact = await db.AccountContacts | ||||||
|             .Where(c => c.AccountId == currentUser.Id && c.Id == id) |             .Where(c => c.AccountId == currentUser.Id && c.Id == id) | ||||||
| @@ -824,9 +828,9 @@ public class AccountCurrentController( | |||||||
|  |  | ||||||
|     [HttpDelete("contacts/{id:guid}")] |     [HttpDelete("contacts/{id:guid}")] | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     public async Task<ActionResult<AccountContact>> DeleteContact(Guid id) |     public async Task<ActionResult<SnAccountContact>> DeleteContact(Guid id) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         var contact = await db.AccountContacts |         var contact = await db.AccountContacts | ||||||
|             .Where(c => c.AccountId == currentUser.Id && c.Id == id) |             .Where(c => c.AccountId == currentUser.Id && c.Id == id) | ||||||
| @@ -845,11 +849,11 @@ public class AccountCurrentController( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     [HttpGet("badges")] |     [HttpGet("badges")] | ||||||
|     [ProducesResponseType<List<AccountBadge>>(StatusCodes.Status200OK)] |     [ProducesResponseType<List<SnAccountBadge>>(StatusCodes.Status200OK)] | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     public async Task<ActionResult<List<AccountBadge>>> GetBadges() |     public async Task<ActionResult<List<SnAccountBadge>>> GetBadges() | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         var badges = await db.Badges |         var badges = await db.Badges | ||||||
|             .Where(b => b.AccountId == currentUser.Id) |             .Where(b => b.AccountId == currentUser.Id) | ||||||
| @@ -859,9 +863,9 @@ public class AccountCurrentController( | |||||||
|  |  | ||||||
|     [HttpPost("badges/{id:guid}/active")] |     [HttpPost("badges/{id:guid}/active")] | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     public async Task<ActionResult<AccountBadge>> ActivateBadge(Guid id) |     public async Task<ActionResult<SnAccountBadge>> ActivateBadge(Guid id) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
| @@ -876,12 +880,12 @@ public class AccountCurrentController( | |||||||
|  |  | ||||||
|     [HttpGet("leveling")] |     [HttpGet("leveling")] | ||||||
|     [Authorize] |     [Authorize] | ||||||
|     public async Task<ActionResult<ExperienceRecord>> GetLevelingHistory( |     public async Task<ActionResult<SnExperienceRecord>> GetLevelingHistory( | ||||||
|         [FromQuery] int take = 20, |         [FromQuery] int take = 20, | ||||||
|         [FromQuery] int offset = 0 |         [FromQuery] int offset = 0 | ||||||
|     ) |     ) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         var queryable = db.ExperienceRecords |         var queryable = db.ExperienceRecords | ||||||
|             .Where(r => r.AccountId == currentUser.Id) |             .Where(r => r.AccountId == currentUser.Id) | ||||||
| @@ -901,7 +905,7 @@ public class AccountCurrentController( | |||||||
|     [HttpGet("credits")] |     [HttpGet("credits")] | ||||||
|     public async Task<ActionResult<bool>> GetSocialCredit() |     public async Task<ActionResult<bool>> GetSocialCredit() | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         var credit = await creditService.GetSocialCredit(currentUser.Id); |         var credit = await creditService.GetSocialCredit(currentUser.Id); | ||||||
|         return Ok(credit); |         return Ok(credit); | ||||||
| @@ -913,7 +917,7 @@ public class AccountCurrentController( | |||||||
|         [FromQuery] int offset = 0 |         [FromQuery] int offset = 0 | ||||||
|     ) |     ) | ||||||
|     { |     { | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |         if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); | ||||||
|  |  | ||||||
|         var queryable = db.SocialCreditRecords |         var queryable = db.SocialCreditRecords | ||||||
|             .Where(r => r.AccountId == currentUser.Id) |             .Where(r => r.AccountId == currentUser.Id) | ||||||
|   | |||||||
| @@ -1,9 +1,13 @@ | |||||||
| using System.Globalization; | using System.Globalization; | ||||||
| using DysonNetwork.Pass.Wallet; | using DysonNetwork.Pass.Wallet; | ||||||
| using DysonNetwork.Shared.Cache; | using DysonNetwork.Shared.Cache; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
|  | using DysonNetwork.Shared.Stream; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.Extensions.Localization; | using Microsoft.Extensions.Localization; | ||||||
|  | using NATS.Client.Core; | ||||||
|  | using NATS.Net; | ||||||
| using NodaTime; | using NodaTime; | ||||||
| using NodaTime.Extensions; | using NodaTime.Extensions; | ||||||
|  |  | ||||||
| @@ -16,7 +20,8 @@ public class AccountEventService( | |||||||
|     IStringLocalizer<Localization.AccountEventResource> localizer, |     IStringLocalizer<Localization.AccountEventResource> localizer, | ||||||
|     RingService.RingServiceClient pusher, |     RingService.RingServiceClient pusher, | ||||||
|     SubscriptionService subscriptions, |     SubscriptionService subscriptions, | ||||||
|     Pass.Leveling.ExperienceService experienceService |     Pass.Leveling.ExperienceService experienceService, | ||||||
|  |     INatsConnection nats | ||||||
| ) | ) | ||||||
| { | { | ||||||
|     private static readonly Random Random = new(); |     private static readonly Random Random = new(); | ||||||
| @@ -36,10 +41,23 @@ public class AccountEventService( | |||||||
|         cache.RemoveAsync(cacheKey); |         cache.RemoveAsync(cacheKey); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<Status> GetStatus(Guid userId) |     private async Task BroadcastStatusUpdate(SnAccountStatus status) | ||||||
|  |     { | ||||||
|  |         await nats.PublishAsync( | ||||||
|  |             AccountStatusUpdatedEvent.Type, | ||||||
|  |             GrpcTypeHelper.ConvertObjectToByteString(new AccountStatusUpdatedEvent | ||||||
|  |             { | ||||||
|  |                 AccountId = status.AccountId, | ||||||
|  |                 Status = status, | ||||||
|  |                 UpdatedAt = SystemClock.Instance.GetCurrentInstant() | ||||||
|  |             }).ToByteArray() | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<SnAccountStatus> GetStatus(Guid userId) | ||||||
|     { |     { | ||||||
|         var cacheKey = $"{StatusCacheKey}{userId}"; |         var cacheKey = $"{StatusCacheKey}{userId}"; | ||||||
|         var cachedStatus = await cache.GetAsync<Status>(cacheKey); |         var cachedStatus = await cache.GetAsync<SnAccountStatus>(cacheKey); | ||||||
|         if (cachedStatus is not null) |         if (cachedStatus is not null) | ||||||
|         { |         { | ||||||
|             cachedStatus!.IsOnline = !cachedStatus.IsInvisible && await GetAccountIsConnected(userId); |             cachedStatus!.IsOnline = !cachedStatus.IsInvisible && await GetAccountIsConnected(userId); | ||||||
| @@ -63,9 +81,9 @@ public class AccountEventService( | |||||||
|  |  | ||||||
|         if (isOnline) |         if (isOnline) | ||||||
|         { |         { | ||||||
|             return new Status |             return new SnAccountStatus | ||||||
|             { |             { | ||||||
|                 Attitude = StatusAttitude.Neutral, |                 Attitude = Shared.Models.StatusAttitude.Neutral, | ||||||
|                 IsOnline = true, |                 IsOnline = true, | ||||||
|                 IsCustomized = false, |                 IsCustomized = false, | ||||||
|                 Label = "Online", |                 Label = "Online", | ||||||
| @@ -73,9 +91,9 @@ public class AccountEventService( | |||||||
|             }; |             }; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         return new Status |         return new SnAccountStatus | ||||||
|         { |         { | ||||||
|             Attitude = StatusAttitude.Neutral, |             Attitude = Shared.Models.StatusAttitude.Neutral, | ||||||
|             IsOnline = false, |             IsOnline = false, | ||||||
|             IsCustomized = false, |             IsCustomized = false, | ||||||
|             Label = "Offline", |             Label = "Offline", | ||||||
| @@ -83,15 +101,15 @@ public class AccountEventService( | |||||||
|         }; |         }; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<Dictionary<Guid, Status>> GetStatuses(List<Guid> userIds) |     public async Task<Dictionary<Guid, SnAccountStatus>> GetStatuses(List<Guid> userIds) | ||||||
|     { |     { | ||||||
|         var results = new Dictionary<Guid, Status>(); |         var results = new Dictionary<Guid, SnAccountStatus>(); | ||||||
|         var cacheMissUserIds = new List<Guid>(); |         var cacheMissUserIds = new List<Guid>(); | ||||||
|  |  | ||||||
|         foreach (var userId in userIds) |         foreach (var userId in userIds) | ||||||
|         { |         { | ||||||
|             var cacheKey = $"{StatusCacheKey}{userId}"; |             var cacheKey = $"{StatusCacheKey}{userId}"; | ||||||
|             var cachedStatus = await cache.GetAsync<Status>(cacheKey); |             var cachedStatus = await cache.GetAsync<SnAccountStatus>(cacheKey); | ||||||
|             if (cachedStatus != null) |             if (cachedStatus != null) | ||||||
|             { |             { | ||||||
|                 cachedStatus.IsOnline = !cachedStatus.IsInvisible && await GetAccountIsConnected(userId); |                 cachedStatus.IsOnline = !cachedStatus.IsInvisible && await GetAccountIsConnected(userId); | ||||||
| @@ -131,9 +149,9 @@ public class AccountEventService( | |||||||
|                 foreach (var userId in usersWithoutStatus) |                 foreach (var userId in usersWithoutStatus) | ||||||
|                 { |                 { | ||||||
|                     var isOnline = await GetAccountIsConnected(userId); |                     var isOnline = await GetAccountIsConnected(userId); | ||||||
|                     var defaultStatus = new Status |                     var defaultStatus = new SnAccountStatus | ||||||
|                     { |                     { | ||||||
|                         Attitude = StatusAttitude.Neutral, |                         Attitude = Shared.Models.StatusAttitude.Neutral, | ||||||
|                         IsOnline = isOnline, |                         IsOnline = isOnline, | ||||||
|                         IsCustomized = false, |                         IsCustomized = false, | ||||||
|                         Label = isOnline ? "Online" : "Offline", |                         Label = isOnline ? "Online" : "Offline", | ||||||
| @@ -147,7 +165,7 @@ public class AccountEventService( | |||||||
|         return results; |         return results; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<Status> CreateStatus(Account user, Status status) |     public async Task<SnAccountStatus> CreateStatus(SnAccount user, SnAccountStatus status) | ||||||
|     { |     { | ||||||
|         var now = SystemClock.Instance.GetCurrentInstant(); |         var now = SystemClock.Instance.GetCurrentInstant(); | ||||||
|         await db.AccountStatuses |         await db.AccountStatuses | ||||||
| @@ -157,22 +175,25 @@ public class AccountEventService( | |||||||
|         db.AccountStatuses.Add(status); |         db.AccountStatuses.Add(status); | ||||||
|         await db.SaveChangesAsync(); |         await db.SaveChangesAsync(); | ||||||
|  |  | ||||||
|  |         await BroadcastStatusUpdate(status); | ||||||
|  |  | ||||||
|         return status; |         return status; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task ClearStatus(Account user, Status status) |     public async Task ClearStatus(SnAccount user, SnAccountStatus status) | ||||||
|     { |     { | ||||||
|         status.ClearedAt = SystemClock.Instance.GetCurrentInstant(); |         status.ClearedAt = SystemClock.Instance.GetCurrentInstant(); | ||||||
|         db.Update(status); |         db.Update(status); | ||||||
|         await db.SaveChangesAsync(); |         await db.SaveChangesAsync(); | ||||||
|         PurgeStatusCache(user.Id); |         PurgeStatusCache(user.Id); | ||||||
|  |         await BroadcastStatusUpdate(status); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     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 = "checkin:captcha:"; |     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(SnAccount user) | ||||||
|     { |     { | ||||||
|         var perkSubscription = await subscriptions.GetPerkSubscriptionAsync(user.Id); |         var perkSubscription = await subscriptions.GetPerkSubscriptionAsync(user.Id); | ||||||
|         if (perkSubscription is not null) return false; |         if (perkSubscription is not null) return false; | ||||||
| @@ -187,7 +208,7 @@ public class AccountEventService( | |||||||
|         return result; |         return result; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<bool> CheckInDailyIsAvailable(Account user) |     public async Task<bool> CheckInDailyIsAvailable(SnAccount user) | ||||||
|     { |     { | ||||||
|         var now = SystemClock.Instance.GetCurrentInstant(); |         var now = SystemClock.Instance.GetCurrentInstant(); | ||||||
|         var lastCheckIn = await db.AccountCheckInResults |         var lastCheckIn = await db.AccountCheckInResults | ||||||
| @@ -204,7 +225,7 @@ public class AccountEventService( | |||||||
|         return lastDate < currentDate; |         return lastDate < currentDate; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<bool> CheckInBackdatedIsAvailable(Account user, Instant backdated) |     public async Task<bool> CheckInBackdatedIsAvailable(SnAccount user, Instant backdated) | ||||||
|     { |     { | ||||||
|         var aDay = Duration.FromDays(1); |         var aDay = Duration.FromDays(1); | ||||||
|         var backdatedStart = backdated.ToDateTimeUtc().Date.ToInstant(); |         var backdatedStart = backdated.ToDateTimeUtc().Date.ToInstant(); | ||||||
| @@ -250,9 +271,9 @@ public class AccountEventService( | |||||||
|         return backdatedCheckInMonths < 4; |         return backdatedCheckInMonths < 4; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public const string CheckInLockKey = "checkin:lock:"; |     private const string CheckInLockKey = "checkin:lock:"; | ||||||
|  |  | ||||||
|     public async Task<CheckInResult> CheckInDaily(Account user, Instant? backdated = null) |     public async Task<SnCheckInResult> CheckInDaily(SnAccount user, Instant? backdated = null) | ||||||
|     { |     { | ||||||
|         var lockKey = $"{CheckInLockKey}{user.Id}"; |         var lockKey = $"{CheckInLockKey}{user.Id}"; | ||||||
|  |  | ||||||
| @@ -270,9 +291,7 @@ public class AccountEventService( | |||||||
|  |  | ||||||
|         // Now try to acquire the lock properly |         // Now try to acquire the lock properly | ||||||
|         await using var lockObj = |         await using var lockObj = | ||||||
|             await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5)); |             await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5)) ?? throw new InvalidOperationException("Check-in was in progress."); | ||||||
|         if (lockObj is null) throw new InvalidOperationException("Check-in was in progress."); |  | ||||||
|  |  | ||||||
|         var cultureInfo = new CultureInfo(user.Language, false); |         var cultureInfo = new CultureInfo(user.Language, false); | ||||||
|         CultureInfo.CurrentCulture = cultureInfo; |         CultureInfo.CurrentCulture = cultureInfo; | ||||||
|         CultureInfo.CurrentUICulture = cultureInfo; |         CultureInfo.CurrentUICulture = cultureInfo; | ||||||
| @@ -282,9 +301,10 @@ public class AccountEventService( | |||||||
|             .OrderBy(_ => Random.Next()) |             .OrderBy(_ => Random.Next()) | ||||||
|             .Take(2) |             .Take(2) | ||||||
|             .ToList(); |             .ToList(); | ||||||
|         var tips = positiveIndices.Select(index => new FortuneTip |         var tips = positiveIndices.Select(index => new CheckInFortuneTip | ||||||
|         { |         { | ||||||
|             IsPositive = true, Title = localizer[$"FortuneTipPositiveTitle_{index}"].Value, |             IsPositive = true, | ||||||
|  |             Title = localizer[$"FortuneTipPositiveTitle_{index}"].Value, | ||||||
|             Content = localizer[$"FortuneTipPositiveContent_{index}"].Value |             Content = localizer[$"FortuneTipPositiveContent_{index}"].Value | ||||||
|         }).ToList(); |         }).ToList(); | ||||||
|  |  | ||||||
| @@ -294,16 +314,33 @@ public class AccountEventService( | |||||||
|             .OrderBy(_ => Random.Next()) |             .OrderBy(_ => Random.Next()) | ||||||
|             .Take(2) |             .Take(2) | ||||||
|             .ToList(); |             .ToList(); | ||||||
|         tips.AddRange(negativeIndices.Select(index => new FortuneTip |         tips.AddRange(negativeIndices.Select(index => new CheckInFortuneTip | ||||||
|         { |         { | ||||||
|             IsPositive = false, Title = localizer[$"FortuneTipNegativeTitle_{index}"].Value, |             IsPositive = false, | ||||||
|  |             Title = localizer[$"FortuneTipNegativeTitle_{index}"].Value, | ||||||
|             Content = localizer[$"FortuneTipNegativeContent_{index}"].Value |             Content = localizer[$"FortuneTipNegativeContent_{index}"].Value | ||||||
|         })); |         })); | ||||||
|  |  | ||||||
|         var result = new CheckInResult |         // The 5 is specialized, keep it alone. | ||||||
|  |         var sum = 0; | ||||||
|  |         var maxLevel = Enum.GetValues<CheckInResultLevel>().Length - 1; | ||||||
|  |         for (var i = 0; i < 5; i++) | ||||||
|  |             sum += Random.Next(maxLevel); | ||||||
|  |         var checkInLevel = (CheckInResultLevel)(sum / 5); | ||||||
|  |  | ||||||
|  |         var accountBirthday = await db.AccountProfiles | ||||||
|  |             .Where(x => x.AccountId == user.Id) | ||||||
|  |             .Select(x => x.Birthday) | ||||||
|  |             .FirstOrDefaultAsync(); | ||||||
|  |  | ||||||
|  |         var now = SystemClock.Instance.GetCurrentInstant().InUtc().Date; | ||||||
|  |         if (accountBirthday.HasValue && accountBirthday.Value.InUtc().Date == now) | ||||||
|  |             checkInLevel = CheckInResultLevel.Special; | ||||||
|  |  | ||||||
|  |         var result = new SnCheckInResult | ||||||
|         { |         { | ||||||
|             Tips = tips, |             Tips = tips, | ||||||
|             Level = (CheckInResultLevel)Random.Next(Enum.GetValues<CheckInResultLevel>().Length), |             Level = checkInLevel, | ||||||
|             AccountId = user.Id, |             AccountId = user.Id, | ||||||
|             RewardExperience = 100, |             RewardExperience = 100, | ||||||
|             RewardPoints = backdated.HasValue ? null : 10, |             RewardPoints = backdated.HasValue ? null : 10, | ||||||
| @@ -311,7 +348,6 @@ public class AccountEventService( | |||||||
|             CreatedAt = backdated ?? SystemClock.Instance.GetCurrentInstant(), |             CreatedAt = backdated ?? SystemClock.Instance.GetCurrentInstant(), | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         var now = SystemClock.Instance.GetCurrentInstant().InUtc().Date; |  | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
|             if (result.RewardPoints.HasValue) |             if (result.RewardPoints.HasValue) | ||||||
| @@ -342,7 +378,7 @@ public class AccountEventService( | |||||||
|         return result; |         return result; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<List<DailyEventResponse>> GetEventCalendar(Account user, int month, int year = 0, |     public async Task<List<DailyEventResponse>> GetEventCalendar(SnAccount user, int month, int year = 0, | ||||||
|         bool replaceInvisible = false) |         bool replaceInvisible = false) | ||||||
|     { |     { | ||||||
|         if (year == 0) |         if (year == 0) | ||||||
| @@ -356,7 +392,7 @@ public class AccountEventService( | |||||||
|             .AsNoTracking() |             .AsNoTracking() | ||||||
|             .TagWith("eventcal: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 SnAccountStatus | ||||||
|             { |             { | ||||||
|                 Id = x.Id, |                 Id = x.Id, | ||||||
|                 Attitude = x.Attitude, |                 Attitude = x.Attitude, | ||||||
| @@ -394,7 +430,7 @@ public class AccountEventService( | |||||||
|             { |             { | ||||||
|                 Date = date, |                 Date = date, | ||||||
|                 CheckInResult = checkInByDate.GetValueOrDefault(utcDate), |                 CheckInResult = checkInByDate.GetValueOrDefault(utcDate), | ||||||
|                 Statuses = statusesByDate.GetValueOrDefault(utcDate, new List<Status>()) |                 Statuses = statusesByDate.GetValueOrDefault(utcDate, new List<SnAccountStatus>()) | ||||||
|             }; |             }; | ||||||
|         }).ToList(); |         }).ToList(); | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -1,19 +1,14 @@ | |||||||
| using System.Globalization; | using System.Globalization; | ||||||
| using System.Text.Json; |  | ||||||
| using DysonNetwork.Pass.Auth; |  | ||||||
| using DysonNetwork.Pass.Auth.OpenId; | using DysonNetwork.Pass.Auth.OpenId; | ||||||
| using DysonNetwork.Pass.Localization; | using DysonNetwork.Pass.Localization; | ||||||
| using DysonNetwork.Pass.Mailer; | using DysonNetwork.Pass.Mailer; | ||||||
| using DysonNetwork.Pass.Permission; |  | ||||||
| using DysonNetwork.Shared.Cache; | using DysonNetwork.Shared.Cache; | ||||||
| using DysonNetwork.Shared.Data; | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
| using DysonNetwork.Shared.Stream; | using DysonNetwork.Shared.Stream; | ||||||
| using EFCore.BulkExtensions; |  | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.Extensions.Localization; | using Microsoft.Extensions.Localization; | ||||||
| using NATS.Client.Core; | using NATS.Client.Core; | ||||||
| using NATS.Client.JetStream; |  | ||||||
| using NATS.Net; | using NATS.Net; | ||||||
| using NodaTime; | using NodaTime; | ||||||
| using OtpNet; | using OtpNet; | ||||||
| @@ -36,7 +31,7 @@ public class AccountService( | |||||||
|     INatsConnection nats |     INatsConnection nats | ||||||
| ) | ) | ||||||
| { | { | ||||||
|     public static void SetCultureInfo(Account account) |     public static void SetCultureInfo(SnAccount account) | ||||||
|     { |     { | ||||||
|         SetCultureInfo(account.Language); |         SetCultureInfo(account.Language); | ||||||
|     } |     } | ||||||
| @@ -50,12 +45,12 @@ public class AccountService( | |||||||
|  |  | ||||||
|     public const string AccountCachePrefix = "account:"; |     public const string AccountCachePrefix = "account:"; | ||||||
|  |  | ||||||
|     public async Task PurgeAccountCache(Account account) |     public async Task PurgeAccountCache(SnAccount account) | ||||||
|     { |     { | ||||||
|         await cache.RemoveGroupAsync($"{AccountCachePrefix}{account.Id}"); |         await cache.RemoveGroupAsync($"{AccountCachePrefix}{account.Id}"); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<Account?> LookupAccount(string probe) |     public async Task<SnAccount?> LookupAccount(string probe) | ||||||
|     { |     { | ||||||
|         var account = await db.Accounts.Where(a => a.Name == probe).FirstOrDefaultAsync(); |         var account = await db.Accounts.Where(a => a.Name == probe).FirstOrDefaultAsync(); | ||||||
|         if (account is not null) return account; |         if (account is not null) return account; | ||||||
| @@ -67,7 +62,7 @@ public class AccountService( | |||||||
|         return contact?.Account; |         return contact?.Account; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<Account?> LookupAccountByConnection(string identifier, string provider) |     public async Task<SnAccount?> LookupAccountByConnection(string identifier, string provider) | ||||||
|     { |     { | ||||||
|         var connection = await db.AccountConnections |         var connection = await db.AccountConnections | ||||||
|             .Where(c => c.ProvidedIdentifier == identifier && c.Provider == provider) |             .Where(c => c.ProvidedIdentifier == identifier && c.Provider == provider) | ||||||
| @@ -84,7 +79,7 @@ public class AccountService( | |||||||
|         return profile?.Level; |         return profile?.Level; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<Account> CreateAccount( |     public async Task<SnAccount> CreateAccount( | ||||||
|         string name, |         string name, | ||||||
|         string nick, |         string nick, | ||||||
|         string email, |         string email, | ||||||
| @@ -100,39 +95,39 @@ public class AccountService( | |||||||
|             throw new InvalidOperationException("Account name has already been taken."); |             throw new InvalidOperationException("Account name has already been taken."); | ||||||
|  |  | ||||||
|         var dupeEmailCount = await db.AccountContacts |         var dupeEmailCount = await db.AccountContacts | ||||||
|             .Where(c => c.Content == email && c.Type == AccountContactType.Email |             .Where(c => c.Content == email && c.Type == Shared.Models.AccountContactType.Email | ||||||
|             ).CountAsync(); |             ).CountAsync(); | ||||||
|         if (dupeEmailCount > 0) |         if (dupeEmailCount > 0) | ||||||
|             throw new InvalidOperationException("Account email has already been used."); |             throw new InvalidOperationException("Account email has already been used."); | ||||||
|  |  | ||||||
|         var account = new Account |         var account = new SnAccount | ||||||
|         { |         { | ||||||
|             Name = name, |             Name = name, | ||||||
|             Nick = nick, |             Nick = nick, | ||||||
|             Language = language, |             Language = language, | ||||||
|             Region = region, |             Region = region, | ||||||
|             Contacts = new List<AccountContact> |             Contacts = | ||||||
|             { |             [ | ||||||
|                 new() |                 new() | ||||||
|                 { |                 { | ||||||
|                     Type = AccountContactType.Email, |                     Type = Shared.Models.AccountContactType.Email, | ||||||
|                     Content = email, |                     Content = email, | ||||||
|                     VerifiedAt = isEmailVerified ? SystemClock.Instance.GetCurrentInstant() : null, |                     VerifiedAt = isEmailVerified ? SystemClock.Instance.GetCurrentInstant() : null, | ||||||
|                     IsPrimary = true |                     IsPrimary = true | ||||||
|                 } |                 } | ||||||
|             }, |             ], | ||||||
|             AuthFactors = password is not null |             AuthFactors = password is not null | ||||||
|                 ? new List<AccountAuthFactor> |                 ? new List<SnAccountAuthFactor> | ||||||
|                 { |                 { | ||||||
|                     new AccountAuthFactor |                     new SnAccountAuthFactor | ||||||
|                     { |                     { | ||||||
|                         Type = AccountAuthFactorType.Password, |                         Type = Shared.Models.AccountAuthFactorType.Password, | ||||||
|                         Secret = password, |                         Secret = password, | ||||||
|                         EnabledAt = SystemClock.Instance.GetCurrentInstant() |                         EnabledAt = SystemClock.Instance.GetCurrentInstant() | ||||||
|                     }.HashSecret() |                     }.HashSecret() | ||||||
|                 } |                 } | ||||||
|                 : [], |                 : [], | ||||||
|             Profile = new AccountProfile() |             Profile = new SnAccountProfile() | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         if (isActivated) |         if (isActivated) | ||||||
| @@ -141,7 +136,7 @@ public class AccountService( | |||||||
|             var defaultGroup = await db.PermissionGroups.FirstOrDefaultAsync(g => g.Key == "default"); |             var defaultGroup = await db.PermissionGroups.FirstOrDefaultAsync(g => g.Key == "default"); | ||||||
|             if (defaultGroup is not null) |             if (defaultGroup is not null) | ||||||
|             { |             { | ||||||
|                 db.PermissionGroupMembers.Add(new PermissionGroupMember |                 db.PermissionGroupMembers.Add(new SnPermissionGroupMember | ||||||
|                 { |                 { | ||||||
|                     Actor = $"user:{account.Id}", |                     Actor = $"user:{account.Id}", | ||||||
|                     Group = defaultGroup |                     Group = defaultGroup | ||||||
| @@ -167,7 +162,7 @@ public class AccountService( | |||||||
|         return account; |         return account; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<Account> CreateAccount(OidcUserInfo userInfo) |     public async Task<SnAccount> CreateAccount(OidcUserInfo userInfo) | ||||||
|     { |     { | ||||||
|         if (string.IsNullOrEmpty(userInfo.Email)) |         if (string.IsNullOrEmpty(userInfo.Email)) | ||||||
|             throw new ArgumentException("Email is required for account creation"); |             throw new ArgumentException("Email is required for account creation"); | ||||||
| @@ -191,7 +186,7 @@ public class AccountService( | |||||||
|         ); |         ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<Account> CreateBotAccount(Account account, Guid automatedId, string? pictureId, |     public async Task<SnAccount> CreateBotAccount(SnAccount account, Guid automatedId, string? pictureId, | ||||||
|         string? backgroundId) |         string? backgroundId) | ||||||
|     { |     { | ||||||
|         var dupeAutomateCount = await db.Accounts.Where(a => a.AutomatedId == automatedId).CountAsync(); |         var dupeAutomateCount = await db.Accounts.Where(a => a.AutomatedId == automatedId).CountAsync(); | ||||||
| @@ -217,7 +212,7 @@ public class AccountService( | |||||||
|                     Usage = "profile.picture" |                     Usage = "profile.picture" | ||||||
|                 } |                 } | ||||||
|             ); |             ); | ||||||
|             account.Profile.Picture = CloudFileReferenceObject.FromProtoValue(file); |             account.Profile.Picture = SnCloudFileReferenceObject.FromProtoValue(file); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if (!string.IsNullOrEmpty(backgroundId)) |         if (!string.IsNullOrEmpty(backgroundId)) | ||||||
| @@ -231,7 +226,7 @@ public class AccountService( | |||||||
|                     Usage = "profile.background" |                     Usage = "profile.background" | ||||||
|                 } |                 } | ||||||
|             ); |             ); | ||||||
|             account.Profile.Background = CloudFileReferenceObject.FromProtoValue(file); |             account.Profile.Background = SnCloudFileReferenceObject.FromProtoValue(file); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         db.Accounts.Add(account); |         db.Accounts.Add(account); | ||||||
| @@ -240,12 +235,12 @@ public class AccountService( | |||||||
|         return account; |         return account; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<Account?> GetBotAccount(Guid automatedId) |     public async Task<SnAccount?> GetBotAccount(Guid automatedId) | ||||||
|     { |     { | ||||||
|         return await db.Accounts.FirstOrDefaultAsync(a => a.AutomatedId == automatedId); |         return await db.Accounts.FirstOrDefaultAsync(a => a.AutomatedId == automatedId); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task RequestAccountDeletion(Account account) |     public async Task RequestAccountDeletion(SnAccount account) | ||||||
|     { |     { | ||||||
|         var spell = await spells.CreateMagicSpell( |         var spell = await spells.CreateMagicSpell( | ||||||
|             account, |             account, | ||||||
| @@ -257,7 +252,7 @@ public class AccountService( | |||||||
|         await spells.NotifyMagicSpell(spell); |         await spells.NotifyMagicSpell(spell); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task RequestPasswordReset(Account account) |     public async Task RequestPasswordReset(SnAccount account) | ||||||
|     { |     { | ||||||
|         var spell = await spells.CreateMagicSpell( |         var spell = await spells.CreateMagicSpell( | ||||||
|             account, |             account, | ||||||
| @@ -269,7 +264,7 @@ public class AccountService( | |||||||
|         await spells.NotifyMagicSpell(spell); |         await spells.NotifyMagicSpell(spell); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<bool> CheckAuthFactorExists(Account account, AccountAuthFactorType type) |     public async Task<bool> CheckAuthFactorExists(SnAccount account, Shared.Models.AccountAuthFactorType type) | ||||||
|     { |     { | ||||||
|         var isExists = await db.AccountAuthFactors |         var isExists = await db.AccountAuthFactors | ||||||
|             .Where(x => x.AccountId == account.Id && x.Type == type) |             .Where(x => x.AccountId == account.Id && x.Type == type) | ||||||
| @@ -277,45 +272,45 @@ public class AccountService( | |||||||
|         return isExists; |         return isExists; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<AccountAuthFactor?> CreateAuthFactor(Account account, AccountAuthFactorType type, string? secret) |     public async Task<SnAccountAuthFactor?> CreateAuthFactor(SnAccount account, Shared.Models.AccountAuthFactorType type, string? secret) | ||||||
|     { |     { | ||||||
|         AccountAuthFactor? factor = null; |         SnAccountAuthFactor? factor = null; | ||||||
|         switch (type) |         switch (type) | ||||||
|         { |         { | ||||||
|             case AccountAuthFactorType.Password: |             case Shared.Models.AccountAuthFactorType.Password: | ||||||
|                 if (string.IsNullOrWhiteSpace(secret)) throw new ArgumentNullException(nameof(secret)); |                 if (string.IsNullOrWhiteSpace(secret)) throw new ArgumentNullException(nameof(secret)); | ||||||
|                 factor = new AccountAuthFactor |                 factor = new SnAccountAuthFactor | ||||||
|                 { |                 { | ||||||
|                     Type = AccountAuthFactorType.Password, |                     Type = Shared.Models.AccountAuthFactorType.Password, | ||||||
|                     Trustworthy = 1, |                     Trustworthy = 1, | ||||||
|                     AccountId = account.Id, |                     AccountId = account.Id, | ||||||
|                     Secret = secret, |                     Secret = secret, | ||||||
|                     EnabledAt = SystemClock.Instance.GetCurrentInstant(), |                     EnabledAt = SystemClock.Instance.GetCurrentInstant(), | ||||||
|                 }.HashSecret(); |                 }.HashSecret(); | ||||||
|                 break; |                 break; | ||||||
|             case AccountAuthFactorType.EmailCode: |             case Shared.Models.AccountAuthFactorType.EmailCode: | ||||||
|                 factor = new AccountAuthFactor |                 factor = new SnAccountAuthFactor | ||||||
|                 { |                 { | ||||||
|                     Type = AccountAuthFactorType.EmailCode, |                     Type = Shared.Models.AccountAuthFactorType.EmailCode, | ||||||
|                     Trustworthy = 2, |                     Trustworthy = 2, | ||||||
|                     EnabledAt = SystemClock.Instance.GetCurrentInstant(), |                     EnabledAt = SystemClock.Instance.GetCurrentInstant(), | ||||||
|                 }; |                 }; | ||||||
|                 break; |                 break; | ||||||
|             case AccountAuthFactorType.InAppCode: |             case Shared.Models.AccountAuthFactorType.InAppCode: | ||||||
|                 factor = new AccountAuthFactor |                 factor = new SnAccountAuthFactor | ||||||
|                 { |                 { | ||||||
|                     Type = AccountAuthFactorType.InAppCode, |                     Type = Shared.Models.AccountAuthFactorType.InAppCode, | ||||||
|                     Trustworthy = 1, |                     Trustworthy = 1, | ||||||
|                     EnabledAt = SystemClock.Instance.GetCurrentInstant() |                     EnabledAt = SystemClock.Instance.GetCurrentInstant() | ||||||
|                 }; |                 }; | ||||||
|                 break; |                 break; | ||||||
|             case AccountAuthFactorType.TimedCode: |             case Shared.Models.AccountAuthFactorType.TimedCode: | ||||||
|                 var skOtp = KeyGeneration.GenerateRandomKey(20); |                 var skOtp = KeyGeneration.GenerateRandomKey(20); | ||||||
|                 var skOtp32 = Base32Encoding.ToString(skOtp); |                 var skOtp32 = Base32Encoding.ToString(skOtp); | ||||||
|                 factor = new AccountAuthFactor |                 factor = new SnAccountAuthFactor | ||||||
|                 { |                 { | ||||||
|                     Secret = skOtp32, |                     Secret = skOtp32, | ||||||
|                     Type = AccountAuthFactorType.TimedCode, |                     Type = Shared.Models.AccountAuthFactorType.TimedCode, | ||||||
|                     Trustworthy = 2, |                     Trustworthy = 2, | ||||||
|                     EnabledAt = null, // It needs to be tired once to enable |                     EnabledAt = null, // It needs to be tired once to enable | ||||||
|                     CreatedResponse = new Dictionary<string, object> |                     CreatedResponse = new Dictionary<string, object> | ||||||
| @@ -329,13 +324,13 @@ public class AccountService( | |||||||
|                     } |                     } | ||||||
|                 }; |                 }; | ||||||
|                 break; |                 break; | ||||||
|             case AccountAuthFactorType.PinCode: |             case Shared.Models.AccountAuthFactorType.PinCode: | ||||||
|                 if (string.IsNullOrWhiteSpace(secret)) throw new ArgumentNullException(nameof(secret)); |                 if (string.IsNullOrWhiteSpace(secret)) throw new ArgumentNullException(nameof(secret)); | ||||||
|                 if (!secret.All(char.IsDigit) || secret.Length != 6) |                 if (!secret.All(char.IsDigit) || secret.Length != 6) | ||||||
|                     throw new ArgumentException("PIN code must be exactly 6 digits"); |                     throw new ArgumentException("PIN code must be exactly 6 digits"); | ||||||
|                 factor = new AccountAuthFactor |                 factor = new SnAccountAuthFactor | ||||||
|                 { |                 { | ||||||
|                     Type = AccountAuthFactorType.PinCode, |                     Type = Shared.Models.AccountAuthFactorType.PinCode, | ||||||
|                     Trustworthy = 0, // Only for confirming, can't be used for login |                     Trustworthy = 0, // Only for confirming, can't be used for login | ||||||
|                     Secret = secret, |                     Secret = secret, | ||||||
|                     EnabledAt = SystemClock.Instance.GetCurrentInstant(), |                     EnabledAt = SystemClock.Instance.GetCurrentInstant(), | ||||||
| @@ -352,10 +347,10 @@ public class AccountService( | |||||||
|         return factor; |         return factor; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<AccountAuthFactor> EnableAuthFactor(AccountAuthFactor factor, string? code) |     public async Task<SnAccountAuthFactor> EnableAuthFactor(SnAccountAuthFactor factor, string? code) | ||||||
|     { |     { | ||||||
|         if (factor.EnabledAt is not null) throw new ArgumentException("The factor has been enabled."); |         if (factor.EnabledAt is not null) throw new ArgumentException("The factor has been enabled."); | ||||||
|         if (factor.Type is AccountAuthFactorType.Password or AccountAuthFactorType.TimedCode) |         if (factor.Type is Shared.Models.AccountAuthFactorType.Password or Shared.Models.AccountAuthFactorType.TimedCode) | ||||||
|         { |         { | ||||||
|             if (code is null || !factor.VerifyPassword(code)) |             if (code is null || !factor.VerifyPassword(code)) | ||||||
|                 throw new InvalidOperationException( |                 throw new InvalidOperationException( | ||||||
| @@ -370,7 +365,7 @@ public class AccountService( | |||||||
|         return factor; |         return factor; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<AccountAuthFactor> DisableAuthFactor(AccountAuthFactor factor) |     public async Task<SnAccountAuthFactor> DisableAuthFactor(SnAccountAuthFactor factor) | ||||||
|     { |     { | ||||||
|         if (factor.EnabledAt is null) throw new ArgumentException("The factor has been disabled."); |         if (factor.EnabledAt is null) throw new ArgumentException("The factor has been disabled."); | ||||||
|  |  | ||||||
| @@ -388,7 +383,7 @@ public class AccountService( | |||||||
|         return factor; |         return factor; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task DeleteAuthFactor(AccountAuthFactor factor) |     public async Task DeleteAuthFactor(SnAccountAuthFactor factor) | ||||||
|     { |     { | ||||||
|         var count = await db.AccountAuthFactors |         var count = await db.AccountAuthFactors | ||||||
|             .Where(f => f.AccountId == factor.AccountId) |             .Where(f => f.AccountId == factor.AccountId) | ||||||
| @@ -406,13 +401,13 @@ public class AccountService( | |||||||
|     /// </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> | ||||||
|     public async Task SendFactorCode(Account account, AccountAuthFactor factor) |     public async Task SendFactorCode(SnAccount account, SnAccountAuthFactor factor) | ||||||
|     { |     { | ||||||
|         var code = new Random().Next(100000, 999999).ToString("000000"); |         var code = new Random().Next(100000, 999999).ToString("000000"); | ||||||
|  |  | ||||||
|         switch (factor.Type) |         switch (factor.Type) | ||||||
|         { |         { | ||||||
|             case AccountAuthFactorType.InAppCode: |             case Shared.Models.AccountAuthFactorType.InAppCode: | ||||||
|                 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."); | ||||||
|  |  | ||||||
| @@ -431,12 +426,12 @@ public class AccountService( | |||||||
|                 ); |                 ); | ||||||
|                 await _SetFactorCode(factor, code, TimeSpan.FromMinutes(5)); |                 await _SetFactorCode(factor, code, TimeSpan.FromMinutes(5)); | ||||||
|                 break; |                 break; | ||||||
|             case AccountAuthFactorType.EmailCode: |             case Shared.Models.AccountAuthFactorType.EmailCode: | ||||||
|                 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."); | ||||||
|  |  | ||||||
|                 var contact = await db.AccountContacts |                 var contact = await db.AccountContacts | ||||||
|                     .Where(c => c.Type == AccountContactType.Email) |                     .Where(c => c.Type == Shared.Models.AccountContactType.Email) | ||||||
|                     .Where(c => c.VerifiedAt != null) |                     .Where(c => c.VerifiedAt != null) | ||||||
|                     .Where(c => c.IsPrimary) |                     .Where(c => c.IsPrimary) | ||||||
|                     .Where(c => c.AccountId == account.Id) |                     .Where(c => c.AccountId == account.Id) | ||||||
| @@ -465,27 +460,27 @@ public class AccountService( | |||||||
|  |  | ||||||
|                 await _SetFactorCode(factor, code, TimeSpan.FromMinutes(30)); |                 await _SetFactorCode(factor, code, TimeSpan.FromMinutes(30)); | ||||||
|                 break; |                 break; | ||||||
|             case AccountAuthFactorType.Password: |             case Shared.Models.AccountAuthFactorType.Password: | ||||||
|             case AccountAuthFactorType.TimedCode: |             case Shared.Models.AccountAuthFactorType.TimedCode: | ||||||
|             default: |             default: | ||||||
|                 // No need to send, such as password etc... |                 // No need to send, such as password etc... | ||||||
|                 return; |                 return; | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<bool> VerifyFactorCode(AccountAuthFactor factor, string code) |     public async Task<bool> VerifyFactorCode(SnAccountAuthFactor factor, string code) | ||||||
|     { |     { | ||||||
|         switch (factor.Type) |         switch (factor.Type) | ||||||
|         { |         { | ||||||
|             case AccountAuthFactorType.EmailCode: |             case Shared.Models.AccountAuthFactorType.EmailCode: | ||||||
|             case AccountAuthFactorType.InAppCode: |             case Shared.Models.AccountAuthFactorType.InAppCode: | ||||||
|                 var correctCode = await _GetFactorCode(factor); |                 var correctCode = await _GetFactorCode(factor); | ||||||
|                 var isCorrect = correctCode is not null && |                 var isCorrect = correctCode is not null && | ||||||
|                                 string.Equals(correctCode, code, StringComparison.OrdinalIgnoreCase); |                                 string.Equals(correctCode, code, StringComparison.OrdinalIgnoreCase); | ||||||
|                 await cache.RemoveAsync($"{AuthFactorCachePrefix}{factor.Id}:code"); |                 await cache.RemoveAsync($"{AuthFactorCachePrefix}{factor.Id}:code"); | ||||||
|                 return isCorrect; |                 return isCorrect; | ||||||
|             case AccountAuthFactorType.Password: |             case Shared.Models.AccountAuthFactorType.Password: | ||||||
|             case AccountAuthFactorType.TimedCode: |             case Shared.Models.AccountAuthFactorType.TimedCode: | ||||||
|             default: |             default: | ||||||
|                 return factor.VerifyPassword(code); |                 return factor.VerifyPassword(code); | ||||||
|         } |         } | ||||||
| @@ -493,7 +488,7 @@ public class AccountService( | |||||||
|  |  | ||||||
|     private const string AuthFactorCachePrefix = "authfactor:"; |     private const string AuthFactorCachePrefix = "authfactor:"; | ||||||
|  |  | ||||||
|     private async Task _SetFactorCode(AccountAuthFactor factor, string code, TimeSpan expires) |     private async Task _SetFactorCode(SnAccountAuthFactor factor, string code, TimeSpan expires) | ||||||
|     { |     { | ||||||
|         await cache.SetAsync( |         await cache.SetAsync( | ||||||
|             $"{AuthFactorCachePrefix}{factor.Id}:code", |             $"{AuthFactorCachePrefix}{factor.Id}:code", | ||||||
| @@ -502,7 +497,7 @@ public class AccountService( | |||||||
|         ); |         ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private async Task<string?> _GetFactorCode(AccountAuthFactor factor) |     private async Task<string?> _GetFactorCode(SnAccountAuthFactor factor) | ||||||
|     { |     { | ||||||
|         return await cache.GetAsync<string?>( |         return await cache.GetAsync<string?>( | ||||||
|             $"{AuthFactorCachePrefix}{factor.Id}:code" |             $"{AuthFactorCachePrefix}{factor.Id}:code" | ||||||
| @@ -516,7 +511,7 @@ public class AccountService( | |||||||
|             .AnyAsync(s => s.Challenge.ClientId == id); |             .AnyAsync(s => s.Challenge.ClientId == id); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<AuthClient> UpdateDeviceName(Account account, string deviceId, string label) |     public async Task<SnAuthClient> UpdateDeviceName(SnAccount account, string deviceId, string label) | ||||||
|     { |     { | ||||||
|         var device = await db.AuthClients.FirstOrDefaultAsync(c => c.DeviceId == deviceId && c.AccountId == account.Id |         var device = await db.AuthClients.FirstOrDefaultAsync(c => c.DeviceId == deviceId && c.AccountId == account.Id | ||||||
|         ); |         ); | ||||||
| @@ -529,7 +524,7 @@ public class AccountService( | |||||||
|         return device; |         return device; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task DeleteSession(Account account, Guid sessionId) |     public async Task DeleteSession(SnAccount account, Guid sessionId) | ||||||
|     { |     { | ||||||
|         var session = await db.AuthSessions |         var session = await db.AuthSessions | ||||||
|             .Include(s => s.Challenge) |             .Include(s => s.Challenge) | ||||||
| @@ -555,7 +550,7 @@ public class AccountService( | |||||||
|         await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{session.Id}"); |         await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{session.Id}"); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task DeleteDevice(Account account, string deviceId) |     public async Task DeleteDevice(SnAccount account, string deviceId) | ||||||
|     { |     { | ||||||
|         var device = await db.AuthClients.FirstOrDefaultAsync(c => c.DeviceId == deviceId && c.AccountId == account.Id |         var device = await db.AuthClients.FirstOrDefaultAsync(c => c.DeviceId == deviceId && c.AccountId == account.Id | ||||||
|         ); |         ); | ||||||
| @@ -585,7 +580,7 @@ public class AccountService( | |||||||
|             await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{item.Id}"); |             await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{item.Id}"); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<AccountContact> CreateContactMethod(Account account, AccountContactType type, string content) |     public async Task<SnAccountContact> CreateContactMethod(SnAccount account, Shared.Models.AccountContactType type, string content) | ||||||
|     { |     { | ||||||
|         var isExists = await db.AccountContacts |         var isExists = await db.AccountContacts | ||||||
|             .Where(x => x.AccountId == account.Id && x.Type == type && x.Content == content) |             .Where(x => x.AccountId == account.Id && x.Type == type && x.Content == content) | ||||||
| @@ -593,7 +588,7 @@ public class AccountService( | |||||||
|         if (isExists) |         if (isExists) | ||||||
|             throw new InvalidOperationException("Contact method already exists."); |             throw new InvalidOperationException("Contact method already exists."); | ||||||
|  |  | ||||||
|         var contact = new AccountContact |         var contact = new SnAccountContact | ||||||
|         { |         { | ||||||
|             Type = type, |             Type = type, | ||||||
|             Content = content, |             Content = content, | ||||||
| @@ -606,7 +601,7 @@ public class AccountService( | |||||||
|         return contact; |         return contact; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task VerifyContactMethod(Account account, AccountContact contact) |     public async Task VerifyContactMethod(SnAccount account, SnAccountContact contact) | ||||||
|     { |     { | ||||||
|         var spell = await spells.CreateMagicSpell( |         var spell = await spells.CreateMagicSpell( | ||||||
|             account, |             account, | ||||||
| @@ -618,7 +613,7 @@ public class AccountService( | |||||||
|         await spells.NotifyMagicSpell(spell); |         await spells.NotifyMagicSpell(spell); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<AccountContact> SetContactMethodPrimary(Account account, AccountContact contact) |     public async Task<SnAccountContact> SetContactMethodPrimary(SnAccount account, SnAccountContact contact) | ||||||
|     { |     { | ||||||
|         if (contact.AccountId != account.Id) |         if (contact.AccountId != account.Id) | ||||||
|             throw new InvalidOperationException("Contact method does not belong to this account."); |             throw new InvalidOperationException("Contact method does not belong to this account."); | ||||||
| @@ -647,7 +642,7 @@ public class AccountService( | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<AccountContact> SetContactMethodPublic(Account account, AccountContact contact, bool isPublic) |     public async Task<SnAccountContact> SetContactMethodPublic(SnAccount account, SnAccountContact contact, bool isPublic) | ||||||
|     { |     { | ||||||
|         contact.IsPublic = isPublic; |         contact.IsPublic = isPublic; | ||||||
|         db.AccountContacts.Update(contact); |         db.AccountContacts.Update(contact); | ||||||
| @@ -655,7 +650,7 @@ public class AccountService( | |||||||
|         return contact; |         return contact; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task DeleteContactMethod(Account account, AccountContact contact) |     public async Task DeleteContactMethod(SnAccount account, SnAccountContact contact) | ||||||
|     { |     { | ||||||
|         if (contact.AccountId != account.Id) |         if (contact.AccountId != account.Id) | ||||||
|             throw new InvalidOperationException("Contact method does not belong to this account."); |             throw new InvalidOperationException("Contact method does not belong to this account."); | ||||||
| @@ -670,7 +665,7 @@ public class AccountService( | |||||||
|     /// This method will grant a badge to the account. |     /// This method will grant a badge to the account. | ||||||
|     /// Shouldn't be exposed to normal user and the user itself. |     /// Shouldn't be exposed to normal user and the user itself. | ||||||
|     /// </summary> |     /// </summary> | ||||||
|     public async Task<AccountBadge> GrantBadge(Account account, AccountBadge badge) |     public async Task<SnAccountBadge> GrantBadge(SnAccount account, SnAccountBadge badge) | ||||||
|     { |     { | ||||||
|         badge.AccountId = account.Id; |         badge.AccountId = account.Id; | ||||||
|         db.Badges.Add(badge); |         db.Badges.Add(badge); | ||||||
| @@ -682,14 +677,12 @@ public class AccountService( | |||||||
|     /// This method will revoke a badge from the account. |     /// This method will revoke a badge from the account. | ||||||
|     /// Shouldn't be exposed to normal user and the user itself. |     /// Shouldn't be exposed to normal user and the user itself. | ||||||
|     /// </summary> |     /// </summary> | ||||||
|     public async Task RevokeBadge(Account account, Guid badgeId) |     public async Task RevokeBadge(SnAccount account, Guid badgeId) | ||||||
|     { |     { | ||||||
|         var badge = await db.Badges |         var badge = await db.Badges | ||||||
|             .Where(b => b.AccountId == account.Id && b.Id == badgeId) |             .Where(b => b.AccountId == account.Id && b.Id == badgeId) | ||||||
|             .OrderByDescending(b => b.CreatedAt) |             .OrderByDescending(b => b.CreatedAt) | ||||||
|             .FirstOrDefaultAsync(); |             .FirstOrDefaultAsync() ?? throw new InvalidOperationException("Badge was not found."); | ||||||
|         if (badge is null) throw new InvalidOperationException("Badge was not found."); |  | ||||||
|  |  | ||||||
|         var profile = await db.AccountProfiles |         var profile = await db.AccountProfiles | ||||||
|             .Where(p => p.AccountId == account.Id) |             .Where(p => p.AccountId == account.Id) | ||||||
|             .FirstOrDefaultAsync(); |             .FirstOrDefaultAsync(); | ||||||
| @@ -700,7 +693,7 @@ public class AccountService( | |||||||
|         await db.SaveChangesAsync(); |         await db.SaveChangesAsync(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task ActiveBadge(Account account, Guid badgeId) |     public async Task ActiveBadge(SnAccount account, Guid badgeId) | ||||||
|     { |     { | ||||||
|         await using var transaction = await db.Database.BeginTransactionAsync(); |         await using var transaction = await db.Database.BeginTransactionAsync(); | ||||||
|  |  | ||||||
| @@ -734,7 +727,7 @@ public class AccountService( | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task DeleteAccount(Account account) |     public async Task DeleteAccount(SnAccount account) | ||||||
|     { |     { | ||||||
|         await db.AuthSessions |         await db.AuthSessions | ||||||
|             .Where(s => s.AccountId == account.Id) |             .Where(s => s.AccountId == account.Id) | ||||||
|   | |||||||
| @@ -12,13 +12,11 @@ public class AccountServiceGrpc( | |||||||
|     AccountEventService accountEvents, |     AccountEventService accountEvents, | ||||||
|     RelationshipService relationships, |     RelationshipService relationships, | ||||||
|     SubscriptionService subscriptions, |     SubscriptionService subscriptions, | ||||||
|     IClock clock, |  | ||||||
|     ILogger<AccountServiceGrpc> logger |     ILogger<AccountServiceGrpc> logger | ||||||
| ) | ) | ||||||
|     : Shared.Proto.AccountService.AccountServiceBase |     : Shared.Proto.AccountService.AccountServiceBase | ||||||
| { | { | ||||||
|     private readonly AppDatabase _db = db ?? throw new ArgumentNullException(nameof(db)); |     private readonly AppDatabase _db = db ?? throw new ArgumentNullException(nameof(db)); | ||||||
|     private readonly IClock _clock = clock ?? throw new ArgumentNullException(nameof(clock)); |  | ||||||
|  |  | ||||||
|     private readonly ILogger<AccountServiceGrpc> |     private readonly ILogger<AccountServiceGrpc> | ||||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); |         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||||
| @@ -160,6 +158,26 @@ public class AccountServiceGrpc( | |||||||
|         return response; |         return response; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     public override async Task<GetAccountBatchResponse> SearchAccount(SearchAccountRequest request, ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         var accounts = await _db.Accounts | ||||||
|  |             .AsNoTracking() | ||||||
|  |             .Where(a => EF.Functions.ILike(a.Name, $"%{request.Query}%")) | ||||||
|  |             .Include(a => a.Profile) | ||||||
|  |             .ToListAsync(); | ||||||
|  |  | ||||||
|  |         var perks = await subscriptions.GetPerkSubscriptionsAsync( | ||||||
|  |             accounts.Select(x => x.Id).ToList() | ||||||
|  |         ); | ||||||
|  |         foreach (var account in accounts) | ||||||
|  |             if (perks.TryGetValue(account.Id, out var perk)) | ||||||
|  |                 account.PerkSubscription = perk?.ToReference(); | ||||||
|  |  | ||||||
|  |         var response = new GetAccountBatchResponse(); | ||||||
|  |         response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue())); | ||||||
|  |         return response; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     public override async Task<ListAccountsResponse> ListAccounts(ListAccountsRequest request, |     public override async Task<ListAccountsResponse> ListAccounts(ListAccountsRequest request, | ||||||
|         ServerCallContext context) |         ServerCallContext context) | ||||||
|     { |     { | ||||||
| @@ -236,7 +254,7 @@ public class AccountServiceGrpc( | |||||||
|         var relationship = await relationships.GetRelationship( |         var relationship = await relationships.GetRelationship( | ||||||
|             Guid.Parse(request.AccountId), |             Guid.Parse(request.AccountId), | ||||||
|             Guid.Parse(request.RelatedId), |             Guid.Parse(request.RelatedId), | ||||||
|             status: (RelationshipStatus?)request.Status |             status: (Shared.Models.RelationshipStatus?)request.Status | ||||||
|         ); |         ); | ||||||
|         return new GetRelationshipResponse |         return new GetRelationshipResponse | ||||||
|         { |         { | ||||||
| @@ -246,7 +264,7 @@ public class AccountServiceGrpc( | |||||||
|  |  | ||||||
|     public override async Task<BoolValue> HasRelationship(GetRelationshipRequest request, ServerCallContext context) |     public override async Task<BoolValue> HasRelationship(GetRelationshipRequest request, ServerCallContext context) | ||||||
|     { |     { | ||||||
|         var hasRelationship = false; |         bool hasRelationship; | ||||||
|         if (!request.HasStatus) |         if (!request.HasStatus) | ||||||
|             hasRelationship = await relationships.HasExistingRelationship( |             hasRelationship = await relationships.HasExistingRelationship( | ||||||
|                 Guid.Parse(request.AccountId), |                 Guid.Parse(request.AccountId), | ||||||
| @@ -256,7 +274,7 @@ public class AccountServiceGrpc( | |||||||
|             hasRelationship = await relationships.HasRelationshipWithStatus( |             hasRelationship = await relationships.HasRelationshipWithStatus( | ||||||
|                 Guid.Parse(request.AccountId), |                 Guid.Parse(request.AccountId), | ||||||
|                 Guid.Parse(request.RelatedId), |                 Guid.Parse(request.RelatedId), | ||||||
|                 (RelationshipStatus)request.Status |                 (Shared.Models.RelationshipStatus)request.Status | ||||||
|             ); |             ); | ||||||
|         return new BoolValue { Value = hasRelationship }; |         return new BoolValue { Value = hasRelationship }; | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -1,46 +0,0 @@ | |||||||
| using System.ComponentModel.DataAnnotations; |  | ||||||
| using System.ComponentModel.DataAnnotations.Schema; |  | ||||||
| using System.Text.Json.Serialization; |  | ||||||
| using DysonNetwork.Shared.Data; |  | ||||||
| using DysonNetwork.Shared.GeoIp; |  | ||||||
| using DysonNetwork.Shared.Proto; |  | ||||||
| using NodaTime.Serialization.Protobuf; |  | ||||||
| using Point = NetTopologySuite.Geometries.Point; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Pass.Account; |  | ||||||
|  |  | ||||||
| public class ActionLog : ModelBase |  | ||||||
| { |  | ||||||
|     public Guid Id { get; set; } = Guid.NewGuid(); |  | ||||||
|     [MaxLength(4096)] public string Action { get; set; } = null!; |  | ||||||
|     [Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new(); |  | ||||||
|     [MaxLength(512)] public string? UserAgent { get; set; } |  | ||||||
|     [MaxLength(128)] public string? IpAddress { get; set; } |  | ||||||
|     [Column(TypeName = "jsonb")] public GeoPoint? Location { get; set; } |  | ||||||
|  |  | ||||||
|     public Guid AccountId { get; set; } |  | ||||||
|     public Account Account { get; set; } = null!; |  | ||||||
|     public Guid? SessionId { get; set; } |  | ||||||
|  |  | ||||||
|     public Shared.Proto.ActionLog ToProtoValue() |  | ||||||
|     { |  | ||||||
|         var protoLog = new Shared.Proto.ActionLog |  | ||||||
|         { |  | ||||||
|             Id = Id.ToString(), |  | ||||||
|             Action = Action, |  | ||||||
|             UserAgent = UserAgent ?? string.Empty, |  | ||||||
|             IpAddress = IpAddress ?? string.Empty, |  | ||||||
|             Location = Location?.ToString() ?? string.Empty, |  | ||||||
|             AccountId = AccountId.ToString(), |  | ||||||
|             CreatedAt = CreatedAt.ToTimestamp() |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         // Convert Meta dictionary to Struct |  | ||||||
|         protoLog.Meta.Add(GrpcTypeHelper.ConvertToValueMap(Meta)); |  | ||||||
|  |  | ||||||
|         if (SessionId.HasValue) |  | ||||||
|             protoLog.SessionId = SessionId.Value.ToString(); |  | ||||||
|  |  | ||||||
|         return protoLog; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,13 +1,14 @@ | |||||||
| using DysonNetwork.Shared.Cache; | using DysonNetwork.Shared.Cache; | ||||||
| using DysonNetwork.Shared.GeoIp; | using DysonNetwork.Shared.GeoIp; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Pass.Account; | namespace DysonNetwork.Pass.Account; | ||||||
|  |  | ||||||
| public class ActionLogService(GeoIpService geo, FlushBufferService fbs) | public class ActionLogService(GeoIpService geo, FlushBufferService fbs) | ||||||
| { | { | ||||||
|     public void CreateActionLog(Guid accountId, string action, Dictionary<string, object?> meta) |     public void CreateActionLog(Guid accountId, string action, Dictionary<string, object> meta) | ||||||
|     { |     { | ||||||
|         var log = new ActionLog |         var log = new SnActionLog | ||||||
|         { |         { | ||||||
|             Action = action, |             Action = action, | ||||||
|             AccountId = accountId, |             AccountId = accountId, | ||||||
| @@ -18,9 +19,9 @@ public class ActionLogService(GeoIpService geo, FlushBufferService fbs) | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     public void CreateActionLogFromRequest(string action, Dictionary<string, object> meta, HttpRequest request, |     public void CreateActionLogFromRequest(string action, Dictionary<string, object> meta, HttpRequest request, | ||||||
|         Account? account = null) |         SnAccount? account = null) | ||||||
|     { |     { | ||||||
|         var log = new ActionLog |         var log = new SnActionLog | ||||||
|         { |         { | ||||||
|             Action = action, |             Action = action, | ||||||
|             Meta = meta, |             Meta = meta, | ||||||
| @@ -29,14 +30,14 @@ public class ActionLogService(GeoIpService geo, FlushBufferService fbs) | |||||||
|             Location = geo.GetPointFromIp(request.HttpContext.Connection.RemoteIpAddress?.ToString()) |             Location = geo.GetPointFromIp(request.HttpContext.Connection.RemoteIpAddress?.ToString()) | ||||||
|         }; |         }; | ||||||
|          |          | ||||||
|         if (request.HttpContext.Items["CurrentUser"] is Account currentUser) |         if (request.HttpContext.Items["CurrentUser"] is SnAccount currentUser) | ||||||
|             log.AccountId = currentUser.Id; |             log.AccountId = currentUser.Id; | ||||||
|         else if (account != null) |         else if (account != null) | ||||||
|             log.AccountId = account.Id; |             log.AccountId = account.Id; | ||||||
|         else |         else | ||||||
|             throw new ArgumentException("No user context was found"); |             throw new ArgumentException("No user context was found"); | ||||||
|          |          | ||||||
|         if (request.HttpContext.Items["CurrentSession"] is Auth.AuthSession currentSession) |         if (request.HttpContext.Items["CurrentSession"] is SnAuthSession currentSession) | ||||||
|             log.SessionId = currentSession.Id; |             log.SessionId = currentSession.Id; | ||||||
|  |  | ||||||
|         fbs.Enqueue(log); |         fbs.Enqueue(log); | ||||||
|   | |||||||
| @@ -32,8 +32,8 @@ public class ActionLogServiceGrpc : Shared.Proto.ActionLogService.ActionLogServi | |||||||
|         try |         try | ||||||
|         { |         { | ||||||
|             var meta = request.Meta |             var meta = request.Meta | ||||||
|                 ?.Select(x => new KeyValuePair<string, object?>(x.Key, GrpcTypeHelper.ConvertValueToObject(x.Value))) |                 ?.Select(x => new KeyValuePair<string, object>(x.Key, GrpcTypeHelper.ConvertValueToObject(x.Value))) | ||||||
|                 .ToDictionary() ?? new Dictionary<string, object?>(); |                 .ToDictionary() ?? new Dictionary<string, object>(); | ||||||
|  |  | ||||||
|             _actionLogService.CreateActionLog( |             _actionLogService.CreateActionLog( | ||||||
|                 accountId, |                 accountId, | ||||||
| @@ -41,6 +41,7 @@ public class ActionLogServiceGrpc : Shared.Proto.ActionLogService.ActionLogServi | |||||||
|                 meta |                 meta | ||||||
|             ); |             ); | ||||||
|  |  | ||||||
|  |             await Task.CompletedTask; | ||||||
|             return new CreateActionLogResponse(); |             return new CreateActionLogResponse(); | ||||||
|         } |         } | ||||||
|         catch (Exception ex) |         catch (Exception ex) | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| using DysonNetwork.Shared.Data; | using DysonNetwork.Shared.Models; | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
| using Grpc.Core; | using Grpc.Core; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| @@ -22,7 +22,7 @@ public class BotAccountReceiverGrpc( | |||||||
|         ServerCallContext context |         ServerCallContext context | ||||||
|     ) |     ) | ||||||
|     { |     { | ||||||
|         var account = Account.FromProtoValue(request.Account); |         var account = SnAccount.FromProtoValue(request.Account); | ||||||
|         account = await accounts.CreateBotAccount( |         account = await accounts.CreateBotAccount( | ||||||
|             account, |             account, | ||||||
|             Guid.Parse(request.AutomatedId), |             Guid.Parse(request.AutomatedId), | ||||||
| @@ -48,7 +48,7 @@ public class BotAccountReceiverGrpc( | |||||||
|         ServerCallContext context |         ServerCallContext context | ||||||
|     ) |     ) | ||||||
|     { |     { | ||||||
|         var account = Account.FromProtoValue(request.Account); |         var account = SnAccount.FromProtoValue(request.Account); | ||||||
|  |  | ||||||
|         if (request.PictureId is not null) |         if (request.PictureId is not null) | ||||||
|         { |         { | ||||||
| @@ -65,7 +65,7 @@ public class BotAccountReceiverGrpc( | |||||||
|                     Usage = "profile.picture" |                     Usage = "profile.picture" | ||||||
|                 } |                 } | ||||||
|             ); |             ); | ||||||
|             account.Profile.Picture = CloudFileReferenceObject.FromProtoValue(file); |             account.Profile.Picture = SnCloudFileReferenceObject.FromProtoValue(file); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if (request.BackgroundId is not null) |         if (request.BackgroundId is not null) | ||||||
| @@ -83,7 +83,7 @@ public class BotAccountReceiverGrpc( | |||||||
|                     Usage = "profile.background" |                     Usage = "profile.background" | ||||||
|                 } |                 } | ||||||
|             ); |             ); | ||||||
|             account.Profile.Background = CloudFileReferenceObject.FromProtoValue(file); |             account.Profile.Background = SnCloudFileReferenceObject.FromProtoValue(file); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         db.Accounts.Update(account); |         db.Accounts.Update(account); | ||||||
|   | |||||||
| @@ -50,7 +50,7 @@ public class MagicSpellController(AppDatabase db, MagicSpellService sp) : Contro | |||||||
|             return NotFound(); |             return NotFound(); | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
|             if (spell.Type == MagicSpellType.AuthPasswordReset && request?.NewPassword is not null) |             if (spell.Type == Shared.Models.MagicSpellType.AuthPasswordReset && request?.NewPassword is not null) | ||||||
|                 await sp.ApplyPasswordReset(spell, request.NewPassword); |                 await sp.ApplyPasswordReset(spell, request.NewPassword); | ||||||
|             else |             else | ||||||
|                 await sp.ApplyMagicSpell(spell); |                 await sp.ApplyMagicSpell(spell); | ||||||
|   | |||||||
| @@ -2,8 +2,8 @@ using System.Security.Cryptography; | |||||||
| using System.Text.Json; | using System.Text.Json; | ||||||
| using DysonNetwork.Pass.Emails; | using DysonNetwork.Pass.Emails; | ||||||
| using DysonNetwork.Pass.Mailer; | using DysonNetwork.Pass.Mailer; | ||||||
| using DysonNetwork.Pass.Permission; |  | ||||||
| using DysonNetwork.Shared.Cache; | using DysonNetwork.Shared.Cache; | ||||||
|  | using DysonNetwork.Shared.Models; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.Extensions.Localization; | using Microsoft.Extensions.Localization; | ||||||
| using NodaTime; | using NodaTime; | ||||||
| @@ -20,8 +20,8 @@ public class MagicSpellService( | |||||||
|     ICacheService cache |     ICacheService cache | ||||||
| ) | ) | ||||||
| { | { | ||||||
|     public async Task<MagicSpell> CreateMagicSpell( |     public async Task<SnMagicSpell> CreateMagicSpell( | ||||||
|         Account account, |         SnAccount account, | ||||||
|         MagicSpellType type, |         MagicSpellType type, | ||||||
|         Dictionary<string, object> meta, |         Dictionary<string, object> meta, | ||||||
|         Instant? expiredAt = null, |         Instant? expiredAt = null, | ||||||
| @@ -42,7 +42,7 @@ public class MagicSpellService( | |||||||
|         } |         } | ||||||
|  |  | ||||||
|         var spellWord = _GenerateRandomString(128); |         var spellWord = _GenerateRandomString(128); | ||||||
|         var spell = new MagicSpell |         var spell = new SnMagicSpell | ||||||
|         { |         { | ||||||
|             Spell = spellWord, |             Spell = spellWord, | ||||||
|             Type = type, |             Type = type, | ||||||
| @@ -60,7 +60,7 @@ public class MagicSpellService( | |||||||
|  |  | ||||||
|     private const string SpellNotifyCacheKeyPrefix = "spells:notify:"; |     private const string SpellNotifyCacheKeyPrefix = "spells:notify:"; | ||||||
|  |  | ||||||
|     public async Task NotifyMagicSpell(MagicSpell spell, bool bypassVerify = false) |     public async Task NotifyMagicSpell(SnMagicSpell spell, bool bypassVerify = false) | ||||||
|     { |     { | ||||||
|         var cacheKey = SpellNotifyCacheKeyPrefix + spell.Id; |         var cacheKey = SpellNotifyCacheKeyPrefix + spell.Id; | ||||||
|         var (found, _) = await cache.GetAsyncWithStatus<bool?>(cacheKey); |         var (found, _) = await cache.GetAsyncWithStatus<bool?>(cacheKey); | ||||||
| @@ -156,7 +156,7 @@ public class MagicSpellService( | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task ApplyMagicSpell(MagicSpell spell) |     public async Task ApplyMagicSpell(SnMagicSpell spell) | ||||||
|     { |     { | ||||||
|         switch (spell.Type) |         switch (spell.Type) | ||||||
|         { |         { | ||||||
| @@ -191,7 +191,7 @@ public class MagicSpellService( | |||||||
|                 var defaultGroup = await db.PermissionGroups.FirstOrDefaultAsync(g => g.Key == "default"); |                 var defaultGroup = await db.PermissionGroups.FirstOrDefaultAsync(g => g.Key == "default"); | ||||||
|                 if (defaultGroup is not null && account is not null) |                 if (defaultGroup is not null && account is not null) | ||||||
|                 { |                 { | ||||||
|                     db.PermissionGroupMembers.Add(new PermissionGroupMember |                     db.PermissionGroupMembers.Add(new SnPermissionGroupMember | ||||||
|                     { |                     { | ||||||
|                         Actor = $"user:{account.Id}", |                         Actor = $"user:{account.Id}", | ||||||
|                         Group = defaultGroup |                         Group = defaultGroup | ||||||
| @@ -218,7 +218,7 @@ public class MagicSpellService( | |||||||
|         await db.SaveChangesAsync(); |         await db.SaveChangesAsync(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task ApplyPasswordReset(MagicSpell spell, string newPassword) |     public async Task ApplyPasswordReset(SnMagicSpell spell, string newPassword) | ||||||
|     { |     { | ||||||
|         if (spell.Type != MagicSpellType.AuthPasswordReset) |         if (spell.Type != MagicSpellType.AuthPasswordReset) | ||||||
|             throw new ArgumentException("This spell is not a password reset spell."); |             throw new ArgumentException("This spell is not a password reset spell."); | ||||||
| @@ -231,7 +231,7 @@ public class MagicSpellService( | |||||||
|         { |         { | ||||||
|             var account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId); |             var account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId); | ||||||
|             if (account is null) throw new InvalidOperationException("Both account and auth factor was not found."); |             if (account is null) throw new InvalidOperationException("Both account and auth factor was not found."); | ||||||
|             passwordFactor = new AccountAuthFactor |             passwordFactor = new SnAccountAuthFactor | ||||||
|             { |             { | ||||||
|                 Type = AccountAuthFactorType.Password, |                 Type = AccountAuthFactorType.Password, | ||||||
|                 Account = account, |                 Account = account, | ||||||
| @@ -257,6 +257,6 @@ public class MagicSpellService( | |||||||
|  |  | ||||||
|         var base64String = Convert.ToBase64String(randomBytes); |         var base64String = Convert.ToBase64String(randomBytes); | ||||||
|  |  | ||||||
|         return base64String.Substring(0, length); |         return base64String[..length]; | ||||||
|     } |     } | ||||||
| } | } | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user