67 Commits

Author SHA1 Message Date
7385caff9a Lotteries 2025-10-24 01:34:18 +08:00
15954dbfe2 Providing the post featured record in the response 2025-10-24 00:51:30 +08:00
4ba6206c9d 🛂 Stricter post visibility check 2025-10-24 00:02:27 +08:00
266b9e36e2 🗃️ Update schema to clean up unused code 2025-10-23 01:01:19 +08:00
e6aa61b03b 🐛 Bug fixes in the Sphere still referencing the old realm db 2025-10-22 23:31:42 +08:00
0c09ef25ec ⬆️ Upgrade dependencies in order to prevent CVE-2025-55315 2025-10-22 22:58:52 +08:00
dd5929c691 💥 Moved the /id to /pass and bug fixes of moved realms 2025-10-22 22:52:09 +08:00
cf87fdfb49 🗑️ Remove per service rate-limiting due to gateway covered it 2025-10-22 22:10:37 +08:00
ff03584518 🐛 Fix some issues in moving realm service 2025-10-22 21:56:50 +08:00
d6c37784e1 ♻️ Move the realm service from sphere to the pass 2025-10-21 23:45:36 +08:00
46ebd92dc1 ♻️ Refactored the chat mention logic 2025-10-17 00:46:55 +08:00
7f8521bb40 👔 Optimize subscriptions logic 2025-10-16 13:13:08 +08:00
f01226d91a 🐛 Fix post controller return incomplete structure 2025-10-13 23:11:35 +08:00
6cb6dee6be 🐛 Remove project Sphere dict key snake case convert to fix reaction counts 2025-10-13 01:19:51 +08:00
0e9caf67ff 🐛 username color hotfix 2025-10-13 01:16:35 +08:00
ca70bb5487 🐛 Fix missing username color in proto profile 2025-10-13 01:08:48 +08:00
59ed135f20 Load account info in reaction list API 2025-10-12 21:57:37 +08:00
6077f91529 Sticker search 2025-10-12 21:46:45 +08:00
5c485bb1c3 🐛 Fix autocomplete again 2025-10-12 19:30:46 +08:00
27d979d77b 🐛 Fix sticker auto complete 2025-10-12 19:21:00 +08:00
15687a0c32 Standalone auto complete 2025-10-12 16:59:26 +08:00
37ea882ef7 Full featured auto complete 2025-10-12 16:55:32 +08:00
e624c2bb3e ⬆️ Upgrade aspire 2025-10-12 16:06:39 +08:00
9631cd3edd Auto completion in chat 2025-10-12 16:00:32 +08:00
f4a659fce5 🐛 Fix DM room member loading issue 2025-10-12 15:46:45 +08:00
1ded811b36 Publisher heatmap 2025-10-12 15:32:49 +08:00
32977d9580 🐛 Fix post controller does not contains publisher in success created response 2025-10-11 23:55:00 +08:00
aaf29e7228 🐛 Fix gateway user ip detection 2025-10-09 22:50:26 +08:00
658ef3bddf 🐛 Fix gateway IP detection issue 2025-10-09 00:10:32 +08:00
fc0bc936ce New version of sticker rendering support 2025-10-08 21:28:48 +08:00
3850ae6a8e 🔊 Rate limiting logs 2025-10-08 18:07:19 +08:00
21c99567b4 🐛 Fix wrong method to configure rate limiting 2025-10-08 18:05:59 +08:00
1315c7f4d4 🐛 Fix rate limiter 2025-10-08 18:01:25 +08:00
630a532d98 🐛 Fix app host 2025-10-08 18:01:21 +08:00
b9bb180113 Username color 2025-10-08 13:11:30 +08:00
04d74d0d70 Trying to optimize the scheduled jobs 2025-10-08 12:59:54 +08:00
6a8a0ed491 👔 Limit custom reactions 2025-10-08 02:46:56 +08:00
0f835845bf ♻️ Merge the ServiceDefault and Shared project 2025-10-07 19:44:52 +08:00
c5d8a8d07f 🔇 Mute ungraceful closed websocket 2025-10-07 17:54:58 +08:00
95e2ba1136 🐛 Fixes some issues in drive service 2025-10-07 01:07:24 +08:00
1176fde8b4 🐛 Fix health check 2025-10-07 00:41:26 +08:00
e634968e00 🐛 Brings health check back to live 2025-10-07 00:34:00 +08:00
282a1dbddc 🐛 Fix didn't expose X-Total 2025-10-06 23:40:44 +08:00
c64adace24 💄 Using remote site instead of embed frontend (removed) to handle oidc redirect 2025-10-06 13:05:50 +08:00
8ac0b28c66 🚚 Move callback to under api 2025-10-06 13:01:15 +08:00
8f71d7f9e5 🐛 Fix some bugs 2025-10-06 12:46:25 +08:00
c435e63917 Able to update the custom apps order's status 2025-10-05 22:20:32 +08:00
243159e4cc Custom apps create payment orders 2025-10-05 21:59:07 +08:00
42dad7095a 💄 Optimize the transfer 2025-10-05 16:17:57 +08:00
d1efcdede8 Transfer fee and pin validate 2025-10-05 15:52:54 +08:00
47680475b3 🐛 Fix develop service 2025-10-05 00:09:21 +08:00
6632d43f32 🐛 Trying to fix develop 2025-10-05 00:05:37 +08:00
29c4dcd71c Wallet stats 2025-10-05 00:05:31 +08:00
e7aa887715 🐛 Fix wrong signing algo 2025-10-04 19:55:27 +08:00
0f05633996 🐛 Fix oidc didn't provides with authorized party 2025-10-04 19:03:57 +08:00
966af08a33 Wallet stats 2025-10-04 15:38:58 +08:00
b25b90a074 Wallet funds 2025-10-04 01:17:21 +08:00
dcbefeaaab 👔 Purchase gift requires minimal level 2025-10-03 17:20:58 +08:00
eb83a0392a 👔 Update level requirements of purchase Stellar Program 2025-10-03 17:16:53 +08:00
85fefcf724 🐛 Fix subscription check 2025-10-03 17:16:18 +08:00
d17c26a228 👔 Skip level check when redeem gift 2025-10-03 17:12:23 +08:00
2e5ef8ff94 🐛 Fix members related operations 2025-10-03 17:07:57 +08:00
7a5f410e36 🐛 Trying to fix migration 2025-10-03 16:53:19 +08:00
0b4e8a9777 🚑 Ignoring migration error for now 2025-10-03 16:44:22 +08:00
30fd912281 Optimize queue usage 2025-10-03 16:38:10 +08:00
5bf58f0194 🐛 Fix subscription gift 2025-10-03 16:38:01 +08:00
8e3e3f09df Gateway config serving 2025-10-03 16:37:51 +08:00
120 changed files with 24857 additions and 4973 deletions

5
.editorconfig Normal file
View File

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

613
API_WALLET_FUNDS.md Normal file
View File

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

View File

@@ -7,25 +7,17 @@ var isDev = builder.Environment.IsDevelopment();
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); .WithReference(sphereService);
@@ -38,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)
@@ -60,14 +55,12 @@ for (var idx = 0; idx < services.Count; idx++)
ringService.WithReference(passService); ringService.WithReference(passService);
var gateway = builder.AddProject<Projects.DysonNetwork_Gateway>("gateway") var gateway = builder.AddProject<Projects.DysonNetwork_Gateway>("gateway")
.WithReference(ringService)
.WithReference(passService)
.WithReference(driveService)
.WithReference(sphereService)
.WithReference(developService)
.WithEnvironment("HTTP_PORTS", "5001") .WithEnvironment("HTTP_PORTS", "5001")
.WithHttpEndpoint(port: 5001, targetPort: null, isProxied: false, name: "http"); .WithHttpEndpoint(port: 5001, targetPort: null, isProxied: false, name: "http");
foreach (var service in services)
gateway.WithReference(service);
builder.AddDockerComposeEnvironment("docker-compose"); builder.AddDockerComposeEnvironment("docker-compose");
builder.Build().Run(); builder.Build().Run();

View File

@@ -1,7 +1,5 @@
<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.5.0"/>
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
@@ -10,14 +8,12 @@
<UserSecretsId>a68b3195-a00d-40c2-b5ed-d675356b7cde</UserSecretsId> <UserSecretsId>a68b3195-a00d-40c2-b5ed-d675356b7cde</UserSecretsId>
<RootNamespace>DysonNetwork.Control</RootNamespace> <RootNamespace>DysonNetwork.Control</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.5.0" /> <PackageReference Include="Aspire.Hosting.AppHost" Version="9.5.1" />
<PackageReference Include="Aspire.Hosting.Docker" Version="9.4.2-preview.1.25428.12" /> <PackageReference Include="Aspire.Hosting.Docker" Version="9.4.2-preview.1.25428.12" />
<PackageReference Include="Aspire.Hosting.Nats" Version="9.5.0" /> <PackageReference Include="Aspire.Hosting.Nats" Version="9.5.1" />
<PackageReference Include="Aspire.Hosting.Redis" Version="9.5.0" /> <PackageReference Include="Aspire.Hosting.Redis" Version="9.5.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\DysonNetwork.Develop\DysonNetwork.Develop.csproj" /> <ProjectReference Include="..\DysonNetwork.Develop\DysonNetwork.Develop.csproj" />
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" /> <ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
@@ -26,5 +22,4 @@
<ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" /> <ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" />
<ProjectReference Include="..\DysonNetwork.Gateway\DysonNetwork.Gateway.csproj" /> <ProjectReference Include="..\DysonNetwork.Gateway\DysonNetwork.Gateway.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

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

View File

@@ -9,7 +9,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7"/> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -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.4" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
<PackageReference Include="NodaTime" Version="3.2.2"/> <PackageReference Include="NodaTime" Version="3.2.2"/>
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/>
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0"/> <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0"/>
@@ -31,7 +31,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -16,10 +16,10 @@ namespace DysonNetwork.Develop.Identity;
[Authorize] [Authorize]
public class BotAccountController( public class BotAccountController(
BotAccountService botService, BotAccountService botService,
DeveloperService developerService, DeveloperService ds,
DevProjectService projectService, DevProjectService projectService,
ILogger<BotAccountController> logger, ILogger<BotAccountController> logger,
AccountClientHelper accounts, RemoteAccountService remoteAccounts,
BotAccountReceiverService.BotAccountReceiverServiceClient accountsReceiver BotAccountReceiverService.BotAccountReceiverServiceClient accountsReceiver
) )
: ControllerBase : ControllerBase
@@ -50,9 +50,9 @@ public class BotAccountController(
] ]
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
[Required] [MaxLength(256)] public string Nick { get; set; } = string.Empty; [Required][MaxLength(256)] public string Nick { get; set; } = string.Empty;
[Required] [MaxLength(1024)] public string Slug { get; set; } = string.Empty; [Required][MaxLength(1024)] public string Slug { get; set; } = string.Empty;
[MaxLength(128)] public string Language { get; set; } = "en-us"; [MaxLength(128)] public string Language { get; set; } = "en-us";
} }
@@ -68,7 +68,7 @@ public class BotAccountController(
[MaxLength(256)] public string? Nick { get; set; } = string.Empty; [MaxLength(256)] public string? Nick { get; set; } = string.Empty;
[Required] [MaxLength(1024)] public string? Slug { get; set; } = string.Empty; [Required][MaxLength(1024)] public string? Slug { get; set; } = string.Empty;
[MaxLength(128)] public string? Language { get; set; } [MaxLength(128)] public string? Language { get; set; }
@@ -83,11 +83,11 @@ 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),
Shared.Proto.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");
@@ -108,11 +108,11 @@ 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),
Shared.Proto.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");
@@ -137,11 +137,11 @@ 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),
Shared.Proto.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");
@@ -206,11 +206,11 @@ 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),
Shared.Proto.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");
@@ -222,7 +222,7 @@ public class BotAccountController(
if (bot is null || bot.ProjectId != projectId) if (bot is null || bot.ProjectId != projectId)
return NotFound("Bot not found"); return NotFound("Bot not found");
var botAccount = await accounts.GetBotAccount(bot.Id); var botAccount = await remoteAccounts.GetBotAccount(bot.Id);
if (request.Name is not null) botAccount.Name = request.Name; if (request.Name is not null) botAccount.Name = request.Name;
if (request.Nick is not null) botAccount.Nick = request.Nick; if (request.Nick is not null) botAccount.Nick = request.Nick;
@@ -267,11 +267,11 @@ 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),
Shared.Proto.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");
@@ -443,10 +443,10 @@ public class BotAccountController(
Account currentUser, Account currentUser,
Shared.Proto.PublisherMemberRole requiredRole) Shared.Proto.PublisherMemberRole requiredRole)
{ {
var developer = await developerService.GetDeveloperByName(pubName); var developer = await ds.GetDeveloperByName(pubName);
if (developer == null) return (null, null, null); if (developer == null) return (null, null, null);
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), requiredRole)) if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), requiredRole))
return (null, null, null); return (null, null, null);
var project = await projectService.GetProjectAsync(projectId, developer.Id); var project = await projectService.GetProjectAsync(projectId, developer.Id);

View File

@@ -10,7 +10,7 @@ namespace DysonNetwork.Develop.Identity;
public class BotAccountService( public class BotAccountService(
AppDatabase db, AppDatabase db,
BotAccountReceiverService.BotAccountReceiverServiceClient accountReceiver, BotAccountReceiverService.BotAccountReceiverServiceClient accountReceiver,
AccountClientHelper accounts RemoteAccountService remoteAccounts
) )
{ {
public async Task<SnBotAccount?> GetBotByIdAsync(Guid id) public async Task<SnBotAccount?> GetBotByIdAsync(Guid id)
@@ -20,7 +20,7 @@ public class BotAccountService(
.FirstOrDefaultAsync(b => b.Id == id); .FirstOrDefaultAsync(b => b.Id == id);
} }
public async Task<IEnumerable<SnBotAccount>> 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)
@@ -155,11 +155,10 @@ public class BotAccountService(
public async Task<SnBotAccount?> LoadBotAccountAsync(SnBotAccount bot) => public async Task<SnBotAccount?> LoadBotAccountAsync(SnBotAccount bot) =>
(await LoadBotsAccountAsync([bot])).FirstOrDefault(); (await LoadBotsAccountAsync([bot])).FirstOrDefault();
public async Task<List<SnBotAccount>> LoadBotsAccountAsync(IEnumerable<SnBotAccount> bots) public async Task<List<SnBotAccount>> LoadBotsAccountAsync(List<SnBotAccount> bots)
{ {
bots = [.. bots];
var automatedIds = bots.Select(b => b.Id).ToList(); var automatedIds = bots.Select(b => b.Id).ToList();
var data = await accounts.GetBotAccountBatch(automatedIds); var data = await remoteAccounts.GetBotAccountBatch(automatedIds);
foreach (var bot in bots) foreach (var bot in bots)
{ {
@@ -168,6 +167,6 @@ public class BotAccountService(
.FirstOrDefault(e => e.AutomatedId == bot.Id); .FirstOrDefault(e => e.AutomatedId == bot.Id);
} }
return bots as List<SnBotAccount> ?? []; return bots;
} }
} }

View File

@@ -35,6 +35,6 @@ using (var scope = app.Services.CreateScope())
app.ConfigureAppMiddleware(builder.Configuration); app.ConfigureAppMiddleware(builder.Configuration);
app.UseSwaggerManifest(); app.UseSwaggerManifest("DysonNetwork.Develop");
app.Run(); app.Run();

View File

@@ -12,7 +12,7 @@
<PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" /> <PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" />
<PackageReference Include="FFMpegCore" Version="5.2.0" /> <PackageReference Include="FFMpegCore" Version="5.2.0" />
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" /> <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
@@ -40,7 +40,7 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" /> <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" /> <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" /> <PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
@@ -56,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.4" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" /> <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.6" />
<PackageReference Include="tusdotnet" Version="2.10.0" /> <PackageReference Include="tusdotnet" Version="2.10.0" />
</ItemGroup> </ItemGroup>
@@ -68,7 +68,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -16,7 +16,6 @@ builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxV
// Add application services // Add application services
builder.Services.AddAppServices(builder.Configuration); builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppRateLimiting();
builder.Services.AddAppAuthentication(); builder.Services.AddAppAuthentication();
builder.Services.AddDysonAuth(); builder.Services.AddDysonAuth();
builder.Services.AddAccountService(); builder.Services.AddAccountService();
@@ -49,6 +48,6 @@ app.ConfigureAppMiddleware(tusDiskStore);
// Configure gRPC // Configure gRPC
app.ConfigureGrpcServices(); app.ConfigureGrpcServices();
app.UseSwaggerManifest(); app.UseSwaggerManifest("DysonNetwork.Drive");
app.Run(); app.Run();

View File

@@ -41,7 +41,7 @@ public class BroadcastEventHandler(
await js.EnsureStreamCreated("file_events", [FileUploadedEvent.Type]); await js.EnsureStreamCreated("file_events", [FileUploadedEvent.Type]);
var fileUploadedConsumer = await js.CreateOrUpdateConsumerAsync("file_events", var fileUploadedConsumer = await js.CreateOrUpdateConsumerAsync("file_events",
new ConsumerConfig("drive_file_uploaded_handler"), cancellationToken: stoppingToken); new ConsumerConfig("drive_file_uploaded_handler") { MaxDeliver = 3 }, cancellationToken: stoppingToken);
var accountDeletedTask = HandleAccountDeleted(accountEventConsumer, stoppingToken); var accountDeletedTask = HandleAccountDeleted(accountEventConsumer, stoppingToken);
var fileUploadedTask = HandleFileUploaded(fileUploadedConsumer, stoppingToken); var fileUploadedTask = HandleFileUploaded(fileUploadedConsumer, stoppingToken);
@@ -75,8 +75,8 @@ public class BroadcastEventHandler(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error processing FileUploadedEvent for file {FileId}", payload?.FileId); logger.LogError(ex, "Error processing FileUploadedEvent for file {FileId}", payload.FileId);
await msg.NakAsync(cancellationToken: stoppingToken); await msg.NakAsync(cancellationToken: stoppingToken, delay: TimeSpan.FromSeconds(60));
} }
} }
} }

View File

@@ -43,19 +43,6 @@ public static class ServiceCollectionExtensions
return services; return services;
} }
public static IServiceCollection AddAppRateLimiting(this IServiceCollection services)
{
services.AddRateLimiter(o => o.AddFixedWindowLimiter(policyName: "fixed", opts =>
{
opts.Window = TimeSpan.FromMinutes(1);
opts.PermitLimit = 120;
opts.QueueLimit = 2;
opts.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
}));
return services;
}
public static IServiceCollection AddAppAuthentication(this IServiceCollection services) public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
{ {
services.AddAuthorization(); services.AddAuthorization();

View File

@@ -70,13 +70,11 @@ public class FileController(
} }
} }
return StatusCode(StatusCodes.Status503ServiceUnavailable, "File is being processed. Please try again later."); return StatusCode(StatusCodes.Status400BadRequest, "File is being processed. Please try again later.");
} }
if (!file.PoolId.HasValue) if (!file.PoolId.HasValue)
{
return StatusCode(StatusCodes.Status500InternalServerError, "File is in an inconsistent state: uploaded but no pool ID."); 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)

View File

@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("config")]
public class ConfigurationController(IConfiguration configuration) : ControllerBase
{
[HttpGet]
public IActionResult Get() => Ok(configuration.GetSection("Client").Get<Dictionary<string, object>>());
[HttpGet("site")]
public IActionResult GetSiteUrl() => Ok(configuration["SiteUrl"]);
}

View File

