62 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
190 changed files with 22933 additions and 2217 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,189 +1,60 @@
name: Build and Push Microservices
on:
push:
branches:
- master
workflow_dispatch:
jobs:
build-sphere:
runs-on: ubuntu-latest
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.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:
runs-on: ubuntu-latest
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:
runs-on: ubuntu-latest
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:
runs-on: ubuntu-latest
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.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:
runs-on: ubuntu-latest
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.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:
runs-on: ubuntu-latest
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.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: Aspire Publish Workflow
on:
push:
branches:
- master
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: "9.0.x"
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install Aspire CLI
run: dotnet tool install -g Aspire.Cli --prerelease
- name: Build and Publish Aspire Application
run: aspire publish --project ./DysonNetwork.Control/DysonNetwork.Control.csproj --output publish
- name: Tag and Push Images
run: |
IMAGES=( "sphere" "pass" "ring" "drive" "develop" )
for image in "${IMAGES[@]}"; do
IMAGE_NAME="ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-$image:alpha"
SOURCE_IMAGE_NAME="$image:latest" # Aspire's default local image name
echo "Tagging and pushing $SOURCE_IMAGE_NAME to $IMAGE_NAME..."
docker tag $SOURCE_IMAGE_NAME $IMAGE_NAME
docker push $IMAGE_NAME
done
- 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>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup>

View File

@@ -1,17 +1,16 @@
using DysonNetwork.Develop;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Registry;
using DysonNetwork.Develop.Startup;
using DysonNetwork.Shared.Stream;
using DysonNetwork.Shared.Registry;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.ConfigureAppKestrel(builder.Configuration);
builder.Services.AddRegistryService(builder.Configuration);
builder.Services.AddStreamConnection(builder.Configuration);
builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppAuthentication();
builder.Services.AddAppSwagger();
@@ -22,6 +21,8 @@ builder.Services.AddDriveService();
var app = builder.Build();
app.MapDefaultEndpoints();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();

View File

@@ -1,6 +1,7 @@
using System.Net;
using DysonNetwork.Develop.Identity;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http;
using Microsoft.AspNetCore.HttpOverrides;
using Prometheus;
@@ -18,7 +19,7 @@ public static class ApplicationConfiguration
app.UseRequestLocalization();
ConfigureForwardedHeaders(app, configuration);
app.ConfigureForwardedHeaders(configuration);
app.UseAuthentication();
app.UseAuthorization();
@@ -30,26 +31,4 @@ public static class ApplicationConfiguration
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.Serialization.SystemTextJson;
using System.Text.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Develop.Identity;
using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Cache;
@@ -19,19 +20,16 @@ public static class ServiceCollectionExtensions
services.AddDbContext<AppDatabase>();
services.AddSingleton<IClock>(SystemClock.Instance);
services.AddHttpContextAccessor();
services.AddSingleton<IConnectionMultiplexer>(_ =>
{
var connection = configuration.GetConnectionString("FastRetrieve")!;
return ConnectionMultiplexer.Connect(connection);
});
services.AddSingleton<ICacheService, CacheServiceRedis>();
services.AddHttpClient();
services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
});

View File

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

View File

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

View File

