119 Commits

Author SHA1 Message Date
f6f0703cb3 Proper gRPC protocol 2025-09-18 01:02:25 +08:00
3d47b4e44e ⬆️ Save progress and say goodbye 2025-09-17 00:57:24 +08:00
71fe2a30e7 👔 Change the version tag for aspire based images 2025-09-16 23:36:55 +08:00
d8f57161ae 🔨 Add aspire build workflow 2025-09-16 23:36:26 +08:00
3caa79b9a7 ♻️ Remove the Sphere project depends on the Pass project. Move to the shared project instead. 2025-09-16 00:52:37 +08:00
49beb17925 🧱 Make .NET Aspire uses docker compose 2025-09-16 00:47:18 +08:00
bd8e13f25d ♻️ Replace use aspire redis 2025-09-15 01:44:18 +08:00
1128c9a0ba 🗑️ Remove useless connection strings 2025-09-15 01:39:42 +08:00
8dfe201afe 🐛 Fixes bugs, endless CA issue, and endless unsecure grpc 2025-09-15 01:37:17 +08:00
c1016e496a Gateway in Aspire 2025-09-15 01:14:43 +08:00
091097a858 ♻️ Remove etcd, replace with asprie. Move infra to aspire. Disable gateway for now 2025-09-15 00:16:13 +08:00
5c97733b3e 💥 Rename Pusher to Ring 2025-09-14 19:42:51 +08:00
4ee387ab76 ♻️ Replace normal streams with JetStream
🐛 Fix pass order didn't handled successfully
2025-09-14 19:25:53 +08:00
19bf17200d 🐛 Session auto renew 2025-09-13 16:33:43 +08:00
be6d97ec85 🐛 Session will expired 2025-09-13 16:31:23 +08:00
9d282b26f3 Remove jetstream 2025-09-11 19:14:30 +08:00
dbc2c54ab0 🐛 Fix jetstream 2025-09-11 18:52:59 +08:00
aa062932cf 🐛 Fix post reading issue 2025-09-11 18:34:35 +08:00
812dd03e85 ♻️ Use jetstream to handle events broadcast 2025-09-09 22:52:26 +08:00
06d639a114 🐛 Fix compile error 2025-09-09 00:56:51 +08:00
74f51036b1 🐛 Optimize order handling 2025-09-09 00:51:51 +08:00
8308325b73 🐛 Trying to fix wallet transactions history error 2025-09-09 00:34:59 +08:00
fa7010db3d Able to list awards 2025-09-09 00:32:34 +08:00
89320fc540 🐛 Fix subscription 2025-09-09 00:23:34 +08:00
5ec8d89563 Able to only remove automated status 2025-09-09 00:09:37 +08:00
0eeafb5352 👔 Update the automated status logic 2025-09-09 00:01:56 +08:00
ab2bdcc7ca Mix awarded score into ranks 2025-09-08 23:45:57 +08:00
c2b49e6642 Automated status 2025-09-08 23:33:35 +08:00
1a89c48790 🐛 Fix transaction query
 Add orderes query
2025-09-08 14:26:17 +08:00
8dddfe77cd 🐛 Fix The JSON value could not be converted to System.Decimal 2025-09-08 14:19:09 +08:00
8e8b011fdd 🐛 Trying to fix transaction history API 2025-09-08 13:43:39 +08:00
abd346bb97 🐛 Trying to fix payment award event 2025-09-08 13:42:15 +08:00
6386ec8caa 🐛 Fix transaction listing 2025-09-08 02:26:40 +08:00
ad062828ff 🐛 Fix bugs 2025-09-08 02:22:03 +08:00
92e4988114 🐛 Fix bugs 2025-09-08 02:04:13 +08:00
f9269d7558 🐛 Trying to fix unable create order from rpc 2025-09-07 23:41:05 +08:00
fa01b7027a Anonymous poll 2025-09-07 23:22:34 +08:00
eaa3a9c297 Post embed 2025-09-07 22:39:42 +08:00
6cedda9307 Post awarded notification 2025-09-07 22:06:33 +08:00
942ca73f8d 🐛 Trying to fix award post 2025-09-07 21:54:10 +08:00
da3f58f2ec 🗑️ Remove NetTopo 2025-09-07 15:01:06 +08:00
4a8521d59d 🐛 Refactor to fix GeoIP 2025-09-07 14:57:44 +08:00
d7ad84e199 Notable days next 2025-09-07 14:42:37 +08:00
52430c19a5 🐛 Enable JsonNumberHandling.AllowNamedFloatingPointLiterals global wide 2025-09-07 14:39:25 +08:00
9492b6cac6 Notable days (holiday) 2025-09-07 14:33:24 +08:00
5f324a2348 🐛 Ignore point data to avoid cycling 2025-09-07 12:23:03 +08:00
7452b14817 🐛 Trying to fix JSON float 2025-09-07 12:16:28 +08:00
4a27794ccc Account region 2025-09-07 01:55:34 +08:00
d2f5ba36ab 🐛 Fix GeoIP related issue 2025-09-07 01:44:50 +08:00
0117fdf084 But fix pusher missing grpc 2025-09-06 22:20:19 +08:00
02680d224a 🐛 Fix known proxies 2025-09-06 22:15:27 +08:00
68bfdebcbd ⚗️ Testing the new ranking algo 2025-09-06 16:24:18 +08:00
54907eede1 🐛 trying to fix IP issue 2025-09-06 16:10:15 +08:00
a21d19c3ef List publishers managed by account 2025-09-06 14:12:55 +08:00
df732616d5 IP Check endpoints 2025-09-06 14:06:41 +08:00
79a31ae060 ⚗️ Change the algorithm of ranking posts 2025-09-06 11:31:41 +08:00
6eacfcd8f2 Award post 2025-09-06 11:19:23 +08:00
5e328509bd 🗃️ Add post award database 2025-09-05 00:24:54 +08:00
9c078db564 ♻️ Move in-app wallet buy stellar program order confirm logic 2025-09-05 00:20:20 +08:00
ddd109c77c ♻️ Refactored order handling 2025-09-05 00:13:58 +08:00
3ee04d0b24 ⚗️ Adjust the algorithm for both the featured post and the activity feed 2025-09-03 23:44:27 +08:00
7f110313e9 🐛 Fix inconsistent post data in activity 2025-09-03 23:32:44 +08:00
bc2e87c56f 💄 Optimized activity feed 2025-09-03 00:32:44 +08:00
d7271a2d11 🐛 Fix odic stuff 2025-09-02 00:33:47 +08:00
c57d65db67 🐛 Fix wrong magic spell subject 2025-09-01 23:46:16 +08:00
edf3aab173 Make the resend magic spell easiler to do so 2025-09-01 23:45:37 +08:00
352746a141 🐛 Fix send factor code in mail 2025-09-01 23:25:50 +08:00
216c72ea36 🗑️ Remove some unused code 2025-09-01 22:52:43 +08:00
d0723b366b 🔊 Email service logging 2025-09-01 22:10:44 +08:00
fb6721cb1b 💄 Optimize punishment reason display 2025-08-26 20:32:07 +08:00
9fcb169c94 🐛 Fix chat room invites 2025-08-26 19:08:23 +08:00
572874431d 🐛 Fix sticker perm check 2025-08-26 14:48:30 +08:00
f595ac8001 🐛 Fix uploading file didn't uploaded 2025-08-26 13:02:51 +08:00
18674e0e1d Remove /cgi directly handled by gateway 2025-08-26 02:59:51 +08:00
da4c4d3a84 🐛 Fix bugs 2025-08-26 02:48:16 +08:00
aec01b117d 🐛 Fix chat service duplicate notifying 2025-08-26 00:15:39 +08:00
d299c32e35 ♻️ Clean up OIDC provider 2025-08-25 23:53:04 +08:00
344007af66 🔊 Logging more ip address 2025-08-25 23:42:41 +08:00
d4de5aeac2 🐛 Fix api key exists cause regular login 500 2025-08-25 23:30:41 +08:00
8ce5ba50f4 🐛 Fix api key cause 401 in other serivces 2025-08-25 23:20:27 +08:00
5a44952b27 🐛 Fix oidc token aud 2025-08-25 23:17:40 +08:00
c30946daf6 🐛 Still bug fixes in auth service 2025-08-25 23:01:17 +08:00
0221d7b294 🐛 Fix compress GIF wrongly 2025-08-25 22:42:14 +08:00
c44b0b64c3 🐛 Fix api key auth issue 2025-08-25 22:39:35 +08:00
442ee3bcfd 🐛 Fixes in auth service 2025-08-25 22:24:18 +08:00
081815c512 Trying to optimize pusher serivce 2025-08-25 21:48:07 +08:00
eab2a388ae 🐛 Fixes in authorize 2025-08-25 21:22:04 +08:00
5f7ab49abb 🛂 Add permission check in post pin / unpin 2025-08-25 20:04:21 +08:00
4ff89173b2 ♻️ Some optimzations for sync message endpoint 2025-08-25 19:24:42 +08:00
f2052410c7 Filtered realm posts 2025-08-25 17:47:30 +08:00
83a49be725 🐛 Fix websocket missing in notification 2025-08-25 17:43:37 +08:00
9b205a73fd 💄 Optimize post controller 2025-08-25 17:06:21 +08:00
d5157eb7e3 Post category tags subscriptions 2025-08-25 14:18:14 +08:00
75c92c51db 🐛 Dozens of bug fixes 2025-08-25 13:43:40 +08:00
915054fce0 Pinned post 2025-08-25 13:37:25 +08:00
63653680ba 👔 Update the algorithm to pick featured post 2025-08-25 13:06:09 +08:00
84c4df6620 👔 Prevent from creating duplicate featured record 2025-08-25 13:05:34 +08:00
8c748fd57a Bring OIDC back 2025-08-25 02:44:44 +08:00
4684550ebf App custom secret management 2025-08-24 23:50:57 +08:00
51db08f374 🐛 Fix develop API permission check 2025-08-24 21:53:41 +08:00
9f38a288b9 🐛 Fix save notification again.. 2025-08-24 18:05:42 +08:00
75a975049c 🐛 Fix get subscribed feed 2025-08-24 17:37:30 +08:00
f8c35c0350 🐛 Fix queue background service in pusher didn't save notification now 2025-08-24 16:59:27 +08:00
d9a5fed77f 🐛 Fix wrong queue name 2025-08-24 13:19:39 +08:00
7cb14940d9 🐛 Fix rotate key 2025-08-24 01:49:48 +08:00
953bf5d4de Bot controller has keys endpoints 2025-08-23 19:52:05 +08:00
d9620fd6a4 Bot transparency API 2025-08-23 17:55:42 +08:00
541e2dd14c 🐛 Fix bots errors 2025-08-23 17:06:52 +08:00
c7925d98c8 🐛 Fix bot account missing created / updated at 2025-08-23 14:25:46 +08:00
f759b19bcb 🐛 Fixes in bot 2025-08-23 14:20:21 +08:00
5d7429a416 ♻️ Refind bot account 2025-08-23 13:00:30 +08:00
fb7e52d6f3 Sticker pack includes preview stickers 2025-08-22 23:02:16 +08:00
50e888b075 🐛 Fix mark all read will reset the viewed at 2025-08-22 22:42:32 +08:00
76c8bbf307 🐛 Fix social credit cache didn't have base value 2025-08-22 22:41:38 +08:00
8f3825e92c Cache user social credits on profile 2025-08-22 22:28:48 +08:00
d1c3610ec8 🐛 Dozens of bug fixes 2025-08-22 19:55:16 +08:00
4b958a3c31 🗑️ Remove the old search API 2025-08-22 17:07:22 +08:00
1f9021d459 🎨 Disassmeble the activity service parts 2025-08-22 16:56:21 +08:00
7ad9deaf70 🎨 Adjust post shuffle query 2025-08-22 16:50:06 +08:00
236 changed files with 32545 additions and 2963 deletions

3
.aspire/settings.json Normal file
View File

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

35
.env Normal file
View File

@@ -0,0 +1,35 @@
# Default container port for ring
RING_PORT=8080
# Default container port for pass
PASS_PORT=8080
# Default container port for drive
DRIVE_PORT=8080
# Default container port for sphere
SPHERE_PORT=8080
# Default container port for develop
DEVELOP_PORT=8080
# Parameter cache-password
CACHE_PASSWORD=KS3jSPaU9e
# Parameter queue-password
QUEUE_PASSWORD=8xEECa4ckz
# Container image name for ring
RING_IMAGE=ring:latest
# Container image name for pass
PASS_IMAGE=pass:latest
# Container image name for drive
DRIVE_IMAGE=drive:latest
# Container image name for sphere
SPHERE_IMAGE=sphere:latest
# Container image name for develop
DEVELOP_IMAGE=develop:latest

View File

@@ -1,4 +1,4 @@
name: Build and Push Microservices name: Aspire Publish Workflow
on: on:
push: push:
@@ -7,7 +7,7 @@
workflow_dispatch: workflow_dispatch:
jobs: jobs:
build-sphere: publish:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
@@ -15,175 +15,46 @@
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with: with:
fetch-depth: 0 dotnet-version: "9.0.x"
- name: Setup NBGV
uses: dotnet/nbgv@master
id: nbgv
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry - name: Log in to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push DysonNetwork.Sphere Docker image
uses: docker/build-push-action@v6
with:
file: DysonNetwork.Sphere/Dockerfile
context: .
push: true
tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-sphere:latest
platforms: linux/amd64
build-pass: - name: Install Aspire CLI
runs-on: ubuntu-latest run: dotnet tool install -g Aspire.Cli --prerelease
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup NBGV
uses: dotnet/nbgv@master
id: nbgv
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push DysonNetwork.Pass Docker image
uses: docker/build-push-action@v6
with:
file: DysonNetwork.Pass/Dockerfile
context: .
push: true
tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-pass:latest
platforms: linux/amd64
build-pusher: - name: Build and Publish Aspire Application
runs-on: ubuntu-latest run: aspire publish --project ./DysonNetwork.Control/DysonNetwork.Control.csproj --output publish
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup NBGV
uses: dotnet/nbgv@master
id: nbgv
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push DysonNetwork.Pusher Docker image
uses: docker/build-push-action@v6
with:
file: DysonNetwork.Pusher/Dockerfile
context: .
push: true
tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-pusher:latest
platforms: linux/amd64
build-drive: - name: Tag and Push Images
runs-on: ubuntu-latest run: |
permissions: IMAGES=( "sphere" "pass" "ring" "drive" "develop" )
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup NBGV
uses: dotnet/nbgv@master
id: nbgv
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push DysonNetwork.Drive Docker image
uses: docker/build-push-action@v6
with:
file: DysonNetwork.Drive/Dockerfile
context: .
push: true
tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-drive:latest
platforms: linux/amd64
build-gateway: for image in "${IMAGES[@]}"; do
runs-on: ubuntu-latest IMAGE_NAME="ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-$image:alpha"
permissions: SOURCE_IMAGE_NAME="$image:latest" # Aspire's default local image name
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup NBGV
uses: dotnet/nbgv@master
id: nbgv
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push DysonNetwork.Gateway Docker image
uses: docker/build-push-action@v6
with:
file: DysonNetwork.Gateway/Dockerfile
context: .
push: true
tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-gateway:latest
platforms: linux/amd64
build-develop: echo "Tagging and pushing $SOURCE_IMAGE_NAME to $IMAGE_NAME..."
runs-on: ubuntu-latest docker tag $SOURCE_IMAGE_NAME $IMAGE_NAME
permissions: docker push $IMAGE_NAME
contents: read done
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup NBGV
uses: dotnet/nbgv@master
id: nbgv
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push DysonNetwork.Develop Docker image
uses: docker/build-push-action@v6
with:
file: DysonNetwork.Develop/Dockerfile
context: .
push: true
tags: ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-develop:latest
platforms: linux/amd64
- name: Upload Aspire Publish Directory
uses: actions/upload-artifact@v3
with:
name: aspire-publish-output
path: ./publish/
- name: Upload Docker Compose file
uses: actions/upload-artifact@v3
with:
name: docker-compose-output
path: ./publish/docker-compose.yml

View File

@@ -0,0 +1,77 @@
using Aspire.Hosting.Yarp.Transforms;
var builder = DistributedApplication.CreateBuilder(args);
// Database was configured separately in each service.
// var database = builder.AddPostgres("database");
var cache = builder.AddRedis("cache");
var queue = builder.AddNats("queue").WithJetStream();
var ringService = builder.AddProject<Projects.DysonNetwork_Ring>("ring")
.WithReference(queue)
.WithHttpHealthCheck()
.WithEndpoint(5001, 5001, "https", name: "grpc");
var passService = builder.AddProject<Projects.DysonNetwork_Pass>("pass")
.WithReference(cache)
.WithReference(queue)
.WithReference(ringService)
.WithHttpHealthCheck()
.WithEndpoint(5001, 5001, "https", name: "grpc");
var driveService = builder.AddProject<Projects.DysonNetwork_Drive>("drive")
.WithReference(cache)
.WithReference(queue)
.WithReference(passService)
.WithReference(ringService)
.WithHttpHealthCheck()
.WithEndpoint(5001, 5001, "https", name: "grpc");
var sphereService = builder.AddProject<Projects.DysonNetwork_Sphere>("sphere")
.WithReference(cache)
.WithReference(queue)
.WithReference(passService)
.WithReference(ringService)
.WithHttpHealthCheck()
.WithEndpoint(5001, 5001, "https", name: "grpc");
var developService = builder.AddProject<Projects.DysonNetwork_Develop>("develop")
.WithReference(cache)
.WithReference(passService)
.WithReference(ringService)
.WithHttpHealthCheck()
.WithEndpoint(5001, 5001, "https", name: "grpc");
// Extra double-ended references
ringService.WithReference(passService);
builder.AddYarp("gateway")
.WithHostPort(5000)
.WithConfiguration(yarp =>
{
var ringCluster = yarp.AddCluster(ringService.GetEndpoint("http"));
yarp.AddRoute("/ws", ringCluster);
yarp.AddRoute("/ring/{**catch-all}", ringCluster)
.WithTransformPathRemovePrefix("/ring")
.WithTransformPathPrefix("/api");
var passCluster = yarp.AddCluster(passService.GetEndpoint("http"));
yarp.AddRoute("/.well-known/openid-configuration", passCluster);
yarp.AddRoute("/.well-known/jwks", passCluster);
yarp.AddRoute("/id/{**catch-all}", passCluster)
.WithTransformPathRemovePrefix("/id")
.WithTransformPathPrefix("/api");
var driveCluster = yarp.AddCluster(driveService.GetEndpoint("http"));
yarp.AddRoute("/api/tus", driveCluster);
yarp.AddRoute("/drive/{**catch-all}", driveCluster)
.WithTransformPathRemovePrefix("/drive")
.WithTransformPathPrefix("/api");
var sphereCluster = yarp.AddCluster(sphereService.GetEndpoint("http"));
yarp.AddRoute("/sphere/{**catch-all}", sphereCluster)
.WithTransformPathRemovePrefix("/sphere")
.WithTransformPathPrefix("/api");
var developCluster = yarp.AddCluster(developService.GetEndpoint("http"));
yarp.AddRoute("/develop/{**catch-all}", developCluster)
.WithTransformPathRemovePrefix("/develop")
.WithTransformPathPrefix("/api");
});
builder.AddDockerComposeEnvironment("docker-compose");
builder.Build().Run();

View File

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

View File

@@ -0,0 +1,29 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:17025;http://localhost:15057",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21175",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22189"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:15057",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19163",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20185"
}
}
}
}

View File

@@ -0,0 +1,11 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"cache": "localhost:6379"
}
}

View File