@@ -12,7 +12,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -1,7 +1,7 @@
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using DysonNetwork.Shared.Http; using DysonNetwork.Shared.Http;
using Microsoft.AspNetCore.RateLimiting;
using Yarp.ReverseProxy.Configuration; using Yarp.ReverseProxy.Configuration;
using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -17,19 +17,43 @@ builder.Services.AddCors(options =>
policy.SetIsOriginAllowed(origin => true) policy.SetIsOriginAllowed(origin => true)
.AllowAnyMethod() .AllowAnyMethod()
.AllowAnyHeader() .AllowAnyHeader()
.AllowCredentials(); .AllowCredentials()
.WithExposedHeaders("X-Total");
}); });
}); });
builder.Services.AddRateLimiter(options => builder.Services.AddRateLimiter(options =>
{ {
options.AddFixedWindowLimiter("fixed", limiterOptions => options.AddPolicy("fixed", context =>
{ {
limiterOptions.PermitLimit = 120; var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; return RateLimitPartition.GetFixedWindowLimiter(
limiterOptions.QueueLimit = 0; 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 serviceNames = new[] { "ring", "pass", "drive", "sphere", "develop" };
@@ -66,7 +90,6 @@ var apiRoutes = serviceNames.Select(serviceName =>
{ {
var apiPath = serviceName switch var apiPath = serviceName switch
{ {
"pass" => "/id",
_ => $"/{serviceName}" _ => $"/{serviceName}"
}; };
return new RouteConfig return new RouteConfig
@@ -99,6 +122,20 @@ var routes = specialRoutes.Concat(apiRoutes).Concat(swaggerRoutes).ToArray();
var clusters = serviceNames.Select(serviceName => new ClusterConfig var clusters = serviceNames.Select(serviceName => new ClusterConfig
{ {
ClusterId = serviceName, 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> Destinations = new Dictionary<string, DestinationConfig>
{ {
{ "destination1", new DestinationConfig { Address = $"http://{serviceName}" } } { "destination1", new DestinationConfig { Address = $"http://{serviceName}" } }
@@ -106,16 +143,26 @@ var clusters = serviceNames.Select(serviceName => new ClusterConfig
}).ToArray(); }).ToArray();
builder.Services builder.Services
.AddReverseProxy() .AddReverseProxy()
.LoadFromMemory(routes, clusters) .LoadFromMemory(routes, clusters)
.AddServiceDiscoveryDestinationResolver(); .AddServiceDiscoveryDestinationResolver();
builder.Services.AddControllers();
var app = builder.Build(); var app = builder.Build();
app.UseCors(); var forwardedHeadersOptions = new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.All
};
forwardedHeadersOptions.KnownNetworks.Clear();
forwardedHeadersOptions.KnownProxies.Clear();
app.UseForwardedHeaders(forwardedHeadersOptions);
app.UseRateLimiter(); app.UseCors();
app.MapReverseProxy().RequireRateLimiting("fixed"); app.MapReverseProxy().RequireRateLimiting("fixed");
app.MapControllers();
app.Run(); app.Run();

View File

@@ -5,5 +5,9 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"AllowedHosts": "*" "AllowedHosts": "*",
"SiteUrl": "http://localhost:3000",
"Client": {
"SomeSetting": "SomeValue"
}
} }

View File

@@ -80,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; }
@@ -115,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)
{ {

View File

@@ -12,13 +12,11 @@ public class AccountServiceGrpc(
AccountEventService accountEvents, AccountEventService accountEvents,
RelationshipService relationships, RelationshipService relationships,
SubscriptionService subscriptions, SubscriptionService subscriptions,
IClock clock,
ILogger<AccountServiceGrpc> logger ILogger<AccountServiceGrpc> logger
) )
: Shared.Proto.AccountService.AccountServiceBase : Shared.Proto.AccountService.AccountServiceBase
{ {
private readonly AppDatabase _db = db ?? throw new ArgumentNullException(nameof(db)); private readonly AppDatabase _db = db ?? throw new ArgumentNullException(nameof(db));
private readonly IClock _clock = clock ?? throw new ArgumentNullException(nameof(clock));
private readonly ILogger<AccountServiceGrpc> private readonly ILogger<AccountServiceGrpc>
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
@@ -160,6 +158,26 @@ public class AccountServiceGrpc(
return response; return response;
} }
public override async Task<GetAccountBatchResponse> SearchAccount(SearchAccountRequest request, ServerCallContext context)
{
var accounts = await _db.Accounts
.AsNoTracking()
.Where(a => EF.Functions.ILike(a.Name, $"%{request.Query}%"))
.Include(a => a.Profile)
.ToListAsync();
var perks = await subscriptions.GetPerkSubscriptionsAsync(
accounts.Select(x => x.Id).ToList()
);
foreach (var account in accounts)
if (perks.TryGetValue(account.Id, out var perk))
account.PerkSubscription = perk?.ToReference();
var response = new GetAccountBatchResponse();
response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue()));
return response;
}
public override async Task<ListAccountsResponse> ListAccounts(ListAccountsRequest request, public override async Task<ListAccountsResponse> ListAccounts(ListAccountsRequest request,
ServerCallContext context) ServerCallContext context)
{ {
@@ -246,7 +264,7 @@ public class AccountServiceGrpc(
public override async Task<BoolValue> HasRelationship(GetRelationshipRequest request, ServerCallContext context) public override async Task<BoolValue> HasRelationship(GetRelationshipRequest request, ServerCallContext context)
{ {
var hasRelationship = false; bool hasRelationship;
if (!request.HasStatus) if (!request.HasStatus)
hasRelationship = await relationships.HasExistingRelationship( hasRelationship = await relationships.HasExistingRelationship(
Guid.Parse(request.AccountId), Guid.Parse(request.AccountId),

View File

@@ -39,10 +39,15 @@ public class AppDatabase(
public DbSet<SnAuthClient> AuthClients { get; set; } = null!; public DbSet<SnAuthClient> AuthClients { get; set; } = null!;
public DbSet<SnApiKey> ApiKeys { get; set; } = null!; public DbSet<SnApiKey> ApiKeys { get; set; } = null!;
public DbSet<SnRealm> Realms { get; set; } = null!;
public DbSet<SnRealmMember> RealmMembers { get; set; } = null!;
public DbSet<SnWallet> Wallets { get; set; } = null!; public DbSet<SnWallet> Wallets { get; set; } = null!;
public DbSet<SnWalletPocket> WalletPockets { get; set; } = null!; public DbSet<SnWalletPocket> WalletPockets { get; set; } = null!;
public DbSet<SnWalletOrder> PaymentOrders { get; set; } = null!; public DbSet<SnWalletOrder> PaymentOrders { get; set; } = null!;
public DbSet<SnWalletTransaction> PaymentTransactions { get; set; } = null!; public DbSet<SnWalletTransaction> PaymentTransactions { get; set; } = null!;
public DbSet<SnWalletFund> WalletFunds { get; set; } = null!;
public DbSet<SnWalletFundRecipient> WalletFundRecipients { get; set; } = null!;
public DbSet<SnWalletSubscription> WalletSubscriptions { get; set; } = null!; public DbSet<SnWalletSubscription> WalletSubscriptions { get; set; } = null!;
public DbSet<SnWalletGift> WalletGifts { get; set; } = null!; public DbSet<SnWalletGift> WalletGifts { get; set; } = null!;
public DbSet<SnWalletCoupon> WalletCoupons { get; set; } = null!; public DbSet<SnWalletCoupon> WalletCoupons { get; set; } = null!;
@@ -52,6 +57,9 @@ public class AppDatabase(
public DbSet<SnSocialCreditRecord> SocialCreditRecords { get; set; } = null!; public DbSet<SnSocialCreditRecord> SocialCreditRecords { get; set; } = null!;
public DbSet<SnExperienceRecord> ExperienceRecords { get; set; } = null!; public DbSet<SnExperienceRecord> ExperienceRecords { get; set; } = null!;
public DbSet<SnLottery> Lotteries { get; set; } = null!;
public DbSet<SnLotteryRecord> LotteryRecords { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
optionsBuilder.UseNpgsql( optionsBuilder.UseNpgsql(
@@ -126,6 +134,14 @@ public class AppDatabase(
.WithMany(a => a.IncomingRelationships) .WithMany(a => a.IncomingRelationships)
.HasForeignKey(r => r.RelatedId); .HasForeignKey(r => r.RelatedId);
modelBuilder.Entity<SnRealmMember>()
.HasKey(pm => new { pm.RealmId, pm.AccountId });
modelBuilder.Entity<SnRealmMember>()
.HasOne(pm => pm.Realm)
.WithMany(p => p.Members)
.HasForeignKey(pm => pm.RealmId)
.OnDelete(DeleteBehavior.Cascade);
// Automatically apply soft-delete filter to all entities inheriting BaseModel // Automatically apply soft-delete filter to all entities inheriting BaseModel
foreach (var entityType in modelBuilder.Model.GetEntityTypes()) foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{ {

View File

@@ -343,15 +343,15 @@ public class OidcProviderController(
{ {
issuer, issuer,
authorization_endpoint = $"{siteUrl}/auth/authorize", authorization_endpoint = $"{siteUrl}/auth/authorize",
token_endpoint = $"{baseUrl}/id/auth/open/token", token_endpoint = $"{baseUrl}/pass/auth/open/token",
userinfo_endpoint = $"{baseUrl}/id/auth/open/userinfo", userinfo_endpoint = $"{baseUrl}/pass/auth/open/userinfo",
jwks_uri = $"{baseUrl}/.well-known/jwks", jwks_uri = $"{baseUrl}/.well-known/jwks",
scopes_supported = new[] { "openid", "profile", "email" }, scopes_supported = new[] { "openid", "profile", "email" },
response_types_supported = new[] response_types_supported = new[]
{ "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token" }, { "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token" },
grant_types_supported = new[] { "authorization_code", "refresh_token" }, grant_types_supported = new[] { "authorization_code", "refresh_token" },
token_endpoint_auth_methods_supported = new[] { "client_secret_basic", "client_secret_post" }, token_endpoint_auth_methods_supported = new[] { "client_secret_basic", "client_secret_post" },
id_token_signing_alg_values_supported = new[] { "HS256" }, id_token_signing_alg_values_supported = new[] { "HS256", "RS256" },
subject_types_supported = new[] { "public" }, subject_types_supported = new[] { "public" },
claims_supported = new[] { "sub", "name", "email", "email_verified" }, claims_supported = new[] { "sub", "name", "email", "email_verified" },
code_challenge_methods_supported = new[] { "S256" }, code_challenge_methods_supported = new[] { "S256" },

View File

@@ -200,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(
@@ -314,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,

View File

@@ -16,7 +16,8 @@ public class ConnectionController(
IEnumerable<OidcService> oidcServices, IEnumerable<OidcService> oidcServices,
AccountService accounts, AccountService accounts,
AuthService auth, AuthService auth,
ICacheService cache ICacheService cache,
IConfiguration configuration
) : ControllerBase ) : ControllerBase
{ {
private const string StateCachePrefix = "oidc-state:"; private const string StateCachePrefix = "oidc-state:";
@@ -128,7 +129,7 @@ public class ConnectionController(
} }
[AllowAnonymous] [AllowAnonymous]
[Route("/auth/callback/{provider}")] [Route("/api/auth/callback/{provider}")]
[HttpGet, HttpPost] [HttpGet, HttpPost]
public async Task<IActionResult> HandleCallback([FromRoute] string provider) public async Task<IActionResult> HandleCallback([FromRoute] string provider)
{ {
@@ -277,7 +278,9 @@ public class ConnectionController(
var returnUrl = await cache.GetAsync<string>(returnUrlKey); var returnUrl = await cache.GetAsync<string>(returnUrlKey);
await cache.RemoveAsync(returnUrlKey); await cache.RemoveAsync(returnUrlKey);
return Redirect(string.IsNullOrEmpty(returnUrl) ? "/auth/callback" : returnUrl); var siteUrl = configuration["SiteUrl"];
return Redirect(string.IsNullOrEmpty(returnUrl) ? siteUrl + "/auth/callback" : returnUrl);
} }
private async Task<IActionResult> HandleLoginOrRegistration( private async Task<IActionResult> HandleLoginOrRegistration(
@@ -341,7 +344,10 @@ public class ConnectionController(
var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant()); var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant());
var loginToken = auth.CreateToken(loginSession); var loginToken = auth.CreateToken(loginSession);
return Redirect($"/auth/callback?token={loginToken}");
var siteUrl = configuration["SiteUrl"];
return Redirect(siteUrl + $"/auth/callback?token={loginToken}");
} }
private static async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request) private static async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request)

View File

@@ -57,7 +57,7 @@ public abstract class OidcService(
{ {
ClientId = Configuration[$"Oidc:{ConfigSectionName}:ClientId"] ?? "", ClientId = Configuration[$"Oidc:{ConfigSectionName}:ClientId"] ?? "",
ClientSecret = Configuration[$"Oidc:{ConfigSectionName}:ClientSecret"] ?? "", ClientSecret = Configuration[$"Oidc:{ConfigSectionName}:ClientSecret"] ?? "",
RedirectUri = Configuration["BaseUrl"] + "/auth/callback/" + ProviderName.ToLower() RedirectUri = Configuration["SiteUrl"] + "/auth/callback/" + ProviderName.ToLower()
}; };
} }

View File

@@ -8,7 +8,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0"/> <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0"/>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7"/> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
@@ -28,7 +28,7 @@
<PackageReference Include="OpenGraph-Net" Version="4.0.1" /> <PackageReference Include="OpenGraph-Net" Version="4.0.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/> <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0"/> <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0"/> <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/> <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/> <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/>
<PackageReference Include="Otp.NET" Version="1.4.0"/> <PackageReference Include="Otp.NET" Version="1.4.0"/>
@@ -44,12 +44,11 @@
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.1"/> <PackageReference Include="EFCore.BulkExtensions" Version="9.0.1"/>
<PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1"/> <PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1"/>
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0"/> <PackageReference Include="EFCore.NamingConventions" Version="9.0.0"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" /> <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.6" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj"/> <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj"/>
</ItemGroup> </ItemGroup>

View File

@@ -0,0 +1,115 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Pass.Permission;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Lotteries;
[ApiController]
[Route("/api/lotteries")]
public class LotteryController(AppDatabase db, LotteryService lotteryService) : ControllerBase
{
public class CreateLotteryRequest
{
[Required]
public List<int> RegionOneNumbers { get; set; } = null!;
[Required]
[Range(0, 99)]
public int RegionTwoNumber { get; set; }
[Range(1, int.MaxValue)]
public int Multiplier { get; set; } = 1;
}
[HttpPost]
[Authorize]
public async Task<ActionResult<SnWalletOrder>> CreateLottery([FromBody] CreateLotteryRequest request)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
var order = await lotteryService.CreateLotteryOrderAsync(
accountId: currentUser.Id,
region1: request.RegionOneNumbers,
region2: request.RegionTwoNumber,
multiplier: request.Multiplier);
return Ok(order);
}
catch (ArgumentException err)
{
return BadRequest(err.Message);
}
}
[HttpGet]
[Authorize]
public async Task<ActionResult<List<SnLottery>>> GetLotteries(
[FromQuery] int offset = 0,
[FromQuery] int limit = 20)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var lotteries = await lotteryService.GetUserTicketsAsync(currentUser.Id, offset, limit);
var total = await lotteryService.GetUserTicketCountAsync(currentUser.Id);
Response.Headers["X-Total"] = total.ToString();
return Ok(lotteries);
}
[HttpGet("{id}")]
[Authorize]
public async Task<ActionResult<SnLottery>> GetLottery(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var lottery = await lotteryService.GetTicketAsync(id);
if (lottery == null || lottery.AccountId != currentUser.Id)
return NotFound();
return Ok(lottery);
}
[HttpPost("draw")]
[Authorize]
[RequiredPermission("maintenance", "lotteries.draw.perform")]
public async Task<IActionResult> PerformLotteryDraw()
{
await lotteryService.PerformDailyDrawAsync();
return Ok("Lottery draw performed successfully.");
}
[HttpGet("records")]
[Authorize]
public async Task<ActionResult<List<SnLotteryRecord>>> GetLotteryRecords(
[FromQuery] Instant? startDate = null,
[FromQuery] Instant? endDate = null,
[FromQuery] int offset = 0,
[FromQuery] int limit = 20)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var query = db.LotteryRecords.AsQueryable();
if (startDate.HasValue)
query = query.Where(r => r.DrawDate >= startDate.Value);
if (endDate.HasValue)
query = query.Where(r => r.DrawDate <= endDate.Value);
var total = await query.CountAsync();
Response.Headers["X-Total"] = total.ToString();
var records = await query
.OrderByDescending(r => r.DrawDate)
.Skip(offset)
.Take(limit)
.ToListAsync();
return Ok(records);
}
}

View File

@@ -0,0 +1,21 @@
using Quartz;
namespace DysonNetwork.Pass.Lotteries;
public class LotteryDrawJob(LotteryService lotteryService, ILogger<LotteryDrawJob> logger) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
logger.LogInformation("Starting daily lottery draw...");
try
{
await lotteryService.PerformDailyDrawAsync();
logger.LogInformation("Daily lottery draw completed successfully.");
}
catch (Exception ex)
{
logger.LogError(ex, "Error occurred during daily lottery draw.");
}
}
}

View File

@@ -0,0 +1,208 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Pass.Wallet;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Lotteries;
public class LotteryService(AppDatabase db, PaymentService paymentService, WalletService walletService)
{
private static bool ValidateNumbers(List<int> region1, int region2)
{
if (region1.Count != 5 || region1.Distinct().Count() != 5)
return false;
if (region1.Any(n => n < 0 || n > 99))
return false;
if (region2 < 0 || region2 > 99)
return false;
return true;
}
public async Task<SnLottery> CreateTicketAsync(Guid accountId, List<int> region1, int region2, int multiplier = 1)
{
if (!ValidateNumbers(region1, region2))
throw new ArgumentException("Invalid lottery numbers");
var lottery = new SnLottery
{
AccountId = accountId,
RegionOneNumbers = region1,
RegionTwoNumber = region2,
Multiplier = multiplier
};
db.Lotteries.Add(lottery);
await db.SaveChangesAsync();
return lottery;
}
public async Task<List<SnLottery>> GetUserTicketsAsync(Guid accountId, int offset = 0, int limit = 20)
{
return await db.Lotteries
.Where(l => l.AccountId == accountId)
.OrderByDescending(l => l.CreatedAt)
.Skip(offset)
.Take(limit)
.ToListAsync();
}
public async Task<SnLottery?> GetTicketAsync(Guid id)
{
return await db.Lotteries.FirstOrDefaultAsync(l => l.Id == id);
}
public async Task<int> GetUserTicketCountAsync(Guid accountId)
{
return await db.Lotteries.CountAsync(l => l.AccountId == accountId);
}
private static decimal CalculateLotteryPrice(int multiplier)
{
return 10 + (multiplier - 1) * 10;
}
public async Task<SnWalletOrder> CreateLotteryOrderAsync(Guid accountId, List<int> region1, int region2, int multiplier = 1)
{
if (!ValidateNumbers(region1, region2))
throw new ArgumentException("Invalid lottery numbers");
var now = SystemClock.Instance.GetCurrentInstant();
var todayStart = new LocalDateTime(now.InUtc().Year, now.InUtc().Month, now.InUtc().Day, 0, 0).InUtc().ToInstant();
var hasPurchasedToday = await db.Lotteries.AnyAsync(l => l.AccountId == accountId && l.CreatedAt >= todayStart);
if (hasPurchasedToday)
throw new InvalidOperationException("You can only purchase one lottery per day.");
var price = CalculateLotteryPrice(multiplier);
return await paymentService.CreateOrderAsync(
null,
"isp",
price,
appIdentifier: "lottery",
productIdentifier: "lottery",
meta: new Dictionary<string, object>
{
["account_id"] = accountId.ToString(),
["region_one_numbers"] = region1,
["region_two_number"] = region2,
["multiplier"] = multiplier
});
}
public async Task HandleLotteryOrder(SnWalletOrder order)
{
if (order.Status != OrderStatus.Paid ||
!order.Meta.TryGetValue("account_id", out var accountIdValue) ||
!order.Meta.TryGetValue("region_one_numbers", out var region1Value) ||
!order.Meta.TryGetValue("region_two_number", out var region2Value) ||
!order.Meta.TryGetValue("multiplier", out var multiplierValue))
throw new InvalidOperationException("Invalid order.");
var accountId = Guid.Parse((string)accountIdValue!);
var region1Json = (System.Text.Json.JsonElement)region1Value;
var region1 = region1Json.EnumerateArray().Select(e => e.GetInt32()).ToList();
var region2 = Convert.ToInt32((string)region2Value!);
var multiplier = Convert.ToInt32((string)multiplierValue!);
await CreateTicketAsync(accountId, region1, region2, multiplier);
}
private static int CalculateReward(int region1Matches, bool region2Match)
{
var reward = region1Matches switch
{
0 => 0,
1 => 10,
2 => 20,
3 => 50,
4 => 100,
5 => 1000,
_ => 0
};
if (region2Match) reward *= 10;
return reward;
}
private static List<int> GenerateUniqueRandomNumbers(int count, int min, int max)
{
var numbers = new List<int>();
var random = new Random();
while (numbers.Count < count)
{
var num = random.Next(min, max + 1);
if (!numbers.Contains(num)) numbers.Add(num);
}
return numbers.OrderBy(n => n).ToList();
}
private int CountMatches(List<int> playerNumbers, List<int> winningNumbers)
{
return playerNumbers.Intersect(winningNumbers).Count();
}
public async Task PerformDailyDrawAsync()
{
var now = SystemClock.Instance.GetCurrentInstant();
var yesterdayStart = new LocalDateTime(now.InUtc().Year, now.InUtc().Month, now.InUtc().Day, 0, 0).InUtc().ToInstant().Minus(Duration.FromDays(1));
var todayStart = new LocalDateTime(now.InUtc().Year, now.InUtc().Month, now.InUtc().Day, 0, 0).InUtc().ToInstant();
// Tickets purchased yesterday that are still pending draw
var tickets = await db.Lotteries
.Where(l => l.CreatedAt >= yesterdayStart && l.CreatedAt < todayStart && l.DrawStatus == LotteryDrawStatus.Pending)
.ToListAsync();
if (!tickets.Any()) return;
// Generate winning numbers
var winningRegion1 = GenerateUniqueRandomNumbers(5, 0, 99);
var winningRegion2 = GenerateUniqueRandomNumbers(1, 0, 99)[0];
var drawDate = Instant.FromDateTimeUtc(DateTime.Today.AddDays(-1)); // Yesterday's date
var totalPrizesAwarded = 0;
long totalPrizeAmount = 0;
// Process each ticket
foreach (var ticket in tickets)
{
var region1Matches = CountMatches(ticket.RegionOneNumbers, winningRegion1);
var region2Match = ticket.RegionTwoNumber == winningRegion2;
var reward = CalculateReward(region1Matches, region2Match);
if (reward > 0)
{
var wallet = await walletService.GetWalletAsync(ticket.AccountId);
if (wallet != null)
{
await paymentService.CreateTransactionAsync(
payerWalletId: null,
payeeWalletId: wallet.Id,
currency: "isp",
amount: reward,
remarks: $"Lottery prize: {region1Matches} matches{(region2Match ? " + special" : "")}"
);
totalPrizesAwarded++;
totalPrizeAmount += reward;
}
}
ticket.DrawStatus = LotteryDrawStatus.Drawn;
ticket.DrawDate = drawDate;
}
// Save the draw record
var lotteryRecord = new SnLotteryRecord
{
DrawDate = drawDate,
WinningRegionOneNumbers = winningRegion1,
WinningRegionTwoNumber = winningRegion2,
TotalTickets = tickets.Count,
TotalPrizesAwarded = totalPrizesAwarded,
TotalPrizeAmount = totalPrizeAmount
};
db.LotteryRecords.Add(lotteryRecord);
await db.SaveChangesAsync();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class RefactorSubscriptionRelation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_wallet_subscriptions_wallet_gifts_gift_id",
table: "wallet_subscriptions");
migrationBuilder.DropIndex(
name: "ix_wallet_subscriptions_gift_id",
table: "wallet_subscriptions");
migrationBuilder.DropColumn(
name: "gift_id",
table: "wallet_subscriptions");
migrationBuilder.AddColumn<Guid>(
name: "subscription_id",
table: "wallet_gifts",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "ix_wallet_gifts_subscription_id",
table: "wallet_gifts",
column: "subscription_id",
unique: true);
migrationBuilder.AddForeignKey(
name: "fk_wallet_gifts_wallet_subscriptions_subscription_id",
table: "wallet_gifts",
column: "subscription_id",
principalTable: "wallet_subscriptions",
principalColumn: "id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_wallet_gifts_wallet_subscriptions_subscription_id",
table: "wallet_gifts");
migrationBuilder.DropIndex(
name: "ix_wallet_gifts_subscription_id",
table: "wallet_gifts");
migrationBuilder.DropColumn(
name: "subscription_id",
table: "wallet_gifts");
migrationBuilder.AddColumn<Guid>(
name: "gift_id",
table: "wallet_subscriptions",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "ix_wallet_subscriptions_gift_id",
table: "wallet_subscriptions",
column: "gift_id",
unique: true);
migrationBuilder.AddForeignKey(
name: "fk_wallet_subscriptions_wallet_gifts_gift_id",
table: "wallet_subscriptions",
column: "gift_id",
principalTable: "wallet_gifts",
principalColumn: "id");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,99 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddWalletFund : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "wallet_funds",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
currency = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
total_amount = table.Column<decimal>(type: "numeric", nullable: false),
split_type = table.Column<int>(type: "integer", nullable: false),
status = table.Column<int>(type: "integer", nullable: false),
message = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
creator_account_id = table.Column<Guid>(type: "uuid", nullable: false),
expired_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),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_wallet_funds", x => x.id);
table.ForeignKey(
name: "fk_wallet_funds_accounts_creator_account_id",
column: x => x.creator_account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "wallet_fund_recipients",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
fund_id = table.Column<Guid>(type: "uuid", nullable: false),
recipient_account_id = table.Column<Guid>(type: "uuid", nullable: false),
amount = table.Column<decimal>(type: "numeric", nullable: false),
is_received = table.Column<bool>(type: "boolean", nullable: false),
received_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_wallet_fund_recipients", x => x.id);
table.ForeignKey(
name: "fk_wallet_fund_recipients_accounts_recipient_account_id",
column: x => x.recipient_account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_wallet_fund_recipients_wallet_funds_fund_id",
column: x => x.fund_id,
principalTable: "wallet_funds",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_wallet_fund_recipients_fund_id",
table: "wallet_fund_recipients",
column: "fund_id");
migrationBuilder.CreateIndex(
name: "ix_wallet_fund_recipients_recipient_account_id",
table: "wallet_fund_recipients",
column: "recipient_account_id");
migrationBuilder.CreateIndex(
name: "ix_wallet_funds_creator_account_id",
table: "wallet_funds",
column: "creator_account_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "wallet_fund_recipients");
migrationBuilder.DropTable(
name: "wallet_funds");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddUsernameColor : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<UsernameColor>(
name: "username_color",
table: "account_profiles",
type: "jsonb",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "username_color",
table: "account_profiles");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,160 @@
using System;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddRealmFromSphere : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "realms",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
slug = 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: false),
is_community = table.Column<bool>(type: "boolean", nullable: false),
is_public = table.Column<bool>(type: "boolean", nullable: false),
picture_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
background_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
picture = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
background = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
verification = table.Column<SnVerificationMark>(type: "jsonb", nullable: true),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_realms", x => x.id);
});
migrationBuilder.CreateTable(
name: "realm_members",
columns: table => new
{
realm_id = table.Column<Guid>(type: "uuid", nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
role = table.Column<int>(type: "integer", nullable: false),
joined_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
leave_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_realm_members", x => new { x.realm_id, x.account_id });
table.ForeignKey(
name: "fk_realm_members_realms_realm_id",
column: x => x.realm_id,
principalTable: "realms",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "sn_chat_room",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
type = table.Column<int>(type: "integer", nullable: false),
is_community = table.Column<bool>(type: "boolean", nullable: false),
is_public = table.Column<bool>(type: "boolean", nullable: false),
picture_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
background_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
picture = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
background = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
realm_id = table.Column<Guid>(type: "uuid", nullable: true),
sn_realm_id = table.Column<Guid>(type: "uuid", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_sn_chat_room", x => x.id);
table.ForeignKey(
name: "fk_sn_chat_room_realms_sn_realm_id",
column: x => x.sn_realm_id,
principalTable: "realms",
principalColumn: "id");
});
migrationBuilder.CreateTable(
name: "sn_chat_member",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
chat_room_id = table.Column<Guid>(type: "uuid", nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
nick = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
role = table.Column<int>(type: "integer", nullable: false),
notify = table.Column<int>(type: "integer", nullable: false),
last_read_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
joined_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
leave_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
is_bot = table.Column<bool>(type: "boolean", nullable: false),
break_until = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
timeout_until = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
timeout_cause = table.Column<ChatTimeoutCause>(type: "jsonb", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_sn_chat_member", x => x.id);
table.ForeignKey(
name: "fk_sn_chat_member_sn_chat_room_chat_room_id",
column: x => x.chat_room_id,
principalTable: "sn_chat_room",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_realms_slug",
table: "realms",
column: "slug",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_sn_chat_member_chat_room_id",
table: "sn_chat_member",
column: "chat_room_id");
migrationBuilder.CreateIndex(
name: "ix_sn_chat_room_sn_realm_id",
table: "sn_chat_room",
column: "sn_realm_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "realm_members");
migrationBuilder.DropTable(
name: "sn_chat_member");
migrationBuilder.DropTable(
name: "sn_chat_room");
migrationBuilder.DropTable(
name: "realms");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,99 @@
using System;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class RemoveChatRoom : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "sn_chat_member");
migrationBuilder.DropTable(
name: "sn_chat_room");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "sn_chat_room",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
background = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
background_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
is_community = table.Column<bool>(type: "boolean", nullable: false),
is_public = table.Column<bool>(type: "boolean", nullable: false),
name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
picture = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
picture_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
realm_id = table.Column<Guid>(type: "uuid", nullable: true),
sn_realm_id = table.Column<Guid>(type: "uuid", nullable: true),
type = table.Column<int>(type: "integer", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_sn_chat_room", x => x.id);
table.ForeignKey(
name: "fk_sn_chat_room_realms_sn_realm_id",
column: x => x.sn_realm_id,
principalTable: "realms",
principalColumn: "id");
});
migrationBuilder.CreateTable(
name: "sn_chat_member",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
chat_room_id = table.Column<Guid>(type: "uuid", nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
break_until = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
is_bot = table.Column<bool>(type: "boolean", nullable: false),
joined_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
last_read_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
leave_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
nick = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
notify = table.Column<int>(type: "integer", nullable: false),
role = table.Column<int>(type: "integer", nullable: false),
timeout_cause = table.Column<ChatTimeoutCause>(type: "jsonb", nullable: true),
timeout_until = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_sn_chat_member", x => x.id);
table.ForeignKey(
name: "fk_sn_chat_member_sn_chat_room_chat_room_id",
column: x => x.chat_room_id,
principalTable: "sn_chat_room",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_sn_chat_member_chat_room_id",
table: "sn_chat_member",
column: "chat_room_id");
migrationBuilder.CreateIndex(
name: "ix_sn_chat_room_sn_realm_id",
table: "sn_chat_room",
column: "sn_realm_id");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddLotteries : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "lotteries",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
region_one_numbers = table.Column<List<int>>(type: "jsonb", nullable: false),
region_two_number = table.Column<int>(type: "integer", nullable: false),
multiplier = table.Column<int>(type: "integer", nullable: false),
draw_status = table.Column<int>(type: "integer", nullable: false),
draw_date = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_lotteries", x => x.id);
table.ForeignKey(
name: "fk_lotteries_accounts_account_id",
column: x => x.account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "lottery_records",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
draw_date = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
winning_region_one_numbers = table.Column<List<int>>(type: "jsonb", nullable: false),
winning_region_two_number = table.Column<int>(type: "integer", nullable: false),
total_tickets = table.Column<int>(type: "integer", nullable: false),
total_prizes_awarded = table.Column<int>(type: "integer", nullable: false),
total_prize_amount = table.Column<long>(type: "bigint", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_lottery_records", x => x.id);
});
migrationBuilder.CreateIndex(
name: "ix_lotteries_account_id",
table: "lotteries",
column: "account_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "lotteries");
migrationBuilder.DropTable(
name: "lottery_records");
}
}
}

View File

@@ -22,7 +22,7 @@ namespace DysonNetwork.Pass.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "9.0.7") .HasAnnotation("ProductVersion", "9.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -478,6 +478,10 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("updated_at"); .HasColumnName("updated_at");
b.Property<UsernameColor>("UsernameColor")
.HasColumnType("jsonb")
.HasColumnName("username_color");
b.Property<SnVerificationMark>("Verification") b.Property<SnVerificationMark>("Verification")
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("verification"); .HasColumnName("verification");
@@ -1055,6 +1059,109 @@ namespace DysonNetwork.Pass.Migrations
b.ToTable("experience_records", (string)null); b.ToTable("experience_records", (string)null);
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnLottery", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("DrawDate")
.HasColumnType("timestamp with time zone")
.HasColumnName("draw_date");
b.Property<int>("DrawStatus")
.HasColumnType("integer")
.HasColumnName("draw_status");
b.Property<int>("Multiplier")
.HasColumnType("integer")
.HasColumnName("multiplier");
b.Property<List<int>>("RegionOneNumbers")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("region_one_numbers");
b.Property<int>("RegionTwoNumber")
.HasColumnType("integer")
.HasColumnName("region_two_number");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_lotteries");
b.HasIndex("AccountId")
.HasDatabaseName("ix_lotteries_account_id");
b.ToTable("lotteries", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnLotteryRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant>("DrawDate")
.HasColumnType("timestamp with time zone")
.HasColumnName("draw_date");
b.Property<long>("TotalPrizeAmount")
.HasColumnType("bigint")
.HasColumnName("total_prize_amount");
b.Property<int>("TotalPrizesAwarded")
.HasColumnType("integer")
.HasColumnName("total_prizes_awarded");
b.Property<int>("TotalTickets")
.HasColumnType("integer")
.HasColumnName("total_tickets");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<List<int>>("WinningRegionOneNumbers")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("winning_region_one_numbers");
b.Property<int>("WinningRegionTwoNumber")
.HasColumnType("integer")
.HasColumnName("winning_region_two_number");
b.HasKey("Id")
.HasName("pk_lottery_records");
b.ToTable("lottery_records", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnMagicSpell", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnMagicSpell", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -1248,6 +1355,127 @@ namespace DysonNetwork.Pass.Migrations
b.ToTable("permission_nodes", (string)null); b.ToTable("permission_nodes", (string)null);
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealm", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<SnCloudFileReferenceObject>("Background")
.HasColumnType("jsonb")
.HasColumnName("background");
b.Property<string>("BackgroundId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("background_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("description");
b.Property<bool>("IsCommunity")
.HasColumnType("boolean")
.HasColumnName("is_community");
b.Property<bool>("IsPublic")
.HasColumnType("boolean")
.HasColumnName("is_public");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<SnCloudFileReferenceObject>("Picture")
.HasColumnType("jsonb")
.HasColumnName("picture");
b.Property<string>("PictureId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("picture_id");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("slug");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<SnVerificationMark>("Verification")
.HasColumnType("jsonb")
.HasColumnName("verification");
b.HasKey("Id")
.HasName("pk_realms");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_realms_slug");
b.ToTable("realms", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealmMember", b =>
{
b.Property<Guid>("RealmId")
.HasColumnType("uuid")
.HasColumnName("realm_id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("JoinedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("joined_at");
b.Property<Instant?>("LeaveAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("leave_at");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("RealmId", "AccountId")
.HasName("pk_realm_members");
b.ToTable("realm_members", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnSocialCreditRecord", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnSocialCreditRecord", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -1387,6 +1615,116 @@ namespace DysonNetwork.Pass.Migrations
b.ToTable("wallet_coupons", (string)null); b.ToTable("wallet_coupons", (string)null);
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFund", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("CreatorAccountId")
.HasColumnType("uuid")
.HasColumnName("creator_account_id");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("currency");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("Message")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("message");
b.Property<int>("SplitType")
.HasColumnType("integer")
.HasColumnName("split_type");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<decimal>("TotalAmount")
.HasColumnType("numeric")
.HasColumnName("total_amount");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_wallet_funds");
b.HasIndex("CreatorAccountId")
.HasDatabaseName("ix_wallet_funds_creator_account_id");
b.ToTable("wallet_funds", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFundRecipient", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<decimal>("Amount")
.HasColumnType("numeric")
.HasColumnName("amount");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Guid>("FundId")
.HasColumnType("uuid")
.HasColumnName("fund_id");
b.Property<bool>("IsReceived")
.HasColumnType("boolean")
.HasColumnName("is_received");
b.Property<Instant?>("ReceivedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("received_at");
b.Property<Guid>("RecipientAccountId")
.HasColumnType("uuid")
.HasColumnName("recipient_account_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_wallet_fund_recipients");
b.HasIndex("FundId")
.HasDatabaseName("ix_wallet_fund_recipients_fund_id");
b.HasIndex("RecipientAccountId")
.HasDatabaseName("ix_wallet_fund_recipients_recipient_account_id");
b.ToTable("wallet_fund_recipients", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletGift", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletGift", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -1464,6 +1802,10 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("integer") .HasColumnType("integer")
.HasColumnName("status"); .HasColumnName("status");
b.Property<Guid?>("SubscriptionId")
.HasColumnType("uuid")
.HasColumnName("subscription_id");
b.Property<string>("SubscriptionIdentifier") b.Property<string>("SubscriptionIdentifier")
.IsRequired() .IsRequired()
.HasMaxLength(4096) .HasMaxLength(4096)
@@ -1492,6 +1834,10 @@ namespace DysonNetwork.Pass.Migrations
b.HasIndex("RedeemerId") b.HasIndex("RedeemerId")
.HasDatabaseName("ix_wallet_gifts_redeemer_id"); .HasDatabaseName("ix_wallet_gifts_redeemer_id");
b.HasIndex("SubscriptionId")
.IsUnique()
.HasDatabaseName("ix_wallet_gifts_subscription_id");
b.ToTable("wallet_gifts", (string)null); b.ToTable("wallet_gifts", (string)null);
}); });
@@ -1648,10 +1994,6 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("ended_at"); .HasColumnName("ended_at");
b.Property<Guid?>("GiftId")
.HasColumnType("uuid")
.HasColumnName("gift_id");
b.Property<string>("Identifier") b.Property<string>("Identifier")
.IsRequired() .IsRequired()
.HasMaxLength(4096) .HasMaxLength(4096)
@@ -1698,10 +2040,6 @@ namespace DysonNetwork.Pass.Migrations
b.HasIndex("CouponId") b.HasIndex("CouponId")
.HasDatabaseName("ix_wallet_subscriptions_coupon_id"); .HasDatabaseName("ix_wallet_subscriptions_coupon_id");
b.HasIndex("GiftId")
.IsUnique()
.HasDatabaseName("ix_wallet_subscriptions_gift_id");
b.HasIndex("Identifier") b.HasIndex("Identifier")
.HasDatabaseName("ix_wallet_subscriptions_identifier"); .HasDatabaseName("ix_wallet_subscriptions_identifier");
@@ -1999,6 +2337,18 @@ namespace DysonNetwork.Pass.Migrations
b.Navigation("Account"); b.Navigation("Account");
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnLottery", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_lotteries_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnMagicSpell", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnMagicSpell", b =>
{ {
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account") b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
@@ -2031,6 +2381,18 @@ namespace DysonNetwork.Pass.Migrations
b.Navigation("Group"); b.Navigation("Group");
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealmMember", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnRealm", "Realm")
.WithMany("Members")
.HasForeignKey("RealmId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_realm_members_realms_realm_id");
b.Navigation("Realm");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnSocialCreditRecord", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnSocialCreditRecord", b =>
{ {
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account") b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
@@ -2055,6 +2417,39 @@ namespace DysonNetwork.Pass.Migrations
b.Navigation("Account"); b.Navigation("Account");
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFund", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "CreatorAccount")
.WithMany()
.HasForeignKey("CreatorAccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_wallet_funds_accounts_creator_account_id");
b.Navigation("CreatorAccount");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFundRecipient", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnWalletFund", "Fund")
.WithMany("Recipients")
.HasForeignKey("FundId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_wallet_fund_recipients_wallet_funds_fund_id");
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "RecipientAccount")
.WithMany()
.HasForeignKey("RecipientAccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_wallet_fund_recipients_accounts_recipient_account_id");
b.Navigation("Fund");
b.Navigation("RecipientAccount");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletGift", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletGift", b =>
{ {
b.HasOne("DysonNetwork.Shared.Models.SnWalletCoupon", "Coupon") b.HasOne("DysonNetwork.Shared.Models.SnWalletCoupon", "Coupon")
@@ -2079,6 +2474,11 @@ namespace DysonNetwork.Pass.Migrations
.HasForeignKey("RedeemerId") .HasForeignKey("RedeemerId")
.HasConstraintName("fk_wallet_gifts_accounts_redeemer_id"); .HasConstraintName("fk_wallet_gifts_accounts_redeemer_id");
b.HasOne("DysonNetwork.Shared.Models.SnWalletSubscription", "Subscription")
.WithOne("Gift")
.HasForeignKey("DysonNetwork.Shared.Models.SnWalletGift", "SubscriptionId")
.HasConstraintName("fk_wallet_gifts_wallet_subscriptions_subscription_id");
b.Navigation("Coupon"); b.Navigation("Coupon");
b.Navigation("Gifter"); b.Navigation("Gifter");
@@ -2086,6 +2486,8 @@ namespace DysonNetwork.Pass.Migrations
b.Navigation("Recipient"); b.Navigation("Recipient");
b.Navigation("Redeemer"); b.Navigation("Redeemer");
b.Navigation("Subscription");
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletOrder", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletOrder", b =>
@@ -2131,16 +2533,9 @@ namespace DysonNetwork.Pass.Migrations
.HasForeignKey("CouponId") .HasForeignKey("CouponId")
.HasConstraintName("fk_wallet_subscriptions_wallet_coupons_coupon_id"); .HasConstraintName("fk_wallet_subscriptions_wallet_coupons_coupon_id");
b.HasOne("DysonNetwork.Shared.Models.SnWalletGift", "Gift")
.WithOne("Subscription")
.HasForeignKey("DysonNetwork.Shared.Models.SnWalletSubscription", "GiftId")
.HasConstraintName("fk_wallet_subscriptions_wallet_gifts_gift_id");
b.Navigation("Account"); b.Navigation("Account");
b.Navigation("Coupon"); b.Navigation("Coupon");
b.Navigation("Gift");
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletTransaction", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletTransaction", b =>
@@ -2189,14 +2584,24 @@ namespace DysonNetwork.Pass.Migrations
b.Navigation("Nodes"); b.Navigation("Nodes");
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealm", b =>
{
b.Navigation("Members");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWallet", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnWallet", b =>
{ {
b.Navigation("Pockets"); b.Navigation("Pockets");
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletGift", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFund", b =>
{ {
b.Navigation("Subscription"); b.Navigation("Recipients");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletSubscription", b =>
{
b.Navigation("Gift");
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }

View File

@@ -13,7 +13,6 @@ builder.ConfigureAppKestrel(builder.Configuration);
// Add application services // Add application services
builder.Services.AddAppServices(builder.Configuration); builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppRateLimiting();
builder.Services.AddAppAuthentication(); builder.Services.AddAppAuthentication();
builder.Services.AddRingService(); builder.Services.AddRingService();
builder.Services.AddDriveService(); builder.Services.AddDriveService();
@@ -36,7 +35,14 @@ app.MapDefaultEndpoints();
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>(); var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
try
{
await db.Database.MigrateAsync(); await db.Database.MigrateAsync();
}
catch (Exception err)
{
Console.WriteLine(err);
}
} }
// Configure application middleware pipeline // Configure application middleware pipeline
@@ -45,6 +51,6 @@ app.ConfigureAppMiddleware(builder.Configuration);
// Configure gRPC // Configure gRPC
app.ConfigureGrpcServices(); app.ConfigureGrpcServices();
app.UseSwaggerManifest(); app.UseSwaggerManifest("DysonNetwork.Pass");
app.Run(); app.Run();

View File

@@ -1,14 +1,17 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Account;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry; using DysonNetwork.Shared.Registry;
using Google.Protobuf.WellKnownTypes;
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 Google.Protobuf.WellKnownTypes; using AccountService = DysonNetwork.Pass.Account.AccountService;
using DysonNetwork.Shared.Models; using ActionLogService = DysonNetwork.Pass.Account.ActionLogService;
namespace DysonNetwork.Sphere.Realm; namespace DysonNetwork.Pass.Realm;
[ApiController] [ApiController]
[Route("/api/realms")] [Route("/api/realms")]
@@ -17,9 +20,9 @@ public class RealmController(
RealmService rs, RealmService rs,
FileService.FileServiceClient files, FileService.FileServiceClient files,
FileReferenceService.FileReferenceServiceClient fileRefs, FileReferenceService.FileReferenceServiceClient fileRefs,
ActionLogService.ActionLogServiceClient als, ActionLogService als,
AccountService.AccountServiceClient accounts, RelationshipService rels,
AccountClientHelper accountsHelper AccountEventService accountEvents
) : Controller ) : Controller
{ {
[HttpGet("{slug}")] [HttpGet("{slug}")]
@@ -37,13 +40,12 @@ public class RealmController(
[Authorize] [Authorize]
public async Task<ActionResult<List<SnRealm>>> ListJoinedRealms() public async Task<ActionResult<List<SnRealm>>> ListJoinedRealms()
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id); var accountId = currentUser.Id;
var members = await db.RealmMembers var members = await db.RealmMembers
.Where(m => m.AccountId == accountId) .Where(m => m.AccountId == accountId)
.Where(m => m.JoinedAt != null) .Where(m => m.JoinedAt != null && m.LeaveAt == null)
.Where(m => m.LeaveAt == null)
.Include(e => e.Realm) .Include(e => e.Realm)
.Select(m => m.Realm) .Select(m => m.Realm)
.ToListAsync(); .ToListAsync();
@@ -55,8 +57,8 @@ public class RealmController(
[Authorize] [Authorize]
public async Task<ActionResult<List<SnRealmMember>>> ListInvites() public async Task<ActionResult<List<SnRealmMember>>> ListInvites()
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id); var accountId = currentUser.Id;
var members = await db.RealmMembers var members = await db.RealmMembers
.Where(m => m.AccountId == accountId) .Where(m => m.AccountId == accountId)
@@ -78,20 +80,18 @@ public class RealmController(
public async Task<ActionResult<SnRealmMember>> InviteMember(string slug, public async Task<ActionResult<SnRealmMember>> InviteMember(string slug,
[FromBody] RealmMemberRequest request) [FromBody] RealmMemberRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id); var accountId = currentUser.Id;
var relatedUser = var relatedUser = await db.Accounts.Where(a => a.Id == request.RelatedUserId).FirstOrDefaultAsync();
await accounts.GetAccountAsync(new GetAccountRequest { Id = request.RelatedUserId.ToString() });
if (relatedUser == null) return BadRequest("Related user was not found"); if (relatedUser == null) return BadRequest("Related user was not found");
var hasBlocked = await accounts.HasRelationshipAsync(new GetRelationshipRequest() var hasBlocked = await rels.HasRelationshipWithStatus(
{ currentUser.Id,
AccountId = currentUser.Id, request.RelatedUserId,
RelatedId = request.RelatedUserId.ToString(), RelationshipStatus.Blocked
Status = -100 );
}); if (hasBlocked)
if (hasBlocked?.Value ?? false)
return StatusCode(403, "You cannot invite a user that blocked you."); return StatusCode(403, "You cannot invite a user that blocked you.");
var realm = await db.Realms var realm = await db.Realms
@@ -102,17 +102,38 @@ public class RealmController(
if (!await rs.IsMemberWithRole(realm.Id, accountId, request.Role)) if (!await rs.IsMemberWithRole(realm.Id, accountId, request.Role))
return StatusCode(403, "You cannot invite member has higher permission than yours."); return StatusCode(403, "You cannot invite member has higher permission than yours.");
var hasExistingMember = await db.RealmMembers var existingMember = await db.RealmMembers
.Where(m => m.AccountId == Guid.Parse(relatedUser.Id)) .Where(m => m.AccountId == relatedUser.Id)
.Where(m => m.RealmId == realm.Id) .Where(m => m.RealmId == realm.Id)
.Where(m => m.JoinedAt != null && m.LeaveAt == null) .FirstOrDefaultAsync();
.AnyAsync(); if (existingMember != null)
if (hasExistingMember) {
if (existingMember.LeaveAt == null)
return BadRequest("This user already in the realm cannot be invited again."); return BadRequest("This user already in the realm cannot be invited again.");
existingMember.LeaveAt = null;
existingMember.JoinedAt = null;
db.RealmMembers.Update(existingMember);
await db.SaveChangesAsync();
await rs.SendInviteNotify(existingMember);
als.CreateActionLogFromRequest(
"realms.members.invite",
new Dictionary<string, object>()
{
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "account_id", Value.ForString(existingMember.AccountId.ToString()) },
{ "role", Value.ForNumber(request.Role) }
},
Request
);
return Ok(existingMember);
}
var member = new SnRealmMember var member = new SnRealmMember
{ {
AccountId = Guid.Parse(relatedUser.Id), AccountId = relatedUser.Id,
RealmId = realm.Id, RealmId = realm.Id,
Role = request.Role, Role = request.Role,
}; };
@@ -120,21 +141,18 @@ public class RealmController(
db.RealmMembers.Add(member); db.RealmMembers.Add(member);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest als.CreateActionLogFromRequest(
{ "realms.members.invite",
Action = "realms.members.invite", new Dictionary<string, object>()
Meta =
{ {
{ "realm_id", Value.ForString(realm.Id.ToString()) }, { "realm_id", Value.ForString(realm.Id.ToString()) },
{ "account_id", Value.ForString(member.AccountId.ToString()) }, { "account_id", Value.ForString(member.AccountId.ToString()) },
{ "role", Value.ForNumber(request.Role) } { "role", Value.ForNumber(request.Role) }
}, },
AccountId = currentUser.Id, Request
UserAgent = Request.Headers.UserAgent.ToString(), );
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
member.AccountId = Guid.Parse(relatedUser.Id); member.AccountId = relatedUser.Id;
member.Realm = realm; member.Realm = realm;
await rs.SendInviteNotify(member); await rs.SendInviteNotify(member);
@@ -145,8 +163,8 @@ public class RealmController(
[Authorize] [Authorize]
public async Task<ActionResult<SnRealm>> AcceptMemberInvite(string slug) public async Task<ActionResult<SnRealm>> AcceptMemberInvite(string slug)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id); var accountId = currentUser.Id;
var member = await db.RealmMembers var member = await db.RealmMembers
.Where(m => m.AccountId == accountId) .Where(m => m.AccountId == accountId)
@@ -159,18 +177,15 @@ public class RealmController(
db.Update(member); db.Update(member);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest als.CreateActionLogFromRequest(
"realms.members.join",
new Dictionary<string, object>()
{ {
Action = "realms.members.join", { "realm_id", member.RealmId.ToString() },
Meta = { "account_id", member.AccountId.ToString() }
{
{ "realm_id", Value.ForString(member.RealmId.ToString()) },
{ "account_id", Value.ForString(member.AccountId.ToString()) }
}, },
AccountId = currentUser.Id, Request
UserAgent = Request.Headers.UserAgent.ToString(), );
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return Ok(member); return Ok(member);
} }
@@ -179,8 +194,8 @@ public class RealmController(
[Authorize] [Authorize]
public async Task<ActionResult> DeclineMemberInvite(string slug) public async Task<ActionResult> DeclineMemberInvite(string slug)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id); var accountId = currentUser.Id;
var member = await db.RealmMembers var member = await db.RealmMembers
.Where(m => m.AccountId == accountId) .Where(m => m.AccountId == accountId)
@@ -192,19 +207,16 @@ public class RealmController(
member.LeaveAt = SystemClock.Instance.GetCurrentInstant(); member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest als.CreateActionLogFromRequest(
{ "realms.members.decline_invite",
Action = "realms.members.decline_invite", new Dictionary<string, object>()
Meta =
{ {
{ "realm_id", Value.ForString(member.RealmId.ToString()) }, { "realm_id", Value.ForString(member.RealmId.ToString()) },
{ "account_id", Value.ForString(member.AccountId.ToString()) }, { "account_id", Value.ForString(member.AccountId.ToString()) },
{ "decliner_id", Value.ForString(currentUser.Id) } { "decliner_id", Value.ForString(currentUser.Id.ToString()) }
}, },
AccountId = currentUser.Id, Request
UserAgent = Request.Headers.UserAgent.ToString(), );
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return NoContent(); return NoContent();
} }
@@ -225,8 +237,8 @@ public class RealmController(
if (!realm.IsPublic) if (!realm.IsPublic)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
if (!await rs.IsMemberWithRole(realm.Id, Guid.Parse(currentUser.Id), RealmMemberRole.Normal)) if (!await rs.IsMemberWithRole(realm.Id, currentUser.Id, RealmMemberRole.Normal))
return StatusCode(403, "You must be a member to view this realm's members."); return StatusCode(403, "You must be a member to view this realm's members.");
} }
@@ -240,7 +252,7 @@ public class RealmController(
.OrderBy(m => m.JoinedAt) .OrderBy(m => m.JoinedAt)
.ToListAsync(); .ToListAsync();
var memberStatuses = await accountsHelper.GetAccountStatusBatch( var memberStatuses = await accountEvents.GetStatuses(
members.Select(m => m.AccountId).ToList() members.Select(m => m.AccountId).ToList()
); );
@@ -283,8 +295,8 @@ public class RealmController(
[Authorize] [Authorize]
public async Task<ActionResult<SnRealmMember>> GetCurrentIdentity(string slug) public async Task<ActionResult<SnRealmMember>> GetCurrentIdentity(string slug)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id); var accountId = currentUser.Id;
var member = await db.RealmMembers var member = await db.RealmMembers
.Where(m => m.AccountId == accountId) .Where(m => m.AccountId == accountId)
@@ -300,8 +312,8 @@ public class RealmController(
[Authorize] [Authorize]
public async Task<ActionResult> LeaveRealm(string slug) public async Task<ActionResult> LeaveRealm(string slug)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id); var accountId = currentUser.Id;
var member = await db.RealmMembers var member = await db.RealmMembers
.Where(m => m.AccountId == accountId) .Where(m => m.AccountId == accountId)
@@ -316,19 +328,16 @@ public class RealmController(
member.LeaveAt = SystemClock.Instance.GetCurrentInstant(); member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest als.CreateActionLogFromRequest(
"realms.members.leave",
new Dictionary<string, object>()
{ {
Action = "realms.members.leave", { "realm_id", member.RealmId.ToString() },
Meta = { "account_id", member.AccountId.ToString() },
{ { "leaver_id", currentUser.Id }
{ "realm_id", Value.ForString(member.RealmId.ToString()) },
{ "account_id", Value.ForString(member.AccountId.ToString()) },
{ "leaver_id", Value.ForString(currentUser.Id) }
}, },
AccountId = currentUser.Id, Request
UserAgent = Request.Headers.UserAgent.ToString(), );
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return NoContent(); return NoContent();
} }
@@ -348,7 +357,7 @@ public class RealmController(
[Authorize] [Authorize]
public async Task<ActionResult<SnRealm>> CreateRealm(RealmRequest request) public async Task<ActionResult<SnRealm>> CreateRealm(RealmRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest("You cannot create a realm without a name."); if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest("You cannot create a realm without a name.");
if (string.IsNullOrWhiteSpace(request.Slug)) return BadRequest("You cannot create a realm without a slug."); if (string.IsNullOrWhiteSpace(request.Slug)) return BadRequest("You cannot create a realm without a slug.");
@@ -360,7 +369,7 @@ public class RealmController(
Name = request.Name!, Name = request.Name!,
Slug = request.Slug!, Slug = request.Slug!,
Description = request.Description!, Description = request.Description!,
AccountId = Guid.Parse(currentUser.Id), AccountId = currentUser.Id,
IsCommunity = request.IsCommunity ?? false, IsCommunity = request.IsCommunity ?? false,
IsPublic = request.IsPublic ?? false, IsPublic = request.IsPublic ?? false,
Members = new List<SnRealmMember> Members = new List<SnRealmMember>
@@ -368,7 +377,7 @@ public class RealmController(
new() new()
{ {
Role = RealmMemberRole.Owner, Role = RealmMemberRole.Owner,
AccountId = Guid.Parse(currentUser.Id), AccountId = currentUser.Id,
JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow) JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow)
} }
} }
@@ -391,21 +400,18 @@ public class RealmController(
db.Realms.Add(realm); db.Realms.Add(realm);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest als.CreateActionLogFromRequest(
"realms.create",
new Dictionary<string, object>()
{ {
Action = "realms.create", { "realm_id", realm.Id.ToString() },
Meta = { "name", realm.Name },
{ { "slug", realm.Slug },
{ "realm_id", Value.ForString(realm.Id.ToString()) }, { "is_community", realm.IsCommunity },
{ "name", Value.ForString(realm.Name) }, { "is_public", realm.IsPublic }
{ "slug", Value.ForString(realm.Slug) },
{ "is_community", Value.ForBool(realm.IsCommunity) },
{ "is_public", Value.ForBool(realm.IsPublic) }
}, },
AccountId = currentUser.Id, Request
UserAgent = Request.Headers.UserAgent.ToString(), );
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
var realmResourceId = $"realm:{realm.Id}"; var realmResourceId = $"realm:{realm.Id}";
@@ -436,14 +442,14 @@ public class RealmController(
[Authorize] [Authorize]
public async Task<ActionResult<SnRealm>> Update(string slug, [FromBody] RealmRequest request) public async Task<ActionResult<SnRealm>> Update(string slug, [FromBody] RealmRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var realm = await db.Realms var realm = await db.Realms
.Where(r => r.Slug == slug) .Where(r => r.Slug == slug)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (realm is null) return NotFound(); if (realm is null) return NotFound();
var accountId = Guid.Parse(currentUser.Id); var accountId = currentUser.Id;
var member = await db.RealmMembers var member = await db.RealmMembers
.Where(m => m.AccountId == accountId && m.RealmId == realm.Id && m.JoinedAt != null && m.LeaveAt == null) .Where(m => m.AccountId == accountId && m.RealmId == realm.Id && m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
@@ -519,24 +525,21 @@ public class RealmController(
db.Realms.Update(realm); db.Realms.Update(realm);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest als.CreateActionLogFromRequest(
"realms.update",
new Dictionary<string, object>()
{ {
Action = "realms.update", { "realm_id", realm.Id.ToString() },
Meta = { "name_updated", request.Name != null },
{ { "slug_updated", request.Slug != null },
{ "realm_id", Value.ForString(realm.Id.ToString()) }, { "description_updated", request.Description != null },
{ "name_updated", Value.ForBool(request.Name != null) }, { "picture_updated", request.PictureId != null },
{ "slug_updated", Value.ForBool(request.Slug != null) }, { "background_updated", request.BackgroundId != null },
{ "description_updated", Value.ForBool(request.Description != null) }, { "is_community_updated", request.IsCommunity != null },
{ "picture_updated", Value.ForBool(request.PictureId != null) }, { "is_public_updated", request.IsPublic != null }
{ "background_updated", Value.ForBool(request.BackgroundId != null) },
{ "is_community_updated", Value.ForBool(request.IsCommunity != null) },
{ "is_public_updated", Value.ForBool(request.IsPublic != null) }
}, },
AccountId = currentUser.Id, Request
UserAgent = Request.Headers.UserAgent.ToString(), );
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return Ok(realm); return Ok(realm);
} }
@@ -545,7 +548,7 @@ public class RealmController(
[Authorize] [Authorize]
public async Task<ActionResult<SnRealmMember>> JoinRealm(string slug) public async Task<ActionResult<SnRealmMember>> JoinRealm(string slug)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var realm = await db.Realms var realm = await db.Realms
.Where(r => r.Slug == slug) .Where(r => r.Slug == slug)
@@ -556,14 +559,36 @@ public class RealmController(
return StatusCode(403, "Only community realms can be joined without invitation."); return StatusCode(403, "Only community realms can be joined without invitation.");
var existingMember = await db.RealmMembers var existingMember = await db.RealmMembers
.Where(m => m.AccountId == Guid.Parse(currentUser.Id) && m.RealmId == realm.Id && m.JoinedAt != null && m.LeaveAt == null) .Where(m => m.AccountId == currentUser.Id && m.RealmId == realm.Id)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (existingMember is not null) if (existingMember is not null)
{
if (existingMember.LeaveAt == null)
return BadRequest("You are already a member of this realm."); return BadRequest("You are already a member of this realm.");
existingMember.LeaveAt = null;
existingMember.JoinedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(existingMember);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
"realms.members.join",
new Dictionary<string, object>()
{
{ "realm_id", existingMember.RealmId.ToString() },
{ "account_id", currentUser.Id },
{ "is_community", realm.IsCommunity }
},
Request
);
return Ok(existingMember);
}
var member = new SnRealmMember var member = new SnRealmMember
{ {
AccountId = Guid.Parse(currentUser.Id), AccountId = currentUser.Id,
RealmId = realm.Id, RealmId = realm.Id,
Role = RealmMemberRole.Normal, Role = RealmMemberRole.Normal,
JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow) JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow)
@@ -572,19 +597,16 @@ public class RealmController(
db.RealmMembers.Add(member); db.RealmMembers.Add(member);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest als.CreateActionLogFromRequest(
"realms.members.join",
new Dictionary<string, object>()
{ {
Action = "realms.members.join", { "realm_id", realm.Id.ToString() },
Meta = { "account_id", currentUser.Id },
{ { "is_community", realm.IsCommunity }
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "account_id", Value.ForString(currentUser.Id) },
{ "is_community", Value.ForBool(realm.IsCommunity) }
}, },
AccountId = currentUser.Id, Request
UserAgent = Request.Headers.UserAgent.ToString(), );
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return Ok(member); return Ok(member);
} }
@@ -593,7 +615,7 @@ public class RealmController(
[Authorize] [Authorize]
public async Task<ActionResult> RemoveMember(string slug, Guid memberId) public async Task<ActionResult> RemoveMember(string slug, Guid memberId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var realm = await db.Realms var realm = await db.Realms
.Where(r => r.Slug == slug) .Where(r => r.Slug == slug)
@@ -605,25 +627,22 @@ public class RealmController(
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (member is null) return NotFound(); if (member is null) return NotFound();
if (!await rs.IsMemberWithRole(realm.Id, Guid.Parse(currentUser.Id), RealmMemberRole.Moderator, member.Role)) if (!await rs.IsMemberWithRole(realm.Id, currentUser.Id, RealmMemberRole.Moderator, member.Role))
return StatusCode(403, "You do not have permission to remove members from this realm."); return StatusCode(403, "You do not have permission to remove members from this realm.");
member.LeaveAt = SystemClock.Instance.GetCurrentInstant(); member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest als.CreateActionLogFromRequest(
"realms.members.kick",
new Dictionary<string, object>()
{ {
Action = "realms.members.kick", { "realm_id", realm.Id.ToString() },
Meta = { "account_id", memberId.ToString() },
{ { "kicker_id", currentUser.Id }
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "account_id", Value.ForString(memberId.ToString()) },
{ "kicker_id", Value.ForString(currentUser.Id) }
}, },
AccountId = currentUser.Id, Request
UserAgent = Request.Headers.UserAgent.ToString(), );
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return NoContent(); return NoContent();
} }
@@ -633,7 +652,7 @@ public class RealmController(
public async Task<ActionResult<SnRealmMember>> UpdateMemberRole(string slug, Guid memberId, [FromBody] int newRole) public async Task<ActionResult<SnRealmMember>> UpdateMemberRole(string slug, Guid memberId, [FromBody] int newRole)
{ {
if (newRole >= RealmMemberRole.Owner) return BadRequest("Unable to set realm member to owner or greater role."); if (newRole >= RealmMemberRole.Owner) return BadRequest("Unable to set realm member to owner or greater role.");
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var realm = await db.Realms var realm = await db.Realms
.Where(r => r.Slug == slug) .Where(r => r.Slug == slug)
@@ -645,7 +664,7 @@ public class RealmController(
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (member is null) return NotFound(); if (member is null) return NotFound();
if (!await rs.IsMemberWithRole(realm.Id, Guid.Parse(currentUser.Id), RealmMemberRole.Moderator, member.Role, if (!await rs.IsMemberWithRole(realm.Id, currentUser.Id, RealmMemberRole.Moderator, member.Role,
newRole)) newRole))
return StatusCode(403, "You do not have permission to update member roles in this realm."); return StatusCode(403, "You do not have permission to update member roles in this realm.");
@@ -653,20 +672,17 @@ public class RealmController(
db.RealmMembers.Update(member); db.RealmMembers.Update(member);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest als.CreateActionLogFromRequest(
"realms.members.role_update",
new Dictionary<string, object>()
{ {
Action = "realms.members.role_update", { "realm_id", realm.Id.ToString() },
Meta = { "account_id", memberId.ToString() },
{ { "new_role", newRole },
{ "realm_id", Value.ForString(realm.Id.ToString()) }, { "updater_id", currentUser.Id }
{ "account_id", Value.ForString(memberId.ToString()) },
{ "new_role", Value.ForNumber(newRole) },
{ "updater_id", Value.ForString(currentUser.Id) }
}, },
AccountId = currentUser.Id, Request
UserAgent = Request.Headers.UserAgent.ToString(), );
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
return Ok(member); return Ok(member);
} }
@@ -675,7 +691,7 @@ public class RealmController(
[Authorize] [Authorize]
public async Task<ActionResult> Delete(string slug) public async Task<ActionResult> Delete(string slug)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var transaction = await db.Database.BeginTransactionAsync(); var transaction = await db.Database.BeginTransactionAsync();
@@ -684,16 +700,11 @@ public class RealmController(
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (realm is null) return NotFound(); if (realm is null) return NotFound();
if (!await rs.IsMemberWithRole(realm.Id, Guid.Parse(currentUser.Id), RealmMemberRole.Owner)) if (!await rs.IsMemberWithRole(realm.Id, currentUser.Id, RealmMemberRole.Owner))
return StatusCode(403, "Only the owner can delete this realm."); return StatusCode(403, "Only the owner can delete this realm.");
try try
{ {
var chats = await db.ChatRooms
.Where(c => c.RealmId == realm.Id)
.Select(c => c.Id)
.ToListAsync();
db.Realms.Remove(realm); db.Realms.Remove(realm);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -701,15 +712,6 @@ public class RealmController(
await db.RealmMembers await db.RealmMembers
.Where(m => m.RealmId == realm.Id) .Where(m => m.RealmId == realm.Id)
.ExecuteUpdateAsync(m => m.SetProperty(m => m.DeletedAt, now)); .ExecuteUpdateAsync(m => m.SetProperty(m => m.DeletedAt, now));
await db.ChatRooms
.Where(c => c.RealmId == realm.Id)
.ExecuteUpdateAsync(c => c.SetProperty(c => c.DeletedAt, now));
await db.ChatMessages
.Where(m => chats.Contains(m.ChatRoomId))
.ExecuteUpdateAsync(m => m.SetProperty(m => m.DeletedAt, now));
await db.ChatMembers
.Where(m => chats.Contains(m.ChatRoomId))
.ExecuteUpdateAsync(m => m.SetProperty(m => m.DeletedAt, now));
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await transaction.CommitAsync(); await transaction.CommitAsync();
} }
@@ -719,19 +721,16 @@ public class RealmController(
throw; throw;
} }
_ = als.CreateActionLogAsync(new CreateActionLogRequest als.CreateActionLogFromRequest(
"realms.delete",
new Dictionary<string, object>()
{ {
Action = "realms.delete", { "realm_id", realm.Id.ToString() },
Meta = { "realm_name", realm.Name },
{ { "realm_slug", realm.Slug }
{ "realm_id", Value.ForString(realm.Id.ToString()) },
{ "realm_name", Value.ForString(realm.Name) },
{ "realm_slug", Value.ForString(realm.Slug) }
}, },
AccountId = currentUser.Id, Request
UserAgent = Request.Headers.UserAgent.ToString(), );
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
});
// Delete all file references for this realm // Delete all file references for this realm
var realmResourceId = $"realm:{realm.Id}"; var realmResourceId = $"realm:{realm.Id}";

View File

@@ -1,20 +1,18 @@
using DysonNetwork.Pass.Localization;
using DysonNetwork.Shared; using DysonNetwork.Shared;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry; using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere.Localization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
namespace DysonNetwork.Sphere.Realm; namespace DysonNetwork.Pass.Realm;
public class RealmService( public class RealmService(
AppDatabase db, AppDatabase db,
RingService.RingServiceClient pusher, RingService.RingServiceClient pusher,
AccountService.AccountServiceClient accounts,
IStringLocalizer<NotificationResource> localizer, IStringLocalizer<NotificationResource> localizer,
AccountClientHelper accountsHelper,
ICacheService cache ICacheService cache
) )
{ {
@@ -42,13 +40,18 @@ public class RealmService(
public async Task SendInviteNotify(SnRealmMember member) public async Task SendInviteNotify(SnRealmMember member)
{ {
var account = await accounts.GetAccountAsync(new GetAccountRequest { Id = member.AccountId.ToString() }); var account = await db.Accounts
CultureService.SetCultureInfo(account); .Include(a => a.Profile)
.FirstOrDefaultAsync(a => a.Id == member.AccountId);
if (account == null) throw new InvalidOperationException("Account not found");
CultureService.SetCultureInfo(account.Language);
await pusher.SendPushNotificationToUserAsync( await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest new SendPushNotificationToUserRequest
{ {
UserId = account.Id, UserId = account.Id.ToString(),
Notification = new PushNotification Notification = new PushNotification
{ {
Topic = "invites.realms", Topic = "invites.realms",
@@ -75,20 +78,26 @@ public class RealmService(
public async Task<SnRealmMember> LoadMemberAccount(SnRealmMember member) public async Task<SnRealmMember> LoadMemberAccount(SnRealmMember member)
{ {
var account = await accountsHelper.GetAccount(member.AccountId); var account = await db.Accounts
member.Account = SnAccount.FromProtoValue(account); .Include(a => a.Profile)
.FirstOrDefaultAsync(a => a.Id == member.AccountId);
if (account != null)
member.Account = account;
return member; return member;
} }
public async Task<List<SnRealmMember>> LoadMemberAccounts(ICollection<SnRealmMember> members) public async Task<List<SnRealmMember>> LoadMemberAccounts(ICollection<SnRealmMember> members)
{ {
var accountIds = members.Select(m => m.AccountId).ToList(); var accountIds = members.Select(m => m.AccountId).ToList();
var accounts = (await accountsHelper.GetAccountBatch(accountIds)).ToDictionary(a => Guid.Parse(a.Id), a => a); var accountsDict = await db.Accounts
.Include(a => a.Profile)
.Where(a => accountIds.Contains(a.Id))
.ToDictionaryAsync(a => a.Id, a => a);
return members.Select(m => return members.Select(m =>
{ {
if (accounts.TryGetValue(m.AccountId, out var account)) if (accountsDict.TryGetValue(m.AccountId, out var account))
m.Account = SnAccount.FromProtoValue(account); m.Account = account;
return m; return m;
}).ToList(); }).ToList();
} }

View File

@@ -0,0 +1,170 @@
using DysonNetwork.Shared.Proto;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.EntityFrameworkCore;
using DysonNetwork.Pass.Localization;
using DysonNetwork.Shared;
using DysonNetwork.Shared.Cache;
using Microsoft.Extensions.Localization;
namespace DysonNetwork.Pass.Realm;
public class RealmServiceGrpc(
AppDatabase db,
RingService.RingServiceClient pusher,
IStringLocalizer<NotificationResource> localizer,
ICacheService cache
)
: Shared.Proto.RealmService.RealmServiceBase
{
private const string CacheKeyPrefix = "account:realms:";
public override async Task<Shared.Proto.Realm> GetRealm(GetRealmRequest request, ServerCallContext context)
{
var realm = request.QueryCase switch
{
GetRealmRequest.QueryOneofCase.Id when !string.IsNullOrWhiteSpace(request.Id) => await db.Realms.FindAsync(
Guid.Parse(request.Id)),
GetRealmRequest.QueryOneofCase.Slug when !string.IsNullOrWhiteSpace(request.Slug) => await db.Realms
.FirstOrDefaultAsync(r => r.Slug == request.Slug),
_ => throw new RpcException(new Status(StatusCode.InvalidArgument, "Must provide either id or slug"))
};
return realm == null
? throw new RpcException(new Status(StatusCode.NotFound, "Realm not found"))
: realm.ToProtoValue();
}
public override async Task<GetRealmBatchResponse> GetRealmBatch(GetRealmBatchRequest request, ServerCallContext context)
{
var ids = request.Ids.Select(Guid.Parse).ToList();
var realms = await db.Realms.Where(r => ids.Contains(r.Id)).ToListAsync();
var response = new GetRealmBatchResponse();
response.Realms.AddRange(realms.Select(r => r.ToProtoValue()));
return response;
}
public override async Task<GetUserRealmsResponse> GetUserRealms(GetUserRealmsRequest request,
ServerCallContext context)
{
var accountId = Guid.Parse(request.AccountId);
var cacheKey = $"{CacheKeyPrefix}{accountId}";
var (found, cachedRealms) = await cache.GetAsyncWithStatus<List<Guid>>(cacheKey);
if (found && cachedRealms != null)
return new GetUserRealmsResponse { RealmIds = { cachedRealms.Select(g => g.ToString()) } };
var realms = await db.RealmMembers
.Include(m => m.Realm)
.Where(m => m.AccountId == accountId)
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
.Where(m => m.Realm != null)
.Select(m => m.Realm!.Id)
.ToListAsync();
// Cache the result for 5 minutes
await cache.SetAsync(cacheKey, realms, TimeSpan.FromMinutes(5));
return new GetUserRealmsResponse { RealmIds = { realms.Select(g => g.ToString()) } };
}
public override async Task<GetPublicRealmsResponse> GetPublicRealms(Empty request, ServerCallContext context)
{
var realms = await db.Realms.Where(r => r.IsPublic).ToListAsync();
var response = new GetPublicRealmsResponse();
response.Realms.AddRange(realms.Select(r => r.ToProtoValue()));
return response;
}
public override async Task<GetPublicRealmsResponse> SearchRealms(SearchRealmsRequest request, ServerCallContext context)
{
var realms = await db.Realms
.Where(r => r.IsPublic)
.Where(r => EF.Functions.Like(r.Slug, $"{request.Query}%") || EF.Functions.Like(r.Name, $"{request.Query}%"))
.Take(request.Limit)
.ToListAsync();
var response = new GetPublicRealmsResponse();
response.Realms.AddRange(realms.Select(r => r.ToProtoValue()));
return response;
}
public override async Task<Empty> SendInviteNotify(SendInviteNotifyRequest request, ServerCallContext context)
{
var member = request.Member;
var account = await db.Accounts
.AsNoTracking()
.Include(a => a.Profile)
.FirstOrDefaultAsync(a => a.Id == Guid.Parse(member.AccountId));
if (account == null) throw new RpcException(new Status(StatusCode.NotFound, "Account not found"));
CultureService.SetCultureInfo(account.Language);
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = account.Id.ToString(),
Notification = new PushNotification
{
Topic = "invites.realms",
Title = localizer["RealmInviteTitle"],
Body = localizer["RealmInviteBody", member.Realm?.Name ?? "Unknown Realm"],
ActionUri = "/realms",
IsSavable = true
}
}
);
return new Empty();
}
public override async Task<BoolValue> IsMemberWithRole(IsMemberWithRoleRequest request, ServerCallContext context)
{
if (request.RequiredRoles.Count == 0)
return new BoolValue { Value = false };
var maxRequiredRole = request.RequiredRoles.Max();
var member = await db.RealmMembers
.Where(m => m.RealmId == Guid.Parse(request.RealmId) && m.AccountId == Guid.Parse(request.AccountId) &&
m.JoinedAt != null && m.LeaveAt == null)
.FirstOrDefaultAsync();
return new BoolValue { Value = member?.Role >= maxRequiredRole };
}
public override async Task<RealmMember> LoadMemberAccount(LoadMemberAccountRequest request,
ServerCallContext context)
{
var member = request.Member;
var account = await db.Accounts
.AsNoTracking()
.Include(a => a.Profile)
.FirstOrDefaultAsync(a => a.Id == Guid.Parse(member.AccountId));
var response = new RealmMember(member) { Account = account?.ToProtoValue() };
return response;
}
public override async Task<LoadMemberAccountsResponse> LoadMemberAccounts(LoadMemberAccountsRequest request,
ServerCallContext context)
{
var accountIds = request.Members.Select(m => Guid.Parse(m.AccountId)).ToList();
var accounts = await db.Accounts
.AsNoTracking()
.Include(a => a.Profile)
.Where(a => accountIds.Contains(a.Id))
.ToDictionaryAsync(a => a.Id, a => a.ToProtoValue());
var response = new LoadMemberAccountsResponse();
foreach (var member in request.Members)
{
var updatedMember = new RealmMember(member);
if (accounts.TryGetValue(Guid.Parse(member.AccountId), out var account))
{
updatedMember.Account = account;
}
response.Members.Add(updatedMember);
}
return response;
}
}

View File

@@ -3,6 +3,7 @@ using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Credit; using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Leveling; using DysonNetwork.Pass.Leveling;
using DysonNetwork.Pass.Permission; using DysonNetwork.Pass.Permission;
using DysonNetwork.Pass.Realm;
using DysonNetwork.Pass.Wallet; using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Http; using DysonNetwork.Shared.Http;
using Prometheus; using Prometheus;
@@ -21,7 +22,6 @@ public static class ApplicationConfiguration
app.ConfigureForwardedHeaders(configuration); app.ConfigureForwardedHeaders(configuration);
app.UseWebSockets(); app.UseWebSockets();
app.UseRateLimiter();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseMiddleware<PermissionMiddleware>(); app.UseMiddleware<PermissionMiddleware>();
@@ -42,6 +42,7 @@ public static class ApplicationConfiguration
app.MapGrpcService<BotAccountReceiverGrpc>(); app.MapGrpcService<BotAccountReceiverGrpc>();
app.MapGrpcService<WalletServiceGrpc>(); app.MapGrpcService<WalletServiceGrpc>();
app.MapGrpcService<PaymentServiceGrpc>(); app.MapGrpcService<PaymentServiceGrpc>();
app.MapGrpcService<RealmServiceGrpc>();
return app; return app;
} }

View File

@@ -53,32 +53,10 @@ public class BroadcastEventHandler(
continue; continue;
// Handle subscription orders // Handle subscription orders
if (evt.ProductIdentifier.StartsWith(SubscriptionType.StellarProgram)) if (
{ evt.ProductIdentifier.StartsWith(SubscriptionType.StellarProgram) &&
logger.LogInformation("Handling stellar program order: {OrderId}", evt.OrderId); evt.Meta?.TryGetValue("gift_id", out var giftIdValue) == true
)
await using var scope = serviceProvider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var subscriptions = scope.ServiceProvider.GetRequiredService<SubscriptionService>();
var order = await db.PaymentOrders.FindAsync(
[evt.OrderId],
cancellationToken: stoppingToken
);
if (order is null)
{
logger.LogWarning("Order with ID {OrderId} not found. Redelivering.", evt.OrderId);
await msg.NakAsync(cancellationToken: stoppingToken);
continue;
}
await subscriptions.HandleSubscriptionOrder(order);
logger.LogInformation("Subscription for order {OrderId} handled successfully.", evt.OrderId);
await msg.AckAsync(cancellationToken: stoppingToken);
}
// Handle gift orders
else if (evt.Meta?.TryGetValue("gift_id", out var giftIdValue) == true)
{ {
logger.LogInformation("Handling gift order: {OrderId}", evt.OrderId); logger.LogInformation("Handling gift order: {OrderId}", evt.OrderId);
@@ -102,9 +80,57 @@ public class BroadcastEventHandler(
logger.LogInformation("Gift for order {OrderId} handled successfully.", evt.OrderId); logger.LogInformation("Gift for order {OrderId} handled successfully.", evt.OrderId);
await msg.AckAsync(cancellationToken: stoppingToken); await msg.AckAsync(cancellationToken: stoppingToken);
} }
else if (evt.ProductIdentifier.StartsWith(SubscriptionType.StellarProgram))
{
logger.LogInformation("Handling stellar program order: {OrderId}", evt.OrderId);
await using var scope = serviceProvider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var subscriptions = scope.ServiceProvider.GetRequiredService<SubscriptionService>();
var order = await db.PaymentOrders.FindAsync(
[evt.OrderId],
cancellationToken: stoppingToken
);
if (order is null)
{
logger.LogWarning("Order with ID {OrderId} not found. Redelivering.", evt.OrderId);
await msg.NakAsync(cancellationToken: stoppingToken);
continue;
}
await subscriptions.HandleSubscriptionOrder(order);
logger.LogInformation("Subscription for order {OrderId} handled successfully.", evt.OrderId);
await msg.AckAsync(cancellationToken: stoppingToken);
}
else if (evt.ProductIdentifier == "lottery")
{
logger.LogInformation("Handling lottery order: {OrderId}", evt.OrderId);
await using var scope = serviceProvider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var lotteries = scope.ServiceProvider.GetRequiredService<Lotteries.LotteryService>();
var order = await db.PaymentOrders.FindAsync(
[evt.OrderId],
cancellationToken: stoppingToken
);
if (order == null)
{
logger.LogWarning("Order with ID {OrderId} not found. Redelivering.", evt.OrderId);
await msg.NakAsync(cancellationToken: stoppingToken);
continue;
}
await lotteries.HandleLotteryOrder(order);
logger.LogInformation("Lottery ticket for order {OrderId} created successfully.", evt.OrderId);
await msg.AckAsync(cancellationToken: stoppingToken);
}
else else
{ {
// Not a subscription or gift order, skip // Not a subscription, gift, or lottery order, skip
continue; continue;
} }
} }

View File

@@ -56,6 +56,23 @@ public static class ScheduledJobsConfiguration
.WithIntervalInHours(1) .WithIntervalInHours(1)
.RepeatForever()) .RepeatForever())
); );
var fundExpirationJob = new JobKey("FundExpiration");
q.AddJob<FundExpirationJob>(opts => opts.WithIdentity(fundExpirationJob));
q.AddTrigger(opts => opts
.ForJob(fundExpirationJob)
.WithIdentity("FundExpirationTrigger")
.WithSimpleSchedule(o => o
.WithIntervalInHours(1)
.RepeatForever())
);
var lotteryDrawJob = new JobKey("LotteryDraw");
q.AddJob<Lotteries.LotteryDrawJob>(opts => opts.WithIdentity(lotteryDrawJob));
q.AddTrigger(opts => opts
.ForJob(lotteryDrawJob)
.WithIdentity("LotteryDrawTrigger")
.WithCronSchedule("0 0 0 * * ?"));
}); });
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);

View File

@@ -17,6 +17,7 @@ using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Handlers; using DysonNetwork.Pass.Handlers;
using DysonNetwork.Pass.Leveling; using DysonNetwork.Pass.Leveling;
using DysonNetwork.Pass.Mailer; using DysonNetwork.Pass.Mailer;
using DysonNetwork.Pass.Realm;
using DysonNetwork.Pass.Safety; using DysonNetwork.Pass.Safety;
using DysonNetwork.Pass.Wallet.PaymentHandlers; using DysonNetwork.Pass.Wallet.PaymentHandlers;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
@@ -91,19 +92,6 @@ public static class ServiceCollectionExtensions
return services; return services;
} }
public static IServiceCollection AddAppRateLimiting(this IServiceCollection services)
{
services.AddRateLimiter(o => o.AddFixedWindowLimiter(policyName: "fixed", opts =>
{
opts.Window = TimeSpan.FromMinutes(1);
opts.PermitLimit = 120;
opts.QueueLimit = 2;
opts.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
}));
return services;
}
public static IServiceCollection AddAppAuthentication(this IServiceCollection services) public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
{ {
services.AddAuthorization(); services.AddAuthorization();
@@ -152,6 +140,8 @@ public static class ServiceCollectionExtensions
services.AddScoped<SafetyService>(); services.AddScoped<SafetyService>();
services.AddScoped<SocialCreditService>(); services.AddScoped<SocialCreditService>();
services.AddScoped<ExperienceService>(); services.AddScoped<ExperienceService>();
services.AddScoped<RealmService>();
services.AddScoped<Lotteries.LotteryService>();
services.Configure<OidcProviderOptions>(configuration.GetSection("OidcProvider")); services.Configure<OidcProviderOptions>(configuration.GetSection("OidcProvider"));
services.AddScoped<OidcProviderService>(); services.AddScoped<OidcProviderService>();

View File

@@ -0,0 +1,28 @@
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Quartz;
namespace DysonNetwork.Pass.Wallet;
public class FundExpirationJob(
AppDatabase db,
PaymentService paymentService,
ILogger<FundExpirationJob> logger
) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
logger.LogInformation("Starting fund expiration job...");
try
{
await paymentService.ProcessExpiredFundsAsync();
logger.LogInformation("Successfully processed expired funds");
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing expired funds");
}
}
}

View File

@@ -1,5 +1,5 @@
using DysonNetwork.Pass.Auth;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
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;
@@ -8,8 +8,54 @@ namespace DysonNetwork.Pass.Wallet;
[ApiController] [ApiController]
[Route("/api/orders")] [Route("/api/orders")]
public class OrderController(PaymentService payment, AuthService auth, AppDatabase db) : ControllerBase public class OrderController(
PaymentService payment,
Pass.Auth.AuthService auth,
AppDatabase db,
CustomAppService.CustomAppServiceClient customApps
) : ControllerBase
{ {
public class CreateOrderRequest
{
public string Currency { get; set; } = null!;
public decimal Amount { get; set; }
public string? Remarks { get; set; }
public string? ProductIdentifier { get; set; }
public Dictionary<string, object>? Meta { get; set; }
public int DurationHours { get; set; } = 24;
public string ClientId { get; set; } = null!;
public string ClientSecret { get; set; } = null!;
}
[HttpPost]
public async Task<ActionResult<SnWalletOrder>> CreateOrder([FromBody] CreateOrderRequest request)
{
var clientResp = await customApps.GetCustomAppAsync(new GetCustomAppRequest { Slug = request.ClientId });
if (clientResp.App is null) return BadRequest("Client not found");
var client = SnCustomApp.FromProtoValue(clientResp.App);
var secret = await customApps.CheckCustomAppSecretAsync(new CheckCustomAppSecretRequest
{
AppId = client.Id.ToString(),
Secret = request.ClientSecret,
});
if (!secret.Valid) return BadRequest("Invalid client secret");
var order = await payment.CreateOrderAsync(
default,
request.Currency,
request.Amount,
NodaTime.Duration.FromHours(request.DurationHours),
request.ClientId,
request.ProductIdentifier,
request.Remarks,
request.Meta
);
return Ok(order);
}
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
public async Task<ActionResult<SnWalletOrder>> GetOrderById(Guid id) public async Task<ActionResult<SnWalletOrder>> GetOrderById(Guid id)
{ {
@@ -21,6 +67,11 @@ public class OrderController(PaymentService payment, AuthService auth, AppDataba
return Ok(order); return Ok(order);
} }
public class PayOrderRequest
{
public string PinCode { get; set; } = string.Empty;
}
[HttpPost("{id:guid}/pay")] [HttpPost("{id:guid}/pay")]
[Authorize] [Authorize]
public async Task<ActionResult<SnWalletOrder>> PayOrder(Guid id, [FromBody] PayOrderRequest request) public async Task<ActionResult<SnWalletOrder>> PayOrder(Guid id, [FromBody] PayOrderRequest request)
@@ -47,9 +98,46 @@ public class OrderController(PaymentService payment, AuthService auth, AppDataba
return BadRequest(new { error = ex.Message }); return BadRequest(new { error = ex.Message });
} }
} }
public class UpdateOrderStatusRequest
{
public string ClientId { get; set; } = null!;
public string ClientSecret { get; set; } = null!;
public Shared.Models.OrderStatus Status { get; set; }
}
[HttpPatch("{id:guid}/status")]
public async Task<ActionResult<SnWalletOrder>> UpdateOrderStatus(Guid id, [FromBody] UpdateOrderStatusRequest request)
{
var clientResp = await customApps.GetCustomAppAsync(new GetCustomAppRequest { Slug = request.ClientId });
if (clientResp.App is null) return BadRequest("Client not found");
var client = SnCustomApp.FromProtoValue(clientResp.App);
var secret = await customApps.CheckCustomAppSecretAsync(new CheckCustomAppSecretRequest
{
AppId = client.Id.ToString(),
Secret = request.ClientSecret,
});
if (!secret.Valid) return BadRequest("Invalid client secret");
var order = await db.PaymentOrders.FindAsync(id);
if (order == null)
return NotFound();
if (order.AppIdentifier != request.ClientId)
{
return BadRequest("Order does not belong to this client.");
}
if (request.Status != Shared.Models.OrderStatus.Finished && request.Status != Shared.Models.OrderStatus.Cancelled)
return BadRequest("Invalid status. Available statuses are Finished, Cancelled.");
order.Status = request.Status;
await db.SaveChangesAsync();
return Ok(order);
}
} }
public class PayOrderRequest
{
public string PinCode { get; set; } = string.Empty;
}

View File

@@ -439,12 +439,346 @@ public class PaymentService(
throw new InvalidOperationException($"Payee wallet not found for account {payeeAccountId}"); throw new InvalidOperationException($"Payee wallet not found for account {payeeAccountId}");
} }
return await CreateTransactionAsync( // Calculate transfer fee (5%)
decimal fee = Math.Round(amount * 0.05m, 2);
decimal finalCost = amount + fee;
// Make sure the account has sufficient balanace for both fee and the transfer
var (payerPocket, isNewlyCreated) =
await wat.GetOrCreateWalletPocketAsync(payerWallet.Id, currency, amount);
if (isNewlyCreated || payerPocket.Amount < finalCost)
throw new InvalidOperationException("Insufficient funds");
// Create main transfer transaction
var transaction = await CreateTransactionAsync(
payerWallet.Id, payerWallet.Id,
payeeWallet.Id, payeeWallet.Id,
currency, currency,
amount, amount,
$"Transfer from account {payerAccountId} to {payeeAccountId}", $"Transfer from account {payerAccountId} to {payeeAccountId}",
Shared.Models.TransactionType.Transfer); Shared.Models.TransactionType.Transfer);
// Create fee transaction (to system)
await CreateTransactionAsync(
payerWallet.Id,
null,
currency,
fee,
$"Transfer fee for transaction #{transaction.Id}",
Shared.Models.TransactionType.System);
return transaction;
}
public async Task<SnWalletFund> CreateFundAsync(
Guid creatorAccountId,
List<Guid> recipientAccountIds,
string currency,
decimal totalAmount,
Shared.Models.FundSplitType splitType,
string? message = null,
Duration? expiration = null)
{
if (recipientAccountIds.Count == 0)
throw new ArgumentException("At least one recipient is required");
if (totalAmount <= 0)
throw new ArgumentException("Total amount must be positive");
// Validate all recipient accounts exist and have wallets
var recipientWallets = new List<SnWallet>();
foreach (var accountId in recipientAccountIds)
{
var wallet = await wat.GetWalletAsync(accountId);
if (wallet == null)
throw new InvalidOperationException($"Wallet not found for recipient account {accountId}");
recipientWallets.Add(wallet);
}
// Check creator has sufficient funds
var creatorWallet = await wat.GetWalletAsync(creatorAccountId);
if (creatorWallet == null)
throw new InvalidOperationException($"Creator wallet not found for account {creatorAccountId}");
var (creatorPocket, _) = await wat.GetOrCreateWalletPocketAsync(creatorWallet.Id, currency);
if (creatorPocket.Amount < totalAmount)
throw new InvalidOperationException("Insufficient funds");
// Calculate amounts for each recipient
var recipientAmounts = splitType switch
{
Shared.Models.FundSplitType.Even => SplitEvenly(totalAmount, recipientAccountIds.Count),
Shared.Models.FundSplitType.Random => SplitRandomly(totalAmount, recipientAccountIds.Count),
_ => throw new ArgumentException("Invalid split type")
};
var now = SystemClock.Instance.GetCurrentInstant();
var fund = new SnWalletFund
{
CreatorAccountId = creatorAccountId,
Currency = currency,
TotalAmount = totalAmount,
SplitType = splitType,
Message = message,
ExpiredAt = now.Plus(expiration ?? Duration.FromHours(24)),
Recipients = recipientAccountIds.Select((accountId, index) => new SnWalletFundRecipient
{
RecipientAccountId = accountId,
Amount = recipientAmounts[index]
}).ToList()
};
// Deduct from creator's wallet
await db.WalletPockets
.Where(p => p.Id == creatorPocket.Id)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Amount, p => p.Amount - totalAmount));
db.WalletFunds.Add(fund);
await db.SaveChangesAsync();
// Load the fund with account data including profiles
var createdFund = await db.WalletFunds
.Include(f => f.Recipients)
.ThenInclude(r => r.RecipientAccount)
.ThenInclude(a => a.Profile)
.Include(f => f.CreatorAccount)
.ThenInclude(a => a.Profile)
.FirstOrDefaultAsync(f => f.Id == fund.Id);
return createdFund!;
}
private List<decimal> SplitEvenly(decimal totalAmount, int recipientCount)
{
var baseAmount = Math.Floor(totalAmount / recipientCount * 100) / 100; // Round down to 2 decimal places
var remainder = totalAmount - (baseAmount * recipientCount);
var amounts = new List<decimal>();
for (int i = 0; i < recipientCount; i++)
{
var amount = baseAmount;
if (i < remainder * 100) // Distribute remainder as 0.01 increments
amount += 0.01m;
amounts.Add(amount);
}
return amounts;
}
private List<decimal> SplitRandomly(decimal totalAmount, int recipientCount)
{
var random = new Random();
var amounts = new List<decimal>();
// Generate random amounts that sum to total
decimal remaining = totalAmount;
for (int i = 0; i < recipientCount - 1; i++)
{
// Ensure each recipient gets at least 0.01 and leave enough for remaining recipients
var maxAmount = remaining - (recipientCount - i - 1) * 0.01m;
var minAmount = 0.01m;
var amount = Math.Round((decimal)random.NextDouble() * (maxAmount - minAmount) + minAmount, 2);
amounts.Add(amount);
remaining -= amount;
}
// Last recipient gets the remainder
amounts.Add(Math.Round(remaining, 2));
return amounts;
}
public async Task<SnWalletTransaction> ReceiveFundAsync(Guid recipientAccountId, Guid fundId)
{
var fund = await db.WalletFunds
.Include(f => f.Recipients)
.FirstOrDefaultAsync(f => f.Id == fundId);
if (fund == null)
throw new InvalidOperationException("Fund not found");
if (fund.Status == Shared.Models.FundStatus.Expired || fund.Status == Shared.Models.FundStatus.Refunded)
throw new InvalidOperationException("Fund is no longer available");
var recipient = fund.Recipients.FirstOrDefault(r => r.RecipientAccountId == recipientAccountId);
if (recipient == null)
throw new InvalidOperationException("You are not a recipient of this fund");
if (recipient.IsReceived)
throw new InvalidOperationException("You have already received this fund");
var recipientWallet = await wat.GetWalletAsync(recipientAccountId);
if (recipientWallet == null)
throw new InvalidOperationException("Recipient wallet not found");
// Create transaction to transfer funds to recipient
var transaction = await CreateTransactionAsync(
payerWalletId: null, // System transfer
payeeWalletId: recipientWallet.Id,
currency: fund.Currency,
amount: recipient.Amount,
remarks: $"Received fund portion from {fund.CreatorAccountId}",
type: Shared.Models.TransactionType.System,
silent: true
);
// Mark as received
recipient.IsReceived = true;
recipient.ReceivedAt = SystemClock.Instance.GetCurrentInstant();
// Update fund status
var allReceived = fund.Recipients.All(r => r.IsReceived);
if (allReceived)
fund.Status = Shared.Models.FundStatus.FullyReceived;
else
fund.Status = Shared.Models.FundStatus.PartiallyReceived;
await db.SaveChangesAsync();
return transaction;
}
public async Task ProcessExpiredFundsAsync()
{
var now = SystemClock.Instance.GetCurrentInstant();
var expiredFunds = await db.WalletFunds
.Include(f => f.Recipients)
.Where(f => f.Status == Shared.Models.FundStatus.Created || f.Status == Shared.Models.FundStatus.PartiallyReceived)
.Where(f => f.ExpiredAt < now)
.ToListAsync();
foreach (var fund in expiredFunds)
{
// Calculate unclaimed amount
var unclaimedAmount = fund.Recipients
.Where(r => !r.IsReceived)
.Sum(r => r.Amount);
if (unclaimedAmount > 0)
{
// Refund to creator
var creatorWallet = await wat.GetWalletAsync(fund.CreatorAccountId);
if (creatorWallet != null)
{
await CreateTransactionAsync(
payerWalletId: null, // System refund
payeeWalletId: creatorWallet.Id,
currency: fund.Currency,
amount: unclaimedAmount,
remarks: $"Refund for expired fund {fund.Id}",
type: Shared.Models.TransactionType.System,
silent: true
);
}
}
fund.Status = Shared.Models.FundStatus.Expired;
}
await db.SaveChangesAsync();
}
public async Task<WalletOverview> GetWalletOverviewAsync(Guid accountId, DateTime? startDate = null, DateTime? endDate = null)
{
var wallet = await wat.GetWalletAsync(accountId);
if (wallet == null)
throw new InvalidOperationException("Wallet not found");
var query = db.PaymentTransactions
.Where(t => t.PayerWalletId == wallet.Id || t.PayeeWalletId == wallet.Id);
if (startDate.HasValue)
query = query.Where(t => t.CreatedAt >= Instant.FromDateTimeUtc(startDate.Value.ToUniversalTime()));
if (endDate.HasValue)
query = query.Where(t => t.CreatedAt <= Instant.FromDateTimeUtc(endDate.Value.ToUniversalTime()));
var transactions = await query.ToListAsync();
var overview = new WalletOverview
{
AccountId = accountId,
StartDate = startDate?.ToString("O"),
EndDate = endDate?.ToString("O"),
Summary = new Dictionary<string, TransactionSummary>()
};
// Group transactions by type and currency
var groupedTransactions = transactions
.GroupBy(t => new { t.Type, t.Currency })
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var group in groupedTransactions)
{
var typeName = group.Key.Type.ToString();
var currency = group.Key.Currency;
if (!overview.Summary.ContainsKey(typeName))
{
overview.Summary[typeName] = new TransactionSummary
{
Type = typeName,
Currencies = new Dictionary<string, CurrencySummary>()
};
}
var currencySummary = new CurrencySummary
{
Currency = currency,
Income = 0,
Spending = 0,
Net = 0
};
foreach (var transaction in group.Value)
{
if (transaction.PayeeWalletId == wallet.Id)
{
// Money coming in
currencySummary.Income += transaction.Amount;
}
else if (transaction.PayerWalletId == wallet.Id)
{
// Money going out
currencySummary.Spending += transaction.Amount;
}
}
currencySummary.Net = currencySummary.Income - currencySummary.Spending;
overview.Summary[typeName].Currencies[currency] = currencySummary;
}
// Calculate totals
overview.TotalIncome = overview.Summary.Values.Sum(s => s.Currencies.Values.Sum(c => c.Income));
overview.TotalSpending = overview.Summary.Values.Sum(s => s.Currencies.Values.Sum(c => c.Spending));
overview.NetTotal = overview.TotalIncome - overview.TotalSpending;
return overview;
} }
} }
public class WalletOverview
{
public Guid AccountId { get; set; }
public string? StartDate { get; set; }
public string? EndDate { get; set; }
public Dictionary<string, TransactionSummary> Summary { get; set; } = new();
public decimal TotalIncome { get; set; }
public decimal TotalSpending { get; set; }
public decimal NetTotal { get; set; }
}
public class TransactionSummary
{
public string Type { get; set; } = null!;
public Dictionary<string, CurrencySummary> Currencies { get; set; } = new();
}
public class CurrencySummary
{
public string Currency { get; set; } = null!;
public decimal Income { get; set; }
public decimal Spending { get; set; }
public decimal Net { get; set; }
}

View File

@@ -146,19 +146,6 @@ public class SubscriptionGiftController(
{ {
error = "You already have an active subscription of this type."; error = "You already have an active subscription of this type.";
} }
else if (subscriptionInfo.RequiredLevel > 0)
{
var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == currentUser.Id);
if (profile is null || profile.Level < subscriptionInfo.RequiredLevel)
{
error =
$"Account level must be at least {subscriptionInfo.RequiredLevel} to redeem this gift.";
}
else
{
canRedeem = true;
}
}
else else
{ {
canRedeem = true; canRedeem = true;
@@ -197,6 +184,8 @@ public class SubscriptionGiftController(
public int? SubscriptionDurationDays { get; set; } = 30; // Subscription lasts 30 days when redeemed public int? SubscriptionDurationDays { get; set; } = 30; // Subscription lasts 30 days when redeemed
} }
const int MinimumAccountLevel = 60;
/// <summary> /// <summary>
/// Purchases a gift subscription. /// Purchases a gift subscription.
/// </summary> /// </summary>
@@ -206,6 +195,12 @@ public class SubscriptionGiftController(
{ {
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
if (currentUser.Profile.Level < MinimumAccountLevel)
{
if (currentUser.PerkSubscription is null)
return StatusCode(403, "Account level must be at least 60 or a member of the Stellar Program to purchase a gift.");
}
Duration? giftDuration = null; Duration? giftDuration = null;
if (request.GiftDurationDays.HasValue) if (request.GiftDurationDays.HasValue)
giftDuration = Duration.FromDays(request.GiftDurationDays.Value); giftDuration = Duration.FromDays(request.GiftDurationDays.Value);

View File

@@ -79,15 +79,6 @@ public class SubscriptionService(
var couponData = await couponTask; var couponData = await couponTask;
// Validation checks // Validation checks
if (subscriptionInfo.RequiredLevel > 0)
{
if (profile is null)
throw new InvalidOperationException("Account profile was not found.");
if (profile.Level < subscriptionInfo.RequiredLevel)
throw new InvalidOperationException(
$"Account level must be at least {subscriptionInfo.RequiredLevel} to subscribe to {identifier}."
);
}
if (isFreeTrial && prevFreeTrial != null) if (isFreeTrial && prevFreeTrial != null)
throw new InvalidOperationException("Free trial already exists."); throw new InvalidOperationException("Free trial already exists.");
@@ -259,6 +250,14 @@ public class SubscriptionService(
: null; : null;
if (subscriptionInfo is null) throw new InvalidOperationException("No matching subscription found."); if (subscriptionInfo is null) throw new InvalidOperationException("No matching subscription found.");
if (subscriptionInfo.RequiredLevel > 0)
{
var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == subscription.AccountId);
if (profile is null) throw new InvalidOperationException("Account must have a profile");
if (profile.Level < subscriptionInfo.RequiredLevel)
throw new InvalidOperationException("Account level must be at least 60 to purchase a gift.");
}
return await payment.CreateOrderAsync( return await payment.CreateOrderAsync(
null, null,
subscriptionInfo.Currency, subscriptionInfo.Currency,
@@ -662,33 +661,7 @@ public class SubscriptionService(
db.WalletGifts.Add(gift); db.WalletGifts.Add(gift);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Create order and process payment gift.Gifter = gifter;
var order = await payment.CreateOrderAsync(
null, // No specific payee wallet for gifts
subscriptionInfo.Currency,
finalPrice,
appIdentifier: "gift",
productIdentifier: subscriptionIdentifier,
meta: new Dictionary<string, object>
{
["gift_id"] = gift.Id.ToString()
}
);
// If payment method is in-app wallet, process payment immediately
if (paymentMethod == SubscriptionPaymentMethod.InAppWallet)
{
var gifterWallet = await db.Wallets.FirstOrDefaultAsync(w => w.AccountId == gifter.Id);
if (gifterWallet == null)
throw new InvalidOperationException("Gifter wallet not found.");
await payment.PayOrderAsync(order.Id, gifterWallet);
// Mark gift as sent after successful payment
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Sent;
gift.UpdatedAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
}
return gift; return gift;
} }
@@ -719,6 +692,9 @@ public class SubscriptionService(
if (now > gift.ExpiresAt) if (now > gift.ExpiresAt)
throw new InvalidOperationException("Gift has expired."); throw new InvalidOperationException("Gift has expired.");
if (gift.GifterId == redeemer.Id)
throw new InvalidOperationException("You cannot redeem your own gift.");
// Validate redeemer permissions // Validate redeemer permissions
if (!gift.IsOpenGift && gift.RecipientId != redeemer.Id) if (!gift.IsOpenGift && gift.RecipientId != redeemer.Id)
throw new InvalidOperationException("This gift is not intended for you."); throw new InvalidOperationException("This gift is not intended for you.");
@@ -731,6 +707,56 @@ public class SubscriptionService(
if (subscriptionInfo is null) if (subscriptionInfo is null)
throw new InvalidOperationException("Invalid gift subscription type."); throw new InvalidOperationException("Invalid gift subscription type.");
var sameTypeSubscription = await GetSubscriptionAsync(redeemer.Id, gift.SubscriptionIdentifier);
if (sameTypeSubscription is not null)
{
// Extend existing subscription
var subscriptionDuration = Duration.FromDays(28);
if (sameTypeSubscription.EndedAt.HasValue && sameTypeSubscription.EndedAt.Value > now)
{
sameTypeSubscription.EndedAt = sameTypeSubscription.EndedAt.Value.Plus(subscriptionDuration);
}
else
{
sameTypeSubscription.EndedAt = now.Plus(subscriptionDuration);
}
if (sameTypeSubscription.RenewalAt.HasValue)
{
sameTypeSubscription.RenewalAt = sameTypeSubscription.RenewalAt.Value.Plus(subscriptionDuration);
}
// Update gift status and link
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Redeemed;
gift.RedeemedAt = now;
gift.RedeemerId = redeemer.Id;
gift.SubscriptionId = sameTypeSubscription.Id;
gift.UpdatedAt = now;
using var transaction = await db.Database.BeginTransactionAsync();
try
{
db.WalletSubscriptions.Update(sameTypeSubscription);
db.WalletGifts.Update(gift);
await db.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
await NotifyGiftRedeemed(gift, sameTypeSubscription, redeemer);
if (gift.GifterId != redeemer.Id)
{
var gifter = await db.Accounts.FirstOrDefaultAsync(a => a.Id == gift.GifterId);
if (gifter != null) await NotifyGiftClaimedByRecipient(gift, sameTypeSubscription, gifter, redeemer);
}
return (gift, sameTypeSubscription);
}
var subscriptionsInGroup = subscriptionInfo.GroupIdentifier is not null var subscriptionsInGroup = subscriptionInfo.GroupIdentifier is not null
? SubscriptionTypeData.SubscriptionDict ? SubscriptionTypeData.SubscriptionDict
.Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier) .Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier)
@@ -742,17 +768,10 @@ public class SubscriptionService(
if (existingSubscription is not null) if (existingSubscription is not null)
throw new InvalidOperationException("You already have an active subscription of this type."); throw new InvalidOperationException("You already have an active subscription of this type.");
// Check account level requirement // We do not check account level requirement, since it is a gift
if (subscriptionInfo.RequiredLevel > 0)
{
var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == redeemer.Id);
if (profile is null || profile.Level < subscriptionInfo.RequiredLevel)
throw new InvalidOperationException(
$"Account level must be at least {subscriptionInfo.RequiredLevel} to redeem this gift.");
}
// Create the subscription from the gift // Create the subscription from the gift
var cycleDuration = Duration.FromDays(30); // Standard 30-day subscription var cycleDuration = Duration.FromDays(28);
var subscription = new SnWalletSubscription var subscription = new SnWalletSubscription
{ {
BegunAt = now, BegunAt = now,
@@ -761,7 +780,7 @@ public class SubscriptionService(
IsActive = true, IsActive = true,
IsFreeTrial = false, IsFreeTrial = false,
Status = Shared.Models.SubscriptionStatus.Active, Status = Shared.Models.SubscriptionStatus.Active,
PaymentMethod = $"gift:{gift.Id}", // Special payment method indicating gift redemption PaymentMethod = "gift", // Special payment method indicating gift redemption
PaymentDetails = new Shared.Models.SnPaymentDetails PaymentDetails = new Shared.Models.SnPaymentDetails
{ {
Currency = "gift", Currency = "gift",
@@ -772,7 +791,6 @@ public class SubscriptionService(
Coupon = gift.Coupon, Coupon = gift.Coupon,
RenewalAt = now.Plus(cycleDuration), RenewalAt = now.Plus(cycleDuration),
AccountId = redeemer.Id, AccountId = redeemer.Id,
GiftId = gift.Id
}; };
// Update the gift status // Update the gift status
@@ -783,18 +801,18 @@ public class SubscriptionService(
gift.UpdatedAt = now; gift.UpdatedAt = now;
// Save both gift and subscription // Save both gift and subscription
using var transaction = await db.Database.BeginTransactionAsync(); using var createTransaction = await db.Database.BeginTransactionAsync();
try try
{ {
db.WalletSubscriptions.Add(subscription); db.WalletSubscriptions.Add(subscription);
db.WalletGifts.Update(gift); db.WalletGifts.Update(gift);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await transaction.CommitAsync(); await createTransaction.CommitAsync();
} }
catch catch
{ {
await transaction.RollbackAsync(); await createTransaction.RollbackAsync();
throw; throw;
} }

View File

@@ -1,15 +1,18 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Permission; using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models; 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;
using NodaTime;
namespace DysonNetwork.Pass.Wallet; namespace DysonNetwork.Pass.Wallet;
[ApiController] [ApiController]
[Route("/api/wallets")] [Route("/api/wallets")]
public class WalletController(AppDatabase db, WalletService ws, PaymentService payment) : ControllerBase public class WalletController(AppDatabase db, WalletService ws, PaymentService payment, AuthService auth, ICacheService cache) : ControllerBase
{ {
[HttpPost] [HttpPost]
[Authorize] [Authorize]
@@ -39,6 +42,72 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
return Ok(wallet); return Ok(wallet);
} }
public class WalletStats
{
public Instant PeriodBegin { get; set; }
public Instant PeriodEnd { get; set; }
public int TotalTransactions { get; set; }
public int TotalOrders { get; set; }
public Dictionary<string, decimal> IncomeCatgories { get; set; } = null!;
public Dictionary<string, decimal> OutgoingCategories { get; set; } = null!;
public decimal TotalIncome => IncomeCatgories.Values.Sum();
public decimal TotalOutgoing => OutgoingCategories.Values.Sum();
public decimal Sum => TotalIncome - TotalOutgoing;
}
[HttpGet("stats")]
[Authorize]
public async Task<ActionResult<WalletStats>> GetWalletStats([FromQuery] int period = 30)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var wallet = await ws.GetWalletAsync(currentUser.Id);
if (wallet is null) return NotFound("Wallet was not found, please create one first.");
var periodEnd = SystemClock.Instance.GetCurrentInstant();
var periodBegin = periodEnd.Minus(Duration.FromDays(period));
var cacheKey = $"wallet:stats:{currentUser.Id}:{period}";
var cached = await cache.GetAsync<WalletStats>(cacheKey);
if (cached != null)
{
return Ok(cached);
}
var transactions = await db.PaymentTransactions
.Where(t => (t.PayerWalletId == wallet.Id || t.PayeeWalletId == wallet.Id) &&
t.CreatedAt >= periodBegin && t.CreatedAt <= periodEnd)
.ToListAsync();
var orders = await db.PaymentOrders
.Where(o => o.PayeeWalletId == wallet.Id &&
o.CreatedAt >= periodBegin && o.CreatedAt <= periodEnd)
.ToListAsync();
var incomeCategories = transactions
.Where(t => t.PayeeWalletId == wallet.Id)
.GroupBy(t => t.Type.ToString())
.ToDictionary(g => g.Key, g => g.Sum(t => t.Amount));
var outgoingCategories = transactions
.Where(t => t.PayerWalletId == wallet.Id)
.GroupBy(t => t.Type.ToString())
.ToDictionary(g => g.Key, g => g.Sum(t => t.Amount));
var stats = new WalletStats
{
PeriodBegin = periodBegin,
PeriodEnd = periodEnd,
TotalTransactions = transactions.Count,
TotalOrders = orders.Count,
IncomeCatgories = incomeCategories,
OutgoingCategories = outgoingCategories
};
await cache.SetAsync(cacheKey, stats, TimeSpan.FromHours(1));
return Ok(stats);
}
[HttpGet("transactions")] [HttpGet("transactions")]
[Authorize] [Authorize]
public async Task<ActionResult<List<SnWalletTransaction>>> GetTransactions( public async Task<ActionResult<List<SnWalletTransaction>>> GetTransactions(
@@ -61,6 +130,12 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
var transactions = await query var transactions = await query
.Skip(offset) .Skip(offset)
.Take(take) .Take(take)
.Include(t => t.PayerWallet)
.ThenInclude(w => w.Account)
.ThenInclude(w => w.Profile)
.Include(t => t.PayeeWallet)
.ThenInclude(w => w.Account)
.ThenInclude(w => w.Profile)
.ToListAsync(); .ToListAsync();
return Ok(transactions); return Ok(transactions);
@@ -102,6 +177,15 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
[Required] public Guid AccountId { get; set; } [Required] public Guid AccountId { get; set; }
} }
public class WalletTransferRequest
{
public string? Remark { get; set; }
[Required] public decimal Amount { get; set; }
[Required] public string Currency { get; set; } = null!;
[Required] public Guid PayeeAccountId { get; set; }
[Required] public string PinCode { get; set; } = null!;
}
[HttpPost("balance")] [HttpPost("balance")]
[Authorize] [Authorize]
[RequiredPermission("maintenance", "wallets.balance.modify")] [RequiredPermission("maintenance", "wallets.balance.modify")]
@@ -128,4 +212,190 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
return Ok(transaction); return Ok(transaction);
} }
[HttpPost("transfer")]
[Authorize]
public async Task<ActionResult<SnWalletTransaction>> Transfer([FromBody] WalletTransferRequest request)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
// Validate PIN code
if (!await auth.ValidatePinCode(currentUser.Id, request.PinCode))
return StatusCode(403, "Invalid PIN Code");
if (currentUser.Id == request.PayeeAccountId) return BadRequest("Cannot transfer to yourself.");
try
{
var transaction = await payment.TransferAsync(
payerAccountId: currentUser.Id,
payeeAccountId: request.PayeeAccountId,
currency: request.Currency,
amount: request.Amount
);
return Ok(transaction);
}
catch (Exception err)
{
return BadRequest(err.Message);
}
}
public class CreateFundRequest
{
[Required] public List<Guid> RecipientAccountIds { get; set; } = new();
[Required] public string Currency { get; set; } = null!;
[Required] public decimal TotalAmount { get; set; }
[Required] public FundSplitType SplitType { get; set; }
public string? Message { get; set; }
public int? ExpirationHours { get; set; } // Optional: hours until expiration
[Required] public string PinCode { get; set; } = null!; // Required PIN for fund creation
}
[HttpPost("funds")]
[Authorize]
public async Task<ActionResult<SnWalletFund>> CreateFund([FromBody] CreateFundRequest request)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
// Validate PIN code
if (!await auth.ValidatePinCode(currentUser.Id, request.PinCode))
return StatusCode(403, "Invalid PIN Code");
try
{
Duration? expiration = null;
if (request.ExpirationHours.HasValue)
{
expiration = Duration.FromHours(request.ExpirationHours.Value);
}
var fund = await payment.CreateFundAsync(
creatorAccountId: currentUser.Id,
recipientAccountIds: request.RecipientAccountIds,
currency: request.Currency,
totalAmount: request.TotalAmount,
splitType: request.SplitType,
message: request.Message,
expiration: expiration
);
return Ok(fund);
}
catch (Exception err)
{
return BadRequest(err.Message);
}
}
[HttpGet("funds")]
[Authorize]
public async Task<ActionResult<List<SnWalletFund>>> GetFunds(
[FromQuery] int offset = 0,
[FromQuery] int take = 20,
[FromQuery] FundStatus? status = null
)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var query = db.WalletFunds
.Include(f => f.Recipients)
.ThenInclude(r => r.RecipientAccount)
.ThenInclude(a => a.Profile)
.Include(f => f.CreatorAccount)
.ThenInclude(a => a.Profile)
.Where(f => f.CreatorAccountId == currentUser.Id ||
f.Recipients.Any(r => r.RecipientAccountId == currentUser.Id))
.AsQueryable();
if (status.HasValue)
{
query = query.Where(f => f.Status == status.Value);
}
var fundCount = await query.CountAsync();
Response.Headers["X-Total"] = fundCount.ToString();
var funds = await query
.OrderByDescending(f => f.CreatedAt)
.Skip(offset)
.Take(take)
.ToListAsync();
return Ok(funds);
}
[HttpGet("funds/{id}")]
[Authorize]
public async Task<ActionResult<SnWalletFund>> GetFund(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var fund = await db.WalletFunds
.Include(f => f.Recipients)
.ThenInclude(r => r.RecipientAccount)
.ThenInclude(a => a.Profile)
.Include(f => f.CreatorAccount)
.ThenInclude(a => a.Profile)
.FirstOrDefaultAsync(f => f.Id == id);
if (fund == null)
return NotFound("Fund not found");
// Check if user is creator or recipient
var isCreator = fund.CreatorAccountId == currentUser.Id;
var isRecipient = fund.Recipients.Any(r => r.RecipientAccountId == currentUser.Id);
if (!isCreator && !isRecipient)
return Forbid("You don't have permission to view this fund");
return Ok(fund);
}
[HttpPost("funds/{id}/receive")]
[Authorize]
public async Task<ActionResult<SnWalletTransaction>> ReceiveFund(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
var transaction = await payment.ReceiveFundAsync(
recipientAccountId: currentUser.Id,
fundId: id
);
return Ok(transaction);
}
catch (Exception err)
{
return BadRequest(err.Message);
}
}
[HttpGet("overview")]
[Authorize]
public async Task<ActionResult<WalletOverview>> GetWalletOverview(
[FromQuery] DateTime? startDate = null,
[FromQuery] DateTime? endDate = null
)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
var overview = await payment.GetWalletOverviewAsync(
accountId: currentUser.Id,
startDate: startDate,
endDate: endDate
);
return Ok(overview);
}
catch (Exception err)
{
return BadRequest(err.Message);
}
}
} }

View File

@@ -84,6 +84,15 @@ public class WebSocketController(
{ {
await _ConnectionEventLoop(deviceId, currentUser, webSocket, cts.Token); await _ConnectionEventLoop(deviceId, currentUser, webSocket, cts.Token);
} }
catch (WebSocketException ex) when (ex.Message.Contains("The remote party closed the WebSocket connection without completing the close handshake"))
{
logger.LogDebug(
"WebSocket disconnected with user @{UserName}#{UserId} and device #{DeviceId} - client closed connection without proper handshake",
currentUser.Name,
currentUser.Id,
deviceId
);
}
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, logger.LogError(ex,

View File

@@ -15,7 +15,7 @@
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" /> <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" />
<PackageReference Include="MailKit" Version="4.13.0" /> <PackageReference Include="MailKit" Version="4.13.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
@@ -31,8 +31,8 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="Quartz" Version="3.14.0" /> <PackageReference Include="Quartz" Version="3.14.0" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" /> <PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" /> <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.6" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -42,7 +42,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -14,7 +14,6 @@ builder.ConfigureAppKestrel(builder.Configuration);
// Add application services // Add application services
builder.Services.AddAppServices(builder.Configuration); builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppRateLimiting();
builder.Services.AddAppAuthentication(); builder.Services.AddAppAuthentication();
builder.Services.AddDysonAuth(); builder.Services.AddDysonAuth();
builder.Services.AddAccountService(); builder.Services.AddAccountService();
@@ -45,6 +44,6 @@ app.ConfigureAppMiddleware(builder.Configuration);
// Configure gRPC // Configure gRPC
app.ConfigureGrpcServices(); app.ConfigureGrpcServices();
app.UseSwaggerManifest(); app.UseSwaggerManifest("DysonNetwork.Ring");
app.Run(); app.Run();

View File

@@ -43,7 +43,7 @@ public class RingServiceGrpc(
return Task.FromResult(new Empty()); return Task.FromResult(new Empty());
} }
public override Task<Empty> PushWebSocketPacketToDevice(PushWebSocketPacketToDeviceRequest request, public override Task<Empty> PushWebSocketPacketToDevice(PushWebSocketPacketToDeviceRequest request,
ServerCallContext context) ServerCallContext context)
{ {
var packet = Shared.Models.WebSocketPacket.FromProtoValue(request.Packet); var packet = Shared.Models.WebSocketPacket.FromProtoValue(request.Packet);

View File

@@ -2,12 +2,8 @@ using System.Text.Json;
using DysonNetwork.Ring.Email; using DysonNetwork.Ring.Email;
using DysonNetwork.Ring.Notification; using DysonNetwork.Ring.Notification;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Stream;
using Google.Protobuf; using Google.Protobuf;
using NATS.Client.Core; using NATS.Client.Core;
using NATS.Client.JetStream;
using NATS.Client.JetStream.Models;
using NATS.Net;
namespace DysonNetwork.Ring.Services; namespace DysonNetwork.Ring.Services;
@@ -39,29 +35,19 @@ public class QueueBackgroundService(
private async Task RunConsumerAsync(CancellationToken stoppingToken) private async Task RunConsumerAsync(CancellationToken stoppingToken)
{ {
logger.LogInformation("Queue consumer started"); logger.LogInformation("Queue consumer started");
var js = nats.CreateJetStreamContext();
await js.EnsureStreamCreated("pusher_events", [QueueName]); await foreach (var msg in nats.SubscribeAsync<byte[]>(QueueName, queueGroup: QueueGroup, cancellationToken: stoppingToken))
var consumer = await js.CreateOrUpdateConsumerAsync(
"pusher_events",
new ConsumerConfig(QueueGroup), // durable consumer
cancellationToken: stoppingToken);
await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken))
{ {
try try
{ {
var message = GrpcTypeHelper.ConvertByteStringToObject<QueueMessage>(ByteString.CopyFrom(msg.Data)); var message = GrpcTypeHelper.ConvertByteStringToObject<QueueMessage>(ByteString.CopyFrom(msg.Data));
if (message is not null) if (message is not null)
{ {
await ProcessMessageAsync(msg, message, stoppingToken); await ProcessMessageAsync(message, stoppingToken);
await msg.AckAsync(cancellationToken: stoppingToken);
} }
else else
{ {
logger.LogWarning($"Invalid message format for {msg.Subject}"); logger.LogWarning($"Invalid message format for {msg.Subject}");
await msg.AckAsync(cancellationToken: stoppingToken); // Acknowledge invalid messages to avoid redelivery
} }
} }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
@@ -72,12 +58,11 @@ public class QueueBackgroundService(
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error in queue consumer"); logger.LogError(ex, "Error in queue consumer");
await msg.NakAsync(cancellationToken: stoppingToken);
} }
} }
} }
private async ValueTask ProcessMessageAsync(NatsJSMsg<byte[]> rawMsg, QueueMessage message, private async ValueTask ProcessMessageAsync(QueueMessage message,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
using var scope = serviceProvider.CreateScope(); using var scope = serviceProvider.CreateScope();

View File

@@ -1,7 +1,6 @@
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using NATS.Client.Core; using NATS.Client.Core;
using NATS.Net;
namespace DysonNetwork.Ring.Services; namespace DysonNetwork.Ring.Services;
@@ -21,8 +20,7 @@ public class QueueService(INatsConnection nats)
}) })
}; };
var rawMessage = GrpcTypeHelper.ConvertObjectToByteString(message).ToByteArray(); var rawMessage = GrpcTypeHelper.ConvertObjectToByteString(message).ToByteArray();
var js = nats.CreateJetStreamContext(); await nats.PublishAsync(QueueBackgroundService.QueueName, rawMessage);
await js.PublishAsync(QueueBackgroundService.QueueName, rawMessage);
} }
public async Task EnqueuePushNotification(Shared.Models.SnNotification notification, Guid userId, bool isSavable = false) public async Task EnqueuePushNotification(Shared.Models.SnNotification notification, Guid userId, bool isSavable = false)
@@ -37,8 +35,7 @@ public class QueueService(INatsConnection nats)
Data = JsonSerializer.Serialize(notification) Data = JsonSerializer.Serialize(notification)
}; };
var rawMessage = GrpcTypeHelper.ConvertObjectToByteString(message).ToByteArray(); var rawMessage = GrpcTypeHelper.ConvertObjectToByteString(message).ToByteArray();
var js = nats.CreateJetStreamContext(); await nats.PublishAsync(QueueBackgroundService.QueueName, rawMessage);
await js.PublishAsync(QueueBackgroundService.QueueName, rawMessage);
} }
} }

View File

@@ -12,7 +12,6 @@ public static class ApplicationConfiguration
app.ConfigureForwardedHeaders(configuration); app.ConfigureForwardedHeaders(configuration);
app.UseWebSockets(); app.UseWebSockets();
app.UseRateLimiter();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();

View File

@@ -50,19 +50,6 @@ public static class ServiceCollectionExtensions
return services; return services;
} }
public static IServiceCollection AddAppRateLimiting(this IServiceCollection services)
{
services.AddRateLimiter(o => o.AddFixedWindowLimiter(policyName: "fixed", opts =>
{
opts.Window = TimeSpan.FromMinutes(1);
opts.PermitLimit = 120;
opts.QueueLimit = 2;
opts.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
}));
return services;
}
public static IServiceCollection AddAppAuthentication(this IServiceCollection services) public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
{ {
services.AddAuthorization(); services.AddAuthorization();

View File

@@ -1,26 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireSharedProject>true</IsAspireSharedProject>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
<PackageReference Include="Aspire.NATS.Net" Version="9.4.2" />
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.4.2" />
<PackageReference Include="Aspire.StackExchange.Redis" Version="9.4.2" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.7.0"/>
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="9.4.2"/>
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.GrpcNetClient" Version="1.12.0-beta.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/>
</ItemGroup>
</Project>

View File

@@ -20,7 +20,7 @@
</PackageReference> </PackageReference>
<PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" /> <PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.3.0" /> <PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.7" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.7" />
<PackageReference Include="NATS.Net" Version="2.6.8" /> <PackageReference Include="NATS.Net" Version="2.6.8" />
<PackageReference Include="NodaTime" Version="3.2.2" /> <PackageReference Include="NodaTime" Version="3.2.2" />
@@ -29,18 +29,27 @@
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" /> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="OpenGraph-Net" Version="4.0.1" /> <PackageReference Include="OpenGraph-Net" Version="4.0.1" />
<PackageReference Include="Otp.NET" Version="1.4.0" /> <PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" /> <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.6" />
<PackageReference Include="System.Net.Http" Version="4.3.4" /> <PackageReference Include="System.Net.Http" Version="4.3.4" />
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" /> <PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
<PackageReference Include="Aspire.NATS.Net" Version="9.5.1" />
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.5.1" />
<PackageReference Include="Aspire.StackExchange.Redis" Version="9.5.1" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.7.0"/>
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="9.4.2"/>
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.GrpcNetClient" Version="1.12.0-beta.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Protobuf Include="Proto\*.proto" ProtoRoot="Proto" GrpcServices="Both" AdditionalFileExtensions="Proto\" /> <Protobuf Include="Proto\*.proto" ProtoRoot="Proto" GrpcServices="Both" AdditionalFileExtensions="Proto\" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
</ItemGroup>
</Project> </Project>

View File

@@ -117,10 +117,6 @@ public static class Extensions
} }
public static WebApplication MapDefaultEndpoints(this WebApplication app) public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
// Adding health checks endpoints to applications in non-development environments has security implications.
// See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
if (app.Environment.IsDevelopment())
{ {
// All health checks must pass for app to be considered ready to accept traffic after starting // All health checks must pass for app to be considered ready to accept traffic after starting
app.MapHealthChecks(HealthEndpointPath); app.MapHealthChecks(HealthEndpointPath);
@@ -130,7 +126,6 @@ public static class Extensions
{ {
Predicate = r => r.Tags.Contains("live") Predicate = r => r.Tags.Contains("live")
}); });
}
return app; return app;
} }

View File

@@ -56,7 +56,7 @@ public static class SwaggerGen
return builder; return builder;
} }
public static WebApplication UseSwaggerManifest(this WebApplication app) public static WebApplication UseSwaggerManifest(this WebApplication app, string serviceName)
{ {
app.MapOpenApi(); app.MapOpenApi();
@@ -103,7 +103,7 @@ public static class SwaggerGen
var publicBasePath = configuration["Swagger:PublicBasePath"]?.TrimEnd('/') ?? ""; var publicBasePath = configuration["Swagger:PublicBasePath"]?.TrimEnd('/') ?? "";
options.SwaggerEndpoint( options.SwaggerEndpoint(
$"{publicBasePath}/swagger/v1/swagger.json", $"{publicBasePath}/swagger/v1/swagger.json",
"Develop API v1"); $"{serviceName} API v1");
}); });
return app; return app;

View File

@@ -142,6 +142,40 @@ public abstract class Leveling
} }
} }
public class UsernameColor
{
public string Type { get; set; } = "plain"; // "plain" | "gradient"
public string? Value { get; set; } // e.g. "red" or "#ff6600"
public string? Direction { get; set; } // e.g. "to right"
public List<string>? Colors { get; set; } // e.g. ["#ff0000", "#00ff00"]
public Proto.UsernameColor ToProtoValue()
{
var proto = new Proto.UsernameColor
{
Type = Type,
Value = Value,
Direction = Direction,
};
if (Colors is not null)
{
proto.Colors.AddRange(Colors);
}
return proto;
}
public static UsernameColor FromProtoValue(Proto.UsernameColor proto)
{
return new UsernameColor
{
Type = proto.Type,
Value = proto.Value,
Direction = proto.Direction,
Colors = proto.Colors?.ToList()
};
}
}
public class SnAccountProfile : ModelBase, IIdentifiedResource public class SnAccountProfile : ModelBase, IIdentifiedResource
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
@@ -154,6 +188,7 @@ public class SnAccountProfile : ModelBase, IIdentifiedResource
[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; }
[Column(TypeName = "jsonb")] public List<ProfileLink>? Links { get; set; } [Column(TypeName = "jsonb")] public List<ProfileLink>? Links { get; set; }
[Column(TypeName = "jsonb")] public UsernameColor? UsernameColor { get; set; }
public Instant? Birthday { get; set; } public Instant? Birthday { get; set; }
public Instant? LastSeenAt { get; set; } public Instant? LastSeenAt { get; set; }
@@ -209,6 +244,7 @@ public class SnAccountProfile : ModelBase, IIdentifiedResource
AccountId = AccountId.ToString(), AccountId = AccountId.ToString(),
Verification = Verification?.ToProtoValue(), Verification = Verification?.ToProtoValue(),
ActiveBadge = ActiveBadge?.ToProtoValue(), ActiveBadge = ActiveBadge?.ToProtoValue(),
UsernameColor = UsernameColor?.ToProtoValue(),
CreatedAt = CreatedAt.ToTimestamp(), CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp() UpdatedAt = UpdatedAt.ToTimestamp()
}; };
@@ -238,6 +274,7 @@ public class SnAccountProfile : ModelBase, IIdentifiedResource
Picture = proto.Picture is null ? null : SnCloudFileReferenceObject.FromProtoValue(proto.Picture), Picture = proto.Picture is null ? null : SnCloudFileReferenceObject.FromProtoValue(proto.Picture),
Background = proto.Background is null ? null : SnCloudFileReferenceObject.FromProtoValue(proto.Background), Background = proto.Background is null ? null : SnCloudFileReferenceObject.FromProtoValue(proto.Background),
AccountId = Guid.Parse(proto.AccountId), AccountId = Guid.Parse(proto.AccountId),
UsernameColor = proto.UsernameColor is not null ? UsernameColor.FromProtoValue(proto.UsernameColor) : null,
CreatedAt = proto.CreatedAt.ToInstant(), CreatedAt = proto.CreatedAt.ToInstant(),
UpdatedAt = proto.UpdatedAt.ToInstant() UpdatedAt = proto.UpdatedAt.ToInstant()
}; };

View File

@@ -0,0 +1,19 @@
using NodaTime;
namespace DysonNetwork.Shared.Models;
public class ActivityHeatmap
{
public string Unit { get; set; } = "posts";
public Instant PeriodStart { get; set; }
public Instant PeriodEnd { get; set; }
public List<ActivityHeatmapItem> Items { get; set; } = new();
}
public class ActivityHeatmapItem
{
public Instant Date { get; set; }
public int Count { get; set; }
}

View File

@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace DysonNetwork.Shared.Models;
public class AutocompletionRequest
{
[Required] public string Content { get; set; } = null!;
}
public class Autocompletion
{
public string Type { get; set; } = null!;
public string Keyword { get; set; } = null!;
public object Data { get; set; } = null!;
}

View File

@@ -30,7 +30,7 @@ public class SnChatRoom : ModelBase, IIdentifiedResource
[JsonIgnore] public ICollection<SnChatMember> Members { get; set; } = new List<SnChatMember>(); [JsonIgnore] public ICollection<SnChatMember> Members { get; set; } = new List<SnChatMember>();
public Guid? RealmId { get; set; } public Guid? RealmId { get; set; }
public SnRealm? Realm { get; set; } [NotMapped] public SnRealm? Realm { get; set; }
[NotMapped] [NotMapped]
[JsonPropertyName("members")] [JsonPropertyName("members")]

View File

@@ -81,12 +81,14 @@ public class SnCustomApp : ModelBase, IIdentifiedResource
}; };
} }
public SnCustomApp FromProtoValue(Proto.CustomApp p) public static SnCustomApp FromProtoValue(Proto.CustomApp p)
{ {
Id = Guid.Parse(p.Id); var obj = new SnCustomApp
Slug = p.Slug; {
Name = p.Name; Id = Guid.Parse(p.Id),
Description = string.IsNullOrEmpty(p.Description) ? null : p.Description; Slug = p.Slug,
Name = p.Name,
Description = string.IsNullOrEmpty(p.Description) ? null : p.Description,
Status = p.Status switch Status = p.Status switch
{ {
Shared.Proto.CustomAppStatus.Developing => CustomAppStatus.Developing, Shared.Proto.CustomAppStatus.Developing => CustomAppStatus.Developing,
@@ -94,23 +96,26 @@ public class SnCustomApp : ModelBase, IIdentifiedResource
Shared.Proto.CustomAppStatus.Production => CustomAppStatus.Production, Shared.Proto.CustomAppStatus.Production => CustomAppStatus.Production,
Shared.Proto.CustomAppStatus.Suspended => CustomAppStatus.Suspended, Shared.Proto.CustomAppStatus.Suspended => CustomAppStatus.Suspended,
_ => CustomAppStatus.Developing _ => CustomAppStatus.Developing
},
ProjectId = string.IsNullOrEmpty(p.ProjectId) ? Guid.Empty : Guid.Parse(p.ProjectId),
CreatedAt = p.CreatedAt.ToInstant(),
UpdatedAt = p.UpdatedAt.ToInstant(),
}; };
ProjectId = string.IsNullOrEmpty(p.ProjectId) ? Guid.Empty : Guid.Parse(p.ProjectId);
CreatedAt = p.CreatedAt.ToInstant(); if (p.Picture is not null) obj.Picture = SnCloudFileReferenceObject.FromProtoValue(p.Picture);
UpdatedAt = p.UpdatedAt.ToInstant(); if (p.Background is not null) obj.Background = SnCloudFileReferenceObject.FromProtoValue(p.Background);
if (p.Picture is not null) Picture = SnCloudFileReferenceObject.FromProtoValue(p.Picture); if (p.Verification is not null) obj.Verification = SnVerificationMark.FromProtoValue(p.Verification);
if (p.Background is not null) Background = SnCloudFileReferenceObject.FromProtoValue(p.Background);
if (p.Verification is not null) Verification = SnVerificationMark.FromProtoValue(p.Verification);
if (p.Links is not null) if (p.Links is not null)
{ {
Links = new SnCustomAppLinks obj.Links = new SnCustomAppLinks
{ {
HomePage = string.IsNullOrEmpty(p.Links.HomePage) ? null : p.Links.HomePage, HomePage = string.IsNullOrEmpty(p.Links.HomePage) ? null : p.Links.HomePage,
PrivacyPolicy = string.IsNullOrEmpty(p.Links.PrivacyPolicy) ? null : p.Links.PrivacyPolicy, PrivacyPolicy = string.IsNullOrEmpty(p.Links.PrivacyPolicy) ? null : p.Links.PrivacyPolicy,
TermsOfService = string.IsNullOrEmpty(p.Links.TermsOfService) ? null : p.Links.TermsOfService TermsOfService = string.IsNullOrEmpty(p.Links.TermsOfService) ? null : p.Links.TermsOfService
}; };
} }
return this;
return obj;
} }
} }

View File

@@ -64,7 +64,7 @@ public class SnPost : ModelBase, IIdentifiedResource, IActivity
public SnPost? ForwardedPost { get; set; } public SnPost? ForwardedPost { get; set; }
public Guid? RealmId { get; set; } public Guid? RealmId { get; set; }
public SnRealm? Realm { get; set; } [NotMapped] public SnRealm? Realm { get; set; }
[Column(TypeName = "jsonb")] public List<SnCloudFileReferenceObject> Attachments { get; set; } = []; [Column(TypeName = "jsonb")] public List<SnCloudFileReferenceObject> Attachments { get; set; } = [];
@@ -73,11 +73,12 @@ public class SnPost : ModelBase, IIdentifiedResource, IActivity
public Guid PublisherId { get; set; } public Guid PublisherId { get; set; }
public SnPublisher Publisher { get; set; } = null!; public SnPublisher Publisher { get; set; } = null!;
public ICollection<SnPostAward> Awards { get; set; } = null!; public List<SnPostAward> Awards { get; set; } = null!;
[JsonIgnore] public ICollection<SnPostReaction> Reactions { get; set; } = new List<SnPostReaction>(); [JsonIgnore] public List<SnPostReaction> Reactions { get; set; } = [];
public ICollection<SnPostTag> Tags { get; set; } = new List<SnPostTag>(); public List<SnPostTag> Tags { get; set; } = [];
public ICollection<SnPostCategory> Categories { get; set; } = new List<SnPostCategory>(); public List<SnPostCategory> Categories { get; set; } = [];
[JsonIgnore] public ICollection<SnPostCollection> Collections { get; set; } = new List<SnPostCollection>(); [JsonIgnore] public List<SnPostCollection> Collections { get; set; } = [];
public List<SnPostFeaturedRecord> FeaturedRecords { get; set; } = [];
[JsonIgnore] public bool Empty => Content == null && Attachments.Count == 0 && ForwardedPostId == null; [JsonIgnore] public bool Empty => Content == null && Attachments.Count == 0 && ForwardedPostId == null;
[NotMapped] public bool IsTruncated { get; set; } = false; [NotMapped] public bool IsTruncated { get; set; } = false;
@@ -104,7 +105,7 @@ public class SnPostTag : ModelBase
public Guid Id { get; set; } public Guid Id { get; set; }
[MaxLength(128)] public string Slug { get; set; } = null!; [MaxLength(128)] public string Slug { get; set; } = null!;
[MaxLength(256)] public string? Name { get; set; } [MaxLength(256)] public string? Name { get; set; }
[JsonIgnore] public ICollection<SnPost> Posts { get; set; } = new List<SnPost>(); [JsonIgnore] public List<SnPost> Posts { get; set; } = new List<SnPost>();
[NotMapped] public int? Usage { get; set; } [NotMapped] public int? Usage { get; set; }
} }
@@ -114,7 +115,7 @@ public class SnPostCategory : ModelBase
public Guid Id { get; set; } public Guid Id { get; set; }
[MaxLength(128)] public string Slug { get; set; } = null!; [MaxLength(128)] public string Slug { get; set; } = null!;
[MaxLength(256)] public string? Name { get; set; } [MaxLength(256)] public string? Name { get; set; }
[JsonIgnore] public ICollection<SnPost> Posts { get; set; } = new List<SnPost>(); [JsonIgnore] public List<SnPost> Posts { get; set; } = new List<SnPost>();
[NotMapped] public int? Usage { get; set; } [NotMapped] public int? Usage { get; set; }
} }
@@ -139,15 +140,14 @@ public class SnPostCollection : ModelBase
public SnPublisher Publisher { get; set; } = null!; public SnPublisher Publisher { get; set; } = null!;
public ICollection<SnPost> Posts { get; set; } = new List<SnPost>(); public List<SnPost> Posts { get; set; } = new List<SnPost>();
} }
public class SnPostFeaturedRecord : ModelBase public class SnPostFeaturedRecord : ModelBase
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public Guid PostId { get; set; } public Guid PostId { get; set; }
public SnPost Post { get; set; } = null!; [JsonIgnore] public SnPost Post { get; set; } = null!;
public Instant? FeaturedAt { get; set; } public Instant? FeaturedAt { get; set; }
public int SocialCredits { get; set; } public int SocialCredits { get; set; }
} }
@@ -168,6 +168,7 @@ public class SnPostReaction : ModelBase
public Guid PostId { get; set; } public Guid PostId { get; set; }
[JsonIgnore] public SnPost Post { get; set; } = null!; [JsonIgnore] public SnPost Post { get; set; } = null!;
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
[NotMapped] public SnAccount? Account { get; set; }
} }
public class SnPostAward : ModelBase public class SnPostAward : ModelBase

View File

@@ -42,7 +42,7 @@ public class SnPublisher : ModelBase, IIdentifiedResource
public Guid? AccountId { get; set; } public Guid? AccountId { get; set; }
public Guid? RealmId { get; set; } public Guid? RealmId { get; set; }
[JsonIgnore] public SnRealm? Realm { get; set; } [NotMapped] public SnRealm? Realm { get; set; }
[NotMapped] public SnAccount? Account { get; set; } [NotMapped] public SnAccount? Account { get; set; }
public string ResourceIdentifier => $"publisher:{Id}"; public string ResourceIdentifier => $"publisher:{Id}";

View File

@@ -1,8 +1,10 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Shared.Models; namespace DysonNetwork.Shared.Models;
@@ -26,11 +28,35 @@ public class SnRealm : ModelBase, IIdentifiedResource
[Column(TypeName = "jsonb")] public SnVerificationMark? Verification { get; set; } [Column(TypeName = "jsonb")] public SnVerificationMark? Verification { get; set; }
[JsonIgnore] public ICollection<SnRealmMember> Members { get; set; } = new List<SnRealmMember>(); [JsonIgnore] public ICollection<SnRealmMember> Members { get; set; } = new List<SnRealmMember>();
[JsonIgnore] public ICollection<SnChatRoom> ChatRooms { get; set; } = new List<SnChatRoom>();
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
public string ResourceIdentifier => $"realm:{Id}"; public string ResourceIdentifier => $"realm:{Id}";
public Realm ToProtoValue()
{
return new Realm
{
Id = Id.ToString(),
Name = Name,
Slug = Slug,
IsCommunity = IsCommunity,
IsPublic = IsPublic
};
}
public static SnRealm FromProtoValue(Realm proto)
{
return new SnRealm
{
Id = Guid.Parse(proto.Id),
Name = proto.Name,
Slug = proto.Slug,
Description = "",
IsCommunity = proto.IsCommunity,
IsPublic = proto.IsPublic
};
}
} }
public abstract class RealmMemberRole public abstract class RealmMemberRole
@@ -51,4 +77,40 @@ public class SnRealmMember : ModelBase
public int Role { get; set; } = RealmMemberRole.Normal; public int Role { get; set; } = RealmMemberRole.Normal;
public Instant? JoinedAt { get; set; } public Instant? JoinedAt { get; set; }
public Instant? LeaveAt { get; set; } public Instant? LeaveAt { get; set; }
public Proto.RealmMember ToProtoValue()
{
var proto = new Proto.RealmMember
{
AccountId = AccountId.ToString(),
RealmId = RealmId.ToString(),
Role = Role,
JoinedAt = JoinedAt?.ToTimestamp(),
LeaveAt = LeaveAt?.ToTimestamp(),
Realm = Realm.ToProtoValue()
};
if (Account != null)
{
proto.Account = Account.ToProtoValue();
}
return proto;
}
public static SnRealmMember FromProtoValue(RealmMember proto)
{
var member = new SnRealmMember
{
AccountId = Guid.Parse(proto.AccountId),
RealmId = Guid.Parse(proto.RealmId),
Role = proto.Role,
JoinedAt = proto.JoinedAt?.ToInstant(),
LeaveAt = proto.LeaveAt?.ToInstant(),
Realm = proto.Realm != null ? SnRealm.FromProtoValue(proto.Realm) : new SnRealm() // Provide default or handle null
};
if (proto.Account != null)
{
member.Account = SnAccount.FromProtoValue(proto.Account);
}
return member;
}
} }

View File

@@ -0,0 +1,49 @@
using NodaTime;
namespace DysonNetwork.Shared.Models;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
public enum LotteryDrawStatus
{
Pending = 0,
Drawn = 1
}
public class SnLotteryRecord : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public Instant DrawDate { get; set; } // Date of the draw
[Column(TypeName = "jsonb")]
public List<int> WinningRegionOneNumbers { get; set; } = new(); // 5 winning numbers
[Range(0, 99)]
public int WinningRegionTwoNumber { get; set; } // 1 winning number
public int TotalTickets { get; set; } // Total tickets processed for this draw
public int TotalPrizesAwarded { get; set; } // Total prizes awarded
public long TotalPrizeAmount { get; set; } // Total ISP prize amount awarded
}
public class SnLottery : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public SnAccount Account { get; set; } = null!;
public Guid AccountId { get; set; }
[Column(TypeName = "jsonb")]
public List<int> RegionOneNumbers { get; set; } = new(); // 5 numbers, 0-99, unique
[Range(0, 99)]
public int RegionTwoNumber { get; set; } // 1 number, 0-99, can repeat
public int Multiplier { get; set; } = 1; // Default 1x
public LotteryDrawStatus DrawStatus { get; set; } = LotteryDrawStatus.Pending; // Status to track draw processing
public Instant? DrawDate { get; set; } // Date when this ticket was drawn
}

View File

@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Globalization; using System.Globalization;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using NodaTime.Serialization.Protobuf; using NodaTime.Serialization.Protobuf;
@@ -30,21 +31,21 @@ public record class SubscriptionTypeData(
SubscriptionType.StellarProgram, SubscriptionType.StellarProgram,
WalletCurrency.SourcePoint, WalletCurrency.SourcePoint,
1200, 1200,
3 20
), ),
[SubscriptionType.Nova] = new SubscriptionTypeData( [SubscriptionType.Nova] = new SubscriptionTypeData(
SubscriptionType.Nova, SubscriptionType.Nova,
SubscriptionType.StellarProgram, SubscriptionType.StellarProgram,
WalletCurrency.SourcePoint, WalletCurrency.SourcePoint,
2400, 2400,
6 40
), ),
[SubscriptionType.Supernova] = new SubscriptionTypeData( [SubscriptionType.Supernova] = new SubscriptionTypeData(
SubscriptionType.Supernova, SubscriptionType.Supernova,
SubscriptionType.StellarProgram, SubscriptionType.StellarProgram,
WalletCurrency.SourcePoint, WalletCurrency.SourcePoint,
3600, 3600,
9 60
) )
}; };
@@ -128,7 +129,8 @@ public class SnWalletGift : ModelBase
/// <summary> /// <summary>
/// The subscription created when the gift is redeemed. /// The subscription created when the gift is redeemed.
/// </summary> /// </summary>
public SnWalletSubscription? Subscription { get; set; } [JsonIgnore] public SnWalletSubscription? Subscription { get; set; }
public Guid? SubscriptionId { get; set; }
/// <summary> /// <summary>
/// When the gift expires and can no longer be redeemed. /// When the gift expires and can no longer be redeemed.
@@ -337,7 +339,6 @@ public class SnWalletSubscription : ModelBase
/// <summary> /// <summary>
/// If this subscription was redeemed from a gift, this references the gift record. /// If this subscription was redeemed from a gift, this references the gift record.
/// </summary> /// </summary>
public Guid? GiftId { get; set; }
public SnWalletGift? Gift { get; set; } public SnWalletGift? Gift { get; set; }
[NotMapped] [NotMapped]

View File

@@ -1,6 +1,8 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Globalization; using System.Globalization;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using NodaTime;
using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Shared.Models; namespace DysonNetwork.Shared.Models;
@@ -62,3 +64,96 @@ public class SnWalletPocket : ModelBase
WalletId = Guid.Parse(proto.WalletId), WalletId = Guid.Parse(proto.WalletId),
}; };
} }
public enum FundSplitType
{
Even,
Random
}
public enum FundStatus
{
Created,
PartiallyReceived,
FullyReceived,
Expired,
Refunded
}
public class SnWalletFund : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(128)] public string Currency { get; set; } = null!;
public decimal TotalAmount { get; set; }
public FundSplitType SplitType { get; set; }
public FundStatus Status { get; set; } = FundStatus.Created;
[MaxLength(4096)] public string? Message { get; set; }
// Creator
public Guid CreatorAccountId { get; set; }
public SnAccount CreatorAccount { get; set; } = null!;
// Recipients
public ICollection<SnWalletFundRecipient> Recipients { get; set; } = new List<SnWalletFundRecipient>();
// Expiration
public Instant ExpiredAt { get; set; }
public Proto.WalletFund ToProtoValue() => new()
{
Id = Id.ToString(),
Currency = Currency,
TotalAmount = TotalAmount.ToString(CultureInfo.InvariantCulture),
SplitType = (Proto.FundSplitType)SplitType,
Status = (Proto.FundStatus)Status,
Message = Message,
CreatorAccountId = CreatorAccountId.ToString(),
ExpiredAt = ExpiredAt.ToTimestamp(),
};
public static SnWalletFund FromProtoValue(Proto.WalletFund proto) => new()
{
Id = Guid.Parse(proto.Id),
Currency = proto.Currency,
TotalAmount = decimal.Parse(proto.TotalAmount),
SplitType = (FundSplitType)proto.SplitType,
Status = (FundStatus)proto.Status,
Message = proto.Message,
CreatorAccountId = Guid.Parse(proto.CreatorAccountId),
ExpiredAt = proto.ExpiredAt.ToInstant(),
};
}
public class SnWalletFundRecipient : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid FundId { get; set; }
[JsonIgnore] public SnWalletFund Fund { get; set; } = null!;
public Guid RecipientAccountId { get; set; }
public SnAccount RecipientAccount { get; set; } = null!;
public decimal Amount { get; set; }
public bool IsReceived { get; set; } = false;
public Instant? ReceivedAt { get; set; }
public Proto.WalletFundRecipient ToProtoValue() => new()
{
Id = Id.ToString(),
FundId = FundId.ToString(),
RecipientAccountId = RecipientAccountId.ToString(),
Amount = Amount.ToString(CultureInfo.InvariantCulture),
IsReceived = IsReceived,
ReceivedAt = ReceivedAt?.ToTimestamp(),
};
public static SnWalletFundRecipient FromProtoValue(Proto.WalletFundRecipient proto) => new()
{
Id = Guid.Parse(proto.Id),
FundId = Guid.Parse(proto.FundId),
RecipientAccountId = Guid.Parse(proto.RecipientAccountId),
Amount = decimal.Parse(proto.Amount),
IsReceived = proto.IsReceived,
ReceivedAt = proto.ReceivedAt?.ToInstant(),
};
}

View File

@@ -59,6 +59,13 @@ message AccountStatus {
bytes meta = 10; bytes meta = 10;
} }
message UsernameColor {
string type = 1;
google.protobuf.StringValue value = 2;
google.protobuf.StringValue direction = 3;
repeated string colors = 4;
}
// Profile contains detailed information about a user // Profile contains detailed information about a user
message AccountProfile { message AccountProfile {
string id = 1; string id = 1;
@@ -89,6 +96,7 @@ message AccountProfile {
google.protobuf.Timestamp created_at = 22; google.protobuf.Timestamp created_at = 22;
google.protobuf.Timestamp updated_at = 23; google.protobuf.Timestamp updated_at = 23;
optional UsernameColor username_color = 24;
} }
// AccountContact represents a contact method for an account // AccountContact represents a contact method for an account
@@ -254,6 +262,7 @@ service AccountService {
rpc GetAccountBatch(GetAccountBatchRequest) returns (GetAccountBatchResponse) {} rpc GetAccountBatch(GetAccountBatchRequest) returns (GetAccountBatchResponse) {}
rpc GetBotAccountBatch(GetBotAccountBatchRequest) returns (GetAccountBatchResponse) {} rpc GetBotAccountBatch(GetBotAccountBatchRequest) returns (GetAccountBatchResponse) {}
rpc LookupAccountBatch(LookupAccountBatchRequest) returns (GetAccountBatchResponse) {} rpc LookupAccountBatch(LookupAccountBatchRequest) returns (GetAccountBatchResponse) {}
rpc SearchAccount(SearchAccountRequest) returns (GetAccountBatchResponse) {}
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse) {} rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse) {}
rpc GetAccountStatus(GetAccountRequest) returns (AccountStatus) {} rpc GetAccountStatus(GetAccountRequest) returns (AccountStatus) {}
@@ -343,6 +352,10 @@ message LookupAccountBatchRequest {
repeated string names = 1; repeated string names = 1;
} }
message SearchAccountRequest {
string query = 1;
}
message GetAccountBatchResponse { message GetAccountBatchResponse {
repeated Account accounts = 1; // List of accounts repeated Account accounts = 1; // List of accounts
} }

View File

@@ -115,8 +115,8 @@ message BotAccount {
message CreateBotAccountRequest { message CreateBotAccountRequest {
Account account = 1; Account account = 1;
string automated_id = 2; string automated_id = 2;
optional string picture_id = 8; google.protobuf.StringValue picture_id = 8;
optional string background_id = 9; google.protobuf.StringValue background_id = 9;
} }
message CreateBotAccountResponse { message CreateBotAccountResponse {

View File

@@ -0,0 +1,110 @@
syntax = "proto3";
package proto;
option csharp_namespace = "DysonNetwork.Shared.Proto";
import "google/protobuf/timestamp.proto";
import "google/protobuf/wrappers.proto";
import "google/protobuf/empty.proto";
import 'account.proto';
// Message Definitions
message Realm {
string id = 1;
string name = 2;
string slug = 3;
bool is_community = 4;
bool is_public = 5;
}
message RealmMember {
string account_id = 1;
string realm_id = 2;
int32 role = 3;
optional google.protobuf.Timestamp joined_at = 4;
optional google.protobuf.Timestamp leave_at = 5;
optional Account account = 6;
optional Realm realm = 7;
}
// Service Definitions
service RealmService {
// Get realm by id or slug
rpc GetRealm(GetRealmRequest) returns (Realm) {}
// Get realm batch by ids
rpc GetRealmBatch(GetRealmBatchRequest) returns (GetRealmBatchResponse) {}
// Get realms for a user
rpc GetUserRealms(GetUserRealmsRequest) returns (GetUserRealmsResponse) {}
// Get public realms
rpc GetPublicRealms(google.protobuf.Empty) returns (GetPublicRealmsResponse) {}
// Search public realms
rpc SearchRealms(SearchRealmsRequest) returns (GetPublicRealmsResponse) {}
// Send invitation notification
rpc SendInviteNotify(SendInviteNotifyRequest) returns (google.protobuf.Empty) {}
// Check if member has required role
rpc IsMemberWithRole(IsMemberWithRoleRequest) returns (google.protobuf.BoolValue) {}
// Load account for a member
rpc LoadMemberAccount(LoadMemberAccountRequest) returns (RealmMember) {}
// Load accounts for members
rpc LoadMemberAccounts(LoadMemberAccountsRequest) returns (LoadMemberAccountsResponse) {}
}
// Request/Response Messages
message GetRealmRequest {
oneof query {
string id = 1;
string slug = 2;
}
}
message GetUserRealmsRequest {
string account_id = 1;
}
message GetRealmBatchRequest {
repeated string ids = 1;
}
message GetRealmBatchResponse {
repeated Realm realms = 1;
}
message GetUserRealmsResponse {
repeated string realm_ids = 1;
}
message GetPublicRealmsResponse {
repeated Realm realms = 1;
}
message SearchRealmsRequest {
string query = 1;
int32 limit = 2;
}
message SendInviteNotifyRequest {
RealmMember member = 1;
}
message IsMemberWithRoleRequest {
string realm_id = 1;
string account_id = 2;
repeated int32 required_roles = 3;
}
message LoadMemberAccountRequest {
RealmMember member = 1;
}
message LoadMemberAccountsRequest {
repeated RealmMember members = 1;
}
message LoadMemberAccountsResponse {
repeated RealmMember members = 1;
}

View File

@@ -22,6 +22,42 @@ message WalletPocket {
string wallet_id = 4; string wallet_id = 4;
} }
enum FundSplitType {
FUND_SPLIT_TYPE_UNSPECIFIED = 0;
FUND_SPLIT_TYPE_EVEN = 1;
FUND_SPLIT_TYPE_RANDOM = 2;
}
enum FundStatus {
FUND_STATUS_UNSPECIFIED = 0;
FUND_STATUS_CREATED = 1;
FUND_STATUS_PARTIALLY_RECEIVED = 2;
FUND_STATUS_FULLY_RECEIVED = 3;
FUND_STATUS_EXPIRED = 4;
FUND_STATUS_REFUNDED = 5;
}
message WalletFund {
string id = 1;
string currency = 2;
string total_amount = 3;
FundSplitType split_type = 4;
FundStatus status = 5;
optional string message = 6;
string creator_account_id = 7;
google.protobuf.Timestamp expired_at = 8;
repeated WalletFundRecipient recipients = 9;
}
message WalletFundRecipient {
string id = 1;
string fund_id = 2;
string recipient_account_id = 3;
string amount = 4;
bool is_received = 5;
optional google.protobuf.Timestamp received_at = 6;
}
enum SubscriptionStatus { enum SubscriptionStatus {
// Using proto3 enum naming convention // Using proto3 enum naming convention
SUBSCRIPTION_STATUS_UNSPECIFIED = 0; SUBSCRIPTION_STATUS_UNSPECIFIED = 0;

View File

@@ -3,7 +3,7 @@ using DysonNetwork.Shared.Proto;
namespace DysonNetwork.Shared.Registry; namespace DysonNetwork.Shared.Registry;
public class AccountClientHelper(AccountService.AccountServiceClient accounts) public class RemoteAccountService(AccountService.AccountServiceClient accounts)
{ {
public async Task<Account> GetAccount(Guid id) public async Task<Account> GetAccount(Guid id)
{ {
@@ -27,6 +27,13 @@ public class AccountClientHelper(AccountService.AccountServiceClient accounts)
return response.Accounts.ToList(); return response.Accounts.ToList();
} }
public async Task<List<Account>> SearchAccounts(string query)
{
var request = new SearchAccountRequest { Query = query };
var response = await accounts.SearchAccountAsync(request);
return response.Accounts.ToList();
}
public async Task<List<Account>> GetBotAccountBatch(List<Guid> automatedIds) public async Task<List<Account>> GetBotAccountBatch(List<Guid> automatedIds)
{ {
var request = new GetBotAccountBatchRequest(); var request = new GetBotAccountBatchRequest();

View File

@@ -0,0 +1,82 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Google.Protobuf.WellKnownTypes;
namespace DysonNetwork.Shared.Registry;
public class RemoteRealmService(RealmService.RealmServiceClient realms)
{
public async Task<SnRealm> GetRealm(string id)
{
var request = new GetRealmRequest { Id = id };
var response = await realms.GetRealmAsync(request);
return SnRealm.FromProtoValue(response);
}
public async Task<SnRealm> GetRealmBySlug(string slug)
{
var request = new GetRealmRequest { Slug = slug };
var response = await realms.GetRealmAsync(request);
return SnRealm.FromProtoValue(response);
}
public async Task<List<Guid>> GetUserRealms(Guid accountId)
{
var request = new GetUserRealmsRequest { AccountId = accountId.ToString() };
var response = await realms.GetUserRealmsAsync(request);
return response.RealmIds.Select(Guid.Parse).ToList();
}
public async Task<List<SnRealm>> GetPublicRealms()
{
var response = await realms.GetPublicRealmsAsync(new Empty());
return response.Realms.Select(SnRealm.FromProtoValue).ToList();
}
public async Task<List<SnRealm>> SearchRealms(string query, int limit)
{
var request = new SearchRealmsRequest { Query = query, Limit = limit };
var response = await realms.SearchRealmsAsync(request);
return response.Realms.Select(SnRealm.FromProtoValue).ToList();
}
public async Task<List<SnRealm>> GetRealmBatch(List<string> ids)
{
var request = new GetRealmBatchRequest();
request.Ids.AddRange(ids);
var response = await realms.GetRealmBatchAsync(request);
return response.Realms.Select(SnRealm.FromProtoValue).ToList();
}
public async Task SendInviteNotify(SnRealmMember member)
{
var protoMember = member.ToProtoValue();
var request = new SendInviteNotifyRequest { Member = protoMember };
await realms.SendInviteNotifyAsync(request);
}
public async Task<bool> IsMemberWithRole(Guid realmId, Guid accountId, List<int> requiredRoles)
{
var request = new IsMemberWithRoleRequest { RealmId = realmId.ToString(), AccountId = accountId.ToString() };
request.RequiredRoles.AddRange(requiredRoles);
var response = await realms.IsMemberWithRoleAsync(request);
return response.Value;
}
public async Task<SnRealmMember> LoadMemberAccount(SnRealmMember member)
{
var protoMember = member.ToProtoValue();
var request = new LoadMemberAccountRequest { Member = protoMember };
var response = await realms.LoadMemberAccountAsync(request);
return SnRealmMember.FromProtoValue(response);
}
public async Task<List<SnRealmMember>> LoadMemberAccounts(List<SnRealmMember> members)
{
var protoMembers = members.Select(m => m.ToProtoValue()).ToList();
var request = new LoadMemberAccountsRequest();
request.Members.AddRange(protoMembers);
var response = await realms.LoadMemberAccountsAsync(request);
return response.Members.Select(SnRealmMember.FromProtoValue).ToList();
}
}

View File

@@ -36,11 +36,11 @@ public static class ServiceInjectionHelper
public static IServiceCollection AddAccountService(this IServiceCollection services) public static IServiceCollection AddAccountService(this IServiceCollection services)
{ {
services services
.AddGrpcClient<AccountService.AccountServiceClient>(o => o.Address = new Uri("https://_grpc.pass") ) .AddGrpcClient<AccountService.AccountServiceClient>(o => o.Address = new Uri("https://_grpc.pass"))
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler() .ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true } { ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
); );
services.AddSingleton<AccountClientHelper>(); services.AddSingleton<RemoteAccountService>();
services services
.AddGrpcClient<BotAccountReceiverService.BotAccountReceiverServiceClient>(o => .AddGrpcClient<BotAccountReceiverService.BotAccountReceiverServiceClient>(o =>
@@ -60,6 +60,13 @@ public static class ServiceInjectionHelper
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true } { ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
); );
services
.AddGrpcClient<RealmService.RealmServiceClient>(o => o.Address = new Uri("https://_grpc.pass"))
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
);
services.AddSingleton<RemoteRealmService>();
return services; return services;
} }
@@ -70,7 +77,8 @@ public static class ServiceInjectionHelper
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true } { ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
); );
services.AddGrpcClient<FileReferenceService.FileReferenceServiceClient>(o => o.Address = new Uri("https://_grpc.drive")) services.AddGrpcClient<FileReferenceService.FileReferenceServiceClient>(o =>
o.Address = new Uri("https://_grpc.drive"))
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler() .ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true } { ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
); );
@@ -80,7 +88,8 @@ public static class ServiceInjectionHelper
public static IServiceCollection AddPublisherService(this IServiceCollection services) public static IServiceCollection AddPublisherService(this IServiceCollection services)
{ {
services.AddGrpcClient<PublisherService.PublisherServiceClient>(o => o.Address = new Uri("https://_grpc.sphere")) services
.AddGrpcClient<PublisherService.PublisherServiceClient>(o => o.Address = new Uri("https://_grpc.sphere"))
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler() .ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true } { ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
); );
@@ -90,7 +99,8 @@ public static class ServiceInjectionHelper
public static IServiceCollection AddDevelopService(this IServiceCollection services) public static IServiceCollection AddDevelopService(this IServiceCollection services)
{ {
services.AddGrpcClient<CustomAppService.CustomAppServiceClient>(o => o.Address = new Uri("https://_grpc.develop")) services.AddGrpcClient<CustomAppService.CustomAppServiceClient>(o =>
o.Address = new Uri("https://_grpc.develop"))
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler() .ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true } { ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
); );

View File

@@ -1,8 +1,8 @@
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere.Discovery; using DysonNetwork.Sphere.Discovery;
using DysonNetwork.Sphere.Post; using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Realm;
using DysonNetwork.Sphere.WebReader; using DysonNetwork.Sphere.WebReader;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
@@ -13,7 +13,7 @@ public class ActivityService(
AppDatabase db, AppDatabase db,
Publisher.PublisherService pub, Publisher.PublisherService pub,
PostService ps, PostService ps,
RealmService rs, RemoteRealmService rs,
DiscoveryService ds, DiscoveryService ds,
AccountService.AccountServiceClient accounts AccountService.AccountServiceClient accounts
) )
@@ -40,19 +40,15 @@ public class ActivityService(
debugInclude ??= new HashSet<string>(); debugInclude ??= new HashSet<string>();
// Get and process posts // Get and process posts
var postsQuery = db.Posts var publicRealms = await rs.GetPublicRealms();
.Include(e => e.RepliedPost) var publicRealmIds = publicRealms.Select(r => r.Id).ToList();
.Include(e => e.ForwardedPost)
.Include(e => e.Categories) var postsQuery = BuildPostsQuery(cursor, null, publicRealmIds)
.Include(e => e.Tags)
.Include(e => e.Realm)
.Where(e => e.RepliedPostId == null)
.Where(p => cursor == null || p.PublishedAt < cursor)
.OrderByDescending(p => p.PublishedAt)
.FilterWithVisibility(null, [], [], isListing: true) .FilterWithVisibility(null, [], [], isListing: true)
.Take(take * 5); .Take(take * 5);
var posts = await GetAndProcessPosts(postsQuery); var posts = await GetAndProcessPosts(postsQuery);
await LoadPostsRealmsAsync(posts, rs);
posts = RankPosts(posts, take); posts = RankPosts(posts, take);
var interleaved = new List<SnActivity>(); var interleaved = new List<SnActivity>();
@@ -102,7 +98,7 @@ public class ActivityService(
var userRealms = await rs.GetUserRealms(Guid.Parse(currentUser.Id)); var userRealms = await rs.GetUserRealms(Guid.Parse(currentUser.Id));
// Build and execute the posts query // Build and execute the post query
var postsQuery = BuildPostsQuery(cursor, filteredPublishersId, userRealms); var postsQuery = BuildPostsQuery(cursor, filteredPublishersId, userRealms);
// Apply visibility filtering and execute // Apply visibility filtering and execute
@@ -118,10 +114,10 @@ public class ActivityService(
var posts = await GetAndProcessPosts( var posts = await GetAndProcessPosts(
postsQuery, postsQuery,
currentUser, currentUser,
userFriends,
userPublishers,
trackViews: true); trackViews: true);
await LoadPostsRealmsAsync(posts, rs);
posts = RankPosts(posts, take); posts = RankPosts(posts, take);
var interleaved = new List<SnActivity>(); var interleaved = new List<SnActivity>();
@@ -219,15 +215,19 @@ public class ActivityService(
private async Task<SnActivity?> GetShuffledPostsActivity(int count = 5) private async Task<SnActivity?> GetShuffledPostsActivity(int count = 5)
{ {
var publicRealms = await rs.GetPublicRealms();
var publicRealmIds = publicRealms.Select(r => r.Id).ToList();
var postsQuery = db.Posts var postsQuery = db.Posts
.Include(p => p.Categories) .Include(p => p.Categories)
.Include(p => p.Tags) .Include(p => p.Tags)
.Include(p => p.Realm)
.Where(p => p.RepliedPostId == null) .Where(p => p.RepliedPostId == null)
.Where(p => p.RealmId == null || publicRealmIds.Contains(p.RealmId.Value))
.OrderBy(_ => EF.Functions.Random()) .OrderBy(_ => EF.Functions.Random())
.Take(count); .Take(count);
var posts = await GetAndProcessPosts(postsQuery, trackViews: false); var posts = await GetAndProcessPosts(postsQuery, trackViews: false);
await LoadPostsRealmsAsync(posts, rs);
return posts.Count == 0 return posts.Count == 0
? null ? null
@@ -272,8 +272,6 @@ public class ActivityService(
private async Task<List<SnPost>> GetAndProcessPosts( private async Task<List<SnPost>> GetAndProcessPosts(
IQueryable<SnPost> baseQuery, IQueryable<SnPost> baseQuery,
Account? currentUser = null, Account? currentUser = null,
List<Guid>? userFriends = null,
List<Shared.Models.SnPublisher>? userPublishers = null,
bool trackViews = true) bool trackViews = true)
{ {
var posts = await baseQuery.ToListAsync(); var posts = await baseQuery.ToListAsync();
@@ -306,7 +304,7 @@ public class ActivityService(
.Include(e => e.ForwardedPost) .Include(e => e.ForwardedPost)
.Include(e => e.Categories) .Include(e => e.Categories)
.Include(e => e.Tags) .Include(e => e.Tags)
.Include(e => e.Realm) .Include(e => e.FeaturedRecords)
.Where(e => e.RepliedPostId == null) .Where(e => e.RepliedPostId == null)
.Where(p => cursor == null || p.PublishedAt < cursor) .Where(p => cursor == null || p.PublishedAt < cursor)
.OrderByDescending(p => p.PublishedAt) .OrderByDescending(p => p.PublishedAt)
@@ -315,10 +313,14 @@ public class ActivityService(
if (filteredPublishersId != null && filteredPublishersId.Count != 0) if (filteredPublishersId != null && filteredPublishersId.Count != 0)
query = query.Where(p => filteredPublishersId.Contains(p.PublisherId)); query = query.Where(p => filteredPublishersId.Contains(p.PublisherId));
if (userRealms == null) if (userRealms == null)
query = query.Where(p => p.Realm == null || p.Realm.IsPublic); {
// For anonymous users, only show public realm posts or posts without realm
// Get public realm ids in the caller and pass them
query = query.Where(p => p.RealmId == null); // Modify in caller
}
else else
query = query.Where(p => query = query.Where(p =>
p.Realm == null || p.Realm.IsPublic || p.RealmId == null || userRealms.Contains(p.RealmId.Value)); p.RealmId == null || userRealms.Contains(p.RealmId.Value));
return query; return query;
} }
@@ -339,6 +341,23 @@ public class ActivityService(
}; };
} }
private static async Task LoadPostsRealmsAsync(List<SnPost> posts, RemoteRealmService rs)
{
var postRealmIds = posts.Where(p => p.RealmId != null).Select(p => p.RealmId.Value).Distinct().ToList();
if (!postRealmIds.Any()) return;
var realms = await rs.GetRealmBatch(postRealmIds.Select(id => id.ToString()).ToList());
var realmDict = realms.ToDictionary(r => r.Id, r => r);
foreach (var post in posts.Where(p => p.RealmId != null))
{
if (post.RealmId != null && realmDict.TryGetValue(post.RealmId.Value, out var realm))
{
post.Realm = realm;
}
}
}
private static double CalculatePopularity(List<SnPost> posts) private static double CalculatePopularity(List<SnPost> posts)
{ {
var score = posts.Sum(p => p.Upvotes - p.Downvotes); var score = posts.Sum(p => p.Upvotes - p.Downvotes);

View File

@@ -1,6 +1,7 @@
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Reflection; using System.Reflection;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.WebReader;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Query; using Microsoft.EntityFrameworkCore.Query;
@@ -33,26 +34,23 @@ public class AppDatabase(
public DbSet<SnPostFeaturedRecord> PostFeaturedRecords { get; set; } = null!; public DbSet<SnPostFeaturedRecord> PostFeaturedRecords { get; set; } = null!;
public DbSet<SnPostCategorySubscription> PostCategorySubscriptions { get; set; } = null!; public DbSet<SnPostCategorySubscription> PostCategorySubscriptions { get; set; } = null!;
public DbSet<Shared.Models.SnPoll> Polls { get; set; } = null!; public DbSet<SnPoll> Polls { get; set; } = null!;
public DbSet<SnPollQuestion> PollQuestions { get; set; } = null!; public DbSet<SnPollQuestion> PollQuestions { get; set; } = null!;
public DbSet<SnPollAnswer> PollAnswers { get; set; } = null!; public DbSet<SnPollAnswer> PollAnswers { get; set; } = null!;
public DbSet<Shared.Models.SnRealm> Realms { get; set; } = null!;
public DbSet<SnRealmMember> RealmMembers { get; set; } = null!;
public DbSet<SnChatRoom> ChatRooms { get; set; } = null!; public DbSet<SnChatRoom> ChatRooms { get; set; } = null!;
public DbSet<SnChatMember> ChatMembers { get; set; } = null!; public DbSet<SnChatMember> ChatMembers { get; set; } = null!;
public DbSet<SnChatMessage> ChatMessages { get; set; } = null!; public DbSet<SnChatMessage> ChatMessages { get; set; } = null!;
public DbSet<SnRealtimeCall> ChatRealtimeCall { get; set; } = null!; public DbSet<SnRealtimeCall> ChatRealtimeCall { get; set; } = null!;
public DbSet<SnChatMessageReaction> ChatReactions { get; set; } = null!; public DbSet<SnChatMessageReaction> ChatReactions { get; set; } = null!;
public DbSet<Shared.Models.SnSticker> Stickers { get; set; } = null!; public DbSet<SnSticker> Stickers { get; set; } = null!;
public DbSet<StickerPack> StickerPacks { get; set; } = null!; public DbSet<StickerPack> StickerPacks { get; set; } = null!;
public DbSet<StickerPackOwnership> StickerPackOwnerships { get; set; } = null!; public DbSet<StickerPackOwnership> StickerPackOwnerships { get; set; } = null!;
public DbSet<WebReader.WebArticle> WebArticles { get; set; } = null!; public DbSet<WebArticle> WebArticles { get; set; } = null!;
public DbSet<WebReader.WebFeed> WebFeeds { get; set; } = null!; public DbSet<WebFeed> WebFeeds { get; set; } = null!;
public DbSet<WebReader.WebFeedSubscription> WebFeedSubscriptions { get; set; } = null!; public DbSet<WebFeedSubscription> WebFeedSubscriptions { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
@@ -112,14 +110,6 @@ public class AppDatabase(
.WithMany(c => c.Posts) .WithMany(c => c.Posts)
.UsingEntity(j => j.ToTable("post_collection_links")); .UsingEntity(j => j.ToTable("post_collection_links"));
modelBuilder.Entity<SnRealmMember>()
.HasKey(pm => new { pm.RealmId, pm.AccountId });
modelBuilder.Entity<SnRealmMember>()
.HasOne(pm => pm.Realm)
.WithMany(p => p.Members)
.HasForeignKey(pm => pm.RealmId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<SnChatMember>() modelBuilder.Entity<SnChatMember>()
.HasKey(pm => new { pm.Id }); .HasKey(pm => new { pm.Id });
modelBuilder.Entity<SnChatMember>() modelBuilder.Entity<SnChatMember>()
@@ -150,10 +140,10 @@ public class AppDatabase(
.HasForeignKey(m => m.SenderId) .HasForeignKey(m => m.SenderId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<WebReader.WebFeed>() modelBuilder.Entity<WebFeed>()
.HasIndex(f => f.Url) .HasIndex(f => f.Url)
.IsUnique(); .IsUnique();
modelBuilder.Entity<WebReader.WebArticle>() modelBuilder.Entity<WebArticle>()
.HasIndex(a => a.Url) .HasIndex(a => a.Url)
.IsUnique(); .IsUnique();

View File

@@ -0,0 +1,20 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Autocompletion;
[ApiController]
[Route("/api/autocomplete")]
public class AutocompletionController(AutocompletionService aus) : ControllerBase
{
[HttpPost]
public async Task<ActionResult<List<DysonNetwork.Shared.Models.Autocompletion>>> TextAutocomplete([FromBody] AutocompletionRequest request, Guid roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var result = await aus.GetAutocompletion(request.Content, chatId: roomId, limit: 10);
return Ok(result);
}
}

View File

@@ -0,0 +1,144 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Registry;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Autocompletion;
public class AutocompletionService(AppDatabase db, RemoteAccountService remoteAccountsHelper, RemoteRealmService remoteRealmService)
{
public async Task<List<DysonNetwork.Shared.Models.Autocompletion>> GetAutocompletion(string content, Guid? chatId = null, Guid? realmId = null, int limit = 10)
{
if (string.IsNullOrWhiteSpace(content))
return [];
if (content.StartsWith('@'))
{
var afterAt = content[1..];
string type;
string query;
var hadSlash = afterAt.Contains('/');
if (hadSlash)
{
var parts = afterAt.Split('/', 2);
type = parts[0];
query = parts.Length > 1 ? parts[1] : "";
}
else
{
type = "u";
query = afterAt;
}
return await AutocompleteAt(type, query, chatId, realmId, hadSlash, limit);
}
if (!content.StartsWith(':')) return [];
{
var query = content[1..];
return await AutocompleteSticker(query, limit);
}
}
private async Task<List<DysonNetwork.Shared.Models.Autocompletion>> AutocompleteAt(string type, string query, Guid? chatId, Guid? realmId, bool hadSlash,
int limit)
{
var results = new List<DysonNetwork.Shared.Models.Autocompletion>();
switch (type)
{
case "u":
var allAccounts = await remoteAccountsHelper.SearchAccounts(query);
var filteredAccounts = allAccounts;
if (chatId.HasValue)
{
var chatMemberIds = await db.ChatMembers
.Where(m => m.ChatRoomId == chatId.Value && m.JoinedAt != null && m.LeaveAt == null)
.Select(m => m.AccountId)
.ToListAsync();
var chatMemberIdStrings = chatMemberIds.Select(id => id.ToString()).ToHashSet();
filteredAccounts = allAccounts.Where(a => chatMemberIdStrings.Contains(a.Id)).ToList();
}
else if (realmId.HasValue)
{
// TODO: Filter to realm members only - needs efficient implementation
// var realmMemberIds = await db.RealmMembers
// .Where(m => m.RealmId == realmId.Value && m.LeaveAt == null)
// .Select(m => m.AccountId)
// .ToListAsync();
// var realmMemberIdStrings = realmMemberIds.Select(id => id.ToString()).ToHashSet();
// filteredAccounts = allAccounts.Where(a => realmMemberIdStrings.Contains(a.Id)).ToList();
}
var users = filteredAccounts
.Take(limit)
.Select(a => new DysonNetwork.Shared.Models.Autocompletion
{
Type = "user",
Keyword = "@" + (hadSlash ? "u/" : "") + a.Name,
Data = SnAccount.FromProtoValue(a)
})
.ToList();
results.AddRange(users);
break;
case "p":
var publishers = await db.Publishers
.Where(p => EF.Functions.Like(p.Name, $"{query}%") || EF.Functions.Like(p.Nick, $"{query}%"))
.Take(limit)
.Select(p => new DysonNetwork.Shared.Models.Autocompletion
{
Type = "publisher",
Keyword = "@p/" + p.Name,
Data = p
})
.ToListAsync();
results.AddRange(publishers);
break;
case "r":
var realms = await remoteRealmService.SearchRealms(query, limit);
var autocompletions = realms.Select(r => new DysonNetwork.Shared.Models.Autocompletion
{
Type = "realm",
Keyword = "@r/" + r.Slug,
Data = r
});
results.AddRange(autocompletions);
break;
case "c":
var chats = await db.ChatRooms
.Where(c => c.Name != null && EF.Functions.Like(c.Name, $"{query}%"))
.Take(limit)
.Select(c => new DysonNetwork.Shared.Models.Autocompletion
{
Type = "chat",
Keyword = "@c/" + c.Name,
Data = c
})
.ToListAsync();
results.AddRange(chats);
break;
}
return results;
}
private async Task<List<DysonNetwork.Shared.Models.Autocompletion>> AutocompleteSticker(string query, int limit)
{
var stickers = await db.Stickers
.Include(s => s.Pack)
.Where(s => EF.Functions.Like(s.Pack.Prefix + "+" + s.Slug, $"{query}%"))
.Take(limit)
.Select(s => new DysonNetwork.Shared.Models.Autocompletion
{
Type = "sticker",
Keyword = $":{s.Pack.Prefix}+{s.Slug}:",
Data = s
})
.ToListAsync();
var results = stickers.ToList();
return results;
}
}

View File

@@ -4,6 +4,7 @@ using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.Autocompletion;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -17,7 +18,8 @@ public partial class ChatController(
ChatService cs, ChatService cs,
ChatRoomService crs, ChatRoomService crs,
FileService.FileServiceClient files, FileService.FileServiceClient files,
AccountService.AccountServiceClient accounts AccountService.AccountServiceClient accounts,
AutocompletionService aus
) : ControllerBase ) : ControllerBase
{ {
public class MarkMessageReadRequest public class MarkMessageReadRequest
@@ -85,7 +87,8 @@ public partial class ChatController(
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
var member = await db.ChatMembers var member = await db.ChatMembers
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null) .Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null &&
m.LeaveAt == null)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Member) if (member == null || member.Role < ChatMemberRole.Member)
return StatusCode(403, "You are not a member of this chat room."); return StatusCode(403, "You are not a member of this chat room.");
@@ -127,7 +130,8 @@ public partial class ChatController(
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
var member = await db.ChatMembers var member = await db.ChatMembers
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null) .Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null &&
m.LeaveAt == null)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Member) if (member == null || member.Role < ChatMemberRole.Member)
return StatusCode(403, "You are not a member of this chat room."); return StatusCode(403, "You are not a member of this chat room.");
@@ -146,9 +150,74 @@ public partial class ChatController(
} }
[GeneratedRegex("@([A-Za-z0-9_-]+)")] [GeneratedRegex(@"@(?:u/)?([A-Za-z0-9_-]+)")]
private static partial Regex MentionRegex(); private static partial Regex MentionRegex();
/// <summary>
/// Extracts mentioned users from message content, replies, and forwards
/// </summary>
private async Task<List<Guid>> ExtractMentionedUsersAsync(string? content, Guid? repliedMessageId,
Guid? forwardedMessageId, Guid roomId, Guid? excludeSenderId = null)
{
var mentionedUsers = new List<Guid>();
// Add sender of a replied message
if (repliedMessageId.HasValue)
{
var replyingTo = await db.ChatMessages
.Where(m => m.Id == repliedMessageId.Value && m.ChatRoomId == roomId)
.Include(m => m.Sender)
.Select(m => m.Sender)
.FirstOrDefaultAsync();
if (replyingTo != null)
mentionedUsers.Add(replyingTo.AccountId);
}
// Add sender of a forwarded message
if (forwardedMessageId.HasValue)
{
var forwardedMessage = await db.ChatMessages
.Where(m => m.Id == forwardedMessageId.Value)
.Select(m => new { m.SenderId })
.FirstOrDefaultAsync();
if (forwardedMessage != null)
{
mentionedUsers.Add(forwardedMessage.SenderId);
}
}
// Extract mentions from content using regex
if (!string.IsNullOrWhiteSpace(content))
{
var mentionedNames = MentionRegex()
.Matches(content)
.Select(m => m.Groups[1].Value)
.Distinct()
.ToList();
if (mentionedNames.Count > 0)
{
var queryRequest = new LookupAccountBatchRequest();
queryRequest.Names.AddRange(mentionedNames);
var queryResponse = (await accounts.LookupAccountBatchAsync(queryRequest)).Accounts;
var mentionedIds = queryResponse.Select(a => Guid.Parse(a.Id)).ToList();
if (mentionedIds.Count > 0)
{
var mentionedMembers = await db.ChatMembers
.Where(m => m.ChatRoomId == roomId && mentionedIds.Contains(m.AccountId))
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
.Where(m => excludeSenderId == null || m.AccountId != excludeSenderId.Value)
.Select(m => m.AccountId)
.ToListAsync();
mentionedUsers.AddRange(mentionedMembers);
}
}
}
return mentionedUsers.Distinct().ToList();
}
[HttpPost("{roomId:guid}/messages")] [HttpPost("{roomId:guid}/messages")]
[Authorize] [Authorize]
[RequiredPermission("global", "chat.messages.create")] [RequiredPermission("global", "chat.messages.create")]
@@ -186,6 +255,7 @@ public partial class ChatController(
.ToList(); .ToList();
} }
// Validate reply and forward message IDs exist
if (request.RepliedMessageId.HasValue) if (request.RepliedMessageId.HasValue)
{ {
var repliedMessage = await db.ChatMessages var repliedMessage = await db.ChatMessages
@@ -206,28 +276,9 @@ public partial class ChatController(
message.ForwardedMessageId = forwardedMessage.Id; message.ForwardedMessageId = forwardedMessage.Id;
} }
if (request.Content is not null) // Extract mentioned users
{ message.MembersMentioned = await ExtractMentionedUsersAsync(request.Content, request.RepliedMessageId,
var mentioned = MentionRegex() request.ForwardedMessageId, roomId);
.Matches(request.Content)
.Select(m => m.Groups[1].Value)
.ToList();
if (mentioned.Count > 0)
{
var queryRequest = new LookupAccountBatchRequest();
queryRequest.Names.AddRange(mentioned);
var queryResponse = (await accounts.LookupAccountBatchAsync(queryRequest)).Accounts;
var mentionedId = queryResponse
.Select(a => Guid.Parse(a.Id))
.ToList();
var mentionedMembers = await db.ChatMembers
.Where(m => m.ChatRoomId == roomId && mentionedId.Contains(m.AccountId))
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
.Select(m => m.Id)
.ToListAsync();
message.MembersMentioned = mentionedMembers;
}
}
var result = await cs.SendMessageAsync(message, member, member.ChatRoom); var result = await cs.SendMessageAsync(message, member, member.ChatRoom);
@@ -257,6 +308,7 @@ public partial class ChatController(
(request.AttachmentsId == null || request.AttachmentsId.Count == 0)) (request.AttachmentsId == null || request.AttachmentsId.Count == 0))
return BadRequest("You cannot send an empty message."); return BadRequest("You cannot send an empty message.");
// Validate reply and forward message IDs exist
if (request.RepliedMessageId.HasValue) if (request.RepliedMessageId.HasValue)
{ {
var repliedMessage = await db.ChatMessages var repliedMessage = await db.ChatMessages
@@ -273,6 +325,11 @@ public partial class ChatController(
return BadRequest("The message you're forwarding does not exist."); return BadRequest("The message you're forwarding does not exist.");
} }
// Update mentions based on new content and references
var updatedMentions = await ExtractMentionedUsersAsync(request.Content, request.RepliedMessageId,
request.ForwardedMessageId, roomId, accountId);
message.MembersMentioned = updatedMentions;
// Call service method to update the message // Call service method to update the message
await cs.UpdateMessageAsync( await cs.UpdateMessageAsync(
message, message,
@@ -322,11 +379,30 @@ public partial class ChatController(
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
var isMember = await db.ChatMembers var isMember = await db.ChatMembers
.AnyAsync(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null); .AnyAsync(m =>
m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null);
if (!isMember) if (!isMember)
return StatusCode(403, "You are not a member of this chat room."); return StatusCode(403, "You are not a member of this chat room.");
var response = await cs.GetSyncDataAsync(roomId, request.LastSyncTimestamp); var response = await cs.GetSyncDataAsync(roomId, request.LastSyncTimestamp);
return Ok(response); return Ok(response);
} }
[HttpPost("{roomId:guid}/autocomplete")]
public async Task<ActionResult<List<DysonNetwork.Shared.Models.Autocompletion>>> ChatAutoComplete(
[FromBody] AutocompletionRequest request, Guid roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var isMember = await db.ChatMembers
.AnyAsync(m =>
m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null);
if (!isMember)
return StatusCode(403, "You are not a member of this chat room.");
var result = await aus.GetAutocompletion(request.Content, chatId: roomId, limit: 10);
return Ok(result);
}
} }

View File

@@ -6,7 +6,7 @@ using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry; using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere.Localization; using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Realm;
using Grpc.Core; using Grpc.Core;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
@@ -20,14 +20,14 @@ namespace DysonNetwork.Sphere.Chat;
public class ChatRoomController( public class ChatRoomController(
AppDatabase db, AppDatabase db,
ChatRoomService crs, ChatRoomService crs,
RealmService rs, RemoteRealmService rs,
IStringLocalizer<NotificationResource> localizer, IStringLocalizer<NotificationResource> localizer,
AccountService.AccountServiceClient accounts, AccountService.AccountServiceClient accounts,
FileService.FileServiceClient files, FileService.FileServiceClient files,
FileReferenceService.FileReferenceServiceClient fileRefs, FileReferenceService.FileReferenceServiceClient fileRefs,
ActionLogService.ActionLogServiceClient als, ActionLogService.ActionLogServiceClient als,
RingService.RingServiceClient pusher, RingService.RingServiceClient pusher,
AccountClientHelper accountsHelper RemoteAccountService remoteAccountsHelper
) : ControllerBase ) : ControllerBase
{ {
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
@@ -35,9 +35,12 @@ public class ChatRoomController(
{ {
var chatRoom = await db.ChatRooms var chatRoom = await db.ChatRooms
.Where(c => c.Id == id) .Where(c => c.Id == id)
.Include(e => e.Realm)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (chatRoom is null) return NotFound(); if (chatRoom is null) return NotFound();
if (chatRoom.RealmId != null)
chatRoom.Realm = await rs.GetRealm(chatRoom.RealmId.Value.ToString());
if (chatRoom.Type != ChatRoomType.DirectMessage) return Ok(chatRoom); if (chatRoom.Type != ChatRoomType.DirectMessage) return Ok(chatRoom);
if (HttpContext.Items["CurrentUser"] is Account currentUser) if (HttpContext.Items["CurrentUser"] is Account currentUser)
@@ -203,7 +206,7 @@ public class ChatRoomController(
if (request.RealmId is not null) if (request.RealmId is not null)
{ {
if (!await rs.IsMemberWithRole(request.RealmId.Value, Guid.Parse(currentUser.Id), if (!await rs.IsMemberWithRole(request.RealmId.Value, Guid.Parse(currentUser.Id),
RealmMemberRole.Moderator)) [RealmMemberRole.Moderator]))
return StatusCode(403, "You need at least be a moderator to create chat linked to the realm."); return StatusCode(403, "You need at least be a moderator to create chat linked to the realm.");
chatRoom.RealmId = request.RealmId; chatRoom.RealmId = request.RealmId;
} }
@@ -301,7 +304,7 @@ public class ChatRoomController(
if (chatRoom.RealmId is not null) if (chatRoom.RealmId is not null)
{ {
if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id), if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id),
RealmMemberRole.Moderator)) [RealmMemberRole.Moderator]))
return StatusCode(403, "You need at least be a realm moderator to update the chat."); return StatusCode(403, "You need at least be a realm moderator to update the chat.");
} }
else if (!await crs.IsMemberWithRole(chatRoom.Id, Guid.Parse(currentUser.Id), ChatMemberRole.Moderator)) else if (!await crs.IsMemberWithRole(chatRoom.Id, Guid.Parse(currentUser.Id), ChatMemberRole.Moderator))
@@ -309,13 +312,9 @@ public class ChatRoomController(
if (request.RealmId is not null) if (request.RealmId is not null)
{ {
var member = await db.RealmMembers if (!await rs.IsMemberWithRole(request.RealmId.Value, Guid.Parse(currentUser.Id), [RealmMemberRole.Moderator]))
.Where(m => m.AccountId == Guid.Parse(currentUser.Id))
.Where(m => m.RealmId == request.RealmId)
.FirstOrDefaultAsync();
if (member is null || member.Role < RealmMemberRole.Moderator)
return StatusCode(403, "You need at least be a moderator to transfer the chat linked to the realm."); return StatusCode(403, "You need at least be a moderator to transfer the chat linked to the realm.");
chatRoom.RealmId = member.RealmId; chatRoom.RealmId = request.RealmId;
} }
if (request.PictureId is not null) if (request.PictureId is not null)
@@ -415,7 +414,7 @@ public class ChatRoomController(
if (chatRoom.RealmId is not null) if (chatRoom.RealmId is not null)
{ {
if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id), if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id),
RealmMemberRole.Moderator)) [RealmMemberRole.Moderator]))
return StatusCode(403, "You need at least be a realm moderator to delete the chat."); return StatusCode(403, "You need at least be a realm moderator to delete the chat.");
} }
else if (!await crs.IsMemberWithRole(chatRoom.Id, Guid.Parse(currentUser.Id), ChatMemberRole.Owner)) else if (!await crs.IsMemberWithRole(chatRoom.Id, Guid.Parse(currentUser.Id), ChatMemberRole.Owner))
@@ -507,7 +506,7 @@ public class ChatRoomController(
.Select(m => m.AccountId) .Select(m => m.AccountId)
.ToListAsync(); .ToListAsync();
var memberStatuses = await accountsHelper.GetAccountStatusBatch(members); var memberStatuses = await remoteAccountsHelper.GetAccountStatusBatch(members);
var onlineCount = memberStatuses.Count(s => s.Value.IsOnline); var onlineCount = memberStatuses.Count(s => s.Value.IsOnline);
@@ -546,7 +545,7 @@ public class ChatRoomController(
.OrderBy(m => m.JoinedAt) .OrderBy(m => m.JoinedAt)
.ToListAsync(); .ToListAsync();
var memberStatuses = await accountsHelper.GetAccountStatusBatch( var memberStatuses = await remoteAccountsHelper.GetAccountStatusBatch(
members.Select(m => m.AccountId).ToList() members.Select(m => m.AccountId).ToList()
); );
@@ -623,11 +622,7 @@ public class ChatRoomController(
// Handle realm-owned chat rooms // Handle realm-owned chat rooms
if (chatRoom.RealmId is not null) if (chatRoom.RealmId is not null)
{ {
var realmMember = await db.RealmMembers if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, accountId, [RealmMemberRole.Moderator]))
.Where(m => m.AccountId == accountId)
.Where(m => m.RealmId == chatRoom.RealmId)
.FirstOrDefaultAsync();
if (realmMember is null || realmMember.Role < RealmMemberRole.Moderator)
return StatusCode(403, "You need at least be a realm moderator to invite members to this chat."); return StatusCode(403, "You need at least be a realm moderator to invite members to this chat.");
} }
else else
@@ -645,14 +640,37 @@ public class ChatRoomController(
return StatusCode(403, "You cannot invite member with higher permission than yours."); return StatusCode(403, "You cannot invite member with higher permission than yours.");
} }
var hasExistingMember = await db.ChatMembers var existingMember = await db.ChatMembers
.Where(m => m.AccountId == request.RelatedUserId) .Where(m => m.AccountId == request.RelatedUserId)
.Where(m => m.ChatRoomId == roomId) .Where(m => m.ChatRoomId == roomId)
.Where(m => m.JoinedAt != null && m.LeaveAt == null) .FirstOrDefaultAsync();
.AnyAsync(); if (existingMember != null)
if (hasExistingMember) {
if (existingMember.LeaveAt == null)
return BadRequest("This user has been joined the chat cannot be invited again."); return BadRequest("This user has been joined the chat cannot be invited again.");
existingMember.LeaveAt = null;
existingMember.JoinedAt = null;
db.ChatMembers.Update(existingMember);
await db.SaveChangesAsync();
await _SendInviteNotify(existingMember, currentUser);
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "chatrooms.invite",
Meta =
{
{ "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(chatRoom.Id.ToString()) },
{ "account_id", Google.Protobuf.WellKnownTypes.Value.ForString(relatedUser.Id.ToString()) }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent,
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
});
return Ok(existingMember);
}
var newMember = new SnChatMember var newMember = new SnChatMember
{ {
AccountId = Guid.Parse(relatedUser.Id), AccountId = Guid.Parse(relatedUser.Id),
@@ -809,11 +827,7 @@ public class ChatRoomController(
// Check if the chat room is owned by a realm // Check if the chat room is owned by a realm
if (chatRoom.RealmId is not null) if (chatRoom.RealmId is not null)
{ {
var realmMember = await db.RealmMembers if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id), [RealmMemberRole.Moderator]))
.Where(m => m.AccountId == Guid.Parse(currentUser.Id))
.Where(m => m.RealmId == chatRoom.RealmId)
.FirstOrDefaultAsync();
if (realmMember is null || realmMember.Role < RealmMemberRole.Moderator)
return StatusCode(403, "You need at least be a realm moderator to change member roles."); return StatusCode(403, "You need at least be a realm moderator to change member roles.");
} }
else else
@@ -876,12 +890,12 @@ public class ChatRoomController(
if (chatRoom.RealmId is not null) if (chatRoom.RealmId is not null)
{ {
if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id), if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id),
RealmMemberRole.Moderator)) [RealmMemberRole.Moderator]))
return StatusCode(403, "You need at least be a realm moderator to remove members."); return StatusCode(403, "You need at least be a realm moderator to remove members.");
} }
else else
{ {
if (!await crs.IsMemberWithRole(chatRoom.Id, Guid.Parse(currentUser.Id), ChatMemberRole.Moderator)) if (!await crs.IsMemberWithRole(chatRoom.Id, Guid.Parse(currentUser.Id), [ChatMemberRole.Moderator]))
return StatusCode(403, "You need at least be a moderator to remove members."); return StatusCode(403, "You need at least be a moderator to remove members.");
} }
@@ -934,15 +948,15 @@ public class ChatRoomController(
if (existingMember != null) if (existingMember != null)
{ {
if (existingMember.LeaveAt == null) if (existingMember.LeaveAt == null)
{ return BadRequest("You are already a member of this chat room.");
existingMember.LeaveAt = null; existingMember.LeaveAt = null;
db.Update(existingMember); db.Update(existingMember);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_ = crs.PurgeRoomMembersCache(roomId); _ = crs.PurgeRoomMembersCache(roomId);
return Ok(existingMember); return Ok(existingMember);
} }
return BadRequest("You are already a member of this chat room.");
}
var newMember = new SnChatMember var newMember = new SnChatMember
{ {

View File

@@ -9,7 +9,7 @@ namespace DysonNetwork.Sphere.Chat;
public class ChatRoomService( public class ChatRoomService(
AppDatabase db, AppDatabase db,
ICacheService cache, ICacheService cache,
AccountClientHelper accountsHelper RemoteAccountService remoteAccountsHelper
) )
{ {
private const string ChatRoomGroupPrefix = "chatroom:"; private const string ChatRoomGroupPrefix = "chatroom:";
@@ -45,7 +45,8 @@ public class ChatRoomService(
if (member is not null) return member; if (member is not null) return member;
member = await db.ChatMembers member = await db.ChatMembers
.Where(m => m.AccountId == accountId && m.ChatRoomId == chatRoomId && m.JoinedAt != null && m.LeaveAt == null) .Where(m => m.AccountId == accountId && m.ChatRoomId == chatRoomId && m.JoinedAt != null &&
m.LeaveAt == null)
.Include(m => m.ChatRoom) .Include(m => m.ChatRoom)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
@@ -95,7 +96,7 @@ public class ChatRoomService(
? await db.ChatMembers ? await db.ChatMembers
.Where(m => directRoomsId.Contains(m.ChatRoomId)) .Where(m => directRoomsId.Contains(m.ChatRoomId))
.Where(m => m.AccountId != userId) .Where(m => m.AccountId != userId)
.Where(m => m.JoinedAt != null && m.LeaveAt == null) // Ignored the joined at condition here to keep showing userinfo when other didn't accept the invite of DM
.ToListAsync() .ToListAsync()
: []; : [];
members = await LoadMemberAccounts(members); members = await LoadMemberAccounts(members);
@@ -146,7 +147,7 @@ public class ChatRoomService(
public async Task<SnChatMember> LoadMemberAccount(SnChatMember member) public async Task<SnChatMember> LoadMemberAccount(SnChatMember member)
{ {
var account = await accountsHelper.GetAccount(member.AccountId); var account = await remoteAccountsHelper.GetAccount(member.AccountId);
member.Account = SnAccount.FromProtoValue(account); member.Account = SnAccount.FromProtoValue(account);
return member; return member;
} }
@@ -154,14 +155,17 @@ public class ChatRoomService(
public async Task<List<SnChatMember>> LoadMemberAccounts(ICollection<SnChatMember> members) public async Task<List<SnChatMember>> LoadMemberAccounts(ICollection<SnChatMember> members)
{ {
var accountIds = members.Select(m => m.AccountId).ToList(); var accountIds = members.Select(m => m.AccountId).ToList();
var accounts = (await accountsHelper.GetAccountBatch(accountIds)).ToDictionary(a => Guid.Parse(a.Id), a => a); var accounts = (await remoteAccountsHelper.GetAccountBatch(accountIds)).ToDictionary(a => Guid.Parse(a.Id), a => a);
return [.. members.Select(m => return
[
.. members.Select(m =>
{ {
if (accounts.TryGetValue(m.AccountId, out var account)) if (accounts.TryGetValue(m.AccountId, out var account))
m.Account = SnAccount.FromProtoValue(account); m.Account = SnAccount.FromProtoValue(account);
return m; return m;
})]; })
];
} }
private const string ChatRoomSubscribeKeyPrefix = "chatroom:subscribe:"; private const string ChatRoomSubscribeKeyPrefix = "chatroom:subscribe:";

View File

@@ -198,8 +198,6 @@ public partial class ChatService(
public async Task<SnChatMessage> SendMessageAsync(SnChatMessage message, SnChatMember sender, SnChatRoom room) public async Task<SnChatMessage> SendMessageAsync(SnChatMessage message, SnChatMember sender, SnChatRoom room)
{ {
if (string.IsNullOrWhiteSpace(message.Nonce)) message.Nonce = Guid.NewGuid().ToString(); if (string.IsNullOrWhiteSpace(message.Nonce)) message.Nonce = Guid.NewGuid().ToString();
message.CreatedAt = SystemClock.Instance.GetCurrentInstant();
message.UpdatedAt = message.CreatedAt;
// First complete the save operation // First complete the save operation
db.ChatMessages.Add(message); db.ChatMessages.Add(message);
@@ -209,20 +207,25 @@ public partial class ChatService(
await CreateFileReferencesForMessageAsync(message); await CreateFileReferencesForMessageAsync(message);
// Then start the delivery process // Then start the delivery process
var localMessage = message;
var localSender = sender;
var localRoom = room;
var localLogger = logger;
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
try try
{ {
await DeliverMessageAsync(message, sender, room); await DeliverMessageAsync(localMessage, localSender, localRoom);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError($"Error when delivering message: {ex.Message} {ex.StackTrace}"); localLogger.LogError($"Error when delivering message: {ex.Message} {ex.StackTrace}");
} }
}); });
// Process link preview in the background to avoid delaying message sending // Process link preview in the background to avoid delaying message sending
_ = Task.Run(async () => await ProcessMessageLinkPreviewAsync(message)); var localMessageForPreview = message;
_ = Task.Run(async () => await ProcessMessageLinkPreviewAsync(localMessageForPreview));
message.Sender = sender; message.Sender = sender;
message.ChatRoom = room; message.ChatRoom = room;

View File

@@ -1,30 +1,27 @@
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Registry;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Realm; namespace DysonNetwork.Sphere.Chat;
[ApiController] [ApiController]
[Route("/api/realms/{slug}")] [Route("/api/realms/{slug}")]
public class RealmChatController(AppDatabase db, RealmService rs) : ControllerBase public class RealmChatController(AppDatabase db, RemoteRealmService rs) : ControllerBase
{ {
[HttpGet("chat")] [HttpGet("chat")]
[Authorize] [Authorize]
public async Task<ActionResult<List<SnChatRoom>>> ListRealmChat(string slug) public async Task<ActionResult<List<SnChatRoom>>> ListRealmChat(string slug)
{ {
var currentUser = HttpContext.Items["CurrentUser"] as Account; var currentUser = HttpContext.Items["CurrentUser"] as Shared.Proto.Account;
var accountId = currentUser is null ? Guid.Empty : Guid.Parse(currentUser.Id); var accountId = currentUser is null ? Guid.Empty : Guid.Parse(currentUser.Id);
var realm = await db.Realms var realm = await rs.GetRealmBySlug(slug);
.Where(r => r.Slug == slug)
.FirstOrDefaultAsync();
if (realm is null) return NotFound();
if (!realm.IsPublic) if (!realm.IsPublic)
{ {
if (currentUser is null) return Unauthorized(); if (currentUser is null) return Unauthorized();
if (!await rs.IsMemberWithRole(realm.Id, accountId, RealmMemberRole.Normal)) if (!await rs.IsMemberWithRole(realm.Id, accountId, [RealmMemberRole.Normal]))
return StatusCode(403, "You need at least one member to view the realm's chat."); return StatusCode(403, "You need at least one member to view the realm's chat.");
} }

View File

@@ -1,30 +1,31 @@
using Microsoft.EntityFrameworkCore; using DysonNetwork.Shared.Registry;
namespace DysonNetwork.Sphere.Discovery; namespace DysonNetwork.Sphere.Discovery;
public class DiscoveryService(AppDatabase appDatabase) public class DiscoveryService(RemoteRealmService remoteRealmService)
{ {
public Task<List<Shared.Models.SnRealm>> GetCommunityRealmAsync( public async Task<List<Shared.Models.SnRealm>> GetCommunityRealmAsync(
string? query, string? query,
int take = 10, int take = 10,
int offset = 0, int offset = 0,
bool randomizer = false bool randomizer = false
) )
{ {
var realmsQuery = appDatabase.Realms var allRealms = await remoteRealmService.GetPublicRealms();
.Where(r => r.IsCommunity) var communityRealms = allRealms.Where(r => r.IsCommunity);
.OrderByDescending(r => r.CreatedAt)
.AsQueryable();
if (!string.IsNullOrEmpty(query)) if (!string.IsNullOrEmpty(query))
realmsQuery = realmsQuery.Where(r => {
EF.Functions.ILike(r.Name, $"%{query}%") || communityRealms = communityRealms.Where(r =>
EF.Functions.ILike(r.Description, $"%{query}%") r.Name.Contains(query, StringComparison.OrdinalIgnoreCase)
); );
realmsQuery = randomizer }
? realmsQuery.OrderBy(r => EF.Functions.Random())
: realmsQuery.OrderByDescending(r => r.CreatedAt);
return realmsQuery.Skip(offset).Take(take).ToListAsync(); // Since we don't have CreatedAt in the proto model, we'll just apply randomizer if requested
var orderedRealms = randomizer
? communityRealms.OrderBy(_ => Random.Shared.Next())
: communityRealms;
return orderedRealms.Skip(offset).Take(take).ToList();
} }
} }

View File

@@ -19,8 +19,8 @@
<PackageReference Include="HtmlAgilityPack" Version="1.12.3" /> <PackageReference Include="HtmlAgilityPack" Version="1.12.3" />
<PackageReference Include="Livekit.Server.Sdk.Dotnet" Version="1.0.10" /> <PackageReference Include="Livekit.Server.Sdk.Dotnet" Version="1.0.10" />
<PackageReference Include="Markdig" Version="0.41.3"/> <PackageReference Include="Markdig" Version="0.41.3"/>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.7"/> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.10" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7"/> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -44,7 +44,7 @@
<PackageReference Include="OpenGraph-Net" Version="4.0.1" /> <PackageReference Include="OpenGraph-Net" Version="4.0.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/> <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0"/> <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0"/> <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/> <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/> <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/>
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/> <PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/>
@@ -56,9 +56,9 @@
<PackageReference Include="Quartz.AspNetCore" Version="3.14.0"/> <PackageReference Include="Quartz.AspNetCore" Version="3.14.0"/>
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0"/> <PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0"/>
<PackageReference Include="StackExchange.Redis.Extensions.AspNetCore" Version="11.0.0"/> <PackageReference Include="StackExchange.Redis.Extensions.AspNetCore" Version="11.0.0"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="9.0.4" /> <PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="9.0.6" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" /> <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.6" />
<PackageReference Include="System.ServiceModel.Syndication" Version="9.0.7" /> <PackageReference Include="System.ServiceModel.Syndication" Version="9.0.7" />
<PackageReference Include="TencentCloudSDK.Tmt" Version="3.0.1276" /> <PackageReference Include="TencentCloudSDK.Tmt" Version="3.0.1276" />
</ItemGroup> </ItemGroup>
@@ -122,7 +122,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>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,89 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class ChangeRealmReferenceMode : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_chat_rooms_realms_realm_id",
table: "chat_rooms");
migrationBuilder.DropForeignKey(
name: "fk_posts_realms_realm_id",
table: "posts");
migrationBuilder.DropIndex(
name: "ix_posts_realm_id",
table: "posts");
migrationBuilder.DropIndex(
name: "ix_chat_rooms_realm_id",
table: "chat_rooms");
migrationBuilder.AddColumn<Guid>(
name: "sn_realm_id",
table: "chat_rooms",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "ix_chat_rooms_sn_realm_id",
table: "chat_rooms",
column: "sn_realm_id");
migrationBuilder.AddForeignKey(
name: "fk_chat_rooms_realms_sn_realm_id",
table: "chat_rooms",
column: "sn_realm_id",
principalTable: "realms",
principalColumn: "id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_chat_rooms_realms_sn_realm_id",
table: "chat_rooms");
migrationBuilder.DropIndex(
name: "ix_chat_rooms_sn_realm_id",
table: "chat_rooms");
migrationBuilder.DropColumn(
name: "sn_realm_id",
table: "chat_rooms");
migrationBuilder.CreateIndex(
name: "ix_posts_realm_id",
table: "posts",
column: "realm_id");
migrationBuilder.CreateIndex(
name: "ix_chat_rooms_realm_id",
table: "chat_rooms",
column: "realm_id");
migrationBuilder.AddForeignKey(
name: "fk_chat_rooms_realms_realm_id",
table: "chat_rooms",
column: "realm_id",
principalTable: "realms",
principalColumn: "id");
migrationBuilder.AddForeignKey(
name: "fk_posts_realms_realm_id",
table: "posts",
column: "realm_id",
principalTable: "realms",
principalColumn: "id");
}
}
}

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