127 Commits

Author SHA1 Message Date
cdfc3f6571 🐛 Fix post service grpc 2025-10-26 03:41:59 +08:00
f65a7360e2 🌐 Add missing gift claimed localization 2025-10-26 03:13:16 +08:00
85e706335a 🔨 Optimize the performance of gha to do increment build 2025-10-26 03:09:49 +08:00
fe74060df9 🐛 Fix some uncleaned code lead to failing compilation 2025-10-26 02:52:15 +08:00
e8d5f22395 🗑️ Remove old tus api for file upload 2025-10-26 02:48:47 +08:00
83fa2568aa 🗑️ Remove the shit simple search vector 2025-10-26 02:45:15 +08:00
bf1c8e0a85 🗃️ Remove some unused outdated fields 2025-10-26 02:41:30 +08:00
323fa8ee15 🐛 Bug fixes 2025-10-26 02:41:17 +08:00
e7a46e96ed 🔨 Republish to generate docker compose 2025-10-26 02:25:25 +08:00
3a0dee11a6 🚨 Fix warnings in the codebase 2025-10-26 02:20:10 +08:00
43be47d526 ⬆️ Upgrade dependencies 2025-10-26 02:11:50 +08:00
48067af034 ⬆️ Upgrade dependencies 2025-10-26 01:56:35 +08:00
7e7e90ad24 Support deepseek 2025-10-26 01:42:35 +08:00
3af4069581 💄 Optimzation 2025-10-26 00:25:38 +08:00
609b130b4e Thinking 2025-10-25 23:32:51 +08:00
93f7dfd379 Provide real user and posts data for the thinking 2025-10-25 17:58:58 +08:00
40325c6df5 ♻️ Replace the LangChain with Semantic Kernel 2025-10-25 17:07:29 +08:00
bbcaa27ac5 Thinking of the LangChain ver 2025-10-25 16:40:00 +08:00
19d833a522 Add the DysonNetwork.Insight project 2025-10-25 02:28:08 +08:00
a94102e136 👔 Change lottery rewards 2025-10-25 00:29:56 +08:00
fc693793fe 🐛 Fixes of lotteries and enrich features 2025-10-25 00:17:56 +08:00
8cfdabbae4 ♻️ Check in algorithm v2 2025-10-24 21:51:14 +08:00
985ff41c72 📝 Document the lottery 2025-10-24 21:40:50 +08:00
a79ea4ac49 🐛 Fix lottery 2025-10-24 21:40:40 +08:00
7385caff9a Lotteries 2025-10-24 01:34:18 +08:00
15954dbfe2 Providing the post featured record in the response 2025-10-24 00:51:30 +08:00
4ba6206c9d 🛂 Stricter post visibility check 2025-10-24 00:02:27 +08:00
266b9e36e2 🗃️ Update schema to clean up unused code 2025-10-23 01:01:19 +08:00
e6aa61b03b 🐛 Bug fixes in the Sphere still referencing the old realm db 2025-10-22 23:31:42 +08:00
0c09ef25ec ⬆️ Upgrade dependencies in order to prevent CVE-2025-55315 2025-10-22 22:58:52 +08:00
dd5929c691 💥 Moved the /id to /pass and bug fixes of moved realms 2025-10-22 22:52:09 +08:00
cf87fdfb49 🗑️ Remove per service rate-limiting due to gateway covered it 2025-10-22 22:10:37 +08:00
ff03584518 🐛 Fix some issues in moving realm service 2025-10-22 21:56:50 +08:00
d6c37784e1 ♻️ Move the realm service from sphere to the pass 2025-10-21 23:45:36 +08:00
46ebd92dc1 ♻️ Refactored the chat mention logic 2025-10-17 00:46:55 +08:00
7f8521bb40 👔 Optimize subscriptions logic 2025-10-16 13:13:08 +08:00
f01226d91a 🐛 Fix post controller return incomplete structure 2025-10-13 23:11:35 +08:00
6cb6dee6be 🐛 Remove project Sphere dict key snake case convert to fix reaction counts 2025-10-13 01:19:51 +08:00
0e9caf67ff 🐛 username color hotfix 2025-10-13 01:16:35 +08:00
ca70bb5487 🐛 Fix missing username color in proto profile 2025-10-13 01:08:48 +08:00
59ed135f20 Load account info in reaction list API 2025-10-12 21:57:37 +08:00
6077f91529 Sticker search 2025-10-12 21:46:45 +08:00
5c485bb1c3 🐛 Fix autocomplete again 2025-10-12 19:30:46 +08:00
27d979d77b 🐛 Fix sticker auto complete 2025-10-12 19:21:00 +08:00
15687a0c32 Standalone auto complete 2025-10-12 16:59:26 +08:00
37ea882ef7 Full featured auto complete 2025-10-12 16:55:32 +08:00
e624c2bb3e ⬆️ Upgrade aspire 2025-10-12 16:06:39 +08:00
9631cd3edd Auto completion in chat 2025-10-12 16:00:32 +08:00
f4a659fce5 🐛 Fix DM room member loading issue 2025-10-12 15:46:45 +08:00
1ded811b36 Publisher heatmap 2025-10-12 15:32:49 +08:00
32977d9580 🐛 Fix post controller does not contains publisher in success created response 2025-10-11 23:55:00 +08:00
aaf29e7228 🐛 Fix gateway user ip detection 2025-10-09 22:50:26 +08:00
658ef3bddf 🐛 Fix gateway IP detection issue 2025-10-09 00:10:32 +08:00
fc0bc936ce New version of sticker rendering support 2025-10-08 21:28:48 +08:00
3850ae6a8e 🔊 Rate limiting logs 2025-10-08 18:07:19 +08:00
21c99567b4 🐛 Fix wrong method to configure rate limiting 2025-10-08 18:05:59 +08:00
1315c7f4d4 🐛 Fix rate limiter 2025-10-08 18:01:25 +08:00
630a532d98 🐛 Fix app host 2025-10-08 18:01:21 +08:00
b9bb180113 Username color 2025-10-08 13:11:30 +08:00
04d74d0d70 Trying to optimize the scheduled jobs 2025-10-08 12:59:54 +08:00
6a8a0ed491 👔 Limit custom reactions 2025-10-08 02:46:56 +08:00
0f835845bf ♻️ Merge the ServiceDefault and Shared project 2025-10-07 19:44:52 +08:00
c5d8a8d07f 🔇 Mute ungraceful closed websocket 2025-10-07 17:54:58 +08:00
95e2ba1136 🐛 Fixes some issues in drive service 2025-10-07 01:07:24 +08:00
1176fde8b4 🐛 Fix health check 2025-10-07 00:41:26 +08:00
e634968e00 🐛 Brings health check back to live 2025-10-07 00:34:00 +08:00
282a1dbddc 🐛 Fix didn't expose X-Total 2025-10-06 23:40:44 +08:00
c64adace24 💄 Using remote site instead of embed frontend (removed) to handle oidc redirect 2025-10-06 13:05:50 +08:00
8ac0b28c66 🚚 Move callback to under api 2025-10-06 13:01:15 +08:00
8f71d7f9e5 🐛 Fix some bugs 2025-10-06 12:46:25 +08:00
c435e63917 Able to update the custom apps order's status 2025-10-05 22:20:32 +08:00
243159e4cc Custom apps create payment orders 2025-10-05 21:59:07 +08:00
42dad7095a 💄 Optimize the transfer 2025-10-05 16:17:57 +08:00
d1efcdede8 Transfer fee and pin validate 2025-10-05 15:52:54 +08:00
47680475b3 🐛 Fix develop service 2025-10-05 00:09:21 +08:00
6632d43f32 🐛 Trying to fix develop 2025-10-05 00:05:37 +08:00
29c4dcd71c Wallet stats 2025-10-05 00:05:31 +08:00
e7aa887715 🐛 Fix wrong signing algo 2025-10-04 19:55:27 +08:00
0f05633996 🐛 Fix oidc didn't provides with authorized party 2025-10-04 19:03:57 +08:00
966af08a33 Wallet stats 2025-10-04 15:38:58 +08:00
b25b90a074 Wallet funds 2025-10-04 01:17:21 +08:00
dcbefeaaab 👔 Purchase gift requires minimal level 2025-10-03 17:20:58 +08:00
eb83a0392a 👔 Update level requirements of purchase Stellar Program 2025-10-03 17:16:53 +08:00
85fefcf724 🐛 Fix subscription check 2025-10-03 17:16:18 +08:00
d17c26a228 👔 Skip level check when redeem gift 2025-10-03 17:12:23 +08:00
2e5ef8ff94 🐛 Fix members related operations 2025-10-03 17:07:57 +08:00
7a5f410e36 🐛 Trying to fix migration 2025-10-03 16:53:19 +08:00
0b4e8a9777 🚑 Ignoring migration error for now 2025-10-03 16:44:22 +08:00
30fd912281 Optimize queue usage 2025-10-03 16:38:10 +08:00
5bf58f0194 🐛 Fix subscription gift 2025-10-03 16:38:01 +08:00
8e3e3f09df Gateway config serving 2025-10-03 16:37:51 +08:00
fa24f14c05 Subscription gifts 2025-10-03 14:36:27 +08:00
a93b633e84 🐛 Fixes member issue 2025-10-02 17:09:11 +08:00
97a7b876db ♻️ Better file upload error 2025-10-02 01:14:03 +08:00
909fe173c2 🐛 Fix function changes not fully applied 2025-09-27 19:28:47 +08:00
58a44e8af4 Chat subscribe fixes and status update 2025-09-27 19:25:10 +08:00
1075177511 Message subscribe 2025-09-27 17:50:51 +08:00
78f8a9e638 🚚 Move packages 2025-09-27 16:30:35 +08:00
9ce31c4dd8 ♻️ Finish centerlizing the data models 2025-09-27 15:14:05 +08:00
e70d8371f8 ♻️ Centralized data models (wip) 2025-09-27 14:09:28 +08:00
51b6f7309e 💄 Optimize the background file analyze process 2025-09-26 23:29:27 +08:00
d75876a772 🐛 Proper file upload retries 2025-09-26 22:11:52 +08:00
4910c3296b 🐛 Fix openid configuration outdated 2025-09-26 00:13:46 +08:00
7b924fa075 🐛 Fix something 2025-09-26 00:03:09 +08:00
d69c9f9623 ♻️ Refactored swagger generation 2025-09-25 23:44:43 +08:00
a88d828e21 Fix swaggergen for drive 2025-09-25 23:14:17 +08:00
14c93d372e 🐛 Fix develop missing a reference 2025-09-25 13:12:28 +08:00
adf371a72e 🐛 Fix pool order 2025-09-25 02:35:33 +08:00
c03f2472fa ♻️ Refactor Gateway and expose swagger 2025-09-25 01:29:22 +08:00
50efe62bac 🐛 Fix birthday check in 2025-09-24 21:37:59 +08:00
7bc94a9646 🔨 Update build script 2025-09-24 20:22:11 +08:00
d9fe1273b5 🔨 Add gateway image build 2025-09-24 18:55:18 +08:00
ff9d490869 🗃️ Update status migration 2025-09-24 13:48:36 +08:00
266312e97e Automated status meta 2025-09-24 13:45:05 +08:00
7087736e31 👔 New leveling algorithm 2025-09-24 12:54:14 +08:00
82bf1608fd 🐛 Fix award handler 2025-09-23 23:05:41 +08:00
3b3287db0b Add a proper Gateway service 2025-09-23 22:56:06 +08:00
4573d9395f 🐛 Fix inconsistent chat meta 2025-09-23 22:34:47 +08:00
a8c99b3128 Editing message previous content diff 2025-09-23 15:27:26 +08:00
fdd7bd3c9d 🐛 Fixes sync issue 2025-09-23 14:58:25 +08:00
b785d0098b 💥 New message system and syncing API 2025-09-22 01:47:24 +08:00
5b31357fe9 🐛 Fix websocket gateway, finally 2025-09-22 01:33:30 +08:00
d5a5721402 🐛 Fix websocket gateway 2025-09-22 00:13:43 +08:00
204640a759 ♻️ Refactor the way to handle websocket 2025-09-21 23:07:20 +08:00
e3657386cd 🐛 Fix websocket create rpc 2025-09-21 20:20:31 +08:00
f81e3dc9f4 ♻️ Move file analyze, upload into message queue 2025-09-21 19:38:40 +08:00
b2a0d25ffa Functionable new upload method 2025-09-21 18:32:08 +08:00
393 changed files with 37446 additions and 41619 deletions