@@ -31,6 +31,7 @@
</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.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using DysonNetwork.Develop.Project; using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
using NodaTime;
using NodaTime.Serialization.Protobuf; using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Develop.Identity; namespace DysonNetwork.Develop.Identity;
@@ -16,6 +16,14 @@ public class BotAccount : ModelBase
public Guid ProjectId { get; set; } public Guid ProjectId { get; set; }
public DevProject Project { get; set; } = null!; public DevProject Project { get; set; } = null!;
[NotMapped] public AccountReference? Account { get; set; }
/// <summary>
/// This developer field is to serve the transparent info for user to know which developer
/// published this robot. Not for relationships usage.
/// </summary>
[NotMapped] public Developer? Developer { get; set; }
public Shared.Proto.BotAccount ToProtoValue() public Shared.Proto.BotAccount ToProtoValue()
{ {
var proto = new Shared.Proto.BotAccount var proto = new Shared.Proto.BotAccount

View File

@@ -1,8 +1,13 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Develop.Project; using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using Grpc.Core;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NodaTime;
using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Develop.Identity; namespace DysonNetwork.Develop.Identity;
@@ -13,18 +18,62 @@ public class BotAccountController(
BotAccountService botService, BotAccountService botService,
DeveloperService developerService, DeveloperService developerService,
DevProjectService projectService, DevProjectService projectService,
ILogger<BotAccountController> logger ILogger<BotAccountController> logger,
AccountClientHelper accounts,
BotAccountReceiverService.BotAccountReceiverServiceClient accountsReceiver
) )
: ControllerBase : ControllerBase
{ {
public record BotRequest( public class CommonBotRequest
[Required] [MaxLength(1024)] string? Slug {
); [MaxLength(256)] public string? FirstName { get; set; }
[MaxLength(256)] public string? MiddleName { get; set; }
[MaxLength(256)] public string? LastName { get; set; }
[MaxLength(1024)] public string? Gender { get; set; }
[MaxLength(1024)] public string? Pronouns { get; set; }
[MaxLength(1024)] public string? TimeZone { get; set; }
[MaxLength(1024)] public string? Location { get; set; }
[MaxLength(4096)] public string? Bio { get; set; }
public Instant? Birthday { get; set; }
public record UpdateBotRequest( [MaxLength(32)] public string? PictureId { get; set; }
[MaxLength(1024)] string? Slug, [MaxLength(32)] public string? BackgroundId { get; set; }
bool? IsActive }
) : BotRequest(Slug);
public class BotCreateRequest : CommonBotRequest
{
[Required]
[MinLength(2)]
[MaxLength(256)]
[RegularExpression(@"^[A-Za-z0-9_-]+$",
ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.")
]
public string Name { get; set; } = string.Empty;
[Required] [MaxLength(256)] public string Nick { get; set; } = string.Empty;
[Required] [MaxLength(1024)] public string Slug { get; set; } = string.Empty;
[MaxLength(128)] public string Language { get; set; } = "en-us";
}
public class UpdateBotRequest : CommonBotRequest
{
[MinLength(2)]
[MaxLength(256)]
[RegularExpression(@"^[A-Za-z0-9_-]+$",
ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.")
]
public string? Name { get; set; } = string.Empty;
[MaxLength(256)] public string? Nick { get; set; } = string.Empty;
[Required] [MaxLength(1024)] public string? Slug { get; set; } = string.Empty;
[MaxLength(128)] public string? Language { get; set; }
public bool? IsActive { get; set; }
}
[HttpGet] [HttpGet]
public async Task<IActionResult> ListBots( public async Task<IActionResult> ListBots(
@@ -39,15 +88,15 @@ public class BotAccountController(
return NotFound("Developer not found"); return NotFound("Developer not found");
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
PublisherMemberRole.Editor)) PublisherMemberRole.Viewer))
return StatusCode(403, "You must be an editor of the developer to list bots"); return StatusCode(403, "You must be an viewer of the developer to list bots");
var project = await projectService.GetProjectAsync(projectId, developer.Id); var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null) if (project is null)
return NotFound("Project not found or you don't have access"); return NotFound("Project not found or you don't have access");
var bots = await botService.GetBotsByProjectAsync(projectId); var bots = await botService.GetBotsByProjectAsync(projectId);
return Ok(bots); return Ok(await botService.LoadBotsAccountAsync(bots));
} }
[HttpGet("{botId:guid}")] [HttpGet("{botId:guid}")]
@@ -64,8 +113,8 @@ public class BotAccountController(
return NotFound("Developer not found"); return NotFound("Developer not found");
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
PublisherMemberRole.Editor)) PublisherMemberRole.Viewer))
return StatusCode(403, "You must be an editor of the developer to view bot details"); return StatusCode(403, "You must be an viewer of the developer to view bot details");
var project = await projectService.GetProjectAsync(projectId, developer.Id); var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null) if (project is null)
@@ -75,18 +124,16 @@ 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");
return Ok(bot); return Ok(await botService.LoadBotAccountAsync(bot));
} }
[HttpPost] [HttpPost]
public async Task<IActionResult> CreateBot( public async Task<IActionResult> CreateBot(
[FromRoute] string pubName, [FromRoute] string pubName,
[FromRoute] Guid projectId, [FromRoute] Guid projectId,
[FromBody] BotRequest request [FromBody] BotCreateRequest createRequest
) )
{ {
if (string.IsNullOrWhiteSpace(request.Slug))
return BadRequest("Name is required");
if (HttpContext.Items["CurrentUser"] is not Account currentUser) if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized(); return Unauthorized();
@@ -102,9 +149,43 @@ public class BotAccountController(
if (project is null) if (project is null)
return NotFound("Project not found or you don't have access"); return NotFound("Project not found or you don't have access");
var now = SystemClock.Instance.GetCurrentInstant();
var accountId = Guid.NewGuid();
var account = new Account()
{
Id = accountId.ToString(),
Name = createRequest.Name,
Nick = createRequest.Nick,
Language = createRequest.Language,
Profile = new AccountProfile()
{
Id = Guid.NewGuid().ToString(),
Bio = createRequest.Bio,
Gender = createRequest.Gender,
FirstName = createRequest.FirstName,
MiddleName = createRequest.MiddleName,
LastName = createRequest.LastName,
TimeZone = createRequest.TimeZone,
Pronouns = createRequest.Pronouns,
Location = createRequest.Location,
Birthday = createRequest.Birthday?.ToTimestamp(),
AccountId = accountId.ToString(),
CreatedAt = now.ToTimestamp(),
UpdatedAt = now.ToTimestamp()
},
CreatedAt = now.ToTimestamp(),
UpdatedAt = now.ToTimestamp()
};
try try
{ {
var bot = await botService.CreateBotAsync(project, request.Slug); var bot = await botService.CreateBotAsync(
project,
createRequest.Slug,
account,
createRequest.PictureId,
createRequest.BackgroundId
);
return Ok(bot); return Ok(bot);
} }
catch (Exception ex) catch (Exception ex)
@@ -114,7 +195,7 @@ public class BotAccountController(
} }
} }
[HttpPut("{botId:guid}")] [HttpPatch("{botId:guid}")]
public async Task<IActionResult> UpdateBot( public async Task<IActionResult> UpdateBot(
[FromRoute] string pubName, [FromRoute] string pubName,
[FromRoute] Guid projectId, [FromRoute] Guid projectId,
@@ -141,12 +222,31 @@ 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);
if (request.Name is not null) botAccount.Name = request.Name;
if (request.Nick is not null) botAccount.Nick = request.Nick;
if (request.Language is not null) botAccount.Language = request.Language;
if (request.Bio is not null) botAccount.Profile.Bio = request.Bio;
if (request.Gender is not null) botAccount.Profile.Gender = request.Gender;
if (request.FirstName is not null) botAccount.Profile.FirstName = request.FirstName;
if (request.MiddleName is not null) botAccount.Profile.MiddleName = request.MiddleName;
if (request.LastName is not null) botAccount.Profile.LastName = request.LastName;
if (request.TimeZone is not null) botAccount.Profile.TimeZone = request.TimeZone;
if (request.Pronouns is not null) botAccount.Profile.Pronouns = request.Pronouns;
if (request.Location is not null) botAccount.Profile.Location = request.Location;
if (request.Birthday is not null) botAccount.Profile.Birthday = request.Birthday?.ToTimestamp();
if (request.Slug is not null) bot.Slug = request.Slug;
if (request.IsActive is not null) bot.IsActive = request.IsActive.Value;
try try
{ {
var updatedBot = await botService.UpdateBotAsync( var updatedBot = await botService.UpdateBotAsync(
bot, bot,
request.Slug, botAccount,
request.IsActive request.PictureId,
request.BackgroundId
); );
return Ok(updatedBot); return Ok(updatedBot);
@@ -194,4 +294,167 @@ public class BotAccountController(
return StatusCode(500, "An error occurred while deleting the bot account"); return StatusCode(500, "An error occurred while deleting the bot account");
} }
} }
[HttpGet("{botId:guid}/keys")]
public async Task<ActionResult<List<ApiKeyReference>>> ListBotKeys(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid botId
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Viewer);
if (developer == null) return NotFound("Developer not found");
if (project == null) return NotFound("Project not found or you don't have access");
if (bot == null) return NotFound("Bot not found");
var keys = await accountsReceiver.ListApiKeyAsync(new ListApiKeyRequest
{
AutomatedId = bot.Id.ToString()
});
var data = keys.Data.Select(ApiKeyReference.FromProtoValue).ToList();
return Ok(data);
}
[HttpGet("{botId:guid}/keys/{keyId:guid}")]
public async Task<ActionResult<ApiKeyReference>> GetBotKey(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid botId,
[FromRoute] Guid keyId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Viewer);
if (developer == null) return NotFound("Developer not found");
if (project == null) return NotFound("Project not found or you don't have access");
if (bot == null) return NotFound("Bot not found");
try
{
var key = await accountsReceiver.GetApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() });
if (key == null) return NotFound("API key not found");
return Ok(ApiKeyReference.FromProtoValue(key));
}
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
{
return NotFound("API key not found");
}
}
public class CreateApiKeyRequest
{
[Required, MaxLength(1024)]
public string Label { get; set; } = null!;
}
[HttpPost("{botId:guid}/keys")]
public async Task<ActionResult<ApiKeyReference>> CreateBotKey(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid botId,
[FromBody] CreateApiKeyRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor);
if (developer == null) return NotFound("Developer not found");
if (project == null) return NotFound("Project not found or you don't have access");
if (bot == null) return NotFound("Bot not found");
try
{
var newKey = new ApiKey
{
AccountId = bot.Id.ToString(),
Label = request.Label
};
var createdKey = await accountsReceiver.CreateApiKeyAsync(newKey);
return Ok(ApiKeyReference.FromProtoValue(createdKey));
}
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument)
{
return BadRequest(ex.Status.Detail);
}
}
[HttpPost("{botId:guid}/keys/{keyId:guid}/rotate")]
public async Task<ActionResult<ApiKeyReference>> RotateBotKey(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid botId,
[FromRoute] Guid keyId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor);
if (developer == null) return NotFound("Developer not found");
if (project == null) return NotFound("Project not found or you don't have access");
if (bot == null) return NotFound("Bot not found");
try
{
var rotatedKey = await accountsReceiver.RotateApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() });
return Ok(ApiKeyReference.FromProtoValue(rotatedKey));
}
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
{
return NotFound("API key not found");
}
}
[HttpDelete("{botId:guid}/keys/{keyId:guid}")]
public async Task<IActionResult> DeleteBotKey(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid botId,
[FromRoute] Guid keyId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor);
if (developer == null) return NotFound("Developer not found");
if (project == null) return NotFound("Project not found or you don't have access");
if (bot == null) return NotFound("Bot not found");
try
{
await accountsReceiver.DeleteApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() });
return NoContent();
}
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
{
return NotFound("API key not found");
}
}
private async Task<(Developer?, DevProject?, BotAccount?)> ValidateBotAccess(
string pubName,
Guid projectId,
Guid botId,
Account currentUser,
PublisherMemberRole requiredRole)
{
var developer = await developerService.GetDeveloperByName(pubName);
if (developer == null) return (null, null, null);
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), requiredRole))
return (null, null, null);
var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project == null) return (developer, null, null);
var bot = await botService.GetBotByIdAsync(botId);
if (bot == null || bot.ProjectId != projectId) return (developer, project, null);
return (developer, project, bot);
}
} }

View File

@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Develop.Identity;
[ApiController]
[Route("api/bots")]
public class BotAccountPublicController(BotAccountService botService, DeveloperService developerService) : ControllerBase
{
[HttpGet("{botId:guid}")]
public async Task<ActionResult<BotAccount>> GetBotTransparentInfo([FromRoute] Guid botId)
{
var bot = await botService.GetBotByIdAsync(botId);
if (bot is null) return NotFound("Bot not found");
bot = await botService.LoadBotAccountAsync(bot);
var developer = await developerService.GetDeveloperById(bot!.Project.DeveloperId);
if (developer is null) return NotFound("Developer not found");
bot.Developer = await developerService.LoadDeveloperPublisher(developer);
return Ok(bot);
}
[HttpGet("{botId:guid}/developer")]
public async Task<ActionResult<Developer>> GetBotDeveloper([FromRoute] Guid botId)
{
var bot = await botService.GetBotByIdAsync(botId);
if (bot is null) return NotFound("Bot not found");
var developer = await developerService.GetDeveloperById(bot!.Project.DeveloperId);
if (developer is null) return NotFound("Developer not found");
developer = await developerService.LoadDeveloperPublisher(developer);
return Ok(developer);
}
}

View File

@@ -1,13 +1,18 @@
using DysonNetwork.Develop.Project; using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using Grpc.Core; using Grpc.Core;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime;
using NodaTime.Serialization.Protobuf; using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Develop.Identity; namespace DysonNetwork.Develop.Identity;
public class BotAccountService(AppDatabase db, BotAccountReceiverService.BotAccountReceiverServiceClient accountReceiver) public class BotAccountService(
AppDatabase db,
BotAccountReceiverService.BotAccountReceiverServiceClient accountReceiver,
AccountClientHelper accounts
)
{ {
public async Task<BotAccount?> GetBotByIdAsync(Guid id) public async Task<BotAccount?> GetBotByIdAsync(Guid id)
{ {
@@ -23,39 +28,30 @@ public class BotAccountService(AppDatabase db, BotAccountReceiverService.BotAcco
.ToListAsync(); .ToListAsync();
} }
public async Task<BotAccount> CreateBotAsync(DevProject project, string slug) public async Task<BotAccount> CreateBotAsync(
DevProject project,
string slug,
Account account,
string? pictureId,
string? backgroundId
)
{ {
// First, check if a bot with this slug already exists in this project // First, check if a bot with this slug already exists in this project
var existingBot = await db.BotAccounts var existingBot = await db.BotAccounts
.FirstOrDefaultAsync(b => b.ProjectId == project.Id && b.Slug == slug); .FirstOrDefaultAsync(b => b.ProjectId == project.Id && b.Slug == slug);
if (existingBot != null) if (existingBot != null)
{
throw new InvalidOperationException("A bot with this slug already exists in this project."); throw new InvalidOperationException("A bot with this slug already exists in this project.");
}
var now = SystemClock.Instance.GetCurrentInstant();
try try
{ {
// First create the bot account in the Pass service var automatedId = Guid.NewGuid();
var createRequest = new CreateBotAccountRequest var createRequest = new CreateBotAccountRequest
{ {
AutomatedId = Guid.NewGuid().ToString(), AutomatedId = automatedId.ToString(),
Account = new Account Account = account,
{ PictureId = pictureId,
Name = slug, BackgroundId = backgroundId
Nick = $"Bot {slug}",
Language = "en",
Profile = new AccountProfile
{
Id = Guid.NewGuid().ToString(),
CreatedAt = now.ToTimestamp(),
UpdatedAt = now.ToTimestamp()
},
CreatedAt = now.ToTimestamp(),
UpdatedAt = now.ToTimestamp()
}
}; };
var createResponse = await accountReceiver.CreateBotAccountAsync(createRequest); var createResponse = await accountReceiver.CreateBotAccountAsync(createRequest);
@@ -64,7 +60,7 @@ public class BotAccountService(AppDatabase db, BotAccountReceiverService.BotAcco
// Then create the local bot account // Then create the local bot account
var bot = new BotAccount var bot = new BotAccount
{ {
Id = Guid.Parse(botAccount.AutomatedId), Id = automatedId,
Slug = slug, Slug = slug,
ProjectId = project.Id, ProjectId = project.Id,
Project = project, Project = project,
@@ -80,7 +76,8 @@ public class BotAccountService(AppDatabase db, BotAccountReceiverService.BotAcco
} }
catch (RpcException ex) when (ex.StatusCode == StatusCode.AlreadyExists) catch (RpcException ex) when (ex.StatusCode == StatusCode.AlreadyExists)
{ {
throw new InvalidOperationException("A bot account with this ID already exists in the authentication service.", ex); throw new InvalidOperationException(
"A bot account with this ID already exists in the authentication service.", ex);
} }
catch (RpcException ex) when (ex.StatusCode == StatusCode.InvalidArgument) catch (RpcException ex) when (ex.StatusCode == StatusCode.InvalidArgument)
{ {
@@ -92,22 +89,15 @@ public class BotAccountService(AppDatabase db, BotAccountReceiverService.BotAcco
} }
} }
public async Task<BotAccount> UpdateBotAsync(BotAccount bot, string? slug = null, bool? isActive = null) public async Task<BotAccount> UpdateBotAsync(
BotAccount bot,
Account account,
string? pictureId,
string? backgroundId
)
{ {
var updated = false; db.Update(bot);
if (slug != null && bot.Slug != slug) await db.SaveChangesAsync();
{
bot.Slug = slug;
updated = true;
}
if (isActive.HasValue && bot.IsActive != isActive.Value)
{
bot.IsActive = isActive.Value;
updated = true;
}
if (!updated) return bot;
try try
{ {
@@ -115,12 +105,9 @@ public class BotAccountService(AppDatabase db, BotAccountReceiverService.BotAcco
var updateRequest = new UpdateBotAccountRequest var updateRequest = new UpdateBotAccountRequest
{ {
AutomatedId = bot.Id.ToString(), AutomatedId = bot.Id.ToString(),
Account = new Shared.Proto.Account Account = account,
{ PictureId = pictureId,
Name = $"bot-{bot.Slug}", BackgroundId = backgroundId
Nick = $"Bot {bot.Slug}",
UpdatedAt = SystemClock.Instance.GetCurrentInstant().ToTimestamp()
}
}; };
var updateResponse = await accountReceiver.UpdateBotAccountAsync(updateRequest); var updateResponse = await accountReceiver.UpdateBotAccountAsync(updateRequest);
@@ -165,4 +152,23 @@ public class BotAccountService(AppDatabase db, BotAccountReceiverService.BotAcco
db.BotAccounts.Remove(bot); db.BotAccounts.Remove(bot);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
public async Task<BotAccount?> LoadBotAccountAsync(BotAccount bot) =>
(await LoadBotsAccountAsync([bot])).FirstOrDefault();
public async Task<List<BotAccount>> LoadBotsAccountAsync(IEnumerable<BotAccount> bots)
{
bots = bots.ToList();
var automatedIds = bots.Select(b => b.Id).ToList();
var data = await accounts.GetBotAccountBatch(automatedIds);
foreach (var bot in bots)
{
bot.Account = data
.Select(AccountReference.FromProtoValue)
.FirstOrDefault(e => e.AutomatedId == bot.Id);
}
return bots as List<BotAccount> ?? [];
}
} }

View File

@@ -32,7 +32,7 @@ public class CustomApp : ModelBase, IIdentifiedResource
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; } [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; } [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
[Column(TypeName = "jsonb")] public DysonNetwork.Shared.Data.VerificationMark? Verification { get; set; } [Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
[Column(TypeName = "jsonb")] public CustomAppOauthConfig? OauthConfig { get; set; } [Column(TypeName = "jsonb")] public CustomAppOauthConfig? OauthConfig { get; set; }
[Column(TypeName = "jsonb")] public CustomAppLinks? Links { get; set; } [Column(TypeName = "jsonb")] public CustomAppLinks? Links { get; set; }
@@ -62,17 +62,22 @@ public class CustomApp : ModelBase, IIdentifiedResource
CustomAppStatus.Suspended => Shared.Proto.CustomAppStatus.Suspended, CustomAppStatus.Suspended => Shared.Proto.CustomAppStatus.Suspended,
_ => Shared.Proto.CustomAppStatus.Unspecified _ => Shared.Proto.CustomAppStatus.Unspecified
}, },
Picture = Picture is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Picture)), Picture = Picture?.ToProtoValue(),
Background = Background is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Background)), Background = Background?.ToProtoValue(),
Verification = Verification is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Verification)), Verification = Verification?.ToProtoValue(),
Links = Links is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Links)), Links = Links is null ? null : new DysonNetwork.Shared.Proto.CustomAppLinks
{
HomePage = Links.HomePage ?? string.Empty,
PrivacyPolicy = Links.PrivacyPolicy ?? string.Empty,
TermsOfService = Links.TermsOfService ?? string.Empty
},
OauthConfig = OauthConfig is null ? null : new DysonNetwork.Shared.Proto.CustomAppOauthConfig OauthConfig = OauthConfig is null ? null : new DysonNetwork.Shared.Proto.CustomAppOauthConfig
{ {
ClientUri = OauthConfig.ClientUri ?? string.Empty, ClientUri = OauthConfig.ClientUri ?? string.Empty,
RedirectUris = { OauthConfig.RedirectUris ?? Array.Empty<string>() }, RedirectUris = { OauthConfig.RedirectUris ?? [] },
PostLogoutRedirectUris = { OauthConfig.PostLogoutRedirectUris ?? Array.Empty<string>() }, PostLogoutRedirectUris = { OauthConfig.PostLogoutRedirectUris ?? [] },
AllowedScopes = { OauthConfig.AllowedScopes ?? Array.Empty<string>() }, AllowedScopes = { OauthConfig.AllowedScopes ?? [] },
AllowedGrantTypes = { OauthConfig.AllowedGrantTypes ?? Array.Empty<string>() }, AllowedGrantTypes = { OauthConfig.AllowedGrantTypes ?? [] },
RequirePkce = OauthConfig.RequirePkce, RequirePkce = OauthConfig.RequirePkce,
AllowOfflineAccess = OauthConfig.AllowOfflineAccess AllowOfflineAccess = OauthConfig.AllowOfflineAccess
}, },
@@ -99,10 +104,18 @@ public class CustomApp : ModelBase, IIdentifiedResource
ProjectId = string.IsNullOrEmpty(p.ProjectId) ? Guid.Empty : Guid.Parse(p.ProjectId); ProjectId = string.IsNullOrEmpty(p.ProjectId) ? Guid.Empty : Guid.Parse(p.ProjectId);
CreatedAt = p.CreatedAt.ToInstant(); CreatedAt = p.CreatedAt.ToInstant();
UpdatedAt = p.UpdatedAt.ToInstant(); UpdatedAt = p.UpdatedAt.ToInstant();
if (p.Picture.Length > 0) Picture = System.Text.Json.JsonSerializer.Deserialize<CloudFileReferenceObject>(p.Picture.ToStringUtf8()); if (p.Picture is not null) Picture = CloudFileReferenceObject.FromProtoValue(p.Picture);
if (p.Background.Length > 0) Background = System.Text.Json.JsonSerializer.Deserialize<CloudFileReferenceObject>(p.Background.ToStringUtf8()); if (p.Background is not null) Background = CloudFileReferenceObject.FromProtoValue(p.Background);
if (p.Verification.Length > 0) Verification = System.Text.Json.JsonSerializer.Deserialize<DysonNetwork.Shared.Data.VerificationMark>(p.Verification.ToStringUtf8()); if (p.Verification is not null) Verification = VerificationMark.FromProtoValue(p.Verification);
if (p.Links.Length > 0) Links = System.Text.Json.JsonSerializer.Deserialize<CustomAppLinks>(p.Links.ToStringUtf8()); if (p.Links is not null)
{
Links = new CustomAppLinks
{
HomePage = string.IsNullOrEmpty(p.Links.HomePage) ? null : p.Links.HomePage,
PrivacyPolicy = string.IsNullOrEmpty(p.Links.PrivacyPolicy) ? null : p.Links.PrivacyPolicy,
TermsOfService = string.IsNullOrEmpty(p.Links.TermsOfService) ? null : p.Links.TermsOfService
};
}
return this; return this;
} }
} }

View File

@@ -3,12 +3,14 @@ using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NodaTime;
namespace DysonNetwork.Develop.Identity; namespace DysonNetwork.Develop.Identity;
[ApiController] [ApiController]
[Route("/api/developers/{pubName}/projects/{projectId:guid}/apps")] [Route("/api/developers/{pubName}/projects/{projectId:guid}/apps")]
public class CustomAppController(CustomAppService customApps, DeveloperService ds, DevProjectService projectService) : ControllerBase public class CustomAppController(CustomAppService customApps, DeveloperService ds, DevProjectService projectService)
: ControllerBase
{ {
public record CustomAppRequest( public record CustomAppRequest(
[MaxLength(1024)] string? Slug, [MaxLength(1024)] string? Slug,
@@ -21,12 +23,36 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
CustomAppOauthConfig? OauthConfig CustomAppOauthConfig? OauthConfig
); );
public record CreateSecretRequest(
[MaxLength(4096)] string? Description,
TimeSpan? ExpiresIn = null,
bool IsOidc = false
);
public record SecretResponse(
string Id,
string? Secret,
string? Description,
Instant? ExpiresAt,
bool IsOidc,
Instant CreatedAt,
Instant UpdatedAt
);
[HttpGet] [HttpGet]
[Authorize]
public async Task<IActionResult> ListApps([FromRoute] string pubName, [FromRoute] Guid projectId) public async Task<IActionResult> ListApps([FromRoute] string pubName, [FromRoute] Guid projectId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await ds.GetDeveloperByName(pubName); var developer = await ds.GetDeveloperByName(pubName);
if (developer is null) return NotFound(); if (developer is null) return NotFound();
var accountId = Guid.Parse(currentUser.Id);
if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, PublisherMemberRole.Viewer))
return StatusCode(403, "You must be a viewer of the developer to list custom apps");
var project = await projectService.GetProjectAsync(projectId, developer.Id); var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null) return NotFound(); if (project is null) return NotFound();
@@ -35,11 +61,20 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
} }
[HttpGet("{appId:guid}")] [HttpGet("{appId:guid}")]
public async Task<IActionResult> GetApp([FromRoute] string pubName, [FromRoute] Guid projectId, [FromRoute] Guid appId) [Authorize]
public async Task<IActionResult> GetApp([FromRoute] string pubName, [FromRoute] Guid projectId,
[FromRoute] Guid appId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await ds.GetDeveloperByName(pubName); var developer = await ds.GetDeveloperByName(pubName);
if (developer is null) return NotFound(); if (developer is null) return NotFound();
var accountId = Guid.Parse(currentUser.Id);
if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, PublisherMemberRole.Viewer))
return StatusCode(403, "You must be a viewer of the developer to list custom apps");
var project = await projectService.GetProjectAsync(projectId, developer.Id); var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null) return NotFound(); if (project is null) return NotFound();
@@ -61,9 +96,11 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
return Unauthorized(); return Unauthorized();
var developer = await ds.GetDeveloperByName(pubName); var developer = await ds.GetDeveloperByName(pubName);
var accountId = Guid.Parse(currentUser.Id); if (developer is null)
if (developer is null || developer.Id != accountId) return NotFound("Developer not found");
return Forbid();
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to create a custom app");
var project = await projectService.GetProjectAsync(projectId, developer.Id); var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null) if (project is null)
@@ -72,9 +109,6 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Slug)) if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Slug))
return BadRequest("Name and slug are required"); return BadRequest("Name and slug are required");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to create a custom app");
try try
{ {
var app = await customApps.CreateAppAsync(projectId, request); var app = await customApps.CreateAppAsync(projectId, request);
@@ -163,4 +197,235 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
return NoContent(); return NoContent();
} }
[HttpGet("{appId:guid}/secrets")]
[Authorize]
public async Task<IActionResult> ListSecrets(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid appId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to view app secrets");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null)
return NotFound("Project not found or you don't have access");
var app = await customApps.GetAppAsync(appId, projectId);
if (app == null)
return NotFound("App not found");
var secrets = await customApps.GetAppSecretsAsync(appId);
return Ok(secrets.Select(s => new SecretResponse(
s.Id.ToString(),
null,
s.Description,
s.ExpiredAt,
s.IsOidc,
s.CreatedAt,
s.UpdatedAt
)));
}
[HttpPost("{appId:guid}/secrets")]
[Authorize]
public async Task<IActionResult> CreateSecret(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid appId,
[FromBody] CreateSecretRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to create app secrets");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null)
return NotFound("Project not found or you don't have access");
var app = await customApps.GetAppAsync(appId, projectId);
if (app == null)
return NotFound("App not found");
try
{
var secret = await customApps.CreateAppSecretAsync(new CustomAppSecret
{
AppId = appId,
Description = request.Description,
ExpiredAt = request.ExpiresIn.HasValue
? NodaTime.SystemClock.Instance.GetCurrentInstant()
.Plus(Duration.FromTimeSpan(request.ExpiresIn.Value))
: (NodaTime.Instant?)null,
IsOidc = request.IsOidc
});
return CreatedAtAction(
nameof(GetSecret),
new { pubName, projectId, appId, secretId = secret.Id },
new SecretResponse(
secret.Id.ToString(),
secret.Secret,
secret.Description,
secret.ExpiredAt,
secret.IsOidc,
secret.CreatedAt,
secret.UpdatedAt
)
);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("{appId:guid}/secrets/{secretId:guid}")]
[Authorize]
public async Task<IActionResult> GetSecret(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid appId,
[FromRoute] Guid secretId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to view app secrets");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null)
return NotFound("Project not found or you don't have access");
var app = await customApps.GetAppAsync(appId, projectId);
if (app == null)
return NotFound("App not found");
var secret = await customApps.GetAppSecretAsync(secretId, appId);
if (secret == null)
return NotFound("Secret not found");
return Ok(new SecretResponse(
secret.Id.ToString(),
null,
secret.Description,
secret.ExpiredAt,
secret.IsOidc,
secret.CreatedAt,
secret.UpdatedAt
));
}
[HttpDelete("{appId:guid}/secrets/{secretId:guid}")]
[Authorize]
public async Task<IActionResult> DeleteSecret(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid appId,
[FromRoute] Guid secretId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to delete app secrets");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null)
return NotFound("Project not found or you don't have access");
var app = await customApps.GetAppAsync(appId, projectId);
if (app == null)
return NotFound("App not found");
var secret = await customApps.GetAppSecretAsync(secretId, appId);
if (secret == null)
return NotFound("Secret not found");
var result = await customApps.DeleteAppSecretAsync(secretId, appId);
if (!result)
return NotFound("Failed to delete secret");
return NoContent();
}
[HttpPost("{appId:guid}/secrets/{secretId:guid}/rotate")]
[Authorize]
public async Task<IActionResult> RotateSecret(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid appId,
[FromRoute] Guid secretId,
[FromBody] CreateSecretRequest? request = null)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to rotate app secrets");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null)
return NotFound("Project not found or you don't have access");
var app = await customApps.GetAppAsync(appId, projectId);
if (app == null)
return NotFound("App not found");
try
{
var secret = await customApps.RotateAppSecretAsync(new CustomAppSecret
{
Id = secretId,
AppId = appId,
Description = request?.Description,
ExpiredAt = request?.ExpiresIn.HasValue == true
? NodaTime.SystemClock.Instance.GetCurrentInstant()
.Plus(Duration.FromTimeSpan(request.ExpiresIn.Value))
: (NodaTime.Instant?)null,
IsOidc = request?.IsOidc ?? false
});
return Ok(new SecretResponse(
secret.Id.ToString(),
secret.Secret,
secret.Description,
secret.ExpiredAt,
secret.IsOidc,
secret.CreatedAt,
secret.UpdatedAt
));
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
} }

