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" "appHostPath": "../DysonNetwork.Control/DysonNetwork.Control.csproj"
} }

5
.editorconfig Normal file
View File

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

3
.env
View File

@@ -33,3 +33,6 @@ SPHERE_IMAGE=sphere:latest
# Container image name for develop # Container image name for develop
DEVELOP_IMAGE=develop:latest DEVELOP_IMAGE=develop:latest
# Container image name for gateway
GATEWAY_IMAGE=gateway:latest

View File

@@ -26,6 +26,8 @@ jobs:
image: drive image: drive
- service: Develop - service: Develop
image: develop image: develop
- service: Gateway
image: gateway
steps: steps:
- name: Checkout repository - name: Checkout repository

613
API_WALLET_FUNDS.md Normal file
View File

@@ -0,0 +1,613 @@
# Wallet Funds API Documentation
## Overview
The Wallet Funds API provides red packet functionality for the DysonNetwork platform, allowing users to create and distribute funds among multiple recipients with expiration and claiming mechanisms.
## Authentication
All endpoints require Bearer token authentication:
```
Authorization: Bearer {jwt_token}
```
## Data Types
### Enums
#### FundSplitType
```typescript
enum FundSplitType {
Even = 0, // Equal distribution
Random = 1 // Lucky draw distribution
}
```
#### FundStatus
```typescript
enum FundStatus {
Created = 0, // Fund created, waiting for claims
PartiallyReceived = 1, // Some recipients claimed
FullyReceived = 2, // All recipients claimed
Expired = 3, // Fund expired, unclaimed amounts refunded
Refunded = 4 // Legacy status
}
```
### Request/Response Models
#### CreateFundRequest
```typescript
interface CreateFundRequest {
recipientAccountIds: string[]; // UUIDs of recipients
currency: string; // e.g., "points", "golds"
totalAmount: number; // Total amount to distribute
splitType: FundSplitType; // Even or Random
message?: string; // Optional message
expirationHours?: number; // Optional: hours until expiration (default: 24)
pinCode: string; // Required: 6-digit PIN code for security
}
```
#### SnWalletFund
```typescript
interface SnWalletFund {
id: string; // UUID
currency: string;
totalAmount: number;
splitType: FundSplitType;
status: FundStatus;
message?: string;
creatorAccountId: string; // UUID
creatorAccount: SnAccount; // Creator account details (includes profile)
recipients: SnWalletFundRecipient[];
expiredAt: string; // ISO 8601 timestamp
createdAt: string; // ISO 8601 timestamp
updatedAt: string; // ISO 8601 timestamp
}
```
#### SnWalletFundRecipient
```typescript
interface SnWalletFundRecipient {
id: string; // UUID
fundId: string; // UUID
recipientAccountId: string; // UUID
recipientAccount: SnAccount; // Recipient account details (includes profile)
amount: number; // Allocated amount
isReceived: boolean;
receivedAt?: string; // ISO 8601 timestamp (if claimed)
createdAt: string; // ISO 8601 timestamp
updatedAt: string; // ISO 8601 timestamp
}
```
#### SnWalletTransaction
```typescript
interface SnWalletTransaction {
id: string; // UUID
payerWalletId?: string; // UUID (null for system transfers)
payeeWalletId?: string; // UUID (null for system transfers)
currency: string;
amount: number;
remarks?: string;
type: TransactionType;
createdAt: string; // ISO 8601 timestamp
updatedAt: string; // ISO 8601 timestamp
}
```
#### Error Response
```typescript
interface ErrorResponse {
type: string; // Error type
title: string; // Error title
status: number; // HTTP status code
detail: string; // Error details
instance?: string; // Request instance
}
```
## API Endpoints
### 1. Create Fund
Creates a new fund (red packet) for distribution among recipients.
**Endpoint:** `POST /api/wallets/funds`
**Request Body:** `CreateFundRequest`
**Response:** `SnWalletFund` (201 Created)
**Example Request:**
```bash
curl -X POST "/api/wallets/funds" \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{
"recipientAccountIds": [
"550e8400-e29b-41d4-a716-446655440000",
"550e8400-e29b-41d4-a716-446655440001",
"550e8400-e29b-41d4-a716-446655440002"
],
"currency": "points",
"totalAmount": 100.00,
"splitType": "Even",
"message": "Happy New Year! 🎉",
"expirationHours": 48,
"pinCode": "123456"
}'
```
**Example Response:**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440003",
"currency": "points",
"totalAmount": 100.00,
"splitType": 0,
"status": 0,
"message": "Happy New Year! 🎉",
"creatorAccountId": "550e8400-e29b-41d4-a716-446655440004",
"creatorAccount": {
"id": "550e8400-e29b-41d4-a716-446655440004",
"username": "creator_user"
},
"recipients": [
{
"id": "550e8400-e29b-41d4-a716-446655440005",
"fundId": "550e8400-e29b-41d4-a716-446655440003",
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440000",
"amount": 33.34,
"isReceived": false,
"createdAt": "2025-10-03T22:00:00Z",
"updatedAt": "2025-10-03T22:00:00Z"
},
{
"id": "550e8400-e29b-41d4-a716-446655440006",
"fundId": "550e8400-e29b-41d4-a716-446655440003",
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440001",
"amount": 33.33,
"isReceived": false,
"createdAt": "2025-10-03T22:00:00Z",
"updatedAt": "2025-10-03T22:00:00Z"
},
{
"id": "550e8400-e29b-41d4-a716-446655440007",
"fundId": "550e8400-e29b-41d4-a716-446655440003",
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440002",
"amount": 33.33,
"isReceived": false,
"createdAt": "2025-10-03T22:00:00Z",
"updatedAt": "2025-10-03T22:00:00Z"
}
],
"expiredAt": "2025-10-05T22:00:00Z",
"createdAt": "2025-10-03T22:00:00Z",
"updatedAt": "2025-10-03T22:00:00Z"
}
```
**Error Responses:**
- `400 Bad Request`: Invalid parameters, insufficient funds, invalid recipients
- `401 Unauthorized`: Missing or invalid authentication
- `403 Forbidden`: Invalid PIN code
- `422 Unprocessable Entity`: Business logic violations
---
### 2. Get Funds
Retrieves funds that the authenticated user is involved in (as creator or recipient).
**Endpoint:** `GET /api/wallets/funds`
**Query Parameters:**
- `offset` (number, optional): Pagination offset (default: 0)
- `take` (number, optional): Number of items to return (default: 20, max: 100)
- `status` (FundStatus, optional): Filter by fund status
**Response:** `SnWalletFund[]` (200 OK)
**Headers:**
- `X-Total`: Total number of funds matching the criteria
**Example Request:**
```bash
curl -X GET "/api/wallets/funds?offset=0&take=10&status=0" \
-H "Authorization: Bearer {token}"
```
**Example Response:**
```json
[
{
"id": "550e8400-e29b-41d4-a716-446655440003",
"currency": "points",
"totalAmount": 100.00,
"splitType": 0,
"status": 0,
"message": "Happy New Year! 🎉",
"creatorAccountId": "550e8400-e29b-41d4-a716-446655440004",
"creatorAccount": {
"id": "550e8400-e29b-41d4-a716-446655440004",
"username": "creator_user"
},
"recipients": [
{
"id": "550e8400-e29b-41d4-a716-446655440005",
"fundId": "550e8400-e29b-41d4-a716-446655440003",
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440000",
"amount": 33.34,
"isReceived": false
}
],
"expiredAt": "2025-10-05T22:00:00Z",
"createdAt": "2025-10-03T22:00:00Z",
"updatedAt": "2025-10-03T22:00:00Z"
}
]
```
**Error Responses:**
- `401 Unauthorized`: Missing or invalid authentication
---
### 3. Get Fund
Retrieves details of a specific fund.
**Endpoint:** `GET /api/wallets/funds/{id}`
**Path Parameters:**
- `id` (string): Fund UUID
**Response:** `SnWalletFund` (200 OK)
**Example Request:**
```bash
curl -X GET "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003" \
-H "Authorization: Bearer {token}"
```
**Example Response:** (Same as create fund response)
**Error Responses:**
- `401 Unauthorized`: Missing or invalid authentication
- `403 Forbidden`: User doesn't have permission to view this fund
- `404 Not Found`: Fund not found
---
### 4. Receive Fund
Claims the authenticated user's portion of a fund.
**Endpoint:** `POST /api/wallets/funds/{id}/receive`
**Path Parameters:**
- `id` (string): Fund UUID
**Response:** `SnWalletTransaction` (200 OK)
**Example Request:**
```bash
curl -X POST "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive" \
-H "Authorization: Bearer {token}"
```
**Example Response:**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440008",
"payerWalletId": null,
"payeeWalletId": "550e8400-e29b-41d4-a716-446655440009",
"currency": "points",
"amount": 33.34,
"remarks": "Received fund portion from 550e8400-e29b-41d4-a716-446655440004",
"type": 1,
"createdAt": "2025-10-03T22:05:00Z",
"updatedAt": "2025-10-03T22:05:00Z"
}
```
**Error Responses:**
- `400 Bad Request`: Fund expired, already claimed, not a recipient
- `401 Unauthorized`: Missing or invalid authentication
- `404 Not Found`: Fund not found
---
### 5. Get Wallet Overview
Retrieves a summarized overview of wallet transactions grouped by type for graphing/charting purposes.
**Endpoint:** `GET /api/wallets/overview`
**Query Parameters:**
- `startDate` (string, optional): Start date in ISO 8601 format (e.g., "2025-01-01T00:00:00Z")
- `endDate` (string, optional): End date in ISO 8601 format (e.g., "2025-12-31T23:59:59Z")
**Response:** `WalletOverview` (200 OK)
**Example Request:**
```bash
curl -X GET "/api/wallets/overview?startDate=2025-01-01T00:00:00Z&endDate=2025-12-31T23:59:59Z" \
-H "Authorization: Bearer {token}"
```
**Example Response:**
```json
{
"accountId": "550e8400-e29b-41d4-a716-446655440000",
"startDate": "2025-01-01T00:00:00.0000000Z",
"endDate": "2025-12-31T23:59:59.0000000Z",
"summary": {
"System": {
"type": "System",
"currencies": {
"points": {
"currency": "points",
"income": 150.00,
"spending": 0.00,
"net": 150.00
}
}
},
"Transfer": {
"type": "Transfer",
"currencies": {
"points": {
"currency": "points",
"income": 25.00,
"spending": 75.00,
"net": -50.00
},
"golds": {
"currency": "golds",
"income": 0.00,
"spending": 10.00,
"net": -10.00
}
}
},
"Order": {
"type": "Order",
"currencies": {
"points": {
"currency": "points",
"income": 0.00,
"spending": 200.00,
"net": -200.00
}
}
}
},
"totalIncome": 175.00,
"totalSpending": 285.00,
"netTotal": -110.00
}
```
**Response Fields:**
- `accountId`: User's account UUID
- `startDate`/`endDate`: Date range applied (ISO 8601 format)
- `summary`: Object keyed by transaction type
- `type`: Transaction type name
- `currencies`: Object keyed by currency code
- `currency`: Currency name
- `income`: Total money received
- `spending`: Total money spent
- `net`: Income minus spending
- `totalIncome`: Sum of all income across all types/currencies
- `totalSpending`: Sum of all spending across all types/currencies
- `netTotal`: Overall net (totalIncome - totalSpending)
**Error Responses:**
- `401 Unauthorized`: Missing or invalid authentication
## Error Codes
### Common Error Types
#### Validation Errors
```json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Bad Request",
"status": 400,
"detail": "At least one recipient is required",
"instance": "/api/wallets/funds"
}
```
#### Insufficient Funds
```json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Bad Request",
"status": 400,
"detail": "Insufficient funds",
"instance": "/api/wallets/funds"
}
```
#### Fund Not Available
```json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Bad Request",
"status": 400,
"detail": "Fund is no longer available",
"instance": "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive"
}
```
#### Already Claimed
```json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Bad Request",
"status": 400,
"detail": "You have already received this fund",
"instance": "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive"
}
```
## Rate Limiting
- **Create Fund**: 10 requests per minute per user
- **Get Funds**: 60 requests per minute per user
- **Get Fund**: 60 requests per minute per user
- **Receive Fund**: 30 requests per minute per user
## Webhooks/Notifications
The system integrates with the platform's notification system:
- **Fund Created**: Creator receives confirmation
- **Fund Claimed**: Creator receives notification when someone claims
- **Fund Expired**: Creator receives refund notification
## SDK Examples
### JavaScript/TypeScript
```typescript
// Create a fund
const createFund = async (fundData: CreateFundRequest): Promise<SnWalletFund> => {
const response = await fetch('/api/wallets/funds', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(fundData)
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
};
// Get user's funds
const getFunds = async (params?: {
offset?: number;
take?: number;
status?: FundStatus;
}): Promise<SnWalletFund[]> => {
const queryParams = new URLSearchParams();
if (params?.offset) queryParams.set('offset', params.offset.toString());
if (params?.take) queryParams.set('take', params.take.toString());
if (params?.status !== undefined) queryParams.set('status', params.status.toString());
const response = await fetch(`/api/wallets/funds?${queryParams}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
};
// Claim a fund
const receiveFund = async (fundId: string): Promise<SnWalletTransaction> => {
const response = await fetch(`/api/wallets/funds/${fundId}/receive`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
};
```
### Python
```python
import requests
from typing import List, Optional
from enum import Enum
class FundSplitType(Enum):
EVEN = 0
RANDOM = 1
class FundStatus(Enum):
CREATED = 0
PARTIALLY_RECEIVED = 1
FULLY_RECEIVED = 2
EXPIRED = 3
REFUNDED = 4
def create_fund(token: str, fund_data: dict) -> dict:
"""Create a new fund"""
response = requests.post(
'/api/wallets/funds',
json=fund_data,
headers={
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json'
}
)
response.raise_for_status()
return response.json()
def get_funds(
token: str,
offset: int = 0,
take: int = 20,
status: Optional[FundStatus] = None
) -> List[dict]:
"""Get user's funds"""
params = {'offset': offset, 'take': take}
if status is not None:
params['status'] = status.value
response = requests.get(
'/api/wallets/funds',
params=params,
headers={'Authorization': f'Bearer {token}'}
)
response.raise_for_status()
return response.json()
def receive_fund(token: str, fund_id: str) -> dict:
"""Claim a fund portion"""
response = requests.post(
f'/api/wallets/funds/{fund_id}/receive',
headers={'Authorization': f'Bearer {token}'}
)
response.raise_for_status()
return response.json()
```
## Changelog
### Version 1.0.0
- Initial release with basic red packet functionality
- Support for even and random split types
- 24-hour expiration with automatic refunds
- RESTful API endpoints
- Comprehensive error handling
## Support
For API support or questions:
- Check the main documentation at `README_WALLET_FUNDS.md`
- Review error messages for specific guidance
- Contact the development team for technical issues

View File

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

View File

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

View File

@@ -10,7 +10,9 @@
"ASPNETCORE_ENVIRONMENT": "Development", "ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21175", "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21175",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22189" "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22189",
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21260",
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22052"
} }
}, },
"http": { "http": {
@@ -22,7 +24,8 @@
"ASPNETCORE_ENVIRONMENT": "Development", "ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19163", "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19163",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20185" "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20185",
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:22108"
} }
} }
} }

View File

@@ -1,6 +1,4 @@
using System.Text.Json; using DysonNetwork.Shared.Models;
using DysonNetwork.Develop.Identity;
using DysonNetwork.Develop.Project;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Design;
@@ -11,13 +9,13 @@ public class AppDatabase(
IConfiguration configuration IConfiguration configuration
) : DbContext(options) ) : DbContext(options)
{ {
public DbSet<Developer> Developers { get; set; } = null!; public DbSet<SnDeveloper> Developers { get; set; } = null!;
public DbSet<DevProject> DevProjects { get; set; } = null!; public DbSet<SnDevProject> DevProjects { get; set; } = null!;
public DbSet<CustomApp> CustomApps { get; set; } = null!; public DbSet<SnCustomApp> CustomApps { get; set; } = null!;
public DbSet<CustomAppSecret> CustomAppSecrets { get; set; } = null!; public DbSet<SnCustomAppSecret> CustomAppSecrets { get; set; } = null!;
public DbSet<BotAccount> BotAccounts { get; set; } = null!; public DbSet<SnBotAccount> BotAccounts { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {

View File

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

View File

@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Develop.Project; using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry; using DysonNetwork.Shared.Registry;
using Grpc.Core; using Grpc.Core;
@@ -16,7 +16,7 @@ namespace DysonNetwork.Develop.Identity;
[Authorize] [Authorize]
public class BotAccountController( public class BotAccountController(
BotAccountService botService, BotAccountService botService,
DeveloperService developerService, DeveloperService ds,
DevProjectService projectService, DevProjectService projectService,
ILogger<BotAccountController> logger, ILogger<BotAccountController> logger,
AccountClientHelper accounts, AccountClientHelper accounts,
@@ -50,9 +50,9 @@ public class BotAccountController(
] ]
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
[Required] [MaxLength(256)] public string Nick { get; set; } = string.Empty; [Required][MaxLength(256)] public string Nick { get; set; } = string.Empty;
[Required] [MaxLength(1024)] public string Slug { get; set; } = string.Empty; [Required][MaxLength(1024)] public string Slug { get; set; } = string.Empty;
[MaxLength(128)] public string Language { get; set; } = "en-us"; [MaxLength(128)] public string Language { get; set; } = "en-us";
} }
@@ -68,7 +68,7 @@ public class BotAccountController(
[MaxLength(256)] public string? Nick { get; set; } = string.Empty; [MaxLength(256)] public string? Nick { get; set; } = string.Empty;
[Required] [MaxLength(1024)] public string? Slug { get; set; } = string.Empty; [Required][MaxLength(1024)] public string? Slug { get; set; } = string.Empty;
[MaxLength(128)] public string? Language { get; set; } [MaxLength(128)] public string? Language { get; set; }
@@ -83,12 +83,12 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser) if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized(); return Unauthorized();
var developer = await developerService.GetDeveloperByName(pubName); var developer = await ds.GetDeveloperByName(pubName);
if (developer is null) if (developer is null)
return NotFound("Developer not found"); return NotFound("Developer not found");
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
PublisherMemberRole.Viewer)) Shared.Proto.PublisherMemberRole.Viewer))
return StatusCode(403, "You must be an viewer of the developer to list bots"); return StatusCode(403, "You must be an viewer of the developer to list bots");
var project = await projectService.GetProjectAsync(projectId, developer.Id); var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -108,12 +108,12 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser) if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized(); return Unauthorized();
var developer = await developerService.GetDeveloperByName(pubName); var developer = await ds.GetDeveloperByName(pubName);
if (developer is null) if (developer is null)
return NotFound("Developer not found"); return NotFound("Developer not found");
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
PublisherMemberRole.Viewer)) Shared.Proto.PublisherMemberRole.Viewer))
return StatusCode(403, "You must be an viewer of the developer to view bot details"); return StatusCode(403, "You must be an viewer of the developer to view bot details");
var project = await projectService.GetProjectAsync(projectId, developer.Id); var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -137,12 +137,12 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser) if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized(); return Unauthorized();
var developer = await developerService.GetDeveloperByName(pubName); var developer = await ds.GetDeveloperByName(pubName);
if (developer is null) if (developer is null)
return NotFound("Developer not found"); return NotFound("Developer not found");
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
PublisherMemberRole.Editor)) Shared.Proto.PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to create a bot"); return StatusCode(403, "You must be an editor of the developer to create a bot");
var project = await projectService.GetProjectAsync(projectId, developer.Id); var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -206,12 +206,12 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser) if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized(); return Unauthorized();
var developer = await developerService.GetDeveloperByName(pubName); var developer = await ds.GetDeveloperByName(pubName);
if (developer is null) if (developer is null)
return NotFound("Developer not found"); return NotFound("Developer not found");
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
PublisherMemberRole.Editor)) Shared.Proto.PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to update a bot"); return StatusCode(403, "You must be an editor of the developer to update a bot");
var project = await projectService.GetProjectAsync(projectId, developer.Id); var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -267,12 +267,12 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser) if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized(); return Unauthorized();
var developer = await developerService.GetDeveloperByName(pubName); var developer = await ds.GetDeveloperByName(pubName);
if (developer is null) if (developer is null)
return NotFound("Developer not found"); return NotFound("Developer not found");
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
PublisherMemberRole.Editor)) Shared.Proto.PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to delete a bot"); return StatusCode(403, "You must be an editor of the developer to delete a bot");
var project = await projectService.GetProjectAsync(projectId, developer.Id); var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -296,7 +296,7 @@ public class BotAccountController(
} }
[HttpGet("{botId:guid}/keys")] [HttpGet("{botId:guid}/keys")]
public async Task<ActionResult<List<ApiKeyReference>>> ListBotKeys( public async Task<ActionResult<List<SnApiKey>>> ListBotKeys(
[FromRoute] string pubName, [FromRoute] string pubName,
[FromRoute] Guid projectId, [FromRoute] Guid projectId,
[FromRoute] Guid botId [FromRoute] Guid botId
@@ -305,7 +305,7 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser) if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized(); return Unauthorized();
var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Viewer); var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, Shared.Proto.PublisherMemberRole.Viewer);
if (developer == null) return NotFound("Developer not found"); if (developer == null) return NotFound("Developer not found");
if (project == null) return NotFound("Project not found or you don't have access"); if (project == null) return NotFound("Project not found or you don't have access");
if (bot == null) return NotFound("Bot not found"); if (bot == null) return NotFound("Bot not found");
@@ -314,13 +314,13 @@ public class BotAccountController(
{ {
AutomatedId = bot.Id.ToString() AutomatedId = bot.Id.ToString()
}); });
var data = keys.Data.Select(ApiKeyReference.FromProtoValue).ToList(); var data = keys.Data.Select(SnApiKey.FromProtoValue).ToList();
return Ok(data); return Ok(data);
} }
[HttpGet("{botId:guid}/keys/{keyId:guid}")] [HttpGet("{botId:guid}/keys/{keyId:guid}")]
public async Task<ActionResult<ApiKeyReference>> GetBotKey( public async Task<ActionResult<SnApiKey>> GetBotKey(
[FromRoute] string pubName, [FromRoute] string pubName,
[FromRoute] Guid projectId, [FromRoute] Guid projectId,
[FromRoute] Guid botId, [FromRoute] Guid botId,
@@ -329,7 +329,7 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser) if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized(); return Unauthorized();
var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Viewer); var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, Shared.Proto.PublisherMemberRole.Viewer);
if (developer == null) return NotFound("Developer not found"); if (developer == null) return NotFound("Developer not found");
if (project == null) return NotFound("Project not found or you don't have access"); if (project == null) return NotFound("Project not found or you don't have access");
if (bot == null) return NotFound("Bot not found"); if (bot == null) return NotFound("Bot not found");
@@ -338,7 +338,7 @@ public class BotAccountController(
{ {
var key = await accountsReceiver.GetApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() }); var key = await accountsReceiver.GetApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() });
if (key == null) return NotFound("API key not found"); if (key == null) return NotFound("API key not found");
return Ok(ApiKeyReference.FromProtoValue(key)); return Ok(SnApiKey.FromProtoValue(key));
} }
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
{ {
@@ -353,7 +353,7 @@ public class BotAccountController(
} }
[HttpPost("{botId:guid}/keys")] [HttpPost("{botId:guid}/keys")]
public async Task<ActionResult<ApiKeyReference>> CreateBotKey( public async Task<ActionResult<SnApiKey>> CreateBotKey(
[FromRoute] string pubName, [FromRoute] string pubName,
[FromRoute] Guid projectId, [FromRoute] Guid projectId,
[FromRoute] Guid botId, [FromRoute] Guid botId,
@@ -362,7 +362,7 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser) if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized(); return Unauthorized();
var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor); var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, Shared.Proto.PublisherMemberRole.Editor);
if (developer == null) return NotFound("Developer not found"); if (developer == null) return NotFound("Developer not found");
if (project == null) return NotFound("Project not found or you don't have access"); if (project == null) return NotFound("Project not found or you don't have access");
if (bot == null) return NotFound("Bot not found"); if (bot == null) return NotFound("Bot not found");
@@ -376,7 +376,7 @@ public class BotAccountController(
}; };
var createdKey = await accountsReceiver.CreateApiKeyAsync(newKey); var createdKey = await accountsReceiver.CreateApiKeyAsync(newKey);
return Ok(ApiKeyReference.FromProtoValue(createdKey)); return Ok(SnApiKey.FromProtoValue(createdKey));
} }
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument) catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument)
{ {
@@ -385,7 +385,7 @@ public class BotAccountController(
} }
[HttpPost("{botId:guid}/keys/{keyId:guid}/rotate")] [HttpPost("{botId:guid}/keys/{keyId:guid}/rotate")]
public async Task<ActionResult<ApiKeyReference>> RotateBotKey( public async Task<ActionResult<SnApiKey>> RotateBotKey(
[FromRoute] string pubName, [FromRoute] string pubName,
[FromRoute] Guid projectId, [FromRoute] Guid projectId,
[FromRoute] Guid botId, [FromRoute] Guid botId,
@@ -394,7 +394,7 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser) if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized(); return Unauthorized();
var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor); var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, Shared.Proto.PublisherMemberRole.Editor);
if (developer == null) return NotFound("Developer not found"); if (developer == null) return NotFound("Developer not found");
if (project == null) return NotFound("Project not found or you don't have access"); if (project == null) return NotFound("Project not found or you don't have access");
if (bot == null) return NotFound("Bot not found"); if (bot == null) return NotFound("Bot not found");
@@ -402,7 +402,7 @@ public class BotAccountController(
try try
{ {
var rotatedKey = await accountsReceiver.RotateApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() }); var rotatedKey = await accountsReceiver.RotateApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() });
return Ok(ApiKeyReference.FromProtoValue(rotatedKey)); return Ok(SnApiKey.FromProtoValue(rotatedKey));
} }
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
{ {
@@ -420,7 +420,7 @@ public class BotAccountController(
if (HttpContext.Items["CurrentUser"] is not Account currentUser) if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized(); return Unauthorized();
var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor); var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, Shared.Proto.PublisherMemberRole.Editor);
if (developer == null) return NotFound("Developer not found"); if (developer == null) return NotFound("Developer not found");
if (project == null) return NotFound("Project not found or you don't have access"); if (project == null) return NotFound("Project not found or you don't have access");
if (bot == null) return NotFound("Bot not found"); if (bot == null) return NotFound("Bot not found");
@@ -436,17 +436,17 @@ public class BotAccountController(
} }
} }
private async Task<(Developer?, DevProject?, BotAccount?)> ValidateBotAccess( private async Task<(SnDeveloper?, SnDevProject?, SnBotAccount?)> ValidateBotAccess(
string pubName, string pubName,
Guid projectId, Guid projectId,
Guid botId, Guid botId,
Account currentUser, Account currentUser,
PublisherMemberRole requiredRole) Shared.Proto.PublisherMemberRole requiredRole)
{ {
var developer = await developerService.GetDeveloperByName(pubName); var developer = await ds.GetDeveloperByName(pubName);
if (developer == null) return (null, null, null); if (developer == null) return (null, null, null);
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), requiredRole)) if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), requiredRole))
return (null, null, null); return (null, null, null);
var project = await projectService.GetProjectAsync(projectId, developer.Id); var project = await projectService.GetProjectAsync(projectId, developer.Id);

View File

@@ -1,3 +1,4 @@
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Develop.Identity; namespace DysonNetwork.Develop.Identity;
@@ -7,7 +8,7 @@ namespace DysonNetwork.Develop.Identity;
public class BotAccountPublicController(BotAccountService botService, DeveloperService developerService) : ControllerBase public class BotAccountPublicController(BotAccountService botService, DeveloperService developerService) : ControllerBase
{ {
[HttpGet("{botId:guid}")] [HttpGet("{botId:guid}")]
public async Task<ActionResult<BotAccount>> GetBotTransparentInfo([FromRoute] Guid botId) public async Task<ActionResult<SnBotAccount>> GetBotTransparentInfo([FromRoute] Guid botId)
{ {
var bot = await botService.GetBotByIdAsync(botId); var bot = await botService.GetBotByIdAsync(botId);
if (bot is null) return NotFound("Bot not found"); if (bot is null) return NotFound("Bot not found");
@@ -21,7 +22,7 @@ public class BotAccountPublicController(BotAccountService botService, DeveloperS
} }
[HttpGet("{botId:guid}/developer")] [HttpGet("{botId:guid}/developer")]
public async Task<ActionResult<Developer>> GetBotDeveloper([FromRoute] Guid botId) public async Task<ActionResult<SnDeveloper>> GetBotDeveloper([FromRoute] Guid botId)
{ {
var bot = await botService.GetBotByIdAsync(botId); var bot = await botService.GetBotByIdAsync(botId);
if (bot is null) return NotFound("Bot not found"); if (bot is null) return NotFound("Bot not found");

View File

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

View File

@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Develop.Project; using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -18,9 +19,9 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
[MaxLength(4096)] string? Description, [MaxLength(4096)] string? Description,
string? PictureId, string? PictureId,
string? BackgroundId, string? BackgroundId,
CustomAppStatus? Status, Shared.Models.CustomAppStatus? Status,
CustomAppLinks? Links, SnCustomAppLinks? Links,
CustomAppOauthConfig? OauthConfig SnCustomAppOauthConfig? OauthConfig
); );
public record CreateSecretRequest( public record CreateSecretRequest(
@@ -50,7 +51,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
if (developer is null) return NotFound(); if (developer is null) return NotFound();
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, PublisherMemberRole.Viewer)) if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, Shared.Proto.PublisherMemberRole.Viewer))
return StatusCode(403, "You must be a viewer of the developer to list custom apps"); return StatusCode(403, "You must be a viewer of the developer to list custom apps");
var project = await projectService.GetProjectAsync(projectId, developer.Id); var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -72,7 +73,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
if (developer is null) return NotFound(); if (developer is null) return NotFound();
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, PublisherMemberRole.Viewer)) if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, Shared.Proto.PublisherMemberRole.Viewer))
return StatusCode(403, "You must be a viewer of the developer to list custom apps"); return StatusCode(403, "You must be a viewer of the developer to list custom apps");
var project = await projectService.GetProjectAsync(projectId, developer.Id); var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -99,7 +100,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
if (developer is null) if (developer is null)
return NotFound("Developer not found"); return NotFound("Developer not found");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to create a custom app"); return StatusCode(403, "You must be an editor of the developer to create a custom app");
var project = await projectService.GetProjectAsync(projectId, developer.Id); var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -143,7 +144,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
if (developer is null) if (developer is null)
return NotFound("Developer not found"); return NotFound("Developer not found");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to update a custom app"); return StatusCode(403, "You must be an editor of the developer to update a custom app");
var project = await projectService.GetProjectAsync(projectId, developer.Id); var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -180,7 +181,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
if (developer is null) if (developer is null)
return NotFound("Developer not found"); return NotFound("Developer not found");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to delete a custom app"); return StatusCode(403, "You must be an editor of the developer to delete a custom app");
var project = await projectService.GetProjectAsync(projectId, developer.Id); var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -212,7 +213,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
if (developer is null) if (developer is null)
return NotFound("Developer not found"); return NotFound("Developer not found");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to view app secrets"); return StatusCode(403, "You must be an editor of the developer to view app secrets");
var project = await projectService.GetProjectAsync(projectId, developer.Id); var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -250,7 +251,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
if (developer is null) if (developer is null)
return NotFound("Developer not found"); return NotFound("Developer not found");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to create app secrets"); return StatusCode(403, "You must be an editor of the developer to create app secrets");
var project = await projectService.GetProjectAsync(projectId, developer.Id); var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -263,7 +264,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
try try
{ {
var secret = await customApps.CreateAppSecretAsync(new CustomAppSecret var secret = await customApps.CreateAppSecretAsync(new SnCustomAppSecret
{ {
AppId = appId, AppId = appId,
Description = request.Description, Description = request.Description,
@@ -309,7 +310,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
if (developer is null) if (developer is null)
return NotFound("Developer not found"); return NotFound("Developer not found");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to view app secrets"); return StatusCode(403, "You must be an editor of the developer to view app secrets");
var project = await projectService.GetProjectAsync(projectId, developer.Id); var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -350,7 +351,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
if (developer is null) if (developer is null)
return NotFound("Developer not found"); return NotFound("Developer not found");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to delete app secrets"); return StatusCode(403, "You must be an editor of the developer to delete app secrets");
var project = await projectService.GetProjectAsync(projectId, developer.Id); var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -388,7 +389,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
if (developer is null) if (developer is null)
return NotFound("Developer not found"); return NotFound("Developer not found");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to rotate app secrets"); return StatusCode(403, "You must be an editor of the developer to rotate app secrets");
var project = await projectService.GetProjectAsync(projectId, developer.Id); var project = await projectService.GetProjectAsync(projectId, developer.Id);
@@ -401,7 +402,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
try try
{ {
var secret = await customApps.RotateAppSecretAsync(new CustomAppSecret var secret = await customApps.RotateAppSecretAsync(new SnCustomAppSecret
{ {
Id = secretId, Id = secretId,
AppId = appId, AppId = appId,

View File

@@ -1,5 +1,4 @@
using DysonNetwork.Develop.Project; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography; using System.Security.Cryptography;
@@ -13,7 +12,7 @@ public class CustomAppService(
FileService.FileServiceClient files FileService.FileServiceClient files
) )
{ {
public async Task<CustomApp?> CreateAppAsync( public async Task<SnCustomApp?> CreateAppAsync(
Guid projectId, Guid projectId,
CustomAppController.CustomAppRequest request CustomAppController.CustomAppRequest request
) )
@@ -25,12 +24,12 @@ public class CustomAppService(
if (project == null) if (project == null)
return null; return null;
var app = new CustomApp var app = new SnCustomApp
{ {
Slug = request.Slug!, Slug = request.Slug!,
Name = request.Name!, Name = request.Name!,
Description = request.Description, Description = request.Description,
Status = request.Status ?? CustomAppStatus.Developing, Status = request.Status ?? Shared.Models.CustomAppStatus.Developing,
Links = request.Links, Links = request.Links,
OauthConfig = request.OauthConfig, OauthConfig = request.OauthConfig,
ProjectId = projectId ProjectId = projectId
@@ -46,7 +45,7 @@ public class CustomAppService(
); );
if (picture is null) if (picture is null)
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud."); throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
app.Picture = CloudFileReferenceObject.FromProtoValue(picture); app.Picture = SnCloudFileReferenceObject.FromProtoValue(picture);
// Create a new reference // Create a new reference
await fileRefs.CreateReferenceAsync( await fileRefs.CreateReferenceAsync(
@@ -65,7 +64,7 @@ public class CustomAppService(
); );
if (background is null) if (background is null)
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud."); throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
app.Background = CloudFileReferenceObject.FromProtoValue(background); app.Background = SnCloudFileReferenceObject.FromProtoValue(background);
// Create a new reference // Create a new reference
await fileRefs.CreateReferenceAsync( await fileRefs.CreateReferenceAsync(
@@ -84,7 +83,7 @@ public class CustomAppService(
return app; return app;
} }
public async Task<CustomApp?> GetAppAsync(Guid id, Guid? projectId = null) public async Task<SnCustomApp?> GetAppAsync(Guid id, Guid? projectId = null)
{ {
var query = db.CustomApps.AsQueryable(); var query = db.CustomApps.AsQueryable();
@@ -96,7 +95,7 @@ public class CustomAppService(
return await query.FirstOrDefaultAsync(a => a.Id == id); return await query.FirstOrDefaultAsync(a => a.Id == id);
} }
public async Task<List<CustomAppSecret>> GetAppSecretsAsync(Guid appId) public async Task<List<SnCustomAppSecret>> GetAppSecretsAsync(Guid appId)
{ {
return await db.CustomAppSecrets return await db.CustomAppSecrets
.Where(s => s.AppId == appId) .Where(s => s.AppId == appId)
@@ -104,13 +103,13 @@ public class CustomAppService(
.ToListAsync(); .ToListAsync();
} }
public async Task<CustomAppSecret?> GetAppSecretAsync(Guid secretId, Guid appId) public async Task<SnCustomAppSecret?> GetAppSecretAsync(Guid secretId, Guid appId)
{ {
return await db.CustomAppSecrets return await db.CustomAppSecrets
.FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId); .FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId);
} }
public async Task<CustomAppSecret> CreateAppSecretAsync(CustomAppSecret secret) public async Task<SnCustomAppSecret> CreateAppSecretAsync(SnCustomAppSecret secret)
{ {
if (string.IsNullOrWhiteSpace(secret.Secret)) if (string.IsNullOrWhiteSpace(secret.Secret))
{ {
@@ -141,7 +140,7 @@ public class CustomAppService(
return true; return true;
} }
public async Task<CustomAppSecret> RotateAppSecretAsync(CustomAppSecret secretUpdate) public async Task<SnCustomAppSecret> RotateAppSecretAsync(SnCustomAppSecret secretUpdate)
{ {
var existingSecret = await db.CustomAppSecrets var existingSecret = await db.CustomAppSecrets
.FirstOrDefaultAsync(s => s.Id == secretUpdate.Id && s.AppId == secretUpdate.AppId); .FirstOrDefaultAsync(s => s.Id == secretUpdate.Id && s.AppId == secretUpdate.AppId);
@@ -177,14 +176,14 @@ public class CustomAppService(
return res.ToString(); return res.ToString();
} }
public async Task<List<CustomApp>> GetAppsByProjectAsync(Guid projectId) public async Task<List<SnCustomApp>> GetAppsByProjectAsync(Guid projectId)
{ {
return await db.CustomApps return await db.CustomApps
.Where(a => a.ProjectId == projectId) .Where(a => a.ProjectId == projectId)
.ToListAsync(); .ToListAsync();
} }
public async Task<CustomApp?> UpdateAppAsync(CustomApp app, CustomAppController.CustomAppRequest request) public async Task<SnCustomApp?> UpdateAppAsync(SnCustomApp app, CustomAppController.CustomAppRequest request)
{ {
if (request.Slug is not null) if (request.Slug is not null)
app.Slug = request.Slug; app.Slug = request.Slug;
@@ -209,7 +208,7 @@ public class CustomAppService(
); );
if (picture is null) if (picture is null)
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud."); throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
app.Picture = CloudFileReferenceObject.FromProtoValue(picture); app.Picture = SnCloudFileReferenceObject.FromProtoValue(picture);
// Create a new reference // Create a new reference
await fileRefs.CreateReferenceAsync( await fileRefs.CreateReferenceAsync(
@@ -228,7 +227,7 @@ public class CustomAppService(
); );
if (background is null) if (background is null)
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud."); throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
app.Background = CloudFileReferenceObject.FromProtoValue(background); app.Background = SnCloudFileReferenceObject.FromProtoValue(background);
// Create a new reference // Create a new reference
await fileRefs.CreateReferenceAsync( await fileRefs.CreateReferenceAsync(

View File

@@ -1,3 +1,4 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Grpc.Core; using Grpc.Core;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -37,7 +38,7 @@ public class CustomAppServiceGrpc(AppDatabase db) : Shared.Proto.CustomAppServic
if (string.IsNullOrEmpty(request.Secret)) if (string.IsNullOrEmpty(request.Secret))
throw new RpcException(new Status(StatusCode.InvalidArgument, "secret required")); throw new RpcException(new Status(StatusCode.InvalidArgument, "secret required"));
IQueryable<CustomAppSecret> q = db.CustomAppSecrets; IQueryable<SnCustomAppSecret> q = db.CustomAppSecrets;
switch (request.SecretIdentifierCase) switch (request.SecretIdentifierCase)
{ {
case CheckCustomAppSecretRequest.SecretIdentifierOneofCase.SecretId: case CheckCustomAppSecretRequest.SecretIdentifierOneofCase.SecretId:

View File

@@ -1,79 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Data;
using VerificationMark = DysonNetwork.Shared.Data.VerificationMark;
namespace DysonNetwork.Develop.Identity;
public class Developer
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid PublisherId { get; set; }
[JsonIgnore] public List<DevProject> Projects { get; set; } = [];
[NotMapped] public PublisherInfo? Publisher { get; set; }
}
public class PublisherInfo
{
public Guid Id { get; set; }
public PublisherType Type { get; set; }
public string Name { get; set; } = string.Empty;
public string Nick { get; set; } = string.Empty;
public string? Bio { get; set; }
public CloudFileReferenceObject? Picture { get; set; }
public CloudFileReferenceObject? Background { get; set; }
public VerificationMark? Verification { get; set; }
public Guid? AccountId { get; set; }
public Guid? RealmId { get; set; }
public static PublisherInfo FromProto(Publisher proto)
{
var info = new PublisherInfo
{
Id = Guid.Parse(proto.Id),
Type = proto.Type == PublisherType.PubIndividual
? PublisherType.PubIndividual
: PublisherType.PubOrganizational,
Name = proto.Name,
Nick = proto.Nick,
Bio = string.IsNullOrEmpty(proto.Bio) ? null : proto.Bio,
Verification = proto.VerificationMark is not null
? VerificationMark.FromProtoValue(proto.VerificationMark)
: null,
AccountId = string.IsNullOrEmpty(proto.AccountId) ? null : Guid.Parse(proto.AccountId),
RealmId = string.IsNullOrEmpty(proto.RealmId) ? null : Guid.Parse(proto.RealmId)
};
if (proto.Picture != null)
{
info.Picture = new CloudFileReferenceObject
{
Id = proto.Picture.Id,
Name = proto.Picture.Name,
MimeType = proto.Picture.MimeType,
Hash = proto.Picture.Hash,
Size = proto.Picture.Size
};
}
if (proto.Background != null)
{
info.Background = new CloudFileReferenceObject
{
Id = proto.Background.Id,
Name = proto.Background.Name,
MimeType = proto.Background.MimeType,
Hash = proto.Background.Hash,
Size = (long)proto.Background.Size
};
}
return info;
}
}

View File

@@ -1,4 +1,5 @@
using DysonNetwork.Shared.Auth; using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Grpc.Core; using Grpc.Core;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -18,7 +19,7 @@ public class DeveloperController(
: ControllerBase : ControllerBase
{ {
[HttpGet("{name}")] [HttpGet("{name}")]
public async Task<ActionResult<Developer>> GetDeveloper(string name) public async Task<ActionResult<SnDeveloper>> GetDeveloper(string name)
{ {
var developer = await ds.GetDeveloperByName(name); var developer = await ds.GetDeveloperByName(name);
if (developer is null) return NotFound(); if (developer is null) return NotFound();
@@ -47,7 +48,7 @@ public class DeveloperController(
[HttpGet] [HttpGet]
[Authorize] [Authorize]
public async Task<ActionResult<List<Developer>>> ListJoinedDevelopers() public async Task<ActionResult<List<SnDeveloper>>> ListJoinedDevelopers()
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
@@ -69,16 +70,16 @@ public class DeveloperController(
[HttpPost("{name}/enroll")] [HttpPost("{name}/enroll")]
[Authorize] [Authorize]
[RequiredPermission("global", "developers.create")] [RequiredPermission("global", "developers.create")]
public async Task<ActionResult<Developer>> EnrollDeveloperProgram(string name) public async Task<ActionResult<SnDeveloper>> EnrollDeveloperProgram(string name)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
PublisherInfo? pub; SnPublisher? pub;
try try
{ {
var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Name = name }); var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Name = name });
pub = PublisherInfo.FromProto(pubResponse.Publisher); pub = SnPublisher.FromProto(pubResponse.Publisher);
} catch (RpcException ex) } catch (RpcException ex)
{ {
return NotFound(ex.Status.Detail); return NotFound(ex.Status.Detail);
@@ -89,14 +90,14 @@ public class DeveloperController(
{ {
PublisherId = pub.Id.ToString(), PublisherId = pub.Id.ToString(),
AccountId = currentUser.Id, AccountId = currentUser.Id,
Role = PublisherMemberRole.Owner Role = Shared.Proto.PublisherMemberRole.Owner
}); });
if (!permResponse.Valid) return StatusCode(403, "You must be the owner of the publisher to join the developer program"); if (!permResponse.Valid) return StatusCode(403, "You must be the owner of the publisher to join the developer program");
var hasDeveloper = await db.Developers.AnyAsync(d => d.PublisherId == pub.Id); var hasDeveloper = await db.Developers.AnyAsync(d => d.PublisherId == pub.Id);
if (hasDeveloper) return BadRequest("Publisher is already in the developer program"); if (hasDeveloper) return BadRequest("Publisher is already in the developer program");
var developer = new Developer var developer = new SnDeveloper
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
PublisherId = pub.Id PublisherId = pub.Id

View File

@@ -1,3 +1,4 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Grpc.Core; using Grpc.Core;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -9,22 +10,22 @@ public class DeveloperService(
PublisherService.PublisherServiceClient ps, PublisherService.PublisherServiceClient ps,
ILogger<DeveloperService> logger) ILogger<DeveloperService> logger)
{ {
public async Task<Developer> LoadDeveloperPublisher(Developer developer) public async Task<SnDeveloper> LoadDeveloperPublisher(SnDeveloper developer)
{ {
var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Id = developer.PublisherId.ToString() }); var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Id = developer.PublisherId.ToString() });
developer.Publisher = PublisherInfo.FromProto(pubResponse.Publisher); developer.Publisher = SnPublisher.FromProto(pubResponse.Publisher);
return developer; return developer;
} }
public async Task<IEnumerable<Developer>> LoadDeveloperPublisher(IEnumerable<Developer> developers) public async Task<IEnumerable<SnDeveloper>> LoadDeveloperPublisher(IEnumerable<SnDeveloper> developers)
{ {
var enumerable = developers.ToList(); var enumerable = developers.ToList();
var pubIds = enumerable.Select(d => d.PublisherId).ToList(); var pubIds = enumerable.Select(d => d.PublisherId).ToList();
var pubRequest = new GetPublisherBatchRequest(); var pubRequest = new GetPublisherBatchRequest();
pubIds.ForEach(x => pubRequest.Ids.Add(x.ToString())); pubIds.ForEach(x => pubRequest.Ids.Add(x.ToString()));
var pubResponse = await ps.GetPublisherBatchAsync(pubRequest); var pubResponse = await ps.GetPublisherBatchAsync(pubRequest);
var pubs = pubResponse.Publishers.ToDictionary(p => Guid.Parse(p.Id), PublisherInfo.FromProto); var pubs = pubResponse.Publishers.ToDictionary(p => Guid.Parse(p.Id), SnPublisher.FromProto);
return enumerable.Select(d => return enumerable.Select(d =>
{ {
@@ -33,7 +34,7 @@ public class DeveloperService(
}); });
} }
public async Task<Developer?> GetDeveloperByName(string name) public async Task<SnDeveloper?> GetDeveloperByName(string name)
{ {
try try
{ {
@@ -50,12 +51,12 @@ public class DeveloperService(
} }
} }
public async Task<Developer?> GetDeveloperById(Guid id) public async Task<SnDeveloper?> GetDeveloperById(Guid id)
{ {
return await db.Developers.FirstOrDefaultAsync(d => d.Id == id); return await db.Developers.FirstOrDefaultAsync(d => d.Id == id);
} }
public async Task<bool> IsMemberWithRole(Guid pubId, Guid accountId, PublisherMemberRole role) public async Task<bool> IsMemberWithRole(Guid pubId, Guid accountId, Shared.Proto.PublisherMemberRole role)
{ {
try try
{ {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,12 +10,12 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": { "ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_network_dev;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60" "App": "Host=localhost;Port=5432;Database=dyson_develop;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
},
"KnownProxies": ["127.0.0.1", "::1"],
"Swagger": {
"PublicBasePath": "/develop"
}, },
"KnownProxies": [
"127.0.0.1",
"::1"
],
"Etcd": { "Etcd": {
"Insecure": true "Insecure": true
}, },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,13 +8,6 @@ public static class ApplicationBuilderExtensions
{ {
public static WebApplication ConfigureAppMiddleware(this WebApplication app, ITusStore tusStore) 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.UseAuthorization();
app.MapControllers(); app.MapControllers();

View File

@@ -1,10 +1,16 @@
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Drive.Storage; using DysonNetwork.Drive.Storage.Model;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Stream; using DysonNetwork.Shared.Stream;
using FFMpegCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NATS.Client.Core; using NATS.Client.Core;
using NATS.Client.JetStream;
using NATS.Client.JetStream.Models; using NATS.Client.JetStream.Models;
using NATS.Net; using NATS.Net;
using NetVips;
using NodaTime;
using FileService = DysonNetwork.Drive.Storage.FileService;
namespace DysonNetwork.Drive.Startup; namespace DysonNetwork.Drive.Startup;
@@ -14,20 +20,74 @@ public class BroadcastEventHandler(
IServiceProvider serviceProvider IServiceProvider serviceProvider
) : BackgroundService ) : BackgroundService
{ {
private const string TempFileSuffix = "dypart";
private static readonly string[] AnimatedImageTypes =
["image/gif", "image/apng", "image/avif"];
private static readonly string[] AnimatedImageExtensions =
[".gif", ".apng", ".avif"];
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
var js = nats.CreateJetStreamContext(); var js = nats.CreateJetStreamContext();
await js.EnsureStreamCreated("account_events", [AccountDeletedEvent.Type]); await js.EnsureStreamCreated("account_events", [AccountDeletedEvent.Type]);
var consumer = await js.CreateOrUpdateConsumerAsync("account_events", var accountEventConsumer = await js.CreateOrUpdateConsumerAsync("account_events",
new ConsumerConfig("drive_account_deleted_handler"), cancellationToken: stoppingToken); new ConsumerConfig("drive_account_deleted_handler"), cancellationToken: stoppingToken);
await js.EnsureStreamCreated("file_events", [FileUploadedEvent.Type]);
var fileUploadedConsumer = await js.CreateOrUpdateConsumerAsync("file_events",
new ConsumerConfig("drive_file_uploaded_handler") { MaxDeliver = 3 }, cancellationToken: stoppingToken);
var accountDeletedTask = HandleAccountDeleted(accountEventConsumer, stoppingToken);
var fileUploadedTask = HandleFileUploaded(fileUploadedConsumer, stoppingToken);
await Task.WhenAll(accountDeletedTask, fileUploadedTask);
}
private async Task HandleFileUploaded(INatsJSConsumer consumer, CancellationToken stoppingToken)
{
await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken))
{
var payload = JsonSerializer.Deserialize<FileUploadedEventPayload>(msg.Data, GrpcTypeHelper.SerializerOptions);
if (payload == null)
{
await msg.AckAsync(cancellationToken: stoppingToken);
continue;
}
try
{
await ProcessAndUploadInBackgroundAsync(
payload.FileId,
payload.RemoteId,
payload.StorageId,
payload.ContentType,
payload.ProcessingFilePath,
payload.IsTempFile
);
await msg.AckAsync(cancellationToken: stoppingToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing FileUploadedEvent for file {FileId}", payload.FileId);
await msg.NakAsync(cancellationToken: stoppingToken, delay: TimeSpan.FromSeconds(60));
}
}
}
private async Task HandleAccountDeleted(INatsJSConsumer consumer, CancellationToken stoppingToken)
{
await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken)) await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken))
{ {
try try
{ {
var evt = JsonSerializer.Deserialize<AccountDeletedEvent>(msg.Data); var evt = JsonSerializer.Deserialize<AccountDeletedEvent>(msg.Data, GrpcTypeHelper.SerializerOptions);
if (evt == null) if (evt == null)
{ {
await msg.AckAsync(cancellationToken: stoppingToken); await msg.AckAsync(cancellationToken: stoppingToken);
@@ -69,4 +129,169 @@ public class BroadcastEventHandler(
} }
} }
} }
private async Task ProcessAndUploadInBackgroundAsync(
string fileId,
Guid remoteId,
string storageId,
string contentType,
string processingFilePath,
bool isTempFile
)
{
using var scope = serviceProvider.CreateScope();
var fs = scope.ServiceProvider.GetRequiredService<FileService>();
var scopedDb = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var pool = await fs.GetPoolAsync(remoteId);
if (pool is null) return;
var uploads = new List<(string FilePath, string Suffix, string ContentType, bool SelfDestruct)>();
var newMimeType = contentType;
var hasCompression = false;
var hasThumbnail = false;
logger.LogInformation("Processing file {FileId} in background...", fileId);
var fileToUpdate = await scopedDb.Files.AsNoTracking().FirstAsync(f => f.Id == fileId);
if (fileToUpdate.IsEncrypted)
{
uploads.Add((processingFilePath, string.Empty, contentType, false));
}
else if (!pool.PolicyConfig.NoOptimization)
{
var fileExtension = Path.GetExtension(processingFilePath);
switch (contentType.Split('/')[0])
{
case "image":
if (AnimatedImageTypes.Contains(contentType) || AnimatedImageExtensions.Contains(fileExtension))
{
logger.LogInformation("Skip optimize file {FileId} due to it is animated...", fileId);
uploads.Add((processingFilePath, string.Empty, contentType, false));
break;
}
try
{
newMimeType = "image/webp";
using var vipsImage = Image.NewFromFile(processingFilePath);
var imageToWrite = vipsImage;
if (vipsImage.Interpretation is Enums.Interpretation.Scrgb or Enums.Interpretation.Xyz)
{
imageToWrite = vipsImage.Colourspace(Enums.Interpretation.Srgb);
}
var webpPath = Path.Join(Path.GetTempPath(), $"{fileId}.{TempFileSuffix}.webp");
imageToWrite.Autorot().WriteToFile(webpPath,
new VOption { { "lossless", true }, { "strip", true } });
uploads.Add((webpPath, string.Empty, newMimeType, true));
if (imageToWrite.Width * imageToWrite.Height >= 1024 * 1024)
{
var scale = 1024.0 / Math.Max(imageToWrite.Width, imageToWrite.Height);
var compressedPath =
Path.Join(Path.GetTempPath(), $"{fileId}.{TempFileSuffix}.compressed.webp");
using var compressedImage = imageToWrite.Resize(scale);
compressedImage.Autorot().WriteToFile(compressedPath,
new VOption { { "Q", 80 }, { "strip", true } });
uploads.Add((compressedPath, ".compressed", newMimeType, true));
hasCompression = true;
}
if (!ReferenceEquals(imageToWrite, vipsImage))
{
imageToWrite.Dispose();
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to optimize image {FileId}, uploading original", fileId);
uploads.Add((processingFilePath, string.Empty, contentType, false));
newMimeType = contentType;
}
break;
case "video":
uploads.Add((processingFilePath, string.Empty, contentType, false));
var thumbnailPath = Path.Join(Path.GetTempPath(), $"{fileId}.{TempFileSuffix}.thumbnail.jpg");
try
{
await FFMpegArguments
.FromFileInput(processingFilePath, verifyExists: true)
.OutputToFile(thumbnailPath, overwrite: true, options => options
.Seek(TimeSpan.FromSeconds(0))
.WithFrameOutputCount(1)
.WithCustomArgument("-q:v 2")
)
.NotifyOnOutput(line => logger.LogInformation("[FFmpeg] {Line}", line))
.NotifyOnError(line => logger.LogWarning("[FFmpeg] {Line}", line))
.ProcessAsynchronously();
if (File.Exists(thumbnailPath))
{
uploads.Add((thumbnailPath, ".thumbnail", "image/jpeg", true));
hasThumbnail = true;
}
else
{
logger.LogWarning("FFMpeg did not produce thumbnail for video {FileId}", fileId);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to generate thumbnail for video {FileId}", fileId);
}
break;
default:
uploads.Add((processingFilePath, string.Empty, contentType, false));
break;
}
}
else
{
uploads.Add((processingFilePath, string.Empty, contentType, false));
}
logger.LogInformation("Optimized file {FileId}, now uploading...", fileId);
if (uploads.Count > 0)
{
var destPool = remoteId;
var uploadTasks = uploads.Select(item =>
fs.UploadFileToRemoteAsync(
storageId,
destPool,
item.FilePath,
item.Suffix,
item.ContentType,
item.SelfDestruct
)
).ToList();
await Task.WhenAll(uploadTasks);
logger.LogInformation("Uploaded file {FileId} done!", fileId);
var now = SystemClock.Instance.GetCurrentInstant();
await scopedDb.Files.Where(f => f.Id == fileId).ExecuteUpdateAsync(setter => setter
.SetProperty(f => f.UploadedAt, now)
.SetProperty(f => f.PoolId, destPool)
.SetProperty(f => f.MimeType, newMimeType)
.SetProperty(f => f.HasCompression, hasCompression)
.SetProperty(f => f.HasThumbnail, hasThumbnail)
);
// Only delete temp file after successful upload and db update
if (isTempFile)
File.Delete(processingFilePath);
}
await fs._PurgeCacheAsync(fileId);
}
} }

View File

@@ -3,11 +3,8 @@ using System.Text.Json.Serialization;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.OpenApi.Models;
using NodaTime; using NodaTime;
using NodaTime.Serialization.SystemTextJson; using NodaTime.Serialization.SystemTextJson;
using StackExchange.Redis;
using DysonNetwork.Shared.Proto;
using tusdotnet.Stores; using tusdotnet.Stores;
namespace DysonNetwork.Drive.Startup; namespace DysonNetwork.Drive.Startup;
@@ -61,9 +58,7 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddAppAuthentication(this IServiceCollection services) public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
{ {
services.AddCors();
services.AddAuthorization(); services.AddAuthorization();
return services; return services;
} }
@@ -74,52 +69,6 @@ public static class ServiceCollectionExtensions
return services; return services;
} }
public static IServiceCollection AddAppSwagger(this IServiceCollection services)
{
services.AddEndpointsApiExplorer();
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Version = "v1",
Title = "Dyson Drive",
Description =
"The file service of the Dyson Network. Mainly handling file storage and sharing. Also provide image processing and media analysis. Powered the Solar Network Drive as well.",
TermsOfService = new Uri("https://solsynth.dev/terms"), // Update with actual terms
License = new OpenApiLicense
{
Name = "APGLv3", // Update with actual license
Url = new Uri("https://www.gnu.org/licenses/agpl-3.0.html")
}
});
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
In = ParameterLocation.Header,
Description = "Please enter a valid token",
Name = "Authorization",
Type = SecuritySchemeType.Http,
BearerFormat = "JWT",
Scheme = "Bearer"
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
[]
}
});
});
return services;
}
public static IServiceCollection AddAppFileStorage(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddAppFileStorage(this IServiceCollection services, IConfiguration configuration)
{ {
var tusStorePath = configuration.GetSection("Tus").GetValue<string>("StorePath")!; var tusStorePath = configuration.GetSection("Tus").GetValue<string>("StorePath")!;

View File

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

View File

@@ -1,6 +1,6 @@
using DysonNetwork.Drive.Billing; using DysonNetwork.Drive.Billing;
using DysonNetwork.Shared.Auth; using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -46,14 +46,36 @@ public class FileController(
if (!string.IsNullOrWhiteSpace(file.StorageUrl)) return Redirect(file.StorageUrl); if (!string.IsNullOrWhiteSpace(file.StorageUrl)) return Redirect(file.StorageUrl);
if (!file.PoolId.HasValue) if (file.UploadedAt is null)
{ {
var tusStorePath = configuration.GetValue<string>("Tus:StorePath")!; // File is not yet uploaded to remote storage. Try to serve from local temp storage.
var filePath = Path.Combine(env.ContentRootPath, tusStorePath, file.Id); var tempFilePath = Path.Combine(Path.GetTempPath(), file.Id);
if (!System.IO.File.Exists(filePath)) return new NotFoundResult(); if (System.IO.File.Exists(tempFilePath))
return PhysicalFile(filePath, file.MimeType ?? "application/octet-stream", file.Name); {
if (file.IsEncrypted)
{
return StatusCode(StatusCodes.Status403Forbidden, "Encrypted files cannot be accessed before they are processed and stored.");
}
return PhysicalFile(tempFilePath, file.MimeType ?? "application/octet-stream", file.Name, enableRangeProcessing: true);
}
// Fallback for tus uploads that are not processed yet.
var tusStorePath = configuration.GetValue<string>("Tus:StorePath");
if (!string.IsNullOrEmpty(tusStorePath))
{
var tusFilePath = Path.Combine(env.ContentRootPath, tusStorePath, file.Id);
if (System.IO.File.Exists(tusFilePath))
{
return PhysicalFile(tusFilePath, file.MimeType ?? "application/octet-stream", file.Name, enableRangeProcessing: true);
}
}
return StatusCode(StatusCodes.Status400BadRequest, "File is being processed. Please try again later.");
} }
if (!file.PoolId.HasValue)
return StatusCode(StatusCodes.Status500InternalServerError, "File is in an inconsistent state: uploaded but no pool ID.");
var pool = await fs.GetPoolAsync(file.PoolId.Value); var pool = await fs.GetPoolAsync(file.PoolId.Value);
if (pool is null) if (pool is null)
return StatusCode(StatusCodes.Status410Gone, "The pool of the file no longer exists or not accessible."); return StatusCode(StatusCodes.Status410Gone, "The pool of the file no longer exists or not accessible.");
@@ -141,7 +163,7 @@ public class FileController(
} }
[HttpGet("{id}/info")] [HttpGet("{id}/info")]
public async Task<ActionResult<CloudFile>> GetFileInfo(string id) public async Task<ActionResult<SnCloudFile>> GetFileInfo(string id)
{ {
var file = await fs.GetFileAsync(id); var file = await fs.GetFileAsync(id);
if (file is null) return NotFound("File not found."); if (file is null) return NotFound("File not found.");
@@ -151,7 +173,7 @@ public class FileController(
[Authorize] [Authorize]
[HttpPatch("{id}/name")] [HttpPatch("{id}/name")]
public async Task<ActionResult<CloudFile>> UpdateFileName(string id, [FromBody] string name) public async Task<ActionResult<SnCloudFile>> UpdateFileName(string id, [FromBody] string name)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
@@ -170,7 +192,7 @@ public class FileController(
[Authorize] [Authorize]
[HttpPut("{id}/marks")] [HttpPut("{id}/marks")]
public async Task<ActionResult<CloudFile>> MarkFile(string id, [FromBody] MarkFileRequest request) public async Task<ActionResult<SnCloudFile>> MarkFile(string id, [FromBody] MarkFileRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
@@ -184,7 +206,7 @@ public class FileController(
[Authorize] [Authorize]
[HttpPut("{id}/meta")] [HttpPut("{id}/meta")]
public async Task<ActionResult<CloudFile>> UpdateFileMeta(string id, [FromBody] Dictionary<string, object?> meta) public async Task<ActionResult<SnCloudFile>> UpdateFileMeta(string id, [FromBody] Dictionary<string, object?> meta)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
@@ -198,7 +220,7 @@ public class FileController(
[Authorize] [Authorize]
[HttpGet("me")] [HttpGet("me")]
public async Task<ActionResult<List<CloudFile>>> GetMyFiles( public async Task<ActionResult<List<SnCloudFile>>> GetMyFiles(
[FromQuery] Guid? pool, [FromQuery] Guid? pool,
[FromQuery] bool recycled = false, [FromQuery] bool recycled = false,
[FromQuery] int offset = 0, [FromQuery] int offset = 0,
@@ -283,7 +305,7 @@ public class FileController(
[Authorize] [Authorize]
[HttpPost("fast")] [HttpPost("fast")]
[RequiredPermission("global", "files.create")] [RequiredPermission("global", "files.create")]
public async Task<ActionResult<CloudFile>> CreateFastFile([FromBody] CreateFastFileRequest request) public async Task<ActionResult<SnCloudFile>> CreateFastFile([FromBody] CreateFastFileRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
@@ -344,7 +366,7 @@ public class FileController(
await using var transaction = await db.Database.BeginTransactionAsync(); await using var transaction = await db.Database.BeginTransactionAsync();
try try
{ {
var file = new CloudFile var file = new SnCloudFile
{ {
Name = request.Name, Name = request.Name,
Size = request.Size, Size = request.Size,

View File

@@ -1,3 +1,4 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -19,6 +20,7 @@ public class FilePoolController(AppDatabase db, FileService fs) : ControllerBase
var pools = await db.Pools var pools = await db.Pools
.Where(p => p.PolicyConfig.PublicUsable || p.AccountId == accountId) .Where(p => p.PolicyConfig.PublicUsable || p.AccountId == accountId)
.Where(p => !p.IsHidden || p.AccountId == accountId) .Where(p => !p.IsHidden || p.AccountId == accountId)
.OrderBy(p => p.CreatedAt)
.ToListAsync(); .ToListAsync();
pools = pools.Select(p => pools = pools.Select(p =>
{ {

View File

@@ -1,4 +1,5 @@
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using EFCore.BulkExtensions; using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
@@ -347,7 +348,7 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
/// <param name="resourceId">The ID of the resource</param> /// <param name="resourceId">The ID of the resource</param>
/// <param name="usage">Optional filter by usage context</param> /// <param name="usage">Optional filter by usage context</param>
/// <returns>A list of files referenced by the resource</returns> /// <returns>A list of files referenced by the resource</returns>
public async Task<List<CloudFile>> GetResourceFilesAsync(string resourceId, string? usage = null) public async Task<List<SnCloudFile>> GetResourceFilesAsync(string resourceId, string? usage = null)
{ {
var query = db.FileReferences.Where(r => r.ResourceId == resourceId); var query = db.FileReferences.Where(r => r.ResourceId == resourceId);

View File

@@ -3,173 +3,172 @@ using Grpc.Core;
using NodaTime; using NodaTime;
using Duration = NodaTime.Duration; using Duration = NodaTime.Duration;
namespace DysonNetwork.Drive.Storage namespace DysonNetwork.Drive.Storage;
public class FileReferenceServiceGrpc(FileReferenceService fileReferenceService)
: Shared.Proto.FileReferenceService.FileReferenceServiceBase
{ {
public class FileReferenceServiceGrpc(FileReferenceService fileReferenceService) public override async Task<Shared.Proto.CloudFileReference> CreateReference(CreateReferenceRequest request,
: Shared.Proto.FileReferenceService.FileReferenceServiceBase ServerCallContext context)
{ {
public override async Task<Shared.Proto.CloudFileReference> CreateReference(CreateReferenceRequest request, Instant? expiredAt = null;
ServerCallContext context) if (request.ExpiredAt != null)
{ expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
Instant? expiredAt = null; else if (request.Duration != null)
if (request.ExpiredAt != null) expiredAt = SystemClock.Instance.GetCurrentInstant() +
expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds); Duration.FromTimeSpan(request.Duration.ToTimeSpan());
else if (request.Duration != null)
expiredAt = SystemClock.Instance.GetCurrentInstant() +
Duration.FromTimeSpan(request.Duration.ToTimeSpan());
var reference = await fileReferenceService.CreateReferenceAsync( var reference = await fileReferenceService.CreateReferenceAsync(
request.FileId, request.FileId,
request.Usage, request.Usage,
request.ResourceId, request.ResourceId,
expiredAt expiredAt
); );
return reference.ToProtoValue(); return reference.ToProtoValue();
}
public override async Task<CreateReferenceBatchResponse> CreateReferenceBatch(CreateReferenceBatchRequest request,
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;
}
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<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<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)
{
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)
{
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, var references = await fileReferenceService.UpdateResourceFilesAsync(
ServerCallContext context) request.ResourceId,
{ request.FileIds,
Instant? expiredAt = null; request.Usage,
if (request.ExpiredAt != null) expiredAt
expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds); );
else if (request.Duration != null) var response = new UpdateResourceFilesResponse();
expiredAt = SystemClock.Instance.GetCurrentInstant() + response.References.AddRange(references.Select(r => r.ToProtoValue()));
Duration.FromTimeSpan(request.Duration.ToTimeSpan()); return response;
}
var references = await fileReferenceService.CreateReferencesAsync( public override async Task<SetReferenceExpirationResponse> SetReferenceExpiration(
request.FilesId.ToList(), SetReferenceExpirationRequest request, ServerCallContext context)
request.Usage, {
request.ResourceId, Instant? expiredAt = null;
expiredAt if (request.ExpiredAt != null)
); {
var response = new CreateReferenceBatchResponse(); expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
response.References.AddRange(references.Select(r => r.ToProtoValue())); }
return response; else if (request.Duration != null)
{
expiredAt = SystemClock.Instance.GetCurrentInstant() +
Duration.FromTimeSpan(request.Duration.ToTimeSpan());
} }
public override async Task<GetReferencesResponse> GetReferences(GetReferencesRequest request, var success =
ServerCallContext context) await fileReferenceService.SetReferenceExpirationAsync(Guid.Parse(request.ReferenceId), expiredAt);
{ return new SetReferenceExpirationResponse { Success = success };
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, public override async Task<SetFileReferencesExpirationResponse> SetFileReferencesExpiration(
ServerCallContext context) SetFileReferencesExpirationRequest request, ServerCallContext context)
{ {
var count = await fileReferenceService.GetReferenceCountAsync(request.FileId); var expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
return new GetReferenceCountResponse { Count = count }; var updatedCount = await fileReferenceService.SetFileReferencesExpirationAsync(request.FileId, expiredAt);
} return new SetFileReferencesExpirationResponse { UpdatedCount = updatedCount };
}
public override async Task<GetReferencesResponse> GetResourceReferences(GetResourceReferencesRequest request, public override async Task<HasFileReferencesResponse> HasFileReferences(HasFileReferencesRequest request,
ServerCallContext context) ServerCallContext context)
{ {
var references = await fileReferenceService.GetResourceReferencesAsync(request.ResourceId, request.Usage); var hasReferences = await fileReferenceService.HasFileReferencesAsync(request.FileId);
var response = new GetReferencesResponse(); return new HasFileReferencesResponse { HasReferences = hasReferences };
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<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)
{
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)
{
expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
}
else if (request.Duration != null)
{
expiredAt = SystemClock.Instance.GetCurrentInstant() +
Duration.FromTimeSpan(request.Duration.ToTimeSpan());
}
var references = await fileReferenceService.UpdateResourceFilesAsync(
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)
{
expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
}
else if (request.Duration != null)
{
expiredAt = SystemClock.Instance.GetCurrentInstant() +
Duration.FromTimeSpan(request.Duration.ToTimeSpan());
}
var success =
await fileReferenceService.SetReferenceExpirationAsync(Guid.Parse(request.ReferenceId), expiredAt);
return new SetReferenceExpirationResponse { Success = success };
}
public override async Task<SetFileReferencesExpirationResponse> SetFileReferencesExpiration(
SetFileReferencesExpirationRequest request, ServerCallContext context)
{
var expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
var updatedCount = await fileReferenceService.SetFileReferencesExpirationAsync(request.FileId, expiredAt);
return new SetFileReferencesExpirationResponse { UpdatedCount = updatedCount };
}
public override async Task<HasFileReferencesResponse> HasFileReferences(HasFileReferencesRequest request,
ServerCallContext context)
{
var hasReferences = await fileReferenceService.HasFileReferencesAsync(request.FileId);
return new HasFileReferencesResponse { HasReferences = hasReferences };
}
} }
} }

View File

@@ -1,46 +1,38 @@
using System.Drawing;
using System.Globalization; using System.Globalization;
using FFMpegCore; using FFMpegCore;
using System.Security.Cryptography; using System.Security.Cryptography;
using DysonNetwork.Drive.Storage.Model;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Google.Protobuf.WellKnownTypes; using Google.Protobuf.WellKnownTypes;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Minio; using Minio;
using Minio.DataModel.Args; using Minio.DataModel.Args;
using NATS.Client.Core;
using NetVips; using NetVips;
using NodaTime; using NodaTime;
using tusdotnet.Stores;
using System.Linq.Expressions; using System.Linq.Expressions;
using DysonNetwork.Shared.Data;
using Microsoft.EntityFrameworkCore.Query; using Microsoft.EntityFrameworkCore.Query;
using NATS.Net;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Drive.Storage; namespace DysonNetwork.Drive.Storage;
public class FileService( public class FileService(
AppDatabase db, AppDatabase db,
IConfiguration configuration,
ILogger<FileService> logger, ILogger<FileService> logger,
IServiceScopeFactory scopeFactory, ICacheService cache,
ICacheService cache INatsConnection nats
) )
{ {
private const string CacheKeyPrefix = "file:"; private const string CacheKeyPrefix = "file:";
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15); private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15);
/// <summary> public async Task<SnCloudFile?> GetFileAsync(string fileId)
/// The api for getting file meta with cache,
/// the best use case is for accessing the file data.
///
/// <b>This function won't load uploader's information, only keep minimal file meta</b>
/// </summary>
/// <param name="fileId">The id of the cloud file requested</param>
/// <returns>The minimal file meta</returns>
public async Task<CloudFile?> GetFileAsync(string fileId)
{ {
var cacheKey = $"{CacheKeyPrefix}{fileId}"; var cacheKey = $"{CacheKeyPrefix}{fileId}";
var cachedFile = await cache.GetAsync<CloudFile>(cacheKey); var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey);
if (cachedFile is not null) if (cachedFile is not null)
return cachedFile; return cachedFile;
@@ -56,16 +48,15 @@ public class FileService(
return file; return file;
} }
public async Task<List<CloudFile>> GetFilesAsync(List<string> fileIds) public async Task<List<SnCloudFile>> GetFilesAsync(List<string> fileIds)
{ {
var cachedFiles = new Dictionary<string, CloudFile>(); var cachedFiles = new Dictionary<string, SnCloudFile>();
var uncachedIds = new List<string>(); var uncachedIds = new List<string>();
// Check cache first
foreach (var fileId in fileIds) foreach (var fileId in fileIds)
{ {
var cacheKey = $"{CacheKeyPrefix}{fileId}"; var cacheKey = $"{CacheKeyPrefix}{fileId}";
var cachedFile = await cache.GetAsync<CloudFile>(cacheKey); var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey);
if (cachedFile != null) if (cachedFile != null)
cachedFiles[fileId] = cachedFile; cachedFiles[fileId] = cachedFile;
@@ -73,7 +64,6 @@ public class FileService(
uncachedIds.Add(fileId); uncachedIds.Add(fileId);
} }
// Load uncached files from database
if (uncachedIds.Count > 0) if (uncachedIds.Count > 0)
{ {
var dbFiles = await db.Files var dbFiles = await db.Files
@@ -81,7 +71,6 @@ public class FileService(
.Include(f => f.Pool) .Include(f => f.Pool)
.ToListAsync(); .ToListAsync();
// Add to cache
foreach (var file in dbFiles) foreach (var file in dbFiles)
{ {
var cacheKey = $"{CacheKeyPrefix}{file.Id}"; var cacheKey = $"{CacheKeyPrefix}{file.Id}";
@@ -90,28 +79,19 @@ public class FileService(
} }
} }
// Preserve original order
return fileIds return fileIds
.Select(f => cachedFiles.GetValueOrDefault(f)) .Select(f => cachedFiles.GetValueOrDefault(f))
.Where(f => f != null) .Where(f => f != null)
.Cast<CloudFile>() .Cast<SnCloudFile>()
.ToList(); .ToList();
} }
private const string TempFilePrefix = "dyn-cloudfile"; public async Task<SnCloudFile> ProcessNewFileAsync(
private static readonly string[] AnimatedImageTypes =
["image/gif", "image/apng", "image/avif"];
private static readonly string[] AnimatedImageExtensions =
[".gif", ".apng", ".avif"];
public async Task<CloudFile> ProcessNewFileAsync(
Account account, Account account,
string fileId, string fileId,
string filePool, string filePool,
string? fileBundleId, string? fileBundleId,
Stream stream, string filePath,
string fileName, string fileName,
string? contentType, string? contentType,
string? encryptPassword, string? encryptPassword,
@@ -143,57 +123,74 @@ public class FileService(
if (bundle?.ExpiredAt != null) if (bundle?.ExpiredAt != null)
expiredAt = bundle.ExpiredAt.Value; expiredAt = bundle.ExpiredAt.Value;
var ogFilePath = Path.GetFullPath(Path.Join(configuration.GetValue<string>("Tus:StorePath"), fileId)); var managedTempPath = Path.Combine(Path.GetTempPath(), fileId);
var fileSize = stream.Length; File.Copy(filePath, managedTempPath, true);
contentType ??= !fileName.Contains('.') ? "application/octet-stream" : MimeTypes.GetMimeType(fileName);
var fileInfo = new FileInfo(managedTempPath);
var fileSize = fileInfo.Length;
var finalContentType = contentType ??
(!fileName.Contains('.') ? "application/octet-stream" : MimeTypes.GetMimeType(fileName));
var file = new SnCloudFile
{
Id = fileId,
Name = fileName,
MimeType = finalContentType,
Size = fileSize,
ExpiredAt = expiredAt,
BundleId = bundle?.Id,
AccountId = Guid.Parse(account.Id),
};
if (!pool.PolicyConfig.NoMetadata)
{
await ExtractMetadataAsync(file, managedTempPath);
}
string processingPath = managedTempPath;
bool isTempFile = true;
if (!string.IsNullOrWhiteSpace(encryptPassword)) if (!string.IsNullOrWhiteSpace(encryptPassword))
{ {
if (!pool.PolicyConfig.AllowEncryption) if (!pool.PolicyConfig.AllowEncryption)
throw new InvalidOperationException("Encryption is not allowed in this pool"); throw new InvalidOperationException("Encryption is not allowed in this pool");
var encryptedPath = Path.Combine(Path.GetTempPath(), $"{fileId}.encrypted"); var encryptedPath = Path.Combine(Path.GetTempPath(), $"{fileId}.encrypted");
FileEncryptor.EncryptFile(ogFilePath, encryptedPath, encryptPassword); FileEncryptor.EncryptFile(managedTempPath, encryptedPath, encryptPassword);
File.Delete(ogFilePath); // Delete original unencrypted
File.Move(encryptedPath, ogFilePath); // Replace the original one with encrypted File.Delete(managedTempPath);
contentType = "application/octet-stream";
processingPath = encryptedPath;
file.IsEncrypted = true;
file.MimeType = "application/octet-stream";
file.Size = new FileInfo(processingPath).Length;
} }
var hash = await HashFileAsync(ogFilePath); file.Hash = await HashFileAsync(processingPath);
var file = new CloudFile
{
Id = fileId,
Name = fileName,
MimeType = contentType,
Size = fileSize,
Hash = hash,
ExpiredAt = expiredAt,
BundleId = bundle?.Id,
AccountId = Guid.Parse(account.Id),
IsEncrypted = !string.IsNullOrWhiteSpace(encryptPassword) && pool.PolicyConfig.AllowEncryption
};
// Extract metadata on the current thread for a faster initial response
if (!pool.PolicyConfig.NoMetadata)
await ExtractMetadataAsync(file, ogFilePath, stream);
db.Files.Add(file); db.Files.Add(file);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
file.StorageId ??= file.Id; file.StorageId ??= file.Id;
// Offload optimization (image conversion, thumbnailing) and uploading to a background task var js = nats.CreateJetStreamContext();
_ = Task.Run(() => await js.PublishAsync(
ProcessAndUploadInBackgroundAsync(file.Id, filePool, file.StorageId, contentType, ogFilePath, stream)); FileUploadedEvent.Type,
GrpcTypeHelper.ConvertObjectToByteString(new FileUploadedEventPayload(
file.Id,
pool.Id,
file.StorageId,
file.MimeType,
processingPath,
isTempFile)
).ToByteArray()
);
return file; return file;
} }
/// <summary> private async Task ExtractMetadataAsync(SnCloudFile file, string filePath)
/// Extracts metadata from the file based on its content type.
/// This runs synchronously to ensure the initial database record has basic metadata.
/// </summary>
private async Task ExtractMetadataAsync(CloudFile file, string filePath, Stream stream)
{ {
switch (file.MimeType?.Split('/')[0]) switch (file.MimeType?.Split('/')[0])
{ {
@@ -201,6 +198,7 @@ public class FileService(
try try
{ {
var blurhash = BlurHashSharp.SkiaSharp.BlurHashEncoder.Encode(3, 3, filePath); var blurhash = BlurHashSharp.SkiaSharp.BlurHashEncoder.Encode(3, 3, filePath);
await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
stream.Position = 0; stream.Position = 0;
using var vipsImage = Image.NewFromStream(stream); using var vipsImage = Image.NewFromStream(stream);
@@ -265,7 +263,6 @@ public class FileService(
["bit_rate"] = mediaInfo.Format.BitRate.ToString(CultureInfo.InvariantCulture), ["bit_rate"] = mediaInfo.Format.BitRate.ToString(CultureInfo.InvariantCulture),
["tags"] = mediaInfo.Format.Tags ?? new Dictionary<string, string>(), ["tags"] = mediaInfo.Format.Tags ?? new Dictionary<string, string>(),
["chapters"] = mediaInfo.Chapters, ["chapters"] = mediaInfo.Chapters,
// Add detailed stream information
["video_streams"] = mediaInfo.VideoStreams.Select(s => new ["video_streams"] = mediaInfo.VideoStreams.Select(s => new
{ {
s.AvgFrameRate, s.AvgFrameRate,
@@ -303,166 +300,6 @@ public class FileService(
} }
} }
/// <summary>
/// Handles file optimization (image compression, video thumbnail) and uploads to remote storage in the background.
/// </summary>
private async Task ProcessAndUploadInBackgroundAsync(
string fileId,
string remoteId,
string storageId,
string contentType,
string originalFilePath,
Stream stream
)
{
var pool = await GetPoolAsync(Guid.Parse(remoteId));
if (pool is null) return;
await using var bgStream = stream; // Ensure stream is disposed at the end of this task
using var scope = scopeFactory.CreateScope();
var nfs = scope.ServiceProvider.GetRequiredService<FileService>();
var scopedDb = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var uploads = new List<(string FilePath, string Suffix, string ContentType, bool SelfDestruct)>();
var newMimeType = contentType;
var hasCompression = false;
var hasThumbnail = false;
try
{
logger.LogInformation("Processing file {FileId} in background...", fileId);
var fileExtension = Path.GetExtension(originalFilePath);
if (!pool.PolicyConfig.NoOptimization)
switch (contentType.Split('/')[0])
{
case "image":
if (AnimatedImageTypes.Contains(contentType) || AnimatedImageExtensions.Contains(fileExtension))
{
logger.LogInformation("Skip optimize file {FileId} due to it is animated...", fileId);
uploads.Add((originalFilePath, string.Empty, contentType, false));
break;
}
newMimeType = "image/webp";
using (var vipsImage = Image.NewFromFile(originalFilePath))
{
var imageToWrite = vipsImage;
if (vipsImage.Interpretation is Enums.Interpretation.Scrgb or Enums.Interpretation.Xyz)
{
imageToWrite = vipsImage.Colourspace(Enums.Interpretation.Srgb);
}
var webpPath = Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{fileId}.webp");
imageToWrite.Autorot().WriteToFile(webpPath,
new VOption { { "lossless", true }, { "strip", true } });
uploads.Add((webpPath, string.Empty, newMimeType, true));
if (imageToWrite.Width * imageToWrite.Height >= 1024 * 1024)
{
var scale = 1024.0 / Math.Max(imageToWrite.Width, imageToWrite.Height);
var compressedPath =
Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{fileId}-compressed.webp");
using var compressedImage = imageToWrite.Resize(scale);
compressedImage.Autorot().WriteToFile(compressedPath,
new VOption { { "Q", 80 }, { "strip", true } });
uploads.Add((compressedPath, ".compressed", newMimeType, true));
hasCompression = true;
}
if (!ReferenceEquals(imageToWrite, vipsImage))
{
imageToWrite.Dispose(); // Clean up manually created colourspace-converted image
}
}
break;
case "video":
uploads.Add((originalFilePath, string.Empty, contentType, false));
var thumbnailPath = Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{fileId}.thumbnail.jpg");
try
{
await FFMpegArguments
.FromFileInput(originalFilePath, verifyExists: true)
.OutputToFile(thumbnailPath, overwrite: true, options => options
.Seek(TimeSpan.FromSeconds(0))
.WithFrameOutputCount(1)
.WithCustomArgument("-q:v 2")
)
.NotifyOnOutput(line => logger.LogInformation("[FFmpeg] {Line}", line))
.NotifyOnError(line => logger.LogWarning("[FFmpeg] {Line}", line))
.ProcessAsynchronously();
if (File.Exists(thumbnailPath))
{
uploads.Add((thumbnailPath, ".thumbnail", "image/jpeg", true));
hasThumbnail = true;
}
else
{
logger.LogWarning("FFMpeg did not produce thumbnail for video {FileId}", fileId);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to generate thumbnail for video {FileId}", fileId);
}
break;
default:
uploads.Add((originalFilePath, string.Empty, contentType, false));
break;
}
else uploads.Add((originalFilePath, string.Empty, contentType, false));
logger.LogInformation("Optimized file {FileId}, now uploading...", fileId);
if (uploads.Count > 0)
{
var destPool = Guid.Parse(remoteId!);
var uploadTasks = uploads.Select(item =>
nfs.UploadFileToRemoteAsync(
storageId,
destPool,
item.FilePath,
item.Suffix,
item.ContentType,
item.SelfDestruct
)
).ToList();
await Task.WhenAll(uploadTasks);
logger.LogInformation("Uploaded file {FileId} done!", fileId);
var fileToUpdate = await scopedDb.Files.FirstAsync(f => f.Id == fileId);
if (hasThumbnail) fileToUpdate.HasThumbnail = true;
var now = SystemClock.Instance.GetCurrentInstant();
await scopedDb.Files.Where(f => f.Id == fileId).ExecuteUpdateAsync(setter => setter
.SetProperty(f => f.UploadedAt, now)
.SetProperty(f => f.PoolId, destPool)
.SetProperty(f => f.MimeType, newMimeType)
.SetProperty(f => f.HasCompression, hasCompression)
.SetProperty(f => f.HasThumbnail, hasThumbnail)
);
}
}
catch (Exception err)
{
logger.LogError(err, "Failed to process and upload {FileId}", fileId);
}
finally
{
await nfs._PurgeCacheAsync(fileId);
}
}
private static async Task<string> HashFileAsync(string filePath, int chunkSize = 1024 * 1024) private static async Task<string> HashFileAsync(string filePath, int chunkSize = 1024 * 1024)
{ {
var fileInfo = new FileInfo(filePath); var fileInfo = new FileInfo(filePath);
@@ -491,11 +328,11 @@ public class FileService(
} }
var hash = MD5.HashData(buffer.AsSpan(0, bytesRead)); var hash = MD5.HashData(buffer.AsSpan(0, bytesRead));
stream.Position = 0; // Reset stream position stream.Position = 0;
return Convert.ToHexString(hash).ToLowerInvariant(); return Convert.ToHexString(hash).ToLowerInvariant();
} }
private async Task UploadFileToRemoteAsync( public async Task UploadFileToRemoteAsync(
string storageId, string storageId,
Guid targetRemote, Guid targetRemote,
string filePath, string filePath,
@@ -536,7 +373,7 @@ public class FileService(
); );
} }
public async Task<CloudFile> UpdateFileAsync(CloudFile file, FieldMask updateMask) public async Task<SnCloudFile> UpdateFileAsync(SnCloudFile file, FieldMask updateMask)
{ {
var existingFile = await db.Files.FirstOrDefaultAsync(f => f.Id == file.Id); var existingFile = await db.Files.FirstOrDefaultAsync(f => f.Id == file.Id);
if (existingFile == null) if (existingFile == null)
@@ -574,11 +411,10 @@ public class FileService(
await db.Files.Where(f => f.Id == file.Id).ExecuteUpdateAsync(updatable.ToSetPropertyCalls()); await db.Files.Where(f => f.Id == file.Id).ExecuteUpdateAsync(updatable.ToSetPropertyCalls());
await _PurgeCacheAsync(file.Id); await _PurgeCacheAsync(file.Id);
// Re-fetch the file to return the updated state
return await db.Files.AsNoTracking().FirstAsync(f => f.Id == file.Id); return await db.Files.AsNoTracking().FirstAsync(f => f.Id == file.Id);
} }
public async Task DeleteFileAsync(CloudFile file) public async Task DeleteFileAsync(SnCloudFile file)
{ {
db.Remove(file); db.Remove(file);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -587,24 +423,21 @@ public class FileService(
await DeleteFileDataAsync(file); await DeleteFileDataAsync(file);
} }
public async Task DeleteFileDataAsync(CloudFile file, bool force = false) public async Task DeleteFileDataAsync(SnCloudFile file, bool force = false)
{ {
if (!file.PoolId.HasValue) return; if (!file.PoolId.HasValue) return;
if (!force) if (!force)
{ {
// Check if any other file with the same storage ID is referenced
var sameOriginFiles = await db.Files var sameOriginFiles = await db.Files
.Where(f => f.StorageId == file.StorageId && f.Id != file.Id) .Where(f => f.StorageId == file.StorageId && f.Id != file.Id)
.Select(f => f.Id) .Select(f => f.Id)
.ToListAsync(); .ToListAsync();
// Check if any of these files are referenced
if (sameOriginFiles.Count != 0) if (sameOriginFiles.Count != 0)
return; return;
} }
// If any other file with the same storage ID is referenced, don't delete the actual file data
var dest = await GetRemoteStorageConfig(file.PoolId.Value); var dest = await GetRemoteStorageConfig(file.PoolId.Value);
if (dest is null) throw new InvalidOperationException($"No remote storage configured for pool {file.PoolId}"); if (dest is null) throw new InvalidOperationException($"No remote storage configured for pool {file.PoolId}");
var client = CreateMinioClient(dest); var client = CreateMinioClient(dest);
@@ -614,7 +447,7 @@ public class FileService(
); );
var bucket = dest.Bucket; var bucket = dest.Bucket;
var objectId = file.StorageId ?? file.Id; // Use StorageId if available, otherwise fall back to Id var objectId = file.StorageId ?? file.Id;
await client.RemoveObjectAsync( await client.RemoveObjectAsync(
new RemoveObjectArgs().WithBucket(bucket).WithObject(objectId) new RemoveObjectArgs().WithBucket(bucket).WithObject(objectId)
@@ -630,7 +463,6 @@ public class FileService(
} }
catch catch
{ {
// Ignore errors when deleting compressed version
logger.LogWarning("Failed to delete compressed version of file {fileId}", file.Id); logger.LogWarning("Failed to delete compressed version of file {fileId}", file.Id);
} }
} }
@@ -645,25 +477,17 @@ public class FileService(
} }
catch catch
{ {
// Ignore errors when deleting thumbnail
logger.LogWarning("Failed to delete thumbnail of file {fileId}", file.Id); logger.LogWarning("Failed to delete thumbnail of file {fileId}", file.Id);
} }
} }
} }
/// <summary> public async Task DeleteFileDataBatchAsync(List<SnCloudFile> files)
/// The most efficent way to delete file data (stored files) in batch.
/// But this DO NOT check the storage id, so use with caution!
/// </summary>
/// <param name="files">Files to delete</param>
/// <exception cref="InvalidOperationException">Something went wrong</exception>
public async Task DeleteFileDataBatchAsync(List<CloudFile> files)
{ {
files = files.Where(f => f.PoolId.HasValue).ToList(); files = files.Where(f => f.PoolId.HasValue).ToList();
foreach (var fileGroup in files.GroupBy(f => f.PoolId!.Value)) foreach (var fileGroup in files.GroupBy(f => f.PoolId!.Value))
{ {
// If any other file with the same storage ID is referenced, don't delete the actual file data
var dest = await GetRemoteStorageConfig(fileGroup.Key); var dest = await GetRemoteStorageConfig(fileGroup.Key);
if (dest is null) if (dest is null)
throw new InvalidOperationException($"No remote storage configured for pool {fileGroup.Key}"); throw new InvalidOperationException($"No remote storage configured for pool {fileGroup.Key}");
@@ -688,7 +512,7 @@ public class FileService(
} }
} }
private async Task<FileBundle?> GetBundleAsync(Guid id, Guid accountId) private async Task<SnFileBundle?> GetBundleAsync(Guid id, Guid accountId)
{ {
var bundle = await db.Bundles var bundle = await db.Bundles
.Where(e => e.Id == id) .Where(e => e.Id == id)
@@ -733,31 +557,27 @@ public class FileService(
return client.Build(); return client.Build();
} }
// Helper method to purge the cache for a specific file
// Made internal to allow FileReferenceService to use it
internal async Task _PurgeCacheAsync(string fileId) internal async Task _PurgeCacheAsync(string fileId)
{ {
var cacheKey = $"{CacheKeyPrefix}{fileId}"; var cacheKey = $"{CacheKeyPrefix}{fileId}";
await cache.RemoveAsync(cacheKey); await cache.RemoveAsync(cacheKey);
} }
// Helper method to purge cache for multiple files
internal async Task _PurgeCacheRangeAsync(IEnumerable<string> fileIds) internal async Task _PurgeCacheRangeAsync(IEnumerable<string> fileIds)
{ {
var tasks = fileIds.Select(_PurgeCacheAsync); var tasks = fileIds.Select(_PurgeCacheAsync);
await Task.WhenAll(tasks); await Task.WhenAll(tasks);
} }
public async Task<List<CloudFile?>> LoadFromReference(List<CloudFileReferenceObject> references) public async Task<List<SnCloudFile?>> LoadFromReference(List<SnCloudFileReferenceObject> references)
{ {
var cachedFiles = new Dictionary<string, CloudFile>(); var cachedFiles = new Dictionary<string, SnCloudFile>();
var uncachedIds = new List<string>(); var uncachedIds = new List<string>();
// Check cache first
foreach (var reference in references) foreach (var reference in references)
{ {
var cacheKey = $"{CacheKeyPrefix}{reference.Id}"; var cacheKey = $"{CacheKeyPrefix}{reference.Id}";
var cachedFile = await cache.GetAsync<CloudFile>(cacheKey); var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey);
if (cachedFile != null) if (cachedFile != null)
{ {
@@ -769,14 +589,12 @@ public class FileService(
} }
} }
// Load uncached files from database
if (uncachedIds.Count > 0) if (uncachedIds.Count > 0)
{ {
var dbFiles = await db.Files var dbFiles = await db.Files
.Where(f => uncachedIds.Contains(f.Id)) .Where(f => uncachedIds.Contains(f.Id))
.ToListAsync(); .ToListAsync();
// Add to cache
foreach (var file in dbFiles) foreach (var file in dbFiles)
{ {
var cacheKey = $"{CacheKeyPrefix}{file.Id}"; var cacheKey = $"{CacheKeyPrefix}{file.Id}";
@@ -785,18 +603,11 @@ public class FileService(
} }
} }
// Preserve original order return [.. references
return references
.Select(r => cachedFiles.GetValueOrDefault(r.Id)) .Select(r => cachedFiles.GetValueOrDefault(r.Id))
.Where(f => f != null) .Where(f => f != null)];
.ToList();
} }
/// <summary>
/// Gets the number of references to a file based on CloudFileReference records
/// </summary>
/// <param name="fileId">The ID of the file</param>
/// <returns>The number of references to the file</returns>
public async Task<int> GetReferenceCountAsync(string fileId) public async Task<int> GetReferenceCountAsync(string fileId)
{ {
return await db.FileReferences return await db.FileReferences
@@ -804,11 +615,6 @@ public class FileService(
.CountAsync(); .CountAsync();
} }
/// <summary>
/// Checks if a file is referenced by any resource
/// </summary>
/// <param name="fileId">The ID of the file to check</param>
/// <returns>True if the file is referenced, false otherwise</returns>
public async Task<bool> IsReferencedAsync(string fileId) public async Task<bool> IsReferencedAsync(string fileId)
{ {
return await db.FileReferences return await db.FileReferences
@@ -816,12 +622,8 @@ public class FileService(
.AnyAsync(); .AnyAsync();
} }
/// <summary>
/// Checks if an EXIF field should be ignored (e.g., GPS data).
/// </summary>
private static bool IsIgnoredField(string fieldName) private static bool IsIgnoredField(string fieldName)
{ {
// Common GPS EXIF field names
var gpsFields = new[] var gpsFields = new[]
{ {
"gps-latitude", "gps-longitude", "gps-altitude", "gps-latitude-ref", "gps-longitude-ref", "gps-latitude", "gps-longitude", "gps-altitude", "gps-latitude-ref", "gps-longitude-ref",
@@ -882,7 +684,7 @@ public class FileService(
return count; return count;
} }
public async Task<string> CreateFastUploadLinkAsync(CloudFile file) public async Task<string> CreateFastUploadLinkAsync(SnCloudFile file)
{ {
if (file.PoolId is null) throw new InvalidOperationException("Pool ID is null"); if (file.PoolId is null) throw new InvalidOperationException("Pool ID is null");
@@ -904,10 +706,7 @@ public class FileService(
} }
} }
/// <summary> file class UpdatableCloudFile(SnCloudFile file)
/// A helper class to build an ExecuteUpdateAsync call for CloudFile.
/// </summary>
file class UpdatableCloudFile(CloudFile file)
{ {
public string Name { get; set; } = file.Name; public string Name { get; set; } = file.Name;
public string? Description { get; set; } = file.Description; public string? Description { get; set; } = file.Description;
@@ -915,14 +714,14 @@ file class UpdatableCloudFile(CloudFile file)
public Dictionary<string, object?>? UserMeta { get; set; } = file.UserMeta; public Dictionary<string, object?>? UserMeta { get; set; } = file.UserMeta;
public bool IsMarkedRecycle { get; set; } = file.IsMarkedRecycle; public bool IsMarkedRecycle { get; set; } = file.IsMarkedRecycle;
public Expression<Func<SetPropertyCalls<CloudFile>, SetPropertyCalls<CloudFile>>> ToSetPropertyCalls() public Expression<Func<SetPropertyCalls<SnCloudFile>, SetPropertyCalls<SnCloudFile>>> ToSetPropertyCalls()
{ {
var userMeta = UserMeta ?? new Dictionary<string, object?>(); var userMeta = UserMeta ?? [];
return setter => setter return setter => setter
.SetProperty(f => f.Name, Name) .SetProperty(f => f.Name, Name)
.SetProperty(f => f.Description, Description) .SetProperty(f => f.Description, Description)
.SetProperty(f => f.FileMeta, FileMeta) .SetProperty(f => f.FileMeta, FileMeta)
.SetProperty(f => f.UserMeta, userMeta!) .SetProperty(f => f.UserMeta, userMeta)
.SetProperty(f => f.IsMarkedRecycle, IsMarkedRecycle); .SetProperty(f => f.IsMarkedRecycle, IsMarkedRecycle);
} }
} }

View File

@@ -1,4 +1,4 @@
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Google.Protobuf.WellKnownTypes; using Google.Protobuf.WellKnownTypes;
using Grpc.Core; using Grpc.Core;
@@ -48,7 +48,7 @@ namespace DysonNetwork.Drive.Storage
{ {
// Assuming CloudFileReferenceObject is a simple class/struct that holds an ID // Assuming CloudFileReferenceObject is a simple class/struct that holds an ID
// You might need to define this or adjust the LoadFromReference method in FileService // You might need to define this or adjust the LoadFromReference method in FileService
var references = request.ReferenceIds.Select(id => new CloudFileReferenceObject { Id = id }).ToList(); var references = request.ReferenceIds.Select(id => new SnCloudFileReferenceObject { Id = id }).ToList();
var files = await fileService.LoadFromReference(references); var files = await fileService.LoadFromReference(references);
var response = new LoadFromReferenceResponse(); var response = new LoadFromReferenceResponse();
response.Files.AddRange(files.Where(f => f != null).Select(f => f!.ToProtoValue())); response.Files.AddRange(files.Where(f => f != null).Select(f => f!.ToProtoValue()));

View File

@@ -1,7 +1,9 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Drive.Billing; using DysonNetwork.Drive.Billing;
using DysonNetwork.Drive.Storage.Model; using DysonNetwork.Drive.Storage.Model;
using DysonNetwork.Shared.Auth; using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -23,72 +25,68 @@ public class FileUploadController(
: ControllerBase : ControllerBase
{ {
private readonly string _tempPath = private readonly string _tempPath =
Path.Combine(configuration.GetValue<string>("Storage:Uploads") ?? Path.GetTempPath(), "multipart-uploads"); configuration.GetValue<string>("Storage:Uploads") ?? Path.Combine(Path.GetTempPath(), "multipart-uploads");
private const long DefaultChunkSize = 1024 * 1024 * 5; // 5MB private const long DefaultChunkSize = 1024 * 1024 * 5; // 5MB
[HttpPost("create")] [HttpPost("create")]
public async Task<IActionResult> CreateUploadTask([FromBody] CreateUploadTaskRequest request) public async Task<IActionResult> CreateUploadTask([FromBody] CreateUploadTaskRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser)
{
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
}
if (!currentUser.IsSuperuser) if (!currentUser.IsSuperuser)
{ {
var allowed = await permission.HasPermissionAsync(new HasPermissionRequest var allowed = await permission.HasPermissionAsync(new HasPermissionRequest
{ Actor = $"user:{currentUser.Id}", Area = "global", Key = "files.create" }); { Actor = $"user:{currentUser.Id}", Area = "global", Key = "files.create" });
if (!allowed.HasPermission) if (!allowed.HasPermission)
{ {
return Forbid(); return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
} }
} }
if (!Guid.TryParse(request.PoolId, out var poolGuid)) request.PoolId ??= Guid.Parse(configuration["Storage:PreferredRemote"]!);
{
return BadRequest("Invalid file pool id");
}
var pool = await fileService.GetPoolAsync(poolGuid); var pool = await fileService.GetPoolAsync(request.PoolId.Value);
if (pool is null) if (pool is null)
{ {
return BadRequest("Pool not found"); return new ObjectResult(ApiError.NotFound("Pool")) { StatusCode = 404 };
} }
if (pool.PolicyConfig.RequirePrivilege > 0) if (pool.PolicyConfig.RequirePrivilege is > 0)
{ {
if (currentUser.PerkSubscription is null)
{
return new ObjectResult("You need to have join the Stellar Program to use this pool")
{ StatusCode = 403 };
}
var privilege = var privilege =
currentUser.PerkSubscription is null ? 0 :
PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier); PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier);
if (privilege < pool.PolicyConfig.RequirePrivilege) if (privilege < pool.PolicyConfig.RequirePrivilege)
{ {
return new ObjectResult( return new ObjectResult(ApiError.Unauthorized(
$"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use this pool, you are tier {privilege}") $"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use pool {pool.Name}, you are tier {privilege}",
forbidden: true))
{ {
StatusCode = 403 StatusCode = 403
}; };
} }
} }
if (!string.IsNullOrEmpty(request.BundleId) && !Guid.TryParse(request.BundleId, out _))
{
return BadRequest("Invalid file bundle id");
}
var policy = pool.PolicyConfig; var policy = pool.PolicyConfig;
if (!policy.AllowEncryption && !string.IsNullOrEmpty(request.EncryptPassword)) if (!policy.AllowEncryption && !string.IsNullOrEmpty(request.EncryptPassword))
{ {
return new ObjectResult("File encryption is not allowed in this pool") { StatusCode = 403 }; return new ObjectResult(ApiError.Unauthorized("File encryption is not allowed in this pool", true))
{ StatusCode = 403 };
} }
if (policy.AcceptTypes is { Count: > 0 }) if (policy.AcceptTypes is { Count: > 0 })
{ {
if (string.IsNullOrEmpty(request.ContentType)) if (string.IsNullOrEmpty(request.ContentType))
{ {
return BadRequest("Content type is required by the pool's policy"); return new ObjectResult(ApiError.Validation(new Dictionary<string, string[]>
{
{ "contentType", new[] { "Content type is required by the pool's policy" } }
}))
{ StatusCode = 400 };
} }
var foundMatch = policy.AcceptTypes.Any(acceptType => var foundMatch = policy.AcceptTypes.Any(acceptType =>
@@ -104,15 +102,18 @@ public class FileUploadController(
if (!foundMatch) if (!foundMatch)
{ {
return new ObjectResult($"Content type {request.ContentType} is not allowed by the pool's policy") return new ObjectResult(
{ StatusCode = 403 }; ApiError.Unauthorized($"Content type {request.ContentType} is not allowed by the pool's policy",
true))
{ StatusCode = 403 };
} }
} }
if (policy.MaxFileSize is not null && request.FileSize > policy.MaxFileSize) if (policy.MaxFileSize is not null && request.FileSize > policy.MaxFileSize)
{ {
return new ObjectResult( return new ObjectResult(ApiError.Unauthorized(
$"File size {request.FileSize} is larger than the pool's maximum file size {policy.MaxFileSize}") $"File size {request.FileSize} is larger than the pool's maximum file size {policy.MaxFileSize}",
true))
{ {
StatusCode = 403 StatusCode = 403
}; };
@@ -125,8 +126,10 @@ public class FileUploadController(
); );
if (!ok) if (!ok)
{ {
return new ObjectResult($"File size {billableUnit} MiB is exceeded the user's quota {quota} MiB") return new ObjectResult(
{ StatusCode = 403 }; ApiError.Unauthorized($"File size {billableUnit} MiB is exceeded the user's quota {quota} MiB",
true))
{ StatusCode = 403 };
} }
if (!Directory.Exists(_tempPath)) if (!Directory.Exists(_tempPath))
@@ -160,7 +163,7 @@ public class FileUploadController(
ContentType = request.ContentType, ContentType = request.ContentType,
ChunkSize = chunkSize, ChunkSize = chunkSize,
ChunksCount = chunksCount, ChunksCount = chunksCount,
PoolId = request.PoolId, PoolId = request.PoolId.Value,
BundleId = request.BundleId, BundleId = request.BundleId,
EncryptPassword = request.EncryptPassword, EncryptPassword = request.EncryptPassword,
ExpiredAt = request.ExpiredAt, ExpiredAt = request.ExpiredAt,
@@ -178,15 +181,22 @@ public class FileUploadController(
}); });
} }
public class UploadChunkRequest
{
[Required]
public IFormFile Chunk { get; set; } = null!;
}
[HttpPost("chunk/{taskId}/{chunkIndex}")] [HttpPost("chunk/{taskId}/{chunkIndex}")]
[RequestSizeLimit(DefaultChunkSize + 1024 * 1024)] // 6MB to be safe [RequestSizeLimit(DefaultChunkSize + 1024 * 1024)] // 6MB to be safe
[RequestFormLimits(MultipartBodyLengthLimit = DefaultChunkSize + 1024 * 1024)] [RequestFormLimits(MultipartBodyLengthLimit = DefaultChunkSize + 1024 * 1024)]
public async Task<IActionResult> UploadChunk(string taskId, int chunkIndex, [FromForm] IFormFile chunk) public async Task<IActionResult> UploadChunk(string taskId, int chunkIndex, [FromForm] UploadChunkRequest request)
{ {
var chunk = request.Chunk;
var taskPath = Path.Combine(_tempPath, taskId); var taskPath = Path.Combine(_tempPath, taskId);
if (!Directory.Exists(taskPath)) if (!Directory.Exists(taskPath))
{ {
return NotFound("Upload task not found."); return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
} }
var chunkPath = Path.Combine(taskPath, $"{chunkIndex}.chunk"); var chunkPath = Path.Combine(taskPath, $"{chunkIndex}.chunk");
@@ -202,19 +212,20 @@ public class FileUploadController(
var taskPath = Path.Combine(_tempPath, taskId); var taskPath = Path.Combine(_tempPath, taskId);
if (!Directory.Exists(taskPath)) if (!Directory.Exists(taskPath))
{ {
return NotFound("Upload task not found."); return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
} }
var taskJsonPath = Path.Combine(taskPath, "task.json"); var taskJsonPath = Path.Combine(taskPath, "task.json");
if (!System.IO.File.Exists(taskJsonPath)) if (!System.IO.File.Exists(taskJsonPath))
{ {
return NotFound("Upload task metadata not found."); return new ObjectResult(ApiError.NotFound("Upload task metadata")) { StatusCode = 404 };
} }
var task = JsonSerializer.Deserialize<UploadTask>(await System.IO.File.ReadAllTextAsync(taskJsonPath)); var task = JsonSerializer.Deserialize<UploadTask>(await System.IO.File.ReadAllTextAsync(taskJsonPath));
if (task == null) if (task == null)
{ {
return BadRequest("Invalid task metadata."); return new ObjectResult(new ApiError { Code = "BAD_REQUEST", Message = "Invalid task metadata.", Status = 400 })
{ StatusCode = 400 };
} }
var mergedFilePath = Path.Combine(_tempPath, taskId + ".tmp"); var mergedFilePath = Path.Combine(_tempPath, taskId + ".tmp");
@@ -229,7 +240,9 @@ public class FileUploadController(
mergedStream.Close(); mergedStream.Close();
System.IO.File.Delete(mergedFilePath); System.IO.File.Delete(mergedFilePath);
Directory.Delete(taskPath, true); Directory.Delete(taskPath, true);
return BadRequest($"Chunk {i} is missing."); return new ObjectResult(new ApiError
{ Code = "CHUNK_MISSING", Message = $"Chunk {i} is missing.", Status = 400 })
{ StatusCode = 400 };
} }
await using var chunkStream = new FileStream(chunkPath, FileMode.Open); await using var chunkStream = new FileStream(chunkPath, FileMode.Open);
@@ -237,30 +250,29 @@ public class FileUploadController(
} }
} }
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser)
{
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
}
var fileId = await Nanoid.GenerateAsync(); var fileId = await Nanoid.GenerateAsync();
await using (var fileStream = var cloudFile = await fileService.ProcessNewFileAsync(
new FileStream(mergedFilePath, FileMode.Open, FileAccess.Read, FileShare.Read)) currentUser,
{ fileId,
var cloudFile = await fileService.ProcessNewFileAsync( task.PoolId.ToString(),
currentUser, task.BundleId?.ToString(),
fileId, mergedFilePath,
task.PoolId, task.FileName,
task.BundleId, task.ContentType,
fileStream, task.EncryptPassword,
task.FileName, task.ExpiredAt
task.ContentType, );
task.EncryptPassword,
task.ExpiredAt
);
// Clean up // Clean up
Directory.Delete(taskPath, true); Directory.Delete(taskPath, true);
System.IO.File.Delete(mergedFilePath); System.IO.File.Delete(mergedFilePath);
return Ok(cloudFile); return Ok(cloudFile);
}
} }
} }

View File

@@ -0,0 +1,15 @@
namespace DysonNetwork.Drive.Storage.Model;
public static class FileUploadedEvent
{
public const string Type = "file_uploaded";
}
public record FileUploadedEventPayload(
string FileId,
Guid RemoteId,
string StorageId,
string ContentType,
string ProcessingFilePath,
bool IsTempFile
);

View File

@@ -1,4 +1,4 @@
using DysonNetwork.Drive.Storage; using DysonNetwork.Shared.Models;
using NodaTime; using NodaTime;
namespace DysonNetwork.Drive.Storage.Model namespace DysonNetwork.Drive.Storage.Model
@@ -9,8 +9,8 @@ namespace DysonNetwork.Drive.Storage.Model
public string FileName { get; set; } = null!; public string FileName { get; set; } = null!;
public long FileSize { get; set; } public long FileSize { get; set; }
public string ContentType { get; set; } = null!; public string ContentType { get; set; } = null!;
public string PoolId { get; set; } = null!; public Guid? PoolId { get; set; } = null!;
public string? BundleId { get; set; } public Guid? BundleId { get; set; }
public string? EncryptPassword { get; set; } public string? EncryptPassword { get; set; }
public Instant? ExpiredAt { get; set; } public Instant? ExpiredAt { get; set; }
public long? ChunkSize { get; set; } public long? ChunkSize { get; set; }
@@ -19,7 +19,7 @@ namespace DysonNetwork.Drive.Storage.Model
public class CreateUploadTaskResponse public class CreateUploadTaskResponse
{ {
public bool FileExists { get; set; } public bool FileExists { get; set; }
public CloudFile? File { get; set; } public SnCloudFile? File { get; set; }
public string? TaskId { get; set; } public string? TaskId { get; set; }
public long? ChunkSize { get; set; } public long? ChunkSize { get; set; }
public int? ChunksCount { get; set; } public int? ChunksCount { get; set; }
@@ -33,8 +33,8 @@ namespace DysonNetwork.Drive.Storage.Model
public string ContentType { get; set; } = null!; public string ContentType { get; set; } = null!;
public long ChunkSize { get; set; } public long ChunkSize { get; set; }
public int ChunksCount { get; set; } public int ChunksCount { get; set; }
public string PoolId { get; set; } = null!; public Guid PoolId { get; set; }
public string? BundleId { get; set; } public Guid? BundleId { get; set; }
public string? EncryptPassword { get; set; } public string? EncryptPassword { get; set; }
public Instant? ExpiredAt { get; set; } public Instant? ExpiredAt { get; set; }
public string Hash { get; set; } = null!; public string Hash { get; set; } = null!;

View File

@@ -16,7 +16,7 @@ To begin a file upload, you first need to create an upload task. This is done by
"file_name": "string", "file_name": "string",
"file_size": "long (in bytes)", "file_size": "long (in bytes)",
"content_type": "string (e.g., 'image/jpeg')", "content_type": "string (e.g., 'image/jpeg')",
"pool_id": "string (GUID)", "pool_id": "string (GUID, optional)",
"bundle_id": "string (GUID, optional)", "bundle_id": "string (GUID, optional)",
"encrypt_password": "string (optional)", "encrypt_password": "string (optional)",
"expired_at": "string (ISO 8601 format, optional)", "expired_at": "string (ISO 8601 format, optional)",

View File

@@ -113,7 +113,7 @@ public abstract class TusService
: "uploaded_file"; : "uploaded_file";
var contentType = metadata.TryGetValue("content-type", out var ct) ? ct.GetString(Encoding.UTF8) : null; 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 filePool = httpContext.Request.Headers["X-FilePool"].FirstOrDefault();
var bundleId = eventContext.HttpContext.Request.Headers["X-FileBundle"].FirstOrDefault(); var bundleId = eventContext.HttpContext.Request.Headers["X-FileBundle"].FirstOrDefault();
@@ -135,7 +135,7 @@ public abstract class TusService
file.Id, file.Id,
filePool!, filePool!,
bundleId, bundleId,
fileStream, filePath,
fileName, fileName,
contentType, contentType,
encryptPassword, encryptPassword,
@@ -155,11 +155,6 @@ public abstract class TusService
await eventContext.HttpContext.Response.WriteAsync(ex.Message); await eventContext.HttpContext.Response.WriteAsync(ex.Message);
logger.LogError(ex, "Error handling file upload..."); logger.LogError(ex, "Error handling file upload...");
} }
finally
{
// Dispose the stream after all processing is complete
await fileStream.DisposeAsync();
}
}, },
OnBeforeCreateAsync = async eventContext => OnBeforeCreateAsync = async eventContext =>
{ {

View File

@@ -27,15 +27,6 @@
"PublicKeyPath": "Keys/PublicKey.pem", "PublicKeyPath": "Keys/PublicKey.pem",
"PrivateKeyPath": "Keys/PrivateKey.pem" "PrivateKeyPath": "Keys/PrivateKey.pem"
}, },
"OidcProvider": {
"IssuerUri": "https://nt.solian.app",
"PublicKeyPath": "Keys/PublicKey.pem",
"PrivateKeyPath": "Keys/PrivateKey.pem",
"AccessTokenLifetime": "01:00:00",
"RefreshTokenLifetime": "30.00:00:00",
"AuthorizationCodeLifetime": "00:30:00",
"RequireHttpsMetadata": true
},
"Tus": { "Tus": {
"StorePath": "Uploads" "StorePath": "Uploads"
}, },

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.Auth;
using DysonNetwork.Pass.Credit; using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Wallet; using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Error;
using DysonNetwork.Shared.GeoIp; using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
@@ -23,9 +24,9 @@ public class AccountController(
) : ControllerBase ) : ControllerBase
{ {
[HttpGet("{name}")] [HttpGet("{name}")]
[ProducesResponseType<Account>(StatusCodes.Status200OK)] [ProducesResponseType<SnAccount>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Account?>> GetByName(string name) public async Task<ActionResult<SnAccount?>> GetByName(string name)
{ {
var account = await db.Accounts var account = await db.Accounts
.Include(e => e.Badges) .Include(e => e.Badges)
@@ -42,9 +43,9 @@ public class AccountController(
} }
[HttpGet("{name}/badges")] [HttpGet("{name}/badges")]
[ProducesResponseType<List<AccountBadge>>(StatusCodes.Status200OK)] [ProducesResponseType<List<SnAccountBadge>>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<List<AccountBadge>>> GetBadgesByName(string name) public async Task<ActionResult<List<SnAccountBadge>>> GetBadgesByName(string name)
{ {
var account = await db.Accounts var account = await db.Accounts
.Include(e => e.Badges) .Include(e => e.Badges)
@@ -103,9 +104,9 @@ public class AccountController(
} }
[HttpPost] [HttpPost]
[ProducesResponseType<Account>(StatusCodes.Status200OK)] [ProducesResponseType<SnAccount>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<Account>> CreateAccount([FromBody] AccountCreateRequest request) public async Task<ActionResult<SnAccount>> CreateAccount([FromBody] AccountCreateRequest request)
{ {
if (!await auth.ValidateCaptcha(request.CaptchaToken)) if (!await auth.ValidateCaptcha(request.CaptchaToken))
return BadRequest(ApiError.Validation(new Dictionary<string, string[]> return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
@@ -194,11 +195,12 @@ public class AccountController(
public bool IsAutomated { get; set; } = false; public bool IsAutomated { get; set; } = false;
[MaxLength(1024)] public string? Label { get; set; } [MaxLength(1024)] public string? Label { get; set; }
[MaxLength(4096)] public string? AppIdentifier { get; set; } [MaxLength(4096)] public string? AppIdentifier { get; set; }
public Dictionary<string, object>? Meta { get; set; }
public Instant? ClearedAt { get; set; } public Instant? ClearedAt { get; set; }
} }
[HttpGet("{name}/statuses")] [HttpGet("{name}/statuses")]
public async Task<ActionResult<Status>> GetOtherStatus(string name) public async Task<ActionResult<SnAccountStatus>> GetOtherStatus(string name)
{ {
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name); var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name);
if (account is null) if (account is null)
@@ -253,7 +255,7 @@ public class AccountController(
} }
[HttpGet("search")] [HttpGet("search")]
public async Task<List<Account>> Search([FromQuery] string query, [FromQuery] int take = 20) public async Task<List<SnAccount>> Search([FromQuery] string query, [FromQuery] int take = 20)
{ {
if (string.IsNullOrWhiteSpace(query)) if (string.IsNullOrWhiteSpace(query))
return []; return [];

View File

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

View File

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

View File

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

View File

@@ -160,6 +160,26 @@ public class AccountServiceGrpc(
return response; return response;
} }
public override async Task<GetAccountBatchResponse> SearchAccount(SearchAccountRequest request, ServerCallContext context)
{
var accounts = await _db.Accounts
.AsNoTracking()
.Where(a => EF.Functions.ILike(a.Name, $"%{request.Query}%"))
.Include(a => a.Profile)
.ToListAsync();
var perks = await subscriptions.GetPerkSubscriptionsAsync(
accounts.Select(x => x.Id).ToList()
);
foreach (var account in accounts)
if (perks.TryGetValue(account.Id, out var perk))
account.PerkSubscription = perk?.ToReference();
var response = new GetAccountBatchResponse();
response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue()));
return response;
}
public override async Task<ListAccountsResponse> ListAccounts(ListAccountsRequest request, public override async Task<ListAccountsResponse> ListAccounts(ListAccountsRequest request,
ServerCallContext context) ServerCallContext context)
{ {
@@ -236,7 +256,7 @@ public class AccountServiceGrpc(
var relationship = await relationships.GetRelationship( var relationship = await relationships.GetRelationship(
Guid.Parse(request.AccountId), Guid.Parse(request.AccountId),
Guid.Parse(request.RelatedId), Guid.Parse(request.RelatedId),
status: (RelationshipStatus?)request.Status status: (Shared.Models.RelationshipStatus?)request.Status
); );
return new GetRelationshipResponse return new GetRelationshipResponse
{ {
@@ -256,7 +276,7 @@ public class AccountServiceGrpc(
hasRelationship = await relationships.HasRelationshipWithStatus( hasRelationship = await relationships.HasRelationshipWithStatus(
Guid.Parse(request.AccountId), Guid.Parse(request.AccountId),
Guid.Parse(request.RelatedId), Guid.Parse(request.RelatedId),
(RelationshipStatus)request.Status (Shared.Models.RelationshipStatus)request.Status
); );
return new BoolValue { Value = hasRelationship }; return new BoolValue { Value = hasRelationship };
} }

View File

@@ -1,46 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Proto;
using NodaTime.Serialization.Protobuf;
using Point = NetTopologySuite.Geometries.Point;
namespace DysonNetwork.Pass.Account;
public class ActionLog : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(4096)] public string Action { get; set; } = null!;
[Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
[MaxLength(512)] public string? UserAgent { get; set; }
[MaxLength(128)] public string? IpAddress { get; set; }
[Column(TypeName = "jsonb")] public GeoPoint? Location { get; set; }
public Guid AccountId { get; set; }
public Account Account { get; set; } = null!;
public Guid? SessionId { get; set; }
public Shared.Proto.ActionLog ToProtoValue()
{
var protoLog = new Shared.Proto.ActionLog
{
Id = Id.ToString(),
Action = Action,
UserAgent = UserAgent ?? string.Empty,
IpAddress = IpAddress ?? string.Empty,
Location = Location?.ToString() ?? string.Empty,
AccountId = AccountId.ToString(),
CreatedAt = CreatedAt.ToTimestamp()
};
// Convert Meta dictionary to Struct
protoLog.Meta.Add(GrpcTypeHelper.ConvertToValueMap(Meta));
if (SessionId.HasValue)
protoLog.SessionId = SessionId.Value.ToString();
return protoLog;
}
}

View File

@@ -1,5 +1,6 @@
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.GeoIp; using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Pass.Account; namespace DysonNetwork.Pass.Account;
@@ -7,7 +8,7 @@ public class ActionLogService(GeoIpService geo, FlushBufferService fbs)
{ {
public void CreateActionLog(Guid accountId, string action, Dictionary<string, object?> meta) public void CreateActionLog(Guid accountId, string action, Dictionary<string, object?> meta)
{ {
var log = new ActionLog var log = new SnActionLog
{ {
Action = action, Action = action,
AccountId = accountId, AccountId = accountId,
@@ -18,9 +19,9 @@ public class ActionLogService(GeoIpService geo, FlushBufferService fbs)
} }
public void CreateActionLogFromRequest(string action, Dictionary<string, object> meta, HttpRequest request, public void CreateActionLogFromRequest(string action, Dictionary<string, object> meta, HttpRequest request,
Account? account = null) SnAccount? account = null)
{ {
var log = new ActionLog var log = new SnActionLog
{ {
Action = action, Action = action,
Meta = meta, Meta = meta,
@@ -29,14 +30,14 @@ public class ActionLogService(GeoIpService geo, FlushBufferService fbs)
Location = geo.GetPointFromIp(request.HttpContext.Connection.RemoteIpAddress?.ToString()) Location = geo.GetPointFromIp(request.HttpContext.Connection.RemoteIpAddress?.ToString())
}; };
if (request.HttpContext.Items["CurrentUser"] is Account currentUser) if (request.HttpContext.Items["CurrentUser"] is SnAccount currentUser)
log.AccountId = currentUser.Id; log.AccountId = currentUser.Id;
else if (account != null) else if (account != null)
log.AccountId = account.Id; log.AccountId = account.Id;
else else
throw new ArgumentException("No user context was found"); throw new ArgumentException("No user context was found");
if (request.HttpContext.Items["CurrentSession"] is Auth.AuthSession currentSession) if (request.HttpContext.Items["CurrentSession"] is SnAuthSession currentSession)
log.SessionId = currentSession.Id; log.SessionId = currentSession.Id;
fbs.Enqueue(log); fbs.Enqueue(log);

View File

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

View File

@@ -50,7 +50,7 @@ public class MagicSpellController(AppDatabase db, MagicSpellService sp) : Contro
return NotFound(); return NotFound();
try try
{ {
if (spell.Type == MagicSpellType.AuthPasswordReset && request?.NewPassword is not null) if (spell.Type == Shared.Models.MagicSpellType.AuthPasswordReset && request?.NewPassword is not null)
await sp.ApplyPasswordReset(spell, request.NewPassword); await sp.ApplyPasswordReset(spell, request.NewPassword);
else else
await sp.ApplyMagicSpell(spell); await sp.ApplyMagicSpell(spell);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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