View File

@@ -1,3 +1,4 @@
{ {
"appHostPath": "../DysonNetwork.Control/DysonNetwork.Control.csproj" "appHostPath": "../DysonNetwork.Control/DysonNetwork.Control.csproj"
} }

5
.editorconfig Normal file
View File

@@ -0,0 +1,5 @@
root = true
[*]
indent_style = space
indent_size = 4

3
.env
View File

@@ -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

View File

@@ -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
View 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

View File

@@ -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,35 +59,13 @@ 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");
builder.Build().Run(); builder.Build().Run();

View File

@@ -1,30 +1,28 @@
<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> <TargetFramework>net9.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings>
<ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable>
<Nullable>enable</Nullable> <UserSecretsId>a68b3195-a00d-40c2-b5ed-d675356b7cde</UserSecretsId>
<UserSecretsId>a68b3195-a00d-40c2-b5ed-d675356b7cde</UserSecretsId> <RootNamespace>DysonNetwork.Control</RootNamespace>
<RootNamespace>DysonNetwork.Control</RootNamespace> </PropertyGroup>
</PropertyGroup>
<ItemGroup>
<ItemGroup> <PackageReference Include="Aspire.Hosting.AppHost" Version="9.5.2" />
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.4.2"/> <PackageReference Include="Aspire.Hosting.Docker" Version="9.4.2-preview.1.25428.12" />
<PackageReference Include="Aspire.Hosting.Docker" Version="9.4.2-preview.1.25428.12" /> <PackageReference Include="Aspire.Hosting.Nats" Version="9.5.2" />
<PackageReference Include="Aspire.Hosting.Nats" Version="9.4.2" /> <PackageReference Include="Aspire.Hosting.Redis" Version="9.5.2" />
<PackageReference Include="Aspire.Hosting.Redis" Version="9.4.2" /> </ItemGroup>
<PackageReference Include="Aspire.Hosting.Yarp" Version="9.4.2-preview.1.25428.12" /> <ItemGroup>
</ItemGroup> <ProjectReference Include="..\DysonNetwork.Develop\DysonNetwork.Develop.csproj" />
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
<ItemGroup> <ProjectReference Include="..\DysonNetwork.Pass\DysonNetwork.Pass.csproj" />
<ProjectReference Include="..\DysonNetwork.Develop\DysonNetwork.Develop.csproj" /> <ProjectReference Include="..\DysonNetwork.Ring\DysonNetwork.Ring.csproj" />
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" /> <ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" />
<ProjectReference Include="..\DysonNetwork.Pass\DysonNetwork.Pass.csproj" /> <ProjectReference Include="..\DysonNetwork.Gateway\DysonNetwork.Gateway.csproj" />
<ProjectReference Include="..\DysonNetwork.Ring\DysonNetwork.Ring.csproj" /> <ProjectReference Include="..\DysonNetwork.Insight\DysonNetwork.Insight.csproj" />
<ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" /> </ItemGroup>
</ItemGroup> </Project>
</Project>