View File

@@ -2,6 +2,8 @@ using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text;
namespace DysonNetwork.Develop.Identity; namespace DysonNetwork.Develop.Identity;
@@ -94,6 +96,87 @@ public class CustomAppService(
return await query.FirstOrDefaultAsync(a => a.Id == id); return await query.FirstOrDefaultAsync(a => a.Id == id);
} }
public async Task<List<CustomAppSecret>> GetAppSecretsAsync(Guid appId)
{
return await db.CustomAppSecrets
.Where(s => s.AppId == appId)
.OrderByDescending(s => s.CreatedAt)
.ToListAsync();
}
public async Task<CustomAppSecret?> GetAppSecretAsync(Guid secretId, Guid appId)
{
return await db.CustomAppSecrets
.FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId);
}
public async Task<CustomAppSecret> CreateAppSecretAsync(CustomAppSecret secret)
{
if (string.IsNullOrWhiteSpace(secret.Secret))
{
// Generate a new random secret if not provided
secret.Secret = GenerateRandomSecret();
}
secret.Id = Guid.NewGuid();
secret.CreatedAt = NodaTime.SystemClock.Instance.GetCurrentInstant();
secret.UpdatedAt = secret.CreatedAt;
db.CustomAppSecrets.Add(secret);
await db.SaveChangesAsync();
return secret;
}
public async Task<bool> DeleteAppSecretAsync(Guid secretId, Guid appId)
{
var secret = await db.CustomAppSecrets
.FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId);
if (secret == null)
return false;
db.CustomAppSecrets.Remove(secret);
await db.SaveChangesAsync();
return true;
}
public async Task<CustomAppSecret> RotateAppSecretAsync(CustomAppSecret secretUpdate)
{
var existingSecret = await db.CustomAppSecrets
.FirstOrDefaultAsync(s => s.Id == secretUpdate.Id && s.AppId == secretUpdate.AppId);
if (existingSecret == null)
throw new InvalidOperationException("Secret not found");
// Update the existing secret with new values
existingSecret.Secret = GenerateRandomSecret();
existingSecret.Description = secretUpdate.Description ?? existingSecret.Description;
existingSecret.ExpiredAt = secretUpdate.ExpiredAt ?? existingSecret.ExpiredAt;
existingSecret.IsOidc = secretUpdate.IsOidc;
existingSecret.UpdatedAt = NodaTime.SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
return existingSecret;
}
private static string GenerateRandomSecret(int length = 64)
{
const string valid = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-._~+";
var res = new StringBuilder();
using (var rng = RandomNumberGenerator.Create())
{
var uintBuffer = new byte[sizeof(uint)];
while (length-- > 0)
{
rng.GetBytes(uintBuffer);
var num = BitConverter.ToUInt32(uintBuffer, 0);
res.Append(valid[(int)(num % (uint)valid.Length)]);
}
}
return res.ToString();
}
public async Task<List<CustomApp>> GetAppsByProjectAsync(Guid projectId) public async Task<List<CustomApp>> GetAppsByProjectAsync(Guid projectId)
{ {
return await db.CustomApps return await db.CustomApps

View File

@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Develop.Project; using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
@@ -11,7 +12,7 @@ public class Developer
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid PublisherId { get; set; } public Guid PublisherId { get; set; }
public List<DevProject> Projects { get; set; } = []; [JsonIgnore] public List<DevProject> Projects { get; set; } = [];
[NotMapped] public PublisherInfo? Publisher { get; set; } [NotMapped] public PublisherInfo? Publisher { get; set; }
} }

View File

@@ -4,7 +4,10 @@ using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Develop.Identity; namespace DysonNetwork.Develop.Identity;
public class DeveloperService(AppDatabase db, PublisherService.PublisherServiceClient ps, ILogger<DeveloperService> logger) public class DeveloperService(
AppDatabase db,
PublisherService.PublisherServiceClient ps,
ILogger<DeveloperService> logger)
{ {
public async Task<Developer> LoadDeveloperPublisher(Developer developer) public async Task<Developer> LoadDeveloperPublisher(Developer developer)
{ {
@@ -47,6 +50,11 @@ public class DeveloperService(AppDatabase db, PublisherService.PublisherServiceC
} }
} }
public async Task<Developer?> GetDeveloperById(Guid id)
{
return await db.Developers.FirstOrDefaultAsync(d => d.Id == id);
}
public async Task<bool> IsMemberWithRole(Guid pubId, Guid accountId, PublisherMemberRole role) public async Task<bool> IsMemberWithRole(Guid pubId, Guid accountId, PublisherMemberRole role)
{ {
try try

View File

@@ -1,17 +1,16 @@
using DysonNetwork.Develop; using DysonNetwork.Develop;
using DysonNetwork.Shared.Auth; using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http; using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Registry;
using DysonNetwork.Develop.Startup; using DysonNetwork.Develop.Startup;
using DysonNetwork.Shared.Stream; using DysonNetwork.Shared.Registry;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.ConfigureAppKestrel(builder.Configuration); builder.ConfigureAppKestrel(builder.Configuration);
builder.Services.AddRegistryService(builder.Configuration);
builder.Services.AddStreamConnection(builder.Configuration);
builder.Services.AddAppServices(builder.Configuration); builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppAuthentication(); builder.Services.AddAppAuthentication();
builder.Services.AddAppSwagger(); builder.Services.AddAppSwagger();
@@ -22,6 +21,8 @@ builder.Services.AddDriveService();
var app = builder.Build(); var app = builder.Build();
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>();

View File

@@ -1,6 +1,7 @@
using System.Net; using System.Net;
using DysonNetwork.Develop.Identity; using DysonNetwork.Develop.Identity;
using DysonNetwork.Shared.Auth; using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Prometheus; using Prometheus;
@@ -18,7 +19,7 @@ public static class ApplicationConfiguration
app.UseRequestLocalization(); app.UseRequestLocalization();
ConfigureForwardedHeaders(app, configuration); app.ConfigureForwardedHeaders(configuration);
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
@@ -30,26 +31,4 @@ public static class ApplicationConfiguration
return app; return app;
} }
private static void ConfigureForwardedHeaders(WebApplication app, IConfiguration configuration)
{
var knownProxiesSection = configuration.GetSection("KnownProxies");
var forwardedHeadersOptions = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All };
if (knownProxiesSection.Exists())
{
var proxyAddresses = knownProxiesSection.Get<string[]>();
if (proxyAddresses != null)
foreach (var proxy in proxyAddresses)
if (IPAddress.TryParse(proxy, out var ipAddress))
forwardedHeadersOptions.KnownProxies.Add(ipAddress);
}
else
{
forwardedHeadersOptions.KnownProxies.Add(IPAddress.Any);
forwardedHeadersOptions.KnownProxies.Add(IPAddress.IPv6Any);
}
app.UseForwardedHeaders(forwardedHeadersOptions);
}
} }

View File

@@ -3,6 +3,7 @@ using Microsoft.OpenApi.Models;
using NodaTime; using NodaTime;
using NodaTime.Serialization.SystemTextJson; using NodaTime.Serialization.SystemTextJson;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Develop.Identity; using DysonNetwork.Develop.Identity;
using DysonNetwork.Develop.Project; using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
@@ -19,19 +20,16 @@ public static class ServiceCollectionExtensions
services.AddDbContext<AppDatabase>(); services.AddDbContext<AppDatabase>();
services.AddSingleton<IClock>(SystemClock.Instance); services.AddSingleton<IClock>(SystemClock.Instance);
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddSingleton<IConnectionMultiplexer>(_ =>
{
var connection = configuration.GetConnectionString("FastRetrieve")!;
return ConnectionMultiplexer.Connect(connection);
});
services.AddSingleton<ICacheService, CacheServiceRedis>(); services.AddSingleton<ICacheService, CacheServiceRedis>();
services.AddHttpClient(); services.AddHttpClient();
services.AddControllers().AddJsonOptions(options => services.AddControllers().AddJsonOptions(options =>
{ {
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower; options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
}); });

View File

@@ -10,10 +10,7 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": { "ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_network_dev;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60", "App": "Host=localhost;Port=5432;Database=dyson_network_dev;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
"FastRetrieve": "localhost:6379",
"Etcd": "etcd.orb.local:2379",
"Stream": "nats.orb.local:4222"
}, },
"KnownProxies": [ "KnownProxies": [
"127.0.0.1", "127.0.0.1",
@@ -24,8 +21,6 @@
}, },
"Service": { "Service": {
"Name": "DysonNetwork.Develop", "Name": "DysonNetwork.Develop",
"Url": "https://localhost:7099", "Url": "https://localhost:7192"
"ClientCert": "../Certificates/client.crt",
"ClientKey": "../Certificates/client.key"
} }
} }

View File

@@ -31,7 +31,6 @@ public class AppDatabase(
opt => opt opt => opt
.ConfigureDataSource(optSource => optSource.EnableDynamicJson()) .ConfigureDataSource(optSource => optSource.EnableDynamicJson())
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
.UseNetTopologySuite()
.UseNodaTime() .UseNodaTime()
).UseSnakeCaseNamingConvention(); ).UseSnakeCaseNamingConvention();

View File