@@ -35,7 +35,6 @@
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<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="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
@@ -67,6 +66,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</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("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b =>

View File

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

View File

@@ -3,6 +3,8 @@ using DysonNetwork.Drive.Storage;
using DysonNetwork.Shared.Stream;
using Microsoft.EntityFrameworkCore;
using NATS.Client.Core;
using NATS.Client.JetStream.Models;
using NATS.Net;
namespace DysonNetwork.Drive.Startup;
@@ -14,12 +16,23 @@ public class BroadcastEventHandler(
{
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
{
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);
@@ -46,10 +59,13 @@ public class BroadcastEventHandler(
await transaction.RollbackAsync(cancellationToken: stoppingToken);
throw;
}
await msg.AckAsync(cancellationToken: stoppingToken);
}
catch (Exception ex)
{
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.Serialization;
using System.Threading.RateLimiting;
using DysonNetwork.Shared.Cache;
using Microsoft.AspNetCore.RateLimiting;
@@ -16,11 +17,6 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
{
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.AddHttpContextAccessor();
services.AddSingleton<ICacheService, CacheServiceRedis>(); // Uncomment if you have CacheServiceRedis
@@ -40,6 +36,7 @@ public static class ServiceCollectionExtensions
services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;

View File

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

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,239 +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 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 Nick { 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 bool IsSuperuser { get; set; } = false;
@@ -46,6 +47,7 @@ public class Account : ModelBase
Name = Name,
Nick = Nick,
Language = Language,
Region = Region,
ActivatedAt = ActivatedAt?.ToTimestamp(),
IsSuperuser = IsSuperuser,
Profile = Profile.ToProtoValue(),
@@ -75,6 +77,7 @@ public class Account : ModelBase
Name = proto.Name,
Nick = proto.Nick,
Language = proto.Language,
Region = proto.Region,
ActivatedAt = proto.ActivatedAt?.ToInstant(),
IsSuperuser = proto.IsSuperuser,
PerkSubscription = proto.PerkSubscription is not null

View File

@@ -3,6 +3,7 @@ using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Error;
using DysonNetwork.Shared.GeoIp;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
@@ -17,7 +18,8 @@ public class AccountController(
AccountService accounts,
SubscriptionService subscriptions,
AccountEventService events,
SocialCreditService socialCreditService
SocialCreditService socialCreditService,
GeoIpService geo
) : ControllerBase
{
[HttpGet("{name}")]
@@ -32,10 +34,10 @@ public class AccountController(
.Where(a => a.Name == name)
.FirstOrDefaultAsync();
if (account is null) return NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier));
var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id);
account.PerkSubscription = perk?.ToReference();
return account;
}
@@ -48,9 +50,11 @@ public class AccountController(
.Include(e => e.Badges)
.Where(a => a.Name == name)
.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")]
[ProducesResponseType<double>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -60,12 +64,12 @@ public class AccountController(
.Where(a => a.Name == name)
.Select(a => new { a.Id })
.FirstOrDefaultAsync();
if (account is null)
{
return NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier));
}
var credits = await socialCreditService.GetSocialCredit(account.Id);
return credits;
}
@@ -93,7 +97,7 @@ public class AccountController(
[MaxLength(128)]
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;
}
@@ -109,6 +113,10 @@ public class AccountController(
[nameof(request.CaptchaToken)] = ["Invalid captcha token."]
}, 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
{
var account = await accounts.CreateAccount(
@@ -116,7 +124,8 @@ public class AccountController(
request.Nick,
request.Email,
request.Password,
request.Language
request.Language,
region
);
return Ok(account);
}
@@ -182,7 +191,9 @@ public class AccountController(
public StatusAttitude Attitude { get; set; }
public bool IsInvisible { get; set; }
public bool IsNotDisturb { get; set; }
public bool IsAutomated { get; set; } = false;
[MaxLength(1024)] public string? Label { get; set; }
[MaxLength(4096)] public string? AppIdentifier { get; set; }
public Instant? ClearedAt { get; set; }
}

View File

@@ -52,6 +52,7 @@ public class AccountCurrentController(
{
[MaxLength(256)] public string? Nick { get; set; }
[MaxLength(32)] public string? Language { get; set; }
[MaxLength(32)] public string? Region { get; set; }
}
[HttpPatch]
@@ -63,6 +64,7 @@ public class AccountCurrentController(
if (request.Nick is not null) account.Nick = request.Nick;
if (request.Language is not null) account.Language = request.Language;
if (request.Region is not null) account.Region = request.Region;
await db.SaveChangesAsync();
await accounts.PurgeAccountCache(currentUser);
@@ -195,6 +197,8 @@ public class AccountCurrentController(
public async Task<ActionResult<Status>> UpdateStatus([FromBody] AccountController.StatusRequest request)
{
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 status = await db.AccountStatuses
@@ -203,11 +207,15 @@ public class AccountCurrentController(
.OrderByDescending(e => e.CreatedAt)
.FirstOrDefaultAsync();
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.IsInvisible = request.IsInvisible;
status.IsNotDisturb = request.IsNotDisturb;
status.IsAutomated = request.IsAutomated;
status.Label = request.Label;
status.AppIdentifier = request.AppIdentifier;
status.ClearedAt = request.ClearedAt;
db.Update(status);
@@ -223,13 +231,44 @@ public class AccountCurrentController(
{
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
{
AccountId = currentUser.Id,
Attitude = request.Attitude,
IsInvisible = request.IsInvisible,
IsNotDisturb = request.IsNotDisturb,
IsAutomated = request.IsAutomated,
Label = request.Label,
AppIdentifier = request.AppIdentifier,
ClearedAt = request.ClearedAt
};
@@ -237,15 +276,21 @@ public class AccountCurrentController(
}
[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();
var now = SystemClock.Instance.GetCurrentInstant();
var status = await db.AccountStatuses
var queryable = db.AccountStatuses
.Where(s => s.AccountId == currentUser.Id)
.Where(s => s.ClearedAt == null || s.ClearedAt > now)
.OrderByDescending(s => s.CreatedAt)
.AsQueryable();
if (string.IsNullOrWhiteSpace(app))
queryable = queryable.Where(s => s.IsAutomated && s.AppIdentifier == app);
var status = await queryable
.FirstOrDefaultAsync();
if (status is null) return NotFound();

View File

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

View File

@@ -13,6 +13,8 @@ using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using NATS.Client.Core;
using NATS.Client.JetStream;
using NATS.Net;
using NodaTime;
using OtpNet;
using AuthService = DysonNetwork.Pass.Auth.AuthService;
@@ -26,7 +28,7 @@ public class AccountService(
FileReferenceService.FileReferenceServiceClient fileRefs,
AccountUsernameService uname,
EmailService mailer,
PusherService.PusherServiceClient pusher,
RingService.RingServiceClient pusher,
IStringLocalizer<NotificationResource> localizer,
IStringLocalizer<EmailResource> emailLocalizer,
ICacheService cache,
@@ -88,6 +90,7 @@ public class AccountService(
string email,
string? password,
string language = "en-US",
string region = "en",
bool isEmailVerified = false,
bool isActivated = false
)
@@ -107,6 +110,7 @@ public class AccountService(
Name = name,
Nick = nick,
Language = language,
Region = region,
Contacts = new List<AccountContact>
{
new()
@@ -181,12 +185,14 @@ public class AccountService(
userInfo.Email,
null,
"en-US",
"en",
userInfo.EmailVerified,
userInfo.EmailVerified
);
}
public async Task<Account> CreateBotAccount(Account account, Guid automatedId, string? pictureId, string? backgroundId)
public async Task<Account> CreateBotAccount(Account account, Guid automatedId, string? pictureId,
string? backgroundId)
{
var dupeAutomateCount = await db.Accounts.Where(a => a.AutomatedId == automatedId).CountAsync();
if (dupeAutomateCount > 0)
@@ -227,7 +233,7 @@ public class AccountService(
);
account.Profile.Background = CloudFileReferenceObject.FromProtoValue(file);
}
db.Accounts.Add(account);
await db.SaveChangesAsync();
@@ -439,7 +445,7 @@ public class AccountService(
if (contact is null)
{
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;
@@ -737,10 +743,14 @@ public class AccountService(
db.Accounts.Remove(account);
await db.SaveChangesAsync();
await nats.PublishAsync(AccountDeletedEvent.Type, JsonSerializer.SerializeToUtf8Bytes(new AccountDeletedEvent
{
AccountId = account.Id,
DeletedAt = SystemClock.Instance.GetCurrentInstant()
}));
var js = nats.CreateJetStreamContext();
await js.PublishAsync(
AccountDeletedEvent.Type,
GrpcTypeHelper.ConvertObjectToByteString(new AccountDeletedEvent
{
AccountId = account.Id,
DeletedAt = SystemClock.Instance.GetCurrentInstant()
}).ToByteArray()
);
}
}

View File

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

View File

@@ -23,6 +23,12 @@ public class Status : ModelBase
public bool IsNotDisturb { get; set; }
[MaxLength(1024)] public string? Label { 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 Account Account { get; set; } = null!;

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(
AppDatabase db,
ICacheService cache,
PusherService.PusherServiceClient pusher,
RingService.RingServiceClient pusher,
IStringLocalizer<NotificationResource> localizer
)
{

View File

@@ -1,5 +1,7 @@
using System.Linq.Expressions;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Credit;
@@ -50,7 +52,7 @@ public class AppDatabase(
public DbSet<Coupon> WalletCoupons { get; set; } = null!;
public DbSet<Punishment> Punishments { get; set; } = null!;
public DbSet<SocialCreditRecord> SocialCreditRecords { get; set; } = null!;
public DbSet<ExperienceRecord> ExperienceRecords { get; set; } = null!;
@@ -59,9 +61,14 @@ public class AppDatabase(
optionsBuilder.UseNpgsql(
configuration.GetConnectionString("App"),
opt => opt
.ConfigureDataSource(optSource => optSource.EnableDynamicJson())
.ConfigureDataSource(optSource => optSource
.EnableDynamicJson()
.ConfigureJsonOptions(new JsonSerializerOptions()
{
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
})
)
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
.UseNetTopologySuite()
.UseNodaTime()
).UseSnakeCaseNamingConvention();
@@ -276,4 +283,4 @@ public static class OptionalQueryExtensions
{
return condition ? transform(source) : source;
}
}
}

View File

@@ -22,7 +22,7 @@ public class AuthController(
AuthService auth,
GeoIpService geo,
ActionLogService als,
PusherService.PusherServiceClient pusher,
RingService.RingServiceClient pusher,
IConfiguration configuration,
IStringLocalizer<NotificationResource> localizer
) : ControllerBase

View File

@@ -1,5 +1,6 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Pass.Account;
using DysonNetwork.Shared.Cache;
using Microsoft.EntityFrameworkCore;
@@ -137,6 +138,7 @@ public class AuthService(
var jsonOpts = new JsonSerializerOptions
{
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower
};
@@ -211,8 +213,7 @@ public class AuthService(
var session = new AuthSession
{
LastGrantedAt = now,
// Never expire server-side
ExpiredAt = null,
ExpiredAt = now.Plus(Duration.FromDays(7)),
AccountId = challenge.AccountId,
ChallengeId = challenge.Id
};

View File

@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.GeoIp;
using NodaTime;
using NodaTime.Serialization.Protobuf;
using Point = NetTopologySuite.Geometries.Point;
@@ -16,11 +17,11 @@ public class AuthSession : ModelBase
public Guid AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!;
// When the challenge is null, indicates the session is for an API Key
public Guid? ChallengeId { get; set; }
public AuthChallenge? Challenge { get; set; } = null!;
// Indicates the session is for an OIDC connection
public Guid? AppId { get; set; }
@@ -69,7 +70,7 @@ public class AuthChallenge : ModelBase
[MaxLength(128)] public string? IpAddress { get; set; }
[MaxLength(512)] public string? UserAgent { 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; }
[JsonIgnore] public Account.Account Account { get; set; } = null!;
@@ -129,4 +130,4 @@ public class AuthClientWithChallenge : AuthClient
AccountId = client.AccountId,
};
}
}
}

View File

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

View File

@@ -13,7 +13,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</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">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
@@ -24,7 +24,6 @@
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/>
<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="OpenGraph-Net" Version="4.0.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/>
@@ -50,6 +49,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj"/>
</ItemGroup>

View File

@@ -1,12 +1,10 @@
using dotnet_etcd;
using dotnet_etcd.interfaces;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Components;
namespace DysonNetwork.Pass.Email;
public class EmailService(
PusherService.PusherServiceClient pusher,
RingService.RingServiceClient pusher,
RazorViewRenderer viewRenderer,
ILogger<EmailService> logger
)

View File

@@ -18,7 +18,7 @@ public class LastActiveFlushHandler(IServiceProvider srp, ILogger<LastActiveFlus
public async Task FlushAsync(IReadOnlyList<LastActiveInfo> items)
{
logger.LogInformation("Flushing {Count} LastActiveInfo items...", items.Count);
using var scope = srp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
@@ -38,13 +38,22 @@ public class LastActiveFlushHandler(IServiceProvider srp, ILogger<LastActiveFlus
.ToDictionary(g => g.Key, g => g.Last().SeenAt);
var now = SystemClock.Instance.GetCurrentInstant();
var updatingSessions = sessionMap.Select(x => x.Key).ToList();
var sessionUpdates = await db.AuthSessions
.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);
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 profileUpdates = await db.AccountProfiles
.Where(a => updatingAccounts.Contains(a.AccountId))
@@ -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)
{
@@ -62,7 +72,8 @@ public class LastActiveFlushJob(FlushBufferService fbs, LastActiveFlushHandler h
logger.LogInformation("Running LastActiveInfo flush job...");
await fbs.FlushAsync(hdl);
logger.LogInformation("Completed LastActiveInfo flush job...");
} catch (Exception ex)
}
catch (Exception ex)
{
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}")),
});
}
}

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

View File

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

View File

@@ -170,5 +170,47 @@ namespace DysonNetwork.Sphere.Resources.Localization {
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>
</data>
<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 name="NewLoginTitle" xml:space="preserve">
<value>New login detected</value>
@@ -92,4 +92,19 @@
<data name="FriendRequestBody" xml:space="preserve">
<value>You can go to relationships page and decide accept their request or not.</value>
</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>

View File

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

View File

@@ -4,6 +4,8 @@ using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Leveling;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Http;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.FileProviders;
using Prometheus;
@@ -22,7 +24,7 @@ public static class ApplicationConfiguration
app.UseRequestLocalization();
ConfigureForwardedHeaders(app, configuration);
app.ConfigureForwardedHeaders(configuration);
app.UseCors(opts =>
opts.SetIsOriginAllowed(_ => true)
@@ -50,28 +52,6 @@ public static class ApplicationConfiguration
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)
{
app.MapGrpcService<AccountServiceGrpc>();
@@ -81,6 +61,8 @@ public static class ApplicationConfiguration
app.MapGrpcService<SocialCreditServiceGrpc>();
app.MapGrpcService<ExperienceServiceGrpc>();
app.MapGrpcService<BotAccountReceiverGrpc>();
app.MapGrpcService<WalletServiceGrpc>();
app.MapGrpcService<PaymentServiceGrpc>();
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 StackExchange.Redis;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using DysonNetwork.Pass.Auth.OidcProvider.Options;
using DysonNetwork.Pass.Auth.OidcProvider.Services;
@@ -33,11 +34,6 @@ public static class ServiceCollectionExtensions
services.AddLocalization(options => options.ResourcesPath = "Resources");
services.AddDbContext<AppDatabase>();
services.AddSingleton<IConnectionMultiplexer>(_ =>
{
var connection = configuration.GetConnectionString("FastRetrieve")!;
return ConnectionMultiplexer.Connect(connection);
});
services.AddSingleton<IClock>(SystemClock.Instance);
services.AddHttpContextAccessor();
services.AddSingleton<ICacheService, CacheServiceRedis>();
@@ -51,12 +47,8 @@ public static class ServiceCollectionExtensions
options.MaxReceiveMessageSize = 16 * 1024 * 1024; // 16MB
options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB
});
services.AddPusherService();
// Register gRPC services
services.AddScoped<AccountServiceGrpc>();
services.AddScoped<AuthServiceGrpc>();
services.AddRingService();
// Register OIDC services
services.AddScoped<OidcService, GoogleOidcService>();
@@ -74,6 +66,7 @@ public static class ServiceCollectionExtensions
services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
@@ -136,7 +129,8 @@ public static class ServiceCollectionExtensions
{
Version = "v1",
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"),
License = new OpenApiLicense
{
@@ -194,6 +188,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<ActionLogService>();
services.AddScoped<AccountService>();
services.AddScoped<AccountEventService>();
services.AddScoped<NotableDaysService>();
services.AddScoped<ActionLogService>();
services.AddScoped<RelationshipService>();
services.AddScoped<MagicSpellService>();
@@ -210,6 +205,8 @@ public static class ServiceCollectionExtensions
services.Configure<OidcProviderOptions>(configuration.GetSection("OidcProvider"));
services.AddScoped<OidcProviderService>();
services.AddHostedService<BroadcastEventHandler>();
return services;
}

View File

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

View File

@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Globalization;
using DysonNetwork.Shared.Data;
using NodaTime;
using NodaTime.Serialization.Protobuf;
@@ -23,11 +24,14 @@ public enum OrderStatus
public class Order : ModelBase
{
public const string InternalAppIdentifier = "internal";
public Guid Id { get; set; } = Guid.NewGuid();
public OrderStatus Status { get; set; } = OrderStatus.Unpaid;
[MaxLength(128)] public string Currency { get; set; } = null!;
[MaxLength(4096)] public string? Remarks { get; set; }
[MaxLength(4096)] public string? AppIdentifier { get; set; }
[MaxLength(4096)] public string? ProductIdentifier { get; set; }
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; }
public decimal Amount { get; set; }
public Instant ExpiredAt { get; set; }
@@ -44,10 +48,11 @@ public class Order : ModelBase
Currency = Currency,
Remarks = Remarks,
AppIdentifier = AppIdentifier,
ProductIdentifier = ProductIdentifier,
Meta = Meta == null
? null
: Google.Protobuf.ByteString.CopyFrom(System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(Meta)),
Amount = Amount.ToString(),
Amount = Amount.ToString(CultureInfo.InvariantCulture),
ExpiredAt = ExpiredAt.ToTimestamp(),
PayeeWalletId = PayeeWalletId?.ToString(),
TransactionId = TransactionId?.ToString(),
@@ -61,13 +66,14 @@ public class Order : ModelBase
Currency = proto.Currency,
Remarks = proto.Remarks,
AppIdentifier = proto.AppIdentifier,
ProductIdentifier = proto.ProductIdentifier,
Meta = proto.HasMeta
? System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(proto.Meta.ToByteArray())
: null,
Amount = decimal.Parse(proto.Amount),
ExpiredAt = proto.ExpiredAt.ToInstant(),
PayeeWalletId = proto.HasPayeeWalletId ? Guid.Parse(proto.PayeeWalletId) : null,
TransactionId = proto.HasTransactionId ? Guid.Parse(proto.TransactionId) : null,
PayeeWalletId = proto.PayeeWalletId is not null ? Guid.Parse(proto.PayeeWalletId) : null,
TransactionId = proto.TransactionId is not null ? Guid.Parse(proto.TransactionId) : null,
Transaction = proto.Transaction is not null ? Transaction.FromProtoValue(proto.Transaction) : null,
};
}
@@ -100,7 +106,7 @@ public class Transaction : ModelBase
{
Id = Id.ToString(),
Currency = Currency,
Amount = Amount.ToString(),
Amount = Amount.ToString(CultureInfo.InvariantCulture),
Remarks = Remarks,
Type = (Shared.Proto.TransactionType)Type,
PayerWalletId = PayerWalletId?.ToString(),
@@ -114,7 +120,7 @@ public class Transaction : ModelBase
Amount = decimal.Parse(proto.Amount),
Remarks = proto.Remarks,
Type = (TransactionType)proto.Type,
PayerWalletId = proto.HasPayerWalletId ? Guid.Parse(proto.PayerWalletId) : null,
PayeeWalletId = proto.HasPayeeWalletId ? Guid.Parse(proto.PayeeWalletId) : null,
PayerWalletId = proto.PayerWalletId is not null ? Guid.Parse(proto.PayerWalletId) : null,
PayeeWalletId = proto.PayeeWalletId is not null ? Guid.Parse(proto.PayeeWalletId) : null,
};
}

View File

@@ -1,9 +1,14 @@
using System.Globalization;
using System.Text.Json;
using DysonNetwork.Pass.Localization;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Stream;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Localization;
using NATS.Client.Core;
using NATS.Client.JetStream;
using NATS.Net;
using NodaTime;
using AccountService = DysonNetwork.Pass.Account.AccountService;
@@ -12,8 +17,9 @@ namespace DysonNetwork.Pass.Wallet;
public class PaymentService(
AppDatabase db,
WalletService wat,
PusherService.PusherServiceClient pusher,
IStringLocalizer<NotificationResource> localizer
RingService.RingServiceClient pusher,
IStringLocalizer<NotificationResource> localizer,
INatsConnection nats
)
{
public async Task<Order> CreateOrderAsync(
@@ -22,6 +28,8 @@ public class PaymentService(
decimal amount,
Duration? expiration = null,
string? appIdentifier = null,
string? productIdentifier = null,
string? remarks = null,
Dictionary<string, object>? meta = null,
bool reuseable = true
)
@@ -29,26 +37,25 @@ public class PaymentService(
// Check if there's an existing unpaid order that can be reused
if (reuseable && appIdentifier != null)
{
var now = SystemClock.Instance.GetCurrentInstant();
var existingOrder = await db.PaymentOrders
.Where(o => o.Status == OrderStatus.Unpaid &&
o.PayeeWalletId == payeeWalletId &&
o.Currency == currency &&
o.Amount == amount &&
o.AppIdentifier == appIdentifier &&
o.ExpiredAt > SystemClock.Instance.GetCurrentInstant())
o.ProductIdentifier == productIdentifier &&
o.ExpiredAt > now)
.FirstOrDefaultAsync();
// If an existing order is found, check if meta matches
if (existingOrder != null && meta != null && existingOrder.Meta != null)
{
// Compare meta dictionaries - if they are equivalent, reuse the order
// Compare the meta dictionary - if they are equivalent, reuse the order
var metaMatches = existingOrder.Meta.Count == meta.Count &&
!existingOrder.Meta.Except(meta).Any();
if (metaMatches)
{
return existingOrder;
}
}
}
@@ -60,6 +67,8 @@ public class PaymentService(
Amount = amount,
ExpiredAt = SystemClock.Instance.GetCurrentInstant().Plus(expiration ?? Duration.FromHours(24)),
AppIdentifier = appIdentifier,
ProductIdentifier = productIdentifier,
Remarks = remarks,
Meta = meta
};
@@ -104,7 +113,8 @@ public class PaymentService(
string currency,
decimal amount,
string? remarks = null,
TransactionType type = TransactionType.System
TransactionType type = TransactionType.System,
bool silent = false
)
{
if (payerWalletId == null && payeeWalletId == null)
@@ -121,8 +131,12 @@ public class PaymentService(
Type = type
};
Wallet? payerWallet = null, payeeWallet = null;
if (payerWalletId.HasValue)
{
payerWallet = await db.Wallets.FirstOrDefaultAsync(e => e.AccountId == payerWalletId.Value);
var (payerPocket, isNewlyCreated) =
await wat.GetOrCreateWalletPocketAsync(payerWalletId.Value, currency);
@@ -137,6 +151,8 @@ public class PaymentService(
if (payeeWalletId.HasValue)
{
payeeWallet = await db.Wallets.FirstOrDefaultAsync(e => e.AccountId == payeeWalletId.Value);
var (payeePocket, isNewlyCreated) =
await wat.GetOrCreateWalletPocketAsync(payeeWalletId.Value, currency, amount);
@@ -149,13 +165,89 @@ public class PaymentService(
db.PaymentTransactions.Add(transaction);
await db.SaveChangesAsync();
if (!silent)
await NotifyNewTransaction(transaction, payerWallet, payeeWallet);
return transaction;
}
public async Task<Order> PayOrderAsync(Guid orderId, Guid payerWalletId)
private async Task NotifyNewTransaction(Transaction transaction, Wallet? payerWallet, Wallet? payeeWallet)
{
if (payerWallet is not null)
{
var account = await db.Accounts
.Where(a => a.Id == payerWallet.AccountId)
.FirstOrDefaultAsync();
if (account is null) return;
AccountService.SetCultureInfo(account);
// Due to ID is uuid, it longer than 8 words for sure
var readableTransactionId = transaction.Id.ToString().Replace("-", "")[..8];
var readableTransactionRemark = transaction.Remarks ?? $"#{readableTransactionId}";
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = account.Id.ToString(),
Notification = new PushNotification
{
Topic = "wallets.transactions",
Title = localizer["TransactionNewTitle", readableTransactionRemark],
Body = transaction.Amount > 0
? localizer["TransactionNewBodyMinus",
transaction.Amount.ToString(CultureInfo.InvariantCulture),
transaction.Currency]
: localizer["TransactionNewBodyPlus",
transaction.Amount.ToString(CultureInfo.InvariantCulture),
transaction.Currency],
IsSavable = true
}
}
);
}
if (payeeWallet is not null)
{
var account = await db.Accounts
.Where(a => a.Id == payeeWallet.AccountId)
.FirstOrDefaultAsync();
if (account is null) return;
AccountService.SetCultureInfo(account);
// Due to ID is uuid, it longer than 8 words for sure
var readableTransactionId = transaction.Id.ToString().Replace("-", "")[..8];
var readableTransactionRemark = transaction.Remarks ?? $"#{readableTransactionId}";
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = account.Id.ToString(),
Notification = new PushNotification
{
Topic = "wallets.transactions",
Title = localizer["TransactionNewTitle", readableTransactionRemark],
Body = transaction.Amount > 0
? localizer["TransactionNewBodyPlus",
transaction.Amount.ToString(CultureInfo.InvariantCulture),
transaction.Currency]
: localizer["TransactionNewBodyMinus",
transaction.Amount.ToString(CultureInfo.InvariantCulture),
transaction.Currency],
IsSavable = true
}
}
);
}
}
public async Task<Order> PayOrderAsync(Guid orderId, Wallet payerWallet)
{
var order = await db.PaymentOrders
.Include(o => o.Transaction)
.Include(o => o.PayeeWallet)
.FirstOrDefaultAsync(o => o.Id == orderId);
if (order == null)
@@ -163,6 +255,27 @@ public class PaymentService(
throw new InvalidOperationException("Order not found");
}
var js = nats.CreateJetStreamContext();
if (order.Status == OrderStatus.Paid)
{
await js.PublishAsync(
PaymentOrderEventBase.Type,
GrpcTypeHelper.ConvertObjectToByteString(new PaymentOrderEvent
{
OrderId = order.Id,
WalletId = payerWallet.Id,
AccountId = payerWallet.AccountId,
AppIdentifier = order.AppIdentifier,
ProductIdentifier = order.ProductIdentifier,
Meta = order.Meta ?? [],
Status = (int)order.Status,
}).ToByteArray()
);
return order;
}
if (order.Status != OrderStatus.Unpaid)
{
throw new InvalidOperationException($"Order is in invalid status: {order.Status}");
@@ -176,51 +289,101 @@ public class PaymentService(
}
var transaction = await CreateTransactionAsync(
payerWalletId,
payerWallet.Id,
order.PayeeWalletId,
order.Currency,
order.Amount,
order.Remarks ?? $"Payment for Order #{order.Id}",
type: TransactionType.Order);
type: TransactionType.Order,
silent: true);
order.TransactionId = transaction.Id;
order.Transaction = transaction;
order.Status = OrderStatus.Paid;
await db.SaveChangesAsync();
await NotifyOrderPaid(order);
await NotifyOrderPaid(order, payerWallet, order.PayeeWallet);
await js.PublishAsync(
PaymentOrderEventBase.Type,
GrpcTypeHelper.ConvertObjectToByteString(new PaymentOrderEvent
{
OrderId = order.Id,
WalletId = payerWallet.Id,
AccountId = payerWallet.AccountId,
AppIdentifier = order.AppIdentifier,
ProductIdentifier = order.ProductIdentifier,
Meta = order.Meta ?? [],
Status = (int)order.Status,
}).ToByteArray()
);
return order;
}
private async Task NotifyOrderPaid(Order order)
private async Task NotifyOrderPaid(Order order, Wallet? payerWallet, Wallet? payeeWallet)
{
if (order.PayeeWallet is null) return;
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == order.PayeeWallet.AccountId);
if (account is null) return;
AccountService.SetCultureInfo(account);
if (payerWallet is not null)
{
var account = await db.Accounts
.Where(a => a.Id == payerWallet.AccountId)
.FirstOrDefaultAsync();
if (account is null) return;
// Due to ID is uuid, it longer than 8 words for sure
var readableOrderId = order.Id.ToString().Replace("-", "")[..8];
var readableOrderRemark = order.Remarks ?? $"#{readableOrderId}";
AccountService.SetCultureInfo(account);
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = account.Id.ToString(),
Notification = new PushNotification
// Due to ID is uuid, it longer than 8 words for sure
var readableOrderId = order.Id.ToString().Replace("-", "")[..8];
var readableOrderRemark = order.Remarks ?? $"#{readableOrderId}";
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
Topic = "wallets.orders.paid",
Title = localizer["OrderPaidTitle", $"#{readableOrderId}"],
Body = localizer["OrderPaidBody", order.Amount.ToString(CultureInfo.InvariantCulture), order.Currency,
readableOrderRemark],
IsSavable = true
UserId = account.Id.ToString(),
Notification = new PushNotification
{
Topic = "wallets.orders.paid",
Title = localizer["OrderPaidTitle", $"#{readableOrderId}"],
Body = localizer["OrderPaidBody", order.Amount.ToString(CultureInfo.InvariantCulture),
order.Currency,
readableOrderRemark],
IsSavable = true
}
}
}
);
);
}
if (payeeWallet is not null)
{
var account = await db.Accounts
.Where(a => a.Id == payeeWallet.AccountId)
.FirstOrDefaultAsync();
if (account is null) return;
AccountService.SetCultureInfo(account);
// Due to ID is uuid, it longer than 8 words for sure
var readableOrderId = order.Id.ToString().Replace("-", "")[..8];
var readableOrderRemark = order.Remarks ?? $"#{readableOrderId}";
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = account.Id.ToString(),
Notification = new PushNotification
{
Topic = "wallets.orders.received",
Title = localizer["OrderReceivedTitle", $"#{readableOrderId}"],
Body = localizer["OrderReceivedBody", order.Amount.ToString(CultureInfo.InvariantCulture),
order.Currency,
readableOrderRemark],
IsSavable = true
}
}
);
}
}
public async Task<Order> CancelOrderAsync(Guid orderId)

View File

@@ -13,10 +13,11 @@ public class PaymentServiceGrpc(PaymentService paymentService) : Shared.Proto.Pa
request.Currency,
decimal.Parse(request.Amount),
request.Expiration is not null ? Duration.FromSeconds(request.Expiration.Seconds) : null,
request.HasAppIdentifier ? request.AppIdentifier : null,
// Assuming meta is a JSON string
request.HasAppIdentifier ? request.AppIdentifier : Order.InternalAppIdentifier,
request.HasProductIdentifier ? request.ProductIdentifier : null,
request.HasRemarks ? request.Remarks : null,
request.HasMeta
? System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(request.Meta.ToStringUtf8())
? GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object>>(request.Meta)
: null,
request.Reuseable
);

View File

@@ -150,29 +150,6 @@ public class SubscriptionController(SubscriptionService subscriptions, AfdianPay
}
}
public class SubscriptionOrderRequest
{
[Required] public Guid OrderId { get; set; }
}
[HttpPost("order/handle")]
[Authorize]
public async Task<ActionResult<Subscription>> HandleSubscriptionOrder([FromBody] SubscriptionOrderRequest request)
{
var order = await db.PaymentOrders.FindAsync(request.OrderId);
if (order is null) return NotFound($"Order with ID {request.OrderId} was not found.");
try
{
var subscription = await subscriptions.HandleSubscriptionOrder(order);
return subscription;
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
public class RestorePurchaseRequest
{
[Required] public string OrderId { get; set; } = null!;

View File

@@ -68,7 +68,8 @@ public class SubscriptionRenewalJob(
null,
WalletCurrency.GoldenPoint,
subscription.FinalPrice,
appIdentifier: SubscriptionService.SubscriptionOrderIdentifier,
appIdentifier: "internal",
productIdentifier: subscription.Identifier,
meta: new Dictionary<string, object>()
{
["subscription_id"] = subscription.Id.ToString(),
@@ -86,7 +87,7 @@ public class SubscriptionRenewalJob(
if (wallet is null) continue;
// Process automatic payment from wallet
await paymentService.PayOrderAsync(order.Id, wallet.Id);
await paymentService.PayOrderAsync(order.Id, wallet);
// Update subscription details
subscription.BegunAt = subscription.EndedAt!.Value;

View File

@@ -18,7 +18,7 @@ public class SubscriptionService(
AppDatabase db,
PaymentService payment,
AccountService accounts,
PusherService.PusherServiceClient pusher,
RingService.RingServiceClient pusher,
IStringLocalizer<NotificationResource> localizer,
IConfiguration configuration,
ICacheService cache,
@@ -229,8 +229,6 @@ public class SubscriptionService(
return subscription;
}
public const string SubscriptionOrderIdentifier = "solian.subscription.order";
/// <summary>
/// Creates a subscription order for an unpaid or expired subscription.
/// If the subscription is active, it will extend its expiration date.
@@ -259,7 +257,8 @@ public class SubscriptionService(
null,
subscriptionInfo.Currency,
subscription.FinalPrice,
appIdentifier: SubscriptionOrderIdentifier,
appIdentifier: "internal",
productIdentifier: identifier,
meta: new Dictionary<string, object>()
{
["subscription_id"] = subscription.Id.ToString(),
@@ -270,8 +269,7 @@ public class SubscriptionService(
public async Task<Subscription> HandleSubscriptionOrder(Order order)
{
if (order.AppIdentifier != SubscriptionOrderIdentifier || order.Status != OrderStatus.Paid ||
order.Meta?["subscription_id"] is not JsonElement subscriptionIdJson)
if (order.Status != OrderStatus.Paid || order.Meta?["subscription_id"] is not JsonElement subscriptionIdJson)
throw new InvalidOperationException("Invalid order.");
var subscriptionId = Guid.TryParse(subscriptionIdJson.ToString(), out var parsedSubscriptionId)

View File

@@ -46,22 +46,51 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var query = db.PaymentTransactions.AsQueryable()
.Include(t => t.PayeeWallet)
.Include(t => t.PayerWallet)
.Where(t => (t.PayeeWallet != null && t.PayeeWallet.AccountId == currentUser.Id) ||
(t.PayerWallet != null && t.PayerWallet.AccountId == currentUser.Id));
var accountWallet = await db.Wallets.Where(w => w.AccountId == currentUser.Id).FirstOrDefaultAsync();
if (accountWallet is null) return NotFound();
var query = db.PaymentTransactions
.Where(t => t.PayeeWalletId == accountWallet.Id || t.PayerWalletId == accountWallet.Id)
.OrderByDescending(t => t.CreatedAt)
.AsQueryable();
var transactionCount = await query.CountAsync();
Response.Headers["X-Total"] = transactionCount.ToString();
var transactions = await query
.Skip(offset)
.Take(take)
.ToListAsync();
return Ok(transactions);
}
[HttpGet("orders")]
[Authorize]
public async Task<ActionResult<List<Order>>> GetOrders(
[FromQuery] int offset = 0, [FromQuery] int take = 20
)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var accountWallet = await db.Wallets.Where(w => w.AccountId == currentUser.Id).FirstOrDefaultAsync();
if (accountWallet is null) return NotFound();
var query = db.PaymentOrders.AsQueryable()
.Include(o => o.Transaction)
.Where(o => o.Transaction != null && (o.Transaction.PayeeWalletId == accountWallet.Id || o.Transaction.PayerWalletId == accountWallet.Id))
.AsQueryable();
var orderCount = await query.CountAsync();
Response.Headers["X-Total"] = orderCount.ToString();
var orders = await query
.Skip(offset)
.Take(take)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
Response.Headers["X-Total"] = transactionCount.ToString();
return Ok(transactions);
return Ok(orders);
}
public class WalletBalanceRequest

View File

@@ -8,11 +8,7 @@ public class WalletServiceGrpc(WalletService walletService) : Shared.Proto.Walle
public override async Task<Shared.Proto.Wallet> GetWallet(GetWalletRequest request, ServerCallContext context)
{
var wallet = await walletService.GetWalletAsync(Guid.Parse(request.AccountId));
if (wallet == null)
{
throw new RpcException(new Status(StatusCode.NotFound, "Wallet not found."));
}
return wallet.ToProtoValue();
return wallet == null ? throw new RpcException(new Status(StatusCode.NotFound, "Wallet not found.")) : wallet.ToProtoValue();
}
public override async Task<Shared.Proto.Wallet> CreateWallet(CreateWalletRequest request, ServerCallContext context)

View File

@@ -10,10 +10,7 @@
},
"AllowedHosts": "*",
"ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_pass;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"
"App": "Host=localhost;Port=5432;Database=dyson_pass;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
},
"Authentication": {
"Schemes": {
@@ -83,9 +80,7 @@
],
"Service": {
"Name": "DysonNetwork.Pass",
"Url": "https://localhost:7058",
"ClientCert": "../Certificates/client.crt",
"ClientKey": "../Certificates/client.key"
"Url": "https://localhost:7058"
},
"Etcd": {
"Insecure": true

View File

@@ -1,27 +0,0 @@
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
RUN apt-get update && apt-get install -y --no-install-recommends \
libkrb5-dev
USER $APP_UID
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["DysonNetwork.Pusher/DysonNetwork.Pusher.csproj", "DysonNetwork.Pusher/"]
RUN dotnet restore "DysonNetwork.Pusher/DysonNetwork.Pusher.csproj"
COPY . .
WORKDIR "/src/DysonNetwork.Pusher"
RUN dotnet build "./DysonNetwork.Pusher.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./DysonNetwork.Pusher.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "DysonNetwork.Pusher.dll"]

View File

@@ -1,67 +0,0 @@
using System.Net;
using DysonNetwork.Pusher.Services;
using Microsoft.AspNetCore.HttpOverrides;
namespace DysonNetwork.Pusher.Startup;
public static class ApplicationConfiguration
{
public static WebApplication ConfigureAppMiddleware(this WebApplication app, IConfiguration configuration)
{
app.MapOpenApi();
app.UseSwagger();
app.UseSwaggerUI();
app.UseRequestLocalization();
ConfigureForwardedHeaders(app, configuration);
app.UseCors(opts =>
opts.SetIsOriginAllowed(_ => true)
.WithExposedHeaders("*")
.WithHeaders("*")
.AllowCredentials()
.AllowAnyHeader()
.AllowAnyMethod()
);
app.UseWebSockets();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers().RequireRateLimiting("fixed");
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)
{
app.MapGrpcService<PusherServiceGrpc>();
return app;
}
}

View File

@@ -1,6 +1,6 @@
using System.Linq.Expressions;
using System.Reflection;
using DysonNetwork.Pusher.Notification;
using DysonNetwork.Ring.Notification;
using DysonNetwork.Shared.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Query;
using NodaTime;
using Quartz;
namespace DysonNetwork.Pusher;
namespace DysonNetwork.Ring;
public class AppDatabase(
DbContextOptions<AppDatabase> options,

View File

@@ -1,4 +1,4 @@
namespace DysonNetwork.Pusher.Connection;
namespace DysonNetwork.Ring.Connection;
public class ClientTypeMiddleware(RequestDelegate next)
{

View File

@@ -1,7 +1,7 @@
using System.Net.WebSockets;
using DysonNetwork.Shared.Proto;
namespace DysonNetwork.Pusher.Connection;
namespace DysonNetwork.Ring.Connection;
public interface IWebSocketPacketHandler
{

View File

@@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
namespace DysonNetwork.Pusher.Connection;
namespace DysonNetwork.Ring.Connection;
[ApiController]
public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext> logger) : ControllerBase

View File

@@ -4,7 +4,7 @@ using DysonNetwork.Shared.Proto;
using NodaTime;
using NodaTime.Serialization.SystemTextJson;
namespace DysonNetwork.Pusher.Connection;
namespace DysonNetwork.Ring.Connection;
public class WebSocketPacket
{
@@ -29,6 +29,7 @@ public class WebSocketPacket
var json = System.Text.Encoding.UTF8.GetString(bytes);
var jsonOpts = new JsonSerializerOptions
{
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower,
};
@@ -48,6 +49,7 @@ public class WebSocketPacket
var jsonOpts = new JsonSerializerOptions
{
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower,
};
@@ -65,6 +67,7 @@ public class WebSocketPacket
{
var jsonOpts = new JsonSerializerOptions
{
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower,
}.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);

View File

@@ -1,27 +1,23 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
using dotnet_etcd.interfaces;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto;
using Grpc.Core;
namespace DysonNetwork.Pusher.Connection;
namespace DysonNetwork.Ring.Connection;
public class WebSocketService
{
private readonly IConfiguration _configuration;
private readonly ILogger<WebSocketService> _logger;
private readonly IEtcdClient _etcdClient;
private readonly IDictionary<string, IWebSocketPacketHandler> _handlerMap;
public WebSocketService(
IEnumerable<IWebSocketPacketHandler> handlers,
IEtcdClient etcdClient,
ILogger<WebSocketService> logger,
IConfiguration configuration
)
{
_etcdClient = etcdClient;
_logger = logger;
_configuration = configuration;
_handlerMap = handlers.ToDictionary(h => h.PacketType);
@@ -59,8 +55,10 @@ public class WebSocketService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error while closing WebSocket for {AccountId}:{DeviceId}", key.AccountId, key.DeviceId);
_logger.LogWarning(ex, "Error while closing WebSocket for {AccountId}:{DeviceId}", key.AccountId,
key.DeviceId);
}
data.Cts.Cancel();
ActiveConnections.TryRemove(key, out _);
}
@@ -140,45 +138,24 @@ public class WebSocketService
{
try
{
// Get the service URL from etcd for the specified endpoint
var serviceKey = $"/services/{packet.Endpoint}";
var response = await _etcdClient.GetAsync(serviceKey);
var serviceUrl = "https://" + packet.Endpoint;
if (response.Kvs.Count > 0)
var callInvoker = GrpcClientHelper.CreateCallInvoker(serviceUrl);
var client = new RingHandlerService.RingHandlerServiceClient(callInvoker);
try
{
var serviceUrl = response.Kvs[0].Value.ToStringUtf8();
var clientCertPath = _configuration["Service:ClientCert"]!;
var clientKeyPath = _configuration["Service:ClientKey"]!;
var clientCertPassword = _configuration["Service:CertPassword"];
var callInvoker =
GrpcClientHelper.CreateCallInvoker(
serviceUrl,
clientCertPath,
clientKeyPath,
clientCertPassword
);
var client = new PusherHandlerService.PusherHandlerServiceClient(callInvoker);
try
await client.ReceiveWebSocketPacketAsync(new ReceiveWebSocketPacketRequest
{
await client.ReceiveWebSocketPacketAsync(new ReceiveWebSocketPacketRequest
{
Account = currentUser,
DeviceId = deviceId,
Packet = packet.ToProtoValue()
});
}
catch (RpcException ex)
{
_logger.LogError(ex, $"Error forwarding packet to endpoint: {packet.Endpoint}");
}
return;
Account = currentUser,
DeviceId = deviceId,
Packet = packet.ToProtoValue()
});
}
catch (RpcException ex)
{
_logger.LogError(ex, $"Error forwarding packet to endpoint: {packet.Endpoint}");
}
_logger.LogWarning($"No service registered for endpoint: {packet.Endpoint}");
}
catch (Exception ex)
{
@@ -197,4 +174,4 @@ public class WebSocketService
CancellationToken.None
);
}
}
}

View File

@@ -0,0 +1,27 @@
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
RUN apt-get update && apt-get install -y --no-install-recommends \
libkrb5-dev
USER $APP_UID
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["DysonNetwork.Ring/DysonNetwork.Ring.csproj", "DysonNetwork.Ring/"]
RUN dotnet restore "DysonNetwork.Ring/DysonNetwork.Ring.csproj"
COPY . .
WORKDIR "/src/DysonNetwork.Ring"
RUN dotnet build "./DysonNetwork.Ring.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./DysonNetwork.Ring.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "DysonNetwork.Ring.dll"]

View File

@@ -5,6 +5,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<RootNamespace>DysonNetwork.Pusher</RootNamespace>
</PropertyGroup>
<ItemGroup>
@@ -41,6 +42,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup>

View File

@@ -1,7 +1,7 @@
using MailKit.Net.Smtp;
using MimeKit;
namespace DysonNetwork.Pusher.Email;
namespace DysonNetwork.Ring.Email;
public class EmailServiceConfiguration
{

View File

@@ -1,7 +1,7 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using DysonNetwork.Pusher;
using DysonNetwork.Ring;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
@@ -11,7 +11,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DysonNetwork.Pusher.Migrations
namespace DysonNetwork.Ring.Migrations
{
[DbContext(typeof(AppDatabase))]
[Migration("20250713122638_InitialMigration")]
@@ -27,7 +27,7 @@ namespace DysonNetwork.Pusher.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Pusher.Notification.Notification", b =>
modelBuilder.Entity("DysonNetwork.Ring.Notification.Notification", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -89,7 +89,7 @@ namespace DysonNetwork.Pusher.Migrations
b.ToTable("notifications", (string)null);
});
modelBuilder.Entity("DysonNetwork.Pusher.Notification.PushSubscription", b =>
modelBuilder.Entity("DysonNetwork.Ring.Notification.PushSubscription", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()

View File

@@ -5,7 +5,7 @@ using NodaTime;
#nullable disable
namespace DysonNetwork.Pusher.Migrations
namespace DysonNetwork.Ring.Migrations
{
/// <inheritdoc />
public partial class InitialMigration : Migration

View File

@@ -1,7 +1,7 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using DysonNetwork.Pusher;
using DysonNetwork.Ring;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
@@ -11,7 +11,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DysonNetwork.Pusher.Migrations
namespace DysonNetwork.Ring.Migrations
{
[DbContext(typeof(AppDatabase))]
[Migration("20250724070546_UpdateNotificationMeta")]
@@ -27,7 +27,7 @@ namespace DysonNetwork.Pusher.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Pusher.Notification.Notification", b =>
modelBuilder.Entity("DysonNetwork.Ring.Notification.Notification", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -90,7 +90,7 @@ namespace DysonNetwork.Pusher.Migrations
b.ToTable("notifications", (string)null);
});
modelBuilder.Entity("DysonNetwork.Pusher.Notification.PushSubscription", b =>
modelBuilder.Entity("DysonNetwork.Ring.Notification.PushSubscription", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()

View File

@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pusher.Migrations
namespace DysonNetwork.Ring.Migrations
{
/// <inheritdoc />
public partial class UpdateNotificationMeta : Migration

View File

@@ -1,7 +1,7 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using DysonNetwork.Pusher;
using DysonNetwork.Ring;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -10,7 +10,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DysonNetwork.Pusher.Migrations
namespace DysonNetwork.Ring.Migrations
{
[DbContext(typeof(AppDatabase))]
partial class AppDatabaseModelSnapshot : ModelSnapshot
@@ -24,7 +24,7 @@ namespace DysonNetwork.Pusher.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Pusher.Notification.Notification", b =>
modelBuilder.Entity("DysonNetwork.Ring.Notification.Notification", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -87,7 +87,7 @@ namespace DysonNetwork.Pusher.Migrations
b.ToTable("notifications", (string)null);
});
modelBuilder.Entity("DysonNetwork.Pusher.Notification.PushSubscription", b =>
modelBuilder.Entity("DysonNetwork.Ring.Notification.PushSubscription", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()

View File

@@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations.Schema;
using DysonNetwork.Shared.Data;
using NodaTime;
namespace DysonNetwork.Pusher.Notification;
namespace DysonNetwork.Ring.Notification;
public class Notification : ModelBase
{

View File

@@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pusher.Notification;
namespace DysonNetwork.Ring.Notification;
[ApiController]
[Route("/api/notifications")]

View File

@@ -3,7 +3,7 @@ using EFCore.BulkExtensions;
using NodaTime;
using Quartz;
namespace DysonNetwork.Pusher.Notification;
namespace DysonNetwork.Ring.Notification;
public class NotificationFlushHandler(AppDatabase db) : IFlushHandler<Notification>
{

View File

@@ -1,13 +1,13 @@
using CorePush.Apple;
using CorePush.Firebase;
using DysonNetwork.Pusher.Connection;
using DysonNetwork.Pusher.Services;
using DysonNetwork.Ring.Connection;
using DysonNetwork.Ring.Services;
using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using WebSocketPacket = DysonNetwork.Pusher.Connection.WebSocketPacket;
using WebSocketPacket = DysonNetwork.Ring.Connection.WebSocketPacket;
namespace DysonNetwork.Pusher.Notification;
namespace DysonNetwork.Ring.Notification;
public class PushService
{

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