View File

@@ -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,8 +24,9 @@
"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"
} }
} }
} }
} }

View File

@@ -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)
{ {
@@ -31,6 +30,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)
{ {

View File

@@ -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>

View File

@@ -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");
@@ -374,9 +374,9 @@ public class BotAccountController(
AccountId = bot.Id.ToString(), AccountId = bot.Id.ToString(),
Label = request.Label Label = request.Label
}; };
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);
@@ -457,4 +457,4 @@ public class BotAccountController(
return (developer, project, bot); return (developer, project, bot);
} }
} }

View File

@@ -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");

View File

@@ -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
@@ -98,7 +97,7 @@ public class BotAccountService(
{ {
db.Update(bot); db.Update(bot);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
try try
{ {
// Update the bot account in the Pass service // Update the bot account in the Pass service
@@ -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;
} }
} }

View File

@@ -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,

View File

@@ -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(

View File

@@ -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:

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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
{ {

View File

@@ -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");

View File

@@ -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),

View File

@@ -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");

View File

@@ -1,5 +1,4 @@
using System; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime; using NodaTime;
#nullable disable #nullable disable

View File

@@ -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");

View File

@@ -1,5 +1,4 @@
using System; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime; using NodaTime;
#nullable disable #nullable disable

View File

@@ -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");

View File

@@ -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();

View File

@@ -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!,
@@ -25,14 +21,14 @@ public class DevProjectService(
db.DevProjects.Add(project); db.DevProjects.Add(project);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
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();
if (developerId.HasValue) if (developerId.HasValue)
{ {
query = query.Where(p => p.DeveloperId == developerId.Value); query = query.Where(p => p.DeveloperId == developerId.Value);
@@ -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
@@ -74,4 +70,4 @@ public class DevProjectService(
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return true; return true;
} }
} }

View File

@@ -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);

View File

@@ -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;
}
} }