@@ -35,7 +35,6 @@
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" /> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" 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="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" />
@@ -67,6 +66,7 @@
</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,403 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using DysonNetwork.Drive;
using DysonNetwork.Drive.Storage;
using DysonNetwork.Shared.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DysonNetwork.Drive.Migrations
{
[DbContext(typeof(AppDatabase))]
[Migration("20250907070034_RemoveNetTopo")]
partial class RemoveNetTopo
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text")
.HasColumnName("description");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<long>("Quota")
.HasColumnType("bigint")
.HasColumnName("quota");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_quota_records");
b.ToTable("quota_records", (string)null);
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
{
b.Property<string>("Id")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Guid?>("BundleId")
.HasColumnType("uuid")
.HasColumnName("bundle_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")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("description");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<Dictionary<string, object>>("FileMeta")
.HasColumnType("jsonb")
.HasColumnName("file_meta");
b.Property<bool>("HasCompression")
.HasColumnType("boolean")
.HasColumnName("has_compression");
b.Property<bool>("HasThumbnail")
.HasColumnType("boolean")
.HasColumnName("has_thumbnail");
b.Property<string>("Hash")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("hash");
b.Property<bool>("IsEncrypted")
.HasColumnType("boolean")
.HasColumnName("is_encrypted");
b.Property<bool>("IsMarkedRecycle")
.HasColumnType("boolean")
.HasColumnName("is_marked_recycle");
b.Property<string>("MimeType")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("mime_type");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<Guid?>("PoolId")
.HasColumnType("uuid")
.HasColumnName("pool_id");
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
.HasColumnType("jsonb")
.HasColumnName("sensitive_marks");
b.Property<long>("Size")
.HasColumnType("bigint")
.HasColumnName("size");
b.Property<string>("StorageId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("storage_id");
b.Property<string>("StorageUrl")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("storage_url");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<Instant?>("UploadedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("uploaded_at");
b.Property<Dictionary<string, object>>("UserMeta")
.HasColumnType("jsonb")
.HasColumnName("user_meta");
b.HasKey("Id")
.HasName("pk_files");
b.HasIndex("BundleId")
.HasDatabaseName("ix_files_bundle_id");
b.HasIndex("PoolId")
.HasDatabaseName("ix_files_pool_id");
b.ToTable("files", (string)null);
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", 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?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("FileId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("file_id");
b.Property<string>("ResourceId")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("resource_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("Usage")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("usage");
b.HasKey("Id")
.HasName("pk_file_references");
b.HasIndex("FileId")
.HasDatabaseName("ix_file_references_file_id");
b.ToTable("file_references", (string)null);
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("description");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<string>("Passcode")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("passcode");
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.HasKey("Id")
.HasName("pk_bundles");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_bundles_slug");
b.ToTable("bundles", (string)null);
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid?>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<BillingConfig>("BillingConfig")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("billing_config");
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(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("description");
b.Property<bool>("IsHidden")
.HasColumnType("boolean")
.HasColumnName("is_hidden");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<PolicyConfig>("PolicyConfig")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("policy_config");
b.Property<RemoteStorageConfig>("StorageConfig")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("storage_config");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_pools");
b.ToTable("pools", (string)null);
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
{
b.HasOne("DysonNetwork.Drive.Storage.FileBundle", "Bundle")
.WithMany("Files")
.HasForeignKey("BundleId")
.HasConstraintName("fk_files_bundles_bundle_id");
b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool")
.WithMany()
.HasForeignKey("PoolId")
.HasConstraintName("fk_files_pools_pool_id");
b.Navigation("Bundle");
b.Navigation("Pool");
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
{
b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
.WithMany("References")
.HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_references_files_file_id");
b.Navigation("File");
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
{
b.Navigation("References");
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
{
b.Navigation("Files");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Drive.Migrations
{
/// <inheritdoc />
public partial class RemoveNetTopo : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.OldAnnotation("Npgsql:PostgresExtension:postgis", ",,");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:PostgresExtension:postgis", ",,");
}
}
}

View File

@@ -24,7 +24,6 @@ namespace DysonNetwork.Drive.Migrations
.HasAnnotation("ProductVersion", "9.0.7") .HasAnnotation("ProductVersion", "9.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b => modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b =>

View File

@@ -5,18 +5,18 @@ using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http; using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.PageData; using DysonNetwork.Shared.PageData;
using DysonNetwork.Shared.Registry; using DysonNetwork.Shared.Registry;
using DysonNetwork.Shared.Stream;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using tusdotnet.Stores; using tusdotnet.Stores;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
// Configure Kestrel and server options // Configure Kestrel and server options
builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxValue); builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxValue);
// Add application services // Add application services
builder.Services.AddRegistryService(builder.Configuration);
builder.Services.AddStreamConnection(builder.Configuration);
builder.Services.AddAppServices(builder.Configuration); builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppRateLimiting(); builder.Services.AddAppRateLimiting();
builder.Services.AddAppAuthentication(); builder.Services.AddAppAuthentication();
@@ -39,6 +39,8 @@ builder.Services.AddTransient<IPageDataProvider, VersionPageData>();
var app = builder.Build(); var app = builder.Build();
app.MapDefaultEndpoints();
// Run database migrations // Run database migrations
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
@@ -51,8 +53,6 @@ var tusDiskStore = app.Services.GetRequiredService<TusDiskStore>();
// Configure application middleware pipeline // Configure application middleware pipeline
app.ConfigureAppMiddleware(tusDiskStore, builder.Environment.ContentRootPath); app.ConfigureAppMiddleware(tusDiskStore, builder.Environment.ContentRootPath);
app.MapGatewayProxy();
app.MapPages(Path.Combine(app.Environment.WebRootPath, "dist", "index.html")); app.MapPages(Path.Combine(app.Environment.WebRootPath, "dist", "index.html"));
// Configure gRPC // Configure gRPC

View File

@@ -3,6 +3,8 @@ using DysonNetwork.Drive.Storage;
using DysonNetwork.Shared.Stream; using DysonNetwork.Shared.Stream;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NATS.Client.Core; using NATS.Client.Core;
using NATS.Client.JetStream.Models;
using NATS.Net;
namespace DysonNetwork.Drive.Startup; namespace DysonNetwork.Drive.Startup;
@@ -14,12 +16,23 @@ public class BroadcastEventHandler(
{ {
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
await foreach (var msg in nats.SubscribeAsync<byte[]>("accounts.deleted", cancellationToken: stoppingToken)) var js = nats.CreateJetStreamContext();
await js.EnsureStreamCreated("account_events", [AccountDeletedEvent.Type]);
var consumer = await js.CreateOrUpdateConsumerAsync("account_events",
new ConsumerConfig("drive_account_deleted_handler"), cancellationToken: stoppingToken);
await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken))
{ {
try try
{ {
var evt = JsonSerializer.Deserialize<AccountDeletedEvent>(msg.Data); var evt = JsonSerializer.Deserialize<AccountDeletedEvent>(msg.Data);
if (evt == null) continue; if (evt == null)
{
await msg.AckAsync(cancellationToken: stoppingToken);
continue;
}
logger.LogInformation("Account deleted: {AccountId}", evt.AccountId); logger.LogInformation("Account deleted: {AccountId}", evt.AccountId);
@@ -46,10 +59,13 @@ public class BroadcastEventHandler(
await transaction.RollbackAsync(cancellationToken: stoppingToken); await transaction.RollbackAsync(cancellationToken: stoppingToken);
throw; throw;
} }
await msg.AckAsync(cancellationToken: stoppingToken);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error processing AccountDeleted"); logger.LogError(ex, "Error processing AccountDeleted");
await msg.NakAsync(cancellationToken: stoppingToken);
} }
} }
} }

View File

@@ -1,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
@@ -16,11 +17,6 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
{ {
services.AddDbContext<AppDatabase>(); // Assuming you'll have an AppDatabase services.AddDbContext<AppDatabase>(); // Assuming you'll have an AppDatabase
services.AddSingleton<IConnectionMultiplexer>(_ =>
{
var connection = configuration.GetConnectionString("FastRetrieve")!;
return ConnectionMultiplexer.Connect(connection);
});
services.AddSingleton<IClock>(SystemClock.Instance); services.AddSingleton<IClock>(SystemClock.Instance);
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddSingleton<ICacheService, CacheServiceRedis>(); // Uncomment if you have CacheServiceRedis services.AddSingleton<ICacheService, CacheServiceRedis>(); // Uncomment if you have CacheServiceRedis
@@ -40,6 +36,7 @@ public static class ServiceCollectionExtensions
services.AddControllers().AddJsonOptions(options => services.AddControllers().AddJsonOptions(options =>
{ {
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower; options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;

View File

@@ -337,8 +337,14 @@ public class FileService(
if (!pool.PolicyConfig.NoOptimization) if (!pool.PolicyConfig.NoOptimization)
switch (contentType.Split('/')[0]) switch (contentType.Split('/')[0])
{ {
case "image" when !AnimatedImageTypes.Contains(contentType) && case "image":
!AnimatedImageExtensions.Contains(fileExtension): if (AnimatedImageTypes.Contains(contentType) || AnimatedImageExtensions.Contains(fileExtension))
{
logger.LogInformation("Skip optimize file {FileId} due to it is animated...", fileId);
uploads.Add((originalFilePath, string.Empty, contentType, false));
break;
}
newMimeType = "image/webp"; newMimeType = "image/webp";
using (var vipsImage = Image.NewFromFile(originalFilePath)) using (var vipsImage = Image.NewFromFile(originalFilePath))
{ {

View File

@@ -10,10 +10,7 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": { "ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60", "App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
"FastRetrieve": "localhost:6379",
"Etcd": "etcd.orb.local:2379",
"Stream": "nats.orb.local:4222"
}, },
"Authentication": { "Authentication": {
"Schemes": { "Schemes": {
@@ -131,8 +128,6 @@
], ],
"Service": { "Service": {
"Name": "DysonNetwork.Drive", "Name": "DysonNetwork.Drive",
"Url": "https://localhost:7092", "Url": "https://localhost:7092"
"ClientCert": "../Certificates/client.crt",
"ClientKey": "../Certificates/client.key"
} }
} }

View File

@@ -1,78 +0,0 @@
using System.Text;
using dotnet_etcd.interfaces;
using Microsoft.AspNetCore.Mvc;
using Yarp.ReverseProxy.Configuration;
namespace DysonNetwork.Gateway.Controllers;
[ApiController]
[Route("/.well-known")]
public class WellKnownController(
IConfiguration configuration,
IProxyConfigProvider proxyConfigProvider,
IEtcdClient etcdClient)
: ControllerBase
{
[HttpGet("domains")]
public IActionResult GetDomainMappings()
{
var domainMappings = configuration.GetSection("DomainMappings").GetChildren()
.ToDictionary(x => x.Key, x => x.Value);
return Ok(domainMappings);
}
[HttpGet("services")]
public IActionResult GetServices()
{
var local = configuration.GetValue<bool>("LocalMode");
var response = etcdClient.GetRange("/services/");
var kvs = response.Kvs;
var serviceMap = kvs.ToDictionary(
kv => Encoding.UTF8.GetString(kv.Key.ToByteArray()).Replace("/services/", ""),
kv => Encoding.UTF8.GetString(kv.Value.ToByteArray())
);
if (local) return Ok(serviceMap);
var domainMappings = configuration.GetSection("DomainMappings").GetChildren()
.ToDictionary(x => x.Key, x => x.Value);
foreach (var (key, _) in serviceMap.ToList())
{
if (!domainMappings.TryGetValue(key, out var domain)) continue;
if (domain is not null)
serviceMap[key] = "https://" + domain;
}
return Ok(serviceMap);
}
[HttpGet("routes")]
public IActionResult GetProxyRules()
{
var config = proxyConfigProvider.GetConfig();
var rules = config.Routes.Select(r => new
{
r.RouteId,
r.ClusterId,
Match = new
{
r.Match.Path,
Hosts = r.Match.Hosts != null ? string.Join(", ", r.Match.Hosts) : null
},
Transforms = r.Transforms?.Select(t => t.Select(kv => $"{kv.Key}: {kv.Value}").ToList())
}).ToList();
var clusters = config.Clusters.Select(c => new
{
c.ClusterId,
Destinations = c.Destinations?.Select(d => new
{
d.Key,
d.Value.Address
}).ToList()
}).ToList();
return Ok(new { Rules = rules, Clusters = clusters });
}
}

View File

@@ -1,23 +0,0 @@
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["DysonNetwork.Gateway/DysonNetwork.Gateway.csproj", "DysonNetwork.Gateway/"]
RUN dotnet restore "DysonNetwork.Gateway/DysonNetwork.Gateway.csproj"
COPY . .
WORKDIR "/src/DysonNetwork.Gateway"
RUN dotnet build "./DysonNetwork.Gateway.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./DysonNetwork.Gateway.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "DysonNetwork.Gateway.dll"]

View File

@@ -1,23 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="dotnet-etcd" Version="8.0.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" />
<PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,38 +0,0 @@
using DysonNetwork.Gateway.Startup;
using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseContentRoot(Directory.GetCurrentDirectory());
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = long.MaxValue;
options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2);
options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30);
});
// Add services to the container.
builder.Services.AddGateway(builder.Configuration);
builder.Services.AddControllers();
var app = builder.Build();
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
app.UseRequestTimeouts();
app.UseCors(opts =>
opts.SetIsOriginAllowed(_ => true)
.WithExposedHeaders("*")
.WithHeaders("*")
.AllowCredentials()
.AllowAnyHeader()
.AllowAnyMethod()
);
app.MapControllers();
app.MapReverseProxy();
app.Run();

View File

@@ -1,23 +0,0 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5094",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7034;http://0.0.0.0:5094",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -1,259 +0,0 @@
using System.Text;
using dotnet_etcd.interfaces;
using Yarp.ReverseProxy.Configuration;
using Yarp.ReverseProxy.Forwarder;
namespace DysonNetwork.Gateway;
public class RegistryProxyConfigProvider : IProxyConfigProvider, IDisposable
{
private readonly object _lock = new();
private readonly IEtcdClient _etcdClient;
private readonly IConfiguration _configuration;
private readonly ILogger<RegistryProxyConfigProvider> _logger;
private readonly CancellationTokenSource _watchCts = new();
private CancellationTokenSource _cts;
private IProxyConfig _config;
public RegistryProxyConfigProvider(
IEtcdClient etcdClient,
IConfiguration configuration,
ILogger<RegistryProxyConfigProvider> logger
)
{
_etcdClient = etcdClient;
_configuration = configuration;
_logger = logger;
_cts = new CancellationTokenSource();
_config = LoadConfig();
// Watch for changes in etcd
_etcdClient.WatchRange("/services/", _ =>
{
_logger.LogInformation("Etcd configuration changed. Reloading proxy config.");
ReloadConfig();
}, cancellationToken: _watchCts.Token);
}
public IProxyConfig GetConfig() => _config;
private void ReloadConfig()
{
lock (_lock)
{
var oldCts = _cts;
_cts = new CancellationTokenSource();
_config = LoadConfig();
oldCts.Cancel();
oldCts.Dispose();
}
}
private IProxyConfig LoadConfig()
{
_logger.LogInformation("Generating new proxy config.");
var response = _etcdClient.GetRange("/services/");
var kvs = response.Kvs;
var serviceMap = kvs.ToDictionary(
kv => Encoding.UTF8.GetString(kv.Key.ToByteArray()).Replace("/services/", ""),
kv => Encoding.UTF8.GetString(kv.Value.ToByteArray())
);
var clusters = new List<ClusterConfig>();
var routes = new List<RouteConfig>();
var domainMappings = _configuration.GetSection("DomainMappings").GetChildren()
.ToDictionary(x => x.Key, x => x.Value);
var pathAliases = _configuration.GetSection("PathAliases").GetChildren()
.ToDictionary(x => x.Key, x => x.Value);
var directRoutes = _configuration.GetSection("DirectRoutes").Get<List<DirectRouteConfig>>() ??
[];
_logger.LogInformation("Indexing {ServiceCount} services from Etcd.", kvs.Count);
var gatewayServiceName = _configuration["Service:Name"];
// Add direct route for /cgi to Gateway
var gatewayCluster = new ClusterConfig
{
ClusterId = "gateway-self",
Destinations = new Dictionary<string, DestinationConfig>
{
{ "self", new DestinationConfig { Address = _configuration["Kestrel:Endpoints:Http:Url"] ?? "http://localhost:5000" } }
}
};
clusters.Add(gatewayCluster);
var cgiRoute = new RouteConfig
{
RouteId = "gateway-cgi-route",
ClusterId = "gateway-self",
Match = new RouteMatch { Path = "/cgi/{**catch-all}" }
};
routes.Add(cgiRoute);
_logger.LogInformation(" Added CGI Route: /cgi/** -> Gateway");
// Add direct routes
foreach (var directRoute in directRoutes)
{
if (serviceMap.TryGetValue(directRoute.Service, out var serviceUrl))
{
var existingCluster = clusters.FirstOrDefault(c => c.ClusterId == directRoute.Service);
if (existingCluster is null)
{
var cluster = new ClusterConfig
{
ClusterId = directRoute.Service,
Destinations = new Dictionary<string, DestinationConfig>
{
{ "destination1", new DestinationConfig { Address = serviceUrl } }
},
};
clusters.Add(cluster);
}
var route = new RouteConfig
{
RouteId = $"direct-{directRoute.Service}-{directRoute.Path.Replace("/", "-")}",
ClusterId = directRoute.Service,
Match = new RouteMatch { Path = directRoute.Path },
};
routes.Add(route);
_logger.LogInformation(" Added Direct Route: {Path} -> {Service}", directRoute.Path,
directRoute.Service);
}
else
{
_logger.LogWarning(" Direct route service {Service} not found in Etcd.", directRoute.Service);
}
}
foreach (var serviceName in serviceMap.Keys)
{
if (serviceName == gatewayServiceName)
{
_logger.LogInformation("Skipping gateway service: {ServiceName}", serviceName);
continue;
}
var serviceUrl = serviceMap[serviceName];
// Determine the path alias
string? pathAlias;
pathAlias = pathAliases.TryGetValue(serviceName, out var alias)
? alias
: serviceName.Split('.').Last().ToLowerInvariant();
_logger.LogInformation(" Service: {ServiceName}, URL: {ServiceUrl}, Path Alias: {PathAlias}", serviceName,
serviceUrl, pathAlias);
// Check if the cluster already exists
var existingCluster = clusters.FirstOrDefault(c => c.ClusterId == serviceName);
if (existingCluster == null)
{
var cluster = new ClusterConfig
{
ClusterId = serviceName,
Destinations = new Dictionary<string, DestinationConfig>
{
{ "destination1", new DestinationConfig { Address = serviceUrl } }
}
};
clusters.Add(cluster);
_logger.LogInformation(" Added Cluster: {ServiceName}", serviceName);
}
else if (existingCluster.Destinations is not null)
{
// Create a new cluster with merged destinations
var newDestinations = new Dictionary<string, DestinationConfig>(existingCluster.Destinations)
{
{
$"destination{existingCluster.Destinations.Count + 1}",
new DestinationConfig { Address = serviceUrl }
}
};
var mergedCluster = new ClusterConfig
{
ClusterId = serviceName,
Destinations = newDestinations
};
// Replace the existing cluster with the merged one
var index = clusters.IndexOf(existingCluster);
clusters[index] = mergedCluster;
_logger.LogInformation(" Updated Cluster {ServiceName} with {DestinationCount} destinations",
serviceName, mergedCluster.Destinations.Count);
}
// Host-based routing
if (domainMappings.TryGetValue(serviceName, out var domain))
{
var hostRoute = new RouteConfig
{
RouteId = $"{serviceName}-host",
ClusterId = serviceName,
Match = new RouteMatch
{
Hosts = [domain],
Path = "/{**catch-all}"
}
};
routes.Add(hostRoute);
_logger.LogInformation(" Added Host-based Route: {Host}", domain);
}
// Path-based routing
var pathRoute = new RouteConfig
{
RouteId = $"{serviceName}-path",
ClusterId = serviceName,
Match = new RouteMatch { Path = $"/{pathAlias}/{{**catch-all}}" },
Transforms = new List<Dictionary<string, string>>
{
new() { { "PathRemovePrefix", $"/{pathAlias}" } },
new() { { "PathPrefix", "/api" } }
},
Timeout = TimeSpan.FromSeconds(5)
};
routes.Add(pathRoute);
_logger.LogInformation(" Added Path-based Route: {Path}", pathRoute.Match.Path);
}
return new CustomProxyConfig(
routes,
clusters,
new Microsoft.Extensions.Primitives.CancellationChangeToken(_cts.Token)
);
}
private class CustomProxyConfig(
IReadOnlyList<RouteConfig> routes,
IReadOnlyList<ClusterConfig> clusters,
Microsoft.Extensions.Primitives.IChangeToken changeToken
)
: IProxyConfig
{
public IReadOnlyList<RouteConfig> Routes { get; } = routes;
public IReadOnlyList<ClusterConfig> Clusters { get; } = clusters;
public Microsoft.Extensions.Primitives.IChangeToken ChangeToken { get; } = changeToken;
}
public record DirectRouteConfig
{
public required string Path { get; set; }
public required string Service { get; set; }
}
public virtual void Dispose()
{
_cts.Cancel();
_cts.Dispose();
_watchCts.Cancel();
_watchCts.Dispose();
}
}

View File

@@ -1,35 +0,0 @@
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using DysonNetwork.Shared.Registry;
using Yarp.ReverseProxy.Configuration;
using Yarp.ReverseProxy.Transforms;
namespace DysonNetwork.Gateway.Startup;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddGateway(this IServiceCollection services, IConfiguration configuration)
{
services.AddRequestTimeouts();
services
.AddReverseProxy()
.ConfigureHttpClient((context, handler) =>
{
// var caCert = X509CertificateLoader.LoadCertificateFromFile(configuration["CaCert"]!);
handler.SslOptions = new SslClientAuthenticationOptions
{
RemoteCertificateValidationCallback = (sender, cert, chain, errors) => true
};
})
.AddTransforms(context =>
{
context.AddForwarded();
});
services.AddRegistryService(configuration, addForwarder: false);
services.AddSingleton<IProxyConfigProvider, RegistryProxyConfigProvider>();
return services;
}
}

View File

@@ -1,20 +0,0 @@
using DysonNetwork.Shared.Data;
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Gateway;
[ApiController]
[Route("/api/version")]
public class VersionController : ControllerBase
{
[HttpGet]
public IActionResult Get()
{
return Ok(new AppVersion
{
Version = ThisAssembly.AssemblyVersion,
Commit = ThisAssembly.GitCommitId,
UpdateDate = ThisAssembly.GitCommitDate
});
}
}

View File

@@ -1,49 +0,0 @@
{
"LocalMode": true,
"CaCert": "../Certificates/ca.crt",
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"Etcd": "etcd.orb.local:2379"
},
"Etcd": {
"Insecure": true
},
"Service": {
"Name": "DysonNetwork.Gateway",
"Url": "https://localhost:7034"
},
"DomainMappings": {
"DysonNetwork.Pass": "id.solsynth.dev",
"DysonNetwork.Drive": "drive.solsynth.dev",
"DysonNetwork.Pusher": "push.solsynth.dev",
"DysonNetwork.Sphere": "sphere.solsynth.dev"
},
"PathAliases": {
"DysonNetwork.Pass": "id",
"DysonNetwork.Drive": "drive"
},
"DirectRoutes": [
{
"Path": "/ws",
"Service": "DysonNetwork.Pusher"
},
{
"Path": "/api/tus",
"Service": "DysonNetwork.Drive"
},
{
"Path": "/.well-known/openid-configuration",
"Service": "DysonNetwork.Pass"
},
{
"Path": "/.well-known/jwks",
"Service": "DysonNetwork.Pass"
}
]
}

View File

@@ -1,7 +0,0 @@
{
"version": "1.0",
"publicReleaseRefSpec": ["^refs/heads/main$"],
"cloudBuild": {
"setVersionVariables": true
}
}

View File

@@ -18,6 +18,7 @@ public class Account : ModelBase
[MaxLength(256)] public string Name { get; set; } = string.Empty; [MaxLength(256)] public string Name { get; set; } = string.Empty;
[MaxLength(256)] public string Nick { get; set; } = string.Empty; [MaxLength(256)] public string Nick { get; set; } = string.Empty;
[MaxLength(32)] public string Language { get; set; } = string.Empty; [MaxLength(32)] public string Language { get; set; } = string.Empty;
[MaxLength(32)] public string Region { get; set; } = string.Empty;
public Instant? ActivatedAt { get; set; } public Instant? ActivatedAt { get; set; }
public bool IsSuperuser { get; set; } = false; public bool IsSuperuser { get; set; } = false;
@@ -46,12 +47,14 @@ public class Account : ModelBase
Name = Name, Name = Name,
Nick = Nick, Nick = Nick,
Language = Language, Language = Language,
Region = Region,
ActivatedAt = ActivatedAt?.ToTimestamp(), ActivatedAt = ActivatedAt?.ToTimestamp(),
IsSuperuser = IsSuperuser, IsSuperuser = IsSuperuser,
Profile = Profile.ToProtoValue(), Profile = Profile.ToProtoValue(),
PerkSubscription = PerkSubscription?.ToProtoValue(), PerkSubscription = PerkSubscription?.ToProtoValue(),
CreatedAt = CreatedAt.ToTimestamp(), CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp() UpdatedAt = UpdatedAt.ToTimestamp(),
AutomatedId = AutomatedId?.ToString()
}; };
// Add contacts // Add contacts
@@ -74,6 +77,7 @@ public class Account : ModelBase
Name = proto.Name, Name = proto.Name,
Nick = proto.Nick, Nick = proto.Nick,
Language = proto.Language, Language = proto.Language,
Region = proto.Region,
ActivatedAt = proto.ActivatedAt?.ToInstant(), ActivatedAt = proto.ActivatedAt?.ToInstant(),
IsSuperuser = proto.IsSuperuser, IsSuperuser = proto.IsSuperuser,
PerkSubscription = proto.PerkSubscription is not null PerkSubscription = proto.PerkSubscription is not null
@@ -81,10 +85,10 @@ public class Account : ModelBase
: null, : null,
CreatedAt = proto.CreatedAt.ToInstant(), CreatedAt = proto.CreatedAt.ToInstant(),
UpdatedAt = proto.UpdatedAt.ToInstant(), UpdatedAt = proto.UpdatedAt.ToInstant(),
AutomatedId = proto.AutomatedId is not null ? Guid.Parse(proto.AutomatedId) : null,
Profile = AccountProfile.FromProtoValue(proto.Profile)
}; };
account.Profile = AccountProfile.FromProtoValue(proto.Profile);
foreach (var contactProto in proto.Contacts) foreach (var contactProto in proto.Contacts)
account.Contacts.Add(AccountContact.FromProtoValue(contactProto)); account.Contacts.Add(AccountContact.FromProtoValue(contactProto));
@@ -119,7 +123,7 @@ public abstract class Leveling
public class AccountProfile : ModelBase, IIdentifiedResource public class AccountProfile : ModelBase, IIdentifiedResource
{ {
public Guid Id { get; set; } public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(256)] public string? FirstName { get; set; } [MaxLength(256)] public string? FirstName { get; set; }
[MaxLength(256)] public string? MiddleName { get; set; } [MaxLength(256)] public string? MiddleName { get; set; }
[MaxLength(256)] public string? LastName { get; set; } [MaxLength(256)] public string? LastName { get; set; }
@@ -135,9 +139,20 @@ public class AccountProfile : ModelBase, IIdentifiedResource
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; } [Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
[Column(TypeName = "jsonb")] public BadgeReferenceObject? ActiveBadge { get; set; } [Column(TypeName = "jsonb")] public BadgeReferenceObject? ActiveBadge { get; set; }
public int Experience { get; set; } = 0; public int Experience { get; set; }
[NotMapped] public int Level => Leveling.ExperiencePerLevel.Count(xp => Experience >= xp) - 1; [NotMapped] public int Level => Leveling.ExperiencePerLevel.Count(xp => Experience >= xp) - 1;
public double SocialCredits { get; set; } = 100;
[NotMapped]
public int SocialCreditsLevel => SocialCredits switch
{
< 100 => -1,
> 100 and < 200 => 0,
< 200 => 1,
_ => 2
};
[NotMapped] [NotMapped]
public double LevelingProgress => Level >= Leveling.ExperiencePerLevel.Count - 1 public double LevelingProgress => Level >= Leveling.ExperiencePerLevel.Count - 1
? 100 ? 100
@@ -168,6 +183,8 @@ public class AccountProfile : ModelBase, IIdentifiedResource
Experience = Experience, Experience = Experience,
Level = Level, Level = Level,
LevelingProgress = LevelingProgress, LevelingProgress = LevelingProgress,
SocialCredits = SocialCredits,
SocialCreditsLevel = SocialCreditsLevel,
Picture = Picture?.ToProtoValue(), Picture = Picture?.ToProtoValue(),
Background = Background?.ToProtoValue(), Background = Background?.ToProtoValue(),
AccountId = AccountId.ToString(), AccountId = AccountId.ToString(),
@@ -198,6 +215,7 @@ public class AccountProfile : ModelBase, IIdentifiedResource
Verification = proto.Verification is null ? null : VerificationMark.FromProtoValue(proto.Verification), Verification = proto.Verification is null ? null : VerificationMark.FromProtoValue(proto.Verification),
ActiveBadge = proto.ActiveBadge is null ? null : BadgeReferenceObject.FromProtoValue(proto.ActiveBadge), ActiveBadge = proto.ActiveBadge is null ? null : BadgeReferenceObject.FromProtoValue(proto.ActiveBadge),
Experience = proto.Experience, Experience = proto.Experience,
SocialCredits = proto.SocialCredits,
Picture = proto.Picture is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Picture), Picture = proto.Picture is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Picture),
Background = proto.Background is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Background), Background = proto.Background is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Background),
AccountId = Guid.Parse(proto.AccountId), AccountId = Guid.Parse(proto.AccountId),

View File

@@ -3,6 +3,7 @@ using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Credit; using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Wallet; using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Error; using DysonNetwork.Shared.Error;
using DysonNetwork.Shared.GeoIp;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
@@ -17,7 +18,8 @@ public class AccountController(
AccountService accounts, AccountService accounts,
SubscriptionService subscriptions, SubscriptionService subscriptions,
AccountEventService events, AccountEventService events,
SocialCreditService socialCreditService SocialCreditService socialCreditService,
GeoIpService geo
) : ControllerBase ) : ControllerBase
{ {
[HttpGet("{name}")] [HttpGet("{name}")]
@@ -48,7 +50,9 @@ public class AccountController(
.Include(e => e.Badges) .Include(e => e.Badges)
.Where(a => a.Name == name) .Where(a => a.Name == name)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
return account is null ? NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier)) : account.Badges.ToList(); return account is null
? NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier))
: account.Badges.ToList();
} }
[HttpGet("{name}/credits")] [HttpGet("{name}/credits")]
@@ -93,7 +97,7 @@ public class AccountController(
[MaxLength(128)] [MaxLength(128)]
public string Password { get; set; } = string.Empty; public string Password { get; set; } = string.Empty;
[MaxLength(128)] public string Language { get; set; } = "en-us"; [MaxLength(32)] public string Language { get; set; } = "en-us";
[Required] public string CaptchaToken { get; set; } = string.Empty; [Required] public string CaptchaToken { get; set; } = string.Empty;
} }
@@ -109,6 +113,10 @@ public class AccountController(
[nameof(request.CaptchaToken)] = ["Invalid captcha token."] [nameof(request.CaptchaToken)] = ["Invalid captcha token."]
}, traceId: HttpContext.TraceIdentifier)); }, traceId: HttpContext.TraceIdentifier));
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
if (ip is null) return BadRequest(ApiError.NotFound(request.Name, traceId: HttpContext.TraceIdentifier));
var region = geo.GetFromIp(ip)?.Country.IsoCode ?? "us";
try try
{ {
var account = await accounts.CreateAccount( var account = await accounts.CreateAccount(
@@ -116,7 +124,8 @@ public class AccountController(
request.Nick, request.Nick,
request.Email, request.Email,
request.Password, request.Password,
request.Language request.Language,
region
); );
return Ok(account); return Ok(account);
} }
@@ -182,7 +191,9 @@ public class AccountController(
public StatusAttitude Attitude { get; set; } public StatusAttitude Attitude { get; set; }
public bool IsInvisible { get; set; } public bool IsInvisible { get; set; }
public bool IsNotDisturb { get; set; } public bool IsNotDisturb { get; set; }
public bool IsAutomated { get; set; } = false;
[MaxLength(1024)] public string? Label { get; set; } [MaxLength(1024)] public string? Label { get; set; }
[MaxLength(4096)] public string? AppIdentifier { get; set; }
public Instant? ClearedAt { get; set; } public Instant? ClearedAt { get; set; }
} }

View File

