93 Commits

Author SHA1 Message Date
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
338 changed files with 13535 additions and 39884 deletions

View File

@@ -1,3 +1,4 @@
{
"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
DEVELOP_IMAGE=develop:latest
# Container image name for gateway
GATEWAY_IMAGE=gateway:latest

View File

@@ -26,6 +26,8 @@ jobs:
image: drive
- service: Develop
image: develop
- service: Gateway
image: gateway
steps:
- 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,39 +1,28 @@
using System.Net;
using System.Net.Sockets;
using Aspire.Hosting.Yarp.Transforms;
using Microsoft.Extensions.Hosting;
var builder = DistributedApplication.CreateBuilder(args);
var isDev = builder.Environment.IsDevelopment();
// Database was configured separately in each service.
// var database = builder.AddPostgres("database");
var cache = builder.AddRedis("cache");
var queue = builder.AddNats("queue").WithJetStream();
var ringService = builder.AddProject<Projects.DysonNetwork_Ring>("ring")
.WithReference(queue);
var ringService = builder.AddProject<Projects.DysonNetwork_Ring>("ring");
var passService = builder.AddProject<Projects.DysonNetwork_Pass>("pass")
.WithReference(cache)
.WithReference(queue)
.WithReference(ringService);
var driveService = builder.AddProject<Projects.DysonNetwork_Drive>("drive")
.WithReference(cache)
.WithReference(queue)
.WithReference(passService)
.WithReference(ringService);
var sphereService = builder.AddProject<Projects.DysonNetwork_Sphere>("sphere")
.WithReference(cache)
.WithReference(queue)
.WithReference(passService)
.WithReference(ringService)
.WithReference(driveService);
var developService = builder.AddProject<Projects.DysonNetwork_Develop>("develop")
.WithReference(cache)
.WithReference(passService)
.WithReference(ringService);
.WithReference(ringService)
.WithReference(sphereService);
passService.WithReference(developService).WithReference(driveService);
List<IResourceBuilder<ProjectResource>> services =
[ringService, passService, driveService, sphereService, developService];
@@ -41,6 +30,9 @@ List<IResourceBuilder<ProjectResource>> services =
for (var idx = 0; idx < services.Count; idx++)
{
var service = services[idx];
service.WithReference(cache).WithReference(queue);
var grpcPort = 7002 + idx;
if (isDev)
@@ -62,35 +54,13 @@ for (var idx = 0; idx < services.Count; idx++)
// Extra double-ended references
ringService.WithReference(passService);
builder.AddYarp("gateway")
.WithConfiguration(yarp =>
{
var ringCluster = yarp.AddCluster(ringService.GetEndpoint("http"));
yarp.AddRoute("/ws", ringCluster);
yarp.AddRoute("/ring/{**catch-all}", ringCluster)
.WithTransformPathRemovePrefix("/ring")
.WithTransformPathPrefix("/api");
var passCluster = yarp.AddCluster(passService.GetEndpoint("http"));
yarp.AddRoute("/.well-known/openid-configuration", passCluster);
yarp.AddRoute("/.well-known/jwks", passCluster);
yarp.AddRoute("/id/{**catch-all}", passCluster)
.WithTransformPathRemovePrefix("/id")
.WithTransformPathPrefix("/api");
var driveCluster = yarp.AddCluster(driveService.GetEndpoint("http"));
yarp.AddRoute("/api/tus", driveCluster);
yarp.AddRoute("/drive/{**catch-all}", driveCluster)
.WithTransformPathRemovePrefix("/drive")
.WithTransformPathPrefix("/api");
var sphereCluster = yarp.AddCluster(sphereService.GetEndpoint("http"));
yarp.AddRoute("/sphere/{**catch-all}", sphereCluster)
.WithTransformPathRemovePrefix("/sphere")
.WithTransformPathPrefix("/api");
var developCluster = yarp.AddCluster(developService.GetEndpoint("http"));
yarp.AddRoute("/develop/{**catch-all}", developCluster)
.WithTransformPathRemovePrefix("/develop")
.WithTransformPathPrefix("/api");
});
var gateway = builder.AddProject<Projects.DysonNetwork_Gateway>("gateway")
.WithEnvironment("HTTP_PORTS", "5001")
.WithHttpEndpoint(port: 5001, targetPort: null, isProxied: false, name: "http");
foreach (var service in services)
gateway.WithReference(service);
builder.AddDockerComposeEnvironment("docker-compose");
builder.Build().Run();
builder.Build().Run();

View File

@@ -1,30 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.4.2"/>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>a68b3195-a00d-40c2-b5ed-d675356b7cde</UserSecretsId>
<RootNamespace>DysonNetwork.Control</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.4.2"/>
<PackageReference Include="Aspire.Hosting.Docker" Version="9.4.2-preview.1.25428.12" />
<PackageReference Include="Aspire.Hosting.Nats" Version="9.4.2" />
<PackageReference Include="Aspire.Hosting.Redis" Version="9.4.2" />
<PackageReference Include="Aspire.Hosting.Yarp" Version="9.4.2-preview.1.25428.12" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.Develop\DysonNetwork.Develop.csproj" />
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
<ProjectReference Include="..\DysonNetwork.Pass\DysonNetwork.Pass.csproj" />
<ProjectReference Include="..\DysonNetwork.Ring\DysonNetwork.Ring.csproj" />
<ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" />
</ItemGroup>
</Project>
<Sdk Name="Aspire.AppHost.Sdk" Version="9.5.1" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>a68b3195-a00d-40c2-b5ed-d675356b7cde</UserSecretsId>
<RootNamespace>DysonNetwork.Control</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.5.1" />
<PackageReference Include="Aspire.Hosting.Docker" Version="9.4.2-preview.1.25428.12" />
<PackageReference Include="Aspire.Hosting.Nats" Version="9.5.1" />
<PackageReference Include="Aspire.Hosting.Redis" Version="9.5.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.Develop\DysonNetwork.Develop.csproj" />
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
<ProjectReference Include="..\DysonNetwork.Pass\DysonNetwork.Pass.csproj" />
<ProjectReference Include="..\DysonNetwork.Ring\DysonNetwork.Ring.csproj" />
<ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" />
<ProjectReference Include="..\DysonNetwork.Gateway\DysonNetwork.Gateway.csproj" />
</ItemGroup>
</Project>

View File

@@ -10,7 +10,9 @@
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"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": {
@@ -22,8 +24,9 @@
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"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,6 +1,4 @@
using System.Text.Json;
using DysonNetwork.Develop.Identity;
using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
@@ -11,13 +9,13 @@ public class AppDatabase(
IConfiguration configuration
) : 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<CustomAppSecret> CustomAppSecrets { get; set; } = null!;
public DbSet<BotAccount> BotAccounts { get; set; } = null!;
public DbSet<SnCustomApp> CustomApps { get; set; } = null!;
public DbSet<SnCustomAppSecret> CustomAppSecrets { get; set; } = null!;
public DbSet<SnBotAccount> BotAccounts { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{

View File

@@ -18,7 +18,7 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
<PackageReference Include="NodaTime" Version="3.2.2"/>
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/>
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0"/>
@@ -31,7 +31,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup>

View File

@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using Grpc.Core;
@@ -16,7 +16,7 @@ namespace DysonNetwork.Develop.Identity;
[Authorize]
public class BotAccountController(
BotAccountService botService,
DeveloperService developerService,
DeveloperService ds,
DevProjectService projectService,
ILogger<BotAccountController> logger,
AccountClientHelper accounts,
@@ -50,9 +50,9 @@ public class BotAccountController(
]
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";
}
@@ -68,7 +68,7 @@ public class BotAccountController(
[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; }
@@ -83,12 +83,12 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await developerService.GetDeveloperByName(pubName);
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
PublisherMemberRole.Viewer))
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
Shared.Proto.PublisherMemberRole.Viewer))
return StatusCode(403, "You must be an viewer of the developer to list bots");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -108,12 +108,12 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await developerService.GetDeveloperByName(pubName);
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
PublisherMemberRole.Viewer))
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
Shared.Proto.PublisherMemberRole.Viewer))
return StatusCode(403, "You must be an viewer of the developer to view bot details");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -137,12 +137,12 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await developerService.GetDeveloperByName(pubName);
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await developerService.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 bot");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -206,12 +206,12 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await developerService.GetDeveloperByName(pubName);
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await developerService.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 bot");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -267,12 +267,12 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await developerService.GetDeveloperByName(pubName);
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await developerService.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 bot");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -296,7 +296,7 @@ public class BotAccountController(
}
[HttpGet("{botId:guid}/keys")]
public async Task<ActionResult<List<ApiKeyReference>>> ListBotKeys(
public async Task<ActionResult<List<SnApiKey>>> ListBotKeys(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid botId
@@ -305,7 +305,7 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
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 (project == null) return NotFound("Project not found or you don't have access");
if (bot == null) return NotFound("Bot not found");
@@ -314,13 +314,13 @@ public class BotAccountController(
{
AutomatedId = bot.Id.ToString()
});
var data = keys.Data.Select(ApiKeyReference.FromProtoValue).ToList();
var data = keys.Data.Select(SnApiKey.FromProtoValue).ToList();
return Ok(data);
}
[HttpGet("{botId:guid}/keys/{keyId:guid}")]
public async Task<ActionResult<ApiKeyReference>> GetBotKey(
public async Task<ActionResult<SnApiKey>> GetBotKey(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid botId,
@@ -329,7 +329,7 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
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 (project == null) return NotFound("Project not found or you don't have access");
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() });
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)
{
@@ -353,7 +353,7 @@ public class BotAccountController(
}
[HttpPost("{botId:guid}/keys")]
public async Task<ActionResult<ApiKeyReference>> CreateBotKey(
public async Task<ActionResult<SnApiKey>> CreateBotKey(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid botId,
@@ -362,7 +362,7 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
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 (project == null) return NotFound("Project not found or you don't have access");
if (bot == null) return NotFound("Bot not found");
@@ -374,9 +374,9 @@ public class BotAccountController(
AccountId = bot.Id.ToString(),
Label = request.Label
};
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)
{
@@ -385,7 +385,7 @@ public class BotAccountController(
}
[HttpPost("{botId:guid}/keys/{keyId:guid}/rotate")]
public async Task<ActionResult<ApiKeyReference>> RotateBotKey(
public async Task<ActionResult<SnApiKey>> RotateBotKey(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid botId,
@@ -394,7 +394,7 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
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 (project == null) return NotFound("Project not found or you don't have access");
if (bot == null) return NotFound("Bot not found");
@@ -402,7 +402,7 @@ public class BotAccountController(
try
{
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)
{
@@ -420,7 +420,7 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
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 (project == null) return NotFound("Project not found or you don't have access");
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,
Guid projectId,
Guid botId,
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 (!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);
var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -457,4 +457,4 @@ public class BotAccountController(
return (developer, project, bot);
}
}
}

View File

@@ -1,3 +1,4 @@
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Develop.Identity;
@@ -7,7 +8,7 @@ namespace DysonNetwork.Develop.Identity;
public class BotAccountPublicController(BotAccountService botService, DeveloperService developerService) : ControllerBase
{
[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);
if (bot is null) return NotFound("Bot not found");
@@ -21,7 +22,7 @@ public class BotAccountPublicController(BotAccountService botService, DeveloperS
}
[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);
if (bot is null) return NotFound("Bot not found");

View File

@@ -1,5 +1,4 @@
using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using Grpc.Core;
@@ -14,22 +13,22 @@ public class BotAccountService(
AccountClientHelper accounts
)
{
public async Task<BotAccount?> GetBotByIdAsync(Guid id)
public async Task<SnBotAccount?> GetBotByIdAsync(Guid id)
{
return await db.BotAccounts
.Include(b => b.Project)
.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
.Where(b => b.ProjectId == projectId)
.ToListAsync();
}
public async Task<BotAccount> CreateBotAsync(
DevProject project,
public async Task<SnBotAccount> CreateBotAsync(
SnDevProject project,
string slug,
Account account,
string? pictureId,
@@ -58,7 +57,7 @@ public class BotAccountService(
var botAccount = createResponse.Bot;
// Then create the local bot account
var bot = new BotAccount
var bot = new SnBotAccount
{
Id = automatedId,
Slug = slug,
@@ -89,8 +88,8 @@ public class BotAccountService(
}
}
public async Task<BotAccount> UpdateBotAsync(
BotAccount bot,
public async Task<SnBotAccount> UpdateBotAsync(
SnBotAccount bot,
Account account,
string? pictureId,
string? backgroundId
@@ -98,7 +97,7 @@ public class BotAccountService(
{
db.Update(bot);
await db.SaveChangesAsync();
try
{
// Update the bot account in the Pass service
@@ -130,7 +129,7 @@ public class BotAccountService(
return bot;
}
public async Task DeleteBotAsync(BotAccount bot)
public async Task DeleteBotAsync(SnBotAccount bot)
{
try
{
@@ -153,22 +152,21 @@ public class BotAccountService(
await db.SaveChangesAsync();
}
public async Task<BotAccount?> LoadBotAccountAsync(BotAccount bot) =>
public async Task<SnBotAccount?> LoadBotAccountAsync(SnBotAccount bot) =>
(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 data = await accounts.GetBotAccountBatch(automatedIds);
foreach (var bot in bots)
{
bot.Account = data
.Select(AccountReference.FromProtoValue)
.Select(SnAccount.FromProtoValue)
.FirstOrDefault(e => e.AutomatedId == bot.Id);
}
return bots as List<BotAccount> ?? [];
return bots;
}
}
}

View File

@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -18,9 +19,9 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
[MaxLength(4096)] string? Description,
string? PictureId,
string? BackgroundId,
CustomAppStatus? Status,
CustomAppLinks? Links,
CustomAppOauthConfig? OauthConfig
Shared.Models.CustomAppStatus? Status,
SnCustomAppLinks? Links,
SnCustomAppOauthConfig? OauthConfig
);
public record CreateSecretRequest(
@@ -50,7 +51,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
if (developer is null) return NotFound();
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");
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();
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");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -99,7 +100,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
if (developer is null)
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");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -143,7 +144,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
if (developer is null)
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");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -180,7 +181,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
if (developer is null)
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");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -212,7 +213,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
if (developer is null)
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");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -250,7 +251,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
if (developer is null)
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");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -263,7 +264,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
try
{
var secret = await customApps.CreateAppSecretAsync(new CustomAppSecret
var secret = await customApps.CreateAppSecretAsync(new SnCustomAppSecret
{
AppId = appId,
Description = request.Description,
@@ -309,7 +310,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
if (developer is null)
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");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -350,7 +351,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
if (developer is null)
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");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -388,7 +389,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
if (developer is null)
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");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -401,7 +402,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
try
{
var secret = await customApps.RotateAppSecretAsync(new CustomAppSecret
var secret = await customApps.RotateAppSecretAsync(new SnCustomAppSecret
{
Id = secretId,
AppId = appId,

View File

@@ -1,5 +1,4 @@
using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
@@ -13,7 +12,7 @@ public class CustomAppService(
FileService.FileServiceClient files
)
{
public async Task<CustomApp?> CreateAppAsync(
public async Task<SnCustomApp?> CreateAppAsync(
Guid projectId,
CustomAppController.CustomAppRequest request
)
@@ -25,12 +24,12 @@ public class CustomAppService(
if (project == null)
return null;
var app = new CustomApp
var app = new SnCustomApp
{
Slug = request.Slug!,
Name = request.Name!,
Description = request.Description,
Status = request.Status ?? CustomAppStatus.Developing,
Status = request.Status ?? Shared.Models.CustomAppStatus.Developing,
Links = request.Links,
OauthConfig = request.OauthConfig,
ProjectId = projectId
@@ -46,7 +45,7 @@ public class CustomAppService(
);
if (picture is null)
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
await fileRefs.CreateReferenceAsync(
@@ -65,7 +64,7 @@ public class CustomAppService(
);
if (background is null)
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
await fileRefs.CreateReferenceAsync(
@@ -84,7 +83,7 @@ public class CustomAppService(
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();
@@ -96,7 +95,7 @@ public class CustomAppService(
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
.Where(s => s.AppId == appId)
@@ -104,13 +103,13 @@ public class CustomAppService(
.ToListAsync();
}
public async Task<CustomAppSecret?> GetAppSecretAsync(Guid secretId, Guid appId)
public async Task<SnCustomAppSecret?> GetAppSecretAsync(Guid secretId, Guid appId)
{
return await db.CustomAppSecrets
.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))
{
@@ -141,7 +140,7 @@ public class CustomAppService(
return true;
}
public async Task<CustomAppSecret> RotateAppSecretAsync(CustomAppSecret secretUpdate)
public async Task<SnCustomAppSecret> RotateAppSecretAsync(SnCustomAppSecret secretUpdate)
{
var existingSecret = await db.CustomAppSecrets
.FirstOrDefaultAsync(s => s.Id == secretUpdate.Id && s.AppId == secretUpdate.AppId);
@@ -177,14 +176,14 @@ public class CustomAppService(
return res.ToString();
}
public async Task<List<CustomApp>> GetAppsByProjectAsync(Guid projectId)
public async Task<List<SnCustomApp>> GetAppsByProjectAsync(Guid projectId)
{
return await db.CustomApps
.Where(a => a.ProjectId == projectId)
.ToListAsync();
}
public async Task<CustomApp?> UpdateAppAsync(CustomApp app, CustomAppController.CustomAppRequest request)
public async Task<SnCustomApp?> UpdateAppAsync(SnCustomApp app, CustomAppController.CustomAppRequest request)
{
if (request.Slug is not null)
app.Slug = request.Slug;
@@ -209,7 +208,7 @@ public class CustomAppService(
);
if (picture is null)
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
await fileRefs.CreateReferenceAsync(
@@ -228,7 +227,7 @@ public class CustomAppService(
);
if (background is null)
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
await fileRefs.CreateReferenceAsync(

View File

@@ -1,3 +1,4 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Grpc.Core;
using Microsoft.EntityFrameworkCore;
@@ -37,7 +38,7 @@ public class CustomAppServiceGrpc(AppDatabase db) : Shared.Proto.CustomAppServic
if (string.IsNullOrEmpty(request.Secret))
throw new RpcException(new Status(StatusCode.InvalidArgument, "secret required"));
IQueryable<CustomAppSecret> q = db.CustomAppSecrets;
IQueryable<SnCustomAppSecret> q = db.CustomAppSecrets;
switch (request.SecretIdentifierCase)
{
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.Models;
using DysonNetwork.Shared.Proto;
using Grpc.Core;
using Microsoft.AspNetCore.Authorization;
@@ -18,7 +19,7 @@ public class DeveloperController(
: ControllerBase
{
[HttpGet("{name}")]
public async Task<ActionResult<Developer>> GetDeveloper(string name)
public async Task<ActionResult<SnDeveloper>> GetDeveloper(string name)
{
var developer = await ds.GetDeveloperByName(name);
if (developer is null) return NotFound();
@@ -47,7 +48,7 @@ public class DeveloperController(
[HttpGet]
[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();
@@ -69,16 +70,16 @@ public class DeveloperController(
[HttpPost("{name}/enroll")]
[Authorize]
[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();
var accountId = Guid.Parse(currentUser.Id);
PublisherInfo? pub;
SnPublisher? pub;
try
{
var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Name = name });
pub = PublisherInfo.FromProto(pubResponse.Publisher);
pub = SnPublisher.FromProto(pubResponse.Publisher);
} catch (RpcException ex)
{
return NotFound(ex.Status.Detail);
@@ -89,14 +90,14 @@ public class DeveloperController(
{
PublisherId = pub.Id.ToString(),
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");
var hasDeveloper = await db.Developers.AnyAsync(d => d.PublisherId == pub.Id);
if (hasDeveloper) return BadRequest("Publisher is already in the developer program");
var developer = new Developer
var developer = new SnDeveloper
{
Id = Guid.NewGuid(),
PublisherId = pub.Id

View File

@@ -1,3 +1,4 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Grpc.Core;
using Microsoft.EntityFrameworkCore;
@@ -9,22 +10,22 @@ public class DeveloperService(
PublisherService.PublisherServiceClient ps,
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() });
developer.Publisher = PublisherInfo.FromProto(pubResponse.Publisher);
developer.Publisher = SnPublisher.FromProto(pubResponse.Publisher);
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 pubIds = enumerable.Select(d => d.PublisherId).ToList();
var pubRequest = new GetPublisherBatchRequest();
pubIds.ForEach(x => pubRequest.Ids.Add(x.ToString()));
var pubResponse = await ps.GetPublisherBatchAsync(pubRequest);
var pubs = pubResponse.Publishers.ToDictionary(p => Guid.Parse(p.Id), PublisherInfo.FromProto);
var pubs = pubResponse.Publishers.ToDictionary(p => Guid.Parse(p.Id), SnPublisher.FromProto);
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
{
@@ -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);
}
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
{

View File

@@ -1,8 +1,7 @@
// <auto-generated />
using System;
using DysonNetwork.Develop;
using DysonNetwork.Develop.Identity;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
@@ -35,7 +34,7 @@ namespace DysonNetwork.Develop.Migrations
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<CloudFileReferenceObject>("Background")
b.Property<SnCloudFileReferenceObject>("Background")
.HasColumnType("jsonb")
.HasColumnName("background");
@@ -56,7 +55,7 @@ namespace DysonNetwork.Develop.Migrations
.HasColumnType("uuid")
.HasColumnName("developer_id");
b.Property<CustomAppLinks>("Links")
b.Property<SnCustomAppLinks>("Links")
.HasColumnType("jsonb")
.HasColumnName("links");
@@ -66,11 +65,11 @@ namespace DysonNetwork.Develop.Migrations
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<CustomAppOauthConfig>("OauthConfig")
b.Property<SnCustomAppOauthConfig>("OauthConfig")
.HasColumnType("jsonb")
.HasColumnName("oauth_config");
b.Property<CloudFileReferenceObject>("Picture")
b.Property<SnCloudFileReferenceObject>("Picture")
.HasColumnType("jsonb")
.HasColumnName("picture");
@@ -88,7 +87,7 @@ namespace DysonNetwork.Develop.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<VerificationMark>("Verification")
b.Property<SnVerificationMark>("Verification")
.HasColumnType("jsonb")
.HasColumnName("verification");

View File

@@ -1,6 +1,4 @@
using System;
using DysonNetwork.Develop.Identity;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
@@ -35,11 +33,11 @@ namespace DysonNetwork.Develop.Migrations
name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
status = table.Column<int>(type: "integer", nullable: false),
picture = table.Column<CloudFileReferenceObject>(type: "jsonb", nullable: true),
background = table.Column<CloudFileReferenceObject>(type: "jsonb", nullable: true),
verification = table.Column<VerificationMark>(type: "jsonb", nullable: true),
oauth_config = table.Column<CustomAppOauthConfig>(type: "jsonb", nullable: true),
links = table.Column<CustomAppLinks>(type: "jsonb", nullable: true),
picture = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
background = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
verification = table.Column<SnVerificationMark>(type: "jsonb", nullable: true),
oauth_config = table.Column<SnCustomAppOauthConfig>(type: "jsonb", nullable: true),
links = table.Column<SnCustomAppLinks>(type: "jsonb", nullable: true),
developer_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),

View File

@@ -1,8 +1,7 @@
// <auto-generated />
using System;
using DysonNetwork.Develop;
using DysonNetwork.Develop.Identity;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
@@ -35,7 +34,7 @@ namespace DysonNetwork.Develop.Migrations
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<CloudFileReferenceObject>("Background")
b.Property<SnCloudFileReferenceObject>("Background")
.HasColumnType("jsonb")
.HasColumnName("background");
@@ -52,7 +51,7 @@ namespace DysonNetwork.Develop.Migrations
.HasColumnType("character varying(4096)")
.HasColumnName("description");
b.Property<CustomAppLinks>("Links")
b.Property<SnCustomAppLinks>("Links")
.HasColumnType("jsonb")
.HasColumnName("links");
@@ -62,11 +61,11 @@ namespace DysonNetwork.Develop.Migrations
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<CustomAppOauthConfig>("OauthConfig")
b.Property<SnCustomAppOauthConfig>("OauthConfig")
.HasColumnType("jsonb")
.HasColumnName("oauth_config");
b.Property<CloudFileReferenceObject>("Picture")
b.Property<SnCloudFileReferenceObject>("Picture")
.HasColumnType("jsonb")
.HasColumnName("picture");
@@ -88,7 +87,7 @@ namespace DysonNetwork.Develop.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<VerificationMark>("Verification")
b.Property<SnVerificationMark>("Verification")
.HasColumnType("jsonb")
.HasColumnName("verification");

View File

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

View File

@@ -1,8 +1,7 @@
// <auto-generated />
using System;
using DysonNetwork.Develop;
using DysonNetwork.Develop.Identity;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
@@ -77,7 +76,7 @@ namespace DysonNetwork.Develop.Migrations
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<CloudFileReferenceObject>("Background")
b.Property<SnCloudFileReferenceObject>("Background")
.HasColumnType("jsonb")
.HasColumnName("background");
@@ -94,7 +93,7 @@ namespace DysonNetwork.Develop.Migrations
.HasColumnType("character varying(4096)")
.HasColumnName("description");
b.Property<CustomAppLinks>("Links")
b.Property<SnCustomAppLinks>("Links")
.HasColumnType("jsonb")
.HasColumnName("links");
@@ -104,11 +103,11 @@ namespace DysonNetwork.Develop.Migrations
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<CustomAppOauthConfig>("OauthConfig")
b.Property<SnCustomAppOauthConfig>("OauthConfig")
.HasColumnType("jsonb")
.HasColumnName("oauth_config");
b.Property<CloudFileReferenceObject>("Picture")
b.Property<SnCloudFileReferenceObject>("Picture")
.HasColumnType("jsonb")
.HasColumnName("picture");
@@ -130,7 +129,7 @@ namespace DysonNetwork.Develop.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<VerificationMark>("Verification")
b.Property<SnVerificationMark>("Verification")
.HasColumnType("jsonb")
.HasColumnName("verification");

View File

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

View File

@@ -1,8 +1,7 @@
// <auto-generated />
using System;
using DysonNetwork.Develop;
using DysonNetwork.Develop.Identity;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -74,7 +73,7 @@ namespace DysonNetwork.Develop.Migrations
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<CloudFileReferenceObject>("Background")
b.Property<SnCloudFileReferenceObject>("Background")
.HasColumnType("jsonb")
.HasColumnName("background");
@@ -91,7 +90,7 @@ namespace DysonNetwork.Develop.Migrations
.HasColumnType("character varying(4096)")
.HasColumnName("description");
b.Property<CustomAppLinks>("Links")
b.Property<SnCustomAppLinks>("Links")
.HasColumnType("jsonb")
.HasColumnName("links");
@@ -101,11 +100,11 @@ namespace DysonNetwork.Develop.Migrations
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<CustomAppOauthConfig>("OauthConfig")
b.Property<SnCustomAppOauthConfig>("OauthConfig")
.HasColumnType("jsonb")
.HasColumnName("oauth_config");
b.Property<CloudFileReferenceObject>("Picture")
b.Property<SnCloudFileReferenceObject>("Picture")
.HasColumnType("jsonb")
.HasColumnName("picture");
@@ -127,7 +126,7 @@ namespace DysonNetwork.Develop.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<VerificationMark>("Verification")
b.Property<SnVerificationMark>("Verification")
.HasColumnType("jsonb")
.HasColumnName("verification");

View File

@@ -13,12 +13,16 @@ builder.ConfigureAppKestrel(builder.Configuration);
builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppAuthentication();
builder.Services.AddAppSwagger();
builder.Services.AddDysonAuth();
builder.Services.AddPublisherService();
builder.Services.AddAccountService();
builder.Services.AddDriveService();
builder.AddSwaggerManifest(
"DysonNetwork.Develop",
"The developer portal in the Solar Network."
);
var app = builder.Build();
app.MapDefaultEndpoints();
@@ -31,4 +35,6 @@ using (var scope = app.Services.CreateScope())
app.ConfigureAppMiddleware(builder.Configuration);
app.UseSwaggerManifest();
app.Run();

View File

@@ -1,6 +1,6 @@
using DysonNetwork.Develop.Identity;
using Microsoft.EntityFrameworkCore;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Develop.Project;
@@ -10,12 +10,12 @@ public class DevProjectService(
FileService.FileServiceClient files
)
{
public async Task<DevProject> CreateProjectAsync(
Developer developer,
public async Task<SnDevProject> CreateProjectAsync(
SnDeveloper developer,
DevProjectController.DevProjectRequest request
)
{
var project = new DevProject
var project = new SnDevProject
{
Slug = request.Slug!,
Name = request.Name!,
@@ -29,7 +29,7 @@ public class DevProjectService(
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();
@@ -41,14 +41,14 @@ public class DevProjectService(
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
.Where(p => p.DeveloperId == developerId)
.ToListAsync();
}
public async Task<DevProject?> UpdateProjectAsync(
public async Task<SnDevProject?> UpdateProjectAsync(
Guid id,
Guid developerId,
DevProjectController.DevProjectRequest request

View File

@@ -1,8 +1,6 @@
using System.Net;
using DysonNetwork.Develop.Identity;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http;
using Microsoft.AspNetCore.HttpOverrides;
using Prometheus;
namespace DysonNetwork.Develop.Startup;
@@ -14,9 +12,6 @@ public static class ApplicationConfiguration
app.MapMetrics();
app.MapOpenApi();
app.UseSwagger();
app.UseSwaggerUI();
app.UseRequestLocalization();
app.ConfigureForwardedHeaders(configuration);

View File

@@ -1,5 +1,4 @@
using System.Globalization;
using Microsoft.OpenApi.Models;
using NodaTime;
using NodaTime.Serialization.SystemTextJson;
using System.Text.Json;
@@ -7,7 +6,6 @@ using System.Text.Json.Serialization;
using DysonNetwork.Develop.Identity;
using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Cache;
using StackExchange.Redis;
namespace DysonNetwork.Develop.Startup;
@@ -57,23 +55,7 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
{
services.AddCors();
services.AddAuthorization();
return services;
}
public static IServiceCollection AddAppSwagger(this IServiceCollection services)
{
services.AddEndpointsApiExplorer();
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Version = "v1",
Title = "Develop API",
});
});
services.AddOpenApi();
return services;
}
}

View File

@@ -10,12 +10,12 @@
},
"AllowedHosts": "*",
"ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_network_dev;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
"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": {
"Insecure": true
},

View File

@@ -1,8 +1,7 @@
using System.Linq.Expressions;
using System.Reflection;
using DysonNetwork.Drive.Billing;
using DysonNetwork.Drive.Storage;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Query;
@@ -17,11 +16,11 @@ public class AppDatabase(
) : DbContext(options)
{
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<CloudFile> Files { get; set; } = null!;
public DbSet<SnCloudFile> Files { get; set; } = null!;
public DbSet<CloudFileReference> FileReferences { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)

View File

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

View File

@@ -17,6 +17,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="MimeKit" Version="4.13.0" />
<PackageReference Include="MimeTypes" Version="2.5.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -55,8 +56,8 @@
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.9" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.9" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" />
<PackageReference Include="tusdotnet" Version="2.10.0" />
</ItemGroup>
@@ -67,7 +68,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -3,7 +3,7 @@ using System;
using System.Collections.Generic;
using DysonNetwork.Drive;
using DysonNetwork.Drive.Storage;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;

View File

@@ -1,7 +1,4 @@
using System;
using System.Collections.Generic;
using DysonNetwork.Drive.Storage;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;

View File

@@ -2,7 +2,7 @@
using System;
using System.Collections.Generic;
using DysonNetwork.Drive;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;

View File

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

View File

@@ -2,8 +2,7 @@
using System;
using System.Collections.Generic;
using DysonNetwork.Drive;
using DysonNetwork.Drive.Storage;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;

View File

@@ -1,6 +1,4 @@
using System;
using System.Collections.Generic;
using DysonNetwork.Drive.Storage;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;

View File

@@ -2,8 +2,7 @@
using System;
using System.Collections.Generic;
using DysonNetwork.Drive;
using DysonNetwork.Drive.Storage;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;

View File

@@ -2,8 +2,7 @@
using System;
using System.Collections.Generic;
using DysonNetwork.Drive;
using DysonNetwork.Drive.Storage;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;

View File

@@ -2,8 +2,7 @@
using System;
using System.Collections.Generic;
using DysonNetwork.Drive;
using DysonNetwork.Drive.Storage;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;

View File

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

View File

@@ -2,8 +2,7 @@
using System;
using System.Collections.Generic;
using DysonNetwork.Drive;
using DysonNetwork.Drive.Storage;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;

View File

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

View File

@@ -2,8 +2,7 @@
using System;
using System.Collections.Generic;
using DysonNetwork.Drive;
using DysonNetwork.Drive.Storage;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;

View File

@@ -2,8 +2,7 @@
using System;
using System.Collections.Generic;
using DysonNetwork.Drive;
using DysonNetwork.Drive.Storage;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;

View File

@@ -2,8 +2,7 @@
using System;
using System.Collections.Generic;
using DysonNetwork.Drive;
using DysonNetwork.Drive.Storage;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;

View File

@@ -2,8 +2,7 @@
using System;
using System.Collections.Generic;
using DysonNetwork.Drive;
using DysonNetwork.Drive.Storage;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;

View File

@@ -18,21 +18,20 @@ builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxV
builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppRateLimiting();
builder.Services.AddAppAuthentication();
builder.Services.AddAppSwagger();
builder.Services.AddDysonAuth();
builder.Services.AddAccountService();
builder.Services.AddAppFileStorage(builder.Configuration);
// Add flush handlers and websocket handlers
builder.Services.AddAppFlushHandlers();
// Add business services
builder.Services.AddAppBusinessServices();
// Add scheduled jobs
builder.Services.AddAppScheduledJobs();
builder.AddSwaggerManifest(
"DysonNetwork.Drive",
"The file upload and storage service in the Solar Network."
);
var app = builder.Build();
app.MapDefaultEndpoints();
@@ -50,4 +49,6 @@ app.ConfigureAppMiddleware(tusDiskStore);
// Configure gRPC
app.ConfigureGrpcServices();
app.UseSwaggerManifest();
app.Run();

View File

@@ -8,13 +8,6 @@ public static class ApplicationBuilderExtensions
{
public static WebApplication ConfigureAppMiddleware(this WebApplication app, ITusStore tusStore)
{
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseAuthorization();
app.MapControllers();

View File

@@ -1,10 +1,16 @@
using System.Text.Json;
using DysonNetwork.Drive.Storage;
using DysonNetwork.Drive.Storage.Model;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Stream;
using FFMpegCore;
using Microsoft.EntityFrameworkCore;
using NATS.Client.Core;
using NATS.Client.JetStream;
using NATS.Client.JetStream.Models;
using NATS.Net;
using NetVips;
using NodaTime;
using FileService = DysonNetwork.Drive.Storage.FileService;
namespace DysonNetwork.Drive.Startup;
@@ -14,20 +20,74 @@ public class BroadcastEventHandler(
IServiceProvider serviceProvider
) : 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)
{
var js = nats.CreateJetStreamContext();
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);
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))
{
try
{
var evt = JsonSerializer.Deserialize<AccountDeletedEvent>(msg.Data);
var evt = JsonSerializer.Deserialize<AccountDeletedEvent>(msg.Data, GrpcTypeHelper.SerializerOptions);
if (evt == null)
{
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

@@ -3,11 +3,8 @@ using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using DysonNetwork.Shared.Cache;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.OpenApi.Models;
using NodaTime;
using NodaTime.Serialization.SystemTextJson;
using StackExchange.Redis;
using DysonNetwork.Shared.Proto;
using tusdotnet.Stores;
namespace DysonNetwork.Drive.Startup;
@@ -61,9 +58,7 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
{
services.AddCors();
services.AddAuthorization();
return services;
}
@@ -74,52 +69,6 @@ public static class ServiceCollectionExtensions
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")!;

View File

@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -22,7 +23,7 @@ public class BundleController(AppDatabase db) : ControllerBase
}
[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
.Where(e => e.Id == id)
@@ -36,7 +37,7 @@ public class BundleController(AppDatabase db) : ControllerBase
[HttpGet("me")]
[Authorize]
public async Task<ActionResult<List<FileBundle>>> ListBundles(
public async Task<ActionResult<List<SnFileBundle>>> ListBundles(
[FromQuery] string? term,
[FromQuery] int offset = 0,
[FromQuery] int take = 20
@@ -65,7 +66,7 @@ public class BundleController(AppDatabase db) : ControllerBase
[HttpPost]
[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();
var accountId = Guid.Parse(currentUser.Id);
@@ -77,7 +78,7 @@ public class BundleController(AppDatabase db) : ControllerBase
if (string.IsNullOrEmpty(request.Name))
request.Name = "Unnamed Bundle";
var bundle = new FileBundle
var bundle = new SnFileBundle
{
Slug = request.Slug,
Name = request.Name,
@@ -95,7 +96,7 @@ public class BundleController(AppDatabase db) : ControllerBase
[HttpPut("{id:guid}")]
[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();
var accountId = Guid.Parse(currentUser.Id);

View File

@@ -1,6 +1,6 @@
using DysonNetwork.Drive.Billing;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -46,14 +46,36 @@ public class FileController(
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")!;
var filePath = Path.Combine(env.ContentRootPath, tusStorePath, file.Id);
if (!System.IO.File.Exists(filePath)) return new NotFoundResult();
return PhysicalFile(filePath, file.MimeType ?? "application/octet-stream", file.Name);
// File is not yet uploaded to remote storage. Try to serve from local temp storage.
var tempFilePath = Path.Combine(Path.GetTempPath(), file.Id);
if (System.IO.File.Exists(tempFilePath))
{
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);
if (pool is null)
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")]
public async Task<ActionResult<CloudFile>> GetFileInfo(string id)
public async Task<ActionResult<SnCloudFile>> GetFileInfo(string id)
{
var file = await fs.GetFileAsync(id);
if (file is null) return NotFound("File not found.");
@@ -151,7 +173,7 @@ public class FileController(
[Authorize]
[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();
var accountId = Guid.Parse(currentUser.Id);
@@ -170,7 +192,7 @@ public class FileController(
[Authorize]
[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();
var accountId = Guid.Parse(currentUser.Id);
@@ -184,7 +206,7 @@ public class FileController(
[Authorize]
[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();
var accountId = Guid.Parse(currentUser.Id);
@@ -198,7 +220,7 @@ public class FileController(
[Authorize]
[HttpGet("me")]
public async Task<ActionResult<List<CloudFile>>> GetMyFiles(
public async Task<ActionResult<List<SnCloudFile>>> GetMyFiles(
[FromQuery] Guid? pool,
[FromQuery] bool recycled = false,
[FromQuery] int offset = 0,
@@ -283,7 +305,7 @@ public class FileController(
[Authorize]
[HttpPost("fast")]
[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();
var accountId = Guid.Parse(currentUser.Id);
@@ -344,7 +366,7 @@ public class FileController(
await using var transaction = await db.Database.BeginTransactionAsync();
try
{
var file = new CloudFile
var file = new SnCloudFile
{
Name = request.Name,
Size = request.Size,

View File

@@ -1,3 +1,4 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -19,6 +20,7 @@ public class FilePoolController(AppDatabase db, FileService fs) : ControllerBase
var pools = await db.Pools
.Where(p => p.PolicyConfig.PublicUsable || p.AccountId == accountId)
.Where(p => !p.IsHidden || p.AccountId == accountId)
.OrderBy(p => p.CreatedAt)
.ToListAsync();
pools = pools.Select(p =>
{
@@ -29,14 +31,14 @@ public class FilePoolController(AppDatabase db, FileService fs) : ControllerBase
return Ok(pools);
}
[Authorize]
[HttpDelete("{id:guid}/recycle")]
public async Task<ActionResult> DeleteFilePoolRecycledFiles(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var pool = await fs.GetPoolAsync(id);
if (pool is null) return NotFound();
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);
return Ok(new { Count = count });
}
}
}

View File

@@ -1,4 +1,5 @@
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
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="usage">Optional filter by usage context</param>
/// <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);

View File

@@ -3,173 +3,172 @@ using Grpc.Core;
using NodaTime;
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)
: Shared.Proto.FileReferenceService.FileReferenceServiceBase
public override async Task<Shared.Proto.CloudFileReference> CreateReference(CreateReferenceRequest request,
ServerCallContext context)
{
public override async Task<Shared.Proto.CloudFileReference> CreateReference(CreateReferenceRequest request,
ServerCallContext context)
{
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());
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 reference = await fileReferenceService.CreateReferenceAsync(
request.FileId,
request.Usage,
request.ResourceId,
expiredAt
);
return reference.ToProtoValue();
}
var reference = await fileReferenceService.CreateReferenceAsync(
request.FileId,
request.Usage,
request.ResourceId,
expiredAt
);
return reference.ToProtoValue();
}
public override async Task<CreateReferenceBatchResponse> CreateReferenceBatch(CreateReferenceBatchRequest request,
ServerCallContext context)
{
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());
public override async Task<CreateReferenceBatchResponse> CreateReferenceBatch(CreateReferenceBatchRequest request,
ServerCallContext context)
{
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.CreateReferencesAsync(
request.FilesId.ToList(),
request.Usage,
request.ResourceId,
expiredAt
);
var response = new CreateReferenceBatchResponse();
response.References.AddRange(references.Select(r => r.ToProtoValue()));
return response;
}
var references = await fileReferenceService.CreateReferencesAsync(
request.FilesId.ToList(),
request.Usage,
request.ResourceId,
expiredAt
);
var response = new CreateReferenceBatchResponse();
response.References.AddRange(references.Select(r => r.ToProtoValue()));
return response;
}
public override async Task<GetReferencesResponse> GetReferences(GetReferencesRequest request,
ServerCallContext context)
{
var references = await fileReferenceService.GetReferencesAsync(request.FileId);
var response = new GetReferencesResponse();
response.References.AddRange(references.Select(r => r.ToProtoValue()));
return response;
}
public override async Task<GetReferencesResponse> GetReferences(GetReferencesRequest request,
ServerCallContext context)
{
var references = await fileReferenceService.GetReferencesAsync(request.FileId);
var response = new GetReferencesResponse();
response.References.AddRange(references.Select(r => r.ToProtoValue()));
return response;
}
public override async Task<GetReferenceCountResponse> GetReferenceCount(GetReferenceCountRequest request,
ServerCallContext context)
{
var count = await fileReferenceService.GetReferenceCountAsync(request.FileId);
return new GetReferenceCountResponse { Count = count };
}
public override async Task<GetReferenceCountResponse> GetReferenceCount(GetReferenceCountRequest request,
ServerCallContext context)
{
var count = await fileReferenceService.GetReferenceCountAsync(request.FileId);
return new GetReferenceCountResponse { Count = count };
}
public override async Task<GetReferencesResponse> GetResourceReferences(GetResourceReferencesRequest request,
ServerCallContext context)
{
var references = await fileReferenceService.GetResourceReferencesAsync(request.ResourceId, request.Usage);
var response = new GetReferencesResponse();
response.References.AddRange(references.Select(r => r.ToProtoValue()));
return response;
}
public override async Task<GetReferencesResponse> GetResourceReferences(GetResourceReferencesRequest request,
ServerCallContext context)
{
var references = await fileReferenceService.GetResourceReferencesAsync(request.ResourceId, request.Usage);
var response = new GetReferencesResponse();
response.References.AddRange(references.Select(r => r.ToProtoValue()));
return response;
}
public override async Task<GetResourceFilesResponse> GetResourceFiles(GetResourceFilesRequest request,
ServerCallContext context)
{
var files = await fileReferenceService.GetResourceFilesAsync(request.ResourceId, request.Usage);
var response = new GetResourceFilesResponse();
response.Files.AddRange(files.Select(f => f.ToProtoValue()));
return response;
}
public override async Task<GetResourceFilesResponse> GetResourceFiles(GetResourceFilesRequest request,
ServerCallContext context)
{
var files = await fileReferenceService.GetResourceFilesAsync(request.ResourceId, request.Usage);
var response = new GetResourceFilesResponse();
response.Files.AddRange(files.Select(f => f.ToProtoValue()));
return response;
}
public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferences(
DeleteResourceReferencesRequest request, ServerCallContext context)
{
int deletedCount;
if (request.Usage is null)
deletedCount = await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId);
else
deletedCount =
await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId, request.Usage!);
return new DeleteResourceReferencesResponse { DeletedCount = deletedCount };
}
public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferences(
DeleteResourceReferencesRequest request, ServerCallContext context)
{
int deletedCount;
if (request.Usage is null)
deletedCount = await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId);
else
deletedCount =
await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId, request.Usage!);
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();
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 };
expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
}
else if (request.Duration != null)
{
expiredAt = SystemClock.Instance.GetCurrentInstant() +
Duration.FromTimeSpan(request.Duration.ToTimeSpan());
}
public override async Task<DeleteReferenceResponse> DeleteReference(DeleteReferenceRequest request,
ServerCallContext context)
var references = await fileReferenceService.UpdateResourceFilesAsync(
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));
return new DeleteReferenceResponse { Success = success };
expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
}
else if (request.Duration != null)
{
expiredAt = SystemClock.Instance.GetCurrentInstant() +
Duration.FromTimeSpan(request.Duration.ToTimeSpan());
}
public override async Task<UpdateResourceFilesResponse> UpdateResourceFiles(UpdateResourceFilesRequest request,
ServerCallContext context)
{
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 success =
await fileReferenceService.SetReferenceExpirationAsync(Guid.Parse(request.ReferenceId), expiredAt);
return new SetReferenceExpirationResponse { Success = success };
}
var references = await fileReferenceService.UpdateResourceFilesAsync(
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<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<SetReferenceExpirationResponse> SetReferenceExpiration(
SetReferenceExpirationRequest request, ServerCallContext context)
{
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 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 };
}
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 FFMpegCore;
using System.Security.Cryptography;
using DysonNetwork.Drive.Storage.Model;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Proto;
using Google.Protobuf.WellKnownTypes;
using Microsoft.EntityFrameworkCore;
using Minio;
using Minio.DataModel.Args;
using NATS.Client.Core;
using NetVips;
using NodaTime;
using tusdotnet.Stores;
using System.Linq.Expressions;
using DysonNetwork.Shared.Data;
using Microsoft.EntityFrameworkCore.Query;
using NATS.Net;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Drive.Storage;
public class FileService(
AppDatabase db,
IConfiguration configuration,
ILogger<FileService> logger,
IServiceScopeFactory scopeFactory,
ICacheService cache
ICacheService cache,
INatsConnection nats
)
{
private const string CacheKeyPrefix = "file:";
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15);
/// <summary>
/// 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)
public async Task<SnCloudFile?> GetFileAsync(string fileId)
{
var cacheKey = $"{CacheKeyPrefix}{fileId}";
var cachedFile = await cache.GetAsync<CloudFile>(cacheKey);
var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey);
if (cachedFile is not null)
return cachedFile;
@@ -56,16 +48,15 @@ public class FileService(
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>();
// Check cache first
foreach (var fileId in fileIds)
{
var cacheKey = $"{CacheKeyPrefix}{fileId}";
var cachedFile = await cache.GetAsync<CloudFile>(cacheKey);
var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey);
if (cachedFile != null)
cachedFiles[fileId] = cachedFile;
@@ -73,7 +64,6 @@ public class FileService(
uncachedIds.Add(fileId);
}
// Load uncached files from database
if (uncachedIds.Count > 0)
{
var dbFiles = await db.Files
@@ -81,7 +71,6 @@ public class FileService(
.Include(f => f.Pool)
.ToListAsync();
// Add to cache
foreach (var file in dbFiles)
{
var cacheKey = $"{CacheKeyPrefix}{file.Id}";
@@ -90,28 +79,19 @@ public class FileService(
}
}
// Preserve original order
return fileIds
.Select(f => cachedFiles.GetValueOrDefault(f))
.Where(f => f != null)
.Cast<CloudFile>()
.Cast<SnCloudFile>()
.ToList();
}
private const string TempFilePrefix = "dyn-cloudfile";
private static readonly string[] AnimatedImageTypes =
["image/gif", "image/apng", "image/avif"];
private static readonly string[] AnimatedImageExtensions =
[".gif", ".apng", ".avif"];
public async Task<CloudFile> ProcessNewFileAsync(
public async Task<SnCloudFile> ProcessNewFileAsync(
Account account,
string fileId,
string filePool,
string? fileBundleId,
Stream stream,
string filePath,
string fileName,
string? contentType,
string? encryptPassword,
@@ -143,57 +123,74 @@ public class FileService(
if (bundle?.ExpiredAt != null)
expiredAt = bundle.ExpiredAt.Value;
var ogFilePath = Path.GetFullPath(Path.Join(configuration.GetValue<string>("Tus:StorePath"), fileId));
var fileSize = stream.Length;
contentType ??= !fileName.Contains('.') ? "application/octet-stream" : MimeTypes.GetMimeType(fileName);
var managedTempPath = Path.Combine(Path.GetTempPath(), fileId);
File.Copy(filePath, managedTempPath, true);
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 (!pool.PolicyConfig.AllowEncryption)
throw new InvalidOperationException("Encryption is not allowed in this pool");
var encryptedPath = Path.Combine(Path.GetTempPath(), $"{fileId}.encrypted");
FileEncryptor.EncryptFile(ogFilePath, encryptedPath, encryptPassword);
File.Delete(ogFilePath); // Delete original unencrypted
File.Move(encryptedPath, ogFilePath); // Replace the original one with encrypted
contentType = "application/octet-stream";
FileEncryptor.EncryptFile(managedTempPath, encryptedPath, encryptPassword);
File.Delete(managedTempPath);
processingPath = encryptedPath;
file.IsEncrypted = true;
file.MimeType = "application/octet-stream";
file.Size = new FileInfo(processingPath).Length;
}
var hash = await HashFileAsync(ogFilePath);
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);
file.Hash = await HashFileAsync(processingPath);
db.Files.Add(file);
await db.SaveChangesAsync();
file.StorageId ??= file.Id;
// Offload optimization (image conversion, thumbnailing) and uploading to a background task
_ = Task.Run(() =>
ProcessAndUploadInBackgroundAsync(file.Id, filePool, file.StorageId, contentType, ogFilePath, stream));
var js = nats.CreateJetStreamContext();
await js.PublishAsync(
FileUploadedEvent.Type,
GrpcTypeHelper.ConvertObjectToByteString(new FileUploadedEventPayload(
file.Id,
pool.Id,
file.StorageId,
file.MimeType,
processingPath,
isTempFile)
).ToByteArray()
);
return file;
}
/// <summary>
/// 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)
private async Task ExtractMetadataAsync(SnCloudFile file, string filePath)
{
switch (file.MimeType?.Split('/')[0])
{
@@ -201,6 +198,7 @@ public class FileService(
try
{
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;
using var vipsImage = Image.NewFromStream(stream);
@@ -265,7 +263,6 @@ public class FileService(
["bit_rate"] = mediaInfo.Format.BitRate.ToString(CultureInfo.InvariantCulture),
["tags"] = mediaInfo.Format.Tags ?? new Dictionary<string, string>(),
["chapters"] = mediaInfo.Chapters,
// Add detailed stream information
["video_streams"] = mediaInfo.VideoStreams.Select(s => new
{
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)
{
var fileInfo = new FileInfo(filePath);
@@ -491,11 +328,11 @@ public class FileService(
}
var hash = MD5.HashData(buffer.AsSpan(0, bytesRead));
stream.Position = 0; // Reset stream position
stream.Position = 0;
return Convert.ToHexString(hash).ToLowerInvariant();
}
private async Task UploadFileToRemoteAsync(
public async Task UploadFileToRemoteAsync(
string storageId,
Guid targetRemote,
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);
if (existingFile == null)
@@ -574,11 +411,10 @@ public class FileService(
await db.Files.Where(f => f.Id == file.Id).ExecuteUpdateAsync(updatable.ToSetPropertyCalls());
await _PurgeCacheAsync(file.Id);
// Re-fetch the file to return the updated state
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);
await db.SaveChangesAsync();
@@ -587,24 +423,21 @@ public class FileService(
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 (!force)
{
// Check if any other file with the same storage ID is referenced
var sameOriginFiles = await db.Files
.Where(f => f.StorageId == file.StorageId && f.Id != file.Id)
.Select(f => f.Id)
.ToListAsync();
// Check if any of these files are referenced
if (sameOriginFiles.Count != 0)
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);
if (dest is null) throw new InvalidOperationException($"No remote storage configured for pool {file.PoolId}");
var client = CreateMinioClient(dest);
@@ -614,7 +447,7 @@ public class FileService(
);
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(
new RemoveObjectArgs().WithBucket(bucket).WithObject(objectId)
@@ -630,7 +463,6 @@ public class FileService(
}
catch
{
// Ignore errors when deleting compressed version
logger.LogWarning("Failed to delete compressed version of file {fileId}", file.Id);
}
}
@@ -645,25 +477,17 @@ public class FileService(
}
catch
{
// Ignore errors when deleting thumbnail
logger.LogWarning("Failed to delete thumbnail of file {fileId}", file.Id);
}
}
}
/// <summary>
/// The most efficent way to delete file data (stored files) in batch.
/// But this DO NOT check the storage id, so use with caution!
/// </summary>
/// <param name="files">Files to delete</param>
/// <exception cref="InvalidOperationException">Something went wrong</exception>
public async Task DeleteFileDataBatchAsync(List<CloudFile> files)
public async Task DeleteFileDataBatchAsync(List<SnCloudFile> files)
{
files = files.Where(f => f.PoolId.HasValue).ToList();
foreach (var fileGroup in files.GroupBy(f => f.PoolId!.Value))
{
// If any other file with the same storage ID is referenced, don't delete the actual file data
var dest = await GetRemoteStorageConfig(fileGroup.Key);
if (dest is null)
throw new InvalidOperationException($"No remote storage configured for pool {fileGroup.Key}");
@@ -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
.Where(e => e.Id == id)
@@ -733,31 +557,27 @@ public class FileService(
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)
{
var cacheKey = $"{CacheKeyPrefix}{fileId}";
await cache.RemoveAsync(cacheKey);
}
// Helper method to purge cache for multiple files
internal async Task _PurgeCacheRangeAsync(IEnumerable<string> fileIds)
{
var tasks = fileIds.Select(_PurgeCacheAsync);
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>();
// Check cache first
foreach (var reference in references)
{
var cacheKey = $"{CacheKeyPrefix}{reference.Id}";
var cachedFile = await cache.GetAsync<CloudFile>(cacheKey);
var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey);
if (cachedFile != null)
{
@@ -769,14 +589,12 @@ public class FileService(
}
}
// Load uncached files from database
if (uncachedIds.Count > 0)
{
var dbFiles = await db.Files
.Where(f => uncachedIds.Contains(f.Id))
.ToListAsync();
// Add to cache
foreach (var file in dbFiles)
{
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))
.Where(f => f != null)
.ToList();
.Where(f => f != null)];
}
/// <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)
{
return await db.FileReferences
@@ -804,11 +615,6 @@ public class FileService(
.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)
{
return await db.FileReferences
@@ -816,12 +622,8 @@ public class FileService(
.AnyAsync();
}
/// <summary>
/// Checks if an EXIF field should be ignored (e.g., GPS data).
/// </summary>
private static bool IsIgnoredField(string fieldName)
{
// Common GPS EXIF field names
var gpsFields = new[]
{
"gps-latitude", "gps-longitude", "gps-altitude", "gps-latitude-ref", "gps-longitude-ref",
@@ -882,7 +684,7 @@ public class FileService(
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");
@@ -904,10 +706,7 @@ public class FileService(
}
}
/// <summary>
/// A helper class to build an ExecuteUpdateAsync call for CloudFile.
/// </summary>
file class UpdatableCloudFile(CloudFile file)
file class UpdatableCloudFile(SnCloudFile file)
{
public string Name { get; set; } = file.Name;
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 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
.SetProperty(f => f.Name, Name)
.SetProperty(f => f.Description, Description)
.SetProperty(f => f.FileMeta, FileMeta)
.SetProperty(f => f.UserMeta, userMeta!)
.SetProperty(f => f.UserMeta, userMeta)
.SetProperty(f => f.IsMarkedRecycle, IsMarkedRecycle);
}
}

View File

@@ -1,4 +1,4 @@
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
@@ -48,7 +48,7 @@ namespace DysonNetwork.Drive.Storage
{
// Assuming CloudFileReferenceObject is a simple class/struct that holds an ID
// 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 response = new LoadFromReferenceResponse();
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 DysonNetwork.Drive.Billing;
using DysonNetwork.Drive.Storage.Model;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -23,72 +25,68 @@ public class FileUploadController(
: ControllerBase
{
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
[HttpPost("create")]
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)
{
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)
{
return Forbid();
return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
}
}
if (!Guid.TryParse(request.PoolId, out var poolGuid))
{
return BadRequest("Invalid file pool id");
}
request.PoolId ??= Guid.Parse(configuration["Storage:PreferredRemote"]!);
var pool = await fileService.GetPoolAsync(poolGuid);
var pool = await fileService.GetPoolAsync(request.PoolId.Value);
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 =
currentUser.PerkSubscription is null ? 0 :
PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier);
if (privilege < pool.PolicyConfig.RequirePrivilege)
{
return new ObjectResult(
$"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use this pool, you are tier {privilege}")
return new ObjectResult(ApiError.Unauthorized(
$"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use pool {pool.Name}, you are tier {privilege}",
forbidden: true))
{
StatusCode = 403
};
}
}
if (!string.IsNullOrEmpty(request.BundleId) && !Guid.TryParse(request.BundleId, out _))
{
return BadRequest("Invalid file bundle id");
}
var policy = pool.PolicyConfig;
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 (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 =>
@@ -104,15 +102,18 @@ public class FileUploadController(
if (!foundMatch)
{
return new ObjectResult($"Content type {request.ContentType} is not allowed by the pool's policy")
{ StatusCode = 403 };
return new ObjectResult(
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)
{
return new ObjectResult(
$"File size {request.FileSize} is larger than the pool's maximum file size {policy.MaxFileSize}")
return new ObjectResult(ApiError.Unauthorized(
$"File size {request.FileSize} is larger than the pool's maximum file size {policy.MaxFileSize}",
true))
{
StatusCode = 403
};
@@ -125,8 +126,10 @@ public class FileUploadController(
);
if (!ok)
{
return new ObjectResult($"File size {billableUnit} MiB is exceeded the user's quota {quota} MiB")
{ StatusCode = 403 };
return new ObjectResult(
ApiError.Unauthorized($"File size {billableUnit} MiB is exceeded the user's quota {quota} MiB",
true))
{ StatusCode = 403 };
}
if (!Directory.Exists(_tempPath))
@@ -160,7 +163,7 @@ public class FileUploadController(
ContentType = request.ContentType,
ChunkSize = chunkSize,
ChunksCount = chunksCount,
PoolId = request.PoolId,
PoolId = request.PoolId.Value,
BundleId = request.BundleId,
EncryptPassword = request.EncryptPassword,
ExpiredAt = request.ExpiredAt,
@@ -178,15 +181,22 @@ public class FileUploadController(
});
}
public class UploadChunkRequest
{
[Required]
public IFormFile Chunk { get; set; } = null!;
}
[HttpPost("chunk/{taskId}/{chunkIndex}")]
[RequestSizeLimit(DefaultChunkSize + 1024 * 1024)] // 6MB to be safe
[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);
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");
@@ -202,19 +212,20 @@ public class FileUploadController(
var taskPath = Path.Combine(_tempPath, taskId);
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");
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));
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");
@@ -229,7 +240,9 @@ public class FileUploadController(
mergedStream.Close();
System.IO.File.Delete(mergedFilePath);
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);
@@ -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();
await using (var fileStream =
new FileStream(mergedFilePath, FileMode.Open, FileAccess.Read, FileShare.Read))
{
var cloudFile = await fileService.ProcessNewFileAsync(
currentUser,
fileId,
task.PoolId,
task.BundleId,
fileStream,
task.FileName,
task.ContentType,
task.EncryptPassword,
task.ExpiredAt
);
var cloudFile = await fileService.ProcessNewFileAsync(
currentUser,
fileId,
task.PoolId.ToString(),
task.BundleId?.ToString(),
mergedFilePath,
task.FileName,
task.ContentType,
task.EncryptPassword,
task.ExpiredAt
);
// Clean up
Directory.Delete(taskPath, true);
System.IO.File.Delete(mergedFilePath);
// Clean up
Directory.Delete(taskPath, true);
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;
namespace DysonNetwork.Drive.Storage.Model
@@ -9,8 +9,8 @@ namespace DysonNetwork.Drive.Storage.Model
public string FileName { get; set; } = null!;
public long FileSize { get; set; }
public string ContentType { get; set; } = null!;
public string PoolId { get; set; } = null!;
public string? BundleId { get; set; }
public Guid? PoolId { get; set; } = null!;
public Guid? BundleId { get; set; }
public string? EncryptPassword { get; set; }
public Instant? ExpiredAt { get; set; }
public long? ChunkSize { get; set; }
@@ -19,7 +19,7 @@ namespace DysonNetwork.Drive.Storage.Model
public class CreateUploadTaskResponse
{
public bool FileExists { get; set; }
public CloudFile? File { get; set; }
public SnCloudFile? File { get; set; }
public string? TaskId { get; set; }
public long? ChunkSize { get; set; }
public int? ChunksCount { get; set; }
@@ -33,8 +33,8 @@ namespace DysonNetwork.Drive.Storage.Model
public string ContentType { get; set; } = null!;
public long ChunkSize { get; set; }
public int ChunksCount { get; set; }
public string PoolId { get; set; } = null!;
public string? BundleId { get; set; }
public Guid PoolId { get; set; }
public Guid? BundleId { get; set; }
public string? EncryptPassword { get; set; }
public Instant? ExpiredAt { get; set; }
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_size": "long (in bytes)",
"content_type": "string (e.g., 'image/jpeg')",
"pool_id": "string (GUID)",
"pool_id": "string (GUID, optional)",
"bundle_id": "string (GUID, optional)",
"encrypt_password": "string (optional)",
"expired_at": "string (ISO 8601 format, optional)",

View File

@@ -113,7 +113,7 @@ public abstract class TusService
: "uploaded_file";
var contentType = metadata.TryGetValue("content-type", out var ct) ? ct.GetString(Encoding.UTF8) : null;
var fileStream = await file.GetContentAsync(eventContext.CancellationToken);
var filePath = Path.Combine(configuration.GetValue<string>("Tus:StorePath")!, file.Id);
var filePool = httpContext.Request.Headers["X-FilePool"].FirstOrDefault();
var bundleId = eventContext.HttpContext.Request.Headers["X-FileBundle"].FirstOrDefault();
@@ -135,7 +135,7 @@ public abstract class TusService
file.Id,
filePool!,
bundleId,
fileStream,
filePath,
fileName,
contentType,
encryptPassword,
@@ -155,11 +155,6 @@ public abstract class TusService
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 =>
{

View File

@@ -27,15 +27,6 @@
"PublicKeyPath": "Keys/PublicKey.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": {
"StorePath": "Uploads"
},

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.4.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,171 @@
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" };
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
{
"pass" => "/id",
_ => $"/{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()
{
Active = new()
{
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.UseRateLimiter();
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

@@ -2,8 +2,9 @@ using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Error;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
@@ -23,9 +24,9 @@ public class AccountController(
) : ControllerBase
{
[HttpGet("{name}")]
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
[ProducesResponseType<SnAccount>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Account?>> GetByName(string name)
public async Task<ActionResult<SnAccount?>> GetByName(string name)
{
var account = await db.Accounts
.Include(e => e.Badges)
@@ -42,9 +43,9 @@ public class AccountController(
}
[HttpGet("{name}/badges")]
[ProducesResponseType<List<AccountBadge>>(StatusCodes.Status200OK)]
[ProducesResponseType<List<SnAccountBadge>>(StatusCodes.Status200OK)]
[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
.Include(e => e.Badges)
@@ -103,9 +104,9 @@ public class AccountController(
}
[HttpPost]
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
[ProducesResponseType<SnAccount>(StatusCodes.Status200OK)]
[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))
return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
@@ -194,11 +195,12 @@ public class AccountController(
public bool IsAutomated { get; set; } = false;
[MaxLength(1024)] public string? Label { get; set; }
[MaxLength(4096)] public string? AppIdentifier { get; set; }
public Dictionary<string, object>? Meta { get; set; }
public Instant? ClearedAt { get; set; }
}
[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);
if (account is null)
@@ -253,7 +255,7 @@ public class AccountController(
}
[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))
return [];

View File

@@ -1,16 +1,15 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Error;
using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using AuthService = DysonNetwork.Pass.Auth.AuthService;
using AuthSession = DysonNetwork.Pass.Auth.AuthSession;
using SnAuthSession = DysonNetwork.Shared.Models.SnAuthSession;
namespace DysonNetwork.Pass.Account;
@@ -29,11 +28,11 @@ public class AccountCurrentController(
) : ControllerBase
{
[HttpGet]
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
[ProducesResponseType<SnAccount>(StatusCodes.Status200OK)]
[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 account = await db.Accounts
@@ -56,9 +55,9 @@ public class AccountCurrentController(
}
[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);
@@ -81,6 +80,7 @@ public class AccountCurrentController(
[MaxLength(1024)] public string? TimeZone { get; set; }
[MaxLength(1024)] public string? Location { get; set; }
[MaxLength(4096)] public string? Bio { get; set; }
public Shared.Models.UsernameColor? UsernameColor { get; set; }
public Instant? Birthday { get; set; }
public List<ProfileLink>? Links { get; set; }
@@ -89,9 +89,9 @@ public class AccountCurrentController(
}
[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 profile = await db.AccountProfiles
@@ -116,6 +116,7 @@ public class AccountCurrentController(
if (request.Location is not null) profile.Location = request.Location;
if (request.TimeZone is not null) profile.TimeZone = request.TimeZone;
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)
{
@@ -132,7 +133,7 @@ public class AccountCurrentController(
Usage = "profile.picture"
}
);
profile.Picture = CloudFileReferenceObject.FromProtoValue(file);
profile.Picture = SnCloudFileReferenceObject.FromProtoValue(file);
}
if (request.BackgroundId is not null)
@@ -150,7 +151,7 @@ public class AccountCurrentController(
Usage = "profile.background"
}
);
profile.Background = CloudFileReferenceObject.FromProtoValue(file);
profile.Background = SnCloudFileReferenceObject.FromProtoValue(file);
}
db.Update(profile);
@@ -164,7 +165,7 @@ public class AccountCurrentController(
[HttpDelete]
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
{
@@ -185,18 +186,18 @@ public class AccountCurrentController(
}
[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);
return Ok(status);
}
[HttpPatch("statuses")]
[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 })
return BadRequest("Automated status cannot be updated.");
@@ -216,6 +217,7 @@ public class AccountCurrentController(
status.IsAutomated = request.IsAutomated;
status.Label = request.Label;
status.AppIdentifier = request.AppIdentifier;
status.Meta = request.Meta;
status.ClearedAt = request.ClearedAt;
db.Update(status);
@@ -227,9 +229,9 @@ public class AccountCurrentController(
[HttpPost("statuses")]
[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 })
{
@@ -245,6 +247,7 @@ public class AccountCurrentController(
existingStatus.Attitude = request.Attitude;
existingStatus.IsInvisible = request.IsInvisible;
existingStatus.IsNotDisturb = request.IsNotDisturb;
existingStatus.Meta = request.Meta;
existingStatus.Label = request.Label;
db.Update(existingStatus);
await db.SaveChangesAsync();
@@ -260,7 +263,7 @@ public class AccountCurrentController(
return Ok(existingStatus); // Do not override manually set status with automated ones
}
var status = new Status
var status = new SnAccountStatus
{
AccountId = currentUser.Id,
Attitude = request.Attitude,
@@ -268,6 +271,7 @@ public class AccountCurrentController(
IsNotDisturb = request.IsNotDisturb,
IsAutomated = request.IsAutomated,
Label = request.Label,
Meta = request.Meta,
AppIdentifier = request.AppIdentifier,
ClearedAt = request.ClearedAt
};
@@ -278,7 +282,7 @@ public class AccountCurrentController(
[HttpDelete("statuses")]
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 queryable = db.AccountStatuses
@@ -287,7 +291,7 @@ public class AccountCurrentController(
.OrderByDescending(s => s.CreatedAt)
.AsQueryable();
if (string.IsNullOrWhiteSpace(app))
if (!string.IsNullOrWhiteSpace(app))
queryable = queryable.Where(s => s.IsAutomated && s.AppIdentifier == app);
var status = await queryable
@@ -299,9 +303,9 @@ public class AccountCurrentController(
}
[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 now = SystemClock.Instance.GetCurrentInstant();
@@ -321,12 +325,12 @@ public class AccountCurrentController(
}
[HttpPost("check-in")]
public async Task<ActionResult<CheckInResult>> DoCheckIn(
public async Task<ActionResult<SnCheckInResult>> DoCheckIn(
[FromBody] string? captchaToken,
[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)
{
@@ -397,7 +401,7 @@ public class AccountCurrentController(
public async Task<ActionResult<List<DailyEventResponse>>> GetEventCalendar([FromQuery] int? month,
[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;
month ??= currentDate.Month;
@@ -426,7 +430,7 @@ public class AccountCurrentController(
[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
.Where(log => log.AccountId == currentUser.Id)
@@ -444,9 +448,9 @@ public class AccountCurrentController(
}
[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
.Include(f => f.Account)
@@ -458,15 +462,15 @@ public class AccountCurrentController(
public class AuthFactorRequest
{
public AccountAuthFactorType Type { get; set; }
public Shared.Models.AccountAuthFactorType Type { get; set; }
public string? Secret { get; set; }
}
[HttpPost("factors")]
[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))
return BadRequest(new ApiError
{
@@ -482,9 +486,9 @@ public class AccountCurrentController(
[HttpPost("factors/{id:guid}/enable")]
[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
.Where(f => f.AccountId == currentUser.Id && f.Id == id)
@@ -511,9 +515,9 @@ public class AccountCurrentController(
[HttpPost("factors/{id:guid}/disable")]
[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
.Where(f => f.AccountId == currentUser.Id && f.Id == id)
@@ -533,9 +537,9 @@ public class AccountCurrentController(
[HttpDelete("factors/{id:guid}")]
[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
.Where(f => f.AccountId == currentUser.Id && f.Id == id)
@@ -555,10 +559,10 @@ public class AccountCurrentController(
[HttpGet("devices")]
[Authorize]
public async Task<ActionResult<List<AuthClientWithChallenge>>> GetDevices()
public async Task<ActionResult<List<SnAuthClientWithChallenge>>> GetDevices()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
@@ -566,7 +570,7 @@ public class AccountCurrentController(
.Where(device => device.AccountId == currentUser.Id)
.ToListAsync();
var challengeDevices = devices.Select(AuthClientWithChallenge.FromClient).ToList();
var challengeDevices = devices.Select(SnAuthClientWithChallenge.FromClient).ToList();
var deviceIds = challengeDevices.Select(x => x.Id).ToList();
var authChallenges = await db.AuthChallenges
@@ -582,13 +586,13 @@ public class AccountCurrentController(
[HttpGet("sessions")]
[Authorize]
public async Task<ActionResult<List<AuthSession>>> GetSessions(
public async Task<ActionResult<List<SnAuthSession>>> GetSessions(
[FromQuery] int take = 20,
[FromQuery] int offset = 0
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
var query = db.AuthSessions
.Include(session => session.Account)
@@ -610,9 +614,9 @@ public class AccountCurrentController(
[HttpDelete("sessions/{id:guid}")]
[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
{
@@ -627,9 +631,9 @@ public class AccountCurrentController(
[HttpDelete("devices/{deviceId}")]
[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
{
@@ -644,10 +648,10 @@ public class AccountCurrentController(
[HttpDelete("sessions/current")]
[Authorize]
public async Task<ActionResult<AuthSession>> DeleteCurrentSession()
public async Task<ActionResult<SnAuthSession>> DeleteCurrentSession()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
try
{
@@ -662,9 +666,9 @@ public class AccountCurrentController(
[HttpPatch("devices/{deviceId}/label")]
[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
{
@@ -679,10 +683,10 @@ public class AccountCurrentController(
[HttpPatch("devices/current/label")]
[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 ||
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
var device = await db.AuthClients.FirstOrDefaultAsync(d => d.Id == currentSession.Challenge.ClientId);
if (device is null) return NotFound();
@@ -700,9 +704,9 @@ public class AccountCurrentController(
[HttpGet("contacts")]
[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
.Where(c => c.AccountId == currentUser.Id)
@@ -713,15 +717,15 @@ public class AccountCurrentController(
public class AccountContactRequest
{
[Required] public AccountContactType Type { get; set; }
[Required] public Shared.Models.AccountContactType Type { get; set; }
[Required] public string Content { get; set; } = null!;
}
[HttpPost("contacts")]
[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
{
@@ -736,9 +740,9 @@ public class AccountCurrentController(
[HttpPost("contacts/{id:guid}/verify")]
[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
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
@@ -758,9 +762,9 @@ public class AccountCurrentController(
[HttpPost("contacts/{id:guid}/primary")]
[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
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
@@ -780,9 +784,9 @@ public class AccountCurrentController(
[HttpPost("contacts/{id:guid}/public")]
[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
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
@@ -802,9 +806,9 @@ public class AccountCurrentController(
[HttpDelete("contacts/{id:guid}/public")]
[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
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
@@ -824,9 +828,9 @@ public class AccountCurrentController(
[HttpDelete("contacts/{id:guid}")]
[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
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
@@ -845,11 +849,11 @@ public class AccountCurrentController(
}
[HttpGet("badges")]
[ProducesResponseType<List<AccountBadge>>(StatusCodes.Status200OK)]
[ProducesResponseType<List<SnAccountBadge>>(StatusCodes.Status200OK)]
[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
.Where(b => b.AccountId == currentUser.Id)
@@ -859,9 +863,9 @@ public class AccountCurrentController(
[HttpPost("badges/{id:guid}/active")]
[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
{
@@ -876,12 +880,12 @@ public class AccountCurrentController(
[HttpGet("leveling")]
[Authorize]
public async Task<ActionResult<ExperienceRecord>> GetLevelingHistory(
public async Task<ActionResult<SnExperienceRecord>> GetLevelingHistory(
[FromQuery] int take = 20,
[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
.Where(r => r.AccountId == currentUser.Id)
@@ -901,7 +905,7 @@ public class AccountCurrentController(
[HttpGet("credits")]
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);
return Ok(credit);
@@ -913,7 +917,7 @@ public class AccountCurrentController(
[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
.Where(r => r.AccountId == currentUser.Id)
@@ -929,4 +933,4 @@ public class AccountCurrentController(
.ToListAsync();
return Ok(records);
}
}
}

View File

@@ -1,9 +1,13 @@
using System.Globalization;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Stream;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using NATS.Client.Core;
using NATS.Net;
using NodaTime;
using NodaTime.Extensions;
@@ -16,7 +20,8 @@ public class AccountEventService(
IStringLocalizer<Localization.AccountEventResource> localizer,
RingService.RingServiceClient pusher,
SubscriptionService subscriptions,
Pass.Leveling.ExperienceService experienceService
Pass.Leveling.ExperienceService experienceService,
INatsConnection nats
)
{
private static readonly Random Random = new();
@@ -36,10 +41,23 @@ public class AccountEventService(
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 cachedStatus = await cache.GetAsync<Status>(cacheKey);
var cachedStatus = await cache.GetAsync<SnAccountStatus>(cacheKey);
if (cachedStatus is not null)
{
cachedStatus!.IsOnline = !cachedStatus.IsInvisible && await GetAccountIsConnected(userId);
@@ -63,9 +81,9 @@ public class AccountEventService(
if (isOnline)
{
return new Status
return new SnAccountStatus
{
Attitude = StatusAttitude.Neutral,
Attitude = Shared.Models.StatusAttitude.Neutral,
IsOnline = true,
IsCustomized = false,
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,
IsCustomized = false,
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>();
foreach (var userId in userIds)
{
var cacheKey = $"{StatusCacheKey}{userId}";
var cachedStatus = await cache.GetAsync<Status>(cacheKey);
var cachedStatus = await cache.GetAsync<SnAccountStatus>(cacheKey);
if (cachedStatus != null)
{
cachedStatus.IsOnline = !cachedStatus.IsInvisible && await GetAccountIsConnected(userId);
@@ -131,9 +149,9 @@ public class AccountEventService(
foreach (var userId in usersWithoutStatus)
{
var isOnline = await GetAccountIsConnected(userId);
var defaultStatus = new Status
var defaultStatus = new SnAccountStatus
{
Attitude = StatusAttitude.Neutral,
Attitude = Shared.Models.StatusAttitude.Neutral,
IsOnline = isOnline,
IsCustomized = false,
Label = isOnline ? "Online" : "Offline",
@@ -147,7 +165,7 @@ public class AccountEventService(
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();
await db.AccountStatuses
@@ -157,22 +175,25 @@ public class AccountEventService(
db.AccountStatuses.Add(status);
await db.SaveChangesAsync();
await BroadcastStatusUpdate(status);
return status;
}
public async Task ClearStatus(Account user, Status status)
public async Task ClearStatus(SnAccount user, SnAccountStatus status)
{
status.ClearedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(status);
await db.SaveChangesAsync();
PurgeStatusCache(user.Id);
await BroadcastStatusUpdate(status);
}
private const int FortuneTipCount = 14; // This will be the max index for each type (positive/negative)
private const string CaptchaCacheKey = "checkin:captcha:";
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);
if (perkSubscription is not null) return false;
@@ -187,7 +208,7 @@ public class AccountEventService(
return result;
}
public async Task<bool> CheckInDailyIsAvailable(Account user)
public async Task<bool> CheckInDailyIsAvailable(SnAccount user)
{
var now = SystemClock.Instance.GetCurrentInstant();
var lastCheckIn = await db.AccountCheckInResults
@@ -204,7 +225,7 @@ public class AccountEventService(
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 backdatedStart = backdated.ToDateTimeUtc().Date.ToInstant();
@@ -252,7 +273,7 @@ public class AccountEventService(
public 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}";
@@ -270,9 +291,7 @@ public class AccountEventService(
// Now try to acquire the lock properly
await using var lockObj =
await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5));
if (lockObj is null) throw new InvalidOperationException("Check-in was in progress.");
await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5)) ?? throw new InvalidOperationException("Check-in was in progress.");
var cultureInfo = new CultureInfo(user.Language, false);
CultureInfo.CurrentCulture = cultureInfo;
CultureInfo.CurrentUICulture = cultureInfo;
@@ -282,9 +301,10 @@ public class AccountEventService(
.OrderBy(_ => Random.Next())
.Take(2)
.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
}).ToList();
@@ -294,16 +314,29 @@ public class AccountEventService(
.OrderBy(_ => Random.Next())
.Take(2)
.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
}));
var result = new CheckInResult
// The 5 is specialized, keep it alone.
var checkInLevel = (CheckInResultLevel)Random.Next(Enum.GetValues<CheckInResultLevel>().Length - 1);
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,
Level = (CheckInResultLevel)Random.Next(Enum.GetValues<CheckInResultLevel>().Length),
Level = checkInLevel,
AccountId = user.Id,
RewardExperience = 100,
RewardPoints = backdated.HasValue ? null : 10,
@@ -311,7 +344,6 @@ public class AccountEventService(
CreatedAt = backdated ?? SystemClock.Instance.GetCurrentInstant(),
};
var now = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
try
{
if (result.RewardPoints.HasValue)
@@ -342,7 +374,7 @@ public class AccountEventService(
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)
{
if (year == 0)
@@ -356,7 +388,7 @@ public class AccountEventService(
.AsNoTracking()
.TagWith("eventcal:statuses")
.Where(x => x.AccountId == user.Id && x.CreatedAt >= startOfMonth && x.CreatedAt < endOfMonth)
.Select(x => new Status
.Select(x => new SnAccountStatus
{
Id = x.Id,
Attitude = x.Attitude,
@@ -394,8 +426,8 @@ public class AccountEventService(
{
Date = date,
CheckInResult = checkInByDate.GetValueOrDefault(utcDate),
Statuses = statusesByDate.GetValueOrDefault(utcDate, new List<Status>())
Statuses = statusesByDate.GetValueOrDefault(utcDate, new List<SnAccountStatus>())
};
}).ToList();
}
}
}

View File

@@ -1,19 +1,14 @@
using System.Globalization;
using System.Text.Json;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Auth.OpenId;
using DysonNetwork.Pass.Localization;
using DysonNetwork.Pass.Mailer;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Stream;
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using NATS.Client.Core;
using NATS.Client.JetStream;
using NATS.Net;
using NodaTime;
using OtpNet;
@@ -36,7 +31,7 @@ public class AccountService(
INatsConnection nats
)
{
public static void SetCultureInfo(Account account)
public static void SetCultureInfo(SnAccount account)
{
SetCultureInfo(account.Language);
}
@@ -50,12 +45,12 @@ public class AccountService(
public const string AccountCachePrefix = "account:";
public async Task PurgeAccountCache(Account account)
public async Task PurgeAccountCache(SnAccount account)
{
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();
if (account is not null) return account;
@@ -67,7 +62,7 @@ public class AccountService(
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
.Where(c => c.ProvidedIdentifier == identifier && c.Provider == provider)
@@ -84,7 +79,7 @@ public class AccountService(
return profile?.Level;
}
public async Task<Account> CreateAccount(
public async Task<SnAccount> CreateAccount(
string name,
string nick,
string email,
@@ -100,39 +95,39 @@ public class AccountService(
throw new InvalidOperationException("Account name has already been taken.");
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();
if (dupeEmailCount > 0)
throw new InvalidOperationException("Account email has already been used.");
var account = new Account
var account = new SnAccount
{
Name = name,
Nick = nick,
Language = language,
Region = region,
Contacts = new List<AccountContact>
{
Contacts =
[
new()
{
Type = AccountContactType.Email,
Type = Shared.Models.AccountContactType.Email,
Content = email,
VerifiedAt = isEmailVerified ? SystemClock.Instance.GetCurrentInstant() : null,
IsPrimary = true
}
},
],
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,
EnabledAt = SystemClock.Instance.GetCurrentInstant()
}.HashSecret()
}
: [],
Profile = new AccountProfile()
Profile = new SnAccountProfile()
};
if (isActivated)
@@ -141,7 +136,7 @@ public class AccountService(
var defaultGroup = await db.PermissionGroups.FirstOrDefaultAsync(g => g.Key == "default");
if (defaultGroup is not null)
{
db.PermissionGroupMembers.Add(new PermissionGroupMember
db.PermissionGroupMembers.Add(new SnPermissionGroupMember
{
Actor = $"user:{account.Id}",
Group = defaultGroup
@@ -167,7 +162,7 @@ public class AccountService(
return account;
}
public async Task<Account> CreateAccount(OidcUserInfo userInfo)
public async Task<SnAccount> CreateAccount(OidcUserInfo userInfo)
{
if (string.IsNullOrEmpty(userInfo.Email))
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)
{
var dupeAutomateCount = await db.Accounts.Where(a => a.AutomatedId == automatedId).CountAsync();
@@ -217,7 +212,7 @@ public class AccountService(
Usage = "profile.picture"
}
);
account.Profile.Picture = CloudFileReferenceObject.FromProtoValue(file);
account.Profile.Picture = SnCloudFileReferenceObject.FromProtoValue(file);
}
if (!string.IsNullOrEmpty(backgroundId))
@@ -231,7 +226,7 @@ public class AccountService(
Usage = "profile.background"
}
);
account.Profile.Background = CloudFileReferenceObject.FromProtoValue(file);
account.Profile.Background = SnCloudFileReferenceObject.FromProtoValue(file);
}
db.Accounts.Add(account);
@@ -240,12 +235,12 @@ public class AccountService(
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);
}
public async Task RequestAccountDeletion(Account account)
public async Task RequestAccountDeletion(SnAccount account)
{
var spell = await spells.CreateMagicSpell(
account,
@@ -257,7 +252,7 @@ public class AccountService(
await spells.NotifyMagicSpell(spell);
}
public async Task RequestPasswordReset(Account account)
public async Task RequestPasswordReset(SnAccount account)
{
var spell = await spells.CreateMagicSpell(
account,
@@ -269,7 +264,7 @@ public class AccountService(
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
.Where(x => x.AccountId == account.Id && x.Type == type)
@@ -277,45 +272,45 @@ public class AccountService(
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)
{
case AccountAuthFactorType.Password:
case Shared.Models.AccountAuthFactorType.Password:
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,
AccountId = account.Id,
Secret = secret,
EnabledAt = SystemClock.Instance.GetCurrentInstant(),
}.HashSecret();
break;
case AccountAuthFactorType.EmailCode:
factor = new AccountAuthFactor
case Shared.Models.AccountAuthFactorType.EmailCode:
factor = new SnAccountAuthFactor
{
Type = AccountAuthFactorType.EmailCode,
Type = Shared.Models.AccountAuthFactorType.EmailCode,
Trustworthy = 2,
EnabledAt = SystemClock.Instance.GetCurrentInstant(),
};
break;
case AccountAuthFactorType.InAppCode:
factor = new AccountAuthFactor
case Shared.Models.AccountAuthFactorType.InAppCode:
factor = new SnAccountAuthFactor
{
Type = AccountAuthFactorType.InAppCode,
Type = Shared.Models.AccountAuthFactorType.InAppCode,
Trustworthy = 1,
EnabledAt = SystemClock.Instance.GetCurrentInstant()
};
break;
case AccountAuthFactorType.TimedCode:
case Shared.Models.AccountAuthFactorType.TimedCode:
var skOtp = KeyGeneration.GenerateRandomKey(20);
var skOtp32 = Base32Encoding.ToString(skOtp);
factor = new AccountAuthFactor
factor = new SnAccountAuthFactor
{
Secret = skOtp32,
Type = AccountAuthFactorType.TimedCode,
Type = Shared.Models.AccountAuthFactorType.TimedCode,
Trustworthy = 2,
EnabledAt = null, // It needs to be tired once to enable
CreatedResponse = new Dictionary<string, object>
@@ -329,13 +324,13 @@ public class AccountService(
}
};
break;
case AccountAuthFactorType.PinCode:
case Shared.Models.AccountAuthFactorType.PinCode:
if (string.IsNullOrWhiteSpace(secret)) throw new ArgumentNullException(nameof(secret));
if (!secret.All(char.IsDigit) || secret.Length != 6)
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
Secret = secret,
EnabledAt = SystemClock.Instance.GetCurrentInstant(),
@@ -352,10 +347,10 @@ public class AccountService(
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.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))
throw new InvalidOperationException(
@@ -370,7 +365,7 @@ public class AccountService(
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.");
@@ -388,7 +383,7 @@ public class AccountService(
return factor;
}
public async Task DeleteAuthFactor(AccountAuthFactor factor)
public async Task DeleteAuthFactor(SnAccountAuthFactor factor)
{
var count = await db.AccountAuthFactors
.Where(f => f.AccountId == factor.AccountId)
@@ -406,13 +401,13 @@ public class AccountService(
/// </summary>
/// <param name="account">The owner of the auth factor</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");
switch (factor.Type)
{
case AccountAuthFactorType.InAppCode:
case Shared.Models.AccountAuthFactorType.InAppCode:
if (await _GetFactorCode(factor) is not null)
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));
break;
case AccountAuthFactorType.EmailCode:
case Shared.Models.AccountAuthFactorType.EmailCode:
if (await _GetFactorCode(factor) is not null)
throw new InvalidOperationException("A factor code has been sent and in active duration.");
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.IsPrimary)
.Where(c => c.AccountId == account.Id)
@@ -465,27 +460,27 @@ public class AccountService(
await _SetFactorCode(factor, code, TimeSpan.FromMinutes(30));
break;
case AccountAuthFactorType.Password:
case AccountAuthFactorType.TimedCode:
case Shared.Models.AccountAuthFactorType.Password:
case Shared.Models.AccountAuthFactorType.TimedCode:
default:
// No need to send, such as password etc...
return;
}
}
public async Task<bool> VerifyFactorCode(AccountAuthFactor factor, string code)
public async Task<bool> VerifyFactorCode(SnAccountAuthFactor factor, string code)
{
switch (factor.Type)
{
case AccountAuthFactorType.EmailCode:
case AccountAuthFactorType.InAppCode:
case Shared.Models.AccountAuthFactorType.EmailCode:
case Shared.Models.AccountAuthFactorType.InAppCode:
var correctCode = await _GetFactorCode(factor);
var isCorrect = correctCode is not null &&
string.Equals(correctCode, code, StringComparison.OrdinalIgnoreCase);
await cache.RemoveAsync($"{AuthFactorCachePrefix}{factor.Id}:code");
return isCorrect;
case AccountAuthFactorType.Password:
case AccountAuthFactorType.TimedCode:
case Shared.Models.AccountAuthFactorType.Password:
case Shared.Models.AccountAuthFactorType.TimedCode:
default:
return factor.VerifyPassword(code);
}
@@ -493,7 +488,7 @@ public class AccountService(
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(
$"{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?>(
$"{AuthFactorCachePrefix}{factor.Id}:code"
@@ -516,7 +511,7 @@ public class AccountService(
.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
);
@@ -529,7 +524,7 @@ public class AccountService(
return device;
}
public async Task DeleteSession(Account account, Guid sessionId)
public async Task DeleteSession(SnAccount account, Guid sessionId)
{
var session = await db.AuthSessions
.Include(s => s.Challenge)
@@ -555,7 +550,7 @@ public class AccountService(
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
);
@@ -585,7 +580,7 @@ public class AccountService(
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
.Where(x => x.AccountId == account.Id && x.Type == type && x.Content == content)
@@ -593,7 +588,7 @@ public class AccountService(
if (isExists)
throw new InvalidOperationException("Contact method already exists.");
var contact = new AccountContact
var contact = new SnAccountContact
{
Type = type,
Content = content,
@@ -606,7 +601,7 @@ public class AccountService(
return contact;
}
public async Task VerifyContactMethod(Account account, AccountContact contact)
public async Task VerifyContactMethod(SnAccount account, SnAccountContact contact)
{
var spell = await spells.CreateMagicSpell(
account,
@@ -618,7 +613,7 @@ public class AccountService(
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)
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;
db.AccountContacts.Update(contact);
@@ -655,7 +650,7 @@ public class AccountService(
return contact;
}
public async Task DeleteContactMethod(Account account, AccountContact contact)
public async Task DeleteContactMethod(SnAccount account, SnAccountContact contact)
{
if (contact.AccountId != account.Id)
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.
/// Shouldn't be exposed to normal user and the user itself.
/// </summary>
public async Task<AccountBadge> GrantBadge(Account account, AccountBadge badge)
public async Task<SnAccountBadge> GrantBadge(SnAccount account, SnAccountBadge badge)
{
badge.AccountId = account.Id;
db.Badges.Add(badge);
@@ -682,14 +677,12 @@ public class AccountService(
/// This method will revoke a badge from the account.
/// Shouldn't be exposed to normal user and the user itself.
/// </summary>
public async Task RevokeBadge(Account account, Guid badgeId)
public async Task RevokeBadge(SnAccount account, Guid badgeId)
{
var badge = await db.Badges
.Where(b => b.AccountId == account.Id && b.Id == badgeId)
.OrderByDescending(b => b.CreatedAt)
.FirstOrDefaultAsync();
if (badge is null) throw new InvalidOperationException("Badge was not found.");
.FirstOrDefaultAsync() ?? throw new InvalidOperationException("Badge was not found.");
var profile = await db.AccountProfiles
.Where(p => p.AccountId == account.Id)
.FirstOrDefaultAsync();
@@ -700,7 +693,7 @@ public class AccountService(
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();
@@ -734,7 +727,7 @@ public class AccountService(
}
}
public async Task DeleteAccount(Account account)
public async Task DeleteAccount(SnAccount account)
{
await db.AuthSessions
.Where(s => s.AccountId == account.Id)

View File

@@ -160,6 +160,26 @@ public class AccountServiceGrpc(
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,
ServerCallContext context)
{
@@ -236,7 +256,7 @@ public class AccountServiceGrpc(
var relationship = await relationships.GetRelationship(
Guid.Parse(request.AccountId),
Guid.Parse(request.RelatedId),
status: (RelationshipStatus?)request.Status
status: (Shared.Models.RelationshipStatus?)request.Status
);
return new GetRelationshipResponse
{
@@ -256,7 +276,7 @@ public class AccountServiceGrpc(
hasRelationship = await relationships.HasRelationshipWithStatus(
Guid.Parse(request.AccountId),
Guid.Parse(request.RelatedId),
(RelationshipStatus)request.Status
(Shared.Models.RelationshipStatus)request.Status
);
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,5 +1,6 @@
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Pass.Account;
@@ -7,7 +8,7 @@ public class ActionLogService(GeoIpService geo, FlushBufferService fbs)
{
public void CreateActionLog(Guid accountId, string action, Dictionary<string, object?> meta)
{
var log = new ActionLog
var log = new SnActionLog
{
Action = action,
AccountId = accountId,
@@ -18,9 +19,9 @@ public class ActionLogService(GeoIpService geo, FlushBufferService fbs)
}
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,
Meta = meta,
@@ -29,14 +30,14 @@ public class ActionLogService(GeoIpService geo, FlushBufferService fbs)
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;
else if (account != null)
log.AccountId = account.Id;
else
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;
fbs.Enqueue(log);

View File

@@ -1,4 +1,4 @@
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Grpc.Core;
using Microsoft.EntityFrameworkCore;
@@ -22,7 +22,7 @@ public class BotAccountReceiverGrpc(
ServerCallContext context
)
{
var account = Account.FromProtoValue(request.Account);
var account = SnAccount.FromProtoValue(request.Account);
account = await accounts.CreateBotAccount(
account,
Guid.Parse(request.AutomatedId),
@@ -48,7 +48,7 @@ public class BotAccountReceiverGrpc(
ServerCallContext context
)
{
var account = Account.FromProtoValue(request.Account);
var account = SnAccount.FromProtoValue(request.Account);
if (request.PictureId is not null)
{
@@ -65,7 +65,7 @@ public class BotAccountReceiverGrpc(
Usage = "profile.picture"
}
);
account.Profile.Picture = CloudFileReferenceObject.FromProtoValue(file);
account.Profile.Picture = SnCloudFileReferenceObject.FromProtoValue(file);
}
if (request.BackgroundId is not null)
@@ -83,7 +83,7 @@ public class BotAccountReceiverGrpc(
Usage = "profile.background"
}
);
account.Profile.Background = CloudFileReferenceObject.FromProtoValue(file);
account.Profile.Background = SnCloudFileReferenceObject.FromProtoValue(file);
}
db.Accounts.Update(account);

View File

@@ -50,7 +50,7 @@ public class MagicSpellController(AppDatabase db, MagicSpellService sp) : Contro
return NotFound();
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);
else
await sp.ApplyMagicSpell(spell);

View File

@@ -2,8 +2,8 @@ using System.Security.Cryptography;
using System.Text.Json;
using DysonNetwork.Pass.Emails;
using DysonNetwork.Pass.Mailer;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using NodaTime;
@@ -20,8 +20,8 @@ public class MagicSpellService(
ICacheService cache
)
{
public async Task<MagicSpell> CreateMagicSpell(
Account account,
public async Task<SnMagicSpell> CreateMagicSpell(
SnAccount account,
MagicSpellType type,
Dictionary<string, object> meta,
Instant? expiredAt = null,
@@ -42,7 +42,7 @@ public class MagicSpellService(
}
var spellWord = _GenerateRandomString(128);
var spell = new MagicSpell
var spell = new SnMagicSpell
{
Spell = spellWord,
Type = type,
@@ -60,7 +60,7 @@ public class MagicSpellService(
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 (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)
{
@@ -191,7 +191,7 @@ public class MagicSpellService(
var defaultGroup = await db.PermissionGroups.FirstOrDefaultAsync(g => g.Key == "default");
if (defaultGroup is not null && account is not null)
{
db.PermissionGroupMembers.Add(new PermissionGroupMember
db.PermissionGroupMembers.Add(new SnPermissionGroupMember
{
Actor = $"user:{account.Id}",
Group = defaultGroup
@@ -218,7 +218,7 @@ public class MagicSpellService(
await db.SaveChangesAsync();
}
public async Task ApplyPasswordReset(MagicSpell spell, string newPassword)
public async Task ApplyPasswordReset(SnMagicSpell spell, string newPassword)
{
if (spell.Type != MagicSpellType.AuthPasswordReset)
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);
if (account is null) throw new InvalidOperationException("Both account and auth factor was not found.");
passwordFactor = new AccountAuthFactor
passwordFactor = new SnAccountAuthFactor
{
Type = AccountAuthFactorType.Password,
Account = account,
@@ -257,6 +257,6 @@ public class MagicSpellService(
var base64String = Convert.ToBase64String(randomBytes);
return base64String.Substring(0, length);
return base64String[..length];
}
}

View File

@@ -1,3 +1,4 @@
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -26,7 +27,7 @@ public class NotableDaysController(NotableDaysService days) : ControllerBase
[Authorize]
public async Task<ActionResult<List<NotableDay>>> GetAccountNotableDays(int year)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var region = currentUser.Region;
if (string.IsNullOrWhiteSpace(region)) region = "us";
@@ -39,7 +40,7 @@ public class NotableDaysController(NotableDaysService days) : ControllerBase
[Authorize]
public async Task<ActionResult<List<NotableDay>>> GetAccountNotableDaysCurrentYear()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var currentYear = DateTime.Now.Year;
var region = currentUser.Region;
@@ -64,7 +65,7 @@ public class NotableDaysController(NotableDaysService days) : ControllerBase
[Authorize]
public async Task<ActionResult<NotableDay?>> GetAccountNextHoliday()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var region = currentUser.Region;
if (string.IsNullOrWhiteSpace(region)) region = "us";

View File

@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -12,10 +13,10 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
{
[HttpGet]
[Authorize]
public async Task<ActionResult<List<Relationship>>> ListRelationships([FromQuery] int offset = 0,
public async Task<ActionResult<List<SnAccountRelationship>>> ListRelationships([FromQuery] int offset = 0,
[FromQuery] int take = 20)
{
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 query = db.AccountRelationships.AsQueryable()
@@ -44,9 +45,9 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[HttpGet("requests")]
[Authorize]
public async Task<ActionResult<List<Relationship>>> ListSentRequests()
public async Task<ActionResult<List<SnAccountRelationship>>> ListSentRequests()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var relationships = await db.AccountRelationships
.Where(r => r.AccountId == currentUser.Id && r.Status == RelationshipStatus.Pending)
@@ -66,10 +67,10 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[HttpPost("{userId:guid}")]
[Authorize]
public async Task<ActionResult<Relationship>> CreateRelationship(Guid userId,
public async Task<ActionResult<SnAccountRelationship>> CreateRelationship(Guid userId,
[FromBody] RelationshipRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var relatedUser = await db.Accounts.FindAsync(userId);
if (relatedUser is null) return NotFound("Account was not found.");
@@ -89,10 +90,10 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[HttpPatch("{userId:guid}")]
[Authorize]
public async Task<ActionResult<Relationship>> UpdateRelationship(Guid userId,
public async Task<ActionResult<SnAccountRelationship>> UpdateRelationship(Guid userId,
[FromBody] RelationshipRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
@@ -111,9 +112,9 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[HttpGet("{userId:guid}")]
[Authorize]
public async Task<ActionResult<Relationship>> GetRelationship(Guid userId)
public async Task<ActionResult<SnAccountRelationship>> GetRelationship(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
var queries = db.AccountRelationships.AsQueryable()
@@ -131,9 +132,9 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[HttpPost("{userId:guid}/friends")]
[Authorize]
public async Task<ActionResult<Relationship>> SendFriendRequest(Guid userId)
public async Task<ActionResult<SnAccountRelationship>> SendFriendRequest(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var relatedUser = await db.Accounts.FindAsync(userId);
if (relatedUser is null) return NotFound("Account was not found.");
@@ -158,7 +159,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[Authorize]
public async Task<ActionResult> DeleteFriendRequest(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
@@ -173,9 +174,9 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[HttpPost("{userId:guid}/friends/accept")]
[Authorize]
public async Task<ActionResult<Relationship>> AcceptFriendRequest(Guid userId)
public async Task<ActionResult<SnAccountRelationship>> AcceptFriendRequest(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var relationship = await rels.GetRelationship(userId, currentUser.Id, RelationshipStatus.Pending);
if (relationship is null) return NotFound("Friend request was not found.");
@@ -193,9 +194,9 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[HttpPost("{userId:guid}/friends/decline")]
[Authorize]
public async Task<ActionResult<Relationship>> DeclineFriendRequest(Guid userId)
public async Task<ActionResult<SnAccountRelationship>> DeclineFriendRequest(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var relationship = await rels.GetRelationship(userId, currentUser.Id, RelationshipStatus.Pending);
if (relationship is null) return NotFound("Friend request was not found.");
@@ -213,9 +214,9 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[HttpPost("{userId:guid}/block")]
[Authorize]
public async Task<ActionResult<Relationship>> BlockUser(Guid userId)
public async Task<ActionResult<SnAccountRelationship>> BlockUser(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var relatedUser = await db.Accounts.FindAsync(userId);
if (relatedUser is null) return NotFound("Account was not found.");
@@ -233,9 +234,9 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[HttpDelete("{userId:guid}/block")]
[Authorize]
public async Task<ActionResult<Relationship>> UnblockUser(Guid userId)
public async Task<ActionResult<SnAccountRelationship>> UnblockUser(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var relatedUser = await db.Accounts.FindAsync(userId);
if (relatedUser is null) return NotFound("Account was not found.");

View File

@@ -1,5 +1,6 @@
using DysonNetwork.Pass.Localization;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
@@ -26,7 +27,7 @@ public class RelationshipService(
return count > 0;
}
public async Task<Relationship?> GetRelationship(
public async Task<SnAccountRelationship?> GetRelationship(
Guid accountId,
Guid relatedId,
RelationshipStatus? status = null,
@@ -42,7 +43,7 @@ public class RelationshipService(
return relationship;
}
public async Task<Relationship> CreateRelationship(Account sender, Account target, RelationshipStatus status)
public async Task<SnAccountRelationship> CreateRelationship(SnAccount sender, SnAccount target, RelationshipStatus status)
{
if (status == RelationshipStatus.Pending)
throw new InvalidOperationException(
@@ -50,7 +51,7 @@ public class RelationshipService(
if (await HasExistingRelationship(sender.Id, target.Id))
throw new InvalidOperationException("Found existing relationship between you and target user.");
var relationship = new Relationship
var relationship = new SnAccountRelationship
{
AccountId = sender.Id,
RelatedId = target.Id,
@@ -65,14 +66,14 @@ public class RelationshipService(
return relationship;
}
public async Task<Relationship> BlockAccount(Account sender, Account target)
public async Task<SnAccountRelationship> BlockAccount(SnAccount sender, SnAccount target)
{
if (await HasExistingRelationship(sender.Id, target.Id))
return await UpdateRelationship(sender.Id, target.Id, RelationshipStatus.Blocked);
return await CreateRelationship(sender, target, RelationshipStatus.Blocked);
}
public async Task<Relationship> UnblockAccount(Account sender, Account target)
public async Task<SnAccountRelationship> UnblockAccount(SnAccount sender, SnAccount target)
{
var relationship = await GetRelationship(sender.Id, target.Id, RelationshipStatus.Blocked);
if (relationship is null) throw new ArgumentException("There is no relationship between you and the user.");
@@ -84,12 +85,12 @@ public class RelationshipService(
return relationship;
}
public async Task<Relationship> SendFriendRequest(Account sender, Account target)
public async Task<SnAccountRelationship> SendFriendRequest(SnAccount sender, SnAccount target)
{
if (await HasExistingRelationship(sender.Id, target.Id))
throw new InvalidOperationException("Found existing relationship between you and target user.");
var relationship = new Relationship
var relationship = new SnAccountRelationship
{
AccountId = sender.Id,
RelatedId = target.Id,
@@ -128,8 +129,8 @@ public class RelationshipService(
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
}
public async Task<Relationship> AcceptFriendRelationship(
Relationship relationship,
public async Task<SnAccountRelationship> AcceptFriendRelationship(
SnAccountRelationship relationship,
RelationshipStatus status = RelationshipStatus.Friends
)
{
@@ -144,7 +145,7 @@ public class RelationshipService(
relationship.ExpiredAt = null;
db.Update(relationship);
var relationshipBackward = new Relationship
var relationshipBackward = new SnAccountRelationship
{
AccountId = relationship.RelatedId,
RelatedId = relationship.AccountId,
@@ -159,7 +160,7 @@ public class RelationshipService(
return relationshipBackward;
}
public async Task<Relationship> UpdateRelationship(Guid accountId, Guid relatedId, RelationshipStatus status)
public async Task<SnAccountRelationship> UpdateRelationship(Guid accountId, Guid relatedId, RelationshipStatus status)
{
var relationship = await GetRelationship(accountId, relatedId);
if (relationship is null) throw new ArgumentException("There is no relationship between you and the user.");
@@ -173,7 +174,7 @@ public class RelationshipService(
return relationship;
}
public async Task<List<Guid>> ListAccountFriends(Account account)
public async Task<List<Guid>> ListAccountFriends(SnAccount account)
{
return await ListAccountFriends(account.Id);
}
@@ -197,7 +198,7 @@ public class RelationshipService(
return friends ?? [];
}
public async Task<List<Guid>> ListAccountBlocked(Account account)
public async Task<List<Guid>> ListAccountBlocked(SnAccount account)
{
return await ListAccountBlocked(account.Id);
}

View File

@@ -2,13 +2,8 @@ using System.Linq.Expressions;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Leveling;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Query;
@@ -22,39 +17,42 @@ public class AppDatabase(
IConfiguration configuration
) : DbContext(options)
{
public DbSet<PermissionNode> PermissionNodes { get; set; } = null!;
public DbSet<PermissionGroup> PermissionGroups { get; set; } = null!;
public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; } = null!;
public DbSet<SnPermissionNode> PermissionNodes { get; set; } = null!;
public DbSet<SnPermissionGroup> PermissionGroups { get; set; } = null!;
public DbSet<SnPermissionGroupMember> PermissionGroupMembers { get; set; } = null!;
public DbSet<MagicSpell> MagicSpells { get; set; } = null!;
public DbSet<Account.Account> Accounts { get; set; } = null!;
public DbSet<AccountConnection> AccountConnections { get; set; } = null!;
public DbSet<AccountProfile> AccountProfiles { get; set; } = null!;
public DbSet<AccountContact> AccountContacts { get; set; } = null!;
public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; } = null!;
public DbSet<Relationship> AccountRelationships { get; set; } = null!;
public DbSet<Status> AccountStatuses { get; set; } = null!;
public DbSet<CheckInResult> AccountCheckInResults { get; set; } = null!;
public DbSet<AccountBadge> Badges { get; set; } = null!;
public DbSet<ActionLog> ActionLogs { get; set; } = null!;
public DbSet<AbuseReport> AbuseReports { get; set; } = null!;
public DbSet<SnMagicSpell> MagicSpells { get; set; } = null!;
public DbSet<SnAccount> Accounts { get; set; } = null!;
public DbSet<SnAccountConnection> AccountConnections { get; set; } = null!;
public DbSet<SnAccountProfile> AccountProfiles { get; set; } = null!;
public DbSet<SnAccountContact> AccountContacts { get; set; } = null!;
public DbSet<SnAccountAuthFactor> AccountAuthFactors { get; set; } = null!;
public DbSet<SnAccountRelationship> AccountRelationships { get; set; } = null!;
public DbSet<SnAccountStatus> AccountStatuses { get; set; } = null!;
public DbSet<SnCheckInResult> AccountCheckInResults { get; set; } = null!;
public DbSet<SnAccountBadge> Badges { get; set; } = null!;
public DbSet<SnActionLog> ActionLogs { get; set; } = null!;
public DbSet<SnAbuseReport> AbuseReports { get; set; } = null!;
public DbSet<AuthSession> AuthSessions { get; set; } = null!;
public DbSet<AuthChallenge> AuthChallenges { get; set; } = null!;
public DbSet<AuthClient> AuthClients { get; set; } = null!;
public DbSet<ApiKey> ApiKeys { get; set; } = null!;
public DbSet<SnAuthSession> AuthSessions { get; set; } = null!;
public DbSet<SnAuthChallenge> AuthChallenges { get; set; } = null!;
public DbSet<SnAuthClient> AuthClients { get; set; } = null!;
public DbSet<SnApiKey> ApiKeys { get; set; } = null!;
public DbSet<Wallet.Wallet> Wallets { get; set; } = null!;
public DbSet<WalletPocket> WalletPockets { get; set; } = null!;
public DbSet<Order> PaymentOrders { get; set; } = null!;
public DbSet<Transaction> PaymentTransactions { get; set; } = null!;
public DbSet<Subscription> WalletSubscriptions { get; set; } = null!;
public DbSet<Coupon> WalletCoupons { get; set; } = null!;
public DbSet<SnWallet> Wallets { get; set; } = null!;
public DbSet<SnWalletPocket> WalletPockets { get; set; } = null!;
public DbSet<SnWalletOrder> PaymentOrders { get; set; } = null!;
public DbSet<SnWalletTransaction> PaymentTransactions { get; set; } = null!;
public DbSet<SnWalletFund> WalletFunds { get; set; } = null!;
public DbSet<SnWalletFundRecipient> WalletFundRecipients { get; set; } = null!;
public DbSet<SnWalletSubscription> WalletSubscriptions { get; set; } = null!;
public DbSet<SnWalletGift> WalletGifts { get; set; } = null!;
public DbSet<SnWalletCoupon> WalletCoupons { get; set; } = null!;
public DbSet<Punishment> Punishments { get; set; } = null!;
public DbSet<SnAccountPunishment> Punishments { get; set; } = null!;
public DbSet<SocialCreditRecord> SocialCreditRecords { get; set; } = null!;
public DbSet<ExperienceRecord> ExperienceRecords { get; set; } = null!;
public DbSet<SnSocialCreditRecord> SocialCreditRecords { get; set; } = null!;
public DbSet<SnExperienceRecord> ExperienceRecords { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
@@ -74,11 +72,11 @@ public class AppDatabase(
optionsBuilder.UseAsyncSeeding(async (context, _, cancellationToken) =>
{
var defaultPermissionGroup = await context.Set<PermissionGroup>()
var defaultPermissionGroup = await context.Set<SnPermissionGroup>()
.FirstOrDefaultAsync(g => g.Key == "default", cancellationToken);
if (defaultPermissionGroup is null)
{
context.Set<PermissionGroup>().Add(new PermissionGroup
context.Set<SnPermissionGroup>().Add(new SnPermissionGroup
{
Key = "default",
Nodes = new List<string>
@@ -111,21 +109,21 @@ public class AppDatabase(
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<PermissionGroupMember>()
modelBuilder.Entity<SnPermissionGroupMember>()
.HasKey(pg => new { pg.GroupId, pg.Actor });
modelBuilder.Entity<PermissionGroupMember>()
modelBuilder.Entity<SnPermissionGroupMember>()
.HasOne(pg => pg.Group)
.WithMany(g => g.Members)
.HasForeignKey(pg => pg.GroupId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Relationship>()
modelBuilder.Entity<SnAccountRelationship>()
.HasKey(r => new { FromAccountId = r.AccountId, ToAccountId = r.RelatedId });
modelBuilder.Entity<Relationship>()
modelBuilder.Entity<SnAccountRelationship>()
.HasOne(r => r.Account)
.WithMany(a => a.OutgoingRelationships)
.HasForeignKey(r => r.AccountId);
modelBuilder.Entity<Relationship>()
modelBuilder.Entity<SnAccountRelationship>()
.HasOne(r => r.Related)
.WithMany(a => a.IncomingRelationships)
.HasForeignKey(r => r.RelatedId);
@@ -283,4 +281,4 @@ public static class OptionalQueryExtensions
{
return condition ? transform(source) : source;
}
}
}

View File

@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -14,7 +15,7 @@ public class ApiKeyController(AppDatabase db, AuthService auth) : ControllerBase
[Authorize]
public async Task<IActionResult> GetKeys([FromQuery] int offset = 0, [FromQuery] int take = 20)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var query = db.ApiKeys
.Where(e => e.AccountId == currentUser.Id)
@@ -34,7 +35,7 @@ public class ApiKeyController(AppDatabase db, AuthService auth) : ControllerBase
[Authorize]
public async Task<IActionResult> GetKey(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var key = await db.ApiKeys
.Where(e => e.AccountId == currentUser.Id)
@@ -56,7 +57,7 @@ public class ApiKeyController(AppDatabase db, AuthService auth) : ControllerBase
{
if (string.IsNullOrWhiteSpace(request.Label))
return BadRequest("Label is required");
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var key = await auth.CreateApiKey(currentUser.Id, request.Label, request.ExpiredAt);
key.Key = await auth.IssueApiKeyToken(key);
@@ -67,7 +68,7 @@ public class ApiKeyController(AppDatabase db, AuthService auth) : ControllerBase
[Authorize]
public async Task<IActionResult> RotateKey(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var key = await auth.GetApiKey(id, currentUser.Id);
if(key is null) return NotFound();
@@ -80,7 +81,7 @@ public class ApiKeyController(AppDatabase db, AuthService auth) : ControllerBase
[Authorize]
public async Task<IActionResult> DeleteKey(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var key = await auth.GetApiKey(id, currentUser.Id);
if(key is null) return NotFound();

View File

@@ -1,5 +1,3 @@
using NodaTime;
namespace DysonNetwork.Pass.Auth;
public static class AuthCacheConstants

View File

@@ -2,15 +2,13 @@ using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
using NodaTime;
using Microsoft.EntityFrameworkCore;
using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Localization;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Proto;
using Microsoft.Extensions.Localization;
using AccountAuthFactor = DysonNetwork.Pass.Account.AccountAuthFactor;
using AccountService = DysonNetwork.Pass.Account.AccountService;
using ActionLogService = DysonNetwork.Pass.Account.ActionLogService;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Pass.Auth;
@@ -40,7 +38,7 @@ public class AuthController(
}
[HttpPost("challenge")]
public async Task<ActionResult<AuthChallenge>> CreateChallenge([FromBody] ChallengeRequest request)
public async Task<ActionResult<SnAuthChallenge>> CreateChallenge([FromBody] ChallengeRequest request)
{
var account = await accounts.LookupAccount(request.Account);
if (account is null) return NotFound("Account was not found.");
@@ -72,7 +70,7 @@ public class AuthController(
.Where(e => e.UserAgent == userAgent)
.Where(e => e.StepRemain > 0)
.Where(e => e.ExpiredAt != null && now < e.ExpiredAt)
.Where(e => e.Type == ChallengeType.Login)
.Where(e => e.Type == Shared.Models.ChallengeType.Login)
.Where(e => e.ClientId == device.Id)
.FirstOrDefaultAsync();
if (existingChallenge is not null)
@@ -82,7 +80,7 @@ public class AuthController(
if (existingSession is null) return existingChallenge;
}
var challenge = new AuthChallenge
var challenge = new SnAuthChallenge
{
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)),
StepTotal = await auth.DetectChallengeRisk(Request, account),
@@ -106,7 +104,7 @@ public class AuthController(
}
[HttpGet("challenge/{id:guid}")]
public async Task<ActionResult<AuthChallenge>> GetChallenge([FromRoute] Guid id)
public async Task<ActionResult<SnAuthChallenge>> GetChallenge([FromRoute] Guid id)
{
var challenge = await db.AuthChallenges
.Include(e => e.Account)
@@ -119,7 +117,7 @@ public class AuthController(
}
[HttpGet("challenge/{id:guid}/factors")]
public async Task<ActionResult<List<AccountAuthFactor>>> GetChallengeFactors([FromRoute] Guid id)
public async Task<ActionResult<List<SnAccountAuthFactor>>> GetChallengeFactors([FromRoute] Guid id)
{
var challenge = await db.AuthChallenges
.Include(e => e.Account)
@@ -165,7 +163,7 @@ public class AuthController(
}
[HttpPatch("challenge/{id:guid}")]
public async Task<ActionResult<AuthChallenge>> DoChallenge(
public async Task<ActionResult<SnAuthChallenge>> DoChallenge(
[FromRoute] Guid id,
[FromBody] PerformChallengeRequest request
)

View File

@@ -1,8 +1,8 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Pass.Account;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using NodaTime;
@@ -13,8 +13,7 @@ public class AuthService(
IConfiguration config,
IHttpClientFactory httpClientFactory,
IHttpContextAccessor httpContextAccessor,
ICacheService cache,
ILogger<AuthService> logger
ICacheService cache
)
{
private HttpContext HttpContext => httpContextAccessor.HttpContext!;
@@ -27,7 +26,7 @@ public class AuthService(
/// <param name="request">The request context</param>
/// <param name="account">The account to login</param>
/// <returns>The required steps to login</returns>
public async Task<int> DetectChallengeRisk(HttpRequest request, Account.Account account)
public async Task<int> DetectChallengeRisk(HttpRequest request, SnAccount account)
{
// 1) Find out how many authentication factors the account has enabled.
var maxSteps = await db.AccountAuthFactors
@@ -76,10 +75,10 @@ public class AuthService(
return totalRequiredSteps;
}
public async Task<AuthSession> CreateSessionForOidcAsync(Account.Account account, Instant time,
public async Task<SnAuthSession> CreateSessionForOidcAsync(SnAccount account, Instant time,
Guid? customAppId = null)
{
var challenge = new AuthChallenge
var challenge = new SnAuthChallenge
{
AccountId = account.Id,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
@@ -89,7 +88,7 @@ public class AuthService(
Type = customAppId is not null ? ChallengeType.OAuth : ChallengeType.Oidc
};
var session = new AuthSession
var session = new SnAuthSession
{
AccountId = account.Id,
CreatedAt = time,
@@ -105,7 +104,7 @@ public class AuthService(
return session;
}
public async Task<AuthClient> GetOrCreateDeviceAsync(
public async Task<SnAuthClient> GetOrCreateDeviceAsync(
Guid accountId,
string deviceId,
string? deviceName = null,
@@ -114,7 +113,7 @@ public class AuthService(
{
var device = await db.AuthClients.FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.AccountId == accountId);
if (device is not null) return device;
device = new AuthClient
device = new SnAuthClient
{
Platform = platform,
DeviceId = deviceId,
@@ -181,7 +180,7 @@ public class AuthService(
}
}
public string CreateToken(AuthSession session)
public string CreateToken(SnAuthSession session)
{
// Load the private key for signing
var privateKeyPem = File.ReadAllText(config["AuthToken:PrivateKeyPath"]!);
@@ -199,7 +198,7 @@ public class AuthService(
/// <param name="challenge">Completed challenge</param>
/// <returns>Signed compact token</returns>
/// <exception cref="ArgumentException">If challenge not completed or session already exists</exception>
public async Task<string> CreateSessionAndIssueToken(AuthChallenge challenge)
public async Task<string> CreateSessionAndIssueToken(SnAuthChallenge challenge)
{
if (challenge.StepRemain != 0)
throw new ArgumentException("Challenge not yet completed.");
@@ -210,7 +209,7 @@ public class AuthService(
throw new ArgumentException("Session already exists for this challenge.");
var now = SystemClock.Instance.GetCurrentInstant();
var session = new AuthSession
var session = new SnAuthSession
{
LastGrantedAt = now,
ExpiredAt = now.Plus(Duration.FromDays(7)),
@@ -256,7 +255,7 @@ public class AuthService(
return $"{payloadBase64}.{signatureBase64}";
}
public async Task<bool> ValidateSudoMode(AuthSession session, string? pinCode)
public async Task<bool> ValidateSudoMode(SnAuthSession session, string? pinCode)
{
// Check if the session is already in sudo mode (cached)
var sudoModeKey = $"accounts:{session.Id}:sudo";
@@ -319,7 +318,7 @@ public class AuthService(
return factor.VerifyPassword(pinCode);
}
public async Task<ApiKey?> GetApiKey(Guid id, Guid? accountId = null)
public async Task<SnApiKey?> GetApiKey(Guid id, Guid? accountId = null)
{
var key = await db.ApiKeys
.Include(e => e.Session)
@@ -329,13 +328,13 @@ public class AuthService(
return key;
}
public async Task<ApiKey> CreateApiKey(Guid accountId, string label, Instant? expiredAt = null)
public async Task<SnApiKey> CreateApiKey(Guid accountId, string label, Instant? expiredAt = null)
{
var key = new ApiKey
var key = new SnApiKey
{
AccountId = accountId,
Label = label,
Session = new AuthSession
Session = new SnAuthSession
{
AccountId = accountId,
ExpiredAt = expiredAt
@@ -348,7 +347,7 @@ public class AuthService(
return key;
}
public async Task<string> IssueApiKeyToken(ApiKey key)
public async Task<string> IssueApiKeyToken(SnApiKey key)
{
key.Session.LastGrantedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(key.Session);
@@ -357,14 +356,14 @@ public class AuthService(
return tk;
}
public async Task RevokeApiKeyToken(ApiKey key)
public async Task RevokeApiKeyToken(SnApiKey key)
{
db.Remove(key);
db.Remove(key.Session);
await db.SaveChangesAsync();
}
public async Task<ApiKey> RotateApiKeyToken(ApiKey key)
public async Task<SnApiKey> RotateApiKeyToken(SnApiKey key)
{
await using var transaction = await db.Database.BeginTransactionAsync();
try
@@ -372,7 +371,7 @@ public class AuthService(
var oldSessionId = key.SessionId;
// Create new session
var newSession = new AuthSession
var newSession = new SnAuthSession
{
AccountId = key.AccountId,
ExpiredAt = key.Session?.ExpiredAt

View File

@@ -1,4 +1,5 @@
using System.Security.Cryptography;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Pass.Auth;
@@ -7,7 +8,7 @@ public class CompactTokenService(IConfiguration config)
private readonly string _privateKeyPath = config["AuthToken:PrivateKeyPath"]
?? throw new InvalidOperationException("AuthToken:PrivateKeyPath configuration is missing");
public string CreateToken(AuthSession session)
public string CreateToken(SnAuthSession session)
{
// Load the private key for signing
var privateKeyPem = File.ReadAllText(_privateKeyPath);

View File

@@ -6,12 +6,11 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using System.Text.Json.Serialization;
using System.Web;
using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Auth.OidcProvider.Options;
using DysonNetwork.Shared.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Pass.Auth.OidcProvider.Controllers;
@@ -98,9 +97,9 @@ public class OidcProviderController(
var clientInfo = new ClientInfoResponse
{
ClientId = Guid.Parse(client.Id),
Picture = client.Picture is not null ? CloudFileReferenceObject.FromProtoValue(client.Picture) : null,
Picture = client.Picture is not null ? SnCloudFileReferenceObject.FromProtoValue(client.Picture) : null,
Background = client.Background is not null
? CloudFileReferenceObject.FromProtoValue(client.Background)
? SnCloudFileReferenceObject.FromProtoValue(client.Background)
: null,
ClientName = client.Name,
HomeUri = client.Links.HomePage,
@@ -131,7 +130,7 @@ public class OidcProviderController(
[FromForm(Name = "code_challenge_method")]
string? codeChallengeMethod = null)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account account)
if (HttpContext.Items["CurrentUser"] is not SnAccount account)
return Unauthorized();
// Find the client
@@ -226,74 +225,74 @@ public class OidcProviderController(
case "authorization_code" when request.Code == null:
return BadRequest("Authorization code is required");
case "authorization_code":
{
var client = await oidcService.FindClientBySlugAsync(request.ClientId);
if (client == null ||
!await oidcService.ValidateClientCredentialsAsync(Guid.Parse(client.Id), request.ClientSecret))
return BadRequest(new ErrorResponse
{
var client = await oidcService.FindClientBySlugAsync(request.ClientId);
if (client == null ||
!await oidcService.ValidateClientCredentialsAsync(Guid.Parse(client.Id), request.ClientSecret))
return BadRequest(new ErrorResponse
{ Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
// Generate tokens
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
clientId: Guid.Parse(client.Id),
authorizationCode: request.Code!,
redirectUri: request.RedirectUri,
codeVerifier: request.CodeVerifier
);
return Ok(tokenResponse);
}
case "refresh_token" when string.IsNullOrEmpty(request.RefreshToken):
return BadRequest(new ErrorResponse
{ Error = "invalid_request", ErrorDescription = "Refresh token is required" });
case "refresh_token":
{
try
{
// Decode the base64 refresh token to get the session ID
var sessionIdBytes = Convert.FromBase64String(request.RefreshToken);
var sessionId = new Guid(sessionIdBytes);
// Find the session and related data
var session = await oidcService.FindSessionByIdAsync(sessionId);
var now = SystemClock.Instance.GetCurrentInstant();
if (session?.AppId is null || session.ExpiredAt < now)
{
return BadRequest(new ErrorResponse
{
Error = "invalid_grant",
ErrorDescription = "Invalid or expired refresh token"
});
}
// Get the client
var client = await oidcService.FindClientByIdAsync(session.AppId.Value);
if (client == null)
{
return BadRequest(new ErrorResponse
{
Error = "invalid_client",
ErrorDescription = "Client not found"
});
}
// Generate new tokens
// Generate tokens
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
clientId: session.AppId!.Value,
sessionId: session.Id
clientId: Guid.Parse(client.Id),
authorizationCode: request.Code!,
redirectUri: request.RedirectUri,
codeVerifier: request.CodeVerifier
);
return Ok(tokenResponse);
}
catch (FormatException)
case "refresh_token" when string.IsNullOrEmpty(request.RefreshToken):
return BadRequest(new ErrorResponse
{ Error = "invalid_request", ErrorDescription = "Refresh token is required" });
case "refresh_token":
{
return BadRequest(new ErrorResponse
try
{
Error = "invalid_grant",
ErrorDescription = "Invalid refresh token format"
});
// Decode the base64 refresh token to get the session ID
var sessionIdBytes = Convert.FromBase64String(request.RefreshToken);
var sessionId = new Guid(sessionIdBytes);
// Find the session and related data
var session = await oidcService.FindSessionByIdAsync(sessionId);
var now = SystemClock.Instance.GetCurrentInstant();
if (session?.AppId is null || session.ExpiredAt < now)
{
return BadRequest(new ErrorResponse
{
Error = "invalid_grant",
ErrorDescription = "Invalid or expired refresh token"
});
}
// Get the client
var client = await oidcService.FindClientByIdAsync(session.AppId.Value);
if (client == null)
{
return BadRequest(new ErrorResponse
{
Error = "invalid_client",
ErrorDescription = "Client not found"
});
}
// Generate new tokens
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
clientId: session.AppId!.Value,
sessionId: session.Id
);
return Ok(tokenResponse);
}
catch (FormatException)
{
return BadRequest(new ErrorResponse
{
Error = "invalid_grant",
ErrorDescription = "Invalid refresh token format"
});
}
}
}
default:
return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" });
}
@@ -303,8 +302,8 @@ public class OidcProviderController(
[Authorize]
public async Task<IActionResult> GetUserInfo()
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser ||
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
// Get requested scopes from the token
var scopes = currentSession.Challenge?.Scopes ?? [];
@@ -337,21 +336,22 @@ public class OidcProviderController(
public IActionResult GetConfiguration()
{
var baseUrl = configuration["BaseUrl"];
var siteUrl = configuration["SiteUrl"];
var issuer = options.Value.IssuerUri.TrimEnd('/');
return Ok(new
{
issuer,
authorization_endpoint = $"{baseUrl}/auth/authorize",
token_endpoint = $"{baseUrl}/api/auth/open/token",
userinfo_endpoint = $"{baseUrl}/api/auth/open/userinfo",
authorization_endpoint = $"{siteUrl}/auth/authorize",
token_endpoint = $"{baseUrl}/id/auth/open/token",
userinfo_endpoint = $"{baseUrl}/id/auth/open/userinfo",
jwks_uri = $"{baseUrl}/.well-known/jwks",
scopes_supported = new[] { "openid", "profile", "email" },
response_types_supported = new[]
{ "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token" },
grant_types_supported = new[] { "authorization_code", "refresh_token" },
token_endpoint_auth_methods_supported = new[] { "client_secret_basic", "client_secret_post" },
id_token_signing_alg_values_supported = new[] { "HS256" },
id_token_signing_alg_values_supported = new[] { "HS256", "RS256" },
subject_types_supported = new[] { "public" },
claims_supported = new[] { "sub", "name", "email", "email_verified" },
code_challenge_methods_supported = new[] { "S256" },
@@ -428,4 +428,4 @@ public class TokenRequest
[JsonPropertyName("code_verifier")]
[FromForm(Name = "code_verifier")]
public string? CodeVerifier { get; set; }
}
}

View File

@@ -1,5 +1,3 @@
using System;
using System.Collections.Generic;
using NodaTime;
namespace DysonNetwork.Pass.Auth.OidcProvider.Models;

View File

@@ -1,13 +1,12 @@
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Pass.Auth.OidcProvider.Responses;
public class ClientInfoResponse
{
public Guid ClientId { get; set; }
public CloudFileReferenceObject? Picture { get; set; }
public CloudFileReferenceObject? Background { get; set; }
public SnCloudFileReferenceObject? Picture { get; set; }
public SnCloudFileReferenceObject? Background { get; set; }
public string? ClientName { get; set; }
public string? HomeUri { get; set; }
public string? PolicyUri { get; set; }

View File

@@ -6,12 +6,13 @@ using DysonNetwork.Pass.Auth.OidcProvider.Models;
using DysonNetwork.Pass.Auth.OidcProvider.Options;
using DysonNetwork.Pass.Auth.OidcProvider.Responses;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
using AccountContactType = DysonNetwork.Pass.Account.AccountContactType;
using AccountContactType = DysonNetwork.Shared.Models.AccountContactType;
namespace DysonNetwork.Pass.Auth.OidcProvider.Services;
@@ -38,7 +39,7 @@ public class OidcProviderService(
return resp.App ?? null;
}
public async Task<AuthSession?> FindValidSessionAsync(Guid accountId, Guid clientId, bool withAccount = false)
public async Task<SnAuthSession?> FindValidSessionAsync(Guid accountId, Guid clientId, bool withAccount = false)
{
var now = SystemClock.Instance.GetCurrentInstant();
@@ -57,7 +58,7 @@ public class OidcProviderService(
s.AppId == clientId &&
(s.ExpiredAt == null || s.ExpiredAt > now) &&
s.Challenge != null &&
s.Challenge.Type == ChallengeType.OAuth)
s.Challenge.Type == Shared.Models.ChallengeType.OAuth)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync();
}
@@ -80,7 +81,7 @@ public class OidcProviderService(
var client = await FindClientByIdAsync(clientId);
if (client?.Status != CustomAppStatus.Production)
if (client?.Status != Shared.Proto.CustomAppStatus.Production)
return true;
if (client?.OauthConfig?.RedirectUris == null)
@@ -145,7 +146,7 @@ public class OidcProviderService(
private string GenerateIdToken(
CustomApp client,
AuthSession session,
SnAuthSession session,
string? nonce = null,
IEnumerable<string>? scopes = null
)
@@ -199,11 +200,13 @@ public class OidcProviderService(
claims.Add(new Claim("family_name", session.Account.Profile.LastName));
}
claims.Add(new Claim(JwtRegisteredClaimNames.Azp, client.Slug));
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Issuer = _options.IssuerUri,
Audience = client.Id.ToString(),
Audience = client.Slug.ToString(),
Expires = now.Plus(Duration.FromSeconds(_options.AccessTokenLifetime.TotalSeconds)).ToDateTimeUtc(),
NotBefore = now.ToDateTimeUtc(),
SigningCredentials = new SigningCredentials(
@@ -224,11 +227,9 @@ public class OidcProviderService(
Guid? sessionId = null
)
{
var client = await FindClientByIdAsync(clientId);
if (client == null)
throw new InvalidOperationException("Client not found");
var client = await FindClientByIdAsync(clientId) ?? throw new InvalidOperationException("Client not found");
AuthSession session;
SnAuthSession session;
var clock = SystemClock.Instance;
var now = clock.GetCurrentInstant();
string? nonce = null;
@@ -299,7 +300,7 @@ public class OidcProviderService(
private string GenerateJwtToken(
CustomApp client,
AuthSession session,
SnAuthSession session,
Instant expiresAt,
IEnumerable<string>? scopes = null
)
@@ -315,6 +316,7 @@ public class OidcProviderService(
new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(),
ClaimValueTypes.Integer64),
new Claim(JwtRegisteredClaimNames.Azp, client.Slug),
]),
Expires = expiresAt.ToDateTimeUtc(),
Issuer = _options.IssuerUri,
@@ -371,7 +373,7 @@ public class OidcProviderService(
}
}
public async Task<AuthSession?> FindSessionByIdAsync(Guid sessionId)
public async Task<SnAuthSession?> FindSessionByIdAsync(Guid sessionId)
{
return await db.AuthSessions
.Include(s => s.Account)
@@ -379,7 +381,7 @@ public class OidcProviderService(
.FirstOrDefaultAsync(s => s.Id == sessionId);
}
private static string GenerateRefreshToken(AuthSession session)
private static string GenerateRefreshToken(SnAuthSession session)
{
return Convert.ToBase64String(session.Id.ToByteArray());
}
@@ -517,4 +519,4 @@ public class OidcProviderService(
return false;
}
}
}

View File

@@ -1,6 +1,4 @@
using System.Net.Http.Json;
using System.Text.Json;
using DysonNetwork.Pass;
using DysonNetwork.Shared.Cache;
namespace DysonNetwork.Pass.Auth.OpenId;

View File

@@ -1,6 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace DysonNetwork.Pass.Auth.OpenId;

View File

@@ -3,7 +3,6 @@ using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Pass;
using DysonNetwork.Shared.Cache;
using Microsoft.IdentityModel.Tokens;

Some files were not shown because too many files have changed in this diff Show More