View File

@@ -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"
} }
} }

View File

@@ -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)

View File

@@ -1,4 +1,4 @@
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Models;
using NodaTime; using NodaTime;
namespace DysonNetwork.Drive.Billing; namespace DysonNetwork.Drive.Billing;

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,5 +1,4 @@
using System.Collections.Generic; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,5 +1,4 @@
using System; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime; using NodaTime;
#nullable disable #nullable disable

View File

@@ -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;

View File

@@ -1,5 +1,4 @@
using System; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime; using NodaTime;
#nullable disable #nullable disable

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();

View File

@@ -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;
} }

View File

@@ -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);
}
}

View File

@@ -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>();

View File

@@ -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);

View File

@@ -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,14 +46,36 @@ 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)
return StatusCode(StatusCodes.Status410Gone, "The pool of the file no longer exists or not accessible."); return StatusCode(StatusCodes.Status410Gone, "The pool of the file no longer exists or not accessible.");
@@ -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,

View File

@@ -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 =>
{ {
@@ -29,14 +31,14 @@ public class FilePoolController(AppDatabase db, FileService fs) : ControllerBase
return Ok(pools); return Ok(pools);
} }
[Authorize] [Authorize]
[HttpDelete("{id:guid}/recycle")] [HttpDelete("{id:guid}/recycle")]
public async Task<ActionResult> DeleteFilePoolRecycledFiles(Guid id) public async Task<ActionResult> DeleteFilePoolRecycledFiles(Guid id)
{ {
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);
var pool = await fs.GetPoolAsync(id); var pool = await fs.GetPoolAsync(id);
if (pool is null) return NotFound(); if (pool is null) return NotFound();
if (!currentUser.IsSuperuser && pool.AccountId != accountId) return Unauthorized(); if (!currentUser.IsSuperuser && pool.AccountId != accountId) return Unauthorized();
@@ -44,4 +46,4 @@ public class FilePoolController(AppDatabase db, FileService fs) : ControllerBase
var count = await fs.DeletePoolRecycledFilesAsync(id); var count = await fs.DeletePoolRecycledFilesAsync(id);
return Ok(new { Count = count }); return Ok(new { Count = count });
} }
} }

