Compare commits
41 Commits
94f4e68120
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
6891b7c991
|
|||
|
0df720f787
|
|||
|
ef219c336c
|
|||
|
24c756a9a8
|
|||
|
7ecb64742f
|
|||
|
3a7140f0a6
|
|||
|
42082fbefa
|
|||
|
bc3d030a1e
|
|||
|
8642737a07
|
|||
|
8181938aaf
|
|||
|
922afc2239
|
|||
|
a071bd2738
|
|||
|
43945fc524
|
|||
|
e477429a35
|
|||
|
fe3a057185
|
|||
|
ad3c104c5c
|
|||
|
2020d625aa
|
|||
|
f471c5635d
|
|||
|
eaeaa28c60
|
|||
|
ee5c7cb7ce
|
|||
|
33abf12e41
|
|||
|
4a71f92ef0
|
|||
|
4faa1a4b64
|
|||
|
e49a1ec49a
|
|||
|
a88f42b26a
|
|||
|
c45be62331
|
|||
|
c8228e0c8e
|
|||
|
c642c6d646
|
|||
|
270c211cb8
|
|||
|
74c8f3490d
|
|||
|
b364edc74b
|
|||
|
9addf38677
|
|||
|
a02ed10434
|
|||
|
aca28f9318
|
|||
|
c2f72993b7
|
|||
|
158cc75c5b
|
|||
|
fa2f53ff7a
|
|||
|
2cce5ebf80
|
|||
|
13b2e46ecc
|
|||
|
cbd68c9ae6
|
|||
|
b99b61e0f9
|
176
.github/workflows/docker-build.yml
vendored
176
.github/workflows/docker-build.yml
vendored
@@ -1,103 +1,103 @@
|
||||
name: Build and Push Microservices
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
determine-changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.changes.outputs.matrix }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
determine-changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.changes.outputs.matrix }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
run: |
|
||||
echo "files=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} | xargs)" >> $GITHUB_OUTPUT
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
run: |
|
||||
echo "files=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} | xargs)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Determine changed services
|
||||
id: changes
|
||||
run: |
|
||||
files="${{ steps.changed-files.outputs.files }}"
|
||||
matrix="{\"include\":[]}"
|
||||
services=("Sphere" "Pass" "Ring" "Drive" "Develop" "Gateway" "Insight" "Zone")
|
||||
images=("sphere" "pass" "ring" "drive" "develop" "gateway" "insight" "zone")
|
||||
changed_services=()
|
||||
- name: Determine changed services
|
||||
id: changes
|
||||
run: |
|
||||
files="${{ steps.changed-files.outputs.files }}"
|
||||
matrix="{\"include\":[]}"
|
||||
services=("Sphere" "Pass" "Ring" "Drive" "Develop" "Insight" "Zone")
|
||||
images=("sphere" "pass" "ring" "drive" "develop" "insight" "zone")
|
||||
changed_services=()
|
||||
|
||||
for file in $files; do
|
||||
if [[ "$file" == DysonNetwork.Shared/* ]]; then
|
||||
changed_services=("${services[@]}")
|
||||
break
|
||||
fi
|
||||
for i in "${!services[@]}"; do
|
||||
if [[ "$file" == DysonNetwork.${services[$i]}/* ]]; then
|
||||
# check if service is already in changed_services
|
||||
if [[ ! " ${changed_services[@]} " =~ " ${services[$i]} " ]]; then
|
||||
changed_services+=("${services[$i]}")
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done
|
||||
for file in $files; do
|
||||
if [[ "$file" == DysonNetwork.Shared/* ]]; then
|
||||
changed_services=("${services[@]}")
|
||||
break
|
||||
fi
|
||||
for i in "${!services[@]}"; do
|
||||
if [[ "$file" == DysonNetwork.${services[$i]}/* ]]; then
|
||||
# check if service is already in changed_services
|
||||
if [[ ! " ${changed_services[@]} " =~ " ${services[$i]} " ]]; then
|
||||
changed_services+=("${services[$i]}")
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
if [ ${#changed_services[@]} -gt 0 ]; then
|
||||
json_objects=""
|
||||
for service in "${changed_services[@]}"; do
|
||||
for i in "${!services[@]}"; do
|
||||
if [[ "${services[$i]}" == "$service" ]]; then
|
||||
image="${images[$i]}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
json_objects+="{\"service\":\"$service\",\"image\":\"$image\"},"
|
||||
done
|
||||
matrix="{\"include\":[${json_objects%,}]}"
|
||||
fi
|
||||
echo "matrix=$matrix" >> $GITHUB_OUTPUT
|
||||
if [ ${#changed_services[@]} -gt 0 ]; then
|
||||
json_objects=""
|
||||
for service in "${changed_services[@]}"; do
|
||||
for i in "${!services[@]}"; do
|
||||
if [[ "${services[$i]}" == "$service" ]]; then
|
||||
image="${images[$i]}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
json_objects+="{\"service\":\"$service\",\"image\":\"$image\"},"
|
||||
done
|
||||
matrix="{\"include\":[${json_objects%,}]}"
|
||||
fi
|
||||
echo "matrix=$matrix" >> $GITHUB_OUTPUT
|
||||
|
||||
build-and-push:
|
||||
needs: determine-changes
|
||||
if: ${{ needs.determine-changes.outputs.matrix != '{"include":[]}' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
matrix: ${{ fromJson(needs.determine-changes.outputs.matrix) }}
|
||||
build-and-push:
|
||||
needs: determine-changes
|
||||
if: ${{ needs.determine-changes.outputs.matrix != '{"include":[]}' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
matrix: ${{ fromJson(needs.determine-changes.outputs.matrix) }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup NBGV
|
||||
uses: dotnet/nbgv@master
|
||||
id: nbgv
|
||||
- name: Setup NBGV
|
||||
uses: dotnet/nbgv@master
|
||||
id: nbgv
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- 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: 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 Docker image for ${{ matrix.service }}
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: DysonNetwork.${{ matrix.service }}/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-${{ matrix.image }}:${{ steps.nbgv.outputs.SimpleVersion }}
|
||||
ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-${{ matrix.image }}:latest
|
||||
platforms: linux/amd64
|
||||
- name: Build and push Docker image for ${{ matrix.service }}
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: DysonNetwork.${{ matrix.service }}/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-${{ matrix.image }}:${{ steps.nbgv.outputs.SimpleVersion }}
|
||||
ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-${{ matrix.image }}:latest
|
||||
platforms: linux/amd64
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
var builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
var isDev = builder.Environment.IsDevelopment();
|
||||
|
||||
var cache = builder.AddRedis("cache");
|
||||
var queue = builder.AddNats("queue").WithJetStream();
|
||||
|
||||
var ringService = builder.AddProject<Projects.DysonNetwork_Ring>("ring");
|
||||
var passService = builder.AddProject<Projects.DysonNetwork_Pass>("pass")
|
||||
.WithReference(ringService);
|
||||
var driveService = builder.AddProject<Projects.DysonNetwork_Drive>("drive")
|
||||
.WithReference(passService)
|
||||
.WithReference(ringService);
|
||||
var sphereService = builder.AddProject<Projects.DysonNetwork_Sphere>("sphere")
|
||||
.WithReference(passService)
|
||||
.WithReference(ringService)
|
||||
.WithReference(driveService);
|
||||
var developService = builder.AddProject<Projects.DysonNetwork_Develop>("develop")
|
||||
.WithReference(passService)
|
||||
.WithReference(ringService)
|
||||
.WithReference(sphereService);
|
||||
var insightService = builder.AddProject<Projects.DysonNetwork_Insight>("insight")
|
||||
.WithReference(passService)
|
||||
.WithReference(ringService)
|
||||
.WithReference(sphereService)
|
||||
.WithReference(developService);
|
||||
var zoneService = builder.AddProject<Projects.DysonNetwork_Zone>("zone")
|
||||
.WithReference(passService)
|
||||
.WithReference(ringService)
|
||||
.WithReference(sphereService)
|
||||
.WithReference(developService)
|
||||
.WithReference(insightService);
|
||||
|
||||
passService.WithReference(developService).WithReference(driveService);
|
||||
|
||||
List<IResourceBuilder<ProjectResource>> services =
|
||||
[ringService, passService, driveService, sphereService, developService, insightService, zoneService];
|
||||
|
||||
for (var idx = 0; idx < services.Count; idx++)
|
||||
{
|
||||
var service = services[idx];
|
||||
|
||||
service.WithReference(cache).WithReference(queue);
|
||||
|
||||
var grpcPort = 7002 + idx;
|
||||
|
||||
if (isDev)
|
||||
{
|
||||
service.WithEnvironment("GRPC_PORT", grpcPort.ToString());
|
||||
|
||||
var httpPort = 8001 + idx;
|
||||
service.WithEnvironment("HTTP_PORTS", httpPort.ToString());
|
||||
service.WithHttpEndpoint(httpPort, targetPort: null, isProxied: false, name: "http");
|
||||
}
|
||||
else
|
||||
{
|
||||
service.WithHttpEndpoint(8080, targetPort: null, isProxied: false, name: "http");
|
||||
}
|
||||
|
||||
service.WithEndpoint(isDev ? grpcPort : 7001, isDev ? null : 7001, "https", name: "grpc", isProxied: false);
|
||||
}
|
||||
|
||||
// Extra double-ended references
|
||||
ringService.WithReference(passService);
|
||||
|
||||
var gateway = builder.AddProject<Projects.DysonNetwork_Gateway>("gateway")
|
||||
.WithEnvironment("HTTP_PORTS", "5001")
|
||||
.WithHttpEndpoint(port: 5001, targetPort: null, isProxied: false, name: "http");
|
||||
|
||||
foreach (var service in services)
|
||||
gateway.WithReference(service);
|
||||
|
||||
builder.AddDockerComposeEnvironment("docker-compose");
|
||||
|
||||
builder.Build().Run();
|
||||
@@ -1,29 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Sdk Name="Aspire.AppHost.Sdk" Version="13.0.0"/>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.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="13.0.0"/>
|
||||
<PackageReference Include="Aspire.Hosting.Docker" Version="13.0.0-preview.1.25560.3"/>
|
||||
<PackageReference Include="Aspire.Hosting.Nats" Version="13.0.0"/>
|
||||
<PackageReference Include="Aspire.Hosting.Redis" Version="13.0.0"/>
|
||||
</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"/>
|
||||
<ProjectReference Include="..\DysonNetwork.Gateway\DysonNetwork.Gateway.csproj"/>
|
||||
<ProjectReference Include="..\DysonNetwork.Insight\DysonNetwork.Insight.csproj"/>
|
||||
<ProjectReference Include="..\DysonNetwork.Zone\DysonNetwork.Zone.csproj"/>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:17169;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",
|
||||
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21260",
|
||||
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22052"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:22108"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"cache": "localhost:6379"
|
||||
}
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/aspire-8.0.json",
|
||||
"resources": {
|
||||
"cache": {
|
||||
"type": "container.v1",
|
||||
"connectionString": "{cache.bindings.tcp.host}:{cache.bindings.tcp.port},password={cache-password.value}",
|
||||
"image": "docker.io/library/redis:8.2",
|
||||
"entrypoint": "/bin/sh",
|
||||
"args": [
|
||||
"-c",
|
||||
"redis-server --requirepass $REDIS_PASSWORD"
|
||||
],
|
||||
"env": {
|
||||
"REDIS_PASSWORD": "{cache-password.value}"
|
||||
},
|
||||
"bindings": {
|
||||
"tcp": {
|
||||
"scheme": "tcp",
|
||||
"protocol": "tcp",
|
||||
"transport": "tcp",
|
||||
"targetPort": 6379
|
||||
}
|
||||
}
|
||||
},
|
||||
"queue": {
|
||||
"type": "container.v1",
|
||||
"connectionString": "nats://nats:{queue-password.value}@{queue.bindings.tcp.host}:{queue.bindings.tcp.port}",
|
||||
"image": "docker.io/library/nats:2.11",
|
||||
"args": [
|
||||
"--user",
|
||||
"nats",
|
||||
"--pass",
|
||||
"{queue-password.value}",
|
||||
"-js"
|
||||
],
|
||||
"bindings": {
|
||||
"tcp": {
|
||||
"scheme": "tcp",
|
||||
"protocol": "tcp",
|
||||
"transport": "tcp",
|
||||
"targetPort": 4222
|
||||
}
|
||||
}
|
||||
},
|
||||
"ring": {
|
||||
"type": "project.v1",
|
||||
"path": "../DysonNetwork.Ring/DysonNetwork.Ring.csproj",
|
||||
"env": {
|
||||
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
|
||||
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
|
||||
"OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
|
||||
"ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
|
||||
"HTTP_PORTS": "8001",
|
||||
"HTTPS_PORTS": "{ring.bindings.grpc.targetPort}",
|
||||
"ConnectionStrings__cache": "{cache.connectionString}",
|
||||
"ConnectionStrings__queue": "{queue.connectionString}",
|
||||
"GRPC_PORT": "7002",
|
||||
"services__pass__http__0": "{pass.bindings.http.url}",
|
||||
"services__pass__grpc__0": "{pass.bindings.grpc.url}",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT": "{docker-compose-dashboard.bindings.otlp-grpc.url}",
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
|
||||
"OTEL_SERVICE_NAME": "ring"
|
||||
},
|
||||
"bindings": {
|
||||
"http": {
|
||||
"scheme": "http",
|
||||
"protocol": "tcp",
|
||||
"transport": "http",
|
||||
"targetPort": 8001
|
||||
},
|
||||
"grpc": {
|
||||
"scheme": "https",
|
||||
"protocol": "tcp",
|
||||
"transport": "http",
|
||||
"targetPort": 7002
|
||||
}
|
||||
}
|
||||
},
|
||||
"pass": {
|
||||
"type": "project.v1",
|
||||
"path": "../DysonNetwork.Pass/DysonNetwork.Pass.csproj",
|
||||
"env": {
|
||||
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
|
||||
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
|
||||
"OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
|
||||
"ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
|
||||
"HTTP_PORTS": "8002",
|
||||
"HTTPS_PORTS": "{pass.bindings.grpc.targetPort}",
|
||||
"services__ring__http__0": "{ring.bindings.http.url}",
|
||||
"services__ring__grpc__0": "{ring.bindings.grpc.url}",
|
||||
"services__develop__http__0": "{develop.bindings.http.url}",
|
||||
"services__develop__grpc__0": "{develop.bindings.grpc.url}",
|
||||
"services__drive__http__0": "{drive.bindings.http.url}",
|
||||
"services__drive__grpc__0": "{drive.bindings.grpc.url}",
|
||||
"ConnectionStrings__cache": "{cache.connectionString}",
|
||||
"ConnectionStrings__queue": "{queue.connectionString}",
|
||||
"GRPC_PORT": "7003",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT": "{docker-compose-dashboard.bindings.otlp-grpc.url}",
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
|
||||
"OTEL_SERVICE_NAME": "pass"
|
||||
},
|
||||
"bindings": {
|
||||
"http": {
|
||||
"scheme": "http",
|
||||
"protocol": "tcp",
|
||||
"transport": "http",
|
||||
"targetPort": 8002
|
||||
},
|
||||
"grpc": {
|
||||
"scheme": "https",
|
||||
"protocol": "tcp",
|
||||
"transport": "http",
|
||||
"targetPort": 7003
|
||||
}
|
||||
}
|
||||
},
|
||||
"drive": {
|
||||
"type": "project.v1",
|
||||
"path": "../DysonNetwork.Drive/DysonNetwork.Drive.csproj",
|
||||
"env": {
|
||||
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
|
||||
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
|
||||
"OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
|
||||
"ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
|
||||
"HTTP_PORTS": "8003",
|
||||
"HTTPS_PORTS": "{drive.bindings.grpc.targetPort}",
|
||||
"services__pass__http__0": "{pass.bindings.http.url}",
|
||||
"services__pass__grpc__0": "{pass.bindings.grpc.url}",
|
||||
"services__ring__http__0": "{ring.bindings.http.url}",
|
||||
"services__ring__grpc__0": "{ring.bindings.grpc.url}",
|
||||
"ConnectionStrings__cache": "{cache.connectionString}",
|
||||
"ConnectionStrings__queue": "{queue.connectionString}",
|
||||
"GRPC_PORT": "7004",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT": "{docker-compose-dashboard.bindings.otlp-grpc.url}",
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
|
||||
"OTEL_SERVICE_NAME": "drive"
|
||||
},
|
||||
"bindings": {
|
||||
"http": {
|
||||
"scheme": "http",
|
||||
"protocol": "tcp",
|
||||
"transport": "http",
|
||||
"targetPort": 8003
|
||||
},
|
||||
"grpc": {
|
||||
"scheme": "https",
|
||||
"protocol": "tcp",
|
||||
"transport": "http",
|
||||
"targetPort": 7004
|
||||
}
|
||||
}
|
||||
},
|
||||
"sphere": {
|
||||
"type": "project.v1",
|
||||
"path": "../DysonNetwork.Sphere/DysonNetwork.Sphere.csproj",
|
||||
"env": {
|
||||
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
|
||||
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
|
||||
"OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
|
||||
"ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
|
||||
"HTTP_PORTS": "8004",
|
||||
"HTTPS_PORTS": "{sphere.bindings.grpc.targetPort}",
|
||||
"services__pass__http__0": "{pass.bindings.http.url}",
|
||||
"services__pass__grpc__0": "{pass.bindings.grpc.url}",
|
||||
"services__ring__http__0": "{ring.bindings.http.url}",
|
||||
"services__ring__grpc__0": "{ring.bindings.grpc.url}",
|
||||
"services__drive__http__0": "{drive.bindings.http.url}",
|
||||
"services__drive__grpc__0": "{drive.bindings.grpc.url}",
|
||||
"ConnectionStrings__cache": "{cache.connectionString}",
|
||||
"ConnectionStrings__queue": "{queue.connectionString}",
|
||||
"GRPC_PORT": "7005",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT": "{docker-compose-dashboard.bindings.otlp-grpc.url}",
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
|
||||
"OTEL_SERVICE_NAME": "sphere"
|
||||
},
|
||||
"bindings": {
|
||||
"http": {
|
||||
"scheme": "http",
|
||||
"protocol": "tcp",
|
||||
"transport": "http",
|
||||
"targetPort": 8004
|
||||
},
|
||||
"grpc": {
|
||||
"scheme": "https",
|
||||
"protocol": "tcp",
|
||||
"transport": "http",
|
||||
"targetPort": 7005
|
||||
}
|
||||
}
|
||||
},
|
||||
"develop": {
|
||||
"type": "project.v1",
|
||||
"path": "../DysonNetwork.Develop/DysonNetwork.Develop.csproj",
|
||||
"env": {
|
||||
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
|
||||
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
|
||||
"OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
|
||||
"ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
|
||||
"HTTP_PORTS": "8005",
|
||||
"HTTPS_PORTS": "{develop.bindings.grpc.targetPort}",
|
||||
"services__pass__http__0": "{pass.bindings.http.url}",
|
||||
"services__pass__grpc__0": "{pass.bindings.grpc.url}",
|
||||
"services__ring__http__0": "{ring.bindings.http.url}",
|
||||
"services__ring__grpc__0": "{ring.bindings.grpc.url}",
|
||||
"services__sphere__http__0": "{sphere.bindings.http.url}",
|
||||
"services__sphere__grpc__0": "{sphere.bindings.grpc.url}",
|
||||
"ConnectionStrings__cache": "{cache.connectionString}",
|
||||
"ConnectionStrings__queue": "{queue.connectionString}",
|
||||
"GRPC_PORT": "7006",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT": "{docker-compose-dashboard.bindings.otlp-grpc.url}",
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
|
||||
"OTEL_SERVICE_NAME": "develop"
|
||||
},
|
||||
"bindings": {
|
||||
"http": {
|
||||
"scheme": "http",
|
||||
"protocol": "tcp",
|
||||
"transport": "http",
|
||||
"targetPort": 8005
|
||||
},
|
||||
"grpc": {
|
||||
"scheme": "https",
|
||||
"protocol": "tcp",
|
||||
"transport": "http",
|
||||
"targetPort": 7006
|
||||
}
|
||||
}
|
||||
},
|
||||
"insight": {
|
||||
"type": "project.v1",
|
||||
"path": "../DysonNetwork.Insight/DysonNetwork.Insight.csproj",
|
||||
"env": {
|
||||
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
|
||||
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
|
||||
"OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
|
||||
"ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
|
||||
"HTTP_PORTS": "8006",
|
||||
"HTTPS_PORTS": "{insight.bindings.grpc.targetPort}",
|
||||
"services__pass__http__0": "{pass.bindings.http.url}",
|
||||
"services__pass__grpc__0": "{pass.bindings.grpc.url}",
|
||||
"services__ring__http__0": "{ring.bindings.http.url}",
|
||||
"services__ring__grpc__0": "{ring.bindings.grpc.url}",
|
||||
"services__sphere__http__0": "{sphere.bindings.http.url}",
|
||||
"services__sphere__grpc__0": "{sphere.bindings.grpc.url}",
|
||||
"services__develop__http__0": "{develop.bindings.http.url}",
|
||||
"services__develop__grpc__0": "{develop.bindings.grpc.url}",
|
||||
"ConnectionStrings__cache": "{cache.connectionString}",
|
||||
"ConnectionStrings__queue": "{queue.connectionString}",
|
||||
"GRPC_PORT": "7007",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT": "{docker-compose-dashboard.bindings.otlp-grpc.url}",
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
|
||||
"OTEL_SERVICE_NAME": "insight"
|
||||
},
|
||||
"bindings": {
|
||||
"http": {
|
||||
"scheme": "http",
|
||||
"protocol": "tcp",
|
||||
"transport": "http",
|
||||
"targetPort": 8006
|
||||
},
|
||||
"grpc": {
|
||||
"scheme": "https",
|
||||
"protocol": "tcp",
|
||||
"transport": "http",
|
||||
"targetPort": 7007
|
||||
}
|
||||
}
|
||||
},
|
||||
"gateway": {
|
||||
"type": "project.v1",
|
||||
"path": "../DysonNetwork.Gateway/DysonNetwork.Gateway.csproj",
|
||||
"env": {
|
||||
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
|
||||
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
|
||||
"OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
|
||||
"ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
|
||||
"HTTP_PORTS": "5001",
|
||||
"services__ring__http__0": "{ring.bindings.http.url}",
|
||||
"services__ring__grpc__0": "{ring.bindings.grpc.url}",
|
||||
"services__pass__http__0": "{pass.bindings.http.url}",
|
||||
"services__pass__grpc__0": "{pass.bindings.grpc.url}",
|
||||
"services__drive__http__0": "{drive.bindings.http.url}",
|
||||
"services__drive__grpc__0": "{drive.bindings.grpc.url}",
|
||||
"services__sphere__http__0": "{sphere.bindings.http.url}",
|
||||
"services__sphere__grpc__0": "{sphere.bindings.grpc.url}",
|
||||
"services__develop__http__0": "{develop.bindings.http.url}",
|
||||
"services__develop__grpc__0": "{develop.bindings.grpc.url}",
|
||||
"services__insight__http__0": "{insight.bindings.http.url}",
|
||||
"services__insight__grpc__0": "{insight.bindings.grpc.url}",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT": "{docker-compose-dashboard.bindings.otlp-grpc.url}",
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
|
||||
"OTEL_SERVICE_NAME": "gateway"
|
||||
},
|
||||
"bindings": {
|
||||
"http": {
|
||||
"scheme": "http",
|
||||
"protocol": "tcp",
|
||||
"transport": "http",
|
||||
"targetPort": 5001
|
||||
}
|
||||
}
|
||||
},
|
||||
"docker-compose": {
|
||||
"error": "This resource does not support generation in the manifest."
|
||||
},
|
||||
"cache-password": {
|
||||
"type": "parameter.v0",
|
||||
"value": "{cache-password.inputs.value}",
|
||||
"inputs": {
|
||||
"value": {
|
||||
"type": "string",
|
||||
"secret": true,
|
||||
"default": {
|
||||
"generate": {
|
||||
"minLength": 22,
|
||||
"special": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"queue-password": {
|
||||
"type": "parameter.v0",
|
||||
"value": "{queue-password.inputs.value}",
|
||||
"inputs": {
|
||||
"value": {
|
||||
"type": "string",
|
||||
"secret": true,
|
||||
"default": {
|
||||
"generate": {
|
||||
"minLength": 22,
|
||||
"special": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"docker-compose-dashboard": {
|
||||
"type": "container.v1",
|
||||
"image": "mcr.microsoft.com/dotnet/nightly/aspire-dashboard:latest",
|
||||
"bindings": {
|
||||
"http": {
|
||||
"scheme": "http",
|
||||
"protocol": "tcp",
|
||||
"transport": "http",
|
||||
"targetPort": 18888
|
||||
},
|
||||
"otlp-grpc": {
|
||||
"scheme": "http",
|
||||
"protocol": "tcp",
|
||||
"transport": "http",
|
||||
"targetPort": 18889
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ public class DeveloperController(
|
||||
|
||||
[HttpPost("{name}/enroll")]
|
||||
[Authorize]
|
||||
[RequiredPermission("global", "developers.create")]
|
||||
[AskPermission("developers.create")]
|
||||
public async Task<ActionResult<SnDeveloper>> EnrollDeveloperProgram(string name)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
@@ -7,16 +7,15 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
builder.AddServiceDefaults("develop");
|
||||
|
||||
builder.Services.Configure<ServiceRegistrationOptions>(opts => { opts.Name = "develop"; });
|
||||
|
||||
builder.ConfigureAppKestrel(builder.Configuration);
|
||||
|
||||
builder.Services.AddAppServices(builder.Configuration);
|
||||
builder.Services.AddAppAuthentication();
|
||||
builder.Services.AddDysonAuth();
|
||||
builder.Services.AddSphereService();
|
||||
builder.Services.AddAccountService();
|
||||
builder.Services.AddDriveService();
|
||||
|
||||
builder.AddSwaggerManifest(
|
||||
"DysonNetwork.Develop",
|
||||
|
||||
@@ -16,7 +16,7 @@ public static class ApplicationConfiguration
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseMiddleware<PermissionMiddleware>();
|
||||
app.UseMiddleware<RemotePermissionMiddleware>();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
|
||||
@@ -16,9 +16,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddLocalization();
|
||||
|
||||
services.AddDbContext<AppDatabase>();
|
||||
services.AddSingleton<IClock>(SystemClock.Instance);
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddSingleton<ICacheService, CacheServiceRedis>();
|
||||
|
||||
services.AddHttpClient();
|
||||
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
{
|
||||
"Debug": true,
|
||||
"BaseUrl": "http://localhost:5071",
|
||||
"SiteUrl": "https://solian.app",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Debug": true,
|
||||
"BaseUrl": "http://localhost:5071",
|
||||
"SiteUrl": "https://solian.app",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"App": "Host=localhost;Port=5432;Database=dyson_develop;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
|
||||
"Registrar": "127.0.0.1:2379",
|
||||
"Cache": "127.0.0.1:6379",
|
||||
"Queue": "127.0.0.1:4222"
|
||||
},
|
||||
"KnownProxies": [
|
||||
"127.0.0.1",
|
||||
"::1"
|
||||
],
|
||||
"Swagger": {
|
||||
"PublicBasePath": "/develop"
|
||||
},
|
||||
"Cache": {
|
||||
"Serializer": "MessagePack"
|
||||
},
|
||||
"Etcd": {
|
||||
"Insecure": true
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"App": "Host=localhost;Port=5432;Database=dyson_develop;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
||||
},
|
||||
"KnownProxies": ["127.0.0.1", "::1"],
|
||||
"Swagger": {
|
||||
"PublicBasePath": "/develop"
|
||||
},
|
||||
"Etcd": {
|
||||
"Insecure": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
using Microsoft.EntityFrameworkCore.Query;
|
||||
using NodaTime;
|
||||
using Quartz;
|
||||
using TaskStatus = DysonNetwork.Drive.Storage.Model.TaskStatus;
|
||||
|
||||
@@ -7,7 +7,9 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
builder.AddServiceDefaults("drive");
|
||||
|
||||
builder.Services.Configure<ServiceRegistrationOptions>(opts => { opts.Name = "drive"; });
|
||||
|
||||
// Configure Kestrel and server options
|
||||
builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxValue);
|
||||
@@ -17,8 +19,6 @@ builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxV
|
||||
builder.Services.AddAppServices(builder.Configuration);
|
||||
builder.Services.AddAppAuthentication();
|
||||
builder.Services.AddDysonAuth();
|
||||
builder.Services.AddRingService();
|
||||
builder.Services.AddAccountService();
|
||||
|
||||
builder.Services.AddAppFlushHandlers();
|
||||
builder.Services.AddAppBusinessServices();
|
||||
|
||||
@@ -12,9 +12,7 @@ public static class ServiceCollectionExtensions
|
||||
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddDbContext<AppDatabase>(); // Assuming you'll have an AppDatabase
|
||||
services.AddSingleton<IClock>(SystemClock.Instance);
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddSingleton<ICacheService, CacheServiceRedis>(); // Uncomment if you have CacheServiceRedis
|
||||
|
||||
services.AddHttpClient();
|
||||
|
||||
|
||||
@@ -381,7 +381,7 @@ public class FileController(
|
||||
|
||||
[Authorize]
|
||||
[HttpDelete("recycle")]
|
||||
[RequiredPermission("maintenance", "files.delete.recycle")]
|
||||
[AskPermission("files.delete.recycle")]
|
||||
public async Task<ActionResult> DeleteAllRecycledFiles()
|
||||
{
|
||||
var count = await fs.DeleteAllRecycledFilesAsync();
|
||||
|
||||
@@ -58,12 +58,15 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
|
||||
Duration? duration = null
|
||||
)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var data = fileId.Select(id => new SnCloudFileReference
|
||||
{
|
||||
FileId = id,
|
||||
Usage = usage,
|
||||
ResourceId = resourceId,
|
||||
ExpiredAt = expiredAt ?? SystemClock.Instance.GetCurrentInstant() + duration
|
||||
ExpiredAt = expiredAt ?? now + duration,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
}).ToList();
|
||||
await db.BulkInsertAsync(data);
|
||||
return data;
|
||||
|
||||
@@ -113,7 +113,7 @@ public class FileUploadController(
|
||||
if (currentUser.IsSuperuser) return null;
|
||||
|
||||
var allowed = await permission.HasPermissionAsync(new HasPermissionRequest
|
||||
{ Actor = $"user:{currentUser.Id}", Area = "global", Key = "files.create" });
|
||||
{ Actor = currentUser.Id, Key = "files.create" });
|
||||
|
||||
return allowed.HasPermission
|
||||
? null
|
||||
|
||||
@@ -1,118 +1,124 @@
|
||||
{
|
||||
"Debug": true,
|
||||
"BaseUrl": "http://localhost:5090",
|
||||
"GatewayUrl": "http://localhost:5094",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"Authentication": {
|
||||
"Schemes": {
|
||||
"Bearer": {
|
||||
"ValidAudiences": [
|
||||
"http://localhost:5071",
|
||||
"https://localhost:7099"
|
||||
],
|
||||
"ValidIssuer": "solar-network"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AuthToken": {
|
||||
"PublicKeyPath": "Keys/PublicKey.pem",
|
||||
"PrivateKeyPath": "Keys/PrivateKey.pem"
|
||||
},
|
||||
"Storage": {
|
||||
"Uploads": "Uploads",
|
||||
"PreferredRemote": "c53136a6-9152-4ecb-9f88-43c41438c23e",
|
||||
"Remote": [
|
||||
{
|
||||
"Id": "minio",
|
||||
"Label": "Minio",
|
||||
"Region": "auto",
|
||||
"Bucket": "solar-network-development",
|
||||
"Endpoint": "localhost:9000",
|
||||
"SecretId": "littlesheep",
|
||||
"SecretKey": "password",
|
||||
"EnabledSigned": true,
|
||||
"EnableSsl": false
|
||||
},
|
||||
{
|
||||
"Id": "cloudflare",
|
||||
"Label": "Cloudflare R2",
|
||||
"Region": "auto",
|
||||
"Bucket": "solar-network",
|
||||
"Endpoint": "0a70a6d1b7128888c823359d0008f4e1.r2.cloudflarestorage.com",
|
||||
"SecretId": "8ff5d06c7b1639829d60bc6838a542e6",
|
||||
"SecretKey": "fd58158c5201be16d1872c9209d9cf199421dae3c2f9972f94b2305976580d67",
|
||||
"EnableSigned": true,
|
||||
"EnableSsl": true
|
||||
}
|
||||
"Debug": true,
|
||||
"BaseUrl": "http://localhost:5090",
|
||||
"GatewayUrl": "http://localhost:5094",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"Registrar": "127.0.0.1:2379",
|
||||
"Cache": "127.0.0.1:6379",
|
||||
"Queue": "127.0.0.1:4222"
|
||||
},
|
||||
"Authentication": {
|
||||
"Schemes": {
|
||||
"Bearer": {
|
||||
"ValidAudiences": [
|
||||
"http://localhost:5071",
|
||||
"https://localhost:7099"
|
||||
],
|
||||
"ValidIssuer": "solar-network"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AuthToken": {
|
||||
"PublicKeyPath": "Keys/PublicKey.pem",
|
||||
"PrivateKeyPath": "Keys/PrivateKey.pem"
|
||||
},
|
||||
"Storage": {
|
||||
"Uploads": "Uploads",
|
||||
"PreferredRemote": "c53136a6-9152-4ecb-9f88-43c41438c23e",
|
||||
"Remote": [
|
||||
{
|
||||
"Id": "minio",
|
||||
"Label": "Minio",
|
||||
"Region": "auto",
|
||||
"Bucket": "solar-network-development",
|
||||
"Endpoint": "localhost:9000",
|
||||
"SecretId": "littlesheep",
|
||||
"SecretKey": "password",
|
||||
"EnabledSigned": true,
|
||||
"EnableSsl": false
|
||||
},
|
||||
{
|
||||
"Id": "cloudflare",
|
||||
"Label": "Cloudflare R2",
|
||||
"Region": "auto",
|
||||
"Bucket": "solar-network",
|
||||
"Endpoint": "0a70a6d1b7128888c823359d0008f4e1.r2.cloudflarestorage.com",
|
||||
"SecretId": "8ff5d06c7b1639829d60bc6838a542e6",
|
||||
"SecretKey": "fd58158c5201be16d1872c9209d9cf199421dae3c2f9972f94b2305976580d67",
|
||||
"EnableSigned": true,
|
||||
"EnableSsl": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"Captcha": {
|
||||
"Provider": "cloudflare",
|
||||
"ApiKey": "0x4AAAAAABCDUdOujj4feOb_",
|
||||
"ApiSecret": "0x4AAAAAABCDUWABiJQweqlB7tYq-IqIm8U"
|
||||
},
|
||||
"Notifications": {
|
||||
"Topic": "dev.solsynth.solian",
|
||||
"Endpoint": "http://localhost:8088"
|
||||
},
|
||||
"Email": {
|
||||
"Server": "smtp4dev.orb.local",
|
||||
"Port": 25,
|
||||
"UseSsl": false,
|
||||
"Username": "no-reply@mail.solsynth.dev",
|
||||
"Password": "password",
|
||||
"FromAddress": "no-reply@mail.solsynth.dev",
|
||||
"FromName": "Alphabot",
|
||||
"SubjectPrefix": "Solar Network"
|
||||
},
|
||||
"RealtimeChat": {
|
||||
"Endpoint": "https://solar-network-im44o8gq.livekit.cloud",
|
||||
"ApiKey": "APIs6TiL8wj3A4j",
|
||||
"ApiSecret": "SffxRneIwTnlHPtEf3zicmmv3LUEl7xXael4PvWZrEhE"
|
||||
},
|
||||
"GeoIp": {
|
||||
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
|
||||
},
|
||||
"Oidc": {
|
||||
"Google": {
|
||||
"ClientId": "961776991058-963m1qin2vtp8fv693b5fdrab5hmpl89.apps.googleusercontent.com",
|
||||
"ClientSecret": ""
|
||||
},
|
||||
"Apple": {
|
||||
"ClientId": "dev.solsynth.solian",
|
||||
"TeamId": "W7HPZ53V6B",
|
||||
"KeyId": "B668YP4KBG",
|
||||
"PrivateKeyPath": "./Keys/Solarpass.p8"
|
||||
},
|
||||
"Microsoft": {
|
||||
"ClientId": "YOUR_MICROSOFT_CLIENT_ID",
|
||||
"ClientSecret": "YOUR_MICROSOFT_CLIENT_SECRET",
|
||||
"DiscoveryEndpoint": "YOUR_MICROSOFT_DISCOVERY_ENDPOINT"
|
||||
}
|
||||
},
|
||||
"Payment": {
|
||||
"Auth": {
|
||||
"Afdian": "<token here>"
|
||||
},
|
||||
"Subscriptions": {
|
||||
"Afdian": {
|
||||
"7d17aae23c9611f0b5705254001e7c00": "solian.stellar.primary",
|
||||
"7dfae4743c9611f0b3a55254001e7c00": "solian.stellar.nova",
|
||||
"141713ee3d6211f085b352540025c377": "solian.stellar.supernova"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Cache": {
|
||||
"Serializer": "MessagePack"
|
||||
},
|
||||
"KnownProxies": [
|
||||
"127.0.0.1",
|
||||
"::1"
|
||||
]
|
||||
},
|
||||
"Captcha": {
|
||||
"Provider": "cloudflare",
|
||||
"ApiKey": "0x4AAAAAABCDUdOujj4feOb_",
|
||||
"ApiSecret": "0x4AAAAAABCDUWABiJQweqlB7tYq-IqIm8U"
|
||||
},
|
||||
"Notifications": {
|
||||
"Topic": "dev.solsynth.solian",
|
||||
"Endpoint": "http://localhost:8088"
|
||||
},
|
||||
"Email": {
|
||||
"Server": "smtp4dev.orb.local",
|
||||
"Port": 25,
|
||||
"UseSsl": false,
|
||||
"Username": "no-reply@mail.solsynth.dev",
|
||||
"Password": "password",
|
||||
"FromAddress": "no-reply@mail.solsynth.dev",
|
||||
"FromName": "Alphabot",
|
||||
"SubjectPrefix": "Solar Network"
|
||||
},
|
||||
"RealtimeChat": {
|
||||
"Endpoint": "https://solar-network-im44o8gq.livekit.cloud",
|
||||
"ApiKey": "APIs6TiL8wj3A4j",
|
||||
"ApiSecret": "SffxRneIwTnlHPtEf3zicmmv3LUEl7xXael4PvWZrEhE"
|
||||
},
|
||||
"GeoIp": {
|
||||
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
|
||||
},
|
||||
"Oidc": {
|
||||
"Google": {
|
||||
"ClientId": "961776991058-963m1qin2vtp8fv693b5fdrab5hmpl89.apps.googleusercontent.com",
|
||||
"ClientSecret": ""
|
||||
},
|
||||
"Apple": {
|
||||
"ClientId": "dev.solsynth.solian",
|
||||
"TeamId": "W7HPZ53V6B",
|
||||
"KeyId": "B668YP4KBG",
|
||||
"PrivateKeyPath": "./Keys/Solarpass.p8"
|
||||
},
|
||||
"Microsoft": {
|
||||
"ClientId": "YOUR_MICROSOFT_CLIENT_ID",
|
||||
"ClientSecret": "YOUR_MICROSOFT_CLIENT_SECRET",
|
||||
"DiscoveryEndpoint": "YOUR_MICROSOFT_DISCOVERY_ENDPOINT"
|
||||
}
|
||||
},
|
||||
"Payment": {
|
||||
"Auth": {
|
||||
"Afdian": "<token here>"
|
||||
},
|
||||
"Subscriptions": {
|
||||
"Afdian": {
|
||||
"7d17aae23c9611f0b5705254001e7c00": "solian.stellar.primary",
|
||||
"7dfae4743c9611f0b3a55254001e7c00": "solian.stellar.nova",
|
||||
"141713ee3d6211f085b352540025c377": "solian.stellar.supernova"
|
||||
}
|
||||
}
|
||||
},
|
||||
"KnownProxies": [
|
||||
"127.0.0.1",
|
||||
"::1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
[ApiController]
|
||||
[Route("config")]
|
||||
public class ConfigurationController(IConfiguration configuration) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public IActionResult Get() => Ok(configuration.GetSection("Client").Get<Dictionary<string, object>>());
|
||||
|
||||
[HttpGet("site")]
|
||||
public IActionResult GetSiteUrl() => Ok(configuration["SiteUrl"]);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.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"]
|
||||
@@ -1,22 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery.Yarp" Version="10.0.0" />
|
||||
<PackageReference Include="Nerdbank.GitVersioning" Version="3.9.50">
|
||||
<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>
|
||||
@@ -1,168 +0,0 @@
|
||||
using System.Threading.RateLimiting;
|
||||
using DysonNetwork.Shared.Http;
|
||||
using Yarp.ReverseProxy.Configuration;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
|
||||
builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxValue, enableGrpc: false);
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(
|
||||
policy =>
|
||||
{
|
||||
policy.SetIsOriginAllowed(origin => true)
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader()
|
||||
.AllowCredentials()
|
||||
.WithExposedHeaders("X-Total");
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.AddPolicy("fixed", context =>
|
||||
{
|
||||
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
|
||||
return RateLimitPartition.GetFixedWindowLimiter(
|
||||
partitionKey: ip,
|
||||
factory: _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 120, // 120 requests...
|
||||
Window = TimeSpan.FromMinutes(1), // ...per minute per IP
|
||||
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
||||
QueueLimit = 10 // allow short bursts instead of instant 503s
|
||||
});
|
||||
});
|
||||
|
||||
options.OnRejected = async (context, token) =>
|
||||
{
|
||||
// Log the rejected IP
|
||||
var logger = context.HttpContext.RequestServices
|
||||
.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("RateLimiter");
|
||||
|
||||
var ip = context.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
logger.LogWarning("Rate limit exceeded for IP: {IP}", ip);
|
||||
|
||||
// Respond to the client
|
||||
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
await context.HttpContext.Response.WriteAsync(
|
||||
"Rate limit exceeded. Try again later.", token);
|
||||
};
|
||||
});
|
||||
|
||||
var serviceNames = new[] { "ring", "pass", "drive", "sphere", "develop", "insight", "zone" };
|
||||
|
||||
var specialRoutes = new[]
|
||||
{
|
||||
new RouteConfig
|
||||
{
|
||||
RouteId = "ring-ws",
|
||||
ClusterId = "ring",
|
||||
Match = new RouteMatch { Path = "/ws" }
|
||||
},
|
||||
new RouteConfig
|
||||
{
|
||||
RouteId = "pass-openid",
|
||||
ClusterId = "pass",
|
||||
Match = new RouteMatch { Path = "/.well-known/openid-configuration" }
|
||||
},
|
||||
new RouteConfig
|
||||
{
|
||||
RouteId = "pass-jwks",
|
||||
ClusterId = "pass",
|
||||
Match = new RouteMatch { Path = "/.well-known/jwks" }
|
||||
},
|
||||
new RouteConfig
|
||||
{
|
||||
RouteId = "drive-tus",
|
||||
ClusterId = "drive",
|
||||
Match = new RouteMatch { Path = "/api/tus" }
|
||||
}
|
||||
};
|
||||
|
||||
var apiRoutes = serviceNames.Select(serviceName =>
|
||||
{
|
||||
var apiPath = serviceName switch
|
||||
{
|
||||
_ => $"/{serviceName}"
|
||||
};
|
||||
return new RouteConfig
|
||||
{
|
||||
RouteId = $"{serviceName}-api",
|
||||
ClusterId = serviceName,
|
||||
Match = new RouteMatch { Path = $"{apiPath}/{{**catch-all}}" },
|
||||
Transforms =
|
||||
[
|
||||
new Dictionary<string, string> { { "PathRemovePrefix", apiPath } },
|
||||
new Dictionary<string, string> { { "PathPrefix", "/api" } }
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
var swaggerRoutes = serviceNames.Select(serviceName => new RouteConfig
|
||||
{
|
||||
RouteId = $"{serviceName}-swagger",
|
||||
ClusterId = serviceName,
|
||||
Match = new RouteMatch { Path = $"/swagger/{serviceName}/{{**catch-all}}" },
|
||||
Transforms =
|
||||
[
|
||||
new Dictionary<string, string> { { "PathRemovePrefix", $"/swagger/{serviceName}" } },
|
||||
new Dictionary<string, string> { { "PathPrefix", "/swagger" } }
|
||||
]
|
||||
});
|
||||
|
||||
var routes = specialRoutes.Concat(apiRoutes).Concat(swaggerRoutes).ToArray();
|
||||
|
||||
var clusters = serviceNames.Select(serviceName => new ClusterConfig
|
||||
{
|
||||
ClusterId = serviceName,
|
||||
HealthCheck = new HealthCheckConfig
|
||||
{
|
||||
Active = new ActiveHealthCheckConfig
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromSeconds(10),
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Path = "/health"
|
||||
},
|
||||
Passive = new()
|
||||
{
|
||||
Enabled = true
|
||||
}
|
||||
},
|
||||
Destinations = new Dictionary<string, DestinationConfig>
|
||||
{
|
||||
{ "destination1", new DestinationConfig { Address = $"http://{serviceName}" } }
|
||||
}
|
||||
}).ToArray();
|
||||
|
||||
builder.Services
|
||||
.AddReverseProxy()
|
||||
.LoadFromMemory(routes, clusters)
|
||||
.AddServiceDiscoveryDestinationResolver();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
var forwardedHeadersOptions = new ForwardedHeadersOptions
|
||||
{
|
||||
ForwardedHeaders = ForwardedHeaders.All
|
||||
};
|
||||
forwardedHeadersOptions.KnownNetworks.Clear();
|
||||
forwardedHeadersOptions.KnownProxies.Clear();
|
||||
app.UseForwardedHeaders(forwardedHeadersOptions);
|
||||
|
||||
app.UseCors();
|
||||
|
||||
app.MapReverseProxy().RequireRateLimiting("fixed");
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DysonNetwork.Gateway;
|
||||
|
||||
[ApiController]
|
||||
[Route("/version")]
|
||||
public class VersionController : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public IActionResult Get()
|
||||
{
|
||||
return Ok(new AppVersion
|
||||
{
|
||||
Version = ThisAssembly.AssemblyVersion,
|
||||
Commit = ThisAssembly.GitCommitId,
|
||||
UpdateDate = ThisAssembly.GitCommitDate
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"SiteUrl": "http://localhost:3000",
|
||||
"Client": {
|
||||
"SomeSetting": "SomeValue"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"publicReleaseRefSpec": ["^refs/heads/main$"],
|
||||
"cloudBuild": {
|
||||
"setVersionVariables": true
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
builder.Services.Configure<ServiceRegistrationOptions>(opts => { opts.Name = "insight"; });
|
||||
|
||||
builder.AddServiceDefaults("insight");
|
||||
|
||||
builder.ConfigureAppKestrel(builder.Configuration);
|
||||
|
||||
@@ -19,8 +21,6 @@ builder.Services.AddAppBusinessServices();
|
||||
builder.Services.AddAppScheduledJobs();
|
||||
|
||||
builder.Services.AddDysonAuth();
|
||||
builder.Services.AddAccountService();
|
||||
builder.Services.AddSphereService();
|
||||
builder.Services.AddThinkingServices(builder.Configuration);
|
||||
|
||||
builder.AddSwaggerManifest(
|
||||
|
||||
@@ -14,9 +14,7 @@ public static class ServiceCollectionExtensions
|
||||
public static IServiceCollection AddAppServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddDbContext<AppDatabase>();
|
||||
services.AddSingleton<IClock>(SystemClock.Instance);
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddSingleton<ICacheService, CacheServiceRedis>();
|
||||
|
||||
services.AddHttpClient();
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ public class ThoughtProvider
|
||||
private readonly PostService.PostServiceClient _postClient;
|
||||
private readonly AccountService.AccountServiceClient _accountClient;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<ThoughtProvider> _logger;
|
||||
|
||||
private readonly Dictionary<string, Kernel> _kernels = new();
|
||||
private readonly Dictionary<string, string> _serviceProviders = new();
|
||||
@@ -38,11 +37,9 @@ public class ThoughtProvider
|
||||
public ThoughtProvider(
|
||||
IConfiguration configuration,
|
||||
PostService.PostServiceClient postServiceClient,
|
||||
AccountService.AccountServiceClient accountServiceClient,
|
||||
ILogger<ThoughtProvider> logger
|
||||
AccountService.AccountServiceClient accountServiceClient
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_postClient = postServiceClient;
|
||||
_accountClient = accountServiceClient;
|
||||
_configuration = configuration;
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"App": "Host=localhost;Port=5432;Database=dyson_insight;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
||||
"App": "Host=localhost;Port=5432;Database=dyson_insight;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
|
||||
"Registrar": "127.0.0.1:2379",
|
||||
"Cache": "127.0.0.1:6379",
|
||||
"Queue": "127.0.0.1:4222"
|
||||
},
|
||||
"KnownProxies": [
|
||||
"127.0.0.1",
|
||||
@@ -19,6 +22,9 @@
|
||||
"Etcd": {
|
||||
"Insecure": true
|
||||
},
|
||||
"Cache": {
|
||||
"Serializer": "MessagePack"
|
||||
},
|
||||
"Thinking": {
|
||||
"DefaultService": "deepseek-chat",
|
||||
"Services": {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Pass.Affiliation;
|
||||
using DysonNetwork.Pass.Auth;
|
||||
using DysonNetwork.Pass.Credit;
|
||||
using DysonNetwork.Pass.Permission;
|
||||
using DysonNetwork.Pass.Wallet;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using DysonNetwork.Shared.Auth;
|
||||
using DysonNetwork.Shared.Geometry;
|
||||
using DysonNetwork.Shared.Http;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -22,7 +24,8 @@ public class AccountController(
|
||||
SubscriptionService subscriptions,
|
||||
AccountEventService events,
|
||||
SocialCreditService socialCreditService,
|
||||
GeoIpService geo
|
||||
AffiliationSpellService ars,
|
||||
GeoService geo
|
||||
) : ControllerBase
|
||||
{
|
||||
[HttpGet("{name}")]
|
||||
@@ -103,6 +106,8 @@ public class AccountController(
|
||||
[MaxLength(32)] public string Language { get; set; } = "en-us";
|
||||
|
||||
[Required] public string CaptchaToken { get; set; } = string.Empty;
|
||||
|
||||
public string? AffiliationSpell { get; set; }
|
||||
}
|
||||
|
||||
public class AccountCreateValidateRequest
|
||||
@@ -118,6 +123,8 @@ public class AccountController(
|
||||
[RegularExpression(@"^[^+]+@[^@]+\.[^@]+$", ErrorMessage = "Email address cannot contain '+' symbol.")]
|
||||
[MaxLength(1024)]
|
||||
public string? Email { get; set; }
|
||||
|
||||
public string? AffiliationSpell { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("validate")]
|
||||
@@ -138,6 +145,12 @@ public class AccountController(
|
||||
return BadRequest("Email has already been used.");
|
||||
}
|
||||
|
||||
if (request.AffiliationSpell is not null)
|
||||
{
|
||||
if (!await ars.CheckAffiliationSpellHasTaken(request.AffiliationSpell))
|
||||
return BadRequest("No affiliation spell has been found.");
|
||||
}
|
||||
|
||||
return Ok("Everything seems good.");
|
||||
}
|
||||
|
||||
@@ -307,7 +320,7 @@ public class AccountController(
|
||||
|
||||
[HttpPost("credits/validate")]
|
||||
[Authorize]
|
||||
[RequiredPermission("maintenance", "credits.validate.perform")]
|
||||
[AskPermission("credits.validate.perform")]
|
||||
public async Task<IActionResult> PerformSocialCreditValidation()
|
||||
{
|
||||
await socialCreditService.ValidateSocialCredits();
|
||||
@@ -316,7 +329,7 @@ public class AccountController(
|
||||
|
||||
[HttpDelete("{name}")]
|
||||
[Authorize]
|
||||
[RequiredPermission("maintenance", "accounts.deletion")]
|
||||
[AskPermission("accounts.deletion")]
|
||||
public async Task<IActionResult> AdminDeleteAccount(string name)
|
||||
{
|
||||
var account = await accounts.LookupAccount(name);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Pass.Permission;
|
||||
using DysonNetwork.Pass.Wallet;
|
||||
using DysonNetwork.Shared.Auth;
|
||||
using DysonNetwork.Shared.Http;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
@@ -194,7 +195,7 @@ public class AccountCurrentController(
|
||||
}
|
||||
|
||||
[HttpPatch("statuses")]
|
||||
[RequiredPermission("global", "accounts.statuses.update")]
|
||||
[AskPermission("accounts.statuses.update")]
|
||||
public async Task<ActionResult<SnAccountStatus>> UpdateStatus([FromBody] AccountController.StatusRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
@@ -228,7 +229,7 @@ public class AccountCurrentController(
|
||||
}
|
||||
|
||||
[HttpPost("statuses")]
|
||||
[RequiredPermission("global", "accounts.statuses.create")]
|
||||
[AskPermission("accounts.statuses.create")]
|
||||
public async Task<ActionResult<SnAccountStatus>> CreateStatus([FromBody] AccountController.StatusRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
@@ -559,7 +560,7 @@ public class AccountCurrentController(
|
||||
|
||||
[HttpGet("devices")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<SnAuthClientWithChallenge>>> GetDevices()
|
||||
public async Task<ActionResult<List<SnAuthClientWithSessions>>> GetDevices()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
|
||||
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
|
||||
@@ -570,18 +571,41 @@ public class AccountCurrentController(
|
||||
.Where(device => device.AccountId == currentUser.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var challengeDevices = devices.Select(SnAuthClientWithChallenge.FromClient).ToList();
|
||||
var deviceIds = challengeDevices.Select(x => x.DeviceId).ToList();
|
||||
var sessionDevices = devices.ConvertAll(SnAuthClientWithSessions.FromClient).ToList();
|
||||
var clientIds = sessionDevices.Select(x => x.Id).ToList();
|
||||
|
||||
var authChallenges = await db.AuthChallenges
|
||||
.Where(c => deviceIds.Contains(c.DeviceId))
|
||||
.GroupBy(c => c.DeviceId)
|
||||
var authSessions = await db.AuthSessions
|
||||
.Where(c => c.ClientId != null && clientIds.Contains(c.ClientId.Value))
|
||||
.GroupBy(c => c.ClientId!.Value)
|
||||
.ToDictionaryAsync(c => c.Key, c => c.ToList());
|
||||
foreach (var challengeDevice in challengeDevices)
|
||||
if (authChallenges.TryGetValue(challengeDevice.DeviceId, out var challenge))
|
||||
challengeDevice.Challenges = challenge;
|
||||
foreach (var dev in sessionDevices)
|
||||
if (authSessions.TryGetValue(dev.Id, out var challenge))
|
||||
dev.Sessions = challenge;
|
||||
|
||||
return Ok(challengeDevices);
|
||||
return Ok(sessionDevices);
|
||||
}
|
||||
|
||||
[HttpGet("challenges")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<SnAuthChallenge>>> GetChallenges(
|
||||
[FromQuery] int take = 20,
|
||||
[FromQuery] int offset = 0
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
var query = db.AuthChallenges
|
||||
.Where(challenge => challenge.AccountId == currentUser.Id)
|
||||
.OrderByDescending(c => c.CreatedAt);
|
||||
|
||||
var total = await query.CountAsync();
|
||||
Response.Headers.Append("X-Total", total.ToString());
|
||||
|
||||
var challenges = await query
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(challenges);
|
||||
}
|
||||
|
||||
[HttpGet("sessions")]
|
||||
@@ -595,8 +619,8 @@ public class AccountCurrentController(
|
||||
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
|
||||
|
||||
var query = db.AuthSessions
|
||||
.OrderByDescending(x => x.LastGrantedAt)
|
||||
.Include(session => session.Account)
|
||||
.Include(session => session.Challenge)
|
||||
.Where(session => session.Account.Id == currentUser.Id);
|
||||
|
||||
var total = await query.CountAsync();
|
||||
@@ -604,7 +628,6 @@ public class AccountCurrentController(
|
||||
Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
|
||||
|
||||
var sessions = await query
|
||||
.OrderByDescending(x => x.LastGrantedAt)
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Globalization;
|
||||
using DysonNetwork.Pass.Affiliation;
|
||||
using DysonNetwork.Pass.Auth.OpenId;
|
||||
using DysonNetwork.Pass.Localization;
|
||||
using DysonNetwork.Pass.Mailer;
|
||||
@@ -24,6 +25,7 @@ public class AccountService(
|
||||
FileService.FileServiceClient files,
|
||||
FileReferenceService.FileReferenceServiceClient fileRefs,
|
||||
AccountUsernameService uname,
|
||||
AffiliationSpellService ars,
|
||||
EmailService mailer,
|
||||
RingService.RingServiceClient pusher,
|
||||
IStringLocalizer<NotificationResource> localizer,
|
||||
@@ -101,6 +103,7 @@ public class AccountService(
|
||||
string? password,
|
||||
string language = "en-US",
|
||||
string region = "en",
|
||||
string? affiliationSpell = null,
|
||||
bool isEmailVerified = false,
|
||||
bool isActivated = false
|
||||
)
|
||||
@@ -113,7 +116,7 @@ public class AccountService(
|
||||
).CountAsync();
|
||||
if (dupeEmailCount > 0)
|
||||
throw new InvalidOperationException("Account email has already been used.");
|
||||
|
||||
|
||||
var account = new SnAccount
|
||||
{
|
||||
Name = name,
|
||||
@@ -122,7 +125,7 @@ public class AccountService(
|
||||
Region = region,
|
||||
Contacts =
|
||||
[
|
||||
new()
|
||||
new SnAccountContact
|
||||
{
|
||||
Type = Shared.Models.AccountContactType.Email,
|
||||
Content = email,
|
||||
@@ -144,6 +147,9 @@ public class AccountService(
|
||||
Profile = new SnAccountProfile()
|
||||
};
|
||||
|
||||
if (affiliationSpell is not null)
|
||||
await ars.CreateAffiliationResult(affiliationSpell, $"account:{account.Id}");
|
||||
|
||||
if (isActivated)
|
||||
{
|
||||
account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
@@ -152,7 +158,7 @@ public class AccountService(
|
||||
{
|
||||
db.PermissionGroupMembers.Add(new SnPermissionGroupMember
|
||||
{
|
||||
Actor = $"user:{account.Id}",
|
||||
Actor = account.Id.ToString(),
|
||||
Group = defaultGroup
|
||||
});
|
||||
}
|
||||
@@ -193,10 +199,7 @@ public class AccountService(
|
||||
displayName,
|
||||
userInfo.Email,
|
||||
null,
|
||||
"en-US",
|
||||
"en",
|
||||
userInfo.EmailVerified,
|
||||
userInfo.EmailVerified
|
||||
isEmailVerified: userInfo.EmailVerified
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using DysonNetwork.Shared.Geometry;
|
||||
using DysonNetwork.Shared.Models;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
public class ActionLogService(GeoIpService geo, FlushBufferService fbs)
|
||||
public class ActionLogService(GeoService geo, FlushBufferService fbs)
|
||||
{
|
||||
public void CreateActionLog(Guid accountId, string action, Dictionary<string, object> meta)
|
||||
{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -7,17 +9,31 @@ namespace DysonNetwork.Pass.Account;
|
||||
[Route("/api/spells")]
|
||||
public class MagicSpellController(AppDatabase db, MagicSpellService sp) : ControllerBase
|
||||
{
|
||||
[HttpPost("activation/resend")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> ResendActivationMagicSpell()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
var spell = await db.MagicSpells.FirstOrDefaultAsync(s =>
|
||||
s.Type == MagicSpellType.AccountActivation && s.AccountId == currentUser.Id);
|
||||
if (spell is null) return BadRequest("Unable to find activation magic spell.");
|
||||
|
||||
await sp.NotifyMagicSpell(spell, true);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpPost("{spellId:guid}/resend")]
|
||||
public async Task<ActionResult> ResendMagicSpell(Guid spellId)
|
||||
{
|
||||
var spell = db.MagicSpells.FirstOrDefault(x => x.Id == spellId);
|
||||
if (spell == null)
|
||||
return NotFound();
|
||||
|
||||
|
||||
await sp.NotifyMagicSpell(spell, true);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
|
||||
[HttpGet("{spellWord}")]
|
||||
public async Task<ActionResult> GetMagicSpell(string spellWord)
|
||||
{
|
||||
@@ -38,7 +54,8 @@ public class MagicSpellController(AppDatabase db, MagicSpellService sp) : Contro
|
||||
}
|
||||
|
||||
[HttpPost("{spellWord}/apply")]
|
||||
public async Task<ActionResult> ApplyMagicSpell([FromRoute] string spellWord, [FromBody] MagicSpellApplyRequest? request)
|
||||
public async Task<ActionResult> ApplyMagicSpell([FromRoute] string spellWord,
|
||||
[FromBody] MagicSpellApplyRequest? request)
|
||||
{
|
||||
var word = Uri.UnescapeDataString(spellWord);
|
||||
var spell = await db.MagicSpells
|
||||
@@ -59,6 +76,7 @@ public class MagicSpellController(AppDatabase db, MagicSpellService sp) : Contro
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public class MagicSpellService(
|
||||
Dictionary<string, object> meta,
|
||||
Instant? expiredAt = null,
|
||||
Instant? affectedAt = null,
|
||||
string? code = null,
|
||||
bool preventRepeat = false
|
||||
)
|
||||
{
|
||||
@@ -41,7 +42,7 @@ public class MagicSpellService(
|
||||
return existingSpell;
|
||||
}
|
||||
|
||||
var spellWord = _GenerateRandomString(128);
|
||||
var spellWord = code ?? _GenerateRandomString(128);
|
||||
var spell = new SnMagicSpell
|
||||
{
|
||||
Spell = spellWord,
|
||||
@@ -193,7 +194,7 @@ public class MagicSpellService(
|
||||
{
|
||||
db.PermissionGroupMembers.Add(new SnPermissionGroupMember
|
||||
{
|
||||
Actor = $"user:{account.Id}",
|
||||
Actor = account.Id.ToString(),
|
||||
Group = defaultGroup
|
||||
});
|
||||
}
|
||||
|
||||
134
DysonNetwork.Pass/Affiliation/AffiliationSpellController.cs
Normal file
134
DysonNetwork.Pass/Affiliation/AffiliationSpellController.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Pass.Affiliation;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/affiliations")]
|
||||
public class AffiliationSpellController(AppDatabase db, AffiliationSpellService ars) : ControllerBase
|
||||
{
|
||||
public class CreateAffiliationSpellRequest
|
||||
{
|
||||
[MaxLength(1024)] public string? Spell { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<SnAffiliationSpell>> CreateSpell([FromBody] CreateAffiliationSpellRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
var spell = await ars.CreateAffiliationSpell(currentUser.Id, request.Spell);
|
||||
return Ok(spell);
|
||||
}
|
||||
catch (InvalidOperationException e)
|
||||
{
|
||||
return BadRequest(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<SnAffiliationSpell>>> ListCreatedSpells(
|
||||
[FromQuery(Name = "order")] string orderBy = "date",
|
||||
[FromQuery(Name = "desc")] bool orderDesc = false,
|
||||
[FromQuery] int take = 10,
|
||||
[FromQuery] int offset = 0
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
var queryable = db.AffiliationSpells
|
||||
.Where(s => s.AccountId == currentUser.Id)
|
||||
.AsQueryable();
|
||||
|
||||
queryable = orderBy switch
|
||||
{
|
||||
"usage" => orderDesc
|
||||
? queryable.OrderByDescending(q => q.Results.Count)
|
||||
: queryable.OrderBy(q => q.Results.Count),
|
||||
_ => orderDesc
|
||||
? queryable.OrderByDescending(q => q.CreatedAt)
|
||||
: queryable.OrderBy(q => q.CreatedAt)
|
||||
};
|
||||
|
||||
var totalCount = queryable.Count();
|
||||
Response.Headers["X-Total"] = totalCount.ToString();
|
||||
|
||||
var spells = await queryable
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(spells);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<SnAffiliationSpell>> GetSpell([FromRoute] Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
var spell = await db.AffiliationSpells
|
||||
.Where(s => s.AccountId == currentUser.Id)
|
||||
.Where(s => s.Id == id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (spell is null) return NotFound();
|
||||
|
||||
return Ok(spell);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/results")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<SnAffiliationResult>>> ListResults(
|
||||
[FromRoute] Guid id,
|
||||
[FromQuery(Name = "desc")] bool orderDesc = false,
|
||||
[FromQuery] int take = 10,
|
||||
[FromQuery] int offset = 0
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
var queryable = db.AffiliationResults
|
||||
.Include(r => r.Spell)
|
||||
.Where(r => r.Spell.AccountId == currentUser.Id)
|
||||
.Where(r => r.SpellId == id)
|
||||
.AsQueryable();
|
||||
|
||||
// Order by creation date
|
||||
queryable = orderDesc
|
||||
? queryable.OrderByDescending(r => r.CreatedAt)
|
||||
: queryable.OrderBy(r => r.CreatedAt);
|
||||
|
||||
var totalCount = queryable.Count();
|
||||
Response.Headers["X-Total"] = totalCount.ToString();
|
||||
|
||||
var results = await queryable
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> DeleteSpell([FromRoute] Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
var spell = await db.AffiliationSpells
|
||||
.Where(s => s.AccountId == currentUser.Id)
|
||||
.Where(s => s.Id == id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (spell is null) return NotFound();
|
||||
|
||||
db.AffiliationSpells.Remove(spell);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
62
DysonNetwork.Pass/Affiliation/AffiliationSpellService.cs
Normal file
62
DysonNetwork.Pass/Affiliation/AffiliationSpellService.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using System.Security.Cryptography;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Pass.Affiliation;
|
||||
|
||||
public class AffiliationSpellService(AppDatabase db)
|
||||
{
|
||||
public async Task<SnAffiliationSpell> CreateAffiliationSpell(Guid accountId, string? spellWord)
|
||||
{
|
||||
spellWord ??= _GenerateRandomString(8);
|
||||
if (await CheckAffiliationSpellHasTaken(spellWord))
|
||||
throw new InvalidOperationException("The spell has been taken.");
|
||||
|
||||
var spell = new SnAffiliationSpell
|
||||
{
|
||||
AccountId = accountId,
|
||||
Spell = spellWord
|
||||
};
|
||||
|
||||
db.AffiliationSpells.Add(spell);
|
||||
await db.SaveChangesAsync();
|
||||
return spell;
|
||||
}
|
||||
|
||||
public async Task<SnAffiliationResult> CreateAffiliationResult(string spellWord, string resourceId)
|
||||
{
|
||||
var spell =
|
||||
await db.AffiliationSpells.FirstOrDefaultAsync(a => a.Spell == spellWord);
|
||||
if (spell is null) throw new InvalidOperationException("The spell was not found.");
|
||||
|
||||
var result = new SnAffiliationResult
|
||||
{
|
||||
Spell = spell,
|
||||
ResourceIdentifier = resourceId
|
||||
};
|
||||
db.AffiliationResults.Add(result);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<bool> CheckAffiliationSpellHasTaken(string spellWord)
|
||||
{
|
||||
return await db.AffiliationSpells.AnyAsync(s => s.Spell == spellWord);
|
||||
}
|
||||
|
||||
private static string _GenerateRandomString(int length)
|
||||
{
|
||||
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
var result = new char[length];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
for (var i = 0; i < length; i++)
|
||||
{
|
||||
var bytes = new byte[1];
|
||||
rng.GetBytes(bytes);
|
||||
result[i] = chars[bytes[0] % chars.Length];
|
||||
}
|
||||
|
||||
return new string(result);
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,9 @@ public class AppDatabase(
|
||||
public DbSet<SnLottery> Lotteries { get; set; } = null!;
|
||||
public DbSet<SnLotteryRecord> LotteryRecords { get; set; } = null!;
|
||||
|
||||
public DbSet<SnAffiliationSpell> AffiliationSpells { get; set; } = null!;
|
||||
public DbSet<SnAffiliationResult> AffiliationResults { get; set; } = null!;
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
optionsBuilder.UseNpgsql(
|
||||
@@ -100,7 +103,7 @@ public class AppDatabase(
|
||||
"stickers.packs.create",
|
||||
"stickers.create"
|
||||
}.Select(permission =>
|
||||
PermissionService.NewPermissionNode("group:default", "global", permission, true))
|
||||
PermissionService.NewPermissionNode("group:default", permission, true))
|
||||
.ToList()
|
||||
});
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
@@ -70,7 +70,7 @@ public class DysonTokenAuthHandler(
|
||||
};
|
||||
|
||||
// Add scopes as claims
|
||||
session.Challenge?.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope)));
|
||||
session.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope)));
|
||||
|
||||
// Add superuser claim if applicable
|
||||
if (session.Account.IsSuperuser)
|
||||
@@ -117,16 +117,17 @@ public class DysonTokenAuthHandler(
|
||||
{
|
||||
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var token = authHeader["Bearer ".Length..].Trim();
|
||||
var parts = token.Split('.');
|
||||
var tokenText = authHeader["Bearer ".Length..].Trim();
|
||||
var parts = tokenText.Split('.');
|
||||
|
||||
return new TokenInfo
|
||||
{
|
||||
Token = token,
|
||||
Token = tokenText,
|
||||
Type = parts.Length == 3 ? TokenType.OidcKey : TokenType.AuthKey
|
||||
};
|
||||
}
|
||||
else if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new TokenInfo
|
||||
{
|
||||
@@ -134,7 +135,8 @@ public class DysonTokenAuthHandler(
|
||||
Type = TokenType.AuthKey
|
||||
};
|
||||
}
|
||||
else if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new TokenInfo
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using NodaTime;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DysonNetwork.Pass.Localization;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using DysonNetwork.Shared.Geometry;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using AccountService = DysonNetwork.Pass.Account.AccountService;
|
||||
@@ -18,7 +18,7 @@ public class AuthController(
|
||||
AppDatabase db,
|
||||
AccountService accounts,
|
||||
AuthService auth,
|
||||
GeoIpService geo,
|
||||
GeoService geo,
|
||||
ActionLogService als,
|
||||
RingService.RingServiceClient pusher,
|
||||
IConfiguration configuration,
|
||||
@@ -34,8 +34,8 @@ public class AuthController(
|
||||
[Required] [MaxLength(256)] public string Account { get; set; } = null!;
|
||||
[Required] [MaxLength(512)] public string DeviceId { get; set; } = null!;
|
||||
[MaxLength(1024)] public string? DeviceName { get; set; }
|
||||
public List<string> Audiences { get; set; } = new();
|
||||
public List<string> Scopes { get; set; } = new();
|
||||
public List<string> Audiences { get; set; } = [];
|
||||
public List<string> Scopes { get; set; } = [];
|
||||
}
|
||||
|
||||
[HttpPost("challenge")]
|
||||
@@ -68,15 +68,9 @@ public class AuthController(
|
||||
.Where(e => e.UserAgent == userAgent)
|
||||
.Where(e => e.StepRemain > 0)
|
||||
.Where(e => e.ExpiredAt != null && now < e.ExpiredAt)
|
||||
.Where(e => e.Type == Shared.Models.ChallengeType.Login)
|
||||
.Where(e => e.DeviceId == request.DeviceId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (existingChallenge is not null)
|
||||
{
|
||||
var existingSession = await db.AuthSessions.Where(e => e.ChallengeId == existingChallenge.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (existingSession is null) return existingChallenge;
|
||||
}
|
||||
if (existingChallenge is not null) return existingChallenge;
|
||||
|
||||
var challenge = new SnAuthChallenge
|
||||
{
|
||||
@@ -111,14 +105,11 @@ public class AuthController(
|
||||
.ThenInclude(e => e.Profile)
|
||||
.FirstOrDefaultAsync(e => e.Id == id);
|
||||
|
||||
if (challenge is null)
|
||||
{
|
||||
logger.LogWarning("GetChallenge: challenge not found (challengeId={ChallengeId}, ip={IpAddress})",
|
||||
id, HttpContext.Connection.RemoteIpAddress?.ToString());
|
||||
return NotFound("Auth challenge was not found.");
|
||||
}
|
||||
if (challenge is not null) return challenge;
|
||||
logger.LogWarning("GetChallenge: challenge not found (challengeId={ChallengeId}, ip={IpAddress})",
|
||||
id, HttpContext.Connection.RemoteIpAddress?.ToString());
|
||||
return NotFound("Auth challenge was not found.");
|
||||
|
||||
return challenge;
|
||||
}
|
||||
|
||||
[HttpGet("challenge/{id:guid}/factors")]
|
||||
@@ -216,7 +207,7 @@ public class AuthController(
|
||||
throw new ArgumentException("Invalid password.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception)
|
||||
{
|
||||
challenge.FailedAttempts++;
|
||||
db.Update(challenge);
|
||||
@@ -229,8 +220,11 @@ public class AuthController(
|
||||
);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
logger.LogWarning("DoChallenge: authentication failure (challengeId={ChallengeId}, factorId={FactorId}, accountId={AccountId}, failedAttempts={FailedAttempts}, factorType={FactorType}, ip={IpAddress}, uaLength={UaLength})",
|
||||
challenge.Id, factor.Id, challenge.AccountId, challenge.FailedAttempts, factor.Type, HttpContext.Connection.RemoteIpAddress?.ToString(), (HttpContext.Request.Headers.UserAgent.ToString() ?? "").Length);
|
||||
logger.LogWarning(
|
||||
"DoChallenge: authentication failure (challengeId={ChallengeId}, factorId={FactorId}, accountId={AccountId}, failedAttempts={FailedAttempts}, factorType={FactorType}, ip={IpAddress}, uaLength={UaLength})",
|
||||
challenge.Id, factor.Id, challenge.AccountId, challenge.FailedAttempts, factor.Type,
|
||||
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
HttpContext.Request.Headers.UserAgent.ToString().Length);
|
||||
|
||||
return BadRequest("Invalid password.");
|
||||
}
|
||||
@@ -240,7 +234,7 @@ public class AuthController(
|
||||
AccountService.SetCultureInfo(challenge.Account);
|
||||
await pusher.SendPushNotificationToUserAsync(new SendPushNotificationToUserRequest
|
||||
{
|
||||
Notification = new PushNotification()
|
||||
Notification = new PushNotification
|
||||
{
|
||||
Topic = "auth.login",
|
||||
Title = localizer["NewLoginTitle"],
|
||||
@@ -279,7 +273,7 @@ public class AuthController(
|
||||
{
|
||||
[Required] [MaxLength(512)] public string DeviceId { get; set; } = null!;
|
||||
[MaxLength(1024)] public string? DeviceName { get; set; }
|
||||
[Required] public DysonNetwork.Shared.Models.ClientPlatform Platform { get; set; }
|
||||
[Required] public Shared.Models.ClientPlatform Platform { get; set; }
|
||||
public Instant? ExpiredAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -338,8 +332,9 @@ public class AuthController(
|
||||
[Microsoft.AspNetCore.Authorization.Authorize] // Use full namespace to avoid ambiguity with DysonNetwork.Pass.Permission.Authorize
|
||||
public async Task<ActionResult<TokenExchangeResponse>> LoginFromSession([FromBody] NewSessionRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
|
||||
HttpContext.Items["CurrentSession"] is not Shared.Models.SnAuthSession currentSession) return Unauthorized();
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount ||
|
||||
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession)
|
||||
return Unauthorized();
|
||||
|
||||
var newSession = await auth.CreateSessionFromParentAsync(
|
||||
currentSession,
|
||||
@@ -352,16 +347,15 @@ public class AuthController(
|
||||
var tk = auth.CreateToken(newSession);
|
||||
|
||||
// Set cookie using HttpContext, similar to CreateSessionAndIssueToken
|
||||
var cookieDomain = _cookieDomain;
|
||||
HttpContext.Response.Cookies.Append(AuthConstants.CookieTokenName, tk, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = true,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Domain = cookieDomain,
|
||||
Domain = _cookieDomain,
|
||||
Expires = request.ExpiredAt?.ToDateTimeOffset() ?? DateTime.UtcNow.AddYears(20)
|
||||
});
|
||||
|
||||
return Ok(new TokenExchangeResponse { Token = tk });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Geometry;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
@@ -14,7 +15,8 @@ public class AuthService(
|
||||
IConfiguration config,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ICacheService cache
|
||||
ICacheService cache,
|
||||
GeoService geo
|
||||
)
|
||||
{
|
||||
private HttpContext HttpContext => httpContextAccessor.HttpContext!;
|
||||
@@ -31,7 +33,7 @@ public class AuthService(
|
||||
{
|
||||
// 1) Find out how many authentication factors the account has enabled.
|
||||
var enabledFactors = await db.AccountAuthFactors
|
||||
.Where(f => f.AccountId == account.Id)
|
||||
.Where(f => f.AccountId == account.Id && f.Type != AccountAuthFactorType.PinCode)
|
||||
.Where(f => f.EnabledAt != null)
|
||||
.ToListAsync();
|
||||
var maxSteps = enabledFactors.Count;
|
||||
@@ -42,13 +44,18 @@ public class AuthService(
|
||||
|
||||
// 2) Get login context from recent sessions
|
||||
var recentSessions = await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.Where(s => s.AccountId == account.Id)
|
||||
.Where(s => s.LastGrantedAt != null)
|
||||
.OrderByDescending(s => s.LastGrantedAt)
|
||||
.Take(10)
|
||||
.ToListAsync();
|
||||
|
||||
var recentChallengeIds =
|
||||
recentSessions
|
||||
.Where(s => s.ChallengeId != null)
|
||||
.Select(s => s.ChallengeId!.Value).ToList();
|
||||
var recentChallenges = await db.AuthChallenges.Where(c => recentChallengeIds.Contains(c.Id)).ToListAsync();
|
||||
|
||||
var ipAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var userAgent = request.Headers.UserAgent.ToString();
|
||||
|
||||
@@ -60,14 +67,14 @@ public class AuthService(
|
||||
else
|
||||
{
|
||||
// Check if IP has been used before
|
||||
var ipPreviouslyUsed = recentSessions.Any(s => s.Challenge?.IpAddress == ipAddress);
|
||||
var ipPreviouslyUsed = recentChallenges.Any(c => c.IpAddress == ipAddress);
|
||||
if (!ipPreviouslyUsed)
|
||||
{
|
||||
riskScore += 8;
|
||||
}
|
||||
|
||||
// Check geographical distance for last known location
|
||||
var lastKnownIp = recentSessions.FirstOrDefault(s => !string.IsNullOrWhiteSpace(s.Challenge?.IpAddress))?.Challenge?.IpAddress;
|
||||
var lastKnownIp = recentChallenges.FirstOrDefault(c => !string.IsNullOrWhiteSpace(c.IpAddress))?.IpAddress;
|
||||
if (!string.IsNullOrWhiteSpace(lastKnownIp) && lastKnownIp != ipAddress)
|
||||
{
|
||||
riskScore += 6;
|
||||
@@ -81,9 +88,9 @@ public class AuthService(
|
||||
}
|
||||
else
|
||||
{
|
||||
var uaPreviouslyUsed = recentSessions.Any(s =>
|
||||
!string.IsNullOrWhiteSpace(s.Challenge?.UserAgent) &&
|
||||
string.Equals(s.Challenge.UserAgent, userAgent, StringComparison.OrdinalIgnoreCase));
|
||||
var uaPreviouslyUsed = recentChallenges.Any(c =>
|
||||
!string.IsNullOrWhiteSpace(c.UserAgent) &&
|
||||
string.Equals(c.UserAgent, userAgent, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!uaPreviouslyUsed)
|
||||
{
|
||||
@@ -181,33 +188,28 @@ public class AuthService(
|
||||
return totalRequiredSteps;
|
||||
}
|
||||
|
||||
public async Task<SnAuthSession> CreateSessionForOidcAsync(SnAccount account, Instant time,
|
||||
Guid? customAppId = null, SnAuthSession? parentSession = null)
|
||||
public async Task<SnAuthSession> CreateSessionForOidcAsync(
|
||||
SnAccount account,
|
||||
Instant time,
|
||||
Guid? customAppId = null,
|
||||
SnAuthSession? parentSession = null
|
||||
)
|
||||
{
|
||||
var challenge = new SnAuthChallenge
|
||||
{
|
||||
AccountId = account.Id,
|
||||
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
UserAgent = HttpContext.Request.Headers.UserAgent,
|
||||
StepRemain = 1,
|
||||
StepTotal = 1,
|
||||
Type = customAppId is not null ? ChallengeType.OAuth : ChallengeType.Oidc,
|
||||
DeviceId = Guid.NewGuid().ToString(),
|
||||
DeviceName = "OIDC/OAuth",
|
||||
Platform = ClientPlatform.Web,
|
||||
};
|
||||
|
||||
var ipAddr = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var geoLocation = ipAddr is not null ? geo.GetPointFromIp(ipAddr) : null;
|
||||
var session = new SnAuthSession
|
||||
{
|
||||
AccountId = account.Id,
|
||||
CreatedAt = time,
|
||||
LastGrantedAt = time,
|
||||
Challenge = challenge,
|
||||
IpAddress = ipAddr,
|
||||
UserAgent = HttpContext.Request.Headers.UserAgent,
|
||||
Location = geoLocation,
|
||||
AppId = customAppId,
|
||||
ParentSessionId = parentSession?.Id
|
||||
ParentSessionId = parentSession?.Id,
|
||||
Type = customAppId is not null ? SessionType.OAuth : SessionType.Oidc,
|
||||
};
|
||||
|
||||
db.AuthChallenges.Add(challenge);
|
||||
db.AuthSessions.Add(session);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
@@ -221,7 +223,8 @@ public class AuthService(
|
||||
ClientPlatform platform = ClientPlatform.Unidentified
|
||||
)
|
||||
{
|
||||
var device = await db.AuthClients.FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.AccountId == accountId);
|
||||
var device = await db.AuthClients
|
||||
.FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.AccountId == accountId);
|
||||
if (device is not null) return device;
|
||||
device = new SnAuthClient
|
||||
{
|
||||
@@ -342,12 +345,8 @@ public class AuthService(
|
||||
/// <param name="sessionsToRevoke">A HashSet to store the IDs of all sessions to be revoked.</param>
|
||||
private async Task CollectSessionsToRevoke(Guid currentSessionId, HashSet<Guid> sessionsToRevoke)
|
||||
{
|
||||
if (sessionsToRevoke.Contains(currentSessionId))
|
||||
{
|
||||
if (!sessionsToRevoke.Add(currentSessionId))
|
||||
return; // Already processed this session
|
||||
}
|
||||
|
||||
sessionsToRevoke.Add(currentSessionId);
|
||||
|
||||
// Find direct children
|
||||
var childSessions = await db.AuthSessions
|
||||
@@ -419,20 +418,24 @@ public class AuthService(
|
||||
if (challenge.StepRemain != 0)
|
||||
throw new ArgumentException("Challenge not yet completed.");
|
||||
|
||||
var hasSession = await db.AuthSessions
|
||||
.AnyAsync(e => e.ChallengeId == challenge.Id);
|
||||
if (hasSession)
|
||||
throw new ArgumentException("Session already exists for this challenge.");
|
||||
var device = await GetOrCreateDeviceAsync(
|
||||
challenge.AccountId,
|
||||
challenge.DeviceId,
|
||||
challenge.DeviceName,
|
||||
challenge.Platform
|
||||
);
|
||||
|
||||
var device = await GetOrCreateDeviceAsync(challenge.AccountId, challenge.DeviceId, challenge.DeviceName,
|
||||
challenge.Platform);
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var session = new SnAuthSession
|
||||
{
|
||||
LastGrantedAt = now,
|
||||
ExpiredAt = now.Plus(Duration.FromDays(7)),
|
||||
AccountId = challenge.AccountId,
|
||||
IpAddress = challenge.IpAddress,
|
||||
UserAgent = challenge.UserAgent,
|
||||
Location = challenge.Location,
|
||||
Scopes = challenge.Scopes,
|
||||
Audiences = challenge.Audiences,
|
||||
ChallengeId = challenge.Id,
|
||||
ClientId = device.Id,
|
||||
};
|
||||
@@ -457,7 +460,7 @@ public class AuthService(
|
||||
return tk;
|
||||
}
|
||||
|
||||
private string CreateCompactToken(Guid sessionId, RSA rsa)
|
||||
private static string CreateCompactToken(Guid sessionId, RSA rsa)
|
||||
{
|
||||
// Create the payload: just the session ID
|
||||
var payloadBytes = sessionId.ToByteArray();
|
||||
@@ -548,7 +551,8 @@ public class AuthService(
|
||||
return key;
|
||||
}
|
||||
|
||||
public async Task<SnApiKey> CreateApiKey(Guid accountId, string label, Instant? expiredAt = null, SnAuthSession? parentSession = null)
|
||||
public async Task<SnApiKey> CreateApiKey(Guid accountId, string label, Instant? expiredAt = null,
|
||||
SnAuthSession? parentSession = null)
|
||||
{
|
||||
var key = new SnApiKey
|
||||
{
|
||||
@@ -684,9 +688,16 @@ public class AuthService(
|
||||
{
|
||||
var device = await GetOrCreateDeviceAsync(parentSession.AccountId, deviceId, deviceName, platform);
|
||||
|
||||
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
|
||||
var geoLocation = ipAddress is not null ? geo.GetPointFromIp(ipAddress) : null;
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var session = new SnAuthSession
|
||||
{
|
||||
IpAddress = ipAddress,
|
||||
UserAgent = userAgent,
|
||||
Location = geoLocation,
|
||||
AccountId = parentSession.AccountId,
|
||||
CreatedAt = now,
|
||||
LastGrantedAt = now,
|
||||
@@ -700,4 +711,4 @@ public class AuthService(
|
||||
|
||||
return session;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -306,7 +306,7 @@ public class OidcProviderController(
|
||||
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
|
||||
|
||||
// Get requested scopes from the token
|
||||
var scopes = currentSession.Challenge?.Scopes ?? [];
|
||||
var scopes = currentSession.Scopes;
|
||||
|
||||
var userInfo = new Dictionary<string, object>
|
||||
{
|
||||
|
||||
@@ -72,7 +72,6 @@ public class OidcProviderService(
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
var queryable = db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.AsQueryable();
|
||||
if (withAccount)
|
||||
queryable = queryable
|
||||
@@ -85,8 +84,7 @@ public class OidcProviderService(
|
||||
.Where(s => s.AccountId == accountId &&
|
||||
s.AppId == clientId &&
|
||||
(s.ExpiredAt == null || s.ExpiredAt > now) &&
|
||||
s.Challenge != null &&
|
||||
s.Challenge.Type == Shared.Models.ChallengeType.OAuth)
|
||||
s.Type == Shared.Models.SessionType.OAuth)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
@@ -511,7 +509,6 @@ public class OidcProviderService(
|
||||
{
|
||||
return await db.AuthSessions
|
||||
.Include(s => s.Account)
|
||||
.Include(s => s.Challenge)
|
||||
.FirstOrDefaultAsync(s => s.Id == sessionId);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,11 +23,6 @@ public class OidcController(
|
||||
private const string StateCachePrefix = "oidc-state:";
|
||||
private static readonly TimeSpan StateExpiration = TimeSpan.FromMinutes(15);
|
||||
|
||||
public class TokenExchangeResponse
|
||||
{
|
||||
public string Token { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[HttpGet("{provider}")]
|
||||
public async Task<ActionResult> OidcLogin(
|
||||
[FromRoute] string provider,
|
||||
@@ -81,7 +76,7 @@ public class OidcController(
|
||||
/// Handles Apple authentication directly from mobile apps
|
||||
/// </summary>
|
||||
[HttpPost("apple/mobile")]
|
||||
public async Task<ActionResult<TokenExchangeResponse>> AppleMobileLogin(
|
||||
public async Task<ActionResult<AuthController.TokenExchangeResponse>> AppleMobileLogin(
|
||||
[FromBody] AppleMobileSignInRequest request
|
||||
)
|
||||
{
|
||||
@@ -118,7 +113,7 @@ public class OidcController(
|
||||
);
|
||||
|
||||
var token = auth.CreateToken(session);
|
||||
return Ok(new TokenExchangeResponse { Token = token });
|
||||
return Ok(new AuthController.TokenExchangeResponse { Token = token });
|
||||
}
|
||||
catch (SecurityTokenValidationException ex)
|
||||
{
|
||||
|
||||
@@ -77,7 +77,7 @@ public class TokenAuthService(
|
||||
"AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})",
|
||||
sessionId,
|
||||
session.AccountId,
|
||||
session.Challenge?.Scopes.Count,
|
||||
session.Scopes.Count,
|
||||
session.ExpiredAt
|
||||
);
|
||||
return (true, session, null);
|
||||
@@ -87,7 +87,6 @@ public class TokenAuthService(
|
||||
|
||||
session = await db.AuthSessions
|
||||
.AsNoTracking()
|
||||
.Include(e => e.Challenge)
|
||||
.Include(e => e.Client)
|
||||
.Include(e => e.Account)
|
||||
.ThenInclude(e => e.Profile)
|
||||
@@ -112,9 +111,9 @@ public class TokenAuthService(
|
||||
session.AccountId,
|
||||
session.ClientId,
|
||||
session.AppId,
|
||||
session.Challenge?.Scopes.Count,
|
||||
session.Challenge?.IpAddress,
|
||||
(session.Challenge?.UserAgent ?? string.Empty).Length
|
||||
session.Scopes.Count,
|
||||
session.IpAddress,
|
||||
(session.UserAgent ?? string.Empty).Length
|
||||
);
|
||||
|
||||
logger.LogDebug("AuthenticateTokenAsync: enriching account with subscription (accountId={AccountId})", session.AccountId);
|
||||
|
||||
@@ -6,16 +6,17 @@ using Quartz;
|
||||
|
||||
namespace DysonNetwork.Pass.Handlers;
|
||||
|
||||
public class ActionLogFlushHandler(IServiceProvider serviceProvider) : IFlushHandler<SnActionLog>
|
||||
public class ActionLogFlushHandler(IServiceProvider sp) : IFlushHandler<SnActionLog>
|
||||
{
|
||||
public async Task FlushAsync(IReadOnlyList<SnActionLog> items)
|
||||
{
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
using var scope = sp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
await db.BulkInsertAsync(items.Select(x =>
|
||||
{
|
||||
x.CreatedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
x.CreatedAt = now;
|
||||
x.UpdatedAt = x.CreatedAt;
|
||||
return x;
|
||||
}), config => config.ConflictOption = ConflictOption.Ignore);
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Pass.Wallet;
|
||||
using DysonNetwork.Pass.Permission;
|
||||
using DysonNetwork.Shared.Auth;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -81,7 +82,7 @@ public class LotteryController(AppDatabase db, LotteryService lotteryService) :
|
||||
|
||||
[HttpPost("draw")]
|
||||
[Authorize]
|
||||
[RequiredPermission("maintenance", "lotteries.draw.perform")]
|
||||
[AskPermission("lotteries.draw.perform")]
|
||||
public async Task<IActionResult> PerformLotteryDraw()
|
||||
{
|
||||
await lotteryService.DrawLotteries();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using DysonNetwork.Shared.Geometry;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NetTopologySuite.Geometries;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Pass;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using DysonNetwork.Shared.Geometry;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NodaTime;
|
||||
|
||||
@@ -3,7 +3,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Pass;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using DysonNetwork.Shared.Geometry;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -3,7 +3,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Pass;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using DysonNetwork.Shared.Geometry;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -3,7 +3,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Pass;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using DysonNetwork.Shared.Geometry;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -3,7 +3,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Pass;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using DysonNetwork.Shared.Geometry;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -3,7 +3,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Pass;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using DysonNetwork.Shared.Geometry;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -3,7 +3,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Pass;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using DysonNetwork.Shared.Geometry;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -3,7 +3,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Pass;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using DysonNetwork.Shared.Geometry;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -3,7 +3,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Pass;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using DysonNetwork.Shared.Geometry;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -3,7 +3,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Pass;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using DysonNetwork.Shared.Geometry;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -3,7 +3,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Pass;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using DysonNetwork.Shared.Geometry;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -3,7 +3,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Pass;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using DysonNetwork.Shared.Geometry;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -3,7 +3,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Pass;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using DysonNetwork.Shared.Geometry;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -3,7 +3,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Pass;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using DysonNetwork.Shared.Geometry;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
2874
DysonNetwork.Pass/Migrations/20251201145617_AddAffiliationSpell.Designer.cs
generated
Normal file
2874
DysonNetwork.Pass/Migrations/20251201145617_AddAffiliationSpell.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NodaTime;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Pass.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAffiliationSpell : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "affiliation_spells",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
spell = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
type = table.Column<int>(type: "integer", nullable: false),
|
||||
expires_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||
affected_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||
meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: false),
|
||||
account_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_affiliation_spells", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_affiliation_spells_accounts_account_id",
|
||||
column: x => x.account_id,
|
||||
principalTable: "accounts",
|
||||
principalColumn: "id");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "affiliation_results",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
resource_identifier = table.Column<string>(type: "character varying(8192)", maxLength: 8192, nullable: false),
|
||||
spell_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_affiliation_results", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_affiliation_results_affiliation_spells_spell_id",
|
||||
column: x => x.spell_id,
|
||||
principalTable: "affiliation_spells",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_affiliation_results_spell_id",
|
||||
table: "affiliation_results",
|
||||
column: "spell_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_affiliation_spells_account_id",
|
||||
table: "affiliation_spells",
|
||||
column: "account_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_affiliation_spells_spell",
|
||||
table: "affiliation_spells",
|
||||
column: "spell",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "affiliation_results");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "affiliation_spells");
|
||||
}
|
||||
}
|
||||
}
|
||||
2872
DysonNetwork.Pass/Migrations/20251202134035_SimplifiedPermissionNode.Designer.cs
generated
Normal file
2872
DysonNetwork.Pass/Migrations/20251202134035_SimplifiedPermissionNode.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Pass.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SimplifiedPermissionNode : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_permission_nodes_key_area_actor",
|
||||
table: "permission_nodes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "area",
|
||||
table: "permission_nodes");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "type",
|
||||
table: "permission_nodes",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_permission_nodes_key_actor",
|
||||
table: "permission_nodes",
|
||||
columns: new[] { "key", "actor" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_permission_nodes_key_actor",
|
||||
table: "permission_nodes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "type",
|
||||
table: "permission_nodes");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "area",
|
||||
table: "permission_nodes",
|
||||
type: "character varying(1024)",
|
||||
maxLength: 1024,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_permission_nodes_key_area_actor",
|
||||
table: "permission_nodes",
|
||||
columns: new[] { "key", "area", "actor" });
|
||||
}
|
||||
}
|
||||
}
|
||||
2882
DysonNetwork.Pass/Migrations/20251202160759_SimplifiedAuthSession.Designer.cs
generated
Normal file
2882
DysonNetwork.Pass/Migrations/20251202160759_SimplifiedAuthSession.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Pass.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SimplifiedAuthSession : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_auth_sessions_auth_challenges_challenge_id",
|
||||
table: "auth_sessions");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_auth_sessions_challenge_id",
|
||||
table: "auth_sessions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "type",
|
||||
table: "auth_challenges");
|
||||
|
||||
migrationBuilder.AddColumn<List<string>>(
|
||||
name: "audiences",
|
||||
table: "auth_sessions",
|
||||
type: "jsonb",
|
||||
nullable: false,
|
||||
defaultValue: new List<string>());
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ip_address",
|
||||
table: "auth_sessions",
|
||||
type: "character varying(128)",
|
||||
maxLength: 128,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<List<string>>(
|
||||
name: "scopes",
|
||||
table: "auth_sessions",
|
||||
type: "jsonb",
|
||||
nullable: false,
|
||||
defaultValue: new List<string>());
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "type",
|
||||
table: "auth_sessions",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "user_agent",
|
||||
table: "auth_sessions",
|
||||
type: "character varying(512)",
|
||||
maxLength: 512,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "audiences",
|
||||
table: "auth_sessions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ip_address",
|
||||
table: "auth_sessions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "scopes",
|
||||
table: "auth_sessions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "type",
|
||||
table: "auth_sessions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "user_agent",
|
||||
table: "auth_sessions");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "type",
|
||||
table: "auth_challenges",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_auth_sessions_challenge_id",
|
||||
table: "auth_sessions",
|
||||
column: "challenge_id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_auth_sessions_auth_challenges_challenge_id",
|
||||
table: "auth_sessions",
|
||||
column: "challenge_id",
|
||||
principalTable: "auth_challenges",
|
||||
principalColumn: "id");
|
||||
}
|
||||
}
|
||||
}
|
||||
2886
DysonNetwork.Pass/Migrations/20251203163459_AddLocationToSession.Designer.cs
generated
Normal file
2886
DysonNetwork.Pass/Migrations/20251203163459_AddLocationToSession.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using DysonNetwork.Shared.Geometry;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Pass.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLocationToSession : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<GeoPoint>(
|
||||
name: "location",
|
||||
table: "auth_sessions",
|
||||
type: "jsonb",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "location",
|
||||
table: "auth_sessions");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Pass;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using DysonNetwork.Shared.Geometry;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
@@ -712,6 +712,103 @@ namespace DysonNetwork.Pass.Migrations
|
||||
b.ToTable("action_logs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAffiliationResult", 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<string>("ResourceIdentifier")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("character varying(8192)")
|
||||
.HasColumnName("resource_identifier");
|
||||
|
||||
b.Property<Guid>("SpellId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("spell_id");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_affiliation_results");
|
||||
|
||||
b.HasIndex("SpellId")
|
||||
.HasDatabaseName("ix_affiliation_results_spell_id");
|
||||
|
||||
b.ToTable("affiliation_results", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAffiliationSpell", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid?>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Instant?>("AffectedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("affected_at");
|
||||
|
||||
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?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<Dictionary<string, object>>("Meta")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("meta");
|
||||
|
||||
b.Property<string>("Spell")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("spell");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_affiliation_spells");
|
||||
|
||||
b.HasIndex("AccountId")
|
||||
.HasDatabaseName("ix_affiliation_spells_account_id");
|
||||
|
||||
b.HasIndex("Spell")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_affiliation_spells_spell");
|
||||
|
||||
b.ToTable("affiliation_spells", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnApiKey", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -836,10 +933,6 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("step_total");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
@@ -926,6 +1019,11 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("app_id");
|
||||
|
||||
b.Property<List<string>>("Audiences")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("audiences");
|
||||
|
||||
b.Property<Guid?>("ChallengeId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("challenge_id");
|
||||
@@ -946,27 +1044,47 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("IpAddress")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("ip_address");
|
||||
|
||||
b.Property<Instant?>("LastGrantedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_granted_at");
|
||||
|
||||
b.Property<GeoPoint>("Location")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("location");
|
||||
|
||||
b.Property<Guid?>("ParentSessionId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("parent_session_id");
|
||||
|
||||
b.Property<List<string>>("Scopes")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("scopes");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("UserAgent")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasColumnName("user_agent");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_auth_sessions");
|
||||
|
||||
b.HasIndex("AccountId")
|
||||
.HasDatabaseName("ix_auth_sessions_account_id");
|
||||
|
||||
b.HasIndex("ChallengeId")
|
||||
.HasDatabaseName("ix_auth_sessions_challenge_id");
|
||||
|
||||
b.HasIndex("ClientId")
|
||||
.HasDatabaseName("ix_auth_sessions_client_id");
|
||||
|
||||
@@ -1336,12 +1454,6 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("affected_at");
|
||||
|
||||
b.Property<string>("Area")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("area");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
@@ -1364,6 +1476,10 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("key");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
@@ -1379,8 +1495,8 @@ namespace DysonNetwork.Pass.Migrations
|
||||
b.HasIndex("GroupId")
|
||||
.HasDatabaseName("ix_permission_nodes_group_id");
|
||||
|
||||
b.HasIndex("Key", "Area", "Actor")
|
||||
.HasDatabaseName("ix_permission_nodes_key_area_actor");
|
||||
b.HasIndex("Key", "Actor")
|
||||
.HasDatabaseName("ix_permission_nodes_key_actor");
|
||||
|
||||
b.ToTable("permission_nodes", (string)null);
|
||||
});
|
||||
@@ -2366,6 +2482,28 @@ namespace DysonNetwork.Pass.Migrations
|
||||
b.Navigation("Account");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAffiliationResult", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnAffiliationSpell", "Spell")
|
||||
.WithMany()
|
||||
.HasForeignKey("SpellId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_affiliation_results_affiliation_spells_spell_id");
|
||||
|
||||
b.Navigation("Spell");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAffiliationSpell", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
|
||||
.WithMany()
|
||||
.HasForeignKey("AccountId")
|
||||
.HasConstraintName("fk_affiliation_spells_accounts_account_id");
|
||||
|
||||
b.Navigation("Account");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnApiKey", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
|
||||
@@ -2420,11 +2558,6 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_auth_sessions_accounts_account_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnAuthChallenge", "Challenge")
|
||||
.WithMany()
|
||||
.HasForeignKey("ChallengeId")
|
||||
.HasConstraintName("fk_auth_sessions_auth_challenges_challenge_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnAuthClient", "Client")
|
||||
.WithMany()
|
||||
.HasForeignKey("ClientId")
|
||||
@@ -2437,8 +2570,6 @@ namespace DysonNetwork.Pass.Migrations
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("Challenge");
|
||||
|
||||
b.Navigation("Client");
|
||||
|
||||
b.Navigation("ParentSession");
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
using DysonNetwork.Shared.Auth;
|
||||
|
||||
namespace DysonNetwork.Pass.Permission;
|
||||
|
||||
using System;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Shared.Models;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public class RequiredPermissionAttribute(string area, string key) : Attribute
|
||||
{
|
||||
public string Area { get; set; } = area;
|
||||
public string Key { get; } = key;
|
||||
}
|
||||
|
||||
public class PermissionMiddleware(RequestDelegate next, ILogger<PermissionMiddleware> logger)
|
||||
public class LocalPermissionMiddleware(RequestDelegate next, ILogger<LocalPermissionMiddleware> logger)
|
||||
{
|
||||
private const string ForbiddenMessage = "Insufficient permissions";
|
||||
private const string UnauthorizedMessage = "Authentication required";
|
||||
@@ -21,15 +16,15 @@ public class PermissionMiddleware(RequestDelegate next, ILogger<PermissionMiddle
|
||||
var endpoint = httpContext.GetEndpoint();
|
||||
|
||||
var attr = endpoint?.Metadata
|
||||
.OfType<RequiredPermissionAttribute>()
|
||||
.OfType<AskPermissionAttribute>()
|
||||
.FirstOrDefault();
|
||||
|
||||
if (attr != null)
|
||||
{
|
||||
// Validate permission attributes
|
||||
if (string.IsNullOrWhiteSpace(attr.Area) || string.IsNullOrWhiteSpace(attr.Key))
|
||||
if (string.IsNullOrWhiteSpace(attr.Key))
|
||||
{
|
||||
logger.LogWarning("Invalid permission attribute: Area='{Area}', Key='{Key}'", attr.Area, attr.Key);
|
||||
logger.LogWarning("Invalid permission attribute: Key='{Key}'", attr.Key);
|
||||
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
await httpContext.Response.WriteAsync("Server configuration error");
|
||||
return;
|
||||
@@ -37,7 +32,7 @@ public class PermissionMiddleware(RequestDelegate next, ILogger<PermissionMiddle
|
||||
|
||||
if (httpContext.Items["CurrentUser"] is not SnAccount currentUser)
|
||||
{
|
||||
logger.LogWarning("Permission check failed: No authenticated user for {Area}/{Key}", attr.Area, attr.Key);
|
||||
logger.LogWarning("Permission check failed: No authenticated user for {Key}", attr.Key);
|
||||
httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
await httpContext.Response.WriteAsync(UnauthorizedMessage);
|
||||
return;
|
||||
@@ -46,33 +41,29 @@ public class PermissionMiddleware(RequestDelegate next, ILogger<PermissionMiddle
|
||||
if (currentUser.IsSuperuser)
|
||||
{
|
||||
// Bypass the permission check for performance
|
||||
logger.LogDebug("Superuser {UserId} bypassing permission check for {Area}/{Key}",
|
||||
currentUser.Id, attr.Area, attr.Key);
|
||||
logger.LogDebug("Superuser {UserId} bypassing permission check for {Key}", currentUser.Id, attr.Key);
|
||||
await next(httpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
var actor = $"user:{currentUser.Id}";
|
||||
var actor = currentUser.Id.ToString();
|
||||
try
|
||||
{
|
||||
var permNode = await pm.GetPermissionAsync<bool>(actor, attr.Area, attr.Key);
|
||||
var permNode = await pm.GetPermissionAsync<bool>(actor, attr.Key);
|
||||
|
||||
if (!permNode)
|
||||
{
|
||||
logger.LogWarning("Permission denied for user {UserId}: {Area}/{Key}",
|
||||
currentUser.Id, attr.Area, attr.Key);
|
||||
logger.LogWarning("Permission denied for user {UserId}: {Key}", currentUser.Id, attr.Key);
|
||||
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
await httpContext.Response.WriteAsync(ForbiddenMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogDebug("Permission granted for user {UserId}: {Area}/{Key}",
|
||||
currentUser.Id, attr.Area, attr.Key);
|
||||
logger.LogDebug("Permission granted for user {UserId}: {Key}", currentUser.Id, attr.Key);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error checking permission for user {UserId}: {Area}/{Key}",
|
||||
currentUser.Id, attr.Area, attr.Key);
|
||||
logger.LogError(ex, "Error checking permission for user {UserId}: {Key}", currentUser.Id, attr.Key);
|
||||
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
await httpContext.Response.WriteAsync("Permission check failed");
|
||||
return;
|
||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Options;
|
||||
using NodaTime;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Models;
|
||||
|
||||
namespace DysonNetwork.Pass.Permission;
|
||||
@@ -28,8 +29,8 @@ public class PermissionService(
|
||||
private const string PermissionGroupCacheKeyPrefix = "perm-cg:";
|
||||
private const string PermissionGroupPrefix = "perm-g:";
|
||||
|
||||
private static string GetPermissionCacheKey(string actor, string area, string key) =>
|
||||
PermissionCacheKeyPrefix + actor + ":" + area + ":" + key;
|
||||
private static string GetPermissionCacheKey(string actor, string key) =>
|
||||
PermissionCacheKeyPrefix + actor + ":" + key;
|
||||
|
||||
private static string GetGroupsCacheKey(string actor) =>
|
||||
PermissionGroupCacheKeyPrefix + actor;
|
||||
@@ -37,50 +38,56 @@ public class PermissionService(
|
||||
private static string GetPermissionGroupKey(string actor) =>
|
||||
PermissionGroupPrefix + actor;
|
||||
|
||||
public async Task<bool> HasPermissionAsync(string actor, string area, string key)
|
||||
public async Task<bool> HasPermissionAsync(
|
||||
string actor,
|
||||
string key,
|
||||
PermissionNodeActorType type = PermissionNodeActorType.Account
|
||||
)
|
||||
{
|
||||
var value = await GetPermissionAsync<bool>(actor, area, key);
|
||||
var value = await GetPermissionAsync<bool>(actor, key, type);
|
||||
return value;
|
||||
}
|
||||
|
||||
public async Task<T?> GetPermissionAsync<T>(string actor, string area, string key)
|
||||
public async Task<T?> GetPermissionAsync<T>(
|
||||
string actor,
|
||||
string key,
|
||||
PermissionNodeActorType type = PermissionNodeActorType.Account
|
||||
)
|
||||
{
|
||||
// Input validation
|
||||
if (string.IsNullOrWhiteSpace(actor))
|
||||
throw new ArgumentException("Actor cannot be null or empty", nameof(actor));
|
||||
if (string.IsNullOrWhiteSpace(area))
|
||||
throw new ArgumentException("Area cannot be null or empty", nameof(area));
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
throw new ArgumentException("Key cannot be null or empty", nameof(key));
|
||||
|
||||
var cacheKey = GetPermissionCacheKey(actor, area, key);
|
||||
var cacheKey = GetPermissionCacheKey(actor, key);
|
||||
|
||||
try
|
||||
{
|
||||
var (hit, cachedValue) = await cache.GetAsyncWithStatus<T>(cacheKey);
|
||||
if (hit)
|
||||
{
|
||||
logger.LogDebug("Permission cache hit for {Actor}:{Area}:{Key}", actor, area, key);
|
||||
logger.LogDebug("Permission cache hit for {Type}:{Actor}:{Key}", type, actor, key);
|
||||
return cachedValue;
|
||||
}
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var groupsId = await GetOrCacheUserGroupsAsync(actor, now);
|
||||
|
||||
var permission = await FindPermissionNodeAsync(actor, area, key, groupsId, now);
|
||||
var permission = await FindPermissionNodeAsync(type, actor, key, groupsId);
|
||||
var result = permission != null ? DeserializePermissionValue<T>(permission.Value) : default;
|
||||
|
||||
await cache.SetWithGroupsAsync(cacheKey, result,
|
||||
[GetPermissionGroupKey(actor)],
|
||||
_options.CacheExpiration);
|
||||
|
||||
logger.LogDebug("Permission resolved for {Actor}:{Area}:{Key} = {Result}",
|
||||
actor, area, key, result != null);
|
||||
logger.LogDebug("Permission resolved for {Type}:{Actor}:{Key} = {Result}", type, actor, key,
|
||||
result != null);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error retrieving permission for {Actor}:{Area}:{Key}", actor, area, key);
|
||||
logger.LogError(ex, "Error retrieving permission for {Type}:{Actor}:{Key}", type, actor, key);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -109,33 +116,34 @@ public class PermissionService(
|
||||
return groupsId;
|
||||
}
|
||||
|
||||
private async Task<SnPermissionNode?> FindPermissionNodeAsync(string actor, string area, string key,
|
||||
List<Guid> groupsId, Instant now)
|
||||
private async Task<SnPermissionNode?> FindPermissionNodeAsync(
|
||||
PermissionNodeActorType type,
|
||||
string actor,
|
||||
string key,
|
||||
List<Guid> groupsId
|
||||
)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
// First try exact match (highest priority)
|
||||
var exactMatch = await db.PermissionNodes
|
||||
.Where(n => (n.GroupId == null && n.Actor == actor) ||
|
||||
.Where(n => (n.GroupId == null && n.Actor == actor && n.Type == type) ||
|
||||
(n.GroupId != null && groupsId.Contains(n.GroupId.Value)))
|
||||
.Where(n => n.Key == key && n.Area == area)
|
||||
.Where(n => n.Key == key)
|
||||
.Where(n => n.ExpiredAt == null || n.ExpiredAt > now)
|
||||
.Where(n => n.AffectedAt == null || n.AffectedAt <= now)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (exactMatch != null)
|
||||
{
|
||||
return exactMatch;
|
||||
}
|
||||
|
||||
// If no exact match and wildcards are enabled, try wildcard matches
|
||||
if (!_options.EnableWildcardMatching)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var wildcardMatches = await db.PermissionNodes
|
||||
.Where(n => (n.GroupId == null && n.Actor == actor) ||
|
||||
.Where(n => (n.GroupId == null && n.Actor == actor && n.Type == type) ||
|
||||
(n.GroupId != null && groupsId.Contains(n.GroupId.Value)))
|
||||
.Where(n => (n.Key.Contains("*") || n.Area.Contains("*")))
|
||||
.Where(n => EF.Functions.Like(n.Key, "%*%"))
|
||||
.Where(n => n.ExpiredAt == null || n.ExpiredAt > now)
|
||||
.Where(n => n.AffectedAt == null || n.AffectedAt <= now)
|
||||
.Take(_options.MaxWildcardMatches)
|
||||
@@ -147,36 +155,21 @@ public class PermissionService(
|
||||
|
||||
foreach (var node in wildcardMatches)
|
||||
{
|
||||
var score = CalculateWildcardMatchScore(node.Area, node.Key, area, key);
|
||||
if (score > bestMatchScore)
|
||||
{
|
||||
bestMatch = node;
|
||||
bestMatchScore = score;
|
||||
}
|
||||
var score = CalculateWildcardMatchScore(node.Key, key);
|
||||
if (score <= bestMatchScore) continue;
|
||||
bestMatch = node;
|
||||
bestMatchScore = score;
|
||||
}
|
||||
|
||||
if (bestMatch != null)
|
||||
{
|
||||
logger.LogDebug("Found wildcard permission match: {NodeArea}:{NodeKey} for {Area}:{Key}",
|
||||
bestMatch.Area, bestMatch.Key, area, key);
|
||||
}
|
||||
logger.LogDebug("Found wildcard permission match: {NodeKey} for {Key}", bestMatch.Key, key);
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
private static int CalculateWildcardMatchScore(string nodeArea, string nodeKey, string targetArea, string targetKey)
|
||||
private static int CalculateWildcardMatchScore(string nodeKey, string targetKey)
|
||||
{
|
||||
// Calculate how well the wildcard pattern matches
|
||||
// Higher score = better match
|
||||
var areaScore = CalculatePatternMatchScore(nodeArea, targetArea);
|
||||
var keyScore = CalculatePatternMatchScore(nodeKey, targetKey);
|
||||
|
||||
// Perfect match gets highest score
|
||||
if (areaScore == int.MaxValue && keyScore == int.MaxValue)
|
||||
return int.MaxValue;
|
||||
|
||||
// Prefer area matches over key matches, more specific patterns over general ones
|
||||
return (areaScore * 1000) + keyScore;
|
||||
return CalculatePatternMatchScore(nodeKey, targetKey);
|
||||
}
|
||||
|
||||
private static int CalculatePatternMatchScore(string pattern, string target)
|
||||
@@ -184,31 +177,30 @@ public class PermissionService(
|
||||
if (pattern == target)
|
||||
return int.MaxValue; // Exact match
|
||||
|
||||
if (!pattern.Contains("*"))
|
||||
if (!pattern.Contains('*'))
|
||||
return -1; // No wildcard, not a match
|
||||
|
||||
// Simple wildcard matching: * matches any sequence of characters
|
||||
var regexPattern = "^" + System.Text.RegularExpressions.Regex.Escape(pattern).Replace("\\*", ".*") + "$";
|
||||
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
var regex = new System.Text.RegularExpressions.Regex(regexPattern,
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
if (regex.IsMatch(target))
|
||||
{
|
||||
// Score based on specificity (shorter patterns are less specific)
|
||||
var wildcardCount = pattern.Count(c => c == '*');
|
||||
var length = pattern.Length;
|
||||
return Math.Max(1, 1000 - (wildcardCount * 100) - length);
|
||||
}
|
||||
if (!regex.IsMatch(target)) return -1; // No match
|
||||
|
||||
return -1; // No match
|
||||
// Score based on specificity (shorter patterns are less specific)
|
||||
var wildcardCount = pattern.Count(c => c == '*');
|
||||
var length = pattern.Length;
|
||||
|
||||
return Math.Max(1, 1000 - wildcardCount * 100 - length);
|
||||
}
|
||||
|
||||
public async Task<SnPermissionNode> AddPermissionNode<T>(
|
||||
string actor,
|
||||
string area,
|
||||
string key,
|
||||
T value,
|
||||
Instant? expiredAt = null,
|
||||
Instant? affectedAt = null
|
||||
Instant? affectedAt = null,
|
||||
PermissionNodeActorType type = PermissionNodeActorType.Account
|
||||
)
|
||||
{
|
||||
if (value is null) throw new ArgumentNullException(nameof(value));
|
||||
@@ -216,8 +208,8 @@ public class PermissionService(
|
||||
var node = new SnPermissionNode
|
||||
{
|
||||
Actor = actor,
|
||||
Type = type,
|
||||
Key = key,
|
||||
Area = area,
|
||||
Value = SerializePermissionValue(value),
|
||||
ExpiredAt = expiredAt,
|
||||
AffectedAt = affectedAt
|
||||
@@ -227,7 +219,7 @@ public class PermissionService(
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Invalidate related caches
|
||||
await InvalidatePermissionCacheAsync(actor, area, key);
|
||||
await InvalidatePermissionCacheAsync(actor, key);
|
||||
|
||||
return node;
|
||||
}
|
||||
@@ -235,11 +227,11 @@ public class PermissionService(
|
||||
public async Task<SnPermissionNode> AddPermissionNodeToGroup<T>(
|
||||
SnPermissionGroup group,
|
||||
string actor,
|
||||
string area,
|
||||
string key,
|
||||
T value,
|
||||
Instant? expiredAt = null,
|
||||
Instant? affectedAt = null
|
||||
Instant? affectedAt = null,
|
||||
PermissionNodeActorType type = PermissionNodeActorType.Account
|
||||
)
|
||||
{
|
||||
if (value is null) throw new ArgumentNullException(nameof(value));
|
||||
@@ -247,8 +239,8 @@ public class PermissionService(
|
||||
var node = new SnPermissionNode
|
||||
{
|
||||
Actor = actor,
|
||||
Type = type,
|
||||
Key = key,
|
||||
Area = area,
|
||||
Value = SerializePermissionValue(value),
|
||||
ExpiredAt = expiredAt,
|
||||
AffectedAt = affectedAt,
|
||||
@@ -260,44 +252,45 @@ public class PermissionService(
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Invalidate related caches
|
||||
await InvalidatePermissionCacheAsync(actor, area, key);
|
||||
await InvalidatePermissionCacheAsync(actor, key);
|
||||
await cache.RemoveAsync(GetGroupsCacheKey(actor));
|
||||
await cache.RemoveGroupAsync(GetPermissionGroupKey(actor));
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
public async Task RemovePermissionNode(string actor, string area, string key)
|
||||
public async Task RemovePermissionNode(string actor, string key, PermissionNodeActorType? type)
|
||||
{
|
||||
var node = await db.PermissionNodes
|
||||
.Where(n => n.Actor == actor && n.Area == area && n.Key == key)
|
||||
.Where(n => n.Actor == actor && n.Key == key)
|
||||
.If(type is not null, q => q.Where(n => n.Type == type))
|
||||
.FirstOrDefaultAsync();
|
||||
if (node is not null) db.PermissionNodes.Remove(node);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Invalidate cache
|
||||
await InvalidatePermissionCacheAsync(actor, area, key);
|
||||
await InvalidatePermissionCacheAsync(actor, key);
|
||||
}
|
||||
|
||||
public async Task RemovePermissionNodeFromGroup<T>(SnPermissionGroup group, string actor, string area, string key)
|
||||
public async Task RemovePermissionNodeFromGroup<T>(SnPermissionGroup group, string actor, string key)
|
||||
{
|
||||
var node = await db.PermissionNodes
|
||||
.Where(n => n.GroupId == group.Id)
|
||||
.Where(n => n.Actor == actor && n.Area == area && n.Key == key)
|
||||
.Where(n => n.Actor == actor && n.Key == key && n.Type == PermissionNodeActorType.Group)
|
||||
.FirstOrDefaultAsync();
|
||||
if (node is null) return;
|
||||
db.PermissionNodes.Remove(node);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Invalidate caches
|
||||
await InvalidatePermissionCacheAsync(actor, area, key);
|
||||
await InvalidatePermissionCacheAsync(actor, key);
|
||||
await cache.RemoveAsync(GetGroupsCacheKey(actor));
|
||||
await cache.RemoveGroupAsync(GetPermissionGroupKey(actor));
|
||||
}
|
||||
|
||||
private async Task InvalidatePermissionCacheAsync(string actor, string area, string key)
|
||||
private async Task InvalidatePermissionCacheAsync(string actor, string key)
|
||||
{
|
||||
var cacheKey = GetPermissionCacheKey(actor, area, key);
|
||||
var cacheKey = GetPermissionCacheKey(actor, key);
|
||||
await cache.RemoveAsync(cacheKey);
|
||||
}
|
||||
|
||||
@@ -312,12 +305,11 @@ public class PermissionService(
|
||||
return JsonDocument.Parse(str);
|
||||
}
|
||||
|
||||
public static SnPermissionNode NewPermissionNode<T>(string actor, string area, string key, T value)
|
||||
public static SnPermissionNode NewPermissionNode<T>(string actor, string key, T value)
|
||||
{
|
||||
return new SnPermissionNode
|
||||
{
|
||||
Actor = actor,
|
||||
Area = area,
|
||||
Key = key,
|
||||
Value = SerializePermissionValue(value),
|
||||
};
|
||||
@@ -341,8 +333,7 @@ public class PermissionService(
|
||||
(n.GroupId != null && groupsId.Contains(n.GroupId.Value)))
|
||||
.Where(n => n.ExpiredAt == null || n.ExpiredAt > now)
|
||||
.Where(n => n.AffectedAt == null || n.AffectedAt <= now)
|
||||
.OrderBy(n => n.Area)
|
||||
.ThenBy(n => n.Key)
|
||||
.OrderBy(n => n.Key)
|
||||
.ToListAsync();
|
||||
|
||||
logger.LogDebug("Listed {Count} effective permissions for actor {Actor}", permissions.Count, actor);
|
||||
@@ -370,8 +361,7 @@ public class PermissionService(
|
||||
.Where(n => n.GroupId == null && n.Actor == actor)
|
||||
.Where(n => n.ExpiredAt == null || n.ExpiredAt > now)
|
||||
.Where(n => n.AffectedAt == null || n.AffectedAt <= now)
|
||||
.OrderBy(n => n.Area)
|
||||
.ThenBy(n => n.Key)
|
||||
.OrderBy(n => n.Key)
|
||||
.ToListAsync();
|
||||
|
||||
logger.LogDebug("Listed {Count} direct permissions for actor {Actor}", permissions.Count, actor);
|
||||
@@ -424,4 +414,4 @@ public class PermissionService(
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,31 +9,33 @@ using NodaTime.Serialization.Protobuf;
|
||||
namespace DysonNetwork.Pass.Permission;
|
||||
|
||||
public class PermissionServiceGrpc(
|
||||
PermissionService permissionService,
|
||||
PermissionService psv,
|
||||
AppDatabase db,
|
||||
ILogger<PermissionServiceGrpc> logger
|
||||
) : DysonNetwork.Shared.Proto.PermissionService.PermissionServiceBase
|
||||
{
|
||||
public override async Task<HasPermissionResponse> HasPermission(HasPermissionRequest request, ServerCallContext context)
|
||||
{
|
||||
var type = SnPermissionNode.ConvertProtoActorType(request.Type);
|
||||
try
|
||||
{
|
||||
var hasPermission = await permissionService.HasPermissionAsync(request.Actor, request.Area, request.Key);
|
||||
var hasPermission = await psv.HasPermissionAsync(request.Actor, request.Key, type);
|
||||
return new HasPermissionResponse { HasPermission = hasPermission };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error checking permission for actor {Actor}, area {Area}, key {Key}",
|
||||
request.Actor, request.Area, request.Key);
|
||||
logger.LogError(ex, "Error checking permission for {Type}:{Area}:{Key}",
|
||||
type, request.Actor, request.Key);
|
||||
throw new RpcException(new Status(StatusCode.Internal, "Permission check failed"));
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task<GetPermissionResponse> GetPermission(GetPermissionRequest request, ServerCallContext context)
|
||||
{
|
||||
var type = SnPermissionNode.ConvertProtoActorType(request.Type);
|
||||
try
|
||||
{
|
||||
var permissionValue = await permissionService.GetPermissionAsync<JsonDocument>(request.Actor, request.Area, request.Key);
|
||||
var permissionValue = await psv.GetPermissionAsync<JsonDocument>(request.Actor, request.Key, type);
|
||||
return new GetPermissionResponse
|
||||
{
|
||||
Value = permissionValue != null ? Value.Parser.ParseJson(permissionValue.RootElement.GetRawText()) : null
|
||||
@@ -41,14 +43,15 @@ public class PermissionServiceGrpc(
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error getting permission for actor {Actor}, area {Area}, key {Key}",
|
||||
request.Actor, request.Area, request.Key);
|
||||
logger.LogError(ex, "Error getting permission for {Type}:{Area}:{Key}",
|
||||
type, request.Actor, request.Key);
|
||||
throw new RpcException(new Status(StatusCode.Internal, "Failed to retrieve permission"));
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task<AddPermissionNodeResponse> AddPermissionNode(AddPermissionNodeRequest request, ServerCallContext context)
|
||||
{
|
||||
var type = SnPermissionNode.ConvertProtoActorType(request.Type);
|
||||
try
|
||||
{
|
||||
JsonDocument jsonValue;
|
||||
@@ -58,18 +61,18 @@ public class PermissionServiceGrpc(
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Invalid JSON in permission value for actor {Actor}, area {Area}, key {Key}",
|
||||
request.Actor, request.Area, request.Key);
|
||||
logger.LogError(ex, "Invalid JSON in permission value for {Type}:{Area}:{Key}",
|
||||
type, request.Actor, request.Key);
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, "Invalid permission value format"));
|
||||
}
|
||||
|
||||
var node = await permissionService.AddPermissionNode(
|
||||
var node = await psv.AddPermissionNode(
|
||||
request.Actor,
|
||||
request.Area,
|
||||
request.Key,
|
||||
jsonValue,
|
||||
request.ExpiredAt?.ToInstant(),
|
||||
request.AffectedAt?.ToInstant()
|
||||
request.AffectedAt?.ToInstant(),
|
||||
type
|
||||
);
|
||||
return new AddPermissionNodeResponse { Node = node.ToProtoValue() };
|
||||
}
|
||||
@@ -79,14 +82,15 @@ public class PermissionServiceGrpc(
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error adding permission node for actor {Actor}, area {Area}, key {Key}",
|
||||
request.Actor, request.Area, request.Key);
|
||||
logger.LogError(ex, "Error adding permission for {Type}:{Area}:{Key}",
|
||||
type, request.Actor, request.Key);
|
||||
throw new RpcException(new Status(StatusCode.Internal, "Failed to add permission node"));
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task<AddPermissionNodeToGroupResponse> AddPermissionNodeToGroup(AddPermissionNodeToGroupRequest request, ServerCallContext context)
|
||||
{
|
||||
var type = SnPermissionNode.ConvertProtoActorType(request.Type);
|
||||
try
|
||||
{
|
||||
var group = await FindPermissionGroupAsync(request.Group.Id);
|
||||
@@ -102,19 +106,19 @@ public class PermissionServiceGrpc(
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Invalid JSON in permission value for group {GroupId}, actor {Actor}, area {Area}, key {Key}",
|
||||
request.Group.Id, request.Actor, request.Area, request.Key);
|
||||
logger.LogError(ex, "Invalid JSON in permission value for {Type}:{Area}:{Key}",
|
||||
type, request.Actor, request.Key);
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, "Invalid permission value format"));
|
||||
}
|
||||
|
||||
var node = await permissionService.AddPermissionNodeToGroup(
|
||||
var node = await psv.AddPermissionNodeToGroup(
|
||||
group,
|
||||
request.Actor,
|
||||
request.Area,
|
||||
request.Key,
|
||||
jsonValue,
|
||||
request.ExpiredAt?.ToInstant(),
|
||||
request.AffectedAt?.ToInstant()
|
||||
request.AffectedAt?.ToInstant(),
|
||||
type
|
||||
);
|
||||
return new AddPermissionNodeToGroupResponse { Node = node.ToProtoValue() };
|
||||
}
|
||||
@@ -124,23 +128,24 @@ public class PermissionServiceGrpc(
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error adding permission node to group {GroupId} for actor {Actor}, area {Area}, key {Key}",
|
||||
request.Group.Id, request.Actor, request.Area, request.Key);
|
||||
logger.LogError(ex, "Error adding permission for {Type}:{Area}:{Key}",
|
||||
type, request.Actor, request.Key);
|
||||
throw new RpcException(new Status(StatusCode.Internal, "Failed to add permission node to group"));
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task<RemovePermissionNodeResponse> RemovePermissionNode(RemovePermissionNodeRequest request, ServerCallContext context)
|
||||
{
|
||||
var type = SnPermissionNode.ConvertProtoActorType(request.Type);
|
||||
try
|
||||
{
|
||||
await permissionService.RemovePermissionNode(request.Actor, request.Area, request.Key);
|
||||
await psv.RemovePermissionNode(request.Actor, request.Key, type);
|
||||
return new RemovePermissionNodeResponse { Success = true };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error removing permission node for actor {Actor}, area {Area}, key {Key}",
|
||||
request.Actor, request.Area, request.Key);
|
||||
logger.LogError(ex, "Error removing permission for {Type}:{Area}:{Key}",
|
||||
type, request.Actor, request.Key);
|
||||
throw new RpcException(new Status(StatusCode.Internal, "Failed to remove permission node"));
|
||||
}
|
||||
}
|
||||
@@ -155,7 +160,7 @@ public class PermissionServiceGrpc(
|
||||
throw new RpcException(new Status(StatusCode.NotFound, "Permission group not found"));
|
||||
}
|
||||
|
||||
await permissionService.RemovePermissionNodeFromGroup<JsonDocument>(group, request.Actor, request.Area, request.Key);
|
||||
await psv.RemovePermissionNodeFromGroup<JsonDocument>(group, request.Actor, request.Key);
|
||||
return new RemovePermissionNodeFromGroupResponse { Success = true };
|
||||
}
|
||||
catch (RpcException)
|
||||
@@ -164,20 +169,18 @@ public class PermissionServiceGrpc(
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error removing permission node from group {GroupId} for actor {Actor}, area {Area}, key {Key}",
|
||||
request.Group.Id, request.Actor, request.Area, request.Key);
|
||||
logger.LogError(ex, "Error removing permission from group for {Area}:{Key}",
|
||||
request.Actor, request.Key);
|
||||
throw new RpcException(new Status(StatusCode.Internal, "Failed to remove permission node from group"));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<SnPermissionGroup?> FindPermissionGroupAsync(string groupId)
|
||||
{
|
||||
if (!Guid.TryParse(groupId, out var guid))
|
||||
{
|
||||
logger.LogWarning("Invalid GUID format for group ID: {GroupId}", groupId);
|
||||
return null;
|
||||
}
|
||||
if (Guid.TryParse(groupId, out var guid))
|
||||
return await db.PermissionGroups.FirstOrDefaultAsync(g => g.Id == guid);
|
||||
logger.LogWarning("Invalid GUID format for group ID: {GroupId}", groupId);
|
||||
return null;
|
||||
|
||||
return await db.PermissionGroups.FirstOrDefaultAsync(g => g.Id == guid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using DysonNetwork.Pass.Permission;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using NodaTime;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Shared.Auth;
|
||||
|
||||
namespace DysonNetwork.Pass;
|
||||
|
||||
@@ -19,16 +20,20 @@ public class PermissionController(
|
||||
/// <summary>
|
||||
/// Check if an actor has a specific permission
|
||||
/// </summary>
|
||||
[HttpGet("check/{actor}/{area}/{key}")]
|
||||
[RequiredPermission("maintenance", "permissions.check")]
|
||||
[HttpGet("check/{actor}/{key}")]
|
||||
[AskPermission("permissions.check")]
|
||||
[ProducesResponseType<bool>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> CheckPermission(string actor, string area, string key)
|
||||
public async Task<IActionResult> CheckPermission(
|
||||
[FromRoute] string actor,
|
||||
[FromRoute] string key,
|
||||
[FromQuery] PermissionNodeActorType type = PermissionNodeActorType.Account
|
||||
)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hasPermission = await permissionService.HasPermissionAsync(actor, area, key);
|
||||
var hasPermission = await permissionService.HasPermissionAsync(actor, key, type);
|
||||
return Ok(hasPermission);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
@@ -45,7 +50,7 @@ public class PermissionController(
|
||||
/// Get all effective permissions for an actor (including group permissions)
|
||||
/// </summary>
|
||||
[HttpGet("actors/{actor}/permissions/effective")]
|
||||
[RequiredPermission("maintenance", "permissions.check")]
|
||||
[AskPermission("permissions.check")]
|
||||
[ProducesResponseType<List<SnPermissionNode>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
@@ -70,7 +75,7 @@ public class PermissionController(
|
||||
/// Get all direct permissions for an actor (excluding group permissions)
|
||||
/// </summary>
|
||||
[HttpGet("actors/{actor}/permissions/direct")]
|
||||
[RequiredPermission("maintenance", "permissions.check")]
|
||||
[AskPermission("permissions.check")]
|
||||
[ProducesResponseType<List<SnPermissionNode>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
@@ -94,28 +99,27 @@ public class PermissionController(
|
||||
/// <summary>
|
||||
/// Give a permission to an actor
|
||||
/// </summary>
|
||||
[HttpPost("actors/{actor}/permissions/{area}/{key}")]
|
||||
[RequiredPermission("maintenance", "permissions.manage")]
|
||||
[HttpPost("actors/{actor}/permissions/{key}")]
|
||||
[AskPermission("permissions.manage")]
|
||||
[ProducesResponseType<SnPermissionNode>(StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> GivePermission(
|
||||
string actor,
|
||||
string area,
|
||||
string key,
|
||||
[FromBody] PermissionRequest request)
|
||||
[FromBody] PermissionRequest request
|
||||
)
|
||||
{
|
||||
try
|
||||
{
|
||||
var permission = await permissionService.AddPermissionNode(
|
||||
actor,
|
||||
area,
|
||||
key,
|
||||
JsonDocument.Parse(JsonSerializer.Serialize(request.Value)),
|
||||
request.ExpiredAt,
|
||||
request.AffectedAt
|
||||
);
|
||||
return Created($"/api/permissions/actors/{actor}/permissions/{area}/{key}", permission);
|
||||
return Created($"/api/permissions/actors/{actor}/permissions/{key}", permission);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
@@ -130,16 +134,20 @@ public class PermissionController(
|
||||
/// <summary>
|
||||
/// Remove a permission from an actor
|
||||
/// </summary>
|
||||
[HttpDelete("actors/{actor}/permissions/{area}/{key}")]
|
||||
[RequiredPermission("maintenance", "permissions.manage")]
|
||||
[HttpDelete("actors/{actor}/permissions/{key}")]
|
||||
[AskPermission("permissions.manage")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> RemovePermission(string actor, string area, string key)
|
||||
public async Task<IActionResult> RemovePermission(
|
||||
string actor,
|
||||
string key,
|
||||
[FromQuery] PermissionNodeActorType type = PermissionNodeActorType.Account
|
||||
)
|
||||
{
|
||||
try
|
||||
{
|
||||
await permissionService.RemovePermissionNode(actor, area, key);
|
||||
await permissionService.RemovePermissionNode(actor, key, type);
|
||||
return NoContent();
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
@@ -156,7 +164,7 @@ public class PermissionController(
|
||||
/// Get all groups for an actor
|
||||
/// </summary>
|
||||
[HttpGet("actors/{actor}/groups")]
|
||||
[RequiredPermission("maintenance", "permissions.groups.check")]
|
||||
[AskPermission("permissions.groups.check")]
|
||||
[ProducesResponseType<List<SnPermissionGroupMember>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
@@ -183,8 +191,8 @@ public class PermissionController(
|
||||
/// <summary>
|
||||
/// Add an actor to a permission group
|
||||
/// </summary>
|
||||
[HttpPost("actors/{actor}/groups/{groupId}")]
|
||||
[RequiredPermission("maintenance", "permissions.groups.manage")]
|
||||
[HttpPost("actors/{actor}/groups/{groupId:guid}")]
|
||||
[AskPermission("permissions.groups.manage")]
|
||||
[ProducesResponseType<SnPermissionGroupMember>(StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -192,7 +200,8 @@ public class PermissionController(
|
||||
public async Task<IActionResult> AddActorToGroup(
|
||||
string actor,
|
||||
Guid groupId,
|
||||
[FromBody] GroupMembershipRequest? request = null)
|
||||
[FromBody] GroupMembershipRequest? request = null
|
||||
)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -238,7 +247,7 @@ public class PermissionController(
|
||||
/// Remove an actor from a permission group
|
||||
/// </summary>
|
||||
[HttpDelete("actors/{actor}/groups/{groupId}")]
|
||||
[RequiredPermission("maintenance", "permissions.groups.manage")]
|
||||
[AskPermission("permissions.groups.manage")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
@@ -272,7 +281,7 @@ public class PermissionController(
|
||||
/// Clear permission cache for an actor
|
||||
/// </summary>
|
||||
[HttpPost("actors/{actor}/cache/clear")]
|
||||
[RequiredPermission("maintenance", "permissions.cache.manage")]
|
||||
[AskPermission("permissions.cache.manage")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
@@ -297,7 +306,7 @@ public class PermissionController(
|
||||
/// Validate a permission pattern
|
||||
/// </summary>
|
||||
[HttpPost("validate-pattern")]
|
||||
[RequiredPermission("maintenance", "permissions.check")]
|
||||
[AskPermission("permissions.check")]
|
||||
[ProducesResponseType<PatternValidationResponse>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public IActionResult ValidatePattern([FromBody] PatternValidationRequest request)
|
||||
@@ -322,14 +331,14 @@ public class PermissionController(
|
||||
public class PermissionRequest
|
||||
{
|
||||
public object? Value { get; set; }
|
||||
public NodaTime.Instant? ExpiredAt { get; set; }
|
||||
public NodaTime.Instant? AffectedAt { get; set; }
|
||||
public Instant? ExpiredAt { get; set; }
|
||||
public Instant? AffectedAt { get; set; }
|
||||
}
|
||||
|
||||
public class GroupMembershipRequest
|
||||
{
|
||||
public NodaTime.Instant? ExpiredAt { get; set; }
|
||||
public NodaTime.Instant? AffectedAt { get; set; }
|
||||
public Instant? ExpiredAt { get; set; }
|
||||
public Instant? AffectedAt { get; set; }
|
||||
}
|
||||
|
||||
public class PatternValidationRequest
|
||||
@@ -342,4 +351,4 @@ public class PatternValidationResponse
|
||||
public string Pattern { get; set; } = string.Empty;
|
||||
public bool IsValid { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
builder.AddServiceDefaults("pass");
|
||||
|
||||
builder.Services.Configure<ServiceRegistrationOptions>(opts => { opts.Name = "pass"; });
|
||||
|
||||
// Configure Kestrel and server options
|
||||
builder.ConfigureAppKestrel(builder.Configuration);
|
||||
@@ -14,9 +16,6 @@ builder.ConfigureAppKestrel(builder.Configuration);
|
||||
// Add application services
|
||||
builder.Services.AddAppServices(builder.Configuration);
|
||||
builder.Services.AddAppAuthentication();
|
||||
builder.Services.AddRingService();
|
||||
builder.Services.AddDriveService();
|
||||
builder.Services.AddDevelopService();
|
||||
|
||||
builder.Services.AddAppFlushHandlers();
|
||||
builder.Services.AddAppBusinessServices(builder.Configuration);
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"HTTP_PORTS": "5010",
|
||||
"GRPC_PORT": "6010"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@ public class RealmServiceGrpc(
|
||||
: realm.ToProtoValue();
|
||||
}
|
||||
|
||||
public override async Task<GetRealmBatchResponse> GetRealmBatch(GetRealmBatchRequest request, ServerCallContext context)
|
||||
public override async Task<GetRealmBatchResponse> GetRealmBatch(GetRealmBatchRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
var ids = request.Ids.Select(Guid.Parse).ToList();
|
||||
var realms = await db.Realms.Where(r => ids.Contains(r.Id)).ToListAsync();
|
||||
@@ -67,19 +68,33 @@ public class RealmServiceGrpc(
|
||||
return new GetUserRealmsResponse { RealmIds = { realms.Select(g => g.ToString()) } };
|
||||
}
|
||||
|
||||
public override async Task<GetPublicRealmsResponse> GetPublicRealms(Empty request, ServerCallContext context)
|
||||
public override Task<GetPublicRealmsResponse> GetPublicRealms(
|
||||
GetPublicRealmsRequest request,
|
||||
ServerCallContext context
|
||||
)
|
||||
{
|
||||
var realms = await db.Realms.Where(r => r.IsPublic).ToListAsync();
|
||||
var realmsQueryable = db.Realms.Where(r => r.IsPublic).AsQueryable();
|
||||
|
||||
realmsQueryable = request.OrderBy switch
|
||||
{
|
||||
"random" => realmsQueryable.OrderBy(_ => EF.Functions.Random()),
|
||||
"name" => realmsQueryable.OrderBy(r => r.Name),
|
||||
"popularity" => realmsQueryable.OrderByDescending(r => r.Members.Count),
|
||||
_ => realmsQueryable.OrderByDescending(r => r.CreatedAt)
|
||||
};
|
||||
|
||||
var response = new GetPublicRealmsResponse();
|
||||
response.Realms.AddRange(realms.Select(r => r.ToProtoValue()));
|
||||
return response;
|
||||
response.Realms.AddRange(realmsQueryable.Select(r => r.ToProtoValue()));
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
public override async Task<GetPublicRealmsResponse> SearchRealms(SearchRealmsRequest request, ServerCallContext context)
|
||||
public override async Task<GetPublicRealmsResponse> SearchRealms(SearchRealmsRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
var realms = await db.Realms
|
||||
.Where(r => r.IsPublic)
|
||||
.Where(r => EF.Functions.Like(r.Slug, $"{request.Query}%") || EF.Functions.Like(r.Name, $"{request.Query}%"))
|
||||
.Where(r => EF.Functions.Like(r.Slug, $"{request.Query}%") ||
|
||||
EF.Functions.Like(r.Name, $"{request.Query}%"))
|
||||
.Take(request.Limit)
|
||||
.ToListAsync();
|
||||
var response = new GetPublicRealmsResponse();
|
||||
@@ -94,9 +109,9 @@ public class RealmServiceGrpc(
|
||||
.AsNoTracking()
|
||||
.Include(a => a.Profile)
|
||||
.FirstOrDefaultAsync(a => a.Id == Guid.Parse(member.AccountId));
|
||||
|
||||
|
||||
if (account == null) throw new RpcException(new Status(StatusCode.NotFound, "Account not found"));
|
||||
|
||||
|
||||
CultureService.SetCultureInfo(account.Language);
|
||||
|
||||
await pusher.SendPushNotificationToUserAsync(
|
||||
@@ -138,7 +153,7 @@ public class RealmServiceGrpc(
|
||||
.AsNoTracking()
|
||||
.Include(a => a.Profile)
|
||||
.FirstOrDefaultAsync(a => a.Id == Guid.Parse(member.AccountId));
|
||||
|
||||
|
||||
var response = new RealmMember(member) { Account = account?.ToProtoValue() };
|
||||
return response;
|
||||
}
|
||||
@@ -167,4 +182,4 @@ public class RealmServiceGrpc(
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Pass.Permission;
|
||||
using DysonNetwork.Shared.Auth;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -51,7 +52,7 @@ public class SnAbuseReportController(
|
||||
|
||||
[HttpGet("")]
|
||||
[Authorize]
|
||||
[RequiredPermission("safety", "reports.view")]
|
||||
[AskPermission("reports.view")]
|
||||
[ProducesResponseType<List<SnAbuseReport>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<SnAbuseReport>>> GetReports(
|
||||
[FromQuery] int offset = 0,
|
||||
@@ -85,7 +86,7 @@ public class SnAbuseReportController(
|
||||
|
||||
[HttpGet("{id}")]
|
||||
[Authorize]
|
||||
[RequiredPermission("safety", "reports.view")]
|
||||
[AskPermission("reports.view")]
|
||||
[ProducesResponseType<SnAbuseReport>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SnAbuseReport>> GetReportById(Guid id)
|
||||
@@ -122,7 +123,7 @@ public class SnAbuseReportController(
|
||||
|
||||
[HttpPost("{id}/resolve")]
|
||||
[Authorize]
|
||||
[RequiredPermission("safety", "reports.resolve")]
|
||||
[AskPermission("reports.resolve")]
|
||||
[ProducesResponseType<SnAbuseReport>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SnAbuseReport>> ResolveReport(Guid id, [FromBody] ResolveReportRequest request)
|
||||
@@ -144,7 +145,7 @@ public class SnAbuseReportController(
|
||||
|
||||
[HttpGet("count")]
|
||||
[Authorize]
|
||||
[RequiredPermission("safety", "reports.view")]
|
||||
[AskPermission("reports.view")]
|
||||
[ProducesResponseType<object>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<object>> GetReportsCount()
|
||||
{
|
||||
|
||||
@@ -22,7 +22,7 @@ public static class ApplicationConfiguration
|
||||
app.UseWebSockets();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseMiddleware<PermissionMiddleware>();
|
||||
app.UseMiddleware<LocalPermissionMiddleware>();
|
||||
|
||||
app.MapControllers().RequireRateLimiting("fixed");
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ using NodaTime.Serialization.SystemTextJson;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Pass.Account.Presences;
|
||||
using DysonNetwork.Pass.Affiliation;
|
||||
using DysonNetwork.Pass.Auth.OidcProvider.Options;
|
||||
using DysonNetwork.Pass.Auth.OidcProvider.Services;
|
||||
using DysonNetwork.Pass.Credit;
|
||||
@@ -22,7 +23,7 @@ using DysonNetwork.Pass.Realm;
|
||||
using DysonNetwork.Pass.Safety;
|
||||
using DysonNetwork.Pass.Wallet.PaymentHandlers;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using DysonNetwork.Shared.Geometry;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
|
||||
namespace DysonNetwork.Pass.Startup;
|
||||
@@ -34,9 +35,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddLocalization(options => options.ResourcesPath = "Resources");
|
||||
|
||||
services.AddDbContext<AppDatabase>();
|
||||
services.AddSingleton<IClock>(SystemClock.Instance);
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddSingleton<ICacheService, CacheServiceRedis>();
|
||||
|
||||
services.AddHttpClient();
|
||||
|
||||
@@ -49,8 +48,6 @@ public static class ServiceCollectionExtensions
|
||||
});
|
||||
services.AddGrpcReflection();
|
||||
|
||||
services.AddRingService();
|
||||
|
||||
// Register OIDC services
|
||||
services.AddScoped<OidcService, GoogleOidcService>();
|
||||
services.AddScoped<OidcService, AppleOidcService>();
|
||||
@@ -137,7 +134,7 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
services.AddScoped<RazorViewRenderer>();
|
||||
services.Configure<GeoIpOptions>(configuration.GetSection("GeoIP"));
|
||||
services.AddScoped<GeoIpService>();
|
||||
services.AddScoped<GeoService>();
|
||||
services.AddScoped<EmailService>();
|
||||
services.AddScoped<PermissionService>();
|
||||
services.AddScoped<ActionLogService>();
|
||||
@@ -159,6 +156,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<ExperienceService>();
|
||||
services.AddScoped<RealmService>();
|
||||
services.AddScoped<LotteryService>();
|
||||
services.AddScoped<AffiliationSpellService>();
|
||||
|
||||
services.AddScoped<SpotifyPresenceService>();
|
||||
services.AddScoped<SteamPresenceService>();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Pass.Auth;
|
||||
using DysonNetwork.Pass.Permission;
|
||||
using DysonNetwork.Shared.Auth;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -196,7 +197,7 @@ public class WalletController(
|
||||
|
||||
[HttpPost("balance")]
|
||||
[Authorize]
|
||||
[RequiredPermission("maintenance", "wallets.balance.modify")]
|
||||
[AskPermission("wallets.balance.modify")]
|
||||
public async Task<ActionResult<SnWalletTransaction>> ModifyWalletBalance([FromBody] WalletBalanceRequest request)
|
||||
{
|
||||
var wallet = await ws.GetWalletAsync(request.AccountId);
|
||||
|
||||
@@ -1,75 +1,87 @@
|
||||
{
|
||||
"Debug": true,
|
||||
"BaseUrl": "http://localhost:5001",
|
||||
"SiteUrl": "http://localhost:3000",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"Authentication": {
|
||||
"Schemes": {
|
||||
"Bearer": {
|
||||
"ValidAudiences": ["http://localhost:5071", "https://localhost:7099"],
|
||||
"ValidIssuer": "solar-network"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AuthToken": {
|
||||
"CookieDomain": "localhost",
|
||||
"PublicKeyPath": "Keys/PublicKey.pem",
|
||||
"PrivateKeyPath": "Keys/PrivateKey.pem"
|
||||
},
|
||||
"OidcProvider": {
|
||||
"IssuerUri": "https://nt.solian.app",
|
||||
"PublicKeyPath": "Keys/PublicKey.pem",
|
||||
"PrivateKeyPath": "Keys/PrivateKey.pem",
|
||||
"AccessTokenLifetime": "01:00:00",
|
||||
"RefreshTokenLifetime": "30.00:00:00",
|
||||
"AuthorizationCodeLifetime": "00:30:00",
|
||||
"RequireHttpsMetadata": true
|
||||
},
|
||||
"Captcha": {
|
||||
"Provider": "cloudflare",
|
||||
"ApiKey": "0x4AAAAAABCDUdOujj4feOb_",
|
||||
"ApiSecret": "0x4AAAAAABCDUWABiJQweqlB7tYq-IqIm8U"
|
||||
},
|
||||
"GeoIp": {
|
||||
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
|
||||
},
|
||||
"Oidc": {
|
||||
"Google": {
|
||||
"ClientId": "961776991058-963m1qin2vtp8fv693b5fdrab5hmpl89.apps.googleusercontent.com",
|
||||
"ClientSecret": ""
|
||||
"Debug": true,
|
||||
"BaseUrl": "http://localhost:5001",
|
||||
"SiteUrl": "http://localhost:3000",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Apple": {
|
||||
"ClientId": "dev.solsynth.solian",
|
||||
"TeamId": "W7HPZ53V6B",
|
||||
"KeyId": "B668YP4KBG",
|
||||
"PrivateKeyPath": "./Keys/Solarpass.p8"
|
||||
"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",
|
||||
"Registrar": "127.0.0.1:2379",
|
||||
"Cache": "127.0.0.1:6379",
|
||||
"Queue": "127.0.0.1:4222"
|
||||
},
|
||||
"Microsoft": {
|
||||
"ClientId": "YOUR_MICROSOFT_CLIENT_ID",
|
||||
"ClientSecret": "YOUR_MICROSOFT_CLIENT_SECRET",
|
||||
"DiscoveryEndpoint": "YOUR_MICROSOFT_DISCOVERY_ENDPOINT"
|
||||
}
|
||||
},
|
||||
"Payment": {
|
||||
"Auth": {
|
||||
"Afdian": "<token here>"
|
||||
"Authentication": {
|
||||
"Schemes": {
|
||||
"Bearer": {
|
||||
"ValidAudiences": [
|
||||
"http://localhost:5071",
|
||||
"https://localhost:7099"
|
||||
],
|
||||
"ValidIssuer": "solar-network"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Subscriptions": {
|
||||
"Afdian": {
|
||||
"7d17aae23c9611f0b5705254001e7c00": "solian.stellar.primary",
|
||||
"7dfae4743c9611f0b3a55254001e7c00": "solian.stellar.nova",
|
||||
"141713ee3d6211f085b352540025c377": "solian.stellar.supernova"
|
||||
}
|
||||
}
|
||||
},
|
||||
"KnownProxies": ["127.0.0.1", "::1"]
|
||||
"AuthToken": {
|
||||
"CookieDomain": "localhost",
|
||||
"PublicKeyPath": "Keys/PublicKey.pem",
|
||||
"PrivateKeyPath": "Keys/PrivateKey.pem"
|
||||
},
|
||||
"OidcProvider": {
|
||||
"IssuerUri": "https://nt.solian.app",
|
||||
"PublicKeyPath": "Keys/PublicKey.pem",
|
||||
"PrivateKeyPath": "Keys/PrivateKey.pem",
|
||||
"AccessTokenLifetime": "01:00:00",
|
||||
"RefreshTokenLifetime": "30.00:00:00",
|
||||
"AuthorizationCodeLifetime": "00:30:00",
|
||||
"RequireHttpsMetadata": true
|
||||
},
|
||||
"Captcha": {
|
||||
"Provider": "cloudflare",
|
||||
"ApiKey": "0x4AAAAAABCDUdOujj4feOb_",
|
||||
"ApiSecret": "0x4AAAAAABCDUWABiJQweqlB7tYq-IqIm8U"
|
||||
},
|
||||
"GeoIp": {
|
||||
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
|
||||
},
|
||||
"Oidc": {
|
||||
"Google": {
|
||||
"ClientId": "961776991058-963m1qin2vtp8fv693b5fdrab5hmpl89.apps.googleusercontent.com",
|
||||
"ClientSecret": ""
|
||||
},
|
||||
"Apple": {
|
||||
"ClientId": "dev.solsynth.solian",
|
||||
"TeamId": "W7HPZ53V6B",
|
||||
"KeyId": "B668YP4KBG",
|
||||
"PrivateKeyPath": "./Keys/Solarpass.p8"
|
||||
},
|
||||
"Microsoft": {
|
||||
"ClientId": "YOUR_MICROSOFT_CLIENT_ID",
|
||||
"ClientSecret": "YOUR_MICROSOFT_CLIENT_SECRET",
|
||||
"DiscoveryEndpoint": "YOUR_MICROSOFT_DISCOVERY_ENDPOINT"
|
||||
}
|
||||
},
|
||||
"Cache": {
|
||||
"Serializer": "MessagePack"
|
||||
},
|
||||
"Payment": {
|
||||
"Auth": {
|
||||
"Afdian": "<token here>"
|
||||
},
|
||||
"Subscriptions": {
|
||||
"Afdian": {
|
||||
"7d17aae23c9611f0b5705254001e7c00": "solian.stellar.primary",
|
||||
"7dfae4743c9611f0b3a55254001e7c00": "solian.stellar.nova",
|
||||
"141713ee3d6211f085b352540025c377": "solian.stellar.supernova"
|
||||
}
|
||||
}
|
||||
},
|
||||
"KnownProxies": [
|
||||
"127.0.0.1",
|
||||
"::1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ using DysonNetwork.Shared.Stream;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Net;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
using WebSocketPacket = DysonNetwork.Shared.Models.WebSocketPacket;
|
||||
|
||||
@@ -40,10 +39,10 @@ public class WebSocketController(
|
||||
}
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id!);
|
||||
var deviceId = currentSession.Challenge?.DeviceId ?? Guid.NewGuid().ToString();
|
||||
var deviceId = currentSession.ClientId;
|
||||
|
||||
// TODO temporary fix due to the server update
|
||||
if (string.IsNullOrEmpty(deviceId)) deviceId = Guid.NewGuid().ToString().Replace("-", "");
|
||||
if (string.IsNullOrEmpty(deviceId))
|
||||
deviceId = Guid.NewGuid().ToString();
|
||||
if (deviceAlt is not null)
|
||||
deviceId = $"{deviceId}+{deviceAlt}";
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ public class NotificationController(
|
||||
|
||||
var result =
|
||||
await nty.SubscribeDevice(
|
||||
currentSession.Challenge.DeviceId,
|
||||
currentSession.ClientId,
|
||||
request.DeviceToken,
|
||||
request.Provider,
|
||||
currentUser
|
||||
@@ -117,7 +117,7 @@ public class NotificationController(
|
||||
var affectedRows = await db.PushSubscriptions
|
||||
.Where(s =>
|
||||
s.AccountId == accountId &&
|
||||
s.DeviceId == currentSession.Challenge.DeviceId
|
||||
s.DeviceId == currentSession.ClientId
|
||||
).ExecuteDeleteAsync();
|
||||
return Ok(affectedRows);
|
||||
}
|
||||
@@ -139,7 +139,7 @@ public class NotificationController(
|
||||
|
||||
[HttpPost("send")]
|
||||
[Authorize]
|
||||
[RequiredPermission("global", "notifications.send")]
|
||||
[AskPermission("notifications.send")]
|
||||
public async Task<ActionResult> SendNotification(
|
||||
[FromBody] NotificationWithAimRequest request,
|
||||
[FromQuery] bool save = false
|
||||
|
||||
@@ -2,6 +2,7 @@ using CorePush.Apple;
|
||||
using CorePush.Firebase;
|
||||
using DysonNetwork.Ring.Connection;
|
||||
using DysonNetwork.Ring.Services;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -17,12 +18,14 @@ public class PushService
|
||||
private readonly ILogger<PushService> _logger;
|
||||
private readonly FirebaseSender? _fcm;
|
||||
private readonly ApnSender? _apns;
|
||||
private readonly FlushBufferService _fbs;
|
||||
private readonly string? _apnsTopic;
|
||||
|
||||
public PushService(
|
||||
IConfiguration config,
|
||||
AppDatabase db,
|
||||
QueueService queueService,
|
||||
FlushBufferService fbs,
|
||||
IHttpClientFactory httpFactory,
|
||||
ILogger<PushService> logger
|
||||
)
|
||||
@@ -52,6 +55,7 @@ public class PushService
|
||||
}
|
||||
|
||||
_db = db;
|
||||
_fbs = fbs;
|
||||
_queueService = queueService;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -144,14 +148,15 @@ public class PushService
|
||||
_ = _queueService.EnqueuePushNotification(notification, Guid.Parse(accountId), save);
|
||||
}
|
||||
|
||||
public async Task DeliverPushNotification(SnNotification notification, CancellationToken cancellationToken = default)
|
||||
public async Task DeliverPushNotification(SnNotification notification,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
WebSocketService.SendPacketToAccount(notification.AccountId, new WebSocketPacket()
|
||||
{
|
||||
Type = "notifications.new",
|
||||
Data = notification,
|
||||
});
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation(
|
||||
@@ -260,7 +265,8 @@ public class PushService
|
||||
await DeliverPushNotification(notification);
|
||||
}
|
||||
|
||||
private async Task SendPushNotificationAsync(SnNotificationPushSubscription subscription, SnNotification notification)
|
||||
private async Task SendPushNotificationAsync(SnNotificationPushSubscription subscription,
|
||||
SnNotification notification)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -302,7 +308,9 @@ public class PushService
|
||||
}
|
||||
});
|
||||
|
||||
if (fcmResult.Error != null)
|
||||
if (fcmResult.StatusCode is 404 or 410)
|
||||
_fbs.Enqueue(new PushSubRemovalRequest { SubId = subscription.Id });
|
||||
else if (fcmResult.Error != null)
|
||||
throw new Exception($"Notification pushed failed ({fcmResult.StatusCode}) {fcmResult.Error}");
|
||||
break;
|
||||
|
||||
@@ -338,7 +346,10 @@ public class PushService
|
||||
apnsPriority: notification.Priority,
|
||||
apnPushType: ApnPushType.Alert
|
||||
);
|
||||
if (apnResult.Error != null)
|
||||
|
||||
if (apnResult.StatusCode is 404 or 410)
|
||||
_fbs.Enqueue(new PushSubRemovalRequest { SubId = subscription.Id });
|
||||
else if (apnResult.Error != null)
|
||||
throw new Exception($"Notification pushed failed ({apnResult.StatusCode}) {apnResult.Error}");
|
||||
|
||||
break;
|
||||
|
||||
@@ -7,7 +7,9 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
builder.AddServiceDefaults("ring");
|
||||
|
||||
builder.Services.Configure<ServiceRegistrationOptions>(opts => { opts.Name = "ring"; });
|
||||
|
||||
// Configure Kestrel and server options
|
||||
builder.ConfigureAppKestrel(builder.Configuration);
|
||||
@@ -16,7 +18,6 @@ builder.ConfigureAppKestrel(builder.Configuration);
|
||||
builder.Services.AddAppServices(builder.Configuration);
|
||||
builder.Services.AddAppAuthentication();
|
||||
builder.Services.AddDysonAuth();
|
||||
builder.Services.AddAccountService();
|
||||
|
||||
builder.Services.AddAppFlushHandlers();
|
||||
builder.Services.AddAppBusinessServices();
|
||||
|
||||
35
DysonNetwork.Ring/Services/PushSubFlushHandler.cs
Normal file
35
DysonNetwork.Ring/Services/PushSubFlushHandler.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Quartz;
|
||||
|
||||
namespace DysonNetwork.Ring.Services;
|
||||
|
||||
public class PushSubRemovalRequest
|
||||
{
|
||||
public Guid SubId { get; set; }
|
||||
}
|
||||
|
||||
public class PushSubFlushHandler(IServiceProvider sp) : IFlushHandler<PushSubRemovalRequest>
|
||||
{
|
||||
public async Task FlushAsync(IReadOnlyList<PushSubRemovalRequest> items)
|
||||
{
|
||||
using var scope = sp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<PushSubFlushHandler>>();
|
||||
|
||||
var tokenIds = items.Select(x => x.SubId).Distinct().ToList();
|
||||
|
||||
var count = await db.PushSubscriptions
|
||||
.Where(s => tokenIds.Contains(s.Id))
|
||||
.ExecuteDeleteAsync();
|
||||
logger.LogInformation("Removed {Count} invalid push notification tokens...", count);
|
||||
}
|
||||
}
|
||||
|
||||
public class PushSubFlushJob(FlushBufferService fbs, PushSubFlushHandler hdl) : IJob
|
||||
{
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
await fbs.FlushAsync(hdl);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using DysonNetwork.Ring.Notification;
|
||||
using DysonNetwork.Ring.Services;
|
||||
using Quartz;
|
||||
|
||||
namespace DysonNetwork.Ring.Startup;
|
||||
@@ -15,6 +15,15 @@ public static class ScheduledJobsConfiguration
|
||||
.ForJob(appDatabaseRecyclingJob)
|
||||
.WithIdentity("AppDatabaseRecyclingTrigger")
|
||||
.WithCronSchedule("0 0 0 * * ?"));
|
||||
|
||||
q.AddJob<PushSubFlushJob>(opts => opts.WithIdentity("PushSubFlush"));
|
||||
q.AddTrigger(opts => opts
|
||||
.ForJob("PushSubFlush")
|
||||
.WithIdentity("PushSubFlushTrigger")
|
||||
.WithSimpleSchedule(o => o
|
||||
.WithIntervalInMinutes(5)
|
||||
.RepeatForever())
|
||||
);
|
||||
});
|
||||
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
|
||||
|
||||
|
||||
@@ -17,9 +17,7 @@ public static class ServiceCollectionExtensions
|
||||
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddDbContext<AppDatabase>();
|
||||
services.AddSingleton<IClock>(SystemClock.Instance);
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddSingleton<ICacheService, CacheServiceRedis>();
|
||||
|
||||
services.AddHttpClient();
|
||||
|
||||
@@ -57,6 +55,7 @@ public static class ServiceCollectionExtensions
|
||||
public static IServiceCollection AddAppFlushHandlers(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<FlushBufferService>();
|
||||
services.AddScoped<PushSubFlushHandler>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -1,47 +1,56 @@
|
||||
{
|
||||
"Debug": true,
|
||||
"BaseUrl": "http://localhost:5212",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Debug": true,
|
||||
"BaseUrl": "http://localhost:5212",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"App": "Host=localhost;Port=5432;Database=dyson_ring;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
|
||||
"Registrar": "127.0.0.1:2379",
|
||||
"Cache": "127.0.0.1:6379",
|
||||
"Queue": "127.0.0.1:4222"
|
||||
},
|
||||
"Notifications": {
|
||||
"Push": {
|
||||
"Production": true,
|
||||
"Google": "./Keys/Solian.json",
|
||||
"Apple": {
|
||||
"PrivateKey": "./Keys/Solian.p8",
|
||||
"PrivateKeyId": "4US4KSX4W6",
|
||||
"TeamId": "W7HPZ53V6B",
|
||||
"BundleIdentifier": "dev.solsynth.solian"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Email": {
|
||||
"Server": "smtp4dev.orb.local",
|
||||
"Port": 25,
|
||||
"UseSsl": false,
|
||||
"Username": "no-reply@mail.solsynth.dev",
|
||||
"Password": "password",
|
||||
"FromAddress": "no-reply@mail.solsynth.dev",
|
||||
"FromName": "Alphabot",
|
||||
"SubjectPrefix": "Solar Network"
|
||||
},
|
||||
"GeoIp": {
|
||||
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
|
||||
},
|
||||
"KnownProxies": [
|
||||
"127.0.0.1",
|
||||
"::1"
|
||||
],
|
||||
"Service": {
|
||||
"Name": "DysonNetwork.Ring",
|
||||
"Url": "https://localhost:7259"
|
||||
},
|
||||
"Cache": {
|
||||
"Serializer": "MessagePack"
|
||||
},
|
||||
"Etcd": {
|
||||
"Insecure": true
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"App": "Host=localhost;Port=5432;Database=dyson_ring;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
||||
},
|
||||
"Notifications": {
|
||||
"Push": {
|
||||
"Production": true,
|
||||
"Google": "./Keys/Solian.json",
|
||||
"Apple": {
|
||||
"PrivateKey": "./Keys/Solian.p8",
|
||||
"PrivateKeyId": "4US4KSX4W6",
|
||||
"TeamId": "W7HPZ53V6B",
|
||||
"BundleIdentifier": "dev.solsynth.solian"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Email": {
|
||||
"Server": "smtp4dev.orb.local",
|
||||
"Port": 25,
|
||||
"UseSsl": false,
|
||||
"Username": "no-reply@mail.solsynth.dev",
|
||||
"Password": "password",
|
||||
"FromAddress": "no-reply@mail.solsynth.dev",
|
||||
"FromName": "Alphabot",
|
||||
"SubjectPrefix": "Solar Network"
|
||||
},
|
||||
"GeoIp": {
|
||||
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
|
||||
},
|
||||
"KnownProxies": ["127.0.0.1", "::1"],
|
||||
"Service": {
|
||||
"Name": "DysonNetwork.Ring",
|
||||
"Url": "https://localhost:7259"
|
||||
},
|
||||
"Etcd": {
|
||||
"Insecure": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ public class DysonTokenAuthHandler(
|
||||
};
|
||||
|
||||
// Add scopes as claims
|
||||
session.Challenge?.Scopes.ToList().ForEach(scope => claims.Add(new Claim("scope", scope)));
|
||||
session.Scopes.ToList().ForEach(scope => claims.Add(new Claim("scope", scope)));
|
||||
|
||||
// Add superuser claim if applicable
|
||||
if (session.Account.IsSuperuser)
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Grpc.Core;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DysonNetwork.Shared.Auth
|
||||
{
|
||||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
|
||||
public class RequiredPermissionAttribute(string area, string key) : Attribute
|
||||
{
|
||||
public string Area { get; set; } = area;
|
||||
public string Key { get; } = key;
|
||||
}
|
||||
|
||||
public class PermissionMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext httpContext, PermissionService.PermissionServiceClient permissionService, ILogger<PermissionMiddleware> logger)
|
||||
{
|
||||
var endpoint = httpContext.GetEndpoint();
|
||||
|
||||
var attr = endpoint?.Metadata
|
||||
.OfType<RequiredPermissionAttribute>()
|
||||
.FirstOrDefault();
|
||||
|
||||
if (attr != null)
|
||||
{
|
||||
if (httpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
{
|
||||
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
await httpContext.Response.WriteAsync("Unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
// Assuming Account proto has a bool field 'is_superuser' which is generated as 'IsSuperuser'
|
||||
if (currentUser.IsSuperuser)
|
||||
{
|
||||
// Bypass the permission check for performance
|
||||
await next(httpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
var actor = $"user:{currentUser.Id}";
|
||||
|
||||
try
|
||||
{
|
||||
var permResp = await permissionService.HasPermissionAsync(new HasPermissionRequest
|
||||
{
|
||||
Actor = actor,
|
||||
Area = attr.Area,
|
||||
Key = attr.Key
|
||||
});
|
||||
|
||||
if (!permResp.HasPermission)
|
||||
{
|
||||
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
await httpContext.Response.WriteAsync($"Permission {attr.Area}/{attr.Key} was required.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (RpcException ex)
|
||||
{
|
||||
logger.LogError(ex, "gRPC call to PermissionService failed while checking permission {Area}/{Key} for actor {Actor}", attr.Area, attr.Key, actor);
|
||||
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
await httpContext.Response.WriteAsync("Error checking permissions.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await next(httpContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
72
DysonNetwork.Shared/Auth/RemotePermissionMiddleware.cs
Normal file
72
DysonNetwork.Shared/Auth/RemotePermissionMiddleware.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Grpc.Core;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DysonNetwork.Shared.Auth;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
|
||||
public class AskPermissionAttribute(string key, PermissionNodeActorType type = PermissionNodeActorType.Account)
|
||||
: Attribute
|
||||
{
|
||||
public string Key { get; } = key;
|
||||
public PermissionNodeActorType Type { get; } = type;
|
||||
}
|
||||
|
||||
public class RemotePermissionMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext httpContext, PermissionService.PermissionServiceClient permissionService,
|
||||
ILogger<RemotePermissionMiddleware> logger)
|
||||
{
|
||||
var endpoint = httpContext.GetEndpoint();
|
||||
|
||||
var attr = endpoint?.Metadata
|
||||
.OfType<AskPermissionAttribute>()
|
||||
.FirstOrDefault();
|
||||
|
||||
if (attr != null)
|
||||
{
|
||||
if (httpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
{
|
||||
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
await httpContext.Response.WriteAsync("Unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
// Superuser will bypass all the permission check
|
||||
if (currentUser.IsSuperuser)
|
||||
{
|
||||
await next(httpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var permResp = await permissionService.HasPermissionAsync(new HasPermissionRequest
|
||||
{
|
||||
Actor = currentUser.Id,
|
||||
Key = attr.Key
|
||||
});
|
||||
|
||||
if (!permResp.HasPermission)
|
||||
{
|
||||
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
await httpContext.Response.WriteAsync($"Permission {attr.Key} was required.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (RpcException ex)
|
||||
{
|
||||
logger.LogError(ex,
|
||||
"gRPC call to PermissionService failed while checking permission {Key} for actor {Actor}", attr.Key,
|
||||
currentUser.Id
|
||||
);
|
||||
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
await httpContext.Response.WriteAsync("Error checking permissions.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await next(httpContext);
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,6 @@ public static class DysonAuthStartup
|
||||
this IServiceCollection services
|
||||
)
|
||||
{
|
||||
services.AddAuthService();
|
||||
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = AuthConstants.SchemeName;
|
||||
|
||||
@@ -1,396 +1,201 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.SystemTextJson;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using RedLockNet;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace DysonNetwork.Shared.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a distributed lock that can be used to synchronize access across multiple processes
|
||||
/// </summary>
|
||||
public interface IDistributedLock : IAsyncDisposable
|
||||
public class CacheServiceRedis(
|
||||
IDistributedCache cache,
|
||||
IConnectionMultiplexer redis,
|
||||
ICacheSerializer serializer,
|
||||
IDistributedLockFactory lockFactory
|
||||
)
|
||||
: ICacheService
|
||||
{
|
||||
/// <summary>
|
||||
/// The resource identifier this lock is protecting
|
||||
/// </summary>
|
||||
string Resource { get; }
|
||||
private const string GlobalKeyPrefix = "dyson:";
|
||||
private const string GroupKeyPrefix = GlobalKeyPrefix + "cg:";
|
||||
private const string LockKeyPrefix = GlobalKeyPrefix + "lock:";
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier for this lock instance
|
||||
/// </summary>
|
||||
string LockId { get; }
|
||||
private static string Normalize(string key) => $"{GlobalKeyPrefix}{key}";
|
||||
|
||||
/// <summary>
|
||||
/// Extends the lock's expiration time
|
||||
/// </summary>
|
||||
Task<bool> ExtendAsync(TimeSpan timeSpan);
|
||||
|
||||
/// <summary>
|
||||
/// Releases the lock immediately
|
||||
/// </summary>
|
||||
Task ReleaseAsync();
|
||||
}
|
||||
|
||||
public interface ICacheService
|
||||
{
|
||||
/// <summary>
|
||||
/// Sets a value in the cache with an optional expiration time
|
||||
/// </summary>
|
||||
Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value from the cache
|
||||
/// </summary>
|
||||
Task<T?> GetAsync<T>(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Get a value from the cache with the found status
|
||||
/// </summary>
|
||||
Task<(bool found, T? value)> GetAsyncWithStatus<T>(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a specific key from the cache
|
||||
/// </summary>
|
||||
Task<bool> RemoveAsync(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a key to a group for group-based operations
|
||||
/// </summary>
|
||||
Task AddToGroupAsync(string key, string group);
|
||||
|
||||
/// <summary>
|
||||
/// Removes all keys associated with a specific group
|
||||
/// </summary>
|
||||
Task RemoveGroupAsync(string group);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all keys belonging to a specific group
|
||||
/// </summary>
|
||||
Task<IEnumerable<string>> GetGroupKeysAsync(string group);
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to set a value in cache and associate it with multiple groups in one operation
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of value being cached</typeparam>
|
||||
/// <param name="key">Cache key</param>
|
||||
/// <param name="value">The value to cache</param>
|
||||
/// <param name="groups">Optional collection of group names to associate the key with</param>
|
||||
/// <param name="expiry">Optional expiration time for the cached item</param>
|
||||
/// <returns>True if the set operation was successful</returns>
|
||||
Task<bool> SetWithGroupsAsync<T>(string key, T value, IEnumerable<string>? groups = null, TimeSpan? expiry = null);
|
||||
|
||||
/// <summary>
|
||||
/// Acquires a distributed lock on the specified resource
|
||||
/// </summary>
|
||||
/// <param name="resource">The resource identifier to lock</param>
|
||||
/// <param name="expiry">How long the lock should be held before automatically expiring</param>
|
||||
/// <param name="waitTime">How long to wait for the lock before giving up</param>
|
||||
/// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
|
||||
/// <returns>A distributed lock instance if acquired, null otherwise</returns>
|
||||
Task<IDistributedLock?> AcquireLockAsync(string resource, TimeSpan expiry, TimeSpan? waitTime = null,
|
||||
TimeSpan? retryInterval = null);
|
||||
|
||||
/// <summary>
|
||||
/// Executes an action with a distributed lock, ensuring the lock is properly released afterwards
|
||||
/// </summary>
|
||||
/// <param name="resource">The resource identifier to lock</param>
|
||||
/// <param name="action">The action to execute while holding the lock</param>
|
||||
/// <param name="expiry">How long the lock should be held before automatically expiring</param>
|
||||
/// <param name="waitTime">How long to wait for the lock before giving up</param>
|
||||
/// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
|
||||
/// <returns>True if the lock was acquired and the action was executed, false otherwise</returns>
|
||||
Task<bool> ExecuteWithLockAsync(string resource, Func<Task> action, TimeSpan expiry, TimeSpan? waitTime = null,
|
||||
TimeSpan? retryInterval = null);
|
||||
|
||||
/// <summary>
|
||||
/// Executes a function with a distributed lock, ensuring the lock is properly released afterwards
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The return type of the function</typeparam>
|
||||
/// <param name="resource">The resource identifier to lock</param>
|
||||
/// <param name="func">The function to execute while holding the lock</param>
|
||||
/// <param name="expiry">How long the lock should be held before automatically expiring</param>
|
||||
/// <param name="waitTime">How long to wait for the lock before giving up</param>
|
||||
/// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
|
||||
/// <returns>The result of the function if the lock was acquired, default(T) otherwise</returns>
|
||||
Task<(bool Acquired, T? Result)> ExecuteWithLockAsync<T>(string resource, Func<Task<T>> func, TimeSpan expiry,
|
||||
TimeSpan? waitTime = null, TimeSpan? retryInterval = null);
|
||||
}
|
||||
|
||||
public class RedisDistributedLock : IDistributedLock
|
||||
{
|
||||
private readonly IDatabase _database;
|
||||
private bool _disposed;
|
||||
|
||||
public string Resource { get; }
|
||||
public string LockId { get; }
|
||||
|
||||
internal RedisDistributedLock(IDatabase database, string resource, string lockId)
|
||||
{
|
||||
_database = database;
|
||||
Resource = resource;
|
||||
LockId = lockId;
|
||||
}
|
||||
|
||||
public async Task<bool> ExtendAsync(TimeSpan timeSpan)
|
||||
{
|
||||
if (_disposed)
|
||||
throw new ObjectDisposedException(nameof(RedisDistributedLock));
|
||||
|
||||
var script = @"
|
||||
if redis.call('get', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('pexpire', KEYS[1], ARGV[2])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
";
|
||||
|
||||
var result = await _database.ScriptEvaluateAsync(
|
||||
script,
|
||||
[$"{CacheServiceRedis.LockKeyPrefix}{Resource}"],
|
||||
[LockId, (long)timeSpan.TotalMilliseconds]
|
||||
);
|
||||
|
||||
return (long)result! == 1;
|
||||
}
|
||||
|
||||
public async Task ReleaseAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
var script = @"
|
||||
if redis.call('get', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('del', KEYS[1])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
";
|
||||
|
||||
await _database.ScriptEvaluateAsync(
|
||||
script,
|
||||
[$"{CacheServiceRedis.LockKeyPrefix}{Resource}"],
|
||||
[LockId]
|
||||
);
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await ReleaseAsync();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
public class CacheServiceRedis : ICacheService
|
||||
{
|
||||
private readonly IDatabase _database;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
// Global prefix for all cache keys
|
||||
public const string GlobalKeyPrefix = "dyson:";
|
||||
|
||||
// Using prefixes for different types of keys
|
||||
public const string GroupKeyPrefix = GlobalKeyPrefix + "cg:";
|
||||
public const string LockKeyPrefix = GlobalKeyPrefix + "lock:";
|
||||
|
||||
public CacheServiceRedis(IConnectionMultiplexer redis)
|
||||
{
|
||||
var rds = redis ?? throw new ArgumentNullException(nameof(redis));
|
||||
_database = rds.GetDatabase();
|
||||
|
||||
// Configure System.Text.Json with proper NodaTime serialization
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
TypeInfoResolver = new DefaultJsonTypeInfoResolver
|
||||
{
|
||||
Modifiers = { JsonExtensions.UnignoreAllProperties() },
|
||||
},
|
||||
ReferenceHandler = ReferenceHandler.Preserve,
|
||||
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
|
||||
Converters = { new ByteStringConverter() }
|
||||
};
|
||||
_jsonOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
|
||||
_jsonOptions.PropertyNameCaseInsensitive = true;
|
||||
}
|
||||
// -----------------------------------------------------
|
||||
// BASIC OPERATIONS
|
||||
// -----------------------------------------------------
|
||||
|
||||
public async Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null)
|
||||
{
|
||||
key = $"{GlobalKeyPrefix}{key}";
|
||||
if (string.IsNullOrEmpty(key))
|
||||
throw new ArgumentException("Key cannot be null or empty", nameof(key));
|
||||
key = Normalize(key);
|
||||
|
||||
var serializedValue = JsonSerializer.Serialize(value, _jsonOptions);
|
||||
return await _database.StringSetAsync(key, serializedValue, expiry);
|
||||
var json = serializer.Serialize(value);
|
||||
|
||||
var options = new DistributedCacheEntryOptions();
|
||||
if (expiry.HasValue)
|
||||
options.SetAbsoluteExpiration(expiry.Value);
|
||||
|
||||
await cache.SetStringAsync(key, json, options);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<T?> GetAsync<T>(string key)
|
||||
{
|
||||
key = $"{GlobalKeyPrefix}{key}";
|
||||
if (string.IsNullOrEmpty(key))
|
||||
throw new ArgumentException("Key cannot be null or empty", nameof(key));
|
||||
key = Normalize(key);
|
||||
|
||||
var value = await _database.StringGetAsync(key);
|
||||
var json = await cache.GetStringAsync(key);
|
||||
if (json is null)
|
||||
return default;
|
||||
|
||||
return value.IsNullOrEmpty ? default :
|
||||
// For NodaTime serialization, use the configured JSON options
|
||||
JsonSerializer.Deserialize<T>(value.ToString(), _jsonOptions);
|
||||
return serializer.Deserialize<T>(json);
|
||||
}
|
||||
|
||||
public async Task<(bool found, T? value)> GetAsyncWithStatus<T>(string key)
|
||||
{
|
||||
key = $"{GlobalKeyPrefix}{key}";
|
||||
if (string.IsNullOrEmpty(key))
|
||||
throw new ArgumentException("Key cannot be null or empty", nameof(key));
|
||||
key = Normalize(key);
|
||||
|
||||
var value = await _database.StringGetAsync(key);
|
||||
var json = await cache.GetStringAsync(key);
|
||||
if (json is null)
|
||||
return (false, default);
|
||||
|
||||
return value.IsNullOrEmpty ? (false, default) :
|
||||
// For NodaTime serialization, use the configured JSON options
|
||||
(true, JsonSerializer.Deserialize<T>(value!.ToString(), _jsonOptions));
|
||||
return (true, serializer.Deserialize<T>(json));
|
||||
}
|
||||
|
||||
public async Task<bool> RemoveAsync(string key)
|
||||
{
|
||||
key = $"{GlobalKeyPrefix}{key}";
|
||||
if (string.IsNullOrEmpty(key))
|
||||
throw new ArgumentException("Key cannot be null or empty", nameof(key));
|
||||
key = Normalize(key);
|
||||
|
||||
// Before removing the key, find all groups it belongs to and remove it from them
|
||||
var script = @"
|
||||
local groups = redis.call('KEYS', ARGV[1])
|
||||
for _, group in ipairs(groups) do
|
||||
redis.call('SREM', group, ARGV[2])
|
||||
end
|
||||
return redis.call('DEL', ARGV[2])
|
||||
";
|
||||
// Remove key from all groups
|
||||
var db = redis.GetDatabase();
|
||||
|
||||
var result = await _database.ScriptEvaluateAsync(
|
||||
script,
|
||||
values: [$"{GroupKeyPrefix}*", key]
|
||||
);
|
||||
var groupPattern = $"{GroupKeyPrefix}*";
|
||||
var server = redis.GetServers().First();
|
||||
|
||||
return (long)result! > 0;
|
||||
var groups = server.Keys(pattern: groupPattern);
|
||||
foreach (var group in groups)
|
||||
{
|
||||
await db.SetRemoveAsync(group, key);
|
||||
}
|
||||
|
||||
await cache.RemoveAsync(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------
|
||||
// GROUP OPERATIONS
|
||||
// -----------------------------------------------------
|
||||
|
||||
public async Task AddToGroupAsync(string key, string group)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key))
|
||||
throw new ArgumentException(@"Key cannot be null or empty.", nameof(key));
|
||||
|
||||
if (string.IsNullOrEmpty(group))
|
||||
throw new ArgumentException(@"Group cannot be null or empty.", nameof(group));
|
||||
|
||||
key = Normalize(key);
|
||||
var db = redis.GetDatabase();
|
||||
var groupKey = $"{GroupKeyPrefix}{group}";
|
||||
key = $"{GlobalKeyPrefix}{key}";
|
||||
await _database.SetAddAsync(groupKey, key);
|
||||
await db.SetAddAsync(groupKey, key);
|
||||
}
|
||||
|
||||
public async Task RemoveGroupAsync(string group)
|
||||
{
|
||||
if (string.IsNullOrEmpty(group))
|
||||
throw new ArgumentException(@"Group cannot be null or empty.", nameof(group));
|
||||
|
||||
var groupKey = $"{GroupKeyPrefix}{group}";
|
||||
var db = redis.GetDatabase();
|
||||
|
||||
// Get all keys in the group
|
||||
var keys = await _database.SetMembersAsync(groupKey);
|
||||
var keys = await db.SetMembersAsync(groupKey);
|
||||
|
||||
if (keys.Length > 0)
|
||||
{
|
||||
// Delete all the keys
|
||||
var keysTasks = keys.Select(key => _database.KeyDeleteAsync(key.ToString()));
|
||||
await Task.WhenAll(keysTasks);
|
||||
foreach (var key in keys)
|
||||
await cache.RemoveAsync(key.ToString());
|
||||
}
|
||||
|
||||
// Delete the group itself
|
||||
await _database.KeyDeleteAsync(groupKey);
|
||||
await db.KeyDeleteAsync(groupKey);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<string>> GetGroupKeysAsync(string group)
|
||||
{
|
||||
if (string.IsNullOrEmpty(group))
|
||||
throw new ArgumentException("Group cannot be null or empty.", nameof(group));
|
||||
|
||||
var groupKey = string.Concat(GroupKeyPrefix, group);
|
||||
var members = await _database.SetMembersAsync(groupKey);
|
||||
|
||||
return members.Select(m => m.ToString());
|
||||
var groupKey = $"{GroupKeyPrefix}{group}";
|
||||
var db = redis.GetDatabase();
|
||||
var members = await db.SetMembersAsync(groupKey);
|
||||
return members.Select(x => x.ToString());
|
||||
}
|
||||
|
||||
public async Task<bool> SetWithGroupsAsync<T>(string key, T value, IEnumerable<string>? groups = null,
|
||||
public async Task<bool> SetWithGroupsAsync<T>(
|
||||
string key,
|
||||
T value,
|
||||
IEnumerable<string>? groups = null,
|
||||
TimeSpan? expiry = null)
|
||||
{
|
||||
// First, set the value in the cache
|
||||
var setResult = await SetAsync(key, value, expiry);
|
||||
var result = await SetAsync(key, value, expiry);
|
||||
if (!result || groups == null)
|
||||
return result;
|
||||
|
||||
// If successful and there are groups to associate, add the key to each group
|
||||
if (!setResult || groups == null) return setResult;
|
||||
var groupsArray = groups.Where(g => !string.IsNullOrEmpty(g)).ToArray();
|
||||
if (groupsArray.Length <= 0) return setResult;
|
||||
var tasks = groupsArray.Select(group => AddToGroupAsync(key, group));
|
||||
var tasks = groups.Select(g => AddToGroupAsync(key, g));
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
return setResult;
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IDistributedLock?> AcquireLockAsync(string resource, TimeSpan expiry, TimeSpan? waitTime = null,
|
||||
// -----------------------------------------------------
|
||||
// DISTRIBUTED LOCK (RedLock wrapper)
|
||||
// -----------------------------------------------------
|
||||
|
||||
private readonly TimeSpan _defaultRetry = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
public async Task<IDistributedLock?> AcquireLockAsync(
|
||||
string resource,
|
||||
TimeSpan expiry,
|
||||
TimeSpan? waitTime = null,
|
||||
TimeSpan? retryInterval = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(resource))
|
||||
throw new ArgumentException("Resource cannot be null or empty", nameof(resource));
|
||||
if (string.IsNullOrWhiteSpace(resource))
|
||||
throw new ArgumentException("Resource cannot be null", nameof(resource));
|
||||
|
||||
var lockKey = $"{LockKeyPrefix}{resource}";
|
||||
var lockId = Guid.NewGuid().ToString("N");
|
||||
var waitTimeSpan = waitTime ?? TimeSpan.Zero;
|
||||
var retryIntervalSpan = retryInterval ?? TimeSpan.FromMilliseconds(100);
|
||||
var redlock = await lockFactory.CreateLockAsync(
|
||||
lockKey,
|
||||
expiry,
|
||||
waitTime ?? TimeSpan.Zero,
|
||||
retryInterval ?? _defaultRetry
|
||||
);
|
||||
|
||||
var startTime = DateTime.UtcNow;
|
||||
var acquired = false;
|
||||
|
||||
// Try to acquire the lock, retry until waitTime is exceeded
|
||||
while (!acquired && (DateTime.UtcNow - startTime) < waitTimeSpan)
|
||||
{
|
||||
acquired = await _database.StringSetAsync(lockKey, lockId, expiry, When.NotExists);
|
||||
|
||||
if (!acquired)
|
||||
{
|
||||
await Task.Delay(retryIntervalSpan);
|
||||
}
|
||||
}
|
||||
|
||||
if (!acquired)
|
||||
{
|
||||
return null; // Could not acquire the lock within the wait time
|
||||
}
|
||||
|
||||
return new RedisDistributedLock(_database, resource, lockId);
|
||||
return !redlock.IsAcquired ? null : new RedLockAdapter(redlock, resource);
|
||||
}
|
||||
|
||||
public async Task<bool> ExecuteWithLockAsync(string resource, Func<Task> action, TimeSpan expiry,
|
||||
TimeSpan? waitTime = null, TimeSpan? retryInterval = null)
|
||||
public async Task<bool> ExecuteWithLockAsync(
|
||||
string resource,
|
||||
Func<Task> action,
|
||||
TimeSpan expiry,
|
||||
TimeSpan? waitTime = null,
|
||||
TimeSpan? retryInterval = null)
|
||||
{
|
||||
await using var lockObj = await AcquireLockAsync(resource, expiry, waitTime, retryInterval);
|
||||
|
||||
if (lockObj == null)
|
||||
return false; // Could not acquire the lock
|
||||
await using var l = await AcquireLockAsync(resource, expiry, waitTime, retryInterval);
|
||||
if (l is null)
|
||||
return false;
|
||||
|
||||
await action();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<(bool Acquired, T? Result)> ExecuteWithLockAsync<T>(string resource, Func<Task<T>> func,
|
||||
TimeSpan expiry, TimeSpan? waitTime = null, TimeSpan? retryInterval = null)
|
||||
public async Task<(bool Acquired, T? Result)> ExecuteWithLockAsync<T>(
|
||||
string resource,
|
||||
Func<Task<T>> func,
|
||||
TimeSpan expiry,
|
||||
TimeSpan? waitTime = null,
|
||||
TimeSpan? retryInterval = null)
|
||||
{
|
||||
await using var lockObj = await AcquireLockAsync(resource, expiry, waitTime, retryInterval);
|
||||
|
||||
if (lockObj == null)
|
||||
return (false, default); // Could not acquire the lock
|
||||
await using var l = await AcquireLockAsync(resource, expiry, waitTime, retryInterval);
|
||||
if (l is null)
|
||||
return (false, default);
|
||||
|
||||
var result = await func();
|
||||
return (true, result);
|
||||
}
|
||||
}
|
||||
|
||||
public class RedLockAdapter(IRedLock inner, string resource) : IDistributedLock
|
||||
{
|
||||
public string Resource { get; } = resource;
|
||||
public string LockId => inner.LockId;
|
||||
|
||||
public ValueTask ReleaseAsync() => inner.DisposeAsync();
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await inner.DisposeAsync();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
7
DysonNetwork.Shared/Cache/ICacheSerializer.cs
Normal file
7
DysonNetwork.Shared/Cache/ICacheSerializer.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace DysonNetwork.Shared.Cache;
|
||||
|
||||
public interface ICacheSerializer
|
||||
{
|
||||
string Serialize<T>(T value);
|
||||
T? Deserialize<T>(string data);
|
||||
}
|
||||
86
DysonNetwork.Shared/Cache/ICacheService.cs
Normal file
86
DysonNetwork.Shared/Cache/ICacheService.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
namespace DysonNetwork.Shared.Cache;
|
||||
|
||||
public interface ICacheService
|
||||
{
|
||||
/// <summary>
|
||||
/// Sets a value in the cache with an optional expiration time
|
||||
/// </summary>
|
||||
Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value from the cache
|
||||
/// </summary>
|
||||
Task<T?> GetAsync<T>(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Get a value from the cache with the found status
|
||||
/// </summary>
|
||||
Task<(bool found, T? value)> GetAsyncWithStatus<T>(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a specific key from the cache
|
||||
/// </summary>
|
||||
Task<bool> RemoveAsync(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a key to a group for group-based operations
|
||||
/// </summary>
|
||||
Task AddToGroupAsync(string key, string group);
|
||||
|
||||
/// <summary>
|
||||
/// Removes all keys associated with a specific group
|
||||
/// </summary>
|
||||
Task RemoveGroupAsync(string group);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all keys belonging to a specific group
|
||||
/// </summary>
|
||||
Task<IEnumerable<string>> GetGroupKeysAsync(string group);
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to set a value in cache and associate it with multiple groups in one operation
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of value being cached</typeparam>
|
||||
/// <param name="key">Cache key</param>
|
||||
/// <param name="value">The value to cache</param>
|
||||
/// <param name="groups">Optional collection of group names to associate the key with</param>
|
||||
/// <param name="expiry">Optional expiration time for the cached item</param>
|
||||
/// <returns>True if the set operation was successful</returns>
|
||||
Task<bool> SetWithGroupsAsync<T>(string key, T value, IEnumerable<string>? groups = null, TimeSpan? expiry = null);
|
||||
|
||||
/// <summary>
|
||||
/// Acquires a distributed lock on the specified resource
|
||||
/// </summary>
|
||||
/// <param name="resource">The resource identifier to lock</param>
|
||||
/// <param name="expiry">How long the lock should be held before automatically expiring</param>
|
||||
/// <param name="waitTime">How long to wait for the lock before giving up</param>
|
||||
/// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
|
||||
/// <returns>A distributed lock instance if acquired, null otherwise</returns>
|
||||
Task<IDistributedLock?> AcquireLockAsync(string resource, TimeSpan expiry, TimeSpan? waitTime = null,
|
||||
TimeSpan? retryInterval = null);
|
||||
|
||||
/// <summary>
|
||||
/// Executes an action with a distributed lock, ensuring the lock is properly released afterwards
|
||||
/// </summary>
|
||||
/// <param name="resource">The resource identifier to lock</param>
|
||||
/// <param name="action">The action to execute while holding the lock</param>
|
||||
/// <param name="expiry">How long the lock should be held before automatically expiring</param>
|
||||
/// <param name="waitTime">How long to wait for the lock before giving up</param>
|
||||
/// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
|
||||
/// <returns>True if the lock was acquired and the action was executed, false otherwise</returns>
|
||||
Task<bool> ExecuteWithLockAsync(string resource, Func<Task> action, TimeSpan expiry, TimeSpan? waitTime = null,
|
||||
TimeSpan? retryInterval = null);
|
||||
|
||||
/// <summary>
|
||||
/// Executes a function with a distributed lock, ensuring the lock is properly released afterwards
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The return type of the function</typeparam>
|
||||
/// <param name="resource">The resource identifier to lock</param>
|
||||
/// <param name="func">The function to execute while holding the lock</param>
|
||||
/// <param name="expiry">How long the lock should be held before automatically expiring</param>
|
||||
/// <param name="waitTime">How long to wait for the lock before giving up</param>
|
||||
/// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
|
||||
/// <returns>The result of the function if the lock was acquired, default(T) otherwise</returns>
|
||||
Task<(bool Acquired, T? Result)> ExecuteWithLockAsync<T>(string resource, Func<Task<T>> func, TimeSpan expiry,
|
||||
TimeSpan? waitTime = null, TimeSpan? retryInterval = null);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user