Compare commits
93 Commits
e1459951c4
...
master
Author | SHA1 | Date | |
---|---|---|---|
46ebd92dc1
|
|||
7f8521bb40
|
|||
f01226d91a
|
|||
6cb6dee6be
|
|||
0e9caf67ff
|
|||
ca70bb5487
|
|||
59ed135f20
|
|||
6077f91529
|
|||
5c485bb1c3
|
|||
27d979d77b
|
|||
15687a0c32
|
|||
37ea882ef7
|
|||
e624c2bb3e
|
|||
9631cd3edd
|
|||
f4a659fce5
|
|||
1ded811b36
|
|||
32977d9580
|
|||
aaf29e7228
|
|||
658ef3bddf
|
|||
fc0bc936ce
|
|||
3850ae6a8e
|
|||
21c99567b4
|
|||
1315c7f4d4
|
|||
630a532d98
|
|||
b9bb180113
|
|||
04d74d0d70
|
|||
6a8a0ed491
|
|||
0f835845bf
|
|||
c5d8a8d07f
|
|||
95e2ba1136
|
|||
1176fde8b4
|
|||
e634968e00
|
|||
282a1dbddc
|
|||
c64adace24
|
|||
8ac0b28c66
|
|||
8f71d7f9e5
|
|||
c435e63917
|
|||
243159e4cc
|
|||
42dad7095a
|
|||
d1efcdede8
|
|||
47680475b3
|
|||
6632d43f32
|
|||
29c4dcd71c
|
|||
e7aa887715
|
|||
0f05633996
|
|||
966af08a33
|
|||
b25b90a074
|
|||
dcbefeaaab
|
|||
eb83a0392a
|
|||
85fefcf724
|
|||
d17c26a228
|
|||
2e5ef8ff94
|
|||
7a5f410e36
|
|||
0b4e8a9777
|
|||
30fd912281
|
|||
5bf58f0194
|
|||
8e3e3f09df
|
|||
fa24f14c05
|
|||
a93b633e84
|
|||
97a7b876db
|
|||
909fe173c2
|
|||
58a44e8af4
|
|||
1075177511
|
|||
78f8a9e638
|
|||
9ce31c4dd8
|
|||
e70d8371f8
|
|||
51b6f7309e
|
|||
d75876a772
|
|||
4910c3296b
|
|||
7b924fa075
|
|||
d69c9f9623
|
|||
a88d828e21
|
|||
14c93d372e
|
|||
adf371a72e
|
|||
c03f2472fa
|
|||
50efe62bac
|
|||
7bc94a9646
|
|||
d9fe1273b5
|
|||
ff9d490869
|
|||
266312e97e
|
|||
7087736e31
|
|||
82bf1608fd
|
|||
3b3287db0b
|
|||
4573d9395f
|
|||
a8c99b3128
|
|||
fdd7bd3c9d
|
|||
b785d0098b
|
|||
5b31357fe9
|
|||
d5a5721402
|
|||
204640a759
|
|||
e3657386cd
|
|||
f81e3dc9f4
|
|||
b2a0d25ffa
|
@@ -1,3 +1,4 @@
|
|||||||
{
|
{
|
||||||
"appHostPath": "../DysonNetwork.Control/DysonNetwork.Control.csproj"
|
"appHostPath": "../DysonNetwork.Control/DysonNetwork.Control.csproj"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
5
.editorconfig
Normal file
5
.editorconfig
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
3
.env
3
.env
@@ -33,3 +33,6 @@ SPHERE_IMAGE=sphere:latest
|
|||||||
|
|
||||||
# Container image name for develop
|
# Container image name for develop
|
||||||
DEVELOP_IMAGE=develop:latest
|
DEVELOP_IMAGE=develop:latest
|
||||||
|
|
||||||
|
# Container image name for gateway
|
||||||
|
GATEWAY_IMAGE=gateway:latest
|
||||||
|
2
.github/workflows/docker-build.yml
vendored
2
.github/workflows/docker-build.yml
vendored
@@ -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
613
API_WALLET_FUNDS.md
Normal file
@@ -0,0 +1,613 @@
|
|||||||
|
# Wallet Funds API Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Wallet Funds API provides red packet functionality for the DysonNetwork platform, allowing users to create and distribute funds among multiple recipients with expiration and claiming mechanisms.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
All endpoints require Bearer token authentication:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer {jwt_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Types
|
||||||
|
|
||||||
|
### Enums
|
||||||
|
|
||||||
|
#### FundSplitType
|
||||||
|
```typescript
|
||||||
|
enum FundSplitType {
|
||||||
|
Even = 0, // Equal distribution
|
||||||
|
Random = 1 // Lucky draw distribution
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### FundStatus
|
||||||
|
```typescript
|
||||||
|
enum FundStatus {
|
||||||
|
Created = 0, // Fund created, waiting for claims
|
||||||
|
PartiallyReceived = 1, // Some recipients claimed
|
||||||
|
FullyReceived = 2, // All recipients claimed
|
||||||
|
Expired = 3, // Fund expired, unclaimed amounts refunded
|
||||||
|
Refunded = 4 // Legacy status
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request/Response Models
|
||||||
|
|
||||||
|
#### CreateFundRequest
|
||||||
|
```typescript
|
||||||
|
interface CreateFundRequest {
|
||||||
|
recipientAccountIds: string[]; // UUIDs of recipients
|
||||||
|
currency: string; // e.g., "points", "golds"
|
||||||
|
totalAmount: number; // Total amount to distribute
|
||||||
|
splitType: FundSplitType; // Even or Random
|
||||||
|
message?: string; // Optional message
|
||||||
|
expirationHours?: number; // Optional: hours until expiration (default: 24)
|
||||||
|
pinCode: string; // Required: 6-digit PIN code for security
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### SnWalletFund
|
||||||
|
```typescript
|
||||||
|
interface SnWalletFund {
|
||||||
|
id: string; // UUID
|
||||||
|
currency: string;
|
||||||
|
totalAmount: number;
|
||||||
|
splitType: FundSplitType;
|
||||||
|
status: FundStatus;
|
||||||
|
message?: string;
|
||||||
|
creatorAccountId: string; // UUID
|
||||||
|
creatorAccount: SnAccount; // Creator account details (includes profile)
|
||||||
|
recipients: SnWalletFundRecipient[];
|
||||||
|
expiredAt: string; // ISO 8601 timestamp
|
||||||
|
createdAt: string; // ISO 8601 timestamp
|
||||||
|
updatedAt: string; // ISO 8601 timestamp
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### SnWalletFundRecipient
|
||||||
|
```typescript
|
||||||
|
interface SnWalletFundRecipient {
|
||||||
|
id: string; // UUID
|
||||||
|
fundId: string; // UUID
|
||||||
|
recipientAccountId: string; // UUID
|
||||||
|
recipientAccount: SnAccount; // Recipient account details (includes profile)
|
||||||
|
amount: number; // Allocated amount
|
||||||
|
isReceived: boolean;
|
||||||
|
receivedAt?: string; // ISO 8601 timestamp (if claimed)
|
||||||
|
createdAt: string; // ISO 8601 timestamp
|
||||||
|
updatedAt: string; // ISO 8601 timestamp
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### SnWalletTransaction
|
||||||
|
```typescript
|
||||||
|
interface SnWalletTransaction {
|
||||||
|
id: string; // UUID
|
||||||
|
payerWalletId?: string; // UUID (null for system transfers)
|
||||||
|
payeeWalletId?: string; // UUID (null for system transfers)
|
||||||
|
currency: string;
|
||||||
|
amount: number;
|
||||||
|
remarks?: string;
|
||||||
|
type: TransactionType;
|
||||||
|
createdAt: string; // ISO 8601 timestamp
|
||||||
|
updatedAt: string; // ISO 8601 timestamp
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Error Response
|
||||||
|
```typescript
|
||||||
|
interface ErrorResponse {
|
||||||
|
type: string; // Error type
|
||||||
|
title: string; // Error title
|
||||||
|
status: number; // HTTP status code
|
||||||
|
detail: string; // Error details
|
||||||
|
instance?: string; // Request instance
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### 1. Create Fund
|
||||||
|
|
||||||
|
Creates a new fund (red packet) for distribution among recipients.
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/wallets/funds`
|
||||||
|
|
||||||
|
**Request Body:** `CreateFundRequest`
|
||||||
|
|
||||||
|
**Response:** `SnWalletFund` (201 Created)
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
curl -X POST "/api/wallets/funds" \
|
||||||
|
-H "Authorization: Bearer {token}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"recipientAccountIds": [
|
||||||
|
"550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
"550e8400-e29b-41d4-a716-446655440002"
|
||||||
|
],
|
||||||
|
"currency": "points",
|
||||||
|
"totalAmount": 100.00,
|
||||||
|
"splitType": "Even",
|
||||||
|
"message": "Happy New Year! 🎉",
|
||||||
|
"expirationHours": 48,
|
||||||
|
"pinCode": "123456"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440003",
|
||||||
|
"currency": "points",
|
||||||
|
"totalAmount": 100.00,
|
||||||
|
"splitType": 0,
|
||||||
|
"status": 0,
|
||||||
|
"message": "Happy New Year! 🎉",
|
||||||
|
"creatorAccountId": "550e8400-e29b-41d4-a716-446655440004",
|
||||||
|
"creatorAccount": {
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440004",
|
||||||
|
"username": "creator_user"
|
||||||
|
},
|
||||||
|
"recipients": [
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440005",
|
||||||
|
"fundId": "550e8400-e29b-41d4-a716-446655440003",
|
||||||
|
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"amount": 33.34,
|
||||||
|
"isReceived": false,
|
||||||
|
"createdAt": "2025-10-03T22:00:00Z",
|
||||||
|
"updatedAt": "2025-10-03T22:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440006",
|
||||||
|
"fundId": "550e8400-e29b-41d4-a716-446655440003",
|
||||||
|
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
"amount": 33.33,
|
||||||
|
"isReceived": false,
|
||||||
|
"createdAt": "2025-10-03T22:00:00Z",
|
||||||
|
"updatedAt": "2025-10-03T22:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440007",
|
||||||
|
"fundId": "550e8400-e29b-41d4-a716-446655440003",
|
||||||
|
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440002",
|
||||||
|
"amount": 33.33,
|
||||||
|
"isReceived": false,
|
||||||
|
"createdAt": "2025-10-03T22:00:00Z",
|
||||||
|
"updatedAt": "2025-10-03T22:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expiredAt": "2025-10-05T22:00:00Z",
|
||||||
|
"createdAt": "2025-10-03T22:00:00Z",
|
||||||
|
"updatedAt": "2025-10-03T22:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
- `400 Bad Request`: Invalid parameters, insufficient funds, invalid recipients
|
||||||
|
- `401 Unauthorized`: Missing or invalid authentication
|
||||||
|
- `403 Forbidden`: Invalid PIN code
|
||||||
|
- `422 Unprocessable Entity`: Business logic violations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Get Funds
|
||||||
|
|
||||||
|
Retrieves funds that the authenticated user is involved in (as creator or recipient).
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/wallets/funds`
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `offset` (number, optional): Pagination offset (default: 0)
|
||||||
|
- `take` (number, optional): Number of items to return (default: 20, max: 100)
|
||||||
|
- `status` (FundStatus, optional): Filter by fund status
|
||||||
|
|
||||||
|
**Response:** `SnWalletFund[]` (200 OK)
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
- `X-Total`: Total number of funds matching the criteria
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
curl -X GET "/api/wallets/funds?offset=0&take=10&status=0" \
|
||||||
|
-H "Authorization: Bearer {token}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440003",
|
||||||
|
"currency": "points",
|
||||||
|
"totalAmount": 100.00,
|
||||||
|
"splitType": 0,
|
||||||
|
"status": 0,
|
||||||
|
"message": "Happy New Year! 🎉",
|
||||||
|
"creatorAccountId": "550e8400-e29b-41d4-a716-446655440004",
|
||||||
|
"creatorAccount": {
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440004",
|
||||||
|
"username": "creator_user"
|
||||||
|
},
|
||||||
|
"recipients": [
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440005",
|
||||||
|
"fundId": "550e8400-e29b-41d4-a716-446655440003",
|
||||||
|
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"amount": 33.34,
|
||||||
|
"isReceived": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expiredAt": "2025-10-05T22:00:00Z",
|
||||||
|
"createdAt": "2025-10-03T22:00:00Z",
|
||||||
|
"updatedAt": "2025-10-03T22:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
- `401 Unauthorized`: Missing or invalid authentication
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Get Fund
|
||||||
|
|
||||||
|
Retrieves details of a specific fund.
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/wallets/funds/{id}`
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `id` (string): Fund UUID
|
||||||
|
|
||||||
|
**Response:** `SnWalletFund` (200 OK)
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
curl -X GET "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003" \
|
||||||
|
-H "Authorization: Bearer {token}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response:** (Same as create fund response)
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
- `401 Unauthorized`: Missing or invalid authentication
|
||||||
|
- `403 Forbidden`: User doesn't have permission to view this fund
|
||||||
|
- `404 Not Found`: Fund not found
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Receive Fund
|
||||||
|
|
||||||
|
Claims the authenticated user's portion of a fund.
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/wallets/funds/{id}/receive`
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
- `id` (string): Fund UUID
|
||||||
|
|
||||||
|
**Response:** `SnWalletTransaction` (200 OK)
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
curl -X POST "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive" \
|
||||||
|
-H "Authorization: Bearer {token}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440008",
|
||||||
|
"payerWalletId": null,
|
||||||
|
"payeeWalletId": "550e8400-e29b-41d4-a716-446655440009",
|
||||||
|
"currency": "points",
|
||||||
|
"amount": 33.34,
|
||||||
|
"remarks": "Received fund portion from 550e8400-e29b-41d4-a716-446655440004",
|
||||||
|
"type": 1,
|
||||||
|
"createdAt": "2025-10-03T22:05:00Z",
|
||||||
|
"updatedAt": "2025-10-03T22:05:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
- `400 Bad Request`: Fund expired, already claimed, not a recipient
|
||||||
|
- `401 Unauthorized`: Missing or invalid authentication
|
||||||
|
- `404 Not Found`: Fund not found
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Get Wallet Overview
|
||||||
|
|
||||||
|
Retrieves a summarized overview of wallet transactions grouped by type for graphing/charting purposes.
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/wallets/overview`
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `startDate` (string, optional): Start date in ISO 8601 format (e.g., "2025-01-01T00:00:00Z")
|
||||||
|
- `endDate` (string, optional): End date in ISO 8601 format (e.g., "2025-12-31T23:59:59Z")
|
||||||
|
|
||||||
|
**Response:** `WalletOverview` (200 OK)
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
curl -X GET "/api/wallets/overview?startDate=2025-01-01T00:00:00Z&endDate=2025-12-31T23:59:59Z" \
|
||||||
|
-H "Authorization: Bearer {token}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"accountId": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"startDate": "2025-01-01T00:00:00.0000000Z",
|
||||||
|
"endDate": "2025-12-31T23:59:59.0000000Z",
|
||||||
|
"summary": {
|
||||||
|
"System": {
|
||||||
|
"type": "System",
|
||||||
|
"currencies": {
|
||||||
|
"points": {
|
||||||
|
"currency": "points",
|
||||||
|
"income": 150.00,
|
||||||
|
"spending": 0.00,
|
||||||
|
"net": 150.00
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Transfer": {
|
||||||
|
"type": "Transfer",
|
||||||
|
"currencies": {
|
||||||
|
"points": {
|
||||||
|
"currency": "points",
|
||||||
|
"income": 25.00,
|
||||||
|
"spending": 75.00,
|
||||||
|
"net": -50.00
|
||||||
|
},
|
||||||
|
"golds": {
|
||||||
|
"currency": "golds",
|
||||||
|
"income": 0.00,
|
||||||
|
"spending": 10.00,
|
||||||
|
"net": -10.00
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Order": {
|
||||||
|
"type": "Order",
|
||||||
|
"currencies": {
|
||||||
|
"points": {
|
||||||
|
"currency": "points",
|
||||||
|
"income": 0.00,
|
||||||
|
"spending": 200.00,
|
||||||
|
"net": -200.00
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"totalIncome": 175.00,
|
||||||
|
"totalSpending": 285.00,
|
||||||
|
"netTotal": -110.00
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response Fields:**
|
||||||
|
- `accountId`: User's account UUID
|
||||||
|
- `startDate`/`endDate`: Date range applied (ISO 8601 format)
|
||||||
|
- `summary`: Object keyed by transaction type
|
||||||
|
- `type`: Transaction type name
|
||||||
|
- `currencies`: Object keyed by currency code
|
||||||
|
- `currency`: Currency name
|
||||||
|
- `income`: Total money received
|
||||||
|
- `spending`: Total money spent
|
||||||
|
- `net`: Income minus spending
|
||||||
|
- `totalIncome`: Sum of all income across all types/currencies
|
||||||
|
- `totalSpending`: Sum of all spending across all types/currencies
|
||||||
|
- `netTotal`: Overall net (totalIncome - totalSpending)
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
- `401 Unauthorized`: Missing or invalid authentication
|
||||||
|
|
||||||
|
## Error Codes
|
||||||
|
|
||||||
|
### Common Error Types
|
||||||
|
|
||||||
|
#### Validation Errors
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
|
||||||
|
"title": "Bad Request",
|
||||||
|
"status": 400,
|
||||||
|
"detail": "At least one recipient is required",
|
||||||
|
"instance": "/api/wallets/funds"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Insufficient Funds
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
|
||||||
|
"title": "Bad Request",
|
||||||
|
"status": 400,
|
||||||
|
"detail": "Insufficient funds",
|
||||||
|
"instance": "/api/wallets/funds"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Fund Not Available
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
|
||||||
|
"title": "Bad Request",
|
||||||
|
"status": 400,
|
||||||
|
"detail": "Fund is no longer available",
|
||||||
|
"instance": "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Already Claimed
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
|
||||||
|
"title": "Bad Request",
|
||||||
|
"status": 400,
|
||||||
|
"detail": "You have already received this fund",
|
||||||
|
"instance": "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
- **Create Fund**: 10 requests per minute per user
|
||||||
|
- **Get Funds**: 60 requests per minute per user
|
||||||
|
- **Get Fund**: 60 requests per minute per user
|
||||||
|
- **Receive Fund**: 30 requests per minute per user
|
||||||
|
|
||||||
|
## Webhooks/Notifications
|
||||||
|
|
||||||
|
The system integrates with the platform's notification system:
|
||||||
|
|
||||||
|
- **Fund Created**: Creator receives confirmation
|
||||||
|
- **Fund Claimed**: Creator receives notification when someone claims
|
||||||
|
- **Fund Expired**: Creator receives refund notification
|
||||||
|
|
||||||
|
## SDK Examples
|
||||||
|
|
||||||
|
### JavaScript/TypeScript
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Create a fund
|
||||||
|
const createFund = async (fundData: CreateFundRequest): Promise<SnWalletFund> => {
|
||||||
|
const response = await fetch('/api/wallets/funds', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(fundData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get user's funds
|
||||||
|
const getFunds = async (params?: {
|
||||||
|
offset?: number;
|
||||||
|
take?: number;
|
||||||
|
status?: FundStatus;
|
||||||
|
}): Promise<SnWalletFund[]> => {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (params?.offset) queryParams.set('offset', params.offset.toString());
|
||||||
|
if (params?.take) queryParams.set('take', params.take.toString());
|
||||||
|
if (params?.status !== undefined) queryParams.set('status', params.status.toString());
|
||||||
|
|
||||||
|
const response = await fetch(`/api/wallets/funds?${queryParams}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Claim a fund
|
||||||
|
const receiveFund = async (fundId: string): Promise<SnWalletTransaction> => {
|
||||||
|
const response = await fetch(`/api/wallets/funds/${fundId}/receive`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
from typing import List, Optional
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
class FundSplitType(Enum):
|
||||||
|
EVEN = 0
|
||||||
|
RANDOM = 1
|
||||||
|
|
||||||
|
class FundStatus(Enum):
|
||||||
|
CREATED = 0
|
||||||
|
PARTIALLY_RECEIVED = 1
|
||||||
|
FULLY_RECEIVED = 2
|
||||||
|
EXPIRED = 3
|
||||||
|
REFUNDED = 4
|
||||||
|
|
||||||
|
def create_fund(token: str, fund_data: dict) -> dict:
|
||||||
|
"""Create a new fund"""
|
||||||
|
response = requests.post(
|
||||||
|
'/api/wallets/funds',
|
||||||
|
json=fund_data,
|
||||||
|
headers={
|
||||||
|
'Authorization': f'Bearer {token}',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def get_funds(
|
||||||
|
token: str,
|
||||||
|
offset: int = 0,
|
||||||
|
take: int = 20,
|
||||||
|
status: Optional[FundStatus] = None
|
||||||
|
) -> List[dict]:
|
||||||
|
"""Get user's funds"""
|
||||||
|
params = {'offset': offset, 'take': take}
|
||||||
|
if status is not None:
|
||||||
|
params['status'] = status.value
|
||||||
|
|
||||||
|
response = requests.get(
|
||||||
|
'/api/wallets/funds',
|
||||||
|
params=params,
|
||||||
|
headers={'Authorization': f'Bearer {token}'}
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def receive_fund(token: str, fund_id: str) -> dict:
|
||||||
|
"""Claim a fund portion"""
|
||||||
|
response = requests.post(
|
||||||
|
f'/api/wallets/funds/{fund_id}/receive',
|
||||||
|
headers={'Authorization': f'Bearer {token}'}
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### Version 1.0.0
|
||||||
|
- Initial release with basic red packet functionality
|
||||||
|
- Support for even and random split types
|
||||||
|
- 24-hour expiration with automatic refunds
|
||||||
|
- RESTful API endpoints
|
||||||
|
- Comprehensive error handling
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For API support or questions:
|
||||||
|
- Check the main documentation at `README_WALLET_FUNDS.md`
|
||||||
|
- Review error messages for specific guidance
|
||||||
|
- Contact the development team for technical issues
|
@@ -1,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");
|
||||||
|
|
||||||
|
@@ -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>
|
@@ -10,7 +10,9 @@
|
|||||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||||
"DOTNET_ENVIRONMENT": "Development",
|
"DOTNET_ENVIRONMENT": "Development",
|
||||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21175",
|
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21175",
|
||||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22189"
|
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22189",
|
||||||
|
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21260",
|
||||||
|
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22052"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"http": {
|
"http": {
|
||||||
@@ -22,7 +24,8 @@
|
|||||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||||
"DOTNET_ENVIRONMENT": "Development",
|
"DOTNET_ENVIRONMENT": "Development",
|
||||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19163",
|
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19163",
|
||||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20185"
|
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20185",
|
||||||
|
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:22108"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,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)
|
||||||
{
|
{
|
||||||
|
@@ -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>
|
||||||
|
|
||||||
|
@@ -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);
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
using DysonNetwork.Shared.Models;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace DysonNetwork.Develop.Identity;
|
namespace DysonNetwork.Develop.Identity;
|
||||||
@@ -7,7 +8,7 @@ namespace DysonNetwork.Develop.Identity;
|
|||||||
public class BotAccountPublicController(BotAccountService botService, DeveloperService developerService) : ControllerBase
|
public class BotAccountPublicController(BotAccountService botService, DeveloperService developerService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("{botId:guid}")]
|
[HttpGet("{botId:guid}")]
|
||||||
public async Task<ActionResult<BotAccount>> GetBotTransparentInfo([FromRoute] Guid botId)
|
public async Task<ActionResult<SnBotAccount>> GetBotTransparentInfo([FromRoute] Guid botId)
|
||||||
{
|
{
|
||||||
var bot = await botService.GetBotByIdAsync(botId);
|
var bot = await botService.GetBotByIdAsync(botId);
|
||||||
if (bot is null) return NotFound("Bot not found");
|
if (bot is null) return NotFound("Bot not found");
|
||||||
@@ -21,7 +22,7 @@ public class BotAccountPublicController(BotAccountService botService, DeveloperS
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{botId:guid}/developer")]
|
[HttpGet("{botId:guid}/developer")]
|
||||||
public async Task<ActionResult<Developer>> GetBotDeveloper([FromRoute] Guid botId)
|
public async Task<ActionResult<SnDeveloper>> GetBotDeveloper([FromRoute] Guid botId)
|
||||||
{
|
{
|
||||||
var bot = await botService.GetBotByIdAsync(botId);
|
var bot = await botService.GetBotByIdAsync(botId);
|
||||||
if (bot is null) return NotFound("Bot not found");
|
if (bot is null) return NotFound("Bot not found");
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
using DysonNetwork.Develop.Project;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Data;
|
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using DysonNetwork.Shared.Registry;
|
using DysonNetwork.Shared.Registry;
|
||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -1,5 +1,6 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using DysonNetwork.Develop.Project;
|
using DysonNetwork.Develop.Project;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -18,9 +19,9 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
|||||||
[MaxLength(4096)] string? Description,
|
[MaxLength(4096)] string? Description,
|
||||||
string? PictureId,
|
string? PictureId,
|
||||||
string? BackgroundId,
|
string? BackgroundId,
|
||||||
CustomAppStatus? Status,
|
Shared.Models.CustomAppStatus? Status,
|
||||||
CustomAppLinks? Links,
|
SnCustomAppLinks? Links,
|
||||||
CustomAppOauthConfig? OauthConfig
|
SnCustomAppOauthConfig? OauthConfig
|
||||||
);
|
);
|
||||||
|
|
||||||
public record CreateSecretRequest(
|
public record CreateSecretRequest(
|
||||||
@@ -50,7 +51,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
|||||||
if (developer is null) return NotFound();
|
if (developer is null) return NotFound();
|
||||||
|
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, PublisherMemberRole.Viewer))
|
if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, Shared.Proto.PublisherMemberRole.Viewer))
|
||||||
return StatusCode(403, "You must be a viewer of the developer to list custom apps");
|
return StatusCode(403, "You must be a viewer of the developer to list custom apps");
|
||||||
|
|
||||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||||
@@ -72,7 +73,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
|||||||
if (developer is null) return NotFound();
|
if (developer is null) return NotFound();
|
||||||
|
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, PublisherMemberRole.Viewer))
|
if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, Shared.Proto.PublisherMemberRole.Viewer))
|
||||||
return StatusCode(403, "You must be a viewer of the developer to list custom apps");
|
return StatusCode(403, "You must be a viewer of the developer to list custom apps");
|
||||||
|
|
||||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||||
@@ -99,7 +100,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
|||||||
if (developer is null)
|
if (developer is null)
|
||||||
return NotFound("Developer not found");
|
return NotFound("Developer not found");
|
||||||
|
|
||||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor))
|
||||||
return StatusCode(403, "You must be an editor of the developer to create a custom app");
|
return StatusCode(403, "You must be an editor of the developer to create a custom app");
|
||||||
|
|
||||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||||
@@ -143,7 +144,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
|||||||
if (developer is null)
|
if (developer is null)
|
||||||
return NotFound("Developer not found");
|
return NotFound("Developer not found");
|
||||||
|
|
||||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor))
|
||||||
return StatusCode(403, "You must be an editor of the developer to update a custom app");
|
return StatusCode(403, "You must be an editor of the developer to update a custom app");
|
||||||
|
|
||||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||||
@@ -180,7 +181,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
|||||||
if (developer is null)
|
if (developer is null)
|
||||||
return NotFound("Developer not found");
|
return NotFound("Developer not found");
|
||||||
|
|
||||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor))
|
||||||
return StatusCode(403, "You must be an editor of the developer to delete a custom app");
|
return StatusCode(403, "You must be an editor of the developer to delete a custom app");
|
||||||
|
|
||||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||||
@@ -212,7 +213,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
|||||||
if (developer is null)
|
if (developer is null)
|
||||||
return NotFound("Developer not found");
|
return NotFound("Developer not found");
|
||||||
|
|
||||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor))
|
||||||
return StatusCode(403, "You must be an editor of the developer to view app secrets");
|
return StatusCode(403, "You must be an editor of the developer to view app secrets");
|
||||||
|
|
||||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||||
@@ -250,7 +251,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
|||||||
if (developer is null)
|
if (developer is null)
|
||||||
return NotFound("Developer not found");
|
return NotFound("Developer not found");
|
||||||
|
|
||||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor))
|
||||||
return StatusCode(403, "You must be an editor of the developer to create app secrets");
|
return StatusCode(403, "You must be an editor of the developer to create app secrets");
|
||||||
|
|
||||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||||
@@ -263,7 +264,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var secret = await customApps.CreateAppSecretAsync(new CustomAppSecret
|
var secret = await customApps.CreateAppSecretAsync(new SnCustomAppSecret
|
||||||
{
|
{
|
||||||
AppId = appId,
|
AppId = appId,
|
||||||
Description = request.Description,
|
Description = request.Description,
|
||||||
@@ -309,7 +310,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
|||||||
if (developer is null)
|
if (developer is null)
|
||||||
return NotFound("Developer not found");
|
return NotFound("Developer not found");
|
||||||
|
|
||||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor))
|
||||||
return StatusCode(403, "You must be an editor of the developer to view app secrets");
|
return StatusCode(403, "You must be an editor of the developer to view app secrets");
|
||||||
|
|
||||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||||
@@ -350,7 +351,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
|||||||
if (developer is null)
|
if (developer is null)
|
||||||
return NotFound("Developer not found");
|
return NotFound("Developer not found");
|
||||||
|
|
||||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor))
|
||||||
return StatusCode(403, "You must be an editor of the developer to delete app secrets");
|
return StatusCode(403, "You must be an editor of the developer to delete app secrets");
|
||||||
|
|
||||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||||
@@ -388,7 +389,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
|||||||
if (developer is null)
|
if (developer is null)
|
||||||
return NotFound("Developer not found");
|
return NotFound("Developer not found");
|
||||||
|
|
||||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor))
|
||||||
return StatusCode(403, "You must be an editor of the developer to rotate app secrets");
|
return StatusCode(403, "You must be an editor of the developer to rotate app secrets");
|
||||||
|
|
||||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||||
@@ -401,7 +402,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var secret = await customApps.RotateAppSecretAsync(new CustomAppSecret
|
var secret = await customApps.RotateAppSecretAsync(new SnCustomAppSecret
|
||||||
{
|
{
|
||||||
Id = secretId,
|
Id = secretId,
|
||||||
AppId = appId,
|
AppId = appId,
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
using DysonNetwork.Develop.Project;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Data;
|
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
@@ -13,7 +12,7 @@ public class CustomAppService(
|
|||||||
FileService.FileServiceClient files
|
FileService.FileServiceClient files
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
public async Task<CustomApp?> CreateAppAsync(
|
public async Task<SnCustomApp?> CreateAppAsync(
|
||||||
Guid projectId,
|
Guid projectId,
|
||||||
CustomAppController.CustomAppRequest request
|
CustomAppController.CustomAppRequest request
|
||||||
)
|
)
|
||||||
@@ -25,12 +24,12 @@ public class CustomAppService(
|
|||||||
if (project == null)
|
if (project == null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var app = new CustomApp
|
var app = new SnCustomApp
|
||||||
{
|
{
|
||||||
Slug = request.Slug!,
|
Slug = request.Slug!,
|
||||||
Name = request.Name!,
|
Name = request.Name!,
|
||||||
Description = request.Description,
|
Description = request.Description,
|
||||||
Status = request.Status ?? CustomAppStatus.Developing,
|
Status = request.Status ?? Shared.Models.CustomAppStatus.Developing,
|
||||||
Links = request.Links,
|
Links = request.Links,
|
||||||
OauthConfig = request.OauthConfig,
|
OauthConfig = request.OauthConfig,
|
||||||
ProjectId = projectId
|
ProjectId = projectId
|
||||||
@@ -46,7 +45,7 @@ public class CustomAppService(
|
|||||||
);
|
);
|
||||||
if (picture is null)
|
if (picture is null)
|
||||||
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
|
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
|
||||||
app.Picture = CloudFileReferenceObject.FromProtoValue(picture);
|
app.Picture = SnCloudFileReferenceObject.FromProtoValue(picture);
|
||||||
|
|
||||||
// Create a new reference
|
// Create a new reference
|
||||||
await fileRefs.CreateReferenceAsync(
|
await fileRefs.CreateReferenceAsync(
|
||||||
@@ -65,7 +64,7 @@ public class CustomAppService(
|
|||||||
);
|
);
|
||||||
if (background is null)
|
if (background is null)
|
||||||
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
|
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
|
||||||
app.Background = CloudFileReferenceObject.FromProtoValue(background);
|
app.Background = SnCloudFileReferenceObject.FromProtoValue(background);
|
||||||
|
|
||||||
// Create a new reference
|
// Create a new reference
|
||||||
await fileRefs.CreateReferenceAsync(
|
await fileRefs.CreateReferenceAsync(
|
||||||
@@ -84,7 +83,7 @@ public class CustomAppService(
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<CustomApp?> GetAppAsync(Guid id, Guid? projectId = null)
|
public async Task<SnCustomApp?> GetAppAsync(Guid id, Guid? projectId = null)
|
||||||
{
|
{
|
||||||
var query = db.CustomApps.AsQueryable();
|
var query = db.CustomApps.AsQueryable();
|
||||||
|
|
||||||
@@ -96,7 +95,7 @@ public class CustomAppService(
|
|||||||
return await query.FirstOrDefaultAsync(a => a.Id == id);
|
return await query.FirstOrDefaultAsync(a => a.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<CustomAppSecret>> GetAppSecretsAsync(Guid appId)
|
public async Task<List<SnCustomAppSecret>> GetAppSecretsAsync(Guid appId)
|
||||||
{
|
{
|
||||||
return await db.CustomAppSecrets
|
return await db.CustomAppSecrets
|
||||||
.Where(s => s.AppId == appId)
|
.Where(s => s.AppId == appId)
|
||||||
@@ -104,13 +103,13 @@ public class CustomAppService(
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<CustomAppSecret?> GetAppSecretAsync(Guid secretId, Guid appId)
|
public async Task<SnCustomAppSecret?> GetAppSecretAsync(Guid secretId, Guid appId)
|
||||||
{
|
{
|
||||||
return await db.CustomAppSecrets
|
return await db.CustomAppSecrets
|
||||||
.FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId);
|
.FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<CustomAppSecret> CreateAppSecretAsync(CustomAppSecret secret)
|
public async Task<SnCustomAppSecret> CreateAppSecretAsync(SnCustomAppSecret secret)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(secret.Secret))
|
if (string.IsNullOrWhiteSpace(secret.Secret))
|
||||||
{
|
{
|
||||||
@@ -141,7 +140,7 @@ public class CustomAppService(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<CustomAppSecret> RotateAppSecretAsync(CustomAppSecret secretUpdate)
|
public async Task<SnCustomAppSecret> RotateAppSecretAsync(SnCustomAppSecret secretUpdate)
|
||||||
{
|
{
|
||||||
var existingSecret = await db.CustomAppSecrets
|
var existingSecret = await db.CustomAppSecrets
|
||||||
.FirstOrDefaultAsync(s => s.Id == secretUpdate.Id && s.AppId == secretUpdate.AppId);
|
.FirstOrDefaultAsync(s => s.Id == secretUpdate.Id && s.AppId == secretUpdate.AppId);
|
||||||
@@ -177,14 +176,14 @@ public class CustomAppService(
|
|||||||
return res.ToString();
|
return res.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<CustomApp>> GetAppsByProjectAsync(Guid projectId)
|
public async Task<List<SnCustomApp>> GetAppsByProjectAsync(Guid projectId)
|
||||||
{
|
{
|
||||||
return await db.CustomApps
|
return await db.CustomApps
|
||||||
.Where(a => a.ProjectId == projectId)
|
.Where(a => a.ProjectId == projectId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<CustomApp?> UpdateAppAsync(CustomApp app, CustomAppController.CustomAppRequest request)
|
public async Task<SnCustomApp?> UpdateAppAsync(SnCustomApp app, CustomAppController.CustomAppRequest request)
|
||||||
{
|
{
|
||||||
if (request.Slug is not null)
|
if (request.Slug is not null)
|
||||||
app.Slug = request.Slug;
|
app.Slug = request.Slug;
|
||||||
@@ -209,7 +208,7 @@ public class CustomAppService(
|
|||||||
);
|
);
|
||||||
if (picture is null)
|
if (picture is null)
|
||||||
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
|
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
|
||||||
app.Picture = CloudFileReferenceObject.FromProtoValue(picture);
|
app.Picture = SnCloudFileReferenceObject.FromProtoValue(picture);
|
||||||
|
|
||||||
// Create a new reference
|
// Create a new reference
|
||||||
await fileRefs.CreateReferenceAsync(
|
await fileRefs.CreateReferenceAsync(
|
||||||
@@ -228,7 +227,7 @@ public class CustomAppService(
|
|||||||
);
|
);
|
||||||
if (background is null)
|
if (background is null)
|
||||||
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
|
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
|
||||||
app.Background = CloudFileReferenceObject.FromProtoValue(background);
|
app.Background = SnCloudFileReferenceObject.FromProtoValue(background);
|
||||||
|
|
||||||
// Create a new reference
|
// Create a new reference
|
||||||
await fileRefs.CreateReferenceAsync(
|
await fileRefs.CreateReferenceAsync(
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -37,7 +38,7 @@ public class CustomAppServiceGrpc(AppDatabase db) : Shared.Proto.CustomAppServic
|
|||||||
if (string.IsNullOrEmpty(request.Secret))
|
if (string.IsNullOrEmpty(request.Secret))
|
||||||
throw new RpcException(new Status(StatusCode.InvalidArgument, "secret required"));
|
throw new RpcException(new Status(StatusCode.InvalidArgument, "secret required"));
|
||||||
|
|
||||||
IQueryable<CustomAppSecret> q = db.CustomAppSecrets;
|
IQueryable<SnCustomAppSecret> q = db.CustomAppSecrets;
|
||||||
switch (request.SecretIdentifierCase)
|
switch (request.SecretIdentifierCase)
|
||||||
{
|
{
|
||||||
case CheckCustomAppSecretRequest.SecretIdentifierOneofCase.SecretId:
|
case CheckCustomAppSecretRequest.SecretIdentifierOneofCase.SecretId:
|
||||||
|
@@ -1,79 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using DysonNetwork.Develop.Project;
|
|
||||||
using DysonNetwork.Shared.Proto;
|
|
||||||
using DysonNetwork.Shared.Data;
|
|
||||||
using VerificationMark = DysonNetwork.Shared.Data.VerificationMark;
|
|
||||||
|
|
||||||
namespace DysonNetwork.Develop.Identity;
|
|
||||||
|
|
||||||
public class Developer
|
|
||||||
{
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
public Guid PublisherId { get; set; }
|
|
||||||
|
|
||||||
[JsonIgnore] public List<DevProject> Projects { get; set; } = [];
|
|
||||||
|
|
||||||
[NotMapped] public PublisherInfo? Publisher { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class PublisherInfo
|
|
||||||
{
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
public PublisherType Type { get; set; }
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
public string Nick { get; set; } = string.Empty;
|
|
||||||
public string? Bio { get; set; }
|
|
||||||
|
|
||||||
public CloudFileReferenceObject? Picture { get; set; }
|
|
||||||
public CloudFileReferenceObject? Background { get; set; }
|
|
||||||
|
|
||||||
public VerificationMark? Verification { get; set; }
|
|
||||||
public Guid? AccountId { get; set; }
|
|
||||||
public Guid? RealmId { get; set; }
|
|
||||||
|
|
||||||
public static PublisherInfo FromProto(Publisher proto)
|
|
||||||
{
|
|
||||||
var info = new PublisherInfo
|
|
||||||
{
|
|
||||||
Id = Guid.Parse(proto.Id),
|
|
||||||
Type = proto.Type == PublisherType.PubIndividual
|
|
||||||
? PublisherType.PubIndividual
|
|
||||||
: PublisherType.PubOrganizational,
|
|
||||||
Name = proto.Name,
|
|
||||||
Nick = proto.Nick,
|
|
||||||
Bio = string.IsNullOrEmpty(proto.Bio) ? null : proto.Bio,
|
|
||||||
Verification = proto.VerificationMark is not null
|
|
||||||
? VerificationMark.FromProtoValue(proto.VerificationMark)
|
|
||||||
: null,
|
|
||||||
AccountId = string.IsNullOrEmpty(proto.AccountId) ? null : Guid.Parse(proto.AccountId),
|
|
||||||
RealmId = string.IsNullOrEmpty(proto.RealmId) ? null : Guid.Parse(proto.RealmId)
|
|
||||||
};
|
|
||||||
|
|
||||||
if (proto.Picture != null)
|
|
||||||
{
|
|
||||||
info.Picture = new CloudFileReferenceObject
|
|
||||||
{
|
|
||||||
Id = proto.Picture.Id,
|
|
||||||
Name = proto.Picture.Name,
|
|
||||||
MimeType = proto.Picture.MimeType,
|
|
||||||
Hash = proto.Picture.Hash,
|
|
||||||
Size = proto.Picture.Size
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (proto.Background != null)
|
|
||||||
{
|
|
||||||
info.Background = new CloudFileReferenceObject
|
|
||||||
{
|
|
||||||
Id = proto.Background.Id,
|
|
||||||
Name = proto.Background.Name,
|
|
||||||
MimeType = proto.Background.MimeType,
|
|
||||||
Hash = proto.Background.Hash,
|
|
||||||
Size = (long)proto.Background.Size
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return info;
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,4 +1,5 @@
|
|||||||
using DysonNetwork.Shared.Auth;
|
using DysonNetwork.Shared.Auth;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -18,7 +19,7 @@ public class DeveloperController(
|
|||||||
: ControllerBase
|
: ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("{name}")]
|
[HttpGet("{name}")]
|
||||||
public async Task<ActionResult<Developer>> GetDeveloper(string name)
|
public async Task<ActionResult<SnDeveloper>> GetDeveloper(string name)
|
||||||
{
|
{
|
||||||
var developer = await ds.GetDeveloperByName(name);
|
var developer = await ds.GetDeveloperByName(name);
|
||||||
if (developer is null) return NotFound();
|
if (developer is null) return NotFound();
|
||||||
@@ -47,7 +48,7 @@ public class DeveloperController(
|
|||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<List<Developer>>> ListJoinedDevelopers()
|
public async Task<ActionResult<List<SnDeveloper>>> ListJoinedDevelopers()
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
@@ -69,16 +70,16 @@ public class DeveloperController(
|
|||||||
[HttpPost("{name}/enroll")]
|
[HttpPost("{name}/enroll")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[RequiredPermission("global", "developers.create")]
|
[RequiredPermission("global", "developers.create")]
|
||||||
public async Task<ActionResult<Developer>> EnrollDeveloperProgram(string name)
|
public async Task<ActionResult<SnDeveloper>> EnrollDeveloperProgram(string name)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
PublisherInfo? pub;
|
SnPublisher? pub;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Name = name });
|
var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Name = name });
|
||||||
pub = PublisherInfo.FromProto(pubResponse.Publisher);
|
pub = SnPublisher.FromProto(pubResponse.Publisher);
|
||||||
} catch (RpcException ex)
|
} catch (RpcException ex)
|
||||||
{
|
{
|
||||||
return NotFound(ex.Status.Detail);
|
return NotFound(ex.Status.Detail);
|
||||||
@@ -89,14 +90,14 @@ public class DeveloperController(
|
|||||||
{
|
{
|
||||||
PublisherId = pub.Id.ToString(),
|
PublisherId = pub.Id.ToString(),
|
||||||
AccountId = currentUser.Id,
|
AccountId = currentUser.Id,
|
||||||
Role = PublisherMemberRole.Owner
|
Role = Shared.Proto.PublisherMemberRole.Owner
|
||||||
});
|
});
|
||||||
if (!permResponse.Valid) return StatusCode(403, "You must be the owner of the publisher to join the developer program");
|
if (!permResponse.Valid) return StatusCode(403, "You must be the owner of the publisher to join the developer program");
|
||||||
|
|
||||||
var hasDeveloper = await db.Developers.AnyAsync(d => d.PublisherId == pub.Id);
|
var hasDeveloper = await db.Developers.AnyAsync(d => d.PublisherId == pub.Id);
|
||||||
if (hasDeveloper) return BadRequest("Publisher is already in the developer program");
|
if (hasDeveloper) return BadRequest("Publisher is already in the developer program");
|
||||||
|
|
||||||
var developer = new Developer
|
var developer = new SnDeveloper
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
PublisherId = pub.Id
|
PublisherId = pub.Id
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -9,22 +10,22 @@ public class DeveloperService(
|
|||||||
PublisherService.PublisherServiceClient ps,
|
PublisherService.PublisherServiceClient ps,
|
||||||
ILogger<DeveloperService> logger)
|
ILogger<DeveloperService> logger)
|
||||||
{
|
{
|
||||||
public async Task<Developer> LoadDeveloperPublisher(Developer developer)
|
public async Task<SnDeveloper> LoadDeveloperPublisher(SnDeveloper developer)
|
||||||
{
|
{
|
||||||
var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Id = developer.PublisherId.ToString() });
|
var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Id = developer.PublisherId.ToString() });
|
||||||
developer.Publisher = PublisherInfo.FromProto(pubResponse.Publisher);
|
developer.Publisher = SnPublisher.FromProto(pubResponse.Publisher);
|
||||||
return developer;
|
return developer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public async Task<IEnumerable<Developer>> LoadDeveloperPublisher(IEnumerable<Developer> developers)
|
public async Task<IEnumerable<SnDeveloper>> LoadDeveloperPublisher(IEnumerable<SnDeveloper> developers)
|
||||||
{
|
{
|
||||||
var enumerable = developers.ToList();
|
var enumerable = developers.ToList();
|
||||||
var pubIds = enumerable.Select(d => d.PublisherId).ToList();
|
var pubIds = enumerable.Select(d => d.PublisherId).ToList();
|
||||||
var pubRequest = new GetPublisherBatchRequest();
|
var pubRequest = new GetPublisherBatchRequest();
|
||||||
pubIds.ForEach(x => pubRequest.Ids.Add(x.ToString()));
|
pubIds.ForEach(x => pubRequest.Ids.Add(x.ToString()));
|
||||||
var pubResponse = await ps.GetPublisherBatchAsync(pubRequest);
|
var pubResponse = await ps.GetPublisherBatchAsync(pubRequest);
|
||||||
var pubs = pubResponse.Publishers.ToDictionary(p => Guid.Parse(p.Id), PublisherInfo.FromProto);
|
var pubs = pubResponse.Publishers.ToDictionary(p => Guid.Parse(p.Id), SnPublisher.FromProto);
|
||||||
|
|
||||||
return enumerable.Select(d =>
|
return enumerable.Select(d =>
|
||||||
{
|
{
|
||||||
@@ -33,7 +34,7 @@ public class DeveloperService(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Developer?> GetDeveloperByName(string name)
|
public async Task<SnDeveloper?> GetDeveloperByName(string name)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -50,12 +51,12 @@ public class DeveloperService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Developer?> GetDeveloperById(Guid id)
|
public async Task<SnDeveloper?> GetDeveloperById(Guid id)
|
||||||
{
|
{
|
||||||
return await db.Developers.FirstOrDefaultAsync(d => d.Id == id);
|
return await db.Developers.FirstOrDefaultAsync(d => d.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> IsMemberWithRole(Guid pubId, Guid accountId, PublisherMemberRole role)
|
public async Task<bool> IsMemberWithRole(Guid pubId, Guid accountId, Shared.Proto.PublisherMemberRole role)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
@@ -1,8 +1,7 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using DysonNetwork.Develop;
|
using DysonNetwork.Develop;
|
||||||
using DysonNetwork.Develop.Identity;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Data;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
@@ -35,7 +34,7 @@ namespace DysonNetwork.Develop.Migrations
|
|||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("id");
|
.HasColumnName("id");
|
||||||
|
|
||||||
b.Property<CloudFileReferenceObject>("Background")
|
b.Property<SnCloudFileReferenceObject>("Background")
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("background");
|
.HasColumnName("background");
|
||||||
|
|
||||||
@@ -56,7 +55,7 @@ namespace DysonNetwork.Develop.Migrations
|
|||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("developer_id");
|
.HasColumnName("developer_id");
|
||||||
|
|
||||||
b.Property<CustomAppLinks>("Links")
|
b.Property<SnCustomAppLinks>("Links")
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("links");
|
.HasColumnName("links");
|
||||||
|
|
||||||
@@ -66,11 +65,11 @@ namespace DysonNetwork.Develop.Migrations
|
|||||||
.HasColumnType("character varying(1024)")
|
.HasColumnType("character varying(1024)")
|
||||||
.HasColumnName("name");
|
.HasColumnName("name");
|
||||||
|
|
||||||
b.Property<CustomAppOauthConfig>("OauthConfig")
|
b.Property<SnCustomAppOauthConfig>("OauthConfig")
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("oauth_config");
|
.HasColumnName("oauth_config");
|
||||||
|
|
||||||
b.Property<CloudFileReferenceObject>("Picture")
|
b.Property<SnCloudFileReferenceObject>("Picture")
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("picture");
|
.HasColumnName("picture");
|
||||||
|
|
||||||
@@ -88,7 +87,7 @@ namespace DysonNetwork.Develop.Migrations
|
|||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("updated_at");
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
b.Property<VerificationMark>("Verification")
|
b.Property<SnVerificationMark>("Verification")
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("verification");
|
.HasColumnName("verification");
|
||||||
|
|
||||||
|
@@ -1,6 +1,4 @@
|
|||||||
using System;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Develop.Identity;
|
|
||||||
using DysonNetwork.Shared.Data;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
@@ -35,11 +33,11 @@ namespace DysonNetwork.Develop.Migrations
|
|||||||
name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||||
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||||
status = table.Column<int>(type: "integer", nullable: false),
|
status = table.Column<int>(type: "integer", nullable: false),
|
||||||
picture = table.Column<CloudFileReferenceObject>(type: "jsonb", nullable: true),
|
picture = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
|
||||||
background = table.Column<CloudFileReferenceObject>(type: "jsonb", nullable: true),
|
background = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
|
||||||
verification = table.Column<VerificationMark>(type: "jsonb", nullable: true),
|
verification = table.Column<SnVerificationMark>(type: "jsonb", nullable: true),
|
||||||
oauth_config = table.Column<CustomAppOauthConfig>(type: "jsonb", nullable: true),
|
oauth_config = table.Column<SnCustomAppOauthConfig>(type: "jsonb", nullable: true),
|
||||||
links = table.Column<CustomAppLinks>(type: "jsonb", nullable: true),
|
links = table.Column<SnCustomAppLinks>(type: "jsonb", nullable: true),
|
||||||
developer_id = table.Column<Guid>(type: "uuid", nullable: false),
|
developer_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||||
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||||
|
@@ -1,8 +1,7 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using DysonNetwork.Develop;
|
using DysonNetwork.Develop;
|
||||||
using DysonNetwork.Develop.Identity;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Data;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
@@ -35,7 +34,7 @@ namespace DysonNetwork.Develop.Migrations
|
|||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("id");
|
.HasColumnName("id");
|
||||||
|
|
||||||
b.Property<CloudFileReferenceObject>("Background")
|
b.Property<SnCloudFileReferenceObject>("Background")
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("background");
|
.HasColumnName("background");
|
||||||
|
|
||||||
@@ -52,7 +51,7 @@ namespace DysonNetwork.Develop.Migrations
|
|||||||
.HasColumnType("character varying(4096)")
|
.HasColumnType("character varying(4096)")
|
||||||
.HasColumnName("description");
|
.HasColumnName("description");
|
||||||
|
|
||||||
b.Property<CustomAppLinks>("Links")
|
b.Property<SnCustomAppLinks>("Links")
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("links");
|
.HasColumnName("links");
|
||||||
|
|
||||||
@@ -62,11 +61,11 @@ namespace DysonNetwork.Develop.Migrations
|
|||||||
.HasColumnType("character varying(1024)")
|
.HasColumnType("character varying(1024)")
|
||||||
.HasColumnName("name");
|
.HasColumnName("name");
|
||||||
|
|
||||||
b.Property<CustomAppOauthConfig>("OauthConfig")
|
b.Property<SnCustomAppOauthConfig>("OauthConfig")
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("oauth_config");
|
.HasColumnName("oauth_config");
|
||||||
|
|
||||||
b.Property<CloudFileReferenceObject>("Picture")
|
b.Property<SnCloudFileReferenceObject>("Picture")
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("picture");
|
.HasColumnName("picture");
|
||||||
|
|
||||||
@@ -88,7 +87,7 @@ namespace DysonNetwork.Develop.Migrations
|
|||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("updated_at");
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
b.Property<VerificationMark>("Verification")
|
b.Property<SnVerificationMark>("Verification")
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("verification");
|
.HasColumnName("verification");
|
||||||
|
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
using System;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
@@ -1,8 +1,7 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using DysonNetwork.Develop;
|
using DysonNetwork.Develop;
|
||||||
using DysonNetwork.Develop.Identity;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Data;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
@@ -77,7 +76,7 @@ namespace DysonNetwork.Develop.Migrations
|
|||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("id");
|
.HasColumnName("id");
|
||||||
|
|
||||||
b.Property<CloudFileReferenceObject>("Background")
|
b.Property<SnCloudFileReferenceObject>("Background")
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("background");
|
.HasColumnName("background");
|
||||||
|
|
||||||
@@ -94,7 +93,7 @@ namespace DysonNetwork.Develop.Migrations
|
|||||||
.HasColumnType("character varying(4096)")
|
.HasColumnType("character varying(4096)")
|
||||||
.HasColumnName("description");
|
.HasColumnName("description");
|
||||||
|
|
||||||
b.Property<CustomAppLinks>("Links")
|
b.Property<SnCustomAppLinks>("Links")
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("links");
|
.HasColumnName("links");
|
||||||
|
|
||||||
@@ -104,11 +103,11 @@ namespace DysonNetwork.Develop.Migrations
|
|||||||
.HasColumnType("character varying(1024)")
|
.HasColumnType("character varying(1024)")
|
||||||
.HasColumnName("name");
|
.HasColumnName("name");
|
||||||
|
|
||||||
b.Property<CustomAppOauthConfig>("OauthConfig")
|
b.Property<SnCustomAppOauthConfig>("OauthConfig")
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("oauth_config");
|
.HasColumnName("oauth_config");
|
||||||
|
|
||||||
b.Property<CloudFileReferenceObject>("Picture")
|
b.Property<SnCloudFileReferenceObject>("Picture")
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("picture");
|
.HasColumnName("picture");
|
||||||
|
|
||||||
@@ -130,7 +129,7 @@ namespace DysonNetwork.Develop.Migrations
|
|||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("updated_at");
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
b.Property<VerificationMark>("Verification")
|
b.Property<SnVerificationMark>("Verification")
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("verification");
|
.HasColumnName("verification");
|
||||||
|
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
using System;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
@@ -1,8 +1,7 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using DysonNetwork.Develop;
|
using DysonNetwork.Develop;
|
||||||
using DysonNetwork.Develop.Identity;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Data;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
@@ -74,7 +73,7 @@ namespace DysonNetwork.Develop.Migrations
|
|||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("id");
|
.HasColumnName("id");
|
||||||
|
|
||||||
b.Property<CloudFileReferenceObject>("Background")
|
b.Property<SnCloudFileReferenceObject>("Background")
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("background");
|
.HasColumnName("background");
|
||||||
|
|
||||||
@@ -91,7 +90,7 @@ namespace DysonNetwork.Develop.Migrations
|
|||||||
.HasColumnType("character varying(4096)")
|
.HasColumnType("character varying(4096)")
|
||||||
.HasColumnName("description");
|
.HasColumnName("description");
|
||||||
|
|
||||||
b.Property<CustomAppLinks>("Links")
|
b.Property<SnCustomAppLinks>("Links")
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("links");
|
.HasColumnName("links");
|
||||||
|
|
||||||
@@ -101,11 +100,11 @@ namespace DysonNetwork.Develop.Migrations
|
|||||||
.HasColumnType("character varying(1024)")
|
.HasColumnType("character varying(1024)")
|
||||||
.HasColumnName("name");
|
.HasColumnName("name");
|
||||||
|
|
||||||
b.Property<CustomAppOauthConfig>("OauthConfig")
|
b.Property<SnCustomAppOauthConfig>("OauthConfig")
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("oauth_config");
|
.HasColumnName("oauth_config");
|
||||||
|
|
||||||
b.Property<CloudFileReferenceObject>("Picture")
|
b.Property<SnCloudFileReferenceObject>("Picture")
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("picture");
|
.HasColumnName("picture");
|
||||||
|
|
||||||
@@ -127,7 +126,7 @@ namespace DysonNetwork.Develop.Migrations
|
|||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("updated_at");
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
b.Property<VerificationMark>("Verification")
|
b.Property<SnVerificationMark>("Verification")
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("verification");
|
.HasColumnName("verification");
|
||||||
|
|
||||||
|
@@ -13,12 +13,16 @@ builder.ConfigureAppKestrel(builder.Configuration);
|
|||||||
|
|
||||||
builder.Services.AddAppServices(builder.Configuration);
|
builder.Services.AddAppServices(builder.Configuration);
|
||||||
builder.Services.AddAppAuthentication();
|
builder.Services.AddAppAuthentication();
|
||||||
builder.Services.AddAppSwagger();
|
|
||||||
builder.Services.AddDysonAuth();
|
builder.Services.AddDysonAuth();
|
||||||
builder.Services.AddPublisherService();
|
builder.Services.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();
|
@@ -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
|
||||||
|
@@ -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);
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Microsoft.OpenApi.Models;
|
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using NodaTime.Serialization.SystemTextJson;
|
using NodaTime.Serialization.SystemTextJson;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -7,7 +6,6 @@ using System.Text.Json.Serialization;
|
|||||||
using DysonNetwork.Develop.Identity;
|
using DysonNetwork.Develop.Identity;
|
||||||
using DysonNetwork.Develop.Project;
|
using DysonNetwork.Develop.Project;
|
||||||
using DysonNetwork.Shared.Cache;
|
using DysonNetwork.Shared.Cache;
|
||||||
using StackExchange.Redis;
|
|
||||||
|
|
||||||
namespace DysonNetwork.Develop.Startup;
|
namespace DysonNetwork.Develop.Startup;
|
||||||
|
|
||||||
@@ -57,23 +55,7 @@ public static class ServiceCollectionExtensions
|
|||||||
|
|
||||||
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
|
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddCors();
|
|
||||||
services.AddAuthorization();
|
services.AddAuthorization();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IServiceCollection AddAppSwagger(this IServiceCollection services)
|
|
||||||
{
|
|
||||||
services.AddEndpointsApiExplorer();
|
|
||||||
services.AddSwaggerGen(options =>
|
|
||||||
{
|
|
||||||
options.SwaggerDoc("v1", new OpenApiInfo
|
|
||||||
{
|
|
||||||
Version = "v1",
|
|
||||||
Title = "Develop API",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
services.AddOpenApi();
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -10,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
|
||||||
},
|
},
|
||||||
|
@@ -1,8 +1,7 @@
|
|||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using DysonNetwork.Drive.Billing;
|
using DysonNetwork.Drive.Billing;
|
||||||
using DysonNetwork.Drive.Storage;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Data;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Design;
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
using Microsoft.EntityFrameworkCore.Query;
|
using Microsoft.EntityFrameworkCore.Query;
|
||||||
@@ -17,11 +16,11 @@ public class AppDatabase(
|
|||||||
) : DbContext(options)
|
) : DbContext(options)
|
||||||
{
|
{
|
||||||
public DbSet<FilePool> Pools { get; set; } = null!;
|
public DbSet<FilePool> Pools { get; set; } = null!;
|
||||||
public DbSet<FileBundle> Bundles { get; set; } = null!;
|
public DbSet<SnFileBundle> Bundles { get; set; } = null!;
|
||||||
|
|
||||||
public DbSet<QuotaRecord> QuotaRecords { get; set; } = null!;
|
public DbSet<QuotaRecord> QuotaRecords { get; set; } = null!;
|
||||||
|
|
||||||
public DbSet<CloudFile> Files { get; set; } = null!;
|
public DbSet<SnCloudFile> Files { get; set; } = null!;
|
||||||
public DbSet<CloudFileReference> FileReferences { get; set; } = null!;
|
public DbSet<CloudFileReference> FileReferences { get; set; } = null!;
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Models;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
namespace DysonNetwork.Drive.Billing;
|
namespace DysonNetwork.Drive.Billing;
|
||||||
|
@@ -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>
|
||||||
|
@@ -3,7 +3,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using DysonNetwork.Drive;
|
using DysonNetwork.Drive;
|
||||||
using DysonNetwork.Drive.Storage;
|
using DysonNetwork.Drive.Storage;
|
||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
@@ -1,7 +1,4 @@
|
|||||||
using System;
|
using DysonNetwork.Shared.Models;
|
||||||
using System.Collections.Generic;
|
|
||||||
using DysonNetwork.Drive.Storage;
|
|
||||||
using DysonNetwork.Shared.Data;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
|
@@ -2,7 +2,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using DysonNetwork.Drive;
|
using DysonNetwork.Drive;
|
||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
using System.Collections.Generic;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
|
@@ -2,8 +2,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using DysonNetwork.Drive;
|
using DysonNetwork.Drive;
|
||||||
using DysonNetwork.Drive.Storage;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Data;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
@@ -1,6 +1,4 @@
|
|||||||
using System;
|
using DysonNetwork.Shared.Models;
|
||||||
using System.Collections.Generic;
|
|
||||||
using DysonNetwork.Drive.Storage;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
|
@@ -2,8 +2,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using DysonNetwork.Drive;
|
using DysonNetwork.Drive;
|
||||||
using DysonNetwork.Drive.Storage;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Data;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
@@ -2,8 +2,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using DysonNetwork.Drive;
|
using DysonNetwork.Drive;
|
||||||
using DysonNetwork.Drive.Storage;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Data;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
@@ -2,8 +2,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using DysonNetwork.Drive;
|
using DysonNetwork.Drive;
|
||||||
using DysonNetwork.Drive.Storage;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Data;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
using System;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
@@ -2,8 +2,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using DysonNetwork.Drive;
|
using DysonNetwork.Drive;
|
||||||
using DysonNetwork.Drive.Storage;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Data;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
using System;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
@@ -2,8 +2,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using DysonNetwork.Drive;
|
using DysonNetwork.Drive;
|
||||||
using DysonNetwork.Drive.Storage;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Data;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
@@ -2,8 +2,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using DysonNetwork.Drive;
|
using DysonNetwork.Drive;
|
||||||
using DysonNetwork.Drive.Storage;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Data;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
@@ -2,8 +2,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using DysonNetwork.Drive;
|
using DysonNetwork.Drive;
|
||||||
using DysonNetwork.Drive.Storage;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Data;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
@@ -2,8 +2,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using DysonNetwork.Drive;
|
using DysonNetwork.Drive;
|
||||||
using DysonNetwork.Drive.Storage;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Data;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
@@ -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();
|
||||||
|
@@ -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();
|
||||||
|
|
||||||
|
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
@@ -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")!;
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -22,7 +23,7 @@ public class BundleController(AppDatabase db) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
public async Task<ActionResult<FileBundle>> GetBundle([FromRoute] Guid id, [FromQuery] string? passcode)
|
public async Task<ActionResult<SnFileBundle>> GetBundle([FromRoute] Guid id, [FromQuery] string? passcode)
|
||||||
{
|
{
|
||||||
var bundle = await db.Bundles
|
var bundle = await db.Bundles
|
||||||
.Where(e => e.Id == id)
|
.Where(e => e.Id == id)
|
||||||
@@ -36,7 +37,7 @@ public class BundleController(AppDatabase db) : ControllerBase
|
|||||||
|
|
||||||
[HttpGet("me")]
|
[HttpGet("me")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<List<FileBundle>>> ListBundles(
|
public async Task<ActionResult<List<SnFileBundle>>> ListBundles(
|
||||||
[FromQuery] string? term,
|
[FromQuery] string? term,
|
||||||
[FromQuery] int offset = 0,
|
[FromQuery] int offset = 0,
|
||||||
[FromQuery] int take = 20
|
[FromQuery] int take = 20
|
||||||
@@ -65,7 +66,7 @@ public class BundleController(AppDatabase db) : ControllerBase
|
|||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<FileBundle>> CreateBundle([FromBody] BundleRequest request)
|
public async Task<ActionResult<SnFileBundle>> CreateBundle([FromBody] BundleRequest request)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
@@ -77,7 +78,7 @@ public class BundleController(AppDatabase db) : ControllerBase
|
|||||||
if (string.IsNullOrEmpty(request.Name))
|
if (string.IsNullOrEmpty(request.Name))
|
||||||
request.Name = "Unnamed Bundle";
|
request.Name = "Unnamed Bundle";
|
||||||
|
|
||||||
var bundle = new FileBundle
|
var bundle = new SnFileBundle
|
||||||
{
|
{
|
||||||
Slug = request.Slug,
|
Slug = request.Slug,
|
||||||
Name = request.Name,
|
Name = request.Name,
|
||||||
@@ -95,7 +96,7 @@ public class BundleController(AppDatabase db) : ControllerBase
|
|||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<FileBundle>> UpdateBundle([FromRoute] Guid id, [FromBody] BundleRequest request)
|
public async Task<ActionResult<SnFileBundle>> UpdateBundle([FromRoute] Guid id, [FromBody] BundleRequest request)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
using DysonNetwork.Drive.Billing;
|
using DysonNetwork.Drive.Billing;
|
||||||
using DysonNetwork.Shared.Auth;
|
using DysonNetwork.Shared.Auth;
|
||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -46,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,
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -19,6 +20,7 @@ public class FilePoolController(AppDatabase db, FileService fs) : ControllerBase
|
|||||||
var pools = await db.Pools
|
var pools = await db.Pools
|
||||||
.Where(p => p.PolicyConfig.PublicUsable || p.AccountId == accountId)
|
.Where(p => p.PolicyConfig.PublicUsable || p.AccountId == accountId)
|
||||||
.Where(p => !p.IsHidden || p.AccountId == accountId)
|
.Where(p => !p.IsHidden || p.AccountId == accountId)
|
||||||
|
.OrderBy(p => p.CreatedAt)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
pools = pools.Select(p =>
|
pools = pools.Select(p =>
|
||||||
{
|
{
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
using DysonNetwork.Shared.Cache;
|
using DysonNetwork.Shared.Cache;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
using EFCore.BulkExtensions;
|
using EFCore.BulkExtensions;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
@@ -347,7 +348,7 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
|
|||||||
/// <param name="resourceId">The ID of the resource</param>
|
/// <param name="resourceId">The ID of the resource</param>
|
||||||
/// <param name="usage">Optional filter by usage context</param>
|
/// <param name="usage">Optional filter by usage context</param>
|
||||||
/// <returns>A list of files referenced by the resource</returns>
|
/// <returns>A list of files referenced by the resource</returns>
|
||||||
public async Task<List<CloudFile>> GetResourceFilesAsync(string resourceId, string? usage = null)
|
public async Task<List<SnCloudFile>> GetResourceFilesAsync(string resourceId, string? usage = null)
|
||||||
{
|
{
|
||||||
var query = db.FileReferences.Where(r => r.ResourceId == resourceId);
|
var query = db.FileReferences.Where(r => r.ResourceId == resourceId);
|
||||||
|
|
||||||
|
@@ -3,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 };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -1,46 +1,38 @@
|
|||||||
using System.Drawing;
|
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using FFMpegCore;
|
using FFMpegCore;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using DysonNetwork.Drive.Storage.Model;
|
||||||
using DysonNetwork.Shared.Cache;
|
using DysonNetwork.Shared.Cache;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Minio;
|
using Minio;
|
||||||
using Minio.DataModel.Args;
|
using Minio.DataModel.Args;
|
||||||
|
using NATS.Client.Core;
|
||||||
using NetVips;
|
using NetVips;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using tusdotnet.Stores;
|
|
||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
using DysonNetwork.Shared.Data;
|
|
||||||
using Microsoft.EntityFrameworkCore.Query;
|
using Microsoft.EntityFrameworkCore.Query;
|
||||||
|
using NATS.Net;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
|
||||||
namespace DysonNetwork.Drive.Storage;
|
namespace DysonNetwork.Drive.Storage;
|
||||||
|
|
||||||
public class FileService(
|
public class FileService(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
IConfiguration configuration,
|
|
||||||
ILogger<FileService> logger,
|
ILogger<FileService> logger,
|
||||||
IServiceScopeFactory scopeFactory,
|
ICacheService cache,
|
||||||
ICacheService cache
|
INatsConnection nats
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
private const string CacheKeyPrefix = "file:";
|
private const string CacheKeyPrefix = "file:";
|
||||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15);
|
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15);
|
||||||
|
|
||||||
/// <summary>
|
public async Task<SnCloudFile?> GetFileAsync(string fileId)
|
||||||
/// The api for getting file meta with cache,
|
|
||||||
/// the best use case is for accessing the file data.
|
|
||||||
///
|
|
||||||
/// <b>This function won't load uploader's information, only keep minimal file meta</b>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="fileId">The id of the cloud file requested</param>
|
|
||||||
/// <returns>The minimal file meta</returns>
|
|
||||||
public async Task<CloudFile?> GetFileAsync(string fileId)
|
|
||||||
{
|
{
|
||||||
var cacheKey = $"{CacheKeyPrefix}{fileId}";
|
var cacheKey = $"{CacheKeyPrefix}{fileId}";
|
||||||
|
|
||||||
var cachedFile = await cache.GetAsync<CloudFile>(cacheKey);
|
var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey);
|
||||||
if (cachedFile is not null)
|
if (cachedFile is not null)
|
||||||
return cachedFile;
|
return cachedFile;
|
||||||
|
|
||||||
@@ -56,16 +48,15 @@ public class FileService(
|
|||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<CloudFile>> GetFilesAsync(List<string> fileIds)
|
public async Task<List<SnCloudFile>> GetFilesAsync(List<string> fileIds)
|
||||||
{
|
{
|
||||||
var cachedFiles = new Dictionary<string, CloudFile>();
|
var cachedFiles = new Dictionary<string, SnCloudFile>();
|
||||||
var uncachedIds = new List<string>();
|
var uncachedIds = new List<string>();
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
foreach (var fileId in fileIds)
|
foreach (var fileId in fileIds)
|
||||||
{
|
{
|
||||||
var cacheKey = $"{CacheKeyPrefix}{fileId}";
|
var cacheKey = $"{CacheKeyPrefix}{fileId}";
|
||||||
var cachedFile = await cache.GetAsync<CloudFile>(cacheKey);
|
var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey);
|
||||||
|
|
||||||
if (cachedFile != null)
|
if (cachedFile != null)
|
||||||
cachedFiles[fileId] = cachedFile;
|
cachedFiles[fileId] = cachedFile;
|
||||||
@@ -73,7 +64,6 @@ public class FileService(
|
|||||||
uncachedIds.Add(fileId);
|
uncachedIds.Add(fileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load uncached files from database
|
|
||||||
if (uncachedIds.Count > 0)
|
if (uncachedIds.Count > 0)
|
||||||
{
|
{
|
||||||
var dbFiles = await db.Files
|
var dbFiles = await db.Files
|
||||||
@@ -81,7 +71,6 @@ public class FileService(
|
|||||||
.Include(f => f.Pool)
|
.Include(f => f.Pool)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
// Add to cache
|
|
||||||
foreach (var file in dbFiles)
|
foreach (var file in dbFiles)
|
||||||
{
|
{
|
||||||
var cacheKey = $"{CacheKeyPrefix}{file.Id}";
|
var cacheKey = $"{CacheKeyPrefix}{file.Id}";
|
||||||
@@ -90,28 +79,19 @@ public class FileService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve original order
|
|
||||||
return fileIds
|
return fileIds
|
||||||
.Select(f => cachedFiles.GetValueOrDefault(f))
|
.Select(f => cachedFiles.GetValueOrDefault(f))
|
||||||
.Where(f => f != null)
|
.Where(f => f != null)
|
||||||
.Cast<CloudFile>()
|
.Cast<SnCloudFile>()
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private const string TempFilePrefix = "dyn-cloudfile";
|
public async Task<SnCloudFile> ProcessNewFileAsync(
|
||||||
|
|
||||||
private static readonly string[] AnimatedImageTypes =
|
|
||||||
["image/gif", "image/apng", "image/avif"];
|
|
||||||
|
|
||||||
private static readonly string[] AnimatedImageExtensions =
|
|
||||||
[".gif", ".apng", ".avif"];
|
|
||||||
|
|
||||||
public async Task<CloudFile> ProcessNewFileAsync(
|
|
||||||
Account account,
|
Account account,
|
||||||
string fileId,
|
string fileId,
|
||||||
string filePool,
|
string filePool,
|
||||||
string? fileBundleId,
|
string? fileBundleId,
|
||||||
Stream stream,
|
string filePath,
|
||||||
string fileName,
|
string fileName,
|
||||||
string? contentType,
|
string? contentType,
|
||||||
string? encryptPassword,
|
string? encryptPassword,
|
||||||
@@ -143,57 +123,74 @@ public class FileService(
|
|||||||
if (bundle?.ExpiredAt != null)
|
if (bundle?.ExpiredAt != null)
|
||||||
expiredAt = bundle.ExpiredAt.Value;
|
expiredAt = bundle.ExpiredAt.Value;
|
||||||
|
|
||||||
var ogFilePath = Path.GetFullPath(Path.Join(configuration.GetValue<string>("Tus:StorePath"), fileId));
|
var managedTempPath = Path.Combine(Path.GetTempPath(), fileId);
|
||||||
var fileSize = stream.Length;
|
File.Copy(filePath, managedTempPath, true);
|
||||||
contentType ??= !fileName.Contains('.') ? "application/octet-stream" : MimeTypes.GetMimeType(fileName);
|
|
||||||
|
var fileInfo = new FileInfo(managedTempPath);
|
||||||
|
var fileSize = fileInfo.Length;
|
||||||
|
var finalContentType = contentType ??
|
||||||
|
(!fileName.Contains('.') ? "application/octet-stream" : MimeTypes.GetMimeType(fileName));
|
||||||
|
|
||||||
|
var file = new SnCloudFile
|
||||||
|
{
|
||||||
|
Id = fileId,
|
||||||
|
Name = fileName,
|
||||||
|
MimeType = finalContentType,
|
||||||
|
Size = fileSize,
|
||||||
|
ExpiredAt = expiredAt,
|
||||||
|
BundleId = bundle?.Id,
|
||||||
|
AccountId = Guid.Parse(account.Id),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!pool.PolicyConfig.NoMetadata)
|
||||||
|
{
|
||||||
|
await ExtractMetadataAsync(file, managedTempPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
string processingPath = managedTempPath;
|
||||||
|
bool isTempFile = true;
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(encryptPassword))
|
if (!string.IsNullOrWhiteSpace(encryptPassword))
|
||||||
{
|
{
|
||||||
if (!pool.PolicyConfig.AllowEncryption)
|
if (!pool.PolicyConfig.AllowEncryption)
|
||||||
throw new InvalidOperationException("Encryption is not allowed in this pool");
|
throw new InvalidOperationException("Encryption is not allowed in this pool");
|
||||||
|
|
||||||
var encryptedPath = Path.Combine(Path.GetTempPath(), $"{fileId}.encrypted");
|
var encryptedPath = Path.Combine(Path.GetTempPath(), $"{fileId}.encrypted");
|
||||||
FileEncryptor.EncryptFile(ogFilePath, encryptedPath, encryptPassword);
|
FileEncryptor.EncryptFile(managedTempPath, encryptedPath, encryptPassword);
|
||||||
File.Delete(ogFilePath); // Delete original unencrypted
|
|
||||||
File.Move(encryptedPath, ogFilePath); // Replace the original one with encrypted
|
File.Delete(managedTempPath);
|
||||||
contentType = "application/octet-stream";
|
|
||||||
|
processingPath = encryptedPath;
|
||||||
|
|
||||||
|
file.IsEncrypted = true;
|
||||||
|
file.MimeType = "application/octet-stream";
|
||||||
|
file.Size = new FileInfo(processingPath).Length;
|
||||||
}
|
}
|
||||||
|
|
||||||
var hash = await HashFileAsync(ogFilePath);
|
file.Hash = await HashFileAsync(processingPath);
|
||||||
|
|
||||||
var file = new CloudFile
|
|
||||||
{
|
|
||||||
Id = fileId,
|
|
||||||
Name = fileName,
|
|
||||||
MimeType = contentType,
|
|
||||||
Size = fileSize,
|
|
||||||
Hash = hash,
|
|
||||||
ExpiredAt = expiredAt,
|
|
||||||
BundleId = bundle?.Id,
|
|
||||||
AccountId = Guid.Parse(account.Id),
|
|
||||||
IsEncrypted = !string.IsNullOrWhiteSpace(encryptPassword) && pool.PolicyConfig.AllowEncryption
|
|
||||||
};
|
|
||||||
|
|
||||||
// Extract metadata on the current thread for a faster initial response
|
|
||||||
if (!pool.PolicyConfig.NoMetadata)
|
|
||||||
await ExtractMetadataAsync(file, ogFilePath, stream);
|
|
||||||
|
|
||||||
db.Files.Add(file);
|
db.Files.Add(file);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
file.StorageId ??= file.Id;
|
file.StorageId ??= file.Id;
|
||||||
|
|
||||||
// Offload optimization (image conversion, thumbnailing) and uploading to a background task
|
var js = nats.CreateJetStreamContext();
|
||||||
_ = Task.Run(() =>
|
await js.PublishAsync(
|
||||||
ProcessAndUploadInBackgroundAsync(file.Id, filePool, file.StorageId, contentType, ogFilePath, stream));
|
FileUploadedEvent.Type,
|
||||||
|
GrpcTypeHelper.ConvertObjectToByteString(new FileUploadedEventPayload(
|
||||||
|
file.Id,
|
||||||
|
pool.Id,
|
||||||
|
file.StorageId,
|
||||||
|
file.MimeType,
|
||||||
|
processingPath,
|
||||||
|
isTempFile)
|
||||||
|
).ToByteArray()
|
||||||
|
);
|
||||||
|
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private async Task ExtractMetadataAsync(SnCloudFile file, string filePath)
|
||||||
/// Extracts metadata from the file based on its content type.
|
|
||||||
/// This runs synchronously to ensure the initial database record has basic metadata.
|
|
||||||
/// </summary>
|
|
||||||
private async Task ExtractMetadataAsync(CloudFile file, string filePath, Stream stream)
|
|
||||||
{
|
{
|
||||||
switch (file.MimeType?.Split('/')[0])
|
switch (file.MimeType?.Split('/')[0])
|
||||||
{
|
{
|
||||||
@@ -201,6 +198,7 @@ public class FileService(
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var blurhash = BlurHashSharp.SkiaSharp.BlurHashEncoder.Encode(3, 3, filePath);
|
var blurhash = BlurHashSharp.SkiaSharp.BlurHashEncoder.Encode(3, 3, filePath);
|
||||||
|
await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
stream.Position = 0;
|
stream.Position = 0;
|
||||||
|
|
||||||
using var vipsImage = Image.NewFromStream(stream);
|
using var vipsImage = Image.NewFromStream(stream);
|
||||||
@@ -265,7 +263,6 @@ public class FileService(
|
|||||||
["bit_rate"] = mediaInfo.Format.BitRate.ToString(CultureInfo.InvariantCulture),
|
["bit_rate"] = mediaInfo.Format.BitRate.ToString(CultureInfo.InvariantCulture),
|
||||||
["tags"] = mediaInfo.Format.Tags ?? new Dictionary<string, string>(),
|
["tags"] = mediaInfo.Format.Tags ?? new Dictionary<string, string>(),
|
||||||
["chapters"] = mediaInfo.Chapters,
|
["chapters"] = mediaInfo.Chapters,
|
||||||
// Add detailed stream information
|
|
||||||
["video_streams"] = mediaInfo.VideoStreams.Select(s => new
|
["video_streams"] = mediaInfo.VideoStreams.Select(s => new
|
||||||
{
|
{
|
||||||
s.AvgFrameRate,
|
s.AvgFrameRate,
|
||||||
@@ -303,166 +300,6 @@ public class FileService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handles file optimization (image compression, video thumbnail) and uploads to remote storage in the background.
|
|
||||||
/// </summary>
|
|
||||||
private async Task ProcessAndUploadInBackgroundAsync(
|
|
||||||
string fileId,
|
|
||||||
string remoteId,
|
|
||||||
string storageId,
|
|
||||||
string contentType,
|
|
||||||
string originalFilePath,
|
|
||||||
Stream stream
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var pool = await GetPoolAsync(Guid.Parse(remoteId));
|
|
||||||
if (pool is null) return;
|
|
||||||
|
|
||||||
await using var bgStream = stream; // Ensure stream is disposed at the end of this task
|
|
||||||
using var scope = scopeFactory.CreateScope();
|
|
||||||
var nfs = scope.ServiceProvider.GetRequiredService<FileService>();
|
|
||||||
var scopedDb = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
|
||||||
|
|
||||||
var uploads = new List<(string FilePath, string Suffix, string ContentType, bool SelfDestruct)>();
|
|
||||||
var newMimeType = contentType;
|
|
||||||
var hasCompression = false;
|
|
||||||
var hasThumbnail = false;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
logger.LogInformation("Processing file {FileId} in background...", fileId);
|
|
||||||
|
|
||||||
var fileExtension = Path.GetExtension(originalFilePath);
|
|
||||||
|
|
||||||
if (!pool.PolicyConfig.NoOptimization)
|
|
||||||
switch (contentType.Split('/')[0])
|
|
||||||
{
|
|
||||||
case "image":
|
|
||||||
if (AnimatedImageTypes.Contains(contentType) || AnimatedImageExtensions.Contains(fileExtension))
|
|
||||||
{
|
|
||||||
logger.LogInformation("Skip optimize file {FileId} due to it is animated...", fileId);
|
|
||||||
uploads.Add((originalFilePath, string.Empty, contentType, false));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
newMimeType = "image/webp";
|
|
||||||
using (var vipsImage = Image.NewFromFile(originalFilePath))
|
|
||||||
{
|
|
||||||
var imageToWrite = vipsImage;
|
|
||||||
|
|
||||||
if (vipsImage.Interpretation is Enums.Interpretation.Scrgb or Enums.Interpretation.Xyz)
|
|
||||||
{
|
|
||||||
imageToWrite = vipsImage.Colourspace(Enums.Interpretation.Srgb);
|
|
||||||
}
|
|
||||||
|
|
||||||
var webpPath = Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{fileId}.webp");
|
|
||||||
imageToWrite.Autorot().WriteToFile(webpPath,
|
|
||||||
new VOption { { "lossless", true }, { "strip", true } });
|
|
||||||
uploads.Add((webpPath, string.Empty, newMimeType, true));
|
|
||||||
|
|
||||||
if (imageToWrite.Width * imageToWrite.Height >= 1024 * 1024)
|
|
||||||
{
|
|
||||||
var scale = 1024.0 / Math.Max(imageToWrite.Width, imageToWrite.Height);
|
|
||||||
var compressedPath =
|
|
||||||
Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{fileId}-compressed.webp");
|
|
||||||
using var compressedImage = imageToWrite.Resize(scale);
|
|
||||||
compressedImage.Autorot().WriteToFile(compressedPath,
|
|
||||||
new VOption { { "Q", 80 }, { "strip", true } });
|
|
||||||
uploads.Add((compressedPath, ".compressed", newMimeType, true));
|
|
||||||
hasCompression = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ReferenceEquals(imageToWrite, vipsImage))
|
|
||||||
{
|
|
||||||
imageToWrite.Dispose(); // Clean up manually created colourspace-converted image
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "video":
|
|
||||||
uploads.Add((originalFilePath, string.Empty, contentType, false));
|
|
||||||
|
|
||||||
var thumbnailPath = Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{fileId}.thumbnail.jpg");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await FFMpegArguments
|
|
||||||
.FromFileInput(originalFilePath, verifyExists: true)
|
|
||||||
.OutputToFile(thumbnailPath, overwrite: true, options => options
|
|
||||||
.Seek(TimeSpan.FromSeconds(0))
|
|
||||||
.WithFrameOutputCount(1)
|
|
||||||
.WithCustomArgument("-q:v 2")
|
|
||||||
)
|
|
||||||
.NotifyOnOutput(line => logger.LogInformation("[FFmpeg] {Line}", line))
|
|
||||||
.NotifyOnError(line => logger.LogWarning("[FFmpeg] {Line}", line))
|
|
||||||
.ProcessAsynchronously();
|
|
||||||
|
|
||||||
if (File.Exists(thumbnailPath))
|
|
||||||
{
|
|
||||||
uploads.Add((thumbnailPath, ".thumbnail", "image/jpeg", true));
|
|
||||||
hasThumbnail = true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
logger.LogWarning("FFMpeg did not produce thumbnail for video {FileId}", fileId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogError(ex, "Failed to generate thumbnail for video {FileId}", fileId);
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
uploads.Add((originalFilePath, string.Empty, contentType, false));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
else uploads.Add((originalFilePath, string.Empty, contentType, false));
|
|
||||||
|
|
||||||
logger.LogInformation("Optimized file {FileId}, now uploading...", fileId);
|
|
||||||
|
|
||||||
if (uploads.Count > 0)
|
|
||||||
{
|
|
||||||
var destPool = Guid.Parse(remoteId!);
|
|
||||||
var uploadTasks = uploads.Select(item =>
|
|
||||||
nfs.UploadFileToRemoteAsync(
|
|
||||||
storageId,
|
|
||||||
destPool,
|
|
||||||
item.FilePath,
|
|
||||||
item.Suffix,
|
|
||||||
item.ContentType,
|
|
||||||
item.SelfDestruct
|
|
||||||
)
|
|
||||||
).ToList();
|
|
||||||
|
|
||||||
await Task.WhenAll(uploadTasks);
|
|
||||||
|
|
||||||
logger.LogInformation("Uploaded file {FileId} done!", fileId);
|
|
||||||
|
|
||||||
var fileToUpdate = await scopedDb.Files.FirstAsync(f => f.Id == fileId);
|
|
||||||
if (hasThumbnail) fileToUpdate.HasThumbnail = true;
|
|
||||||
|
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
|
||||||
await scopedDb.Files.Where(f => f.Id == fileId).ExecuteUpdateAsync(setter => setter
|
|
||||||
.SetProperty(f => f.UploadedAt, now)
|
|
||||||
.SetProperty(f => f.PoolId, destPool)
|
|
||||||
.SetProperty(f => f.MimeType, newMimeType)
|
|
||||||
.SetProperty(f => f.HasCompression, hasCompression)
|
|
||||||
.SetProperty(f => f.HasThumbnail, hasThumbnail)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception err)
|
|
||||||
{
|
|
||||||
logger.LogError(err, "Failed to process and upload {FileId}", fileId);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await nfs._PurgeCacheAsync(fileId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<string> HashFileAsync(string filePath, int chunkSize = 1024 * 1024)
|
private static async Task<string> HashFileAsync(string filePath, int chunkSize = 1024 * 1024)
|
||||||
{
|
{
|
||||||
var fileInfo = new FileInfo(filePath);
|
var fileInfo = new FileInfo(filePath);
|
||||||
@@ -491,11 +328,11 @@ public class FileService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var hash = MD5.HashData(buffer.AsSpan(0, bytesRead));
|
var hash = MD5.HashData(buffer.AsSpan(0, bytesRead));
|
||||||
stream.Position = 0; // Reset stream position
|
stream.Position = 0;
|
||||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task UploadFileToRemoteAsync(
|
public async Task UploadFileToRemoteAsync(
|
||||||
string storageId,
|
string storageId,
|
||||||
Guid targetRemote,
|
Guid targetRemote,
|
||||||
string filePath,
|
string filePath,
|
||||||
@@ -536,7 +373,7 @@ public class FileService(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<CloudFile> UpdateFileAsync(CloudFile file, FieldMask updateMask)
|
public async Task<SnCloudFile> UpdateFileAsync(SnCloudFile file, FieldMask updateMask)
|
||||||
{
|
{
|
||||||
var existingFile = await db.Files.FirstOrDefaultAsync(f => f.Id == file.Id);
|
var existingFile = await db.Files.FirstOrDefaultAsync(f => f.Id == file.Id);
|
||||||
if (existingFile == null)
|
if (existingFile == null)
|
||||||
@@ -574,11 +411,10 @@ public class FileService(
|
|||||||
await db.Files.Where(f => f.Id == file.Id).ExecuteUpdateAsync(updatable.ToSetPropertyCalls());
|
await db.Files.Where(f => f.Id == file.Id).ExecuteUpdateAsync(updatable.ToSetPropertyCalls());
|
||||||
|
|
||||||
await _PurgeCacheAsync(file.Id);
|
await _PurgeCacheAsync(file.Id);
|
||||||
// Re-fetch the file to return the updated state
|
|
||||||
return await db.Files.AsNoTracking().FirstAsync(f => f.Id == file.Id);
|
return await db.Files.AsNoTracking().FirstAsync(f => f.Id == file.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteFileAsync(CloudFile file)
|
public async Task DeleteFileAsync(SnCloudFile file)
|
||||||
{
|
{
|
||||||
db.Remove(file);
|
db.Remove(file);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -587,24 +423,21 @@ public class FileService(
|
|||||||
await DeleteFileDataAsync(file);
|
await DeleteFileDataAsync(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteFileDataAsync(CloudFile file, bool force = false)
|
public async Task DeleteFileDataAsync(SnCloudFile file, bool force = false)
|
||||||
{
|
{
|
||||||
if (!file.PoolId.HasValue) return;
|
if (!file.PoolId.HasValue) return;
|
||||||
|
|
||||||
if (!force)
|
if (!force)
|
||||||
{
|
{
|
||||||
// Check if any other file with the same storage ID is referenced
|
|
||||||
var sameOriginFiles = await db.Files
|
var sameOriginFiles = await db.Files
|
||||||
.Where(f => f.StorageId == file.StorageId && f.Id != file.Id)
|
.Where(f => f.StorageId == file.StorageId && f.Id != file.Id)
|
||||||
.Select(f => f.Id)
|
.Select(f => f.Id)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
// Check if any of these files are referenced
|
|
||||||
if (sameOriginFiles.Count != 0)
|
if (sameOriginFiles.Count != 0)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If any other file with the same storage ID is referenced, don't delete the actual file data
|
|
||||||
var dest = await GetRemoteStorageConfig(file.PoolId.Value);
|
var dest = await GetRemoteStorageConfig(file.PoolId.Value);
|
||||||
if (dest is null) throw new InvalidOperationException($"No remote storage configured for pool {file.PoolId}");
|
if (dest is null) throw new InvalidOperationException($"No remote storage configured for pool {file.PoolId}");
|
||||||
var client = CreateMinioClient(dest);
|
var client = CreateMinioClient(dest);
|
||||||
@@ -614,7 +447,7 @@ public class FileService(
|
|||||||
);
|
);
|
||||||
|
|
||||||
var bucket = dest.Bucket;
|
var bucket = dest.Bucket;
|
||||||
var objectId = file.StorageId ?? file.Id; // Use StorageId if available, otherwise fall back to Id
|
var objectId = file.StorageId ?? file.Id;
|
||||||
|
|
||||||
await client.RemoveObjectAsync(
|
await client.RemoveObjectAsync(
|
||||||
new RemoveObjectArgs().WithBucket(bucket).WithObject(objectId)
|
new RemoveObjectArgs().WithBucket(bucket).WithObject(objectId)
|
||||||
@@ -630,7 +463,6 @@ public class FileService(
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Ignore errors when deleting compressed version
|
|
||||||
logger.LogWarning("Failed to delete compressed version of file {fileId}", file.Id);
|
logger.LogWarning("Failed to delete compressed version of file {fileId}", file.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -645,25 +477,17 @@ public class FileService(
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Ignore errors when deleting thumbnail
|
|
||||||
logger.LogWarning("Failed to delete thumbnail of file {fileId}", file.Id);
|
logger.LogWarning("Failed to delete thumbnail of file {fileId}", file.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public async Task DeleteFileDataBatchAsync(List<SnCloudFile> files)
|
||||||
/// The most efficent way to delete file data (stored files) in batch.
|
|
||||||
/// But this DO NOT check the storage id, so use with caution!
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="files">Files to delete</param>
|
|
||||||
/// <exception cref="InvalidOperationException">Something went wrong</exception>
|
|
||||||
public async Task DeleteFileDataBatchAsync(List<CloudFile> files)
|
|
||||||
{
|
{
|
||||||
files = files.Where(f => f.PoolId.HasValue).ToList();
|
files = files.Where(f => f.PoolId.HasValue).ToList();
|
||||||
|
|
||||||
foreach (var fileGroup in files.GroupBy(f => f.PoolId!.Value))
|
foreach (var fileGroup in files.GroupBy(f => f.PoolId!.Value))
|
||||||
{
|
{
|
||||||
// If any other file with the same storage ID is referenced, don't delete the actual file data
|
|
||||||
var dest = await GetRemoteStorageConfig(fileGroup.Key);
|
var dest = await GetRemoteStorageConfig(fileGroup.Key);
|
||||||
if (dest is null)
|
if (dest is null)
|
||||||
throw new InvalidOperationException($"No remote storage configured for pool {fileGroup.Key}");
|
throw new InvalidOperationException($"No remote storage configured for pool {fileGroup.Key}");
|
||||||
@@ -688,7 +512,7 @@ public class FileService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<FileBundle?> GetBundleAsync(Guid id, Guid accountId)
|
private async Task<SnFileBundle?> GetBundleAsync(Guid id, Guid accountId)
|
||||||
{
|
{
|
||||||
var bundle = await db.Bundles
|
var bundle = await db.Bundles
|
||||||
.Where(e => e.Id == id)
|
.Where(e => e.Id == id)
|
||||||
@@ -733,31 +557,27 @@ public class FileService(
|
|||||||
return client.Build();
|
return client.Build();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper method to purge the cache for a specific file
|
|
||||||
// Made internal to allow FileReferenceService to use it
|
|
||||||
internal async Task _PurgeCacheAsync(string fileId)
|
internal async Task _PurgeCacheAsync(string fileId)
|
||||||
{
|
{
|
||||||
var cacheKey = $"{CacheKeyPrefix}{fileId}";
|
var cacheKey = $"{CacheKeyPrefix}{fileId}";
|
||||||
await cache.RemoveAsync(cacheKey);
|
await cache.RemoveAsync(cacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper method to purge cache for multiple files
|
|
||||||
internal async Task _PurgeCacheRangeAsync(IEnumerable<string> fileIds)
|
internal async Task _PurgeCacheRangeAsync(IEnumerable<string> fileIds)
|
||||||
{
|
{
|
||||||
var tasks = fileIds.Select(_PurgeCacheAsync);
|
var tasks = fileIds.Select(_PurgeCacheAsync);
|
||||||
await Task.WhenAll(tasks);
|
await Task.WhenAll(tasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<CloudFile?>> LoadFromReference(List<CloudFileReferenceObject> references)
|
public async Task<List<SnCloudFile?>> LoadFromReference(List<SnCloudFileReferenceObject> references)
|
||||||
{
|
{
|
||||||
var cachedFiles = new Dictionary<string, CloudFile>();
|
var cachedFiles = new Dictionary<string, SnCloudFile>();
|
||||||
var uncachedIds = new List<string>();
|
var uncachedIds = new List<string>();
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
foreach (var reference in references)
|
foreach (var reference in references)
|
||||||
{
|
{
|
||||||
var cacheKey = $"{CacheKeyPrefix}{reference.Id}";
|
var cacheKey = $"{CacheKeyPrefix}{reference.Id}";
|
||||||
var cachedFile = await cache.GetAsync<CloudFile>(cacheKey);
|
var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey);
|
||||||
|
|
||||||
if (cachedFile != null)
|
if (cachedFile != null)
|
||||||
{
|
{
|
||||||
@@ -769,14 +589,12 @@ public class FileService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load uncached files from database
|
|
||||||
if (uncachedIds.Count > 0)
|
if (uncachedIds.Count > 0)
|
||||||
{
|
{
|
||||||
var dbFiles = await db.Files
|
var dbFiles = await db.Files
|
||||||
.Where(f => uncachedIds.Contains(f.Id))
|
.Where(f => uncachedIds.Contains(f.Id))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
// Add to cache
|
|
||||||
foreach (var file in dbFiles)
|
foreach (var file in dbFiles)
|
||||||
{
|
{
|
||||||
var cacheKey = $"{CacheKeyPrefix}{file.Id}";
|
var cacheKey = $"{CacheKeyPrefix}{file.Id}";
|
||||||
@@ -785,18 +603,11 @@ public class FileService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve original order
|
return [.. references
|
||||||
return references
|
|
||||||
.Select(r => cachedFiles.GetValueOrDefault(r.Id))
|
.Select(r => cachedFiles.GetValueOrDefault(r.Id))
|
||||||
.Where(f => f != null)
|
.Where(f => f != null)];
|
||||||
.ToList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the number of references to a file based on CloudFileReference records
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="fileId">The ID of the file</param>
|
|
||||||
/// <returns>The number of references to the file</returns>
|
|
||||||
public async Task<int> GetReferenceCountAsync(string fileId)
|
public async Task<int> GetReferenceCountAsync(string fileId)
|
||||||
{
|
{
|
||||||
return await db.FileReferences
|
return await db.FileReferences
|
||||||
@@ -804,11 +615,6 @@ public class FileService(
|
|||||||
.CountAsync();
|
.CountAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a file is referenced by any resource
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="fileId">The ID of the file to check</param>
|
|
||||||
/// <returns>True if the file is referenced, false otherwise</returns>
|
|
||||||
public async Task<bool> IsReferencedAsync(string fileId)
|
public async Task<bool> IsReferencedAsync(string fileId)
|
||||||
{
|
{
|
||||||
return await db.FileReferences
|
return await db.FileReferences
|
||||||
@@ -816,12 +622,8 @@ public class FileService(
|
|||||||
.AnyAsync();
|
.AnyAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if an EXIF field should be ignored (e.g., GPS data).
|
|
||||||
/// </summary>
|
|
||||||
private static bool IsIgnoredField(string fieldName)
|
private static bool IsIgnoredField(string fieldName)
|
||||||
{
|
{
|
||||||
// Common GPS EXIF field names
|
|
||||||
var gpsFields = new[]
|
var gpsFields = new[]
|
||||||
{
|
{
|
||||||
"gps-latitude", "gps-longitude", "gps-altitude", "gps-latitude-ref", "gps-longitude-ref",
|
"gps-latitude", "gps-longitude", "gps-altitude", "gps-latitude-ref", "gps-longitude-ref",
|
||||||
@@ -882,7 +684,7 @@ public class FileService(
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> CreateFastUploadLinkAsync(CloudFile file)
|
public async Task<string> CreateFastUploadLinkAsync(SnCloudFile file)
|
||||||
{
|
{
|
||||||
if (file.PoolId is null) throw new InvalidOperationException("Pool ID is null");
|
if (file.PoolId is null) throw new InvalidOperationException("Pool ID is null");
|
||||||
|
|
||||||
@@ -904,10 +706,7 @@ public class FileService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
file class UpdatableCloudFile(SnCloudFile file)
|
||||||
/// A helper class to build an ExecuteUpdateAsync call for CloudFile.
|
|
||||||
/// </summary>
|
|
||||||
file class UpdatableCloudFile(CloudFile file)
|
|
||||||
{
|
{
|
||||||
public string Name { get; set; } = file.Name;
|
public string Name { get; set; } = file.Name;
|
||||||
public string? Description { get; set; } = file.Description;
|
public string? Description { get; set; } = file.Description;
|
||||||
@@ -915,14 +714,14 @@ file class UpdatableCloudFile(CloudFile file)
|
|||||||
public Dictionary<string, object?>? UserMeta { get; set; } = file.UserMeta;
|
public Dictionary<string, object?>? UserMeta { get; set; } = file.UserMeta;
|
||||||
public bool IsMarkedRecycle { get; set; } = file.IsMarkedRecycle;
|
public bool IsMarkedRecycle { get; set; } = file.IsMarkedRecycle;
|
||||||
|
|
||||||
public Expression<Func<SetPropertyCalls<CloudFile>, SetPropertyCalls<CloudFile>>> ToSetPropertyCalls()
|
public Expression<Func<SetPropertyCalls<SnCloudFile>, SetPropertyCalls<SnCloudFile>>> ToSetPropertyCalls()
|
||||||
{
|
{
|
||||||
var userMeta = UserMeta ?? new Dictionary<string, object?>();
|
var userMeta = UserMeta ?? [];
|
||||||
return setter => setter
|
return setter => setter
|
||||||
.SetProperty(f => f.Name, Name)
|
.SetProperty(f => f.Name, Name)
|
||||||
.SetProperty(f => f.Description, Description)
|
.SetProperty(f => f.Description, Description)
|
||||||
.SetProperty(f => f.FileMeta, FileMeta)
|
.SetProperty(f => f.FileMeta, FileMeta)
|
||||||
.SetProperty(f => f.UserMeta, userMeta!)
|
.SetProperty(f => f.UserMeta, userMeta)
|
||||||
.SetProperty(f => f.IsMarkedRecycle, IsMarkedRecycle);
|
.SetProperty(f => f.IsMarkedRecycle, IsMarkedRecycle);
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -1,4 +1,4 @@
|
|||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
@@ -48,7 +48,7 @@ namespace DysonNetwork.Drive.Storage
|
|||||||
{
|
{
|
||||||
// Assuming CloudFileReferenceObject is a simple class/struct that holds an ID
|
// Assuming CloudFileReferenceObject is a simple class/struct that holds an ID
|
||||||
// You might need to define this or adjust the LoadFromReference method in FileService
|
// You might need to define this or adjust the LoadFromReference method in FileService
|
||||||
var references = request.ReferenceIds.Select(id => new CloudFileReferenceObject { Id = id }).ToList();
|
var references = request.ReferenceIds.Select(id => new SnCloudFileReferenceObject { Id = id }).ToList();
|
||||||
var files = await fileService.LoadFromReference(references);
|
var files = await fileService.LoadFromReference(references);
|
||||||
var response = new LoadFromReferenceResponse();
|
var response = new LoadFromReferenceResponse();
|
||||||
response.Files.AddRange(files.Where(f => f != null).Select(f => f!.ToProtoValue()));
|
response.Files.AddRange(files.Where(f => f != null).Select(f => f!.ToProtoValue()));
|
||||||
|
@@ -1,7 +1,9 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DysonNetwork.Drive.Billing;
|
using DysonNetwork.Drive.Billing;
|
||||||
using DysonNetwork.Drive.Storage.Model;
|
using DysonNetwork.Drive.Storage.Model;
|
||||||
using DysonNetwork.Shared.Auth;
|
using DysonNetwork.Shared.Auth;
|
||||||
|
using DysonNetwork.Shared.Http;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -23,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);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
15
DysonNetwork.Drive/Storage/Model/Events.cs
Normal file
15
DysonNetwork.Drive/Storage/Model/Events.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace DysonNetwork.Drive.Storage.Model;
|
||||||
|
|
||||||
|
public static class FileUploadedEvent
|
||||||
|
{
|
||||||
|
public const string Type = "file_uploaded";
|
||||||
|
}
|
||||||
|
|
||||||
|
public record FileUploadedEventPayload(
|
||||||
|
string FileId,
|
||||||
|
Guid RemoteId,
|
||||||
|
string StorageId,
|
||||||
|
string ContentType,
|
||||||
|
string ProcessingFilePath,
|
||||||
|
bool IsTempFile
|
||||||
|
);
|
@@ -1,4 +1,4 @@
|
|||||||
using DysonNetwork.Drive.Storage;
|
using DysonNetwork.Shared.Models;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
namespace DysonNetwork.Drive.Storage.Model
|
namespace DysonNetwork.Drive.Storage.Model
|
||||||
@@ -9,8 +9,8 @@ namespace DysonNetwork.Drive.Storage.Model
|
|||||||
public string FileName { get; set; } = null!;
|
public string FileName { get; set; } = null!;
|
||||||
public long FileSize { get; set; }
|
public long FileSize { get; set; }
|
||||||
public string ContentType { get; set; } = null!;
|
public string ContentType { get; set; } = null!;
|
||||||
public string PoolId { get; set; } = null!;
|
public Guid? PoolId { get; set; } = null!;
|
||||||
public string? BundleId { get; set; }
|
public Guid? BundleId { get; set; }
|
||||||
public string? EncryptPassword { get; set; }
|
public string? EncryptPassword { get; set; }
|
||||||
public Instant? ExpiredAt { get; set; }
|
public Instant? ExpiredAt { get; set; }
|
||||||
public long? ChunkSize { get; set; }
|
public long? ChunkSize { get; set; }
|
||||||
@@ -19,7 +19,7 @@ namespace DysonNetwork.Drive.Storage.Model
|
|||||||
public class CreateUploadTaskResponse
|
public class CreateUploadTaskResponse
|
||||||
{
|
{
|
||||||
public bool FileExists { get; set; }
|
public bool FileExists { get; set; }
|
||||||
public CloudFile? File { get; set; }
|
public SnCloudFile? File { get; set; }
|
||||||
public string? TaskId { get; set; }
|
public string? TaskId { get; set; }
|
||||||
public long? ChunkSize { get; set; }
|
public long? ChunkSize { get; set; }
|
||||||
public int? ChunksCount { get; set; }
|
public int? ChunksCount { get; set; }
|
||||||
@@ -33,8 +33,8 @@ namespace DysonNetwork.Drive.Storage.Model
|
|||||||
public string ContentType { get; set; } = null!;
|
public string ContentType { get; set; } = null!;
|
||||||
public long ChunkSize { get; set; }
|
public long ChunkSize { get; set; }
|
||||||
public int ChunksCount { get; set; }
|
public int ChunksCount { get; set; }
|
||||||
public string PoolId { get; set; } = null!;
|
public Guid PoolId { get; set; }
|
||||||
public string? BundleId { get; set; }
|
public Guid? BundleId { get; set; }
|
||||||
public string? EncryptPassword { get; set; }
|
public string? EncryptPassword { get; set; }
|
||||||
public Instant? ExpiredAt { get; set; }
|
public Instant? ExpiredAt { get; set; }
|
||||||
public string Hash { get; set; } = null!;
|
public string Hash { get; set; } = null!;
|
||||||
|
@@ -16,7 +16,7 @@ To begin a file upload, you first need to create an upload task. This is done by
|
|||||||
"file_name": "string",
|
"file_name": "string",
|
||||||
"file_size": "long (in bytes)",
|
"file_size": "long (in bytes)",
|
||||||
"content_type": "string (e.g., 'image/jpeg')",
|
"content_type": "string (e.g., 'image/jpeg')",
|
||||||
"pool_id": "string (GUID)",
|
"pool_id": "string (GUID, optional)",
|
||||||
"bundle_id": "string (GUID, optional)",
|
"bundle_id": "string (GUID, optional)",
|
||||||
"encrypt_password": "string (optional)",
|
"encrypt_password": "string (optional)",
|
||||||
"expired_at": "string (ISO 8601 format, optional)",
|
"expired_at": "string (ISO 8601 format, optional)",
|
||||||
|
@@ -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 =>
|
||||||
{
|
{
|
||||||
|
@@ -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"
|
||||||
},
|
},
|
||||||
|
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"lockfileVersion": 1,
|
|
||||||
"workspaces": {
|
|
||||||
"": {
|
|
||||||
"dependencies": {
|
|
||||||
"highlight.js": "^11.11.1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"packages": {
|
|
||||||
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
|
||||||
}
|
|
||||||
}
|
|
@@ -0,0 +1,12 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("config")]
|
||||||
|
public class ConfigurationController(IConfiguration configuration) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public IActionResult Get() => Ok(configuration.GetSection("Client").Get<Dictionary<string, object>>());
|
||||||
|
|
||||||
|
[HttpGet("site")]
|
||||||
|
public IActionResult GetSiteUrl() => Ok(configuration["SiteUrl"]);
|
||||||
|
}
|
23
DysonNetwork.Gateway/Dockerfile
Normal file
23
DysonNetwork.Gateway/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
||||||
|
USER $APP_UID
|
||||||
|
WORKDIR /app
|
||||||
|
EXPOSE 8080
|
||||||
|
EXPOSE 8081
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||||
|
ARG BUILD_CONFIGURATION=Release
|
||||||
|
WORKDIR /src
|
||||||
|
COPY ["DysonNetwork.Gateway/DysonNetwork.Gateway.csproj", "DysonNetwork.Gateway/"]
|
||||||
|
RUN dotnet restore "DysonNetwork.Gateway/DysonNetwork.Gateway.csproj"
|
||||||
|
COPY . .
|
||||||
|
WORKDIR "/src/DysonNetwork.Gateway"
|
||||||
|
RUN dotnet build "./DysonNetwork.Gateway.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||||
|
|
||||||
|
FROM build AS publish
|
||||||
|
ARG BUILD_CONFIGURATION=Release
|
||||||
|
RUN dotnet publish "./DysonNetwork.Gateway.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
|
FROM base AS final
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=publish /app/publish .
|
||||||
|
ENTRYPOINT ["dotnet", "DysonNetwork.Gateway.dll"]
|
18
DysonNetwork.Gateway/DysonNetwork.Gateway.csproj
Normal file
18
DysonNetwork.Gateway/DysonNetwork.Gateway.csproj
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery.Yarp" Version="9.4.2" />
|
||||||
|
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
171
DysonNetwork.Gateway/Program.cs
Normal file
171
DysonNetwork.Gateway/Program.cs
Normal 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();
|
21
DysonNetwork.Gateway/Properties/launchSettings.json
Normal file
21
DysonNetwork.Gateway/Properties/launchSettings.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
13
DysonNetwork.Gateway/appsettings.json
Normal file
13
DysonNetwork.Gateway/appsettings.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"SiteUrl": "http://localhost:3000",
|
||||||
|
"Client": {
|
||||||
|
"SomeSetting": "SomeValue"
|
||||||
|
}
|
||||||
|
}
|
@@ -2,8 +2,9 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
using DysonNetwork.Pass.Auth;
|
using DysonNetwork.Pass.Auth;
|
||||||
using DysonNetwork.Pass.Credit;
|
using DysonNetwork.Pass.Credit;
|
||||||
using DysonNetwork.Pass.Wallet;
|
using DysonNetwork.Pass.Wallet;
|
||||||
using DysonNetwork.Shared.Error;
|
|
||||||
using DysonNetwork.Shared.GeoIp;
|
using DysonNetwork.Shared.GeoIp;
|
||||||
|
using DysonNetwork.Shared.Http;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
@@ -23,9 +24,9 @@ public class AccountController(
|
|||||||
) : ControllerBase
|
) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("{name}")]
|
[HttpGet("{name}")]
|
||||||
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
|
[ProducesResponseType<SnAccount>(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<Account?>> GetByName(string name)
|
public async Task<ActionResult<SnAccount?>> GetByName(string name)
|
||||||
{
|
{
|
||||||
var account = await db.Accounts
|
var account = await db.Accounts
|
||||||
.Include(e => e.Badges)
|
.Include(e => e.Badges)
|
||||||
@@ -42,9 +43,9 @@ public class AccountController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{name}/badges")]
|
[HttpGet("{name}/badges")]
|
||||||
[ProducesResponseType<List<AccountBadge>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<List<SnAccountBadge>>(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<List<AccountBadge>>> GetBadgesByName(string name)
|
public async Task<ActionResult<List<SnAccountBadge>>> GetBadgesByName(string name)
|
||||||
{
|
{
|
||||||
var account = await db.Accounts
|
var account = await db.Accounts
|
||||||
.Include(e => e.Badges)
|
.Include(e => e.Badges)
|
||||||
@@ -103,9 +104,9 @@ public class AccountController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
|
[ProducesResponseType<SnAccount>(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
public async Task<ActionResult<Account>> CreateAccount([FromBody] AccountCreateRequest request)
|
public async Task<ActionResult<SnAccount>> CreateAccount([FromBody] AccountCreateRequest request)
|
||||||
{
|
{
|
||||||
if (!await auth.ValidateCaptcha(request.CaptchaToken))
|
if (!await auth.ValidateCaptcha(request.CaptchaToken))
|
||||||
return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
|
return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
|
||||||
@@ -194,11 +195,12 @@ public class AccountController(
|
|||||||
public bool IsAutomated { get; set; } = false;
|
public bool IsAutomated { get; set; } = false;
|
||||||
[MaxLength(1024)] public string? Label { get; set; }
|
[MaxLength(1024)] public string? Label { get; set; }
|
||||||
[MaxLength(4096)] public string? AppIdentifier { get; set; }
|
[MaxLength(4096)] public string? AppIdentifier { get; set; }
|
||||||
|
public Dictionary<string, object>? Meta { get; set; }
|
||||||
public Instant? ClearedAt { get; set; }
|
public Instant? ClearedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{name}/statuses")]
|
[HttpGet("{name}/statuses")]
|
||||||
public async Task<ActionResult<Status>> GetOtherStatus(string name)
|
public async Task<ActionResult<SnAccountStatus>> GetOtherStatus(string name)
|
||||||
{
|
{
|
||||||
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name);
|
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name);
|
||||||
if (account is null)
|
if (account is null)
|
||||||
@@ -253,7 +255,7 @@ public class AccountController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("search")]
|
[HttpGet("search")]
|
||||||
public async Task<List<Account>> Search([FromQuery] string query, [FromQuery] int take = 20)
|
public async Task<List<SnAccount>> Search([FromQuery] string query, [FromQuery] int take = 20)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(query))
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
return [];
|
return [];
|
||||||
|
@@ -1,16 +1,15 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using DysonNetwork.Pass.Auth;
|
|
||||||
using DysonNetwork.Pass.Permission;
|
using DysonNetwork.Pass.Permission;
|
||||||
using DysonNetwork.Pass.Wallet;
|
using DysonNetwork.Pass.Wallet;
|
||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Http;
|
||||||
using DysonNetwork.Shared.Error;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using AuthService = DysonNetwork.Pass.Auth.AuthService;
|
using AuthService = DysonNetwork.Pass.Auth.AuthService;
|
||||||
using AuthSession = DysonNetwork.Pass.Auth.AuthSession;
|
using SnAuthSession = DysonNetwork.Shared.Models.SnAuthSession;
|
||||||
|
|
||||||
namespace DysonNetwork.Pass.Account;
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
@@ -29,11 +28,11 @@ public class AccountCurrentController(
|
|||||||
) : ControllerBase
|
) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
|
[ProducesResponseType<SnAccount>(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType<ApiError>(StatusCodes.Status401Unauthorized)]
|
[ProducesResponseType<ApiError>(StatusCodes.Status401Unauthorized)]
|
||||||
public async Task<ActionResult<Account>> GetCurrentIdentity()
|
public async Task<ActionResult<SnAccount>> GetCurrentIdentity()
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
var userId = currentUser.Id;
|
var userId = currentUser.Id;
|
||||||
|
|
||||||
var account = await db.Accounts
|
var account = await db.Accounts
|
||||||
@@ -56,9 +55,9 @@ public class AccountCurrentController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch]
|
[HttpPatch]
|
||||||
public async Task<ActionResult<Account>> UpdateBasicInfo([FromBody] BasicInfoRequest request)
|
public async Task<ActionResult<SnAccount>> UpdateBasicInfo([FromBody] BasicInfoRequest request)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
var account = await db.Accounts.FirstAsync(a => a.Id == currentUser.Id);
|
var account = await db.Accounts.FirstAsync(a => a.Id == currentUser.Id);
|
||||||
|
|
||||||
@@ -81,6 +80,7 @@ public class AccountCurrentController(
|
|||||||
[MaxLength(1024)] public string? TimeZone { get; set; }
|
[MaxLength(1024)] public string? TimeZone { get; set; }
|
||||||
[MaxLength(1024)] public string? Location { get; set; }
|
[MaxLength(1024)] public string? Location { get; set; }
|
||||||
[MaxLength(4096)] public string? Bio { get; set; }
|
[MaxLength(4096)] public string? Bio { get; set; }
|
||||||
|
public Shared.Models.UsernameColor? UsernameColor { get; set; }
|
||||||
public Instant? Birthday { get; set; }
|
public Instant? Birthday { get; set; }
|
||||||
public List<ProfileLink>? Links { get; set; }
|
public List<ProfileLink>? Links { get; set; }
|
||||||
|
|
||||||
@@ -89,9 +89,9 @@ public class AccountCurrentController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch("profile")]
|
[HttpPatch("profile")]
|
||||||
public async Task<ActionResult<AccountProfile>> UpdateProfile([FromBody] ProfileRequest request)
|
public async Task<ActionResult<SnAccountProfile>> UpdateProfile([FromBody] ProfileRequest request)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
var userId = currentUser.Id;
|
var userId = currentUser.Id;
|
||||||
|
|
||||||
var profile = await db.AccountProfiles
|
var profile = await db.AccountProfiles
|
||||||
@@ -116,6 +116,7 @@ public class AccountCurrentController(
|
|||||||
if (request.Location is not null) profile.Location = request.Location;
|
if (request.Location is not null) profile.Location = request.Location;
|
||||||
if (request.TimeZone is not null) profile.TimeZone = request.TimeZone;
|
if (request.TimeZone is not null) profile.TimeZone = request.TimeZone;
|
||||||
if (request.Links is not null) profile.Links = request.Links;
|
if (request.Links is not null) profile.Links = request.Links;
|
||||||
|
if (request.UsernameColor is not null) profile.UsernameColor = request.UsernameColor;
|
||||||
|
|
||||||
if (request.PictureId is not null)
|
if (request.PictureId is not null)
|
||||||
{
|
{
|
||||||
@@ -132,7 +133,7 @@ public class AccountCurrentController(
|
|||||||
Usage = "profile.picture"
|
Usage = "profile.picture"
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
profile.Picture = CloudFileReferenceObject.FromProtoValue(file);
|
profile.Picture = SnCloudFileReferenceObject.FromProtoValue(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.BackgroundId is not null)
|
if (request.BackgroundId is not null)
|
||||||
@@ -150,7 +151,7 @@ public class AccountCurrentController(
|
|||||||
Usage = "profile.background"
|
Usage = "profile.background"
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
profile.Background = CloudFileReferenceObject.FromProtoValue(file);
|
profile.Background = SnCloudFileReferenceObject.FromProtoValue(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
db.Update(profile);
|
db.Update(profile);
|
||||||
@@ -164,7 +165,7 @@ public class AccountCurrentController(
|
|||||||
[HttpDelete]
|
[HttpDelete]
|
||||||
public async Task<ActionResult> RequestDeleteAccount()
|
public async Task<ActionResult> RequestDeleteAccount()
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -185,18 +186,18 @@ public class AccountCurrentController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("statuses")]
|
[HttpGet("statuses")]
|
||||||
public async Task<ActionResult<Status>> GetCurrentStatus()
|
public async Task<ActionResult<SnAccountStatus>> GetCurrentStatus()
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
var status = await events.GetStatus(currentUser.Id);
|
var status = await events.GetStatus(currentUser.Id);
|
||||||
return Ok(status);
|
return Ok(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch("statuses")]
|
[HttpPatch("statuses")]
|
||||||
[RequiredPermission("global", "accounts.statuses.update")]
|
[RequiredPermission("global", "accounts.statuses.update")]
|
||||||
public async Task<ActionResult<Status>> UpdateStatus([FromBody] AccountController.StatusRequest request)
|
public async Task<ActionResult<SnAccountStatus>> UpdateStatus([FromBody] AccountController.StatusRequest request)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
if (request is { IsAutomated: true, AppIdentifier: not null })
|
if (request is { IsAutomated: true, AppIdentifier: not null })
|
||||||
return BadRequest("Automated status cannot be updated.");
|
return BadRequest("Automated status cannot be updated.");
|
||||||
|
|
||||||
@@ -216,6 +217,7 @@ public class AccountCurrentController(
|
|||||||
status.IsAutomated = request.IsAutomated;
|
status.IsAutomated = request.IsAutomated;
|
||||||
status.Label = request.Label;
|
status.Label = request.Label;
|
||||||
status.AppIdentifier = request.AppIdentifier;
|
status.AppIdentifier = request.AppIdentifier;
|
||||||
|
status.Meta = request.Meta;
|
||||||
status.ClearedAt = request.ClearedAt;
|
status.ClearedAt = request.ClearedAt;
|
||||||
|
|
||||||
db.Update(status);
|
db.Update(status);
|
||||||
@@ -227,9 +229,9 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
[HttpPost("statuses")]
|
[HttpPost("statuses")]
|
||||||
[RequiredPermission("global", "accounts.statuses.create")]
|
[RequiredPermission("global", "accounts.statuses.create")]
|
||||||
public async Task<ActionResult<Status>> CreateStatus([FromBody] AccountController.StatusRequest request)
|
public async Task<ActionResult<SnAccountStatus>> CreateStatus([FromBody] AccountController.StatusRequest request)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
if (request is { IsAutomated: true, AppIdentifier: not null })
|
if (request is { IsAutomated: true, AppIdentifier: not null })
|
||||||
{
|
{
|
||||||
@@ -245,6 +247,7 @@ public class AccountCurrentController(
|
|||||||
existingStatus.Attitude = request.Attitude;
|
existingStatus.Attitude = request.Attitude;
|
||||||
existingStatus.IsInvisible = request.IsInvisible;
|
existingStatus.IsInvisible = request.IsInvisible;
|
||||||
existingStatus.IsNotDisturb = request.IsNotDisturb;
|
existingStatus.IsNotDisturb = request.IsNotDisturb;
|
||||||
|
existingStatus.Meta = request.Meta;
|
||||||
existingStatus.Label = request.Label;
|
existingStatus.Label = request.Label;
|
||||||
db.Update(existingStatus);
|
db.Update(existingStatus);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -260,7 +263,7 @@ public class AccountCurrentController(
|
|||||||
return Ok(existingStatus); // Do not override manually set status with automated ones
|
return Ok(existingStatus); // Do not override manually set status with automated ones
|
||||||
}
|
}
|
||||||
|
|
||||||
var status = new Status
|
var status = new SnAccountStatus
|
||||||
{
|
{
|
||||||
AccountId = currentUser.Id,
|
AccountId = currentUser.Id,
|
||||||
Attitude = request.Attitude,
|
Attitude = request.Attitude,
|
||||||
@@ -268,6 +271,7 @@ public class AccountCurrentController(
|
|||||||
IsNotDisturb = request.IsNotDisturb,
|
IsNotDisturb = request.IsNotDisturb,
|
||||||
IsAutomated = request.IsAutomated,
|
IsAutomated = request.IsAutomated,
|
||||||
Label = request.Label,
|
Label = request.Label,
|
||||||
|
Meta = request.Meta,
|
||||||
AppIdentifier = request.AppIdentifier,
|
AppIdentifier = request.AppIdentifier,
|
||||||
ClearedAt = request.ClearedAt
|
ClearedAt = request.ClearedAt
|
||||||
};
|
};
|
||||||
@@ -278,7 +282,7 @@ public class AccountCurrentController(
|
|||||||
[HttpDelete("statuses")]
|
[HttpDelete("statuses")]
|
||||||
public async Task<ActionResult> DeleteStatus([FromQuery] string? app)
|
public async Task<ActionResult> DeleteStatus([FromQuery] string? app)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
var queryable = db.AccountStatuses
|
var queryable = db.AccountStatuses
|
||||||
@@ -287,7 +291,7 @@ public class AccountCurrentController(
|
|||||||
.OrderByDescending(s => s.CreatedAt)
|
.OrderByDescending(s => s.CreatedAt)
|
||||||
.AsQueryable();
|
.AsQueryable();
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(app))
|
if (!string.IsNullOrWhiteSpace(app))
|
||||||
queryable = queryable.Where(s => s.IsAutomated && s.AppIdentifier == app);
|
queryable = queryable.Where(s => s.IsAutomated && s.AppIdentifier == app);
|
||||||
|
|
||||||
var status = await queryable
|
var status = await queryable
|
||||||
@@ -299,9 +303,9 @@ public class AccountCurrentController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("check-in")]
|
[HttpGet("check-in")]
|
||||||
public async Task<ActionResult<CheckInResult>> GetCheckInResult()
|
public async Task<ActionResult<SnCheckInResult>> GetCheckInResult()
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
var userId = currentUser.Id;
|
var userId = currentUser.Id;
|
||||||
|
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
@@ -321,12 +325,12 @@ public class AccountCurrentController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("check-in")]
|
[HttpPost("check-in")]
|
||||||
public async Task<ActionResult<CheckInResult>> DoCheckIn(
|
public async Task<ActionResult<SnCheckInResult>> DoCheckIn(
|
||||||
[FromBody] string? captchaToken,
|
[FromBody] string? captchaToken,
|
||||||
[FromQuery] Instant? backdated = null
|
[FromQuery] Instant? backdated = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
if (backdated is null)
|
if (backdated is null)
|
||||||
{
|
{
|
||||||
@@ -397,7 +401,7 @@ public class AccountCurrentController(
|
|||||||
public async Task<ActionResult<List<DailyEventResponse>>> GetEventCalendar([FromQuery] int? month,
|
public async Task<ActionResult<List<DailyEventResponse>>> GetEventCalendar([FromQuery] int? month,
|
||||||
[FromQuery] int? year)
|
[FromQuery] int? year)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
var currentDate = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
|
var currentDate = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
|
||||||
month ??= currentDate.Month;
|
month ??= currentDate.Month;
|
||||||
@@ -426,7 +430,7 @@ public class AccountCurrentController(
|
|||||||
[FromQuery] int offset = 0
|
[FromQuery] int offset = 0
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
var query = db.ActionLogs
|
var query = db.ActionLogs
|
||||||
.Where(log => log.AccountId == currentUser.Id)
|
.Where(log => log.AccountId == currentUser.Id)
|
||||||
@@ -444,9 +448,9 @@ public class AccountCurrentController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("factors")]
|
[HttpGet("factors")]
|
||||||
public async Task<ActionResult<List<AccountAuthFactor>>> GetAuthFactors()
|
public async Task<ActionResult<List<SnAccountAuthFactor>>> GetAuthFactors()
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
var factors = await db.AccountAuthFactors
|
var factors = await db.AccountAuthFactors
|
||||||
.Include(f => f.Account)
|
.Include(f => f.Account)
|
||||||
@@ -458,15 +462,15 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
public class AuthFactorRequest
|
public class AuthFactorRequest
|
||||||
{
|
{
|
||||||
public AccountAuthFactorType Type { get; set; }
|
public Shared.Models.AccountAuthFactorType Type { get; set; }
|
||||||
public string? Secret { get; set; }
|
public string? Secret { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("factors")]
|
[HttpPost("factors")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<AccountAuthFactor>> CreateAuthFactor([FromBody] AuthFactorRequest request)
|
public async Task<ActionResult<SnAccountAuthFactor>> CreateAuthFactor([FromBody] AuthFactorRequest request)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
if (await accounts.CheckAuthFactorExists(currentUser, request.Type))
|
if (await accounts.CheckAuthFactorExists(currentUser, request.Type))
|
||||||
return BadRequest(new ApiError
|
return BadRequest(new ApiError
|
||||||
{
|
{
|
||||||
@@ -482,9 +486,9 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
[HttpPost("factors/{id:guid}/enable")]
|
[HttpPost("factors/{id:guid}/enable")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<AccountAuthFactor>> EnableAuthFactor(Guid id, [FromBody] string? code)
|
public async Task<ActionResult<SnAccountAuthFactor>> EnableAuthFactor(Guid id, [FromBody] string? code)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
var factor = await db.AccountAuthFactors
|
var factor = await db.AccountAuthFactors
|
||||||
.Where(f => f.AccountId == currentUser.Id && f.Id == id)
|
.Where(f => f.AccountId == currentUser.Id && f.Id == id)
|
||||||
@@ -511,9 +515,9 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
[HttpPost("factors/{id:guid}/disable")]
|
[HttpPost("factors/{id:guid}/disable")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<AccountAuthFactor>> DisableAuthFactor(Guid id)
|
public async Task<ActionResult<SnAccountAuthFactor>> DisableAuthFactor(Guid id)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
var factor = await db.AccountAuthFactors
|
var factor = await db.AccountAuthFactors
|
||||||
.Where(f => f.AccountId == currentUser.Id && f.Id == id)
|
.Where(f => f.AccountId == currentUser.Id && f.Id == id)
|
||||||
@@ -533,9 +537,9 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
[HttpDelete("factors/{id:guid}")]
|
[HttpDelete("factors/{id:guid}")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<AccountAuthFactor>> DeleteAuthFactor(Guid id)
|
public async Task<ActionResult<SnAccountAuthFactor>> DeleteAuthFactor(Guid id)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
var factor = await db.AccountAuthFactors
|
var factor = await db.AccountAuthFactors
|
||||||
.Where(f => f.AccountId == currentUser.Id && f.Id == id)
|
.Where(f => f.AccountId == currentUser.Id && f.Id == id)
|
||||||
@@ -555,10 +559,10 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
[HttpGet("devices")]
|
[HttpGet("devices")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<List<AuthClientWithChallenge>>> GetDevices()
|
public async Task<ActionResult<List<SnAuthClientWithChallenge>>> GetDevices()
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
|
||||||
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
|
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
|
||||||
|
|
||||||
Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
|
Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
|
||||||
|
|
||||||
@@ -566,7 +570,7 @@ public class AccountCurrentController(
|
|||||||
.Where(device => device.AccountId == currentUser.Id)
|
.Where(device => device.AccountId == currentUser.Id)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var challengeDevices = devices.Select(AuthClientWithChallenge.FromClient).ToList();
|
var challengeDevices = devices.Select(SnAuthClientWithChallenge.FromClient).ToList();
|
||||||
var deviceIds = challengeDevices.Select(x => x.Id).ToList();
|
var deviceIds = challengeDevices.Select(x => x.Id).ToList();
|
||||||
|
|
||||||
var authChallenges = await db.AuthChallenges
|
var authChallenges = await db.AuthChallenges
|
||||||
@@ -582,13 +586,13 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
[HttpGet("sessions")]
|
[HttpGet("sessions")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<List<AuthSession>>> GetSessions(
|
public async Task<ActionResult<List<SnAuthSession>>> GetSessions(
|
||||||
[FromQuery] int take = 20,
|
[FromQuery] int take = 20,
|
||||||
[FromQuery] int offset = 0
|
[FromQuery] int offset = 0
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
|
||||||
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
|
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
|
||||||
|
|
||||||
var query = db.AuthSessions
|
var query = db.AuthSessions
|
||||||
.Include(session => session.Account)
|
.Include(session => session.Account)
|
||||||
@@ -610,9 +614,9 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
[HttpDelete("sessions/{id:guid}")]
|
[HttpDelete("sessions/{id:guid}")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<AuthSession>> DeleteSession(Guid id)
|
public async Task<ActionResult<SnAuthSession>> DeleteSession(Guid id)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -627,9 +631,9 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
[HttpDelete("devices/{deviceId}")]
|
[HttpDelete("devices/{deviceId}")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<AuthSession>> DeleteDevice(string deviceId)
|
public async Task<ActionResult<SnAuthSession>> DeleteDevice(string deviceId)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -644,10 +648,10 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
[HttpDelete("sessions/current")]
|
[HttpDelete("sessions/current")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<AuthSession>> DeleteCurrentSession()
|
public async Task<ActionResult<SnAuthSession>> DeleteCurrentSession()
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
|
||||||
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
|
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -662,9 +666,9 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
[HttpPatch("devices/{deviceId}/label")]
|
[HttpPatch("devices/{deviceId}/label")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<AuthSession>> UpdateDeviceLabel(string deviceId, [FromBody] string label)
|
public async Task<ActionResult<SnAuthSession>> UpdateDeviceLabel(string deviceId, [FromBody] string label)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -679,10 +683,10 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
[HttpPatch("devices/current/label")]
|
[HttpPatch("devices/current/label")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<AuthSession>> UpdateCurrentDeviceLabel([FromBody] string label)
|
public async Task<ActionResult<SnAuthSession>> UpdateCurrentDeviceLabel([FromBody] string label)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
|
||||||
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
|
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
|
||||||
|
|
||||||
var device = await db.AuthClients.FirstOrDefaultAsync(d => d.Id == currentSession.Challenge.ClientId);
|
var device = await db.AuthClients.FirstOrDefaultAsync(d => d.Id == currentSession.Challenge.ClientId);
|
||||||
if (device is null) return NotFound();
|
if (device is null) return NotFound();
|
||||||
@@ -700,9 +704,9 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
[HttpGet("contacts")]
|
[HttpGet("contacts")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<List<AccountContact>>> GetContacts()
|
public async Task<ActionResult<List<SnAccountContact>>> GetContacts()
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
var contacts = await db.AccountContacts
|
var contacts = await db.AccountContacts
|
||||||
.Where(c => c.AccountId == currentUser.Id)
|
.Where(c => c.AccountId == currentUser.Id)
|
||||||
@@ -713,15 +717,15 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
public class AccountContactRequest
|
public class AccountContactRequest
|
||||||
{
|
{
|
||||||
[Required] public AccountContactType Type { get; set; }
|
[Required] public Shared.Models.AccountContactType Type { get; set; }
|
||||||
[Required] public string Content { get; set; } = null!;
|
[Required] public string Content { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("contacts")]
|
[HttpPost("contacts")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<AccountContact>> CreateContact([FromBody] AccountContactRequest request)
|
public async Task<ActionResult<SnAccountContact>> CreateContact([FromBody] AccountContactRequest request)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -736,9 +740,9 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
[HttpPost("contacts/{id:guid}/verify")]
|
[HttpPost("contacts/{id:guid}/verify")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<AccountContact>> VerifyContact(Guid id)
|
public async Task<ActionResult<SnAccountContact>> VerifyContact(Guid id)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
var contact = await db.AccountContacts
|
var contact = await db.AccountContacts
|
||||||
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
|
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
|
||||||
@@ -758,9 +762,9 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
[HttpPost("contacts/{id:guid}/primary")]
|
[HttpPost("contacts/{id:guid}/primary")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<AccountContact>> SetPrimaryContact(Guid id)
|
public async Task<ActionResult<SnAccountContact>> SetPrimaryContact(Guid id)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
var contact = await db.AccountContacts
|
var contact = await db.AccountContacts
|
||||||
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
|
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
|
||||||
@@ -780,9 +784,9 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
[HttpPost("contacts/{id:guid}/public")]
|
[HttpPost("contacts/{id:guid}/public")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<AccountContact>> SetPublicContact(Guid id)
|
public async Task<ActionResult<SnAccountContact>> SetPublicContact(Guid id)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
var contact = await db.AccountContacts
|
var contact = await db.AccountContacts
|
||||||
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
|
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
|
||||||
@@ -802,9 +806,9 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
[HttpDelete("contacts/{id:guid}/public")]
|
[HttpDelete("contacts/{id:guid}/public")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<AccountContact>> UnsetPublicContact(Guid id)
|
public async Task<ActionResult<SnAccountContact>> UnsetPublicContact(Guid id)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
var contact = await db.AccountContacts
|
var contact = await db.AccountContacts
|
||||||
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
|
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
|
||||||
@@ -824,9 +828,9 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
[HttpDelete("contacts/{id:guid}")]
|
[HttpDelete("contacts/{id:guid}")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<AccountContact>> DeleteContact(Guid id)
|
public async Task<ActionResult<SnAccountContact>> DeleteContact(Guid id)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
var contact = await db.AccountContacts
|
var contact = await db.AccountContacts
|
||||||
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
|
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
|
||||||
@@ -845,11 +849,11 @@ public class AccountCurrentController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("badges")]
|
[HttpGet("badges")]
|
||||||
[ProducesResponseType<List<AccountBadge>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<List<SnAccountBadge>>(StatusCodes.Status200OK)]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<List<AccountBadge>>> GetBadges()
|
public async Task<ActionResult<List<SnAccountBadge>>> GetBadges()
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
var badges = await db.Badges
|
var badges = await db.Badges
|
||||||
.Where(b => b.AccountId == currentUser.Id)
|
.Where(b => b.AccountId == currentUser.Id)
|
||||||
@@ -859,9 +863,9 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
[HttpPost("badges/{id:guid}/active")]
|
[HttpPost("badges/{id:guid}/active")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<AccountBadge>> ActivateBadge(Guid id)
|
public async Task<ActionResult<SnAccountBadge>> ActivateBadge(Guid id)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -876,12 +880,12 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
[HttpGet("leveling")]
|
[HttpGet("leveling")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<ExperienceRecord>> GetLevelingHistory(
|
public async Task<ActionResult<SnExperienceRecord>> GetLevelingHistory(
|
||||||
[FromQuery] int take = 20,
|
[FromQuery] int take = 20,
|
||||||
[FromQuery] int offset = 0
|
[FromQuery] int offset = 0
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
var queryable = db.ExperienceRecords
|
var queryable = db.ExperienceRecords
|
||||||
.Where(r => r.AccountId == currentUser.Id)
|
.Where(r => r.AccountId == currentUser.Id)
|
||||||
@@ -901,7 +905,7 @@ public class AccountCurrentController(
|
|||||||
[HttpGet("credits")]
|
[HttpGet("credits")]
|
||||||
public async Task<ActionResult<bool>> GetSocialCredit()
|
public async Task<ActionResult<bool>> GetSocialCredit()
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
var credit = await creditService.GetSocialCredit(currentUser.Id);
|
var credit = await creditService.GetSocialCredit(currentUser.Id);
|
||||||
return Ok(credit);
|
return Ok(credit);
|
||||||
@@ -913,7 +917,7 @@ public class AccountCurrentController(
|
|||||||
[FromQuery] int offset = 0
|
[FromQuery] int offset = 0
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
var queryable = db.SocialCreditRecords
|
var queryable = db.SocialCreditRecords
|
||||||
.Where(r => r.AccountId == currentUser.Id)
|
.Where(r => r.AccountId == currentUser.Id)
|
||||||
|
@@ -1,9 +1,13 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using DysonNetwork.Pass.Wallet;
|
using DysonNetwork.Pass.Wallet;
|
||||||
using DysonNetwork.Shared.Cache;
|
using DysonNetwork.Shared.Cache;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using DysonNetwork.Shared.Stream;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
|
using NATS.Client.Core;
|
||||||
|
using NATS.Net;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using NodaTime.Extensions;
|
using NodaTime.Extensions;
|
||||||
|
|
||||||
@@ -16,7 +20,8 @@ public class AccountEventService(
|
|||||||
IStringLocalizer<Localization.AccountEventResource> localizer,
|
IStringLocalizer<Localization.AccountEventResource> localizer,
|
||||||
RingService.RingServiceClient pusher,
|
RingService.RingServiceClient pusher,
|
||||||
SubscriptionService subscriptions,
|
SubscriptionService subscriptions,
|
||||||
Pass.Leveling.ExperienceService experienceService
|
Pass.Leveling.ExperienceService experienceService,
|
||||||
|
INatsConnection nats
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
private static readonly Random Random = new();
|
private static readonly Random Random = new();
|
||||||
@@ -36,10 +41,23 @@ public class AccountEventService(
|
|||||||
cache.RemoveAsync(cacheKey);
|
cache.RemoveAsync(cacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Status> GetStatus(Guid userId)
|
private async Task BroadcastStatusUpdate(SnAccountStatus status)
|
||||||
|
{
|
||||||
|
await nats.PublishAsync(
|
||||||
|
AccountStatusUpdatedEvent.Type,
|
||||||
|
GrpcTypeHelper.ConvertObjectToByteString(new AccountStatusUpdatedEvent
|
||||||
|
{
|
||||||
|
AccountId = status.AccountId,
|
||||||
|
Status = status,
|
||||||
|
UpdatedAt = SystemClock.Instance.GetCurrentInstant()
|
||||||
|
}).ToByteArray()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SnAccountStatus> GetStatus(Guid userId)
|
||||||
{
|
{
|
||||||
var cacheKey = $"{StatusCacheKey}{userId}";
|
var cacheKey = $"{StatusCacheKey}{userId}";
|
||||||
var cachedStatus = await cache.GetAsync<Status>(cacheKey);
|
var cachedStatus = await cache.GetAsync<SnAccountStatus>(cacheKey);
|
||||||
if (cachedStatus is not null)
|
if (cachedStatus is not null)
|
||||||
{
|
{
|
||||||
cachedStatus!.IsOnline = !cachedStatus.IsInvisible && await GetAccountIsConnected(userId);
|
cachedStatus!.IsOnline = !cachedStatus.IsInvisible && await GetAccountIsConnected(userId);
|
||||||
@@ -63,9 +81,9 @@ public class AccountEventService(
|
|||||||
|
|
||||||
if (isOnline)
|
if (isOnline)
|
||||||
{
|
{
|
||||||
return new Status
|
return new SnAccountStatus
|
||||||
{
|
{
|
||||||
Attitude = StatusAttitude.Neutral,
|
Attitude = Shared.Models.StatusAttitude.Neutral,
|
||||||
IsOnline = true,
|
IsOnline = true,
|
||||||
IsCustomized = false,
|
IsCustomized = false,
|
||||||
Label = "Online",
|
Label = "Online",
|
||||||
@@ -73,9 +91,9 @@ public class AccountEventService(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Status
|
return new SnAccountStatus
|
||||||
{
|
{
|
||||||
Attitude = StatusAttitude.Neutral,
|
Attitude = Shared.Models.StatusAttitude.Neutral,
|
||||||
IsOnline = false,
|
IsOnline = false,
|
||||||
IsCustomized = false,
|
IsCustomized = false,
|
||||||
Label = "Offline",
|
Label = "Offline",
|
||||||
@@ -83,15 +101,15 @@ public class AccountEventService(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Dictionary<Guid, Status>> GetStatuses(List<Guid> userIds)
|
public async Task<Dictionary<Guid, SnAccountStatus>> GetStatuses(List<Guid> userIds)
|
||||||
{
|
{
|
||||||
var results = new Dictionary<Guid, Status>();
|
var results = new Dictionary<Guid, SnAccountStatus>();
|
||||||
var cacheMissUserIds = new List<Guid>();
|
var cacheMissUserIds = new List<Guid>();
|
||||||
|
|
||||||
foreach (var userId in userIds)
|
foreach (var userId in userIds)
|
||||||
{
|
{
|
||||||
var cacheKey = $"{StatusCacheKey}{userId}";
|
var cacheKey = $"{StatusCacheKey}{userId}";
|
||||||
var cachedStatus = await cache.GetAsync<Status>(cacheKey);
|
var cachedStatus = await cache.GetAsync<SnAccountStatus>(cacheKey);
|
||||||
if (cachedStatus != null)
|
if (cachedStatus != null)
|
||||||
{
|
{
|
||||||
cachedStatus.IsOnline = !cachedStatus.IsInvisible && await GetAccountIsConnected(userId);
|
cachedStatus.IsOnline = !cachedStatus.IsInvisible && await GetAccountIsConnected(userId);
|
||||||
@@ -131,9 +149,9 @@ public class AccountEventService(
|
|||||||
foreach (var userId in usersWithoutStatus)
|
foreach (var userId in usersWithoutStatus)
|
||||||
{
|
{
|
||||||
var isOnline = await GetAccountIsConnected(userId);
|
var isOnline = await GetAccountIsConnected(userId);
|
||||||
var defaultStatus = new Status
|
var defaultStatus = new SnAccountStatus
|
||||||
{
|
{
|
||||||
Attitude = StatusAttitude.Neutral,
|
Attitude = Shared.Models.StatusAttitude.Neutral,
|
||||||
IsOnline = isOnline,
|
IsOnline = isOnline,
|
||||||
IsCustomized = false,
|
IsCustomized = false,
|
||||||
Label = isOnline ? "Online" : "Offline",
|
Label = isOnline ? "Online" : "Offline",
|
||||||
@@ -147,7 +165,7 @@ public class AccountEventService(
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Status> CreateStatus(Account user, Status status)
|
public async Task<SnAccountStatus> CreateStatus(SnAccount user, SnAccountStatus status)
|
||||||
{
|
{
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
await db.AccountStatuses
|
await db.AccountStatuses
|
||||||
@@ -157,22 +175,25 @@ public class AccountEventService(
|
|||||||
db.AccountStatuses.Add(status);
|
db.AccountStatuses.Add(status);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await BroadcastStatusUpdate(status);
|
||||||
|
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ClearStatus(Account user, Status status)
|
public async Task ClearStatus(SnAccount user, SnAccountStatus status)
|
||||||
{
|
{
|
||||||
status.ClearedAt = SystemClock.Instance.GetCurrentInstant();
|
status.ClearedAt = SystemClock.Instance.GetCurrentInstant();
|
||||||
db.Update(status);
|
db.Update(status);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
PurgeStatusCache(user.Id);
|
PurgeStatusCache(user.Id);
|
||||||
|
await BroadcastStatusUpdate(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
private const int FortuneTipCount = 14; // This will be the max index for each type (positive/negative)
|
private const int FortuneTipCount = 14; // This will be the max index for each type (positive/negative)
|
||||||
private const string CaptchaCacheKey = "checkin:captcha:";
|
private const string CaptchaCacheKey = "checkin:captcha:";
|
||||||
private const int CaptchaProbabilityPercent = 20;
|
private const int CaptchaProbabilityPercent = 20;
|
||||||
|
|
||||||
public async Task<bool> CheckInDailyDoAskCaptcha(Account user)
|
public async Task<bool> CheckInDailyDoAskCaptcha(SnAccount user)
|
||||||
{
|
{
|
||||||
var perkSubscription = await subscriptions.GetPerkSubscriptionAsync(user.Id);
|
var perkSubscription = await subscriptions.GetPerkSubscriptionAsync(user.Id);
|
||||||
if (perkSubscription is not null) return false;
|
if (perkSubscription is not null) return false;
|
||||||
@@ -187,7 +208,7 @@ public class AccountEventService(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> CheckInDailyIsAvailable(Account user)
|
public async Task<bool> CheckInDailyIsAvailable(SnAccount user)
|
||||||
{
|
{
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
var lastCheckIn = await db.AccountCheckInResults
|
var lastCheckIn = await db.AccountCheckInResults
|
||||||
@@ -204,7 +225,7 @@ public class AccountEventService(
|
|||||||
return lastDate < currentDate;
|
return lastDate < currentDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> CheckInBackdatedIsAvailable(Account user, Instant backdated)
|
public async Task<bool> CheckInBackdatedIsAvailable(SnAccount user, Instant backdated)
|
||||||
{
|
{
|
||||||
var aDay = Duration.FromDays(1);
|
var aDay = Duration.FromDays(1);
|
||||||
var backdatedStart = backdated.ToDateTimeUtc().Date.ToInstant();
|
var backdatedStart = backdated.ToDateTimeUtc().Date.ToInstant();
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
@@ -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)
|
||||||
|
@@ -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 };
|
||||||
}
|
}
|
||||||
|
@@ -1,46 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using DysonNetwork.Shared.Data;
|
|
||||||
using DysonNetwork.Shared.GeoIp;
|
|
||||||
using DysonNetwork.Shared.Proto;
|
|
||||||
using NodaTime.Serialization.Protobuf;
|
|
||||||
using Point = NetTopologySuite.Geometries.Point;
|
|
||||||
|
|
||||||
namespace DysonNetwork.Pass.Account;
|
|
||||||
|
|
||||||
public class ActionLog : ModelBase
|
|
||||||
{
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
[MaxLength(4096)] public string Action { get; set; } = null!;
|
|
||||||
[Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
|
|
||||||
[MaxLength(512)] public string? UserAgent { get; set; }
|
|
||||||
[MaxLength(128)] public string? IpAddress { get; set; }
|
|
||||||
[Column(TypeName = "jsonb")] public GeoPoint? Location { get; set; }
|
|
||||||
|
|
||||||
public Guid AccountId { get; set; }
|
|
||||||
public Account Account { get; set; } = null!;
|
|
||||||
public Guid? SessionId { get; set; }
|
|
||||||
|
|
||||||
public Shared.Proto.ActionLog ToProtoValue()
|
|
||||||
{
|
|
||||||
var protoLog = new Shared.Proto.ActionLog
|
|
||||||
{
|
|
||||||
Id = Id.ToString(),
|
|
||||||
Action = Action,
|
|
||||||
UserAgent = UserAgent ?? string.Empty,
|
|
||||||
IpAddress = IpAddress ?? string.Empty,
|
|
||||||
Location = Location?.ToString() ?? string.Empty,
|
|
||||||
AccountId = AccountId.ToString(),
|
|
||||||
CreatedAt = CreatedAt.ToTimestamp()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert Meta dictionary to Struct
|
|
||||||
protoLog.Meta.Add(GrpcTypeHelper.ConvertToValueMap(Meta));
|
|
||||||
|
|
||||||
if (SessionId.HasValue)
|
|
||||||
protoLog.SessionId = SessionId.Value.ToString();
|
|
||||||
|
|
||||||
return protoLog;
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,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);
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -22,7 +22,7 @@ public class BotAccountReceiverGrpc(
|
|||||||
ServerCallContext context
|
ServerCallContext context
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var account = Account.FromProtoValue(request.Account);
|
var account = SnAccount.FromProtoValue(request.Account);
|
||||||
account = await accounts.CreateBotAccount(
|
account = await accounts.CreateBotAccount(
|
||||||
account,
|
account,
|
||||||
Guid.Parse(request.AutomatedId),
|
Guid.Parse(request.AutomatedId),
|
||||||
@@ -48,7 +48,7 @@ public class BotAccountReceiverGrpc(
|
|||||||
ServerCallContext context
|
ServerCallContext context
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var account = Account.FromProtoValue(request.Account);
|
var account = SnAccount.FromProtoValue(request.Account);
|
||||||
|
|
||||||
if (request.PictureId is not null)
|
if (request.PictureId is not null)
|
||||||
{
|
{
|
||||||
@@ -65,7 +65,7 @@ public class BotAccountReceiverGrpc(
|
|||||||
Usage = "profile.picture"
|
Usage = "profile.picture"
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
account.Profile.Picture = CloudFileReferenceObject.FromProtoValue(file);
|
account.Profile.Picture = SnCloudFileReferenceObject.FromProtoValue(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.BackgroundId is not null)
|
if (request.BackgroundId is not null)
|
||||||
@@ -83,7 +83,7 @@ public class BotAccountReceiverGrpc(
|
|||||||
Usage = "profile.background"
|
Usage = "profile.background"
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
account.Profile.Background = CloudFileReferenceObject.FromProtoValue(file);
|
account.Profile.Background = SnCloudFileReferenceObject.FromProtoValue(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
db.Accounts.Update(account);
|
db.Accounts.Update(account);
|
||||||
|
@@ -50,7 +50,7 @@ public class MagicSpellController(AppDatabase db, MagicSpellService sp) : Contro
|
|||||||
return NotFound();
|
return NotFound();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (spell.Type == MagicSpellType.AuthPasswordReset && request?.NewPassword is not null)
|
if (spell.Type == Shared.Models.MagicSpellType.AuthPasswordReset && request?.NewPassword is not null)
|
||||||
await sp.ApplyPasswordReset(spell, request.NewPassword);
|
await sp.ApplyPasswordReset(spell, request.NewPassword);
|
||||||
else
|
else
|
||||||
await sp.ApplyMagicSpell(spell);
|
await sp.ApplyMagicSpell(spell);
|
||||||
|
@@ -2,8 +2,8 @@ using System.Security.Cryptography;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DysonNetwork.Pass.Emails;
|
using DysonNetwork.Pass.Emails;
|
||||||
using DysonNetwork.Pass.Mailer;
|
using DysonNetwork.Pass.Mailer;
|
||||||
using DysonNetwork.Pass.Permission;
|
|
||||||
using DysonNetwork.Shared.Cache;
|
using DysonNetwork.Shared.Cache;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
@@ -20,8 +20,8 @@ public class MagicSpellService(
|
|||||||
ICacheService cache
|
ICacheService cache
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
public async Task<MagicSpell> CreateMagicSpell(
|
public async Task<SnMagicSpell> CreateMagicSpell(
|
||||||
Account account,
|
SnAccount account,
|
||||||
MagicSpellType type,
|
MagicSpellType type,
|
||||||
Dictionary<string, object> meta,
|
Dictionary<string, object> meta,
|
||||||
Instant? expiredAt = null,
|
Instant? expiredAt = null,
|
||||||
@@ -42,7 +42,7 @@ public class MagicSpellService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var spellWord = _GenerateRandomString(128);
|
var spellWord = _GenerateRandomString(128);
|
||||||
var spell = new MagicSpell
|
var spell = new SnMagicSpell
|
||||||
{
|
{
|
||||||
Spell = spellWord,
|
Spell = spellWord,
|
||||||
Type = type,
|
Type = type,
|
||||||
@@ -60,7 +60,7 @@ public class MagicSpellService(
|
|||||||
|
|
||||||
private const string SpellNotifyCacheKeyPrefix = "spells:notify:";
|
private const string SpellNotifyCacheKeyPrefix = "spells:notify:";
|
||||||
|
|
||||||
public async Task NotifyMagicSpell(MagicSpell spell, bool bypassVerify = false)
|
public async Task NotifyMagicSpell(SnMagicSpell spell, bool bypassVerify = false)
|
||||||
{
|
{
|
||||||
var cacheKey = SpellNotifyCacheKeyPrefix + spell.Id;
|
var cacheKey = SpellNotifyCacheKeyPrefix + spell.Id;
|
||||||
var (found, _) = await cache.GetAsyncWithStatus<bool?>(cacheKey);
|
var (found, _) = await cache.GetAsyncWithStatus<bool?>(cacheKey);
|
||||||
@@ -156,7 +156,7 @@ public class MagicSpellService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ApplyMagicSpell(MagicSpell spell)
|
public async Task ApplyMagicSpell(SnMagicSpell spell)
|
||||||
{
|
{
|
||||||
switch (spell.Type)
|
switch (spell.Type)
|
||||||
{
|
{
|
||||||
@@ -191,7 +191,7 @@ public class MagicSpellService(
|
|||||||
var defaultGroup = await db.PermissionGroups.FirstOrDefaultAsync(g => g.Key == "default");
|
var defaultGroup = await db.PermissionGroups.FirstOrDefaultAsync(g => g.Key == "default");
|
||||||
if (defaultGroup is not null && account is not null)
|
if (defaultGroup is not null && account is not null)
|
||||||
{
|
{
|
||||||
db.PermissionGroupMembers.Add(new PermissionGroupMember
|
db.PermissionGroupMembers.Add(new SnPermissionGroupMember
|
||||||
{
|
{
|
||||||
Actor = $"user:{account.Id}",
|
Actor = $"user:{account.Id}",
|
||||||
Group = defaultGroup
|
Group = defaultGroup
|
||||||
@@ -218,7 +218,7 @@ public class MagicSpellService(
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ApplyPasswordReset(MagicSpell spell, string newPassword)
|
public async Task ApplyPasswordReset(SnMagicSpell spell, string newPassword)
|
||||||
{
|
{
|
||||||
if (spell.Type != MagicSpellType.AuthPasswordReset)
|
if (spell.Type != MagicSpellType.AuthPasswordReset)
|
||||||
throw new ArgumentException("This spell is not a password reset spell.");
|
throw new ArgumentException("This spell is not a password reset spell.");
|
||||||
@@ -231,7 +231,7 @@ public class MagicSpellService(
|
|||||||
{
|
{
|
||||||
var account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId);
|
var account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId);
|
||||||
if (account is null) throw new InvalidOperationException("Both account and auth factor was not found.");
|
if (account is null) throw new InvalidOperationException("Both account and auth factor was not found.");
|
||||||
passwordFactor = new AccountAuthFactor
|
passwordFactor = new SnAccountAuthFactor
|
||||||
{
|
{
|
||||||
Type = AccountAuthFactorType.Password,
|
Type = AccountAuthFactorType.Password,
|
||||||
Account = account,
|
Account = account,
|
||||||
@@ -257,6 +257,6 @@ public class MagicSpellService(
|
|||||||
|
|
||||||
var base64String = Convert.ToBase64String(randomBytes);
|
var base64String = Convert.ToBase64String(randomBytes);
|
||||||
|
|
||||||
return base64String.Substring(0, length);
|
return base64String[..length];
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -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";
|
||||||
|
@@ -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.");
|
||||||
|
@@ -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);
|
||||||
}
|
}
|
||||||
|
@@ -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);
|
||||||
|
@@ -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();
|
||||||
|
@@ -1,5 +1,3 @@
|
|||||||
using NodaTime;
|
|
||||||
|
|
||||||
namespace DysonNetwork.Pass.Auth;
|
namespace DysonNetwork.Pass.Auth;
|
||||||
|
|
||||||
public static class AuthCacheConstants
|
public static class AuthCacheConstants
|
||||||
|
@@ -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
|
||||||
)
|
)
|
||||||
|
@@ -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
|
||||||
|
@@ -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);
|
||||||
|
@@ -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" },
|
||||||
|
@@ -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;
|
||||||
|
@@ -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; }
|
||||||
|
@@ -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());
|
||||||
}
|
}
|
||||||
|
@@ -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;
|
||||||
|
@@ -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;
|
||||||
|
|
||||||
|
@@ -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
Reference in New Issue
Block a user