View File

@@ -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);

View File

@@ -3,173 +3,172 @@ 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)
: Shared.Proto.FileReferenceService.FileReferenceServiceBase
{ {
public class FileReferenceServiceGrpc(FileReferenceService fileReferenceService) public override async Task<Shared.Proto.CloudFileReference> CreateReference(CreateReferenceRequest request,
: Shared.Proto.FileReferenceService.FileReferenceServiceBase ServerCallContext context)
{ {
public override async Task<Shared.Proto.CloudFileReference> CreateReference(CreateReferenceRequest request, Instant? expiredAt = null;
ServerCallContext context) if (request.ExpiredAt != null)
{ expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
Instant? expiredAt = null; else if (request.Duration != null)
if (request.ExpiredAt != null) expiredAt = SystemClock.Instance.GetCurrentInstant() +
expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds); Duration.FromTimeSpan(request.Duration.ToTimeSpan());
else if (request.Duration != null)
expiredAt = SystemClock.Instance.GetCurrentInstant() +
Duration.FromTimeSpan(request.Duration.ToTimeSpan());
var reference = await fileReferenceService.CreateReferenceAsync( var reference = await fileReferenceService.CreateReferenceAsync(
request.FileId, request.FileId,
request.Usage, request.Usage,
request.ResourceId, request.ResourceId,
expiredAt expiredAt
); );
return reference.ToProtoValue(); return reference.ToProtoValue();
} }
public override async Task<CreateReferenceBatchResponse> CreateReferenceBatch(CreateReferenceBatchRequest request, public override async Task<CreateReferenceBatchResponse> CreateReferenceBatch(CreateReferenceBatchRequest request,
ServerCallContext context) ServerCallContext context)
{ {
Instant? expiredAt = null; Instant? expiredAt = null;
if (request.ExpiredAt != null) if (request.ExpiredAt != null)
expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds); expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
else if (request.Duration != null) else if (request.Duration != null)
expiredAt = SystemClock.Instance.GetCurrentInstant() + expiredAt = SystemClock.Instance.GetCurrentInstant() +
Duration.FromTimeSpan(request.Duration.ToTimeSpan()); Duration.FromTimeSpan(request.Duration.ToTimeSpan());
var references = await fileReferenceService.CreateReferencesAsync( var references = await fileReferenceService.CreateReferencesAsync(
request.FilesId.ToList(), request.FilesId.ToList(),
request.Usage, request.Usage,
request.ResourceId, request.ResourceId,
expiredAt expiredAt
); );
var response = new CreateReferenceBatchResponse(); var response = new CreateReferenceBatchResponse();
response.References.AddRange(references.Select(r => r.ToProtoValue())); response.References.AddRange(references.Select(r => r.ToProtoValue()));
return response; return response;
} }
public override async Task<GetReferencesResponse> GetReferences(GetReferencesRequest request, public override async Task<GetReferencesResponse> GetReferences(GetReferencesRequest request,
ServerCallContext context) ServerCallContext context)
{ {
var references = await fileReferenceService.GetReferencesAsync(request.FileId); var references = await fileReferenceService.GetReferencesAsync(request.FileId);
var response = new GetReferencesResponse(); var response = new GetReferencesResponse();
response.References.AddRange(references.Select(r => r.ToProtoValue())); response.References.AddRange(references.Select(r => r.ToProtoValue()));
return response; return response;
} }
public override async Task<GetReferenceCountResponse> GetReferenceCount(GetReferenceCountRequest request, public override async Task<GetReferenceCountResponse> GetReferenceCount(GetReferenceCountRequest request,
ServerCallContext context) ServerCallContext context)
{ {
var count = await fileReferenceService.GetReferenceCountAsync(request.FileId); var count = await fileReferenceService.GetReferenceCountAsync(request.FileId);
return new GetReferenceCountResponse { Count = count }; return new GetReferenceCountResponse { Count = count };
} }
public override async Task<GetReferencesResponse> GetResourceReferences(GetResourceReferencesRequest request, public override async Task<GetReferencesResponse> GetResourceReferences(GetResourceReferencesRequest request,
ServerCallContext context) ServerCallContext context)
{ {
var references = await fileReferenceService.GetResourceReferencesAsync(request.ResourceId, request.Usage); var references = await fileReferenceService.GetResourceReferencesAsync(request.ResourceId, request.Usage);
var response = new GetReferencesResponse(); var response = new GetReferencesResponse();
response.References.AddRange(references.Select(r => r.ToProtoValue())); response.References.AddRange(references.Select(r => r.ToProtoValue()));
return response; return response;
} }
public override async Task<GetResourceFilesResponse> GetResourceFiles(GetResourceFilesRequest request, public override async Task<GetResourceFilesResponse> GetResourceFiles(GetResourceFilesRequest request,
ServerCallContext context) ServerCallContext context)
{ {
var files = await fileReferenceService.GetResourceFilesAsync(request.ResourceId, request.Usage); var files = await fileReferenceService.GetResourceFilesAsync(request.ResourceId, request.Usage);
var response = new GetResourceFilesResponse(); var response = new GetResourceFilesResponse();
response.Files.AddRange(files.Select(f => f.ToProtoValue())); response.Files.AddRange(files.Select(f => f.ToProtoValue()));
return response; return response;
} }
public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferences( public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferences(
DeleteResourceReferencesRequest request, ServerCallContext context) DeleteResourceReferencesRequest request, ServerCallContext context)
{ {
int deletedCount; int deletedCount;
if (request.Usage is null) if (request.Usage is null)
deletedCount = await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId); deletedCount = await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId);
else else
deletedCount = deletedCount =
await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId, request.Usage!); await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId, request.Usage!);
return new DeleteResourceReferencesResponse { DeletedCount = deletedCount }; return new DeleteResourceReferencesResponse { DeletedCount = deletedCount };
} }
public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferencesBatch(DeleteResourceReferencesBatchRequest request, ServerCallContext context) public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferencesBatch(DeleteResourceReferencesBatchRequest request, ServerCallContext context)
{
var resourceIds = request.ResourceIds.ToList();
int deletedCount;
if (request.Usage is null)
deletedCount = await fileReferenceService.DeleteResourceReferencesBatchAsync(resourceIds);
else
deletedCount =
await fileReferenceService.DeleteResourceReferencesBatchAsync(resourceIds, request.Usage!);
return new DeleteResourceReferencesResponse { DeletedCount = deletedCount };
}
public override async Task<DeleteReferenceResponse> DeleteReference(DeleteReferenceRequest request,
ServerCallContext context)
{
var success = await fileReferenceService.DeleteReferenceAsync(Guid.Parse(request.ReferenceId));
return new DeleteReferenceResponse { Success = success };
}
public override async Task<UpdateResourceFilesResponse> UpdateResourceFiles(UpdateResourceFilesRequest request,
ServerCallContext context)
{
Instant? expiredAt = null;
if (request.ExpiredAt != null)
{ {
var resourceIds = request.ResourceIds.ToList(); expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
int deletedCount; }
if (request.Usage is null) else if (request.Duration != null)
deletedCount = await fileReferenceService.DeleteResourceReferencesBatchAsync(resourceIds); {
else expiredAt = SystemClock.Instance.GetCurrentInstant() +
deletedCount = Duration.FromTimeSpan(request.Duration.ToTimeSpan());
await fileReferenceService.DeleteResourceReferencesBatchAsync(resourceIds, request.Usage!);
return new DeleteResourceReferencesResponse { DeletedCount = deletedCount };
} }
public override async Task<DeleteReferenceResponse> DeleteReference(DeleteReferenceRequest request, var references = await fileReferenceService.UpdateResourceFilesAsync(
ServerCallContext context) request.ResourceId,
request.FileIds,
request.Usage,
expiredAt
);
var response = new UpdateResourceFilesResponse();
response.References.AddRange(references.Select(r => r.ToProtoValue()));
return response;
}
public override async Task<SetReferenceExpirationResponse> SetReferenceExpiration(
SetReferenceExpirationRequest request, ServerCallContext context)
{
Instant? expiredAt = null;
if (request.ExpiredAt != null)
{ {
var success = await fileReferenceService.DeleteReferenceAsync(Guid.Parse(request.ReferenceId)); expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
return new DeleteReferenceResponse { Success = success }; }
else if (request.Duration != null)
{
expiredAt = SystemClock.Instance.GetCurrentInstant() +
Duration.FromTimeSpan(request.Duration.ToTimeSpan());
} }
public override async Task<UpdateResourceFilesResponse> UpdateResourceFiles(UpdateResourceFilesRequest request, var success =
ServerCallContext context) await fileReferenceService.SetReferenceExpirationAsync(Guid.Parse(request.ReferenceId), expiredAt);
{ return new SetReferenceExpirationResponse { Success = success };
Instant? expiredAt = null; }
if (request.ExpiredAt != null)
{
expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
}
else if (request.Duration != null)
{
expiredAt = SystemClock.Instance.GetCurrentInstant() +
Duration.FromTimeSpan(request.Duration.ToTimeSpan());
}
var references = await fileReferenceService.UpdateResourceFilesAsync( public override async Task<SetFileReferencesExpirationResponse> SetFileReferencesExpiration(
request.ResourceId, SetFileReferencesExpirationRequest request, ServerCallContext context)
request.FileIds, {
request.Usage, var expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
expiredAt var updatedCount = await fileReferenceService.SetFileReferencesExpirationAsync(request.FileId, expiredAt);
); return new SetFileReferencesExpirationResponse { UpdatedCount = updatedCount };
var response = new UpdateResourceFilesResponse(); }
response.References.AddRange(references.Select(r => r.ToProtoValue()));
return response;
}
public override async Task<SetReferenceExpirationResponse> SetReferenceExpiration( public override async Task<HasFileReferencesResponse> HasFileReferences(HasFileReferencesRequest request,
SetReferenceExpirationRequest request, ServerCallContext context) ServerCallContext context)
{ {
Instant? expiredAt = null; var hasReferences = await fileReferenceService.HasFileReferencesAsync(request.FileId);
if (request.ExpiredAt != null) return new HasFileReferencesResponse { HasReferences = hasReferences };
{
expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
}
else if (request.Duration != null)
{
expiredAt = SystemClock.Instance.GetCurrentInstant() +
Duration.FromTimeSpan(request.Duration.ToTimeSpan());
}
var success =
await fileReferenceService.SetReferenceExpirationAsync(Guid.Parse(request.ReferenceId), expiredAt);
return new SetReferenceExpirationResponse { Success = success };
}
public override async Task<SetFileReferencesExpirationResponse> SetFileReferencesExpiration(
SetFileReferencesExpirationRequest request, ServerCallContext context)
{
var expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
var updatedCount = await fileReferenceService.SetFileReferencesExpirationAsync(request.FileId, expiredAt);
return new SetFileReferencesExpirationResponse { UpdatedCount = updatedCount };
}
public override async Task<HasFileReferencesResponse> HasFileReferences(HasFileReferencesRequest request,
ServerCallContext context)
{
var hasReferences = await fileReferenceService.HasFileReferencesAsync(request.FileId);
return new HasFileReferencesResponse { HasReferences = hasReferences };
}
} }
} }