@@ -24,11 +24,13 @@ public class AccountCurrentController(
AccountEventService events, AccountEventService events,
AuthService auth, AuthService auth,
FileService.FileServiceClient files, FileService.FileServiceClient files,
FileReferenceService.FileReferenceServiceClient fileRefs FileReferenceService.FileReferenceServiceClient fileRefs,
Credit.SocialCreditService creditService
) : ControllerBase ) : ControllerBase
{ {
[HttpGet] [HttpGet]
[ProducesResponseType<Account>(StatusCodes.Status200OK)] [ProducesResponseType<Account>(StatusCodes.Status200OK)]
[ProducesResponseType<ApiError>(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<Account>> GetCurrentIdentity() public async Task<ActionResult<Account>> GetCurrentIdentity()
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
@@ -50,6 +52,7 @@ public class AccountCurrentController(
{ {
[MaxLength(256)] public string? Nick { get; set; } [MaxLength(256)] public string? Nick { get; set; }
[MaxLength(32)] public string? Language { get; set; } [MaxLength(32)] public string? Language { get; set; }
[MaxLength(32)] public string? Region { get; set; }
} }
[HttpPatch] [HttpPatch]
@@ -61,6 +64,7 @@ public class AccountCurrentController(
if (request.Nick is not null) account.Nick = request.Nick; if (request.Nick is not null) account.Nick = request.Nick;
if (request.Language is not null) account.Language = request.Language; if (request.Language is not null) account.Language = request.Language;
if (request.Region is not null) account.Region = request.Region;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await accounts.PurgeAccountCache(currentUser); await accounts.PurgeAccountCache(currentUser);
@@ -193,6 +197,8 @@ public class AccountCurrentController(
public async Task<ActionResult<Status>> UpdateStatus([FromBody] AccountController.StatusRequest request) public async Task<ActionResult<Status>> UpdateStatus([FromBody] AccountController.StatusRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (request is { IsAutomated: true, AppIdentifier: not null })
return BadRequest("Automated status cannot be updated.");
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
var status = await db.AccountStatuses var status = await db.AccountStatuses
@@ -201,11 +207,15 @@ public class AccountCurrentController(
.OrderByDescending(e => e.CreatedAt) .OrderByDescending(e => e.CreatedAt)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (status is null) return NotFound(ApiError.NotFound("status", traceId: HttpContext.TraceIdentifier)); if (status is null) return NotFound(ApiError.NotFound("status", traceId: HttpContext.TraceIdentifier));
if (status.IsAutomated && request.AppIdentifier is null)
return BadRequest("Automated status cannot be updated.");
status.Attitude = request.Attitude; status.Attitude = request.Attitude;
status.IsInvisible = request.IsInvisible; status.IsInvisible = request.IsInvisible;
status.IsNotDisturb = request.IsNotDisturb; status.IsNotDisturb = request.IsNotDisturb;
status.IsAutomated = request.IsAutomated;
status.Label = request.Label; status.Label = request.Label;
status.AppIdentifier = request.AppIdentifier;
status.ClearedAt = request.ClearedAt; status.ClearedAt = request.ClearedAt;
db.Update(status); db.Update(status);
@@ -221,13 +231,44 @@ public class AccountCurrentController(
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (request is { IsAutomated: true, AppIdentifier: not null })
{
var now = SystemClock.Instance.GetCurrentInstant();
var existingStatus = await db.AccountStatuses
.Where(s => s.AccountId == currentUser.Id)
.Where(s => s.ClearedAt == null || s.ClearedAt > now)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync();
if (existingStatus is not null && existingStatus.IsAutomated)
if (existingStatus.IsAutomated && request.AppIdentifier == existingStatus.AppIdentifier)
{
existingStatus.Attitude = request.Attitude;
existingStatus.IsInvisible = request.IsInvisible;
existingStatus.IsNotDisturb = request.IsNotDisturb;
existingStatus.Label = request.Label;
db.Update(existingStatus);
await db.SaveChangesAsync();
return Ok(existingStatus);
}
else
{
existingStatus.ClearedAt = now;
db.Update(existingStatus);
await db.SaveChangesAsync();
}
else if (existingStatus is not null)
return Ok(existingStatus); // Do not override manually set status with automated ones
}
var status = new Status var status = new Status
{ {
AccountId = currentUser.Id, AccountId = currentUser.Id,
Attitude = request.Attitude, Attitude = request.Attitude,
IsInvisible = request.IsInvisible, IsInvisible = request.IsInvisible,
IsNotDisturb = request.IsNotDisturb, IsNotDisturb = request.IsNotDisturb,
IsAutomated = request.IsAutomated,
Label = request.Label, Label = request.Label,
AppIdentifier = request.AppIdentifier,
ClearedAt = request.ClearedAt ClearedAt = request.ClearedAt
}; };
@@ -235,15 +276,21 @@ public class AccountCurrentController(
} }
[HttpDelete("statuses")] [HttpDelete("statuses")]
public async Task<ActionResult> DeleteStatus() public async Task<ActionResult> DeleteStatus([FromQuery] string? app)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
var status = await db.AccountStatuses var queryable = db.AccountStatuses
.Where(s => s.AccountId == currentUser.Id) .Where(s => s.AccountId == currentUser.Id)
.Where(s => s.ClearedAt == null || s.ClearedAt > now) .Where(s => s.ClearedAt == null || s.ClearedAt > now)
.OrderByDescending(s => s.CreatedAt) .OrderByDescending(s => s.CreatedAt)
.AsQueryable();
if (string.IsNullOrWhiteSpace(app))
queryable = queryable.Where(s => s.IsAutomated && s.AppIdentifier == app);
var status = await queryable
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (status is null) return NotFound(); if (status is null) return NotFound();
@@ -268,7 +315,9 @@ public class AccountCurrentController(
.OrderByDescending(x => x.CreatedAt) .OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
return result is null ? NotFound(ApiError.NotFound("check-in", traceId: HttpContext.TraceIdentifier)) : Ok(result); return result is null
? NotFound(ApiError.NotFound("check-in", traceId: HttpContext.TraceIdentifier))
: Ok(result);
} }
[HttpPost("check-in")] [HttpPost("check-in")]
@@ -323,7 +372,8 @@ public class AccountCurrentController(
TraceId = HttpContext.TraceIdentifier TraceId = HttpContext.TraceIdentifier
} }
), ),
true when !await auth.ValidateCaptcha(captchaToken!) => BadRequest(ApiError.Validation(new Dictionary<string, string[]> true when !await auth.ValidateCaptcha(captchaToken!) => BadRequest(ApiError.Validation(
new Dictionary<string, string[]>
{ {
["captchaToken"] = new[] { "Invalid captcha token." } ["captchaToken"] = new[] { "Invalid captcha token." }
}, traceId: HttpContext.TraceIdentifier)), }, traceId: HttpContext.TraceIdentifier)),
@@ -823,4 +873,60 @@ public class AccountCurrentController(
return BadRequest(ex.Message); return BadRequest(ex.Message);
} }
} }
[HttpGet("leveling")]
[Authorize]
public async Task<ActionResult<ExperienceRecord>> GetLevelingHistory(
[FromQuery] int take = 20,
[FromQuery] int offset = 0
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var queryable = db.ExperienceRecords
.Where(r => r.AccountId == currentUser.Id)
.OrderByDescending(r => r.CreatedAt)
.AsQueryable();
var totalCount = await queryable.CountAsync();
Response.Headers["X-Total"] = totalCount.ToString();
var records = await queryable
.Skip(offset)
.Take(take)
.ToListAsync();
return Ok(records);
}
[HttpGet("credits")]
public async Task<ActionResult<bool>> GetSocialCredit()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var credit = await creditService.GetSocialCredit(currentUser.Id);
return Ok(credit);
}
[HttpGet("credits/history")]
public async Task<ActionResult<SocialCreditRecord>> GetCreditHistory(
[FromQuery] int take = 20,
[FromQuery] int offset = 0
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var queryable = db.SocialCreditRecords
.Where(r => r.AccountId == currentUser.Id)
.OrderByDescending(r => r.CreatedAt)
.AsQueryable();
var totalCount = await queryable.CountAsync();
Response.Headers["X-Total"] = totalCount.ToString();
var records = await queryable
.Skip(offset)
.Take(take)
.ToListAsync();
return Ok(records);
}
} }

View File

@@ -14,7 +14,7 @@ public class AccountEventService(
Wallet.PaymentService payment, Wallet.PaymentService payment,
ICacheService cache, ICacheService cache,
IStringLocalizer<Localization.AccountEventResource> localizer, IStringLocalizer<Localization.AccountEventResource> localizer,
PusherService.PusherServiceClient pusher, RingService.RingServiceClient pusher,
SubscriptionService subscriptions, SubscriptionService subscriptions,
Pass.Leveling.ExperienceService experienceService Pass.Leveling.ExperienceService experienceService
) )

View File