View File

@@ -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);
} }
} }

View File

@@ -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()));

View File

@@ -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,72 +25,68 @@ 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)
{ {
var allowed = await permission.HasPermissionAsync(new HasPermissionRequest var allowed = await permission.HasPermissionAsync(new HasPermissionRequest
{ 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(
{ StatusCode = 403 }; ApiError.Unauthorized($"Content type {request.ContentType} is not allowed by the pool's policy",
true))
{ 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,8 +126,10 @@ 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(
{ StatusCode = 403 }; ApiError.Unauthorized($"File size {billableUnit} MiB is exceeded the user's quota {quota} MiB",
true))
{ StatusCode = 403 };
} }
if (!Directory.Exists(_tempPath)) if (!Directory.Exists(_tempPath))
@@ -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,30 +250,29 @@ 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 = var cloudFile = await fileService.ProcessNewFileAsync(
new FileStream(mergedFilePath, FileMode.Open, FileAccess.Read, FileShare.Read)) currentUser,
{ fileId,
var cloudFile = await fileService.ProcessNewFileAsync( task.PoolId.ToString(),
currentUser, task.BundleId?.ToString(),
fileId, mergedFilePath,
task.PoolId, task.FileName,
task.BundleId, task.ContentType,
fileStream, task.EncryptPassword,
task.FileName, task.ExpiredAt
task.ContentType, );
task.EncryptPassword,
task.ExpiredAt
);
// Clean up // Clean up
Directory.Delete(taskPath, true); Directory.Delete(taskPath, true);
System.IO.File.Delete(mergedFilePath); System.IO.File.Delete(mergedFilePath);
return Ok(cloudFile); return Ok(cloudFile);
}
} }
} }

View 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
);

View File

@@ -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!;

View File

@@ -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)",

View File

@@ -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;
},
}
};
}

View File

@@ -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"
}
} }

View File

@@ -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=="],
}
}

View File

@@ -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"]);
}

View 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"]

View 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>

View 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();

View 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"
}
}
}
}

View File

@@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"SiteUrl": "http://localhost:3000",
"Client": {
"SomeSetting": "SomeValue"
}
}

View 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);
}
}

View 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"]

View 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>

View 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
}
}
}

View File

@@ -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");
}
}
}

View 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
}
}
}

View 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();

View 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"
}
}
}
}

View 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;
}
}

View 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;
}
}

View 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

View 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);
}
}

View 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)
};
}
}

View 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);
}
}

View 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"
}
}

View File

@@ -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 [];

View File

@@ -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)
@@ -929,4 +933,4 @@ public class AccountCurrentController(
.ToListAsync(); .ToListAsync();
return Ok(records); return Ok(records);
} }
} }

View File

@@ -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,8 +430,8 @@ 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();
} }
} }

View File

@@ -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)

View File

@@ -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 };
} }

View File

@@ -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;
}
}

View File

@@ -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,16 +30,16 @@ 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);
} }
} }

View File

@@ -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)
@@ -111,4 +112,4 @@ public class ActionLogServiceGrpc : Shared.Proto.ActionLogService.ActionLogServi
throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.Internal, "Failed to list action logs")); throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.Internal, "Failed to list action logs"));
} }
} }
} }

View File

@@ -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);

View File

@@ -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);

View File

@@ -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