@@ -6,12 +6,15 @@ using DysonNetwork.Pass.Email;
using DysonNetwork.Pass.Localization; using DysonNetwork.Pass.Localization;
using DysonNetwork.Pass.Permission; using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Stream; using DysonNetwork.Shared.Stream;
using EFCore.BulkExtensions; using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using NATS.Client.Core; using NATS.Client.Core;
using NATS.Client.JetStream;
using NATS.Net;
using NodaTime; using NodaTime;
using OtpNet; using OtpNet;
using AuthService = DysonNetwork.Pass.Auth.AuthService; using AuthService = DysonNetwork.Pass.Auth.AuthService;
@@ -21,10 +24,13 @@ namespace DysonNetwork.Pass.Account;
public class AccountService( public class AccountService(
AppDatabase db, AppDatabase db,
MagicSpellService spells, MagicSpellService spells,
FileService.FileServiceClient files,
FileReferenceService.FileReferenceServiceClient fileRefs,
AccountUsernameService uname, AccountUsernameService uname,
EmailService mailer, EmailService mailer,
PusherService.PusherServiceClient pusher, RingService.RingServiceClient pusher,
IStringLocalizer<NotificationResource> localizer, IStringLocalizer<NotificationResource> localizer,
IStringLocalizer<EmailResource> emailLocalizer,
ICacheService cache, ICacheService cache,
ILogger<AccountService> logger, ILogger<AccountService> logger,
INatsConnection nats INatsConnection nats
@@ -84,6 +90,7 @@ public class AccountService(
string email, string email,
string? password, string? password,
string language = "en-US", string language = "en-US",
string region = "en",
bool isEmailVerified = false, bool isEmailVerified = false,
bool isActivated = false bool isActivated = false
) )
@@ -103,6 +110,7 @@ public class AccountService(
Name = name, Name = name,
Nick = nick, Nick = nick,
Language = language, Language = language,
Region = region,
Contacts = new List<AccountContact> Contacts = new List<AccountContact>
{ {
new() new()
@@ -177,12 +185,14 @@ public class AccountService(
userInfo.Email, userInfo.Email,
null, null,
"en-US", "en-US",
"en",
userInfo.EmailVerified, userInfo.EmailVerified,
userInfo.EmailVerified userInfo.EmailVerified
); );
} }
public async Task<Account> CreateBotAccount(Account account, Guid automatedId) public async Task<Account> CreateBotAccount(Account account, Guid automatedId, string? pictureId,
string? backgroundId)
{ {
var dupeAutomateCount = await db.Accounts.Where(a => a.AutomatedId == automatedId).CountAsync(); var dupeAutomateCount = await db.Accounts.Where(a => a.AutomatedId == automatedId).CountAsync();
if (dupeAutomateCount > 0) if (dupeAutomateCount > 0)
@@ -195,8 +205,38 @@ public class AccountService(
account.AutomatedId = automatedId; account.AutomatedId = automatedId;
account.ActivatedAt = SystemClock.Instance.GetCurrentInstant(); account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
account.IsSuperuser = false; account.IsSuperuser = false;
if (!string.IsNullOrEmpty(pictureId))
{
var file = await files.GetFileAsync(new GetFileRequest { Id = pictureId });
await fileRefs.CreateReferenceAsync(
new CreateReferenceRequest
{
ResourceId = account.Profile.ResourceIdentifier,
FileId = pictureId,
Usage = "profile.picture"
}
);
account.Profile.Picture = CloudFileReferenceObject.FromProtoValue(file);
}
if (!string.IsNullOrEmpty(backgroundId))
{
var file = await files.GetFileAsync(new GetFileRequest { Id = backgroundId });
await fileRefs.CreateReferenceAsync(
new CreateReferenceRequest
{
ResourceId = account.Profile.ResourceIdentifier,
FileId = backgroundId,
Usage = "profile.background"
}
);
account.Profile.Background = CloudFileReferenceObject.FromProtoValue(file);
}
db.Accounts.Add(account); db.Accounts.Add(account);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return account; return account;
} }
@@ -399,12 +439,14 @@ public class AccountService(
.Where(c => c.Type == AccountContactType.Email) .Where(c => c.Type == AccountContactType.Email)
.Where(c => c.VerifiedAt != null) .Where(c => c.VerifiedAt != null)
.Where(c => c.IsPrimary) .Where(c => c.IsPrimary)
.Where(c => c.AccountId == account.Id)
.Include(c => c.Account) .Include(c => c.Account)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (contact is null) if (contact is null)
{ {
logger.LogWarning( logger.LogWarning(
"Unable to send factor code to #{FactorId} with, due to no contact method was found..." "Unable to send factor code to #{FactorId} with, due to no contact method was found...",
factor.Id
); );
return; return;
} }
@@ -413,7 +455,7 @@ public class AccountService(
.SendTemplatedEmailAsync<Pages.Emails.VerificationEmail, VerificationEmailModel>( .SendTemplatedEmailAsync<Pages.Emails.VerificationEmail, VerificationEmailModel>(
account.Nick, account.Nick,
contact.Content, contact.Content,
localizer["VerificationEmail"], emailLocalizer["VerificationEmail"],
new VerificationEmailModel new VerificationEmailModel
{ {
Name = account.Name, Name = account.Name,
@@ -701,10 +743,14 @@ public class AccountService(
db.Accounts.Remove(account); db.Accounts.Remove(account);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await nats.PublishAsync(AccountDeletedEvent.Type, JsonSerializer.SerializeToUtf8Bytes(new AccountDeletedEvent var js = nats.CreateJetStreamContext();
await js.PublishAsync(
AccountDeletedEvent.Type,
GrpcTypeHelper.ConvertObjectToByteString(new AccountDeletedEvent
{ {
AccountId = account.Id, AccountId = account.Id,
DeletedAt = SystemClock.Instance.GetCurrentInstant() DeletedAt = SystemClock.Instance.GetCurrentInstant()
})); }).ToByteArray()
);
} }
} }

View File

@@ -42,6 +42,26 @@ public class AccountServiceGrpc(
return account.ToProtoValue(); return account.ToProtoValue();
} }
public override async Task<Shared.Proto.Account> GetBotAccount(GetBotAccountRequest request,
ServerCallContext context)
{
if (!Guid.TryParse(request.AutomatedId, out var automatedId))
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid automated ID format"));
var account = await _db.Accounts
.AsNoTracking()
.Include(a => a.Profile)
.FirstOrDefaultAsync(a => a.AutomatedId == automatedId);
if (account == null)
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Account with automated ID {request.AutomatedId} not found"));
var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id);
account.PerkSubscription = perk?.ToReference();
return account.ToProtoValue();
}
public override async Task<GetAccountBatchResponse> GetAccountBatch(GetAccountBatchRequest request, public override async Task<GetAccountBatchResponse> GetAccountBatch(GetAccountBatchRequest request,
ServerCallContext context) ServerCallContext context)
{ {
@@ -69,6 +89,34 @@ public class AccountServiceGrpc(
return response; return response;
} }
public override async Task<GetAccountBatchResponse> GetBotAccountBatch(GetBotAccountBatchRequest request,
ServerCallContext context)
{
var automatedIds = request.AutomatedId
.Select(id => Guid.TryParse(id, out var automatedId) ? automatedId : (Guid?)null)
.Where(id => id.HasValue)
.Select(id => id!.Value)
.ToList();
var accounts = await _db.Accounts
.AsNoTracking()
.Where(a => a.AutomatedId != null && automatedIds.Contains(a.AutomatedId.Value))
.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<AccountStatus> GetAccountStatus(GetAccountRequest request, ServerCallContext context) public override async Task<AccountStatus> GetAccountStatus(GetAccountRequest request, ServerCallContext context)
{ {
var accountId = Guid.Parse(request.Id); var accountId = Guid.Parse(request.Id);
@@ -76,7 +124,8 @@ public class AccountServiceGrpc(
return status.ToProtoValue(); return status.ToProtoValue();
} }
public override async Task<GetAccountStatusBatchResponse> GetAccountStatusBatch(GetAccountBatchRequest request, ServerCallContext context) public override async Task<GetAccountStatusBatchResponse> GetAccountStatusBatch(GetAccountBatchRequest request,
ServerCallContext context)
{ {
var accountIds = request.Id var accountIds = request.Id
.Select(id => Guid.TryParse(id, out var accountId) ? accountId : (Guid?)null) .Select(id => Guid.TryParse(id, out var accountId) ? accountId : (Guid?)null)

View File

@@ -1,6 +1,8 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using NodaTime.Serialization.Protobuf; using NodaTime.Serialization.Protobuf;
using Point = NetTopologySuite.Geometries.Point; using Point = NetTopologySuite.Geometries.Point;
@@ -14,7 +16,7 @@ public class ActionLog : ModelBase
[Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new(); [Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
[MaxLength(512)] public string? UserAgent { get; set; } [MaxLength(512)] public string? UserAgent { get; set; }
[MaxLength(128)] public string? IpAddress { get; set; } [MaxLength(128)] public string? IpAddress { get; set; }
public Point? Location { get; set; } [Column(TypeName = "jsonb")] public GeoPoint? Location { get; set; }
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
public Account Account { get; set; } = null!; public Account Account { get; set; } = null!;

View File

@@ -1,11 +1,20 @@
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Grpc.Core; using Grpc.Core;
using NodaTime; using Microsoft.EntityFrameworkCore;
using NodaTime.Serialization.Protobuf; using NodaTime.Serialization.Protobuf;
using ApiKey = DysonNetwork.Shared.Proto.ApiKey;
using AuthService = DysonNetwork.Pass.Auth.AuthService;
namespace DysonNetwork.Pass.Account; namespace DysonNetwork.Pass.Account;
public class BotAccountReceiverGrpc(AppDatabase db, AccountService accounts) public class BotAccountReceiverGrpc(
AppDatabase db,
AccountService accounts,
FileService.FileServiceClient files,
FileReferenceService.FileReferenceServiceClient fileRefs,
AuthService authService
)
: BotAccountReceiverService.BotAccountReceiverServiceBase : BotAccountReceiverService.BotAccountReceiverServiceBase
{ {
public override async Task<CreateBotAccountResponse> CreateBotAccount( public override async Task<CreateBotAccountResponse> CreateBotAccount(
@@ -14,7 +23,12 @@ public class BotAccountReceiverGrpc(AppDatabase db, AccountService accounts)
) )
{ {
var account = Account.FromProtoValue(request.Account); var account = Account.FromProtoValue(request.Account);
account = await accounts.CreateBotAccount(account, Guid.Parse(request.AutomatedId)); account = await accounts.CreateBotAccount(
account,
Guid.Parse(request.AutomatedId),
request.PictureId,
request.BackgroundId
);
return new CreateBotAccountResponse return new CreateBotAccountResponse
{ {
@@ -34,15 +48,43 @@ public class BotAccountReceiverGrpc(AppDatabase db, AccountService accounts)
ServerCallContext context ServerCallContext context
) )
{ {
var automatedId = Guid.Parse(request.AutomatedId); var account = Account.FromProtoValue(request.Account);
var account = await accounts.GetBotAccount(automatedId);
if (account is null)
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found"));
account.Name = request.Account.Name; if (request.PictureId is not null)
account.Nick = request.Account.Nick; {
account.Profile = AccountProfile.FromProtoValue(request.Account.Profile); var file = await files.GetFileAsync(new GetFileRequest { Id = request.PictureId });
account.Language = request.Account.Language; if (account.Profile.Picture is not null)
await fileRefs.DeleteResourceReferencesAsync(
new DeleteResourceReferencesRequest { ResourceId = account.Profile.ResourceIdentifier }
);
await fileRefs.CreateReferenceAsync(
new CreateReferenceRequest
{
ResourceId = account.Profile.ResourceIdentifier,
FileId = request.PictureId,
Usage = "profile.picture"
}
);
account.Profile.Picture = CloudFileReferenceObject.FromProtoValue(file);
}
if (request.BackgroundId is not null)
{
var file = await files.GetFileAsync(new GetFileRequest { Id = request.BackgroundId });
if (account.Profile.Background is not null)
await fileRefs.DeleteResourceReferencesAsync(
new DeleteResourceReferencesRequest { ResourceId = account.Profile.ResourceIdentifier }
);
await fileRefs.CreateReferenceAsync(
new CreateReferenceRequest
{
ResourceId = account.Profile.ResourceIdentifier,
FileId = request.BackgroundId,
Usage = "profile.background"
}
);
account.Profile.Background = CloudFileReferenceObject.FromProtoValue(file);
}
db.Accounts.Update(account); db.Accounts.Update(account);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -68,10 +110,109 @@ public class BotAccountReceiverGrpc(AppDatabase db, AccountService accounts)
var automatedId = Guid.Parse(request.AutomatedId); var automatedId = Guid.Parse(request.AutomatedId);
var account = await accounts.GetBotAccount(automatedId); var account = await accounts.GetBotAccount(automatedId);
if (account is null) if (account is null)
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found")); throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.NotFound, "Account not found"));
await accounts.DeleteAccount(account); await accounts.DeleteAccount(account);
return new DeleteBotAccountResponse(); return new DeleteBotAccountResponse();
} }
public override async Task<ApiKey> GetApiKey(GetApiKeyRequest request, ServerCallContext context)
{
var keyId = Guid.Parse(request.Id);
var key = await db.ApiKeys
.Include(k => k.Account)
.FirstOrDefaultAsync(k => k.Id == keyId);
if (key == null)
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found"));
return key.ToProtoValue();
}
public override async Task<GetApiKeyBatchResponse> ListApiKey(ListApiKeyRequest request, ServerCallContext context)
{
var automatedId = Guid.Parse(request.AutomatedId);
var account = await accounts.GetBotAccount(automatedId);
if (account == null)
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found"));
var keys = await db.ApiKeys
.Where(k => k.AccountId == account.Id)
.Select(k => k.ToProtoValue())
.ToListAsync();
var response = new GetApiKeyBatchResponse();
response.Data.AddRange(keys);
return response;
}
public override async Task<ApiKey> CreateApiKey(ApiKey request, ServerCallContext context)
{
var accountId = Guid.Parse(request.AccountId);
var account = await accounts.GetBotAccount(accountId);
if (account == null)
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found"));
if (string.IsNullOrWhiteSpace(request.Label))
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Label is required"));
var key = await authService.CreateApiKey(account.Id, request.Label, null);
key.Key = await authService.IssueApiKeyToken(key);
return key.ToProtoValue();
}
public override async Task<ApiKey> UpdateApiKey(ApiKey request, ServerCallContext context)
{
var keyId = Guid.Parse(request.Id);
var accountId = Guid.Parse(request.AccountId);
var key = await db.ApiKeys
.Include(k => k.Session)
.Where(k => k.Id == keyId && k.AccountId == accountId)
.FirstOrDefaultAsync();
if (key == null)
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found"));
// Only update the label if provided
if (string.IsNullOrWhiteSpace(request.Label)) return key.ToProtoValue();
key.Label = request.Label;
db.ApiKeys.Update(key);
await db.SaveChangesAsync();
return key.ToProtoValue();
}
public override async Task<ApiKey> RotateApiKey(GetApiKeyRequest request, ServerCallContext context)
{
var keyId = Guid.Parse(request.Id);
var key = await db.ApiKeys
.Include(k => k.Session)
.FirstOrDefaultAsync(k => k.Id == keyId);
if (key == null)
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found"));
key = await authService.RotateApiKeyToken(key);
key.Key = await authService.IssueApiKeyToken(key);
return key.ToProtoValue();
}
public override async Task<DeleteApiKeyResponse> DeleteApiKey(GetApiKeyRequest request, ServerCallContext context)
{
var keyId = Guid.Parse(request.Id);
var key = await db.ApiKeys
.Include(k => k.Session)
.FirstOrDefaultAsync(k => k.Id == keyId);
if (key == null)
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found"));
await authService.RevokeApiKeyToken(key);
return new DeleteApiKeyResponse { Success = true };
}
} }

View File

@@ -23,6 +23,12 @@ public class Status : ModelBase
public bool IsNotDisturb { get; set; } public bool IsNotDisturb { get; set; }
[MaxLength(1024)] public string? Label { get; set; } [MaxLength(1024)] public string? Label { get; set; }
public Instant? ClearedAt { get; set; } public Instant? ClearedAt { get; set; }
[MaxLength(4096)] public string? AppIdentifier { get; set; }
/// <summary>
/// Indicates this status is created based on running process or rich presence
/// </summary>
public bool IsAutomated { get; set; }
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
public Account Account { get; set; } = null!; public Account Account { get; set; } = null!;

View File

@@ -3,6 +3,7 @@ using System.Text.Json;
using DysonNetwork.Pass.Email; using DysonNetwork.Pass.Email;
using DysonNetwork.Pass.Pages.Emails; using DysonNetwork.Pass.Pages.Emails;
using DysonNetwork.Pass.Permission; using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Cache;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using NodaTime; using NodaTime;
@@ -15,7 +16,8 @@ public class MagicSpellService(
IConfiguration configuration, IConfiguration configuration,
ILogger<MagicSpellService> logger, ILogger<MagicSpellService> logger,
IStringLocalizer<EmailResource> localizer, IStringLocalizer<EmailResource> localizer,
EmailService email EmailService email,
ICacheService cache
) )
{ {
public async Task<MagicSpell> CreateMagicSpell( public async Task<MagicSpell> CreateMagicSpell(
@@ -35,11 +37,8 @@ public class MagicSpellService(
.Where(s => s.Type == type) .Where(s => s.Type == type)
.Where(s => s.ExpiresAt == null || s.ExpiresAt > now) .Where(s => s.ExpiresAt == null || s.ExpiresAt > now)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (existingSpell is not null)
if (existingSpell != null) return existingSpell;
{
throw new InvalidOperationException($"Account already has an active magic spell of type {type}");
}
} }
var spellWord = _GenerateRandomString(128); var spellWord = _GenerateRandomString(128);
@@ -59,8 +58,18 @@ public class MagicSpellService(
return spell; return spell;
} }
private const string SpellNotifyCacheKeyPrefix = "spells:notify:";
public async Task NotifyMagicSpell(MagicSpell spell, bool bypassVerify = false) public async Task NotifyMagicSpell(MagicSpell spell, bool bypassVerify = false)
{ {
var cacheKey = SpellNotifyCacheKeyPrefix + spell.Id;
var (found, _) = await cache.GetAsyncWithStatus<bool?>(cacheKey);
if (found)
{
logger.LogInformation("Skip sending magic spell {SpellId} due to already sent.", spell.Id);
return;
}
var contact = await db.AccountContacts var contact = await db.AccountContacts
.Where(c => c.Account.Id == spell.AccountId) .Where(c => c.Account.Id == spell.AccountId)
.Where(c => c.Type == AccountContactType.Email) .Where(c => c.Type == AccountContactType.Email)
@@ -112,7 +121,7 @@ public class MagicSpellService(
await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>( await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>(
contact.Account.Nick, contact.Account.Nick,
contact.Content, contact.Content,
localizer["EmailAccountDeletionTitle"], localizer["EmailPasswordResetTitle"],
new PasswordResetEmailModel new PasswordResetEmailModel
{ {
Name = contact.Account.Name, Name = contact.Account.Name,
@@ -138,6 +147,8 @@ public class MagicSpellService(
default: default:
throw new ArgumentOutOfRangeException(); throw new ArgumentOutOfRangeException();
} }
await cache.SetAsync(cacheKey, true, TimeSpan.FromMinutes(5));
} }
catch (Exception err) catch (Exception err)
{ {

View File

@@ -0,0 +1,53 @@
using Nager.Holiday;
using NodaTime;
namespace DysonNetwork.Pass.Account;
/// <summary>
/// Reference from Nager.Holiday
/// </summary>
public enum NotableHolidayType
{
/// <summary>Public holiday</summary>
Public,
/// <summary>Bank holiday, banks and offices are closed</summary>
Bank,
/// <summary>School holiday, schools are closed</summary>
School,
/// <summary>Authorities are closed</summary>
Authorities,
/// <summary>Majority of people take a day off</summary>
Optional,
/// <summary>Optional festivity, no paid day off</summary>
Observance,
}
public class NotableDay
{
public Instant Date { get; set; }
public string? LocalName { get; set; }
public string? GlobalName { get; set; }
public string? CountryCode { get; set; }
public NotableHolidayType[] Holidays { get; set; } = [];
public static NotableDay FromNagerHoliday(PublicHoliday holiday)
{
return new NotableDay()
{
Date = Instant.FromDateTimeUtc(holiday.Date.ToUniversalTime()),
LocalName = holiday.LocalName,
GlobalName = holiday.Name,
CountryCode = holiday.CountryCode,
Holidays = holiday.Types?.Select(x => x switch
{
PublicHolidayType.Public => NotableHolidayType.Public,
PublicHolidayType.Bank => NotableHolidayType.Bank,
PublicHolidayType.School => NotableHolidayType.School,
PublicHolidayType.Authorities => NotableHolidayType.Authorities,
PublicHolidayType.Optional => NotableHolidayType.Optional,
_ => NotableHolidayType.Observance
}).ToArray() ?? [],
};
}
}

View File

@@ -0,0 +1,79 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Pass.Account;
[ApiController]
[Route("/api/notable")]
public class NotableDaysController(NotableDaysService days) : ControllerBase
{
[HttpGet("{regionCode}/{year:int}")]
public async Task<ActionResult<List<NotableDay>>> GetRegionDays(string regionCode, int year)
{
var result = await days.GetNotableDays(year, regionCode);
return Ok(result);
}
[HttpGet("{regionCode}")]
public async Task<ActionResult<List<NotableDay>>> GetRegionDaysCurrentYear(string regionCode)
{
var currentYear = DateTime.Now.Year;
var result = await days.GetNotableDays(currentYear, regionCode);
return Ok(result);
}
[HttpGet("me/{year:int}")]
[Authorize]
public async Task<ActionResult<List<NotableDay>>> GetAccountNotableDays(int year)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var region = currentUser.Region;
if (string.IsNullOrWhiteSpace(region)) region = "us";
var result = await days.GetNotableDays(year, region);
return Ok(result);
}
[HttpGet("me")]
[Authorize]
public async Task<ActionResult<List<NotableDay>>> GetAccountNotableDaysCurrentYear()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var currentYear = DateTime.Now.Year;
var region = currentUser.Region;
if (string.IsNullOrWhiteSpace(region)) region = "us";
var result = await days.GetNotableDays(currentYear, region);
return Ok(result);
}
[HttpGet("{regionCode}/next")]
public async Task<ActionResult<NotableDay?>> GetNextHoliday(string regionCode)
{
var result = await days.GetNextHoliday(regionCode);
if (result == null)
{
return NotFound("No upcoming holidays found");
}
return Ok(result);
}
[HttpGet("me/next")]
[Authorize]
public async Task<ActionResult<NotableDay?>> GetAccountNextHoliday()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var region = currentUser.Region;
if (string.IsNullOrWhiteSpace(region)) region = "us";
var result = await days.GetNextHoliday(region);
if (result == null)
{
return NotFound("No upcoming holidays found");
}
return Ok(result);
}
}

View File

@@ -0,0 +1,55 @@
using DysonNetwork.Shared.Cache;
using Nager.Holiday;
using NodaTime;
namespace DysonNetwork.Pass.Account;
public class NotableDaysService(ICacheService cache)
{
private const string NotableDaysCacheKeyPrefix = "notable:";
public async Task<List<NotableDay>> GetNotableDays(int? year, string regionCode)
{
year ??= DateTime.UtcNow.Year;
// Generate cache key using year and region code
var cacheKey = $"{NotableDaysCacheKeyPrefix}:{year}:{regionCode}";
// Try to get from cache first
var (found, cachedDays) = await cache.GetAsyncWithStatus<List<NotableDay>>(cacheKey);
if (found && cachedDays != null)
{
return cachedDays;
}
// If not in cache, fetch from API
using var holidayClient = new HolidayClient();
var holidays = await holidayClient.GetHolidaysAsync(year.Value, regionCode);
var days = holidays?.Select(NotableDay.FromNagerHoliday).ToList() ?? [];
// Cache the result for 1 day (holiday data doesn't change frequently)
await cache.SetAsync(cacheKey, days, TimeSpan.FromDays(1));
return days;
}
public async Task<NotableDay?> GetNextHoliday(string regionCode)
{
var currentDate = SystemClock.Instance.GetCurrentInstant();
var currentYear = currentDate.InUtc().Year;
// Get holidays for current year and next year to cover all possibilities
var currentYearHolidays = await GetNotableDays(currentYear, regionCode);
var nextYearHolidays = await GetNotableDays(currentYear + 1, regionCode);
var allHolidays = currentYearHolidays.Concat(nextYearHolidays);
// Find the first holiday that is today or in the future
var nextHoliday = allHolidays
.Where(day => day.Date >= currentDate)
.OrderBy(day => day.Date)
.FirstOrDefault();
return nextHoliday;
}
}

View File

@@ -10,7 +10,7 @@ namespace DysonNetwork.Pass.Account;
public class RelationshipService( public class RelationshipService(
AppDatabase db, AppDatabase db,
ICacheService cache, ICacheService cache,
PusherService.PusherServiceClient pusher, RingService.RingServiceClient pusher,
IStringLocalizer<NotificationResource> localizer IStringLocalizer<NotificationResource> localizer
) )
{ {

View File

@@ -1,5 +1,7 @@
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Reflection; using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Pass.Account; using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Auth; using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Credit; using DysonNetwork.Pass.Credit;
@@ -59,9 +61,14 @@ public class AppDatabase(
optionsBuilder.UseNpgsql( optionsBuilder.UseNpgsql(
configuration.GetConnectionString("App"), configuration.GetConnectionString("App"),
opt => opt opt => opt
.ConfigureDataSource(optSource => optSource.EnableDynamicJson()) .ConfigureDataSource(optSource => optSource
.EnableDynamicJson()
.ConfigureJsonOptions(new JsonSerializerOptions()
{
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
})
)
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
.UseNetTopologySuite()
.UseNodaTime() .UseNodaTime()
).UseSnakeCaseNamingConvention(); ).UseSnakeCaseNamingConvention();

View File

@@ -2,6 +2,7 @@ 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.Data; using DysonNetwork.Shared.Data;
using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Pass.Auth; namespace DysonNetwork.Pass.Auth;
@@ -15,5 +16,35 @@ public class ApiKey : ModelBase
public Guid SessionId { get; set; } public Guid SessionId { get; set; }
public AuthSession Session { get; set; } = null!; public AuthSession Session { get; set; } = null!;
[NotMapped] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Key { get; set; } [NotMapped]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Key { get; set; }
public DysonNetwork.Shared.Proto.ApiKey ToProtoValue()
{
return new DysonNetwork.Shared.Proto.ApiKey
{
Id = Id.ToString(),
Label = Label,
AccountId = AccountId.ToString(),
SessionId = SessionId.ToString(),
Key = Key,
CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp()
};
}
public static ApiKey FromProtoValue(DysonNetwork.Shared.Proto.ApiKey proto)
{
return new ApiKey
{
Id = Guid.Parse(proto.Id),
AccountId = Guid.Parse(proto.AccountId),
SessionId = Guid.Parse(proto.SessionId),
Label = proto.Label,
Key = proto.Key,
CreatedAt = proto.CreatedAt.ToInstant(),
UpdatedAt = proto.UpdatedAt.ToInstant()
};
}
} }

View File

@@ -49,7 +49,10 @@ public class DysonTokenAuthHandler(
try try
{ {
var (valid, session, message) = await token.AuthenticateTokenAsync(tokenInfo.Token); // Get client IP address
var ipAddress = Context.Connection.RemoteIpAddress?.ToString();
var (valid, session, message) = await token.AuthenticateTokenAsync(tokenInfo.Token, ipAddress);
if (!valid || session is null) if (!valid || session is null)
return AuthenticateResult.Fail(message ?? "Authentication failed."); return AuthenticateResult.Fail(message ?? "Authentication failed.");
@@ -67,7 +70,7 @@ public class DysonTokenAuthHandler(
}; };
// Add scopes as claims // Add scopes as claims
session.Challenge.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope))); session.Challenge?.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope)));
// Add superuser claim if applicable // Add superuser claim if applicable
if (session.Account.IsSuperuser) if (session.Account.IsSuperuser)

View File

@@ -22,7 +22,7 @@ public class AuthController(
AuthService auth, AuthService auth,
GeoIpService geo, GeoIpService geo,
ActionLogService als, ActionLogService als,
PusherService.PusherServiceClient pusher, RingService.RingServiceClient pusher,
IConfiguration configuration, IConfiguration configuration,
IStringLocalizer<NotificationResource> localizer IStringLocalizer<NotificationResource> localizer
) : ControllerBase ) : ControllerBase
@@ -51,7 +51,11 @@ public class AuthController(
.Where(e => e.Type == PunishmentType.BlockLogin || e.Type == PunishmentType.DisableAccount) .Where(e => e.Type == PunishmentType.BlockLogin || e.Type == PunishmentType.DisableAccount)
.Where(e => e.ExpiredAt == null || now < e.ExpiredAt) .Where(e => e.ExpiredAt == null || now < e.ExpiredAt)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (punishment is not null) return StatusCode(423, punishment); if (punishment is not null)
return StatusCode(
423,
$"Your account has been suspended. Reason: {punishment.Reason}. Expired at: {punishment.ExpiredAt?.ToString() ?? "never"}"
);
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
var userAgent = HttpContext.Request.Headers.UserAgent.ToString(); var userAgent = HttpContext.Request.Headers.UserAgent.ToString();

View File

@@ -1,5 +1,6 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Pass.Account; using DysonNetwork.Pass.Account;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -52,7 +53,7 @@ public class AuthService(
riskScore += 1; riskScore += 1;
else else
{ {
if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge.IpAddress) && if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge?.IpAddress) &&
!lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase)) !lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase))
riskScore += 1; riskScore += 1;
} }
@@ -137,6 +138,7 @@ public class AuthService(
var jsonOpts = new JsonSerializerOptions var jsonOpts = new JsonSerializerOptions
{ {
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower
}; };
@@ -211,8 +213,7 @@ public class AuthService(
var session = new AuthSession var session = new AuthSession
{ {
LastGrantedAt = now, LastGrantedAt = now,
// Never expire server-side ExpiredAt = now.Plus(Duration.FromDays(7)),
ExpiredAt = null,
AccountId = challenge.AccountId, AccountId = challenge.AccountId,
ChallengeId = challenge.Id ChallengeId = challenge.Id
}; };
@@ -365,17 +366,39 @@ public class AuthService(
public async Task<ApiKey> RotateApiKeyToken(ApiKey key) public async Task<ApiKey> RotateApiKeyToken(ApiKey key)
{ {
var originalSession = key.Session; await using var transaction = await db.Database.BeginTransactionAsync();
db.Remove(originalSession); try
key.Session = new AuthSession {
var oldSessionId = key.SessionId;
// Create new session
var newSession = new AuthSession
{ {
AccountId = key.AccountId, AccountId = key.AccountId,
ExpiredAt = originalSession.ExpiredAt ExpiredAt = key.Session?.ExpiredAt
}; };
db.Add(key.Session);
db.AuthSessions.Add(newSession);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Update ApiKey to point to new session
key.SessionId = newSession.Id;
key.Session = newSession;
db.ApiKeys.Update(key);
await db.SaveChangesAsync();
// Delete old session
await db.AuthSessions.Where(s => s.Id == oldSessionId).ExecuteDeleteAsync();
await transaction.CommitAsync();
return key; return key;
} }
catch
{
await transaction.RollbackAsync();
throw;
}
}
// Helper methods for Base64Url encoding/decoding // Helper methods for Base64Url encoding/decoding
private static string Base64UrlEncode(byte[] data) private static string Base64UrlEncode(byte[] data)

View File

@@ -1,9 +1,5 @@
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Grpc.Core; using Grpc.Core;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Auth; namespace DysonNetwork.Pass.Auth;
@@ -18,7 +14,7 @@ public class AuthServiceGrpc(
ServerCallContext context ServerCallContext context
) )
{ {
var (valid, session, message) = await token.AuthenticateTokenAsync(request.Token); var (valid, session, message) = await token.AuthenticateTokenAsync(request.Token, request.IpAddress);
if (!valid || session is null) if (!valid || session is null)
return new AuthenticateResponse { Valid = false, Message = message ?? "Authentication failed." }; return new AuthenticateResponse { Valid = false, Message = message ?? "Authentication failed." };

View File

@@ -2,6 +2,7 @@ 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.Data; using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.GeoIp;
using NodaTime; using NodaTime;
using NodaTime.Serialization.Protobuf; using NodaTime.Serialization.Protobuf;
using Point = NetTopologySuite.Geometries.Point; using Point = NetTopologySuite.Geometries.Point;
@@ -69,7 +70,7 @@ public class AuthChallenge : ModelBase
[MaxLength(128)] public string? IpAddress { get; set; } [MaxLength(128)] public string? IpAddress { get; set; }
[MaxLength(512)] public string? UserAgent { get; set; } [MaxLength(512)] public string? UserAgent { get; set; }
[MaxLength(1024)] public string? Nonce { get; set; } [MaxLength(1024)] public string? Nonce { get; set; }
public Point? Location { get; set; } [Column(TypeName = "jsonb")] public GeoPoint? Location { get; set; }
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!; [JsonIgnore] public Account.Account Account { get; set; } = null!;

View File

@@ -5,8 +5,10 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Web;
using DysonNetwork.Pass.Account; using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Auth.OidcProvider.Options; using DysonNetwork.Pass.Auth.OidcProvider.Options;
using DysonNetwork.Shared.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using NodaTime; using NodaTime;
@@ -19,10 +21,199 @@ public class OidcProviderController(
AppDatabase db, AppDatabase db,
OidcProviderService oidcService, OidcProviderService oidcService,
IConfiguration configuration, IConfiguration configuration,
IOptions<OidcProviderOptions> options IOptions<OidcProviderOptions> options,
) ILogger<OidcProviderController> logger
: ControllerBase ) : ControllerBase
{ {
[HttpGet("authorize")]
[Produces("application/json")]
public async Task<IActionResult> Authorize(
[FromQuery(Name = "client_id")] string clientId,
[FromQuery(Name = "response_type")] string responseType,
[FromQuery(Name = "redirect_uri")] string? redirectUri = null,
[FromQuery] string? scope = null,
[FromQuery] string? state = null,
[FromQuery(Name = "response_mode")] string? responseMode = null,
[FromQuery] string? nonce = null,
[FromQuery] string? display = null,
[FromQuery] string? prompt = null,
[FromQuery(Name = "code_challenge")] string? codeChallenge = null,
[FromQuery(Name = "code_challenge_method")]
string? codeChallengeMethod = null)
{
if (string.IsNullOrEmpty(clientId))
{
return BadRequest(new ErrorResponse
{
Error = "invalid_request",
ErrorDescription = "client_id is required"
});
}
var client = await oidcService.FindClientBySlugAsync(clientId);
if (client == null)
{
return BadRequest(new ErrorResponse
{
Error = "unauthorized_client",
ErrorDescription = "Client not found"
});
}
// Validate response_type
if (string.IsNullOrEmpty(responseType))
{
return BadRequest(new ErrorResponse
{
Error = "invalid_request",
ErrorDescription = "response_type is required"
});
}
// Check if the client is allowed to use the requested response type
var allowedResponseTypes = new[] { "code", "token", "id_token" };
var requestedResponseTypes = responseType.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (requestedResponseTypes.Any(rt => !allowedResponseTypes.Contains(rt)))
{
return BadRequest(new ErrorResponse
{
Error = "unsupported_response_type",
ErrorDescription = "The requested response type is not supported"
});
}
// Validate redirect_uri if provided
if (!string.IsNullOrEmpty(redirectUri) &&
!await oidcService.ValidateRedirectUriAsync(Guid.Parse(client.Id), redirectUri))
{
return BadRequest(new ErrorResponse
{
Error = "invalid_request",
ErrorDescription = "Invalid redirect_uri"
});
}
// Return client information
var clientInfo = new ClientInfoResponse
{
ClientId = Guid.Parse(client.Id),
Picture = client.Picture is not null ? CloudFileReferenceObject.FromProtoValue(client.Picture) : null,
Background = client.Background is not null
? CloudFileReferenceObject.FromProtoValue(client.Background)
: null,
ClientName = client.Name,
HomeUri = client.Links.HomePage,
PolicyUri = client.Links.PrivacyPolicy,
TermsOfServiceUri = client.Links.TermsOfService,
ResponseTypes = responseType,
Scopes = scope?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? [],
State = state,
Nonce = nonce,
CodeChallenge = codeChallenge,
CodeChallengeMethod = codeChallengeMethod
};
return Ok(clientInfo);
}
[HttpPost("authorize")]
[Consumes("application/x-www-form-urlencoded")]
[Authorize]
public async Task<IActionResult> HandleAuthorizationResponse(
[FromForm(Name = "authorize")] string? authorize,
[FromForm(Name = "client_id")] string clientId,
[FromForm(Name = "redirect_uri")] string? redirectUri = null,
[FromForm] string? scope = null,
[FromForm] string? state = null,
[FromForm] string? nonce = null,
[FromForm(Name = "code_challenge")] string? codeChallenge = null,
[FromForm(Name = "code_challenge_method")]
string? codeChallengeMethod = null)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account account)
return Unauthorized();
// Find the client
var client = await oidcService.FindClientBySlugAsync(clientId);
if (client == null)
{
return BadRequest(new ErrorResponse
{
Error = "unauthorized_client",
ErrorDescription = "Client not found"
});
}
// If user denied the request
if (string.IsNullOrEmpty(authorize) || !bool.TryParse(authorize, out var isAuthorized) || !isAuthorized)
{
var errorUri = new UriBuilder(redirectUri ?? client.Links?.HomePage ?? "https://example.com");
var queryParams = HttpUtility.ParseQueryString(errorUri.Query);
queryParams["error"] = "access_denied";
queryParams["error_description"] = "The user denied the authorization request";
if (!string.IsNullOrEmpty(state)) queryParams["state"] = state;
errorUri.Query = queryParams.ToString();
return Ok(new { redirectUri = errorUri.Uri.ToString() });
}
// Validate redirect_uri if provided
if (!string.IsNullOrEmpty(redirectUri) &&
!await oidcService.ValidateRedirectUriAsync(Guid.Parse(client!.Id), redirectUri))
{
return BadRequest(new ErrorResponse
{
Error = "invalid_request",
ErrorDescription = "Invalid redirect_uri"
});
}
// Default to client's first redirect URI if not provided
redirectUri ??= client.OauthConfig?.RedirectUris?.FirstOrDefault();
if (string.IsNullOrEmpty(redirectUri))
{
return BadRequest(new ErrorResponse
{
Error = "invalid_request",
ErrorDescription = "No valid redirect_uri available"
});
}
try
{
// Generate authorization code and create session
var authorizationCode = await oidcService.GenerateAuthorizationCodeAsync(
Guid.Parse(client.Id),
account.Id,
redirectUri,
scope?.Split(' ') ?? [],
codeChallenge,
codeChallengeMethod,
nonce
);
// Build the redirect URI with the authorization code
var redirectBuilder = new UriBuilder(redirectUri);
var queryParams = HttpUtility.ParseQueryString(redirectBuilder.Query);
queryParams["code"] = authorizationCode;
if (!string.IsNullOrEmpty(state)) queryParams["state"] = state;
redirectBuilder.Query = queryParams.ToString();
return Ok(new { redirectUri = redirectBuilder.Uri.ToString() });
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing authorization request");
return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse
{
Error = "server_error",
ErrorDescription = "An error occurred while processing your request"
});
}
}
[HttpPost("token")] [HttpPost("token")]
[Consumes("application/x-www-form-urlencoded")] [Consumes("application/x-www-form-urlencoded")]
public async Task<IActionResult> Token([FromForm] TokenRequest request) public async Task<IActionResult> Token([FromForm] TokenRequest request)
@@ -36,15 +227,15 @@ public class OidcProviderController(
return BadRequest("Authorization code is required"); return BadRequest("Authorization code is required");
case "authorization_code": case "authorization_code":
{ {
var client = await oidcService.FindClientByIdAsync(request.ClientId.Value); var client = await oidcService.FindClientBySlugAsync(request.ClientId);
if (client == null || if (client == null ||
!await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret)) !await oidcService.ValidateClientCredentialsAsync(Guid.Parse(client.Id), request.ClientSecret))
return BadRequest(new ErrorResponse return BadRequest(new ErrorResponse
{ Error = "invalid_client", ErrorDescription = "Invalid client credentials" }); { Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
// Generate tokens // Generate tokens
var tokenResponse = await oidcService.GenerateTokenResponseAsync( var tokenResponse = await oidcService.GenerateTokenResponseAsync(
clientId: request.ClientId.Value, clientId: Guid.Parse(client.Id),
authorizationCode: request.Code!, authorizationCode: request.Code!,
redirectUri: request.RedirectUri, redirectUri: request.RedirectUri,
codeVerifier: request.CodeVerifier codeVerifier: request.CodeVerifier
@@ -116,7 +307,7 @@ public class OidcProviderController(
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized(); HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
// Get requested scopes from the token // Get requested scopes from the token
var scopes = currentSession.Challenge.Scopes; var scopes = currentSession.Challenge?.Scopes ?? [];
var userInfo = new Dictionary<string, object> var userInfo = new Dictionary<string, object>
{ {
@@ -150,10 +341,10 @@ public class OidcProviderController(
return Ok(new return Ok(new
{ {
issuer = issuer, issuer,
authorization_endpoint = $"{baseUrl}/auth/authorize", authorization_endpoint = $"{baseUrl}/auth/authorize",
token_endpoint = $"{baseUrl}/auth/open/token", token_endpoint = $"{baseUrl}/api/auth/open/token",
userinfo_endpoint = $"{baseUrl}/auth/open/userinfo", userinfo_endpoint = $"{baseUrl}/api/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[]
@@ -220,7 +411,7 @@ public class TokenRequest
[JsonPropertyName("client_id")] [JsonPropertyName("client_id")]
[FromForm(Name = "client_id")] [FromForm(Name = "client_id")]
public Guid? ClientId { get; set; } public string? ClientId { get; set; }
[JsonPropertyName("client_secret")] [JsonPropertyName("client_secret")]
[FromForm(Name = "client_secret")] [FromForm(Name = "client_secret")]

View File

@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data;
namespace DysonNetwork.Pass.Auth.OidcProvider.Responses;
public class ClientInfoResponse
{
public Guid ClientId { get; set; }
public CloudFileReferenceObject? Picture { get; set; }
public CloudFileReferenceObject? Background { get; set; }
public string? ClientName { get; set; }
public string? HomeUri { get; set; }
public string? PolicyUri { get; set; }
public string? TermsOfServiceUri { get; set; }
public string? ResponseTypes { get; set; }
public string[]? Scopes { get; set; }
public string? State { get; set; }
public string? Nonce { get; set; }
public string? CodeChallenge { get; set; }
public string? CodeChallengeMethod { get; set; }
}

View File

@@ -20,7 +20,6 @@ public class TokenResponse
[JsonPropertyName("scope")] [JsonPropertyName("scope")]
public string? Scope { get; set; } public string? Scope { get; set; }
[JsonPropertyName("id_token")] [JsonPropertyName("id_token")]
public string? IdToken { get; set; } public string? IdToken { get; set; }
} }

View File

@@ -11,6 +11,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using NodaTime; using NodaTime;
using AccountContactType = DysonNetwork.Pass.Account.AccountContactType;
namespace DysonNetwork.Pass.Auth.OidcProvider.Services; namespace DysonNetwork.Pass.Auth.OidcProvider.Services;
@@ -31,15 +32,31 @@ public class OidcProviderService(
return resp.App ?? null; return resp.App ?? null;
} }
public async Task<AuthSession?> FindValidSessionAsync(Guid accountId, Guid clientId) public async Task<CustomApp?> FindClientBySlugAsync(string slug)
{
var resp = await customApps.GetCustomAppAsync(new GetCustomAppRequest { Slug = slug });
return resp.App ?? null;
}
public async Task<AuthSession?> FindValidSessionAsync(Guid accountId, Guid clientId, bool withAccount = false)
{ {
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
return await db.AuthSessions var queryable = db.AuthSessions
.Include(s => s.Challenge) .Include(s => s.Challenge)
.AsQueryable();
if (withAccount)
queryable = queryable
.Include(s => s.Account)
.ThenInclude(a => a.Profile)
.Include(a => a.Account.Contacts)
.AsQueryable();
return await queryable
.Where(s => s.AccountId == accountId && .Where(s => s.AccountId == accountId &&
s.AppId == clientId && s.AppId == clientId &&
(s.ExpiredAt == null || s.ExpiredAt > now) && (s.ExpiredAt == null || s.ExpiredAt > now) &&
s.Challenge != null &&
s.Challenge.Type == ChallengeType.OAuth) s.Challenge.Type == ChallengeType.OAuth)
.OrderByDescending(s => s.CreatedAt) .OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
@@ -56,6 +73,149 @@ public class OidcProviderService(
return resp.Valid; return resp.Valid;
} }
public async Task<bool> ValidateRedirectUriAsync(Guid clientId, string redirectUri)
{
if (string.IsNullOrEmpty(redirectUri))
return false;
var client = await FindClientByIdAsync(clientId);
if (client?.Status != CustomAppStatus.Production)
return true;
if (client?.OauthConfig?.RedirectUris == null)
return false;
// Check if the redirect URI matches any of the allowed URIs
// For exact match
if (client.OauthConfig.RedirectUris.Contains(redirectUri))
return true;
// Check for wildcard matches (e.g., https://*.example.com/*)
foreach (var allowedUri in client.OauthConfig.RedirectUris)
{
if (string.IsNullOrEmpty(allowedUri))
continue;
// Handle wildcard in domain
if (allowedUri.Contains("*.") && allowedUri.StartsWith("http"))
{
try
{
var allowedUriObj = new Uri(allowedUri);
var redirectUriObj = new Uri(redirectUri);
if (allowedUriObj.Scheme != redirectUriObj.Scheme ||
allowedUriObj.Port != redirectUriObj.Port)
{
continue;
}
// Check if the domain matches the wildcard pattern
var allowedDomain = allowedUriObj.Host;
var redirectDomain = redirectUriObj.Host;
if (allowedDomain.StartsWith("*."))
{
var baseDomain = allowedDomain[2..]; // Remove the "*." prefix
if (redirectDomain == baseDomain || redirectDomain.EndsWith($".{baseDomain}"))
{
// Check path
var allowedPath = allowedUriObj.AbsolutePath.TrimEnd('/');
var redirectPath = redirectUriObj.AbsolutePath.TrimEnd('/');
if (string.IsNullOrEmpty(allowedPath) ||
redirectPath.StartsWith(allowedPath, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
}
catch (UriFormatException)
{
// Invalid URI format in allowed URIs, skip
continue;
}
}
}
return false;
}
private string GenerateIdToken(
CustomApp client,
AuthSession session,
string? nonce = null,
IEnumerable<string>? scopes = null
)
{
var tokenHandler = new JwtSecurityTokenHandler();
var clock = SystemClock.Instance;
var now = clock.GetCurrentInstant();
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Iss, _options.IssuerUri),
new(JwtRegisteredClaimNames.Sub, session.AccountId.ToString()),
new(JwtRegisteredClaimNames.Aud, client.Slug),
new(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
new(JwtRegisteredClaimNames.Exp,
now.Plus(Duration.FromSeconds(_options.AccessTokenLifetime.TotalSeconds)).ToUnixTimeSeconds()
.ToString(), ClaimValueTypes.Integer64),
new(JwtRegisteredClaimNames.AuthTime, session.CreatedAt.ToUnixTimeSeconds().ToString(),
ClaimValueTypes.Integer64),
};
// Add nonce if provided (required for implicit and hybrid flows)
if (!string.IsNullOrEmpty(nonce))
{
claims.Add(new Claim("nonce", nonce));
}
// Add email claim if email scope is requested
var scopesList = scopes?.ToList() ?? [];
if (scopesList.Contains("email"))
{
var contact = session.Account.Contacts.FirstOrDefault(c => c.Type == AccountContactType.Email);
if (contact is not null)
{
claims.Add(new Claim(JwtRegisteredClaimNames.Email, contact.Content));
claims.Add(new Claim("email_verified", contact.VerifiedAt is not null ? "true" : "false",
ClaimValueTypes.Boolean));
}
}
// Add profile claims if profile scope is requested
if (scopes != null && scopesList.Contains("profile"))
{
if (!string.IsNullOrEmpty(session.Account.Name))
claims.Add(new Claim("preferred_username", session.Account.Name));
if (!string.IsNullOrEmpty(session.Account.Nick))
claims.Add(new Claim("name", session.Account.Nick));
if (!string.IsNullOrEmpty(session.Account.Profile.FirstName))
claims.Add(new Claim("given_name", session.Account.Profile.FirstName));
if (!string.IsNullOrEmpty(session.Account.Profile.LastName))
claims.Add(new Claim("family_name", session.Account.Profile.LastName));
}
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Issuer = _options.IssuerUri,
Audience = client.Id.ToString(),
Expires = now.Plus(Duration.FromSeconds(_options.AccessTokenLifetime.TotalSeconds)).ToDateTimeUtc(),
NotBefore = now.ToDateTimeUtc(),
SigningCredentials = new SigningCredentials(
new RsaSecurityKey(_options.GetRsaPrivateKey()),
SecurityAlgorithms.RsaSha256
)
};
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
public async Task<TokenResponse> GenerateTokenResponseAsync( public async Task<TokenResponse> GenerateTokenResponseAsync(
Guid clientId, Guid clientId,
string? authorizationCode = null, string? authorizationCode = null,
@@ -71,24 +231,43 @@ public class OidcProviderService(
AuthSession session; AuthSession session;
var clock = SystemClock.Instance; var clock = SystemClock.Instance;
var now = clock.GetCurrentInstant(); var now = clock.GetCurrentInstant();
string? nonce = null;
List<string>? scopes = null; List<string>? scopes = null;
if (authorizationCode != null) if (authorizationCode != null)
{ {
// Authorization code flow // Authorization code flow
var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier); var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier);
if (authCode is null) throw new InvalidOperationException("Invalid authorization code"); if (authCode == null)
var account = await db.Accounts.Where(a => a.Id == authCode.AccountId).FirstOrDefaultAsync(); throw new InvalidOperationException("Invalid authorization code");
if (account is null) throw new InvalidOperationException("Account was not found");
// Load the session for the user
var existingSession = await FindValidSessionAsync(authCode.AccountId, clientId, withAccount: true);
if (existingSession is null)
{
var account = await db.Accounts
.Where(a => a.Id == authCode.AccountId)
.Include(a => a.Profile)
.Include(a => a.Contacts)
.FirstOrDefaultAsync();
if (account is null) throw new InvalidOperationException("Account not found");
session = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant(), clientId);
session.Account = account;
}
else
{
session = existingSession;
}
session = await auth.CreateSessionForOidcAsync(account, now, clientId);
scopes = authCode.Scopes; scopes = authCode.Scopes;
nonce = authCode.Nonce;
} }
else if (sessionId.HasValue) else if (sessionId.HasValue)
{ {
// Refresh token flow // Refresh token flow
session = await FindSessionByIdAsync(sessionId.Value) ?? session = await FindSessionByIdAsync(sessionId.Value) ??
throw new InvalidOperationException("Invalid session"); throw new InvalidOperationException("Session not found");
// Verify the session is still valid // Verify the session is still valid
if (session.ExpiredAt < now) if (session.ExpiredAt < now)
@@ -102,13 +281,15 @@ public class OidcProviderService(
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds; var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn)); var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
// Generate an access token // Generate tokens
var accessToken = GenerateJwtToken(client, session, expiresAt, scopes); var accessToken = GenerateJwtToken(client, session, expiresAt, scopes);
var idToken = GenerateIdToken(client, session, nonce, scopes);
var refreshToken = GenerateRefreshToken(session); var refreshToken = GenerateRefreshToken(session);
return new TokenResponse return new TokenResponse
{ {
AccessToken = accessToken, AccessToken = accessToken,
IdToken = idToken,
ExpiresIn = expiresIn, ExpiresIn = expiresIn,
TokenType = "Bearer", TokenType = "Bearer",
RefreshToken = refreshToken, RefreshToken = refreshToken,
@@ -134,11 +315,10 @@ 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("client_id", client.Id)
]), ]),
Expires = expiresAt.ToDateTimeUtc(), Expires = expiresAt.ToDateTimeUtc(),
Issuer = _options.IssuerUri, Issuer = _options.IssuerUri,
Audience = client.Id Audience = client.Slug
}; };
// Try to use RSA signing if keys are available, fall back to HMAC // Try to use RSA signing if keys are available, fall back to HMAC
@@ -204,51 +384,6 @@ public class OidcProviderService(
return Convert.ToBase64String(session.Id.ToByteArray()); return Convert.ToBase64String(session.Id.ToByteArray());
} }
private static bool VerifyHashedSecret(string secret, string hashedSecret)
{
// In a real implementation, you'd use a proper password hashing algorithm like PBKDF2, bcrypt, or Argon2
// For now, we'll do a simple comparison, but you should replace this with proper hashing
return string.Equals(secret, hashedSecret, StringComparison.Ordinal);
}
public async Task<string> GenerateAuthorizationCodeForReuseSessionAsync(
AuthSession session,
Guid clientId,
string redirectUri,
IEnumerable<string> scopes,
string? codeChallenge = null,
string? codeChallengeMethod = null,
string? nonce = null)
{
var clock = SystemClock.Instance;
var now = clock.GetCurrentInstant();
var code = Guid.NewGuid().ToString("N");
// Update the session's last activity time
await db.AuthSessions.Where(s => s.Id == session.Id)
.ExecuteUpdateAsync(s => s.SetProperty(s => s.LastGrantedAt, now));
// Create the authorization code info
var authCodeInfo = new AuthorizationCodeInfo
{
ClientId = clientId,
AccountId = session.AccountId,
RedirectUri = redirectUri,
Scopes = scopes.ToList(),
CodeChallenge = codeChallenge,
CodeChallengeMethod = codeChallengeMethod,
Nonce = nonce,
CreatedAt = now
};
// Store the code with its metadata in the cache
var cacheKey = $"auth:code:{code}";
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, session.AccountId);
return code;
}
public async Task<string> GenerateAuthorizationCodeAsync( public async Task<string> GenerateAuthorizationCodeAsync(
Guid clientId, Guid clientId,
Guid userId, Guid userId,
@@ -278,7 +413,7 @@ public class OidcProviderService(
}; };
// Store the code with its metadata in the cache // Store the code with its metadata in the cache
var cacheKey = $"auth:code:{code}"; var cacheKey = $"auth:oidc-code:{code}";
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime); await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId); logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId);
@@ -292,7 +427,7 @@ public class OidcProviderService(
string? codeVerifier = null string? codeVerifier = null
) )
{ {
var cacheKey = $"auth:code:{code}"; var cacheKey = $"auth:oidc-code:{code}";
var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey); var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey);
if (!found || authCode == null) if (!found || authCode == null)

View File

@@ -340,7 +340,7 @@ 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/token?token={loginToken}"); return Redirect($"/auth/callback?token={loginToken}");
} }
private static async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request) private static async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request)

View File

@@ -84,6 +84,7 @@ public class OidcState
{ {
return JsonSerializer.Serialize(this, new JsonSerializerOptions return JsonSerializer.Serialize(this, new JsonSerializerOptions
{ {
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
}); });

View File

@@ -1,3 +1,4 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using DysonNetwork.Pass.Wallet; using DysonNetwork.Pass.Wallet;
@@ -22,8 +23,9 @@ public class TokenAuthService(
/// then cache and return. /// then cache and return.
/// </summary> /// </summary>
/// <param name="token">Incoming token string</param> /// <param name="token">Incoming token string</param>
/// <param name="ipAddress">Client IP address, for logging purposes</param>
/// <returns>(Valid, Session, Message)</returns> /// <returns>(Valid, Session, Message)</returns>
public async Task<(bool Valid, AuthSession? Session, string? Message)> AuthenticateTokenAsync(string token) public async Task<(bool Valid, AuthSession? Session, string? Message)> AuthenticateTokenAsync(string token, string? ipAddress = null)
{ {
try try
{ {
@@ -33,6 +35,11 @@ public class TokenAuthService(
return (false, null, "No token provided."); return (false, null, "No token provided.");
} }
if (!string.IsNullOrEmpty(ipAddress))
{
logger.LogDebug("AuthenticateTokenAsync: client IP: {IpAddress}", ipAddress);
}
// token fingerprint for correlation // token fingerprint for correlation
var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
var tokenFp = tokenHash[..8]; var tokenFp = tokenHash[..8];
@@ -70,7 +77,7 @@ public class TokenAuthService(
"AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})", "AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})",
sessionId, sessionId,
session.AccountId, session.AccountId,
session.Challenge.Scopes.Count, session.Challenge?.Scopes.Count,
session.ExpiredAt session.ExpiredAt
); );
return (true, session, null); return (true, session, null);
@@ -103,11 +110,11 @@ public class TokenAuthService(
"AuthenticateTokenAsync: DB session loaded (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId}, appId={AppId}, scopes={ScopeCount}, ip={Ip}, uaLen={UaLen})", "AuthenticateTokenAsync: DB session loaded (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId}, appId={AppId}, scopes={ScopeCount}, ip={Ip}, uaLen={UaLen})",
sessionId, sessionId,
session.AccountId, session.AccountId,
session.Challenge.ClientId, session.Challenge?.ClientId,
session.AppId, session.AppId,
session.Challenge.Scopes.Count, session.Challenge?.Scopes.Count,
session.Challenge.IpAddress, session.Challenge?.IpAddress,
(session.Challenge.UserAgent ?? string.Empty).Length (session.Challenge?.UserAgent ?? string.Empty).Length
); );
logger.LogDebug("AuthenticateTokenAsync: enriching account with subscription (accountId={AccountId})", session.AccountId); logger.LogDebug("AuthenticateTokenAsync: enriching account with subscription (accountId={AccountId})", session.AccountId);
@@ -136,7 +143,7 @@ public class TokenAuthService(
"AuthenticateTokenAsync: success via DB (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId})", "AuthenticateTokenAsync: success via DB (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId})",
sessionId, sessionId,
session.AccountId, session.AccountId,
session.Challenge.ClientId session.Challenge?.ClientId
); );
return (true, session, null); return (true, session, null);
} }

View File

@@ -60,6 +60,12 @@ const router = createRouter({
name: 'authCallback', name: 'authCallback',
component: () => import('../views/callback.vue'), component: () => import('../views/callback.vue'),
}, },
{
path: '/auth/authorize',
name: 'authAuthorize',
component: () => import('../views/authorize.vue'),
meta: { requiresAuth: true },
},
{ {
path: '/:notFound(.*)', path: '/:notFound(.*)',
name: 'errorNotFound', name: 'errorNotFound',

View File

@@ -0,0 +1,191 @@
<template>
<div class="flex items-center justify-center h-full p-4">
<n-card class="w-full max-w-md" title="Authorize Application">
<n-spin :show="isLoading">
<div v-if="error" class="mb-4">
<n-alert type="error" :title="error" closable @close="error = null" />
</div>
<!-- App Info Section -->
<div v-if="clientInfo" class="mb-6">
<div class="flex items-center">
<n-avatar
v-if="clientInfo.picture"
:src="clientInfo.picture.url"
:alt="clientInfo.client_name"
size="large"
class="mr-3"
/>
<div>
<h2 class="text-xl font-semibold">
{{ clientInfo.client_name || 'Unknown Application' }}
</h2>
<span v-if="isNewApp">wants to access your Solar Network account</span>
<span v-else>wants to access your account</span>
</div>
</div>
<!-- Requested Permissions -->
<n-card size="small" class="mt-4">
<h3 class="font-medium mb-2">
This will allow {{ clientInfo.client_name || 'the app' }} to:
</h3>
<ul class="space-y-1">
<li v-for="scope in requestedScopes" :key="scope" class="flex items-start">
<n-icon :component="CheckBoxFilled" class="mt-1 mr-2" />
<span>{{ scope }}</span>
</li>
</ul>
</n-card>
<!-- Buttons -->
<div class="flex gap-3 mt-4">
<n-button
type="primary"
:loading="isAuthorizing"
@click="handleAuthorize"
class="flex-grow-1 w-1/2"
>
Authorize
</n-button>
<n-button
type="tertiary"
:disabled="isAuthorizing"
@click="handleDeny"
class="flex-grow-1 w-1/2"
>
Deny
</n-button>
</div>
<div class="mt-4 text-xs text-gray-500 text-center">
By authorizing, you agree to the
<n-button text type="primary" size="tiny" @click="openTerms" class="px-1">
Terms of Service
</n-button>
and
<n-button text type="primary" size="tiny" @click="openPrivacy" class="px-1">
Privacy Policy
</n-button>
</div>
</div>
</n-spin>
</n-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { NCard, NButton, NSpin, NAlert, NAvatar, NIcon } from 'naive-ui'
import { CheckBoxFilled } from '@vicons/material'
const route = useRoute()
// State
const isLoading = ref(true)
const isAuthorizing = ref(false)
const error = ref<string | null>(null)
const clientInfo = ref<{
client_name?: string
home_uri?: string
picture?: { url: string }
terms_of_service_uri?: string
privacy_policy_uri?: string
scopes?: string[]
} | null>(null)
const isNewApp = ref(false)
// Computed properties
const requestedScopes = computed(() => {
return clientInfo.value?.scopes || []
})
// Methods
async function fetchClientInfo() {
try {
const response = await fetch(`/api/auth/open/authorize?${window.location.search.slice(1)}`)
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error_description || 'Failed to load authorization request')
}
clientInfo.value = await response.json()
checkIfNewApp()
} catch (err: any) {
error.value = err.message || 'An error occurred while loading the authorization request'
} finally {
isLoading.value = false
}
}
function checkIfNewApp() {
// In a real app, you might want to check if this is the first time authorizing this app
// For now, we'll just set it to false
isNewApp.value = false
}
async function handleAuthorize() {
isAuthorizing.value = true
try {
// In a real implementation, you would submit the authorization
const response = await fetch('/api/auth/open/authorize', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
...route.query,
authorize: 'true',
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error_description || 'Authorization failed')
}
const data = await response.json()
if (data.redirect_uri) {
window.open(data.redirect_uri, '_self')
}
} catch (err: any) {
error.value = err.message || 'An error occurred during authorization'
} finally {
isAuthorizing.value = false
}
}
function handleDeny() {
// Redirect back to the client with an error
// Ensure redirect_uri is always a string (not an array)
const redirectUriStr = Array.isArray(route.query.redirect_uri)
? route.query.redirect_uri[0] || clientInfo.value?.home_uri || '/'
: route.query.redirect_uri || clientInfo.value?.home_uri || '/'
const redirectUri = new URL(redirectUriStr)
// Ensure state is always a string (not an array)
const state = Array.isArray(route.query.state)
? route.query.state[0] || ''
: route.query.state || ''
const params = new URLSearchParams({
error: 'access_denied',
error_description: 'The user denied the authorization request',
state: state,
})
window.open(`${redirectUri}?${params}`, "_self")
}
function openTerms() {
window.open(clientInfo.value?.terms_of_service_uri || '#', "_blank")
}
function openPrivacy() {
window.open(clientInfo.value?.privacy_policy_uri || '#', "_blank")
}
// Lifecycle
onMounted(() => {
fetchClientInfo()
})
</script>
<style scoped>
/* Add any custom styles here */
</style>

View File

@@ -1,11 +1,10 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
using NodaTime; using NodaTime;
using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Pass.Credit; namespace DysonNetwork.Pass.Credit;
using Google.Protobuf.WellKnownTypes;
public class SocialCreditRecord : ModelBase public class SocialCreditRecord : ModelBase
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
@@ -26,8 +25,8 @@ public class SocialCreditRecord : ModelBase
Reason = Reason, Reason = Reason,
Delta = Delta, Delta = Delta,
AccountId = AccountId.ToString(), AccountId = AccountId.ToString(),
CreatedAt = Timestamp.FromDateTimeOffset(CreatedAt.ToDateTimeOffset()), CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = Timestamp.FromDateTimeOffset(UpdatedAt.ToDateTimeOffset()) UpdatedAt = UpdatedAt.ToTimestamp()
}; };
return proto; return proto;

View File

@@ -18,6 +18,13 @@ public class SocialCreditService(AppDatabase db, ICacheService cache)
}; };
db.SocialCreditRecords.Add(record); db.SocialCreditRecords.Add(record);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await db.AccountProfiles
.Where(p => p.AccountId == accountId)
.ExecuteUpdateAsync(p => p.SetProperty(v => v.SocialCredits, v => v.SocialCredits + record.Delta));
await cache.RemoveAsync($"{CacheKeyPrefix}{accountId}");
return record; return record;
} }
@@ -32,6 +39,7 @@ public class SocialCreditService(AppDatabase db, ICacheService cache)
.Where(x => x.AccountId == accountId) .Where(x => x.AccountId == accountId)
.SumAsync(x => x.Delta); .SumAsync(x => x.Delta);
records += BaseSocialCredit; records += BaseSocialCredit;
await cache.SetAsync($"{CacheKeyPrefix}{accountId}", records); await cache.SetAsync($"{CacheKeyPrefix}{accountId}", records);
return records; return records;
} }

View File

@@ -13,7 +13,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="NATS.Client.Core" Version="2.6.6" /> <PackageReference Include="Nager.Holiday" Version="1.0.1" />
<PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115"> <PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
@@ -24,7 +24,6 @@
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0"/> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" 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="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"/>
@@ -50,6 +49,7 @@
</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,12 +1,10 @@
using dotnet_etcd;
using dotnet_etcd.interfaces;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
namespace DysonNetwork.Pass.Email; namespace DysonNetwork.Pass.Email;
public class EmailService( public class EmailService(
PusherService.PusherServiceClient pusher, RingService.RingServiceClient pusher,
RazorViewRenderer viewRenderer, RazorViewRenderer viewRenderer,
ILogger<EmailService> logger ILogger<EmailService> logger
) )

View File

@@ -42,8 +42,17 @@ public class LastActiveFlushHandler(IServiceProvider srp, ILogger<LastActiveFlus
var updatingSessions = sessionMap.Select(x => x.Key).ToList(); var updatingSessions = sessionMap.Select(x => x.Key).ToList();
var sessionUpdates = await db.AuthSessions var sessionUpdates = await db.AuthSessions
.Where(s => updatingSessions.Contains(s.Id)) .Where(s => updatingSessions.Contains(s.Id))
.ExecuteUpdateAsync(s => s.SetProperty(x => x.LastGrantedAt, now)); .ExecuteUpdateAsync(s =>
s.SetProperty(x => x.LastGrantedAt, now)
);
logger.LogInformation("Updated {Count} auth sessions according to LastActiveInfo", sessionUpdates); logger.LogInformation("Updated {Count} auth sessions according to LastActiveInfo", sessionUpdates);
var newExpiration = now.Plus(Duration.FromDays(7));
var keepAliveSessionUpdates = await db.AuthSessions
.Where(s => updatingSessions.Contains(s.Id) && s.ExpiredAt != null)
.ExecuteUpdateAsync(s =>
s.SetProperty(x => x.ExpiredAt, newExpiration)
);
logger.LogInformation("Updated {Count} auth sessions' duration according to LastActiveInfo", sessionUpdates);
var updatingAccounts = accountMap.Select(x => x.Key).ToList(); var updatingAccounts = accountMap.Select(x => x.Key).ToList();
var profileUpdates = await db.AccountProfiles var profileUpdates = await db.AccountProfiles
@@ -53,7 +62,8 @@ public class LastActiveFlushHandler(IServiceProvider srp, ILogger<LastActiveFlus
} }
} }
public class LastActiveFlushJob(FlushBufferService fbs, LastActiveFlushHandler hdl, ILogger<LastActiveFlushJob> logger) : IJob public class LastActiveFlushJob(FlushBufferService fbs, LastActiveFlushHandler hdl, ILogger<LastActiveFlushJob> logger)
: IJob
{ {
public async Task Execute(IJobExecutionContext context) public async Task Execute(IJobExecutionContext context)
{ {
@@ -62,7 +72,8 @@ public class LastActiveFlushJob(FlushBufferService fbs, LastActiveFlushHandler h
logger.LogInformation("Running LastActiveInfo flush job..."); logger.LogInformation("Running LastActiveInfo flush job...");
await fbs.FlushAsync(hdl); await fbs.FlushAsync(hdl);
logger.LogInformation("Completed LastActiveInfo flush job..."); logger.LogInformation("Completed LastActiveInfo flush job...");
} catch (Exception ex) }
catch (Exception ex)
{ {
logger.LogError(ex, "Error running LastActiveInfo job..."); logger.LogError(ex, "Error running LastActiveInfo job...");
} }

View File

@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Pass;
[ApiController]
[Route("/api/ip-check")]
public class IpCheckController : ControllerBase
{
public class IpCheckResponse
{
public string? RemoteIp { get; set; }
public string? XForwardedFor { get; set; }
public string? XForwardedProto { get; set; }
public string? XForwardedHost { get; set; }
public string? XRealIp { get; set; }
public string? Headers { get; set; }
}
[HttpGet]
public ActionResult<IpCheckResponse> GetIpCheck()
{
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
var xForwardedFor = Request.Headers["X-Forwarded-For"].FirstOrDefault();
var xForwardedProto = Request.Headers["X-Forwarded-Proto"].FirstOrDefault();
var xForwardedHost = Request.Headers["X-Forwarded-Host"].FirstOrDefault();
var realIp = Request.Headers["X-Real-IP"].FirstOrDefault();
return Ok(new IpCheckResponse
{
RemoteIp = ip,
XForwardedFor = xForwardedFor,
XForwardedProto = xForwardedProto,
XForwardedHost = xForwardedHost,
XRealIp = realIp,
Headers = string.Join('\n', Request.Headers.Select(h => $"{h.Key}: {h.Value}")),
});
}
}

View File

@@ -1,10 +1,9 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Pass.Leveling; namespace DysonNetwork.Pass.Leveling;
using Google.Protobuf.WellKnownTypes;
public class ExperienceRecord : ModelBase public class ExperienceRecord : ModelBase
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
@@ -26,8 +25,8 @@ public class ExperienceRecord : ModelBase
Delta = Delta, Delta = Delta,
BonusMultiplier = BonusMultiplier, BonusMultiplier = BonusMultiplier,
AccountId = AccountId.ToString(), AccountId = AccountId.ToString(),
CreatedAt = Timestamp.FromDateTimeOffset(CreatedAt.ToDateTimeOffset()), CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = Timestamp.FromDateTimeOffset(UpdatedAt.ToDateTimeOffset()) UpdatedAt = UpdatedAt.ToTimestamp()
}; };
return proto; return proto;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class CacheSocialCreditsInProfile : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "social_credits",
table: "account_profiles",
type: "double precision",
nullable: false,
defaultValue: 0.0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "social_credits",
table: "account_profiles");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddOrderProductIdentifier : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "product_identifier",
table: "payment_orders",
type: "character varying(4096)",
maxLength: 4096,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "product_identifier",
table: "payment_orders");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddAccountRegion : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "region",
table: "accounts",
type: "character varying(32)",
maxLength: 32,
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "region",
table: "accounts");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,63 @@
using DysonNetwork.Shared.GeoIp;
using Microsoft.EntityFrameworkCore.Migrations;
using NetTopologySuite.Geometries;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class RefactorGeoIpPoint : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("UPDATE auth_challenges SET location = NULL;");
migrationBuilder.Sql("UPDATE action_logs SET location = NULL;");
migrationBuilder.DropColumn(
name: "location",
table: "auth_challenges");
migrationBuilder.AddColumn<GeoPoint>(
name: "location",
table: "auth_challenges",
type: "jsonb",
nullable: true);
migrationBuilder.DropColumn(
name: "location",
table: "action_logs");
migrationBuilder.AddColumn<GeoPoint>(
name: "location",
table: "action_logs",
type: "jsonb",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "location",
table: "auth_challenges");
migrationBuilder.AddColumn<Point>(
name: "location",
table: "auth_challenges",
type: "geometry",
nullable: true);
migrationBuilder.DropColumn(
name: "location",
table: "action_logs");
migrationBuilder.AddColumn<Point>(
name: "location",
table: "action_logs",
type: "geometry",
nullable: true);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class RemoveNetTopo : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.OldAnnotation("Npgsql:PostgresExtension:postgis", ",,");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:PostgresExtension:postgis", ",,");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddAutomatedStatus : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "app_identifier",
table: "account_statuses",
type: "character varying(4096)",
maxLength: 4096,
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "is_automated",
table: "account_statuses",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "app_identifier",
table: "account_statuses");
migrationBuilder.DropColumn(
name: "is_automated",
table: "account_statuses");
}
}
}

View File

@@ -6,10 +6,10 @@ using DysonNetwork.Pass;
using DysonNetwork.Pass.Account; using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Wallet; using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.GeoIp;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NetTopologySuite.Geometries;
using NodaTime; using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
@@ -27,7 +27,6 @@ namespace DysonNetwork.Pass.Migrations
.HasAnnotation("ProductVersion", "9.0.7") .HasAnnotation("ProductVersion", "9.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Pass.Account.AbuseReport", b => modelBuilder.Entity("DysonNetwork.Pass.Account.AbuseReport", b =>
@@ -132,6 +131,12 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("character varying(256)") .HasColumnType("character varying(256)")
.HasColumnName("nick"); .HasColumnName("nick");
b.Property<string>("Region")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("region");
b.Property<Instant>("UpdatedAt") b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("updated_at"); .HasColumnName("updated_at");
@@ -462,6 +467,10 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("character varying(1024)") .HasColumnType("character varying(1024)")
.HasColumnName("pronouns"); .HasColumnName("pronouns");
b.Property<double>("SocialCredits")
.HasColumnType("double precision")
.HasColumnName("social_credits");
b.Property<string>("TimeZone") b.Property<string>("TimeZone")
.HasMaxLength(1024) .HasMaxLength(1024)
.HasColumnType("character varying(1024)") .HasColumnType("character varying(1024)")
@@ -515,8 +524,8 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("character varying(128)") .HasColumnType("character varying(128)")
.HasColumnName("ip_address"); .HasColumnName("ip_address");
b.Property<Point>("Location") b.Property<GeoPoint>("Location")
.HasColumnType("geometry") .HasColumnType("jsonb")
.HasColumnName("location"); .HasColumnName("location");
b.Property<Dictionary<string, object>>("Meta") b.Property<Dictionary<string, object>>("Meta")
@@ -758,6 +767,11 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("account_id"); .HasColumnName("account_id");
b.Property<string>("AppIdentifier")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("app_identifier");
b.Property<int>("Attitude") b.Property<int>("Attitude")
.HasColumnType("integer") .HasColumnType("integer")
.HasColumnName("attitude"); .HasColumnName("attitude");
@@ -774,6 +788,10 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at"); .HasColumnName("deleted_at");
b.Property<bool>("IsAutomated")
.HasColumnType("boolean")
.HasColumnName("is_automated");
b.Property<bool>("IsInvisible") b.Property<bool>("IsInvisible")
.HasColumnType("boolean") .HasColumnType("boolean")
.HasColumnName("is_invisible"); .HasColumnName("is_invisible");
@@ -891,8 +909,8 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("character varying(128)") .HasColumnType("character varying(128)")
.HasColumnName("ip_address"); .HasColumnName("ip_address");
b.Property<Point>("Location") b.Property<GeoPoint>("Location")
.HasColumnType("geometry") .HasColumnType("jsonb")
.HasColumnName("location"); .HasColumnName("location");
b.Property<string>("Nonce") b.Property<string>("Nonce")
@@ -1377,6 +1395,11 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("payee_wallet_id"); .HasColumnName("payee_wallet_id");
b.Property<string>("ProductIdentifier")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("product_identifier");
b.Property<string>("Remarks") b.Property<string>("Remarks")
.HasMaxLength(4096) .HasMaxLength(4096)
.HasColumnType("character varying(4096)") .HasColumnType("character varying(4096)")

View File

@@ -4,25 +4,21 @@ using DysonNetwork.Pass.Startup;
using DysonNetwork.Shared.Http; using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.PageData; using DysonNetwork.Shared.PageData;
using DysonNetwork.Shared.Registry; using DysonNetwork.Shared.Registry;
using DysonNetwork.Shared.Stream;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
// Configure Kestrel and server options // Configure Kestrel and server options
builder.ConfigureAppKestrel(builder.Configuration); builder.ConfigureAppKestrel(builder.Configuration);
// Add metrics and telemetry
builder.Services.AddAppMetrics();
// Add application services // Add application services
builder.Services.AddRegistryService(builder.Configuration);
builder.Services.AddStreamConnection(builder.Configuration);
builder.Services.AddAppServices(builder.Configuration); builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppRateLimiting(); builder.Services.AddAppRateLimiting();
builder.Services.AddAppAuthentication(); builder.Services.AddAppAuthentication();
builder.Services.AddAppSwagger(); builder.Services.AddAppSwagger();
builder.Services.AddPusherService(); builder.Services.AddRingService();
builder.Services.AddDriveService(); builder.Services.AddDriveService();
builder.Services.AddDevelopService(); builder.Services.AddDevelopService();
@@ -41,6 +37,8 @@ builder.Services.AddTransient<IPageDataProvider, AccountPageData>();
var app = builder.Build(); var app = builder.Build();
app.MapDefaultEndpoints();
// Run database migrations // Run database migrations
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
@@ -51,8 +49,6 @@ using (var scope = app.Services.CreateScope())
// Configure application middleware pipeline // Configure application middleware pipeline
app.ConfigureAppMiddleware(builder.Configuration, builder.Environment.ContentRootPath); app.ConfigureAppMiddleware(builder.Configuration, builder.Environment.ContentRootPath);
app.MapGatewayProxy();
app.MapPages(Path.Combine(builder.Environment.WebRootPath, "dist", "index.html")); app.MapPages(Path.Combine(builder.Environment.WebRootPath, "dist", "index.html"));
// Configure gRPC // Configure gRPC

View File

@@ -170,5 +170,47 @@ namespace DysonNetwork.Sphere.Resources.Localization {
return ResourceManager.GetString("NewLoginBody", resourceCulture); return ResourceManager.GetString("NewLoginBody", resourceCulture);
} }
} }
internal static string FriendRequestTitle {
get {
return ResourceManager.GetString("FriendRequestTitle", resourceCulture);
}
}
internal static string FriendRequestBody {
get {
return ResourceManager.GetString("FriendRequestBody", resourceCulture);
}
}
internal static string OrderReceivedTitle {
get {
return ResourceManager.GetString("OrderReceivedTitle", resourceCulture);
}
}
internal static string OrderReceivedBody {
get {
return ResourceManager.GetString("OrderReceivedBody", resourceCulture);
}
}
internal static string TransactionNewTitle {
get {
return ResourceManager.GetString("TransactionNewTitle", resourceCulture);
}
}
internal static string TransactionNewBodyPlus {
get {
return ResourceManager.GetString("TransactionNewBodyPlus", resourceCulture);
}
}
internal static string TransactionNewBodyMinus {
get {
return ResourceManager.GetString("TransactionNewBodyMinus", resourceCulture);
}
}
} }
} }

View File

@@ -78,7 +78,7 @@
<value>Order {0} recipent</value> <value>Order {0} recipent</value>
</data> </data>
<data name="OrderPaidBody" xml:space="preserve"> <data name="OrderPaidBody" xml:space="preserve">
<value>{0} {1} was removed from your wallet to pay {2}</value> <value>Paid order {2} with {0} {1}</value>
</data> </data>
<data name="NewLoginTitle" xml:space="preserve"> <data name="NewLoginTitle" xml:space="preserve">
<value>New login detected</value> <value>New login detected</value>
@@ -92,4 +92,19 @@
<data name="FriendRequestBody" xml:space="preserve"> <data name="FriendRequestBody" xml:space="preserve">
<value>You can go to relationships page and decide accept their request or not.</value> <value>You can go to relationships page and decide accept their request or not.</value>
</data> </data>
<data name="OrderReceivedTitle" xml:space="preserve">
<value>Order {0} recipent</value>
</data>
<data name="OrderReceivedBody" xml:space="preserve">
<value>Received {2} payment of {0} {1}</value>
</data>
<data name="TransactionNewTitle" xml:space="preserve">
<value>Transaction {0}</value>
</data>
<data name="TransactionNewBodyPlus" xml:space="preserve">
<value>{0} {1} added to your wallet</value>
</data>
<data name="TransactionNewBodyMinus" xml:space="preserve">
<value>{0} {1} removed from your wallet</value>
</data>
</root> </root>

View File

@@ -67,10 +67,10 @@
<value>感谢你支持 Solar Network 的开发!你的 {0} 天 {1} 订阅刚刚开始,接下来来探索新解锁的新功能吧!</value> <value>感谢你支持 Solar Network 的开发!你的 {0} 天 {1} 订阅刚刚开始,接下来来探索新解锁的新功能吧!</value>
</data> </data>
<data name="OrderPaidTitle" xml:space="preserve"> <data name="OrderPaidTitle" xml:space="preserve">
<value>订单回执 {0}</value> <value>订单收据 {0}</value>
</data> </data>
<data name="OrderPaidBody" xml:space="preserve"> <data name="OrderPaidBody" xml:space="preserve">
<value>{0} {1} 已从你的帐户中扣除来支付 {2}</value> <value>已支付订单 {2} 的 {0} {1}</value>
</data> </data>
<data name="NewLoginTitle" xml:space="preserve"> <data name="NewLoginTitle" xml:space="preserve">
<value>检测到新登陆</value> <value>检测到新登陆</value>
@@ -84,4 +84,19 @@
<data name="FriendRequestBody" xml:space="preserve"> <data name="FriendRequestBody" xml:space="preserve">
<value>您可以前往人际关系页面来决定时候要接受他们的邀请。</value> <value>您可以前往人际关系页面来决定时候要接受他们的邀请。</value>
</data> </data>
<data name="OrderReceivedTitle" xml:space="preserve">
<value>订单收据 {0}</value>
</data>
<data name="OrderReceivedBody" xml:space="preserve">
<value>收到订单 {2} 支付的 {0} {1}</value>
</data>
<data name="TransactionNewTitle" xml:space="preserve">
<value>交易 {0}</value>
</data>
<data name="TransactionNewBodyPlus" xml:space="preserve">
<value>{0} {1} 添加到了您的钱包</value>
</data>
<data name="TransactionNewBodyMinus" xml:space="preserve">
<value>{0} {1} 从您的钱包移除</value>
</data>
</root> </root>

View File

@@ -4,6 +4,8 @@ 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.Wallet;
using DysonNetwork.Shared.Http;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.FileProviders;
using Prometheus; using Prometheus;
@@ -22,7 +24,7 @@ public static class ApplicationConfiguration
app.UseRequestLocalization(); app.UseRequestLocalization();
ConfigureForwardedHeaders(app, configuration); app.ConfigureForwardedHeaders(configuration);
app.UseCors(opts => app.UseCors(opts =>
opts.SetIsOriginAllowed(_ => true) opts.SetIsOriginAllowed(_ => true)
@@ -50,28 +52,6 @@ public static class ApplicationConfiguration
return app; return app;
} }
private static void ConfigureForwardedHeaders(WebApplication app, IConfiguration configuration)
{
var knownProxiesSection = configuration.GetSection("KnownProxies");
var forwardedHeadersOptions = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All };
if (knownProxiesSection.Exists())
{
var proxyAddresses = knownProxiesSection.Get<string[]>();
if (proxyAddresses != null)
foreach (var proxy in proxyAddresses)
if (IPAddress.TryParse(proxy, out var ipAddress))
forwardedHeadersOptions.KnownProxies.Add(ipAddress);
}
else
{
forwardedHeadersOptions.KnownProxies.Add(IPAddress.Any);
forwardedHeadersOptions.KnownProxies.Add(IPAddress.IPv6Any);
}
app.UseForwardedHeaders(forwardedHeadersOptions);
}
public static WebApplication ConfigureGrpcServices(this WebApplication app) public static WebApplication ConfigureGrpcServices(this WebApplication app)
{ {
app.MapGrpcService<AccountServiceGrpc>(); app.MapGrpcService<AccountServiceGrpc>();
@@ -81,6 +61,8 @@ public static class ApplicationConfiguration
app.MapGrpcService<SocialCreditServiceGrpc>(); app.MapGrpcService<SocialCreditServiceGrpc>();
app.MapGrpcService<ExperienceServiceGrpc>(); app.MapGrpcService<ExperienceServiceGrpc>();
app.MapGrpcService<BotAccountReceiverGrpc>(); app.MapGrpcService<BotAccountReceiverGrpc>();
app.MapGrpcService<WalletServiceGrpc>();
app.MapGrpcService<PaymentServiceGrpc>();
return app; return app;
} }

View File

@@ -0,0 +1,73 @@
using System.Text.Json;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Stream;
using NATS.Client.Core;
using NATS.Client.JetStream.Models;
using NATS.Net;
namespace DysonNetwork.Pass.Startup;
public class BroadcastEventHandler(
INatsConnection nats,
ILogger<BroadcastEventHandler> logger,
IServiceProvider serviceProvider
) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var js = nats.CreateJetStreamContext();
await js.EnsureStreamCreated("payment_events", [PaymentOrderEventBase.Type]);
var consumer = await js.CreateOrUpdateConsumerAsync("payment_events",
new ConsumerConfig("pass_payment_handler"),
cancellationToken: stoppingToken);
await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken))
{
PaymentOrderEvent? evt = null;
try
{
evt = JsonSerializer.Deserialize<PaymentOrderEvent>(msg.Data, GrpcTypeHelper.SerializerOptions);
logger.LogInformation(
"Received order event: {ProductIdentifier} {OrderId}",
evt?.ProductIdentifier,
evt?.OrderId
);
if (evt?.ProductIdentifier is null ||
!evt.ProductIdentifier.StartsWith(SubscriptionType.StellarProgram))
continue;
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);
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing payment order event for order {OrderId}. Redelivering.", evt?.OrderId);
await msg.NakAsync(cancellationToken: stoppingToken);
}
}
}
}

View File

@@ -1,40 +0,0 @@
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
using Prometheus;
using Prometheus.SystemMetrics;
namespace DysonNetwork.Pass.Startup;
public static class MetricsConfiguration
{
public static IServiceCollection AddAppMetrics(this IServiceCollection services)
{
// Prometheus
services.UseHttpClientMetrics();
services.AddHealthChecks();
services.AddSystemMetrics();
services.AddPrometheusEntityFrameworkMetrics();
services.AddPrometheusAspNetCoreMetrics();
services.AddPrometheusHttpClientMetrics();
// OpenTelemetry
services.AddOpenTelemetry()
.WithTracing(tracing =>
{
tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddOtlpExporter();
})
.WithMetrics(metrics =>
{
metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddOtlpExporter();
});
return services;
}
}

View File

@@ -12,6 +12,7 @@ using NodaTime;
using NodaTime.Serialization.SystemTextJson; using NodaTime.Serialization.SystemTextJson;
using StackExchange.Redis; using StackExchange.Redis;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using DysonNetwork.Pass.Auth.OidcProvider.Options; using DysonNetwork.Pass.Auth.OidcProvider.Options;
using DysonNetwork.Pass.Auth.OidcProvider.Services; using DysonNetwork.Pass.Auth.OidcProvider.Services;
@@ -33,11 +34,6 @@ public static class ServiceCollectionExtensions
services.AddLocalization(options => options.ResourcesPath = "Resources"); services.AddLocalization(options => options.ResourcesPath = "Resources");
services.AddDbContext<AppDatabase>(); services.AddDbContext<AppDatabase>();
services.AddSingleton<IConnectionMultiplexer>(_ =>
{
var connection = configuration.GetConnectionString("FastRetrieve")!;
return ConnectionMultiplexer.Connect(connection);
});
services.AddSingleton<IClock>(SystemClock.Instance); services.AddSingleton<IClock>(SystemClock.Instance);
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddSingleton<ICacheService, CacheServiceRedis>(); services.AddSingleton<ICacheService, CacheServiceRedis>();
@@ -52,11 +48,7 @@ public static class ServiceCollectionExtensions
options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB
}); });
services.AddPusherService(); services.AddRingService();
// Register gRPC services
services.AddScoped<AccountServiceGrpc>();
services.AddScoped<AuthServiceGrpc>();
// Register OIDC services // Register OIDC services
services.AddScoped<OidcService, GoogleOidcService>(); services.AddScoped<OidcService, GoogleOidcService>();
@@ -74,6 +66,7 @@ public static class ServiceCollectionExtensions
services.AddControllers().AddJsonOptions(options => services.AddControllers().AddJsonOptions(options =>
{ {
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower; options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
@@ -136,7 +129,8 @@ public static class ServiceCollectionExtensions
{ {
Version = "v1", Version = "v1",
Title = "Dyson Pass", Title = "Dyson Pass",
Description = "The authentication service of the Dyson Network. Mainly handling authentication and authorization.", Description =
"The authentication service of the Dyson Network. Mainly handling authentication and authorization.",
TermsOfService = new Uri("https://solsynth.dev/terms"), TermsOfService = new Uri("https://solsynth.dev/terms"),
License = new OpenApiLicense License = new OpenApiLicense
{ {
@@ -194,6 +188,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<ActionLogService>(); services.AddScoped<ActionLogService>();
services.AddScoped<AccountService>(); services.AddScoped<AccountService>();
services.AddScoped<AccountEventService>(); services.AddScoped<AccountEventService>();
services.AddScoped<NotableDaysService>();
services.AddScoped<ActionLogService>(); services.AddScoped<ActionLogService>();
services.AddScoped<RelationshipService>(); services.AddScoped<RelationshipService>();
services.AddScoped<MagicSpellService>(); services.AddScoped<MagicSpellService>();
@@ -211,6 +206,8 @@ public static class ServiceCollectionExtensions
services.Configure<OidcProviderOptions>(configuration.GetSection("OidcProvider")); services.Configure<OidcProviderOptions>(configuration.GetSection("OidcProvider"));
services.AddScoped<OidcProviderService>(); services.AddScoped<OidcProviderService>();
services.AddHostedService<BroadcastEventHandler>();
return services; return services;
} }
} }

View File

@@ -15,9 +15,7 @@ public class OrderController(PaymentService payment, AuthService auth, AppDataba
var order = await db.PaymentOrders.FindAsync(id); var order = await db.PaymentOrders.FindAsync(id);
if (order == null) if (order == null)
{
return NotFound(); return NotFound();
}
return Ok(order); return Ok(order);
} }
@@ -26,8 +24,7 @@ public class OrderController(PaymentService payment, AuthService auth, AppDataba
[Authorize] [Authorize]
public async Task<ActionResult<Order>> PayOrder(Guid id, [FromBody] PayOrderRequest request) public async Task<ActionResult<Order>> PayOrder(Guid id, [FromBody] PayOrderRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser || if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
// Validate PIN code // Validate PIN code
if (!await auth.ValidatePinCode(currentUser.Id, request.PinCode)) if (!await auth.ValidatePinCode(currentUser.Id, request.PinCode))
@@ -41,7 +38,7 @@ public class OrderController(PaymentService payment, AuthService auth, AppDataba
return BadRequest("Wallet was not found."); return BadRequest("Wallet was not found.");
// Pay the order // Pay the order
var paidOrder = await payment.PayOrderAsync(id, wallet.Id); var paidOrder = await payment.PayOrderAsync(id, wallet);
return Ok(paidOrder); return Ok(paidOrder);
} }
catch (InvalidOperationException ex) catch (InvalidOperationException ex)

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