Compare commits
412 Commits
02af78ca99
...
refactor/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
f6f0703cb3
|
|||
|
3d47b4e44e
|
|||
|
71fe2a30e7
|
|||
|
d8f57161ae
|
|||
|
3caa79b9a7
|
|||
|
49beb17925
|
|||
|
bd8e13f25d
|
|||
|
1128c9a0ba
|
|||
|
8dfe201afe
|
|||
|
c1016e496a
|
|||
|
091097a858
|
|||
|
5c97733b3e
|
|||
|
4ee387ab76
|
|||
|
19bf17200d
|
|||
|
be6d97ec85
|
|||
|
9d282b26f3
|
|||
|
dbc2c54ab0
|
|||
|
aa062932cf
|
|||
|
812dd03e85
|
|||
|
06d639a114
|
|||
|
74f51036b1
|
|||
|
8308325b73
|
|||
|
fa7010db3d
|
|||
|
89320fc540
|
|||
|
5ec8d89563
|
|||
|
0eeafb5352
|
|||
|
ab2bdcc7ca
|
|||
|
c2b49e6642
|
|||
|
1a89c48790
|
|||
|
8dddfe77cd
|
|||
|
8e8b011fdd
|
|||
|
abd346bb97
|
|||
|
6386ec8caa
|
|||
|
ad062828ff
|
|||
|
92e4988114
|
|||
|
f9269d7558
|
|||
|
fa01b7027a
|
|||
|
eaa3a9c297
|
|||
|
6cedda9307
|
|||
|
942ca73f8d
|
|||
|
da3f58f2ec
|
|||
|
4a8521d59d
|
|||
|
d7ad84e199
|
|||
|
52430c19a5
|
|||
|
9492b6cac6
|
|||
|
5f324a2348
|
|||
|
7452b14817
|
|||
|
4a27794ccc
|
|||
|
d2f5ba36ab
|
|||
| 0117fdf084 | |||
| 02680d224a | |||
| 68bfdebcbd | |||
| 54907eede1 | |||
| a21d19c3ef | |||
| df732616d5 | |||
| 79a31ae060 | |||
| 6eacfcd8f2 | |||
| 5e328509bd | |||
| 9c078db564 | |||
| ddd109c77c | |||
| 3ee04d0b24 | |||
| 7f110313e9 | |||
| bc2e87c56f | |||
| d7271a2d11 | |||
| c57d65db67 | |||
| edf3aab173 | |||
| 352746a141 | |||
| 216c72ea36 | |||
| d0723b366b | |||
| fb6721cb1b | |||
| 9fcb169c94 | |||
| 572874431d | |||
| f595ac8001 | |||
| 18674e0e1d | |||
| da4c4d3a84 | |||
| aec01b117d | |||
| d299c32e35 | |||
| 344007af66 | |||
| d4de5aeac2 | |||
| 8ce5ba50f4 | |||
| 5a44952b27 | |||
| c30946daf6 | |||
| 0221d7b294 | |||
| c44b0b64c3 | |||
| 442ee3bcfd | |||
| 081815c512 | |||
| eab2a388ae | |||
| 5f7ab49abb | |||
| 4ff89173b2 | |||
| f2052410c7 | |||
| 83a49be725 | |||
| 9b205a73fd | |||
| d5157eb7e3 | |||
| 75c92c51db | |||
| 915054fce0 | |||
| 63653680ba | |||
| 84c4df6620 | |||
| 8c748fd57a | |||
| 4684550ebf | |||
| 51db08f374 | |||
| 9f38a288b9 | |||
| 75a975049c | |||
| f8c35c0350 | |||
| d9a5fed77f | |||
| 7cb14940d9 | |||
| 953bf5d4de | |||
| d9620fd6a4 | |||
| 541e2dd14c | |||
| c7925d98c8 | |||
| f759b19bcb | |||
| 5d7429a416 | |||
| fb7e52d6f3 | |||
| 50e888b075 | |||
| 76c8bbf307 | |||
| 8f3825e92c | |||
| d1c3610ec8 | |||
| 4b958a3c31 | |||
| 1f9021d459 | |||
| 7ad9deaf70 | |||
| c1c17b5f4e | |||
| d92220b4bc | |||
| 4d1972bc99 | |||
| 83c052ec4e | |||
| 57a75fe9e6 | |||
| 379bc37aff | |||
| 0217fbb13b | |||
| 4e9943e6a2 | |||
| b3cc623168 | |||
| 3ee5e5367d | |||
| 85fef30c7f | |||
| e8d8dcbb2d | |||
| 3b679d6134 | |||
| ec44b51ab6 | |||
| 2e52a13c30 | |||
| 1e8e2e9ea7 | |||
| 9e8363c004 | |||
| 56c40ee001 | |||
| e3dfccfee3 | |||
| d555fcaf17 | |||
| 2fdefae718 | |||
| e78858b7b4 | |||
| 636b674229 | |||
| fc6cee17d7 | |||
| 7f7b47fb1c | |||
| bf181b88ec | |||
| c056938b6e | |||
| 66eadf96b0 | |||
| 665595b8b4 | |||
| 29550401fd | |||
| 1bb0012c40 | |||
| 2cea391ebf | |||
| 32e91da0b2 | |||
| 69b56b9658 | |||
| 83e3d77f79 | |||
| 38a8eecd50 | |||
| bd77137714 | |||
| 201126e5d0 | |||
| d4a2e5ef5b | |||
| 2761abf405 | |||
| add16ffdad | |||
| b49cd1c382 | |||
| aa9ae5c11e | |||
| 8e8965eb3d | |||
| a0fe8fd0f0 | |||
| 855031a4fe | |||
| adc2b20aeb | |||
| c860f10cf9 | |||
| d441eff2d2 | |||
| d31f36d3dc | |||
| 4fc7bd47f9 | |||
| a66037d947 | |||
| bb4e04df0b | |||
| d3752caf1d | |||
| 614c77d7ce | |||
| 5d13f08d47 | |||
| 07ba148d9b | |||
| 917e2d5393 | |||
| e384763faf | |||
| 7fb199b187 | |||
| 924e31aad5 | |||
| 48f776e6ff | |||
| a27bda4720 | |||
| a7e0e1e369 | |||
| 5bb5018cc0 | |||
| a9aab6b7e5 | |||
| 651c06caac | |||
| e0d58085f3 | |||
| cb420c2262 | |||
| 6211f546b1 | |||
| 9070fe7fa3 | |||
| c86d7275ec | |||
| 9e1178b7a1 | |||
| cd76cedb7b | |||
| f273445451 | |||
| 740d9a33cf | |||
| 792d703b6f | |||
| f09832404d | |||
| 134b11e7f0 | |||
| 8c01ec364c | |||
| 27e6dde7c4 | |||
| b04b17c8ae | |||
| b037ecad79 | |||
| 7ec3f25d43 | |||
| 1778ab112d | |||
| 5f70d53c94 | |||
| 4b66e97bda | |||
| f8d8e485f1 | |||
| e21bf531e1 | |||
| 76fdf14e79 | |||
| 96cceafe77 | |||
| 58e34b20e1 | |||
|
|
e420b183ce | ||
|
|
a08f058806 | ||
| 616491e6d8 | |||
| 05c6d67c03 | |||
| e66130e893 | |||
| 5bb9bbac73 | |||
| 8474fc7160 | |||
| ea8158cb50 | |||
| 65398c5fec | |||
| 5181897463 | |||
| 96c7927632 | |||
| 0eb3ffcdbe | |||
|
|
736db75cfd | ||
| 0b44c4547c | |||
|
|
728ac9c166 | ||
| 360b58885e | |||
| 09d412053f | |||
| e0107f189d | |||
| 42af09034c | |||
| 963470b693 | |||
| da57936d92 | |||
| 78cec27ef0 | |||
| c3f5ed881f | |||
| 1c52b4d661 | |||
| 765be4f214 | |||
| 91de6797c5 | |||
| 4bceb119ea | |||
| 14a5c01a6d | |||
| 83df727f8f | |||
| 3444e27a96 | |||
| 865505f883 | |||
| 0ed47be689 | |||
| d8c1c63e56 | |||
| 2934225a6c | |||
|
|
d1e5058dae | ||
|
|
cbd58d3e72 | ||
|
|
735268fe46 | ||
| 7ddb904335 | |||
| c514adfbbf | |||
| a32c06552f | |||
|
|
aefc1aeb4f | ||
|
|
7fc36b5d22 | ||
| 5fd52e7b9e | |||
| e7d14d4687 | |||
| a57ae840ff | |||
| 009621a456 | |||
| 36ed0dc893 | |||
| 8a1c490907 | |||
| 32054705d0 | |||
| 5859483654 | |||
| d0ca8db162 | |||
| a3e138cc2d | |||
| 1fab398778 | |||
| 77ccc9aeb5 | |||
| a6dfe8712c | |||
| 973b2f81ea | |||
| 554f73b550 | |||
| ee8e9df12e | |||
| 00cdd1bc5d | |||
| f1ea7c1c5a | |||
| d13e18534f | |||
| 1dc33c5bd4 | |||
| e09922c8df | |||
| e85af628bf | |||
| 4f2e18ca27 | |||
| 1105d6f11e | |||
| f2bba64ee5 | |||
| ebbe14f293 | |||
| 681934a0dc | |||
| a52b09b787 | |||
| b0af3af059 | |||
| 6bc5bcfd1a | |||
| 999ba52003 | |||
| e0ebed7c09 | |||
| e50ce2f515 | |||
| 5bb9ed5f04 | |||
| 4a36557714 | |||
| 1a93cdad46 | |||
| 2bbef9b9d1 | |||
| 22101c8280 | |||
| 256c6469a6 | |||
| 7367f372c0 | |||
| 822a339532 | |||
| 5d2ad2479b | |||
| 795ca04d7c | |||
| 111701a2c4 | |||
| a793a03a20 | |||
| d231b5f27e | |||
| 709dc44d57 | |||
| d7a39ab574 | |||
| 18882c08d9 | |||
| ce6f9a174f | |||
| f5c8b75122 | |||
| 165d2e4d93 | |||
| 9e9d0dc563 | |||
| a9a5082e1a | |||
| eca9601a89 | |||
| 6bfe784b3f | |||
| 6524a56eeb | |||
| b7f853d84f | |||
| 473155b68d | |||
| 608b93fb61 | |||
| 4a36b30d6b | |||
| 72b26c6a2c | |||
| 7fc86441d1 | |||
| 1a05f16299 | |||
| db5d631049 | |||
| 2d7dd26882 | |||
| b0834f48d4 | |||
| 7d3236550c | |||
| adf62fb42b | |||
| 14c6913af7 | |||
| 192ea0fcdd | |||
| 189abd4982 | |||
| 3df66dabd9 | |||
| f46f70b33c | |||
| e689d15688 | |||
| 3d236c35c9 | |||
| 665538bdd3 | |||
| be7d7536fc | |||
| a932108c87 | |||
| 71eccbb466 | |||
| 700803f7a6 | |||
| 1f38d827c5 | |||
| 8d73c0f289 | |||
| f9884e32fb | |||
| 27b6f2022f | |||
| 98b5808b09 | |||
| f4df8c0c3b | |||
| 882c14df06 | |||
| b3ed98322b | |||
| 4cfd4387b6 | |||
| 89406870bd | |||
| c747d03aff | |||
| 77df275ac0 | |||
| d7dcb7221f | |||
| 92a8709df0 | |||
| e3499ff283 | |||
| 0306b54a0f | |||
| 3afbeacffb | |||
| 3e7376c1f7 | |||
| fd81e8389c | |||
| 00dda8faf9 | |||
| 6b1dda41bc | |||
| fd1c47196d | |||
| 7383a5cff8 | |||
| 49fe70b0aa | |||
| 8e6e3e6289 | |||
| cb681681e1 | |||
| 1e25982c08 | |||
| e243b0f47a | |||
| 6f0a42820b | |||
| c1fc6837db | |||
| 51697c31cb | |||
| 409c83b030 | |||
| acb293ec8f | |||
| 162967e68b | |||
| 11266ac69a | |||
| 03b4b7f3b9 | |||
| 2649aeeee8 | |||
| 3e76ef62b3 | |||
| 284cb23d4d | |||
| 24f0d8f151 | |||
| 9d63a3b81c | |||
| f1b594bdf2 | |||
| 1f7b19938b | |||
| 05c6410550 | |||
| 4246fea03f | |||
| 83059374e9 | |||
| 28f6893c68 | |||
| d881a75e48 | |||
| fe5a455b68 | |||
| 0d4473da69 | |||
| f1b62d354f | |||
| 6ef1533abf | |||
| 32f7b0221d | |||
| 8b1bb7fcfd | |||
| e31a5ea017 | |||
| 7442b8416f | |||
| c875c82bdc | |||
| 4a0117906a | |||
| f74b1cf46a | |||
| 52addc91df | |||
| e1ebd44ea8 | |||
| e428e04435 | |||
| b405a46005 | |||
| 4c0e0b5ee9 | |||
| e7e6c258e2 | |||
| 05284760a7 | |||
| 4c0d381be2 | |||
| 42b300fefb | |||
| 0c08bfed5b | |||
| 57c72bdfbf | |||
| 1fd3b39c75 | |||
| f80cabfa75 | |||
| 2d728e4b07 | |||
| 7ff9605460 | |||
| d3bf9739b5 | |||
| 4e68ab4ef0 | |||
| 71accd725e | |||
| 46612b28aa |
3
.aspire/settings.json
Normal file
3
.aspire/settings.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"appHostPath": "../DysonNetwork.Control/DysonNetwork.Control.csproj"
|
||||
}
|
||||
35
.env
Normal file
35
.env
Normal file
@@ -0,0 +1,35 @@
|
||||
# Default container port for ring
|
||||
RING_PORT=8080
|
||||
|
||||
# Default container port for pass
|
||||
PASS_PORT=8080
|
||||
|
||||
# Default container port for drive
|
||||
DRIVE_PORT=8080
|
||||
|
||||
# Default container port for sphere
|
||||
SPHERE_PORT=8080
|
||||
|
||||
# Default container port for develop
|
||||
DEVELOP_PORT=8080
|
||||
|
||||
# Parameter cache-password
|
||||
CACHE_PASSWORD=KS3jSPaU9e
|
||||
|
||||
# Parameter queue-password
|
||||
QUEUE_PASSWORD=8xEECa4ckz
|
||||
|
||||
# Container image name for ring
|
||||
RING_IMAGE=ring:latest
|
||||
|
||||
# Container image name for pass
|
||||
PASS_IMAGE=pass:latest
|
||||
|
||||
# Container image name for drive
|
||||
DRIVE_IMAGE=drive:latest
|
||||
|
||||
# Container image name for sphere
|
||||
SPHERE_IMAGE=sphere:latest
|
||||
|
||||
# Container image name for develop
|
||||
DEVELOP_IMAGE=develop:latest
|
||||
156
.github/workflows/docker-build.yml
vendored
156
.github/workflows/docker-build.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Build and Push Microservices
|
||||
name: Aspire Publish Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -7,132 +7,54 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-sphere:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup NBGV
|
||||
uses: dotnet/nbgv@master
|
||||
id: nbgv
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||
- name: Build and push DysonNetwork.Sphere Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: DysonNetwork.Sphere/Dockerfile
|
||||
context: .
|
||||
push: true
|
||||
tags: xsheep2010/dyson-sphere:latest
|
||||
platforms: linux/amd64
|
||||
|
||||
build-pass:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup NBGV
|
||||
uses: dotnet/nbgv@master
|
||||
id: nbgv
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||
- name: Build and push DysonNetwork.Pass Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: DysonNetwork.Pass/Dockerfile
|
||||
context: .
|
||||
push: true
|
||||
tags: xsheep2010/dyson-pass:latest
|
||||
platforms: linux/amd64
|
||||
dotnet-version: "9.0.x"
|
||||
|
||||
build-pusher:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup NBGV
|
||||
uses: dotnet/nbgv@master
|
||||
id: nbgv
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to DockerHub
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||
- name: Build and push DysonNetwork.Pusher Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: DysonNetwork.Pusher/Dockerfile
|
||||
context: .
|
||||
push: true
|
||||
tags: xsheep2010/dyson-pusher:latest
|
||||
platforms: linux/amd64
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-drive:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup NBGV
|
||||
uses: dotnet/nbgv@master
|
||||
id: nbgv
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||
- name: Build and push DysonNetwork.Drive Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: DysonNetwork.Drive/Dockerfile
|
||||
context: .
|
||||
push: true
|
||||
tags: xsheep2010/dyson-drive:latest
|
||||
platforms: linux/amd64
|
||||
- name: Install Aspire CLI
|
||||
run: dotnet tool install -g Aspire.Cli --prerelease
|
||||
|
||||
build-gateway:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Build and Publish Aspire Application
|
||||
run: aspire publish --project ./DysonNetwork.Control/DysonNetwork.Control.csproj --output publish
|
||||
|
||||
- name: Tag and Push Images
|
||||
run: |
|
||||
IMAGES=( "sphere" "pass" "ring" "drive" "develop" )
|
||||
|
||||
for image in "${IMAGES[@]}"; do
|
||||
IMAGE_NAME="ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-$image:alpha"
|
||||
SOURCE_IMAGE_NAME="$image:latest" # Aspire's default local image name
|
||||
|
||||
echo "Tagging and pushing $SOURCE_IMAGE_NAME to $IMAGE_NAME..."
|
||||
docker tag $SOURCE_IMAGE_NAME $IMAGE_NAME
|
||||
docker push $IMAGE_NAME
|
||||
done
|
||||
|
||||
- name: Upload Aspire Publish Directory
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup NBGV
|
||||
uses: dotnet/nbgv@master
|
||||
id: nbgv
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
name: aspire-publish-output
|
||||
path: ./publish/
|
||||
|
||||
- name: Upload Docker Compose file
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||
- name: Build and push DysonNetwork.Gateway Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: DysonNetwork.Gateway/Dockerfile
|
||||
context: .
|
||||
push: true
|
||||
tags: xsheep2010/dyson-gateway:latest
|
||||
platforms: linux/amd64
|
||||
name: docker-compose-output
|
||||
path: ./publish/docker-compose.yml
|
||||
|
||||
77
DysonNetwork.Control/AppHost.cs
Normal file
77
DysonNetwork.Control/AppHost.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using Aspire.Hosting.Yarp.Transforms;
|
||||
|
||||
var builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
// Database was configured separately in each service.
|
||||
// var database = builder.AddPostgres("database");
|
||||
|
||||
var cache = builder.AddRedis("cache");
|
||||
var queue = builder.AddNats("queue").WithJetStream();
|
||||
|
||||
var ringService = builder.AddProject<Projects.DysonNetwork_Ring>("ring")
|
||||
.WithReference(queue)
|
||||
.WithHttpHealthCheck()
|
||||
.WithEndpoint(5001, 5001, "https", name: "grpc");
|
||||
var passService = builder.AddProject<Projects.DysonNetwork_Pass>("pass")
|
||||
.WithReference(cache)
|
||||
.WithReference(queue)
|
||||
.WithReference(ringService)
|
||||
.WithHttpHealthCheck()
|
||||
.WithEndpoint(5001, 5001, "https", name: "grpc");
|
||||
var driveService = builder.AddProject<Projects.DysonNetwork_Drive>("drive")
|
||||
.WithReference(cache)
|
||||
.WithReference(queue)
|
||||
.WithReference(passService)
|
||||
.WithReference(ringService)
|
||||
.WithHttpHealthCheck()
|
||||
.WithEndpoint(5001, 5001, "https", name: "grpc");
|
||||
var sphereService = builder.AddProject<Projects.DysonNetwork_Sphere>("sphere")
|
||||
.WithReference(cache)
|
||||
.WithReference(queue)
|
||||
.WithReference(passService)
|
||||
.WithReference(ringService)
|
||||
.WithHttpHealthCheck()
|
||||
.WithEndpoint(5001, 5001, "https", name: "grpc");
|
||||
var developService = builder.AddProject<Projects.DysonNetwork_Develop>("develop")
|
||||
.WithReference(cache)
|
||||
.WithReference(passService)
|
||||
.WithReference(ringService)
|
||||
.WithHttpHealthCheck()
|
||||
.WithEndpoint(5001, 5001, "https", name: "grpc");
|
||||
|
||||
// Extra double-ended references
|
||||
ringService.WithReference(passService);
|
||||
|
||||
builder.AddYarp("gateway")
|
||||
.WithHostPort(5000)
|
||||
.WithConfiguration(yarp =>
|
||||
{
|
||||
var ringCluster = yarp.AddCluster(ringService.GetEndpoint("http"));
|
||||
yarp.AddRoute("/ws", ringCluster);
|
||||
yarp.AddRoute("/ring/{**catch-all}", ringCluster)
|
||||
.WithTransformPathRemovePrefix("/ring")
|
||||
.WithTransformPathPrefix("/api");
|
||||
var passCluster = yarp.AddCluster(passService.GetEndpoint("http"));
|
||||
yarp.AddRoute("/.well-known/openid-configuration", passCluster);
|
||||
yarp.AddRoute("/.well-known/jwks", passCluster);
|
||||
yarp.AddRoute("/id/{**catch-all}", passCluster)
|
||||
.WithTransformPathRemovePrefix("/id")
|
||||
.WithTransformPathPrefix("/api");
|
||||
var driveCluster = yarp.AddCluster(driveService.GetEndpoint("http"));
|
||||
yarp.AddRoute("/api/tus", driveCluster);
|
||||
yarp.AddRoute("/drive/{**catch-all}", driveCluster)
|
||||
.WithTransformPathRemovePrefix("/drive")
|
||||
.WithTransformPathPrefix("/api");
|
||||
var sphereCluster = yarp.AddCluster(sphereService.GetEndpoint("http"));
|
||||
yarp.AddRoute("/sphere/{**catch-all}", sphereCluster)
|
||||
.WithTransformPathRemovePrefix("/sphere")
|
||||
.WithTransformPathPrefix("/api");
|
||||
var developCluster = yarp.AddCluster(developService.GetEndpoint("http"));
|
||||
yarp.AddRoute("/develop/{**catch-all}", developCluster)
|
||||
.WithTransformPathRemovePrefix("/develop")
|
||||
.WithTransformPathPrefix("/api");
|
||||
});
|
||||
|
||||
builder.AddDockerComposeEnvironment("docker-compose");
|
||||
|
||||
builder.Build().Run();
|
||||
30
DysonNetwork.Control/DysonNetwork.Control.csproj
Normal file
30
DysonNetwork.Control/DysonNetwork.Control.csproj
Normal file
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<Sdk Name="Aspire.AppHost.Sdk" Version="9.4.2"/>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UserSecretsId>a68b3195-a00d-40c2-b5ed-d675356b7cde</UserSecretsId>
|
||||
<RootNamespace>DysonNetwork.Control</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.4.2"/>
|
||||
<PackageReference Include="Aspire.Hosting.Docker" Version="9.4.2-preview.1.25428.12" />
|
||||
<PackageReference Include="Aspire.Hosting.Nats" Version="9.4.2" />
|
||||
<PackageReference Include="Aspire.Hosting.Redis" Version="9.4.2" />
|
||||
<PackageReference Include="Aspire.Hosting.Yarp" Version="9.4.2-preview.1.25428.12" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DysonNetwork.Develop\DysonNetwork.Develop.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Pass\DysonNetwork.Pass.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Ring\DysonNetwork.Ring.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
29
DysonNetwork.Control/Properties/launchSettings.json
Normal file
29
DysonNetwork.Control/Properties/launchSettings.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:17025;http://localhost:15057",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21175",
|
||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22189"
|
||||
}
|
||||
},
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:15057",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19163",
|
||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20185"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
DysonNetwork.Control/appsettings.json
Normal file
11
DysonNetwork.Control/appsettings.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"cache": "localhost:6379"
|
||||
}
|
||||
}
|
||||
53
DysonNetwork.Develop/AppDatabase.cs
Normal file
53
DysonNetwork.Develop/AppDatabase.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Develop.Identity;
|
||||
using DysonNetwork.Develop.Project;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace DysonNetwork.Develop;
|
||||
|
||||
public class AppDatabase(
|
||||
DbContextOptions<AppDatabase> options,
|
||||
IConfiguration configuration
|
||||
) : DbContext(options)
|
||||
{
|
||||
public DbSet<Developer> Developers { get; set; } = null!;
|
||||
|
||||
public DbSet<DevProject> DevProjects { get; set; } = null!;
|
||||
|
||||
public DbSet<CustomApp> CustomApps { get; set; } = null!;
|
||||
public DbSet<CustomAppSecret> CustomAppSecrets { get; set; } = null!;
|
||||
public DbSet<BotAccount> BotAccounts { get; set; } = null!;
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
optionsBuilder.UseNpgsql(
|
||||
configuration.GetConnectionString("App"),
|
||||
opt => opt
|
||||
.ConfigureDataSource(optSource => optSource.EnableDynamicJson())
|
||||
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
|
||||
.UseNodaTime()
|
||||
).UseSnakeCaseNamingConvention();
|
||||
|
||||
base.OnConfiguring(optionsBuilder);
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
}
|
||||
}
|
||||
|
||||
public class AppDatabaseFactory : IDesignTimeDbContextFactory<AppDatabase>
|
||||
{
|
||||
public AppDatabase CreateDbContext(string[] args)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.SetBasePath(Directory.GetCurrentDirectory())
|
||||
.AddJsonFile("appsettings.json")
|
||||
.Build();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<AppDatabase>();
|
||||
return new AppDatabase(optionsBuilder.Options, configuration);
|
||||
}
|
||||
}
|
||||
23
DysonNetwork.Develop/Dockerfile
Normal file
23
DysonNetwork.Develop/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["DysonNetwork.Develop/DysonNetwork.Develop.csproj", "DysonNetwork.Develop/"]
|
||||
RUN dotnet restore "DysonNetwork.Develop/DysonNetwork.Develop.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/DysonNetwork.Develop"
|
||||
RUN dotnet build "./DysonNetwork.Develop.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "./DysonNetwork.Develop.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "DysonNetwork.Develop.dll"]
|
||||
38
DysonNetwork.Develop/DysonNetwork.Develop.csproj
Normal file
38
DysonNetwork.Develop/DysonNetwork.Develop.csproj
Normal file
@@ -0,0 +1,38 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
|
||||
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3"/>
|
||||
<PackageReference Include="NodaTime" Version="3.2.2"/>
|
||||
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/>
|
||||
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\.dockerignore">
|
||||
<Link>.dockerignore</Link>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
54
DysonNetwork.Develop/Identity/BotAccount.cs
Normal file
54
DysonNetwork.Develop/Identity/BotAccount.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using DysonNetwork.Develop.Project;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
public class BotAccount : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(1024)] public string Slug { get; set; } = null!;
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public Guid ProjectId { get; set; }
|
||||
public DevProject Project { get; set; } = null!;
|
||||
|
||||
[NotMapped] public AccountReference? Account { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// This developer field is to serve the transparent info for user to know which developer
|
||||
/// published this robot. Not for relationships usage.
|
||||
/// </summary>
|
||||
[NotMapped] public Developer? Developer { get; set; }
|
||||
|
||||
public Shared.Proto.BotAccount ToProtoValue()
|
||||
{
|
||||
var proto = new Shared.Proto.BotAccount
|
||||
{
|
||||
Slug = Slug,
|
||||
IsActive = IsActive,
|
||||
AutomatedId = Id.ToString(),
|
||||
CreatedAt = CreatedAt.ToTimestamp(),
|
||||
UpdatedAt = UpdatedAt.ToTimestamp()
|
||||
};
|
||||
|
||||
return proto;
|
||||
}
|
||||
|
||||
public static BotAccount FromProto(Shared.Proto.BotAccount proto)
|
||||
{
|
||||
var botAccount = new BotAccount
|
||||
{
|
||||
Id = Guid.Parse(proto.AutomatedId),
|
||||
Slug = proto.Slug,
|
||||
IsActive = proto.IsActive,
|
||||
CreatedAt = proto.CreatedAt.ToInstant(),
|
||||
UpdatedAt = proto.UpdatedAt.ToInstant()
|
||||
};
|
||||
|
||||
return botAccount;
|
||||
}
|
||||
}
|
||||
460
DysonNetwork.Develop/Identity/BotAccountController.cs
Normal file
460
DysonNetwork.Develop/Identity/BotAccountController.cs
Normal file
@@ -0,0 +1,460 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Develop.Project;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Grpc.Core;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/developers/{pubName}/projects/{projectId:guid}/bots")]
|
||||
[Authorize]
|
||||
public class BotAccountController(
|
||||
BotAccountService botService,
|
||||
DeveloperService developerService,
|
||||
DevProjectService projectService,
|
||||
ILogger<BotAccountController> logger,
|
||||
AccountClientHelper accounts,
|
||||
BotAccountReceiverService.BotAccountReceiverServiceClient accountsReceiver
|
||||
)
|
||||
: ControllerBase
|
||||
{
|
||||
public class CommonBotRequest
|
||||
{
|
||||
[MaxLength(256)] public string? FirstName { get; set; }
|
||||
[MaxLength(256)] public string? MiddleName { get; set; }
|
||||
[MaxLength(256)] public string? LastName { get; set; }
|
||||
[MaxLength(1024)] public string? Gender { get; set; }
|
||||
[MaxLength(1024)] public string? Pronouns { get; set; }
|
||||
[MaxLength(1024)] public string? TimeZone { get; set; }
|
||||
[MaxLength(1024)] public string? Location { get; set; }
|
||||
[MaxLength(4096)] public string? Bio { get; set; }
|
||||
public Instant? Birthday { get; set; }
|
||||
|
||||
[MaxLength(32)] public string? PictureId { get; set; }
|
||||
[MaxLength(32)] public string? BackgroundId { get; set; }
|
||||
}
|
||||
|
||||
public class BotCreateRequest : CommonBotRequest
|
||||
{
|
||||
[Required]
|
||||
[MinLength(2)]
|
||||
[MaxLength(256)]
|
||||
[RegularExpression(@"^[A-Za-z0-9_-]+$",
|
||||
ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.")
|
||||
]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[Required] [MaxLength(256)] public string Nick { get; set; } = string.Empty;
|
||||
|
||||
[Required] [MaxLength(1024)] public string Slug { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(128)] public string Language { get; set; } = "en-us";
|
||||
}
|
||||
|
||||
public class UpdateBotRequest : CommonBotRequest
|
||||
{
|
||||
[MinLength(2)]
|
||||
[MaxLength(256)]
|
||||
[RegularExpression(@"^[A-Za-z0-9_-]+$",
|
||||
ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.")
|
||||
]
|
||||
public string? Name { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(256)] public string? Nick { get; set; } = string.Empty;
|
||||
|
||||
[Required] [MaxLength(1024)] public string? Slug { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(128)] public string? Language { get; set; }
|
||||
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ListBots(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||
PublisherMemberRole.Viewer))
|
||||
return StatusCode(403, "You must be an viewer of the developer to list bots");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var bots = await botService.GetBotsByProjectAsync(projectId);
|
||||
return Ok(await botService.LoadBotsAccountAsync(bots));
|
||||
}
|
||||
|
||||
[HttpGet("{botId:guid}")]
|
||||
public async Task<IActionResult> GetBot(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid botId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||
PublisherMemberRole.Viewer))
|
||||
return StatusCode(403, "You must be an viewer of the developer to view bot details");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var bot = await botService.GetBotByIdAsync(botId);
|
||||
if (bot is null || bot.ProjectId != projectId)
|
||||
return NotFound("Bot not found");
|
||||
|
||||
return Ok(await botService.LoadBotAccountAsync(bot));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CreateBot(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromBody] BotCreateRequest createRequest
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||
PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to create a bot");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var accountId = Guid.NewGuid();
|
||||
var account = new Account()
|
||||
{
|
||||
Id = accountId.ToString(),
|
||||
Name = createRequest.Name,
|
||||
Nick = createRequest.Nick,
|
||||
Language = createRequest.Language,
|
||||
Profile = new AccountProfile()
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
Bio = createRequest.Bio,
|
||||
Gender = createRequest.Gender,
|
||||
FirstName = createRequest.FirstName,
|
||||
MiddleName = createRequest.MiddleName,
|
||||
LastName = createRequest.LastName,
|
||||
TimeZone = createRequest.TimeZone,
|
||||
Pronouns = createRequest.Pronouns,
|
||||
Location = createRequest.Location,
|
||||
Birthday = createRequest.Birthday?.ToTimestamp(),
|
||||
AccountId = accountId.ToString(),
|
||||
CreatedAt = now.ToTimestamp(),
|
||||
UpdatedAt = now.ToTimestamp()
|
||||
},
|
||||
CreatedAt = now.ToTimestamp(),
|
||||
UpdatedAt = now.ToTimestamp()
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var bot = await botService.CreateBotAsync(
|
||||
project,
|
||||
createRequest.Slug,
|
||||
account,
|
||||
createRequest.PictureId,
|
||||
createRequest.BackgroundId
|
||||
);
|
||||
return Ok(bot);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error creating bot account");
|
||||
return StatusCode(500, "An error occurred while creating the bot account");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPatch("{botId:guid}")]
|
||||
public async Task<IActionResult> UpdateBot(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid botId,
|
||||
[FromBody] UpdateBotRequest request
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||
PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to update a bot");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var bot = await botService.GetBotByIdAsync(botId);
|
||||
if (bot is null || bot.ProjectId != projectId)
|
||||
return NotFound("Bot not found");
|
||||
|
||||
var botAccount = await accounts.GetBotAccount(bot.Id);
|
||||
|
||||
if (request.Name is not null) botAccount.Name = request.Name;
|
||||
if (request.Nick is not null) botAccount.Nick = request.Nick;
|
||||
if (request.Language is not null) botAccount.Language = request.Language;
|
||||
if (request.Bio is not null) botAccount.Profile.Bio = request.Bio;
|
||||
if (request.Gender is not null) botAccount.Profile.Gender = request.Gender;
|
||||
if (request.FirstName is not null) botAccount.Profile.FirstName = request.FirstName;
|
||||
if (request.MiddleName is not null) botAccount.Profile.MiddleName = request.MiddleName;
|
||||
if (request.LastName is not null) botAccount.Profile.LastName = request.LastName;
|
||||
if (request.TimeZone is not null) botAccount.Profile.TimeZone = request.TimeZone;
|
||||
if (request.Pronouns is not null) botAccount.Profile.Pronouns = request.Pronouns;
|
||||
if (request.Location is not null) botAccount.Profile.Location = request.Location;
|
||||
if (request.Birthday is not null) botAccount.Profile.Birthday = request.Birthday?.ToTimestamp();
|
||||
|
||||
if (request.Slug is not null) bot.Slug = request.Slug;
|
||||
if (request.IsActive is not null) bot.IsActive = request.IsActive.Value;
|
||||
|
||||
try
|
||||
{
|
||||
var updatedBot = await botService.UpdateBotAsync(
|
||||
bot,
|
||||
botAccount,
|
||||
request.PictureId,
|
||||
request.BackgroundId
|
||||
);
|
||||
|
||||
return Ok(updatedBot);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error updating bot account {BotId}", botId);
|
||||
return StatusCode(500, "An error occurred while updating the bot account");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{botId:guid}")]
|
||||
public async Task<IActionResult> DeleteBot(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid botId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||
PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to delete a bot");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var bot = await botService.GetBotByIdAsync(botId);
|
||||
if (bot is null || bot.ProjectId != projectId)
|
||||
return NotFound("Bot not found");
|
||||
|
||||
try
|
||||
{
|
||||
await botService.DeleteBotAsync(bot);
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error deleting bot {BotId}", botId);
|
||||
return StatusCode(500, "An error occurred while deleting the bot account");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{botId:guid}/keys")]
|
||||
public async Task<ActionResult<List<ApiKeyReference>>> ListBotKeys(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid botId
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Viewer);
|
||||
if (developer == null) return NotFound("Developer not found");
|
||||
if (project == null) return NotFound("Project not found or you don't have access");
|
||||
if (bot == null) return NotFound("Bot not found");
|
||||
|
||||
var keys = await accountsReceiver.ListApiKeyAsync(new ListApiKeyRequest
|
||||
{
|
||||
AutomatedId = bot.Id.ToString()
|
||||
});
|
||||
var data = keys.Data.Select(ApiKeyReference.FromProtoValue).ToList();
|
||||
|
||||
return Ok(data);
|
||||
}
|
||||
|
||||
[HttpGet("{botId:guid}/keys/{keyId:guid}")]
|
||||
public async Task<ActionResult<ApiKeyReference>> GetBotKey(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid botId,
|
||||
[FromRoute] Guid keyId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Viewer);
|
||||
if (developer == null) return NotFound("Developer not found");
|
||||
if (project == null) return NotFound("Project not found or you don't have access");
|
||||
if (bot == null) return NotFound("Bot not found");
|
||||
|
||||
try
|
||||
{
|
||||
var key = await accountsReceiver.GetApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() });
|
||||
if (key == null) return NotFound("API key not found");
|
||||
return Ok(ApiKeyReference.FromProtoValue(key));
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
|
||||
{
|
||||
return NotFound("API key not found");
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateApiKeyRequest
|
||||
{
|
||||
[Required, MaxLength(1024)]
|
||||
public string Label { get; set; } = null!;
|
||||
}
|
||||
|
||||
[HttpPost("{botId:guid}/keys")]
|
||||
public async Task<ActionResult<ApiKeyReference>> CreateBotKey(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid botId,
|
||||
[FromBody] CreateApiKeyRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor);
|
||||
if (developer == null) return NotFound("Developer not found");
|
||||
if (project == null) return NotFound("Project not found or you don't have access");
|
||||
if (bot == null) return NotFound("Bot not found");
|
||||
|
||||
try
|
||||
{
|
||||
var newKey = new ApiKey
|
||||
{
|
||||
AccountId = bot.Id.ToString(),
|
||||
Label = request.Label
|
||||
};
|
||||
|
||||
var createdKey = await accountsReceiver.CreateApiKeyAsync(newKey);
|
||||
return Ok(ApiKeyReference.FromProtoValue(createdKey));
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument)
|
||||
{
|
||||
return BadRequest(ex.Status.Detail);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{botId:guid}/keys/{keyId:guid}/rotate")]
|
||||
public async Task<ActionResult<ApiKeyReference>> RotateBotKey(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid botId,
|
||||
[FromRoute] Guid keyId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor);
|
||||
if (developer == null) return NotFound("Developer not found");
|
||||
if (project == null) return NotFound("Project not found or you don't have access");
|
||||
if (bot == null) return NotFound("Bot not found");
|
||||
|
||||
try
|
||||
{
|
||||
var rotatedKey = await accountsReceiver.RotateApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() });
|
||||
return Ok(ApiKeyReference.FromProtoValue(rotatedKey));
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
|
||||
{
|
||||
return NotFound("API key not found");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{botId:guid}/keys/{keyId:guid}")]
|
||||
public async Task<IActionResult> DeleteBotKey(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid botId,
|
||||
[FromRoute] Guid keyId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor);
|
||||
if (developer == null) return NotFound("Developer not found");
|
||||
if (project == null) return NotFound("Project not found or you don't have access");
|
||||
if (bot == null) return NotFound("Bot not found");
|
||||
|
||||
try
|
||||
{
|
||||
await accountsReceiver.DeleteApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() });
|
||||
return NoContent();
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
|
||||
{
|
||||
return NotFound("API key not found");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(Developer?, DevProject?, BotAccount?)> ValidateBotAccess(
|
||||
string pubName,
|
||||
Guid projectId,
|
||||
Guid botId,
|
||||
Account currentUser,
|
||||
PublisherMemberRole requiredRole)
|
||||
{
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
if (developer == null) return (null, null, null);
|
||||
|
||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), requiredRole))
|
||||
return (null, null, null);
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project == null) return (developer, null, null);
|
||||
|
||||
var bot = await botService.GetBotByIdAsync(botId);
|
||||
if (bot == null || bot.ProjectId != projectId) return (developer, project, null);
|
||||
|
||||
return (developer, project, bot);
|
||||
}
|
||||
}
|
||||
35
DysonNetwork.Develop/Identity/BotAccountPublicController.cs
Normal file
35
DysonNetwork.Develop/Identity/BotAccountPublicController.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/bots")]
|
||||
public class BotAccountPublicController(BotAccountService botService, DeveloperService developerService) : ControllerBase
|
||||
{
|
||||
[HttpGet("{botId:guid}")]
|
||||
public async Task<ActionResult<BotAccount>> GetBotTransparentInfo([FromRoute] Guid botId)
|
||||
{
|
||||
var bot = await botService.GetBotByIdAsync(botId);
|
||||
if (bot is null) return NotFound("Bot not found");
|
||||
bot = await botService.LoadBotAccountAsync(bot);
|
||||
|
||||
var developer = await developerService.GetDeveloperById(bot!.Project.DeveloperId);
|
||||
if (developer is null) return NotFound("Developer not found");
|
||||
bot.Developer = await developerService.LoadDeveloperPublisher(developer);
|
||||
|
||||
return Ok(bot);
|
||||
}
|
||||
|
||||
[HttpGet("{botId:guid}/developer")]
|
||||
public async Task<ActionResult<Developer>> GetBotDeveloper([FromRoute] Guid botId)
|
||||
{
|
||||
var bot = await botService.GetBotByIdAsync(botId);
|
||||
if (bot is null) return NotFound("Bot not found");
|
||||
|
||||
var developer = await developerService.GetDeveloperById(bot!.Project.DeveloperId);
|
||||
if (developer is null) return NotFound("Developer not found");
|
||||
developer = await developerService.LoadDeveloperPublisher(developer);
|
||||
|
||||
return Ok(developer);
|
||||
}
|
||||
}
|
||||
174
DysonNetwork.Develop/Identity/BotAccountService.cs
Normal file
174
DysonNetwork.Develop/Identity/BotAccountService.cs
Normal file
@@ -0,0 +1,174 @@
|
||||
using DysonNetwork.Develop.Project;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Grpc.Core;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
public class BotAccountService(
|
||||
AppDatabase db,
|
||||
BotAccountReceiverService.BotAccountReceiverServiceClient accountReceiver,
|
||||
AccountClientHelper accounts
|
||||
)
|
||||
{
|
||||
public async Task<BotAccount?> GetBotByIdAsync(Guid id)
|
||||
{
|
||||
return await db.BotAccounts
|
||||
.Include(b => b.Project)
|
||||
.FirstOrDefaultAsync(b => b.Id == id);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<BotAccount>> GetBotsByProjectAsync(Guid projectId)
|
||||
{
|
||||
return await db.BotAccounts
|
||||
.Where(b => b.ProjectId == projectId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<BotAccount> CreateBotAsync(
|
||||
DevProject project,
|
||||
string slug,
|
||||
Account account,
|
||||
string? pictureId,
|
||||
string? backgroundId
|
||||
)
|
||||
{
|
||||
// First, check if a bot with this slug already exists in this project
|
||||
var existingBot = await db.BotAccounts
|
||||
.FirstOrDefaultAsync(b => b.ProjectId == project.Id && b.Slug == slug);
|
||||
|
||||
if (existingBot != null)
|
||||
throw new InvalidOperationException("A bot with this slug already exists in this project.");
|
||||
|
||||
try
|
||||
{
|
||||
var automatedId = Guid.NewGuid();
|
||||
var createRequest = new CreateBotAccountRequest
|
||||
{
|
||||
AutomatedId = automatedId.ToString(),
|
||||
Account = account,
|
||||
PictureId = pictureId,
|
||||
BackgroundId = backgroundId
|
||||
};
|
||||
|
||||
var createResponse = await accountReceiver.CreateBotAccountAsync(createRequest);
|
||||
var botAccount = createResponse.Bot;
|
||||
|
||||
// Then create the local bot account
|
||||
var bot = new BotAccount
|
||||
{
|
||||
Id = automatedId,
|
||||
Slug = slug,
|
||||
ProjectId = project.Id,
|
||||
Project = project,
|
||||
IsActive = botAccount.IsActive,
|
||||
CreatedAt = botAccount.CreatedAt.ToInstant(),
|
||||
UpdatedAt = botAccount.UpdatedAt.ToInstant()
|
||||
};
|
||||
|
||||
db.BotAccounts.Add(bot);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return bot;
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == StatusCode.AlreadyExists)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A bot account with this ID already exists in the authentication service.", ex);
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == StatusCode.InvalidArgument)
|
||||
{
|
||||
throw new ArgumentException($"Invalid bot account data: {ex.Status.Detail}", ex);
|
||||
}
|
||||
catch (RpcException ex)
|
||||
{
|
||||
throw new Exception($"Failed to create bot account: {ex.Status.Detail}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<BotAccount> UpdateBotAsync(
|
||||
BotAccount bot,
|
||||
Account account,
|
||||
string? pictureId,
|
||||
string? backgroundId
|
||||
)
|
||||
{
|
||||
db.Update(bot);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Update the bot account in the Pass service
|
||||
var updateRequest = new UpdateBotAccountRequest
|
||||
{
|
||||
AutomatedId = bot.Id.ToString(),
|
||||
Account = account,
|
||||
PictureId = pictureId,
|
||||
BackgroundId = backgroundId
|
||||
};
|
||||
|
||||
var updateResponse = await accountReceiver.UpdateBotAccountAsync(updateRequest);
|
||||
var updatedBot = updateResponse.Bot;
|
||||
|
||||
// Update local bot account
|
||||
bot.UpdatedAt = updatedBot.UpdatedAt.ToInstant();
|
||||
bot.IsActive = updatedBot.IsActive;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound)
|
||||
{
|
||||
throw new Exception("Bot account not found in the authentication service", ex);
|
||||
}
|
||||
catch (RpcException ex)
|
||||
{
|
||||
throw new Exception($"Failed to update bot account: {ex.Status.Detail}", ex);
|
||||
}
|
||||
|
||||
return bot;
|
||||
}
|
||||
|
||||
public async Task DeleteBotAsync(BotAccount bot)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Delete the bot account from the Pass service
|
||||
var deleteRequest = new DeleteBotAccountRequest
|
||||
{
|
||||
AutomatedId = bot.Id.ToString(),
|
||||
Force = false
|
||||
};
|
||||
|
||||
await accountReceiver.DeleteBotAccountAsync(deleteRequest);
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound)
|
||||
{
|
||||
// Account not found in Pass service, continue with local deletion
|
||||
}
|
||||
|
||||
// Delete the local bot account
|
||||
db.BotAccounts.Remove(bot);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<BotAccount?> LoadBotAccountAsync(BotAccount bot) =>
|
||||
(await LoadBotsAccountAsync([bot])).FirstOrDefault();
|
||||
|
||||
public async Task<List<BotAccount>> LoadBotsAccountAsync(IEnumerable<BotAccount> bots)
|
||||
{
|
||||
bots = bots.ToList();
|
||||
var automatedIds = bots.Select(b => b.Id).ToList();
|
||||
var data = await accounts.GetBotAccountBatch(automatedIds);
|
||||
|
||||
foreach (var bot in bots)
|
||||
{
|
||||
bot.Account = data
|
||||
.Select(AccountReference.FromProtoValue)
|
||||
.FirstOrDefault(e => e.AutomatedId == bot.Id);
|
||||
}
|
||||
|
||||
return bots as List<BotAccount> ?? [];
|
||||
}
|
||||
}
|
||||
178
DysonNetwork.Develop/Identity/CustomApp.cs
Normal file
178
DysonNetwork.Develop/Identity/CustomApp.cs
Normal file
@@ -0,0 +1,178 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Develop.Project;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Google.Protobuf;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
using NodaTime;
|
||||
|
||||
using VerificationMark = DysonNetwork.Shared.Data.VerificationMark;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
public enum CustomAppStatus
|
||||
{
|
||||
Developing,
|
||||
Staging,
|
||||
Production,
|
||||
Suspended
|
||||
}
|
||||
|
||||
public class CustomApp : ModelBase, IIdentifiedResource
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(1024)] public string Slug { get; set; } = null!;
|
||||
[MaxLength(1024)] public string Name { get; set; } = null!;
|
||||
[MaxLength(4096)] public string? Description { get; set; }
|
||||
public CustomAppStatus Status { get; set; } = CustomAppStatus.Developing;
|
||||
|
||||
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
|
||||
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
|
||||
|
||||
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
|
||||
[Column(TypeName = "jsonb")] public CustomAppOauthConfig? OauthConfig { get; set; }
|
||||
[Column(TypeName = "jsonb")] public CustomAppLinks? Links { get; set; }
|
||||
|
||||
[JsonIgnore] public ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>();
|
||||
|
||||
public Guid ProjectId { get; set; }
|
||||
public DevProject Project { get; set; } = null!;
|
||||
|
||||
[NotMapped]
|
||||
public Developer Developer => Project.Developer;
|
||||
|
||||
[NotMapped] public string ResourceIdentifier => "custom-app:" + Id;
|
||||
|
||||
public Shared.Proto.CustomApp ToProto()
|
||||
{
|
||||
return new Shared.Proto.CustomApp
|
||||
{
|
||||
Id = Id.ToString(),
|
||||
Slug = Slug,
|
||||
Name = Name,
|
||||
Description = Description ?? string.Empty,
|
||||
Status = Status switch
|
||||
{
|
||||
CustomAppStatus.Developing => Shared.Proto.CustomAppStatus.Developing,
|
||||
CustomAppStatus.Staging => Shared.Proto.CustomAppStatus.Staging,
|
||||
CustomAppStatus.Production => Shared.Proto.CustomAppStatus.Production,
|
||||
CustomAppStatus.Suspended => Shared.Proto.CustomAppStatus.Suspended,
|
||||
_ => Shared.Proto.CustomAppStatus.Unspecified
|
||||
},
|
||||
Picture = Picture?.ToProtoValue(),
|
||||
Background = Background?.ToProtoValue(),
|
||||
Verification = Verification?.ToProtoValue(),
|
||||
Links = Links is null ? null : new DysonNetwork.Shared.Proto.CustomAppLinks
|
||||
{
|
||||
HomePage = Links.HomePage ?? string.Empty,
|
||||
PrivacyPolicy = Links.PrivacyPolicy ?? string.Empty,
|
||||
TermsOfService = Links.TermsOfService ?? string.Empty
|
||||
},
|
||||
OauthConfig = OauthConfig is null ? null : new DysonNetwork.Shared.Proto.CustomAppOauthConfig
|
||||
{
|
||||
ClientUri = OauthConfig.ClientUri ?? string.Empty,
|
||||
RedirectUris = { OauthConfig.RedirectUris ?? [] },
|
||||
PostLogoutRedirectUris = { OauthConfig.PostLogoutRedirectUris ?? [] },
|
||||
AllowedScopes = { OauthConfig.AllowedScopes ?? [] },
|
||||
AllowedGrantTypes = { OauthConfig.AllowedGrantTypes ?? [] },
|
||||
RequirePkce = OauthConfig.RequirePkce,
|
||||
AllowOfflineAccess = OauthConfig.AllowOfflineAccess
|
||||
},
|
||||
ProjectId = ProjectId.ToString(),
|
||||
CreatedAt = CreatedAt.ToTimestamp(),
|
||||
UpdatedAt = UpdatedAt.ToTimestamp()
|
||||
};
|
||||
}
|
||||
|
||||
public CustomApp FromProtoValue(Shared.Proto.CustomApp p)
|
||||
{
|
||||
Id = Guid.Parse(p.Id);
|
||||
Slug = p.Slug;
|
||||
Name = p.Name;
|
||||
Description = string.IsNullOrEmpty(p.Description) ? null : p.Description;
|
||||
Status = p.Status switch
|
||||
{
|
||||
Shared.Proto.CustomAppStatus.Developing => CustomAppStatus.Developing,
|
||||
Shared.Proto.CustomAppStatus.Staging => CustomAppStatus.Staging,
|
||||
Shared.Proto.CustomAppStatus.Production => CustomAppStatus.Production,
|
||||
Shared.Proto.CustomAppStatus.Suspended => CustomAppStatus.Suspended,
|
||||
_ => CustomAppStatus.Developing
|
||||
};
|
||||
ProjectId = string.IsNullOrEmpty(p.ProjectId) ? Guid.Empty : Guid.Parse(p.ProjectId);
|
||||
CreatedAt = p.CreatedAt.ToInstant();
|
||||
UpdatedAt = p.UpdatedAt.ToInstant();
|
||||
if (p.Picture is not null) Picture = CloudFileReferenceObject.FromProtoValue(p.Picture);
|
||||
if (p.Background is not null) Background = CloudFileReferenceObject.FromProtoValue(p.Background);
|
||||
if (p.Verification is not null) Verification = VerificationMark.FromProtoValue(p.Verification);
|
||||
if (p.Links is not null)
|
||||
{
|
||||
Links = new CustomAppLinks
|
||||
{
|
||||
HomePage = string.IsNullOrEmpty(p.Links.HomePage) ? null : p.Links.HomePage,
|
||||
PrivacyPolicy = string.IsNullOrEmpty(p.Links.PrivacyPolicy) ? null : p.Links.PrivacyPolicy,
|
||||
TermsOfService = string.IsNullOrEmpty(p.Links.TermsOfService) ? null : p.Links.TermsOfService
|
||||
};
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
public class CustomAppLinks
|
||||
{
|
||||
[MaxLength(8192)] public string? HomePage { get; set; }
|
||||
[MaxLength(8192)] public string? PrivacyPolicy { get; set; }
|
||||
[MaxLength(8192)] public string? TermsOfService { get; set; }
|
||||
}
|
||||
|
||||
public class CustomAppOauthConfig
|
||||
{
|
||||
[MaxLength(1024)] public string? ClientUri { get; set; }
|
||||
[MaxLength(4096)] public string[] RedirectUris { get; set; } = [];
|
||||
[MaxLength(4096)] public string[]? PostLogoutRedirectUris { get; set; }
|
||||
[MaxLength(256)] public string[]? AllowedScopes { get; set; } = ["openid", "profile", "email"];
|
||||
[MaxLength(256)] public string[] AllowedGrantTypes { get; set; } = ["authorization_code", "refresh_token"];
|
||||
public bool RequirePkce { get; set; } = true;
|
||||
public bool AllowOfflineAccess { get; set; } = false;
|
||||
}
|
||||
|
||||
public class CustomAppSecret : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(1024)] public string Secret { get; set; } = null!;
|
||||
[MaxLength(4096)] public string? Description { get; set; } = null!;
|
||||
public Instant? ExpiredAt { get; set; }
|
||||
public bool IsOidc { get; set; } = false; // Indicates if this secret is for OIDC/OAuth
|
||||
|
||||
public Guid AppId { get; set; }
|
||||
public CustomApp App { get; set; } = null!;
|
||||
|
||||
|
||||
public static CustomAppSecret FromProtoValue(DysonNetwork.Shared.Proto.CustomAppSecret p)
|
||||
{
|
||||
return new CustomAppSecret
|
||||
{
|
||||
Id = Guid.Parse(p.Id),
|
||||
Secret = p.Secret,
|
||||
Description = p.Description,
|
||||
ExpiredAt = p.ExpiredAt?.ToInstant(),
|
||||
IsOidc = p.IsOidc,
|
||||
AppId = Guid.Parse(p.AppId),
|
||||
};
|
||||
}
|
||||
|
||||
public DysonNetwork.Shared.Proto.CustomAppSecret ToProto()
|
||||
{
|
||||
return new DysonNetwork.Shared.Proto.CustomAppSecret
|
||||
{
|
||||
Id = Id.ToString(),
|
||||
Secret = Secret,
|
||||
Description = Description,
|
||||
ExpiredAt = ExpiredAt?.ToTimestamp(),
|
||||
IsOidc = IsOidc,
|
||||
AppId = Id.ToString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
431
DysonNetwork.Develop/Identity/CustomAppController.cs
Normal file
431
DysonNetwork.Develop/Identity/CustomAppController.cs
Normal file
@@ -0,0 +1,431 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Develop.Project;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/developers/{pubName}/projects/{projectId:guid}/apps")]
|
||||
public class CustomAppController(CustomAppService customApps, DeveloperService ds, DevProjectService projectService)
|
||||
: ControllerBase
|
||||
{
|
||||
public record CustomAppRequest(
|
||||
[MaxLength(1024)] string? Slug,
|
||||
[MaxLength(1024)] string? Name,
|
||||
[MaxLength(4096)] string? Description,
|
||||
string? PictureId,
|
||||
string? BackgroundId,
|
||||
CustomAppStatus? Status,
|
||||
CustomAppLinks? Links,
|
||||
CustomAppOauthConfig? OauthConfig
|
||||
);
|
||||
|
||||
public record CreateSecretRequest(
|
||||
[MaxLength(4096)] string? Description,
|
||||
TimeSpan? ExpiresIn = null,
|
||||
bool IsOidc = false
|
||||
);
|
||||
|
||||
public record SecretResponse(
|
||||
string Id,
|
||||
string? Secret,
|
||||
string? Description,
|
||||
Instant? ExpiresAt,
|
||||
bool IsOidc,
|
||||
Instant CreatedAt,
|
||||
Instant UpdatedAt
|
||||
);
|
||||
|
||||
[HttpGet]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> ListApps([FromRoute] string pubName, [FromRoute] Guid projectId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null) return NotFound();
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, PublisherMemberRole.Viewer))
|
||||
return StatusCode(403, "You must be a viewer of the developer to list custom apps");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null) return NotFound();
|
||||
|
||||
var apps = await customApps.GetAppsByProjectAsync(projectId);
|
||||
return Ok(apps);
|
||||
}
|
||||
|
||||
[HttpGet("{appId:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GetApp([FromRoute] string pubName, [FromRoute] Guid projectId,
|
||||
[FromRoute] Guid appId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null) return NotFound();
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, PublisherMemberRole.Viewer))
|
||||
return StatusCode(403, "You must be a viewer of the developer to list custom apps");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null) return NotFound();
|
||||
|
||||
var app = await customApps.GetAppAsync(appId, projectId);
|
||||
if (app == null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(app);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CreateApp(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromBody] CustomAppRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to create a custom app");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Slug))
|
||||
return BadRequest("Name and slug are required");
|
||||
|
||||
try
|
||||
{
|
||||
var app = await customApps.CreateAppAsync(projectId, request);
|
||||
if (app == null)
|
||||
return BadRequest("Failed to create app");
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetApp),
|
||||
new { pubName, projectId, appId = app.Id },
|
||||
app
|
||||
);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPatch("{appId:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> UpdateApp(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid appId,
|
||||
[FromBody] CustomAppRequest request
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to update a custom app");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var app = await customApps.GetAppAsync(appId, projectId);
|
||||
if (app == null)
|
||||
return NotFound();
|
||||
|
||||
try
|
||||
{
|
||||
app = await customApps.UpdateAppAsync(app, request);
|
||||
return Ok(app);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{appId:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> DeleteApp(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid appId
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to delete a custom app");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var app = await customApps.GetAppAsync(appId, projectId);
|
||||
if (app == null)
|
||||
return NotFound();
|
||||
|
||||
var result = await customApps.DeleteAppAsync(appId);
|
||||
if (!result)
|
||||
return NotFound();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("{appId:guid}/secrets")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> ListSecrets(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid appId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to view app secrets");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var app = await customApps.GetAppAsync(appId, projectId);
|
||||
if (app == null)
|
||||
return NotFound("App not found");
|
||||
|
||||
var secrets = await customApps.GetAppSecretsAsync(appId);
|
||||
return Ok(secrets.Select(s => new SecretResponse(
|
||||
s.Id.ToString(),
|
||||
null,
|
||||
s.Description,
|
||||
s.ExpiredAt,
|
||||
s.IsOidc,
|
||||
s.CreatedAt,
|
||||
s.UpdatedAt
|
||||
)));
|
||||
}
|
||||
|
||||
[HttpPost("{appId:guid}/secrets")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CreateSecret(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid appId,
|
||||
[FromBody] CreateSecretRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to create app secrets");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var app = await customApps.GetAppAsync(appId, projectId);
|
||||
if (app == null)
|
||||
return NotFound("App not found");
|
||||
|
||||
try
|
||||
{
|
||||
var secret = await customApps.CreateAppSecretAsync(new CustomAppSecret
|
||||
{
|
||||
AppId = appId,
|
||||
Description = request.Description,
|
||||
ExpiredAt = request.ExpiresIn.HasValue
|
||||
? NodaTime.SystemClock.Instance.GetCurrentInstant()
|
||||
.Plus(Duration.FromTimeSpan(request.ExpiresIn.Value))
|
||||
: (NodaTime.Instant?)null,
|
||||
IsOidc = request.IsOidc
|
||||
});
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetSecret),
|
||||
new { pubName, projectId, appId, secretId = secret.Id },
|
||||
new SecretResponse(
|
||||
secret.Id.ToString(),
|
||||
secret.Secret,
|
||||
secret.Description,
|
||||
secret.ExpiredAt,
|
||||
secret.IsOidc,
|
||||
secret.CreatedAt,
|
||||
secret.UpdatedAt
|
||||
)
|
||||
);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{appId:guid}/secrets/{secretId:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GetSecret(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid appId,
|
||||
[FromRoute] Guid secretId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to view app secrets");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var app = await customApps.GetAppAsync(appId, projectId);
|
||||
if (app == null)
|
||||
return NotFound("App not found");
|
||||
|
||||
var secret = await customApps.GetAppSecretAsync(secretId, appId);
|
||||
if (secret == null)
|
||||
return NotFound("Secret not found");
|
||||
|
||||
return Ok(new SecretResponse(
|
||||
secret.Id.ToString(),
|
||||
null,
|
||||
secret.Description,
|
||||
secret.ExpiredAt,
|
||||
secret.IsOidc,
|
||||
secret.CreatedAt,
|
||||
secret.UpdatedAt
|
||||
));
|
||||
}
|
||||
|
||||
[HttpDelete("{appId:guid}/secrets/{secretId:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> DeleteSecret(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid appId,
|
||||
[FromRoute] Guid secretId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to delete app secrets");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var app = await customApps.GetAppAsync(appId, projectId);
|
||||
if (app == null)
|
||||
return NotFound("App not found");
|
||||
|
||||
var secret = await customApps.GetAppSecretAsync(secretId, appId);
|
||||
if (secret == null)
|
||||
return NotFound("Secret not found");
|
||||
|
||||
var result = await customApps.DeleteAppSecretAsync(secretId, appId);
|
||||
if (!result)
|
||||
return NotFound("Failed to delete secret");
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("{appId:guid}/secrets/{secretId:guid}/rotate")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> RotateSecret(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid appId,
|
||||
[FromRoute] Guid secretId,
|
||||
[FromBody] CreateSecretRequest? request = null)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to rotate app secrets");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var app = await customApps.GetAppAsync(appId, projectId);
|
||||
if (app == null)
|
||||
return NotFound("App not found");
|
||||
|
||||
try
|
||||
{
|
||||
var secret = await customApps.RotateAppSecretAsync(new CustomAppSecret
|
||||
{
|
||||
Id = secretId,
|
||||
AppId = appId,
|
||||
Description = request?.Description,
|
||||
ExpiredAt = request?.ExpiresIn.HasValue == true
|
||||
? NodaTime.SystemClock.Instance.GetCurrentInstant()
|
||||
.Plus(Duration.FromTimeSpan(request.ExpiresIn.Value))
|
||||
: (NodaTime.Instant?)null,
|
||||
IsOidc = request?.IsOidc ?? false
|
||||
});
|
||||
|
||||
return Ok(new SecretResponse(
|
||||
secret.Id.ToString(),
|
||||
secret.Secret,
|
||||
secret.Description,
|
||||
secret.ExpiredAt,
|
||||
secret.IsOidc,
|
||||
secret.CreatedAt,
|
||||
secret.UpdatedAt
|
||||
));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
using DysonNetwork.Develop.Project;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace DysonNetwork.Sphere.Developer;
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
public class CustomAppService(
|
||||
AppDatabase db,
|
||||
@@ -11,10 +14,17 @@ public class CustomAppService(
|
||||
)
|
||||
{
|
||||
public async Task<CustomApp?> CreateAppAsync(
|
||||
Publisher.Publisher pub,
|
||||
Guid projectId,
|
||||
CustomAppController.CustomAppRequest request
|
||||
)
|
||||
{
|
||||
var project = await db.DevProjects
|
||||
.Include(p => p.Developer)
|
||||
.FirstOrDefaultAsync(p => p.Id == projectId);
|
||||
|
||||
if (project == null)
|
||||
return null;
|
||||
|
||||
var app = new CustomApp
|
||||
{
|
||||
Slug = request.Slug!,
|
||||
@@ -23,7 +33,7 @@ public class CustomAppService(
|
||||
Status = request.Status ?? CustomAppStatus.Developing,
|
||||
Links = request.Links,
|
||||
OauthConfig = request.OauthConfig,
|
||||
PublisherId = pub.Id
|
||||
ProjectId = projectId
|
||||
};
|
||||
|
||||
if (request.PictureId is not null)
|
||||
@@ -74,17 +84,104 @@ public class CustomAppService(
|
||||
return app;
|
||||
}
|
||||
|
||||
public async Task<CustomApp?> GetAppAsync(Guid id, Guid? publisherId = null)
|
||||
public async Task<CustomApp?> GetAppAsync(Guid id, Guid? projectId = null)
|
||||
{
|
||||
var query = db.CustomApps.Where(a => a.Id == id).AsQueryable();
|
||||
if (publisherId.HasValue)
|
||||
query = query.Where(a => a.PublisherId == publisherId.Value);
|
||||
return await query.FirstOrDefaultAsync();
|
||||
var query = db.CustomApps.AsQueryable();
|
||||
|
||||
if (projectId.HasValue)
|
||||
{
|
||||
query = query.Where(a => a.ProjectId == projectId.Value);
|
||||
}
|
||||
|
||||
public async Task<List<CustomApp>> GetAppsByPublisherAsync(Guid publisherId)
|
||||
return await query.FirstOrDefaultAsync(a => a.Id == id);
|
||||
}
|
||||
|
||||
public async Task<List<CustomAppSecret>> GetAppSecretsAsync(Guid appId)
|
||||
{
|
||||
return await db.CustomApps.Where(a => a.PublisherId == publisherId).ToListAsync();
|
||||
return await db.CustomAppSecrets
|
||||
.Where(s => s.AppId == appId)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<CustomAppSecret?> GetAppSecretAsync(Guid secretId, Guid appId)
|
||||
{
|
||||
return await db.CustomAppSecrets
|
||||
.FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId);
|
||||
}
|
||||
|
||||
public async Task<CustomAppSecret> CreateAppSecretAsync(CustomAppSecret secret)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(secret.Secret))
|
||||
{
|
||||
// Generate a new random secret if not provided
|
||||
secret.Secret = GenerateRandomSecret();
|
||||
}
|
||||
|
||||
secret.Id = Guid.NewGuid();
|
||||
secret.CreatedAt = NodaTime.SystemClock.Instance.GetCurrentInstant();
|
||||
secret.UpdatedAt = secret.CreatedAt;
|
||||
|
||||
db.CustomAppSecrets.Add(secret);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return secret;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAppSecretAsync(Guid secretId, Guid appId)
|
||||
{
|
||||
var secret = await db.CustomAppSecrets
|
||||
.FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId);
|
||||
|
||||
if (secret == null)
|
||||
return false;
|
||||
|
||||
db.CustomAppSecrets.Remove(secret);
|
||||
await db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<CustomAppSecret> RotateAppSecretAsync(CustomAppSecret secretUpdate)
|
||||
{
|
||||
var existingSecret = await db.CustomAppSecrets
|
||||
.FirstOrDefaultAsync(s => s.Id == secretUpdate.Id && s.AppId == secretUpdate.AppId);
|
||||
|
||||
if (existingSecret == null)
|
||||
throw new InvalidOperationException("Secret not found");
|
||||
|
||||
// Update the existing secret with new values
|
||||
existingSecret.Secret = GenerateRandomSecret();
|
||||
existingSecret.Description = secretUpdate.Description ?? existingSecret.Description;
|
||||
existingSecret.ExpiredAt = secretUpdate.ExpiredAt ?? existingSecret.ExpiredAt;
|
||||
existingSecret.IsOidc = secretUpdate.IsOidc;
|
||||
existingSecret.UpdatedAt = NodaTime.SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return existingSecret;
|
||||
}
|
||||
|
||||
private static string GenerateRandomSecret(int length = 64)
|
||||
{
|
||||
const string valid = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-._~+";
|
||||
var res = new StringBuilder();
|
||||
using (var rng = RandomNumberGenerator.Create())
|
||||
{
|
||||
var uintBuffer = new byte[sizeof(uint)];
|
||||
while (length-- > 0)
|
||||
{
|
||||
rng.GetBytes(uintBuffer);
|
||||
var num = BitConverter.ToUInt32(uintBuffer, 0);
|
||||
res.Append(valid[(int)(num % (uint)valid.Length)]);
|
||||
}
|
||||
}
|
||||
return res.ToString();
|
||||
}
|
||||
|
||||
public async Task<List<CustomApp>> GetAppsByProjectAsync(Guid projectId)
|
||||
{
|
||||
return await db.CustomApps
|
||||
.Where(a => a.ProjectId == projectId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<CustomApp?> UpdateAppAsync(CustomApp app, CustomAppController.CustomAppRequest request)
|
||||
68
DysonNetwork.Develop/Identity/CustomAppServiceGrpc.cs
Normal file
68
DysonNetwork.Develop/Identity/CustomAppServiceGrpc.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Grpc.Core;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
public class CustomAppServiceGrpc(AppDatabase db) : Shared.Proto.CustomAppService.CustomAppServiceBase
|
||||
{
|
||||
public override async Task<GetCustomAppResponse> GetCustomApp(GetCustomAppRequest request, ServerCallContext context)
|
||||
{
|
||||
var q = db.CustomApps.AsQueryable();
|
||||
switch (request.QueryCase)
|
||||
{
|
||||
case GetCustomAppRequest.QueryOneofCase.Id when !string.IsNullOrWhiteSpace(request.Id):
|
||||
{
|
||||
if (!Guid.TryParse(request.Id, out var id))
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, "invalid id"));
|
||||
var appById = await q.FirstOrDefaultAsync(a => a.Id == id);
|
||||
if (appById is null)
|
||||
throw new RpcException(new Status(StatusCode.NotFound, "app not found"));
|
||||
return new GetCustomAppResponse { App = appById.ToProto() };
|
||||
}
|
||||
case GetCustomAppRequest.QueryOneofCase.Slug when !string.IsNullOrWhiteSpace(request.Slug):
|
||||
{
|
||||
var appBySlug = await q.FirstOrDefaultAsync(a => a.Slug == request.Slug);
|
||||
if (appBySlug is null)
|
||||
throw new RpcException(new Status(StatusCode.NotFound, "app not found"));
|
||||
return new GetCustomAppResponse { App = appBySlug.ToProto() };
|
||||
}
|
||||
default:
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, "id or slug required"));
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task<CheckCustomAppSecretResponse> CheckCustomAppSecret(CheckCustomAppSecretRequest request, ServerCallContext context)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.Secret))
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, "secret required"));
|
||||
|
||||
IQueryable<CustomAppSecret> q = db.CustomAppSecrets;
|
||||
switch (request.SecretIdentifierCase)
|
||||
{
|
||||
case CheckCustomAppSecretRequest.SecretIdentifierOneofCase.SecretId:
|
||||
{
|
||||
if (!Guid.TryParse(request.SecretId, out var sid))
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, "invalid secret_id"));
|
||||
q = q.Where(s => s.Id == sid);
|
||||
break;
|
||||
}
|
||||
case CheckCustomAppSecretRequest.SecretIdentifierOneofCase.AppId:
|
||||
{
|
||||
if (!Guid.TryParse(request.AppId, out var aid))
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, "invalid app_id"));
|
||||
q = q.Where(s => s.AppId == aid);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, "secret_id or app_id required"));
|
||||
}
|
||||
|
||||
if (request.HasIsOidc)
|
||||
q = q.Where(s => s.IsOidc == request.IsOidc);
|
||||
|
||||
var now = NodaTime.SystemClock.Instance.GetCurrentInstant();
|
||||
var exists = await q.AnyAsync(s => s.Secret == request.Secret && (s.ExpiredAt == null || s.ExpiredAt > now));
|
||||
return new CheckCustomAppSecretResponse { Valid = exists };
|
||||
}
|
||||
}
|
||||
79
DysonNetwork.Develop/Identity/Developer.cs
Normal file
79
DysonNetwork.Develop/Identity/Developer.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Develop.Project;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using VerificationMark = DysonNetwork.Shared.Data.VerificationMark;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
public class Developer
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid PublisherId { get; set; }
|
||||
|
||||
[JsonIgnore] public List<DevProject> Projects { get; set; } = [];
|
||||
|
||||
[NotMapped] public PublisherInfo? Publisher { get; set; }
|
||||
}
|
||||
|
||||
public class PublisherInfo
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public PublisherType Type { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Nick { get; set; } = string.Empty;
|
||||
public string? Bio { get; set; }
|
||||
|
||||
public CloudFileReferenceObject? Picture { get; set; }
|
||||
public CloudFileReferenceObject? Background { get; set; }
|
||||
|
||||
public VerificationMark? Verification { get; set; }
|
||||
public Guid? AccountId { get; set; }
|
||||
public Guid? RealmId { get; set; }
|
||||
|
||||
public static PublisherInfo FromProto(Publisher proto)
|
||||
{
|
||||
var info = new PublisherInfo
|
||||
{
|
||||
Id = Guid.Parse(proto.Id),
|
||||
Type = proto.Type == PublisherType.PubIndividual
|
||||
? PublisherType.PubIndividual
|
||||
: PublisherType.PubOrganizational,
|
||||
Name = proto.Name,
|
||||
Nick = proto.Nick,
|
||||
Bio = string.IsNullOrEmpty(proto.Bio) ? null : proto.Bio,
|
||||
Verification = proto.VerificationMark is not null
|
||||
? VerificationMark.FromProtoValue(proto.VerificationMark)
|
||||
: null,
|
||||
AccountId = string.IsNullOrEmpty(proto.AccountId) ? null : Guid.Parse(proto.AccountId),
|
||||
RealmId = string.IsNullOrEmpty(proto.RealmId) ? null : Guid.Parse(proto.RealmId)
|
||||
};
|
||||
|
||||
if (proto.Picture != null)
|
||||
{
|
||||
info.Picture = new CloudFileReferenceObject
|
||||
{
|
||||
Id = proto.Picture.Id,
|
||||
Name = proto.Picture.Name,
|
||||
MimeType = proto.Picture.MimeType,
|
||||
Hash = proto.Picture.Hash,
|
||||
Size = proto.Picture.Size
|
||||
};
|
||||
}
|
||||
|
||||
if (proto.Background != null)
|
||||
{
|
||||
info.Background = new CloudFileReferenceObject
|
||||
{
|
||||
Id = proto.Background.Id,
|
||||
Name = proto.Background.Name,
|
||||
MimeType = proto.Background.MimeType,
|
||||
Hash = proto.Background.Hash,
|
||||
Size = (long)proto.Background.Size
|
||||
};
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
}
|
||||
129
DysonNetwork.Develop/Identity/DeveloperController.cs
Normal file
129
DysonNetwork.Develop/Identity/DeveloperController.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using DysonNetwork.Shared.Auth;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Grpc.Core;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/developers")]
|
||||
public class DeveloperController(
|
||||
AppDatabase db,
|
||||
PublisherService.PublisherServiceClient ps,
|
||||
ActionLogService.ActionLogServiceClient als,
|
||||
DeveloperService ds
|
||||
)
|
||||
: ControllerBase
|
||||
{
|
||||
[HttpGet("{name}")]
|
||||
public async Task<ActionResult<Developer>> GetDeveloper(string name)
|
||||
{
|
||||
var developer = await ds.GetDeveloperByName(name);
|
||||
if (developer is null) return NotFound();
|
||||
return Ok(await ds.LoadDeveloperPublisher(developer));
|
||||
}
|
||||
|
||||
[HttpGet("{name}/stats")]
|
||||
public async Task<ActionResult<DeveloperStats>> GetDeveloperStats(string name)
|
||||
{
|
||||
var developer = await ds.GetDeveloperByName(name);
|
||||
if (developer is null) return NotFound();
|
||||
|
||||
// Get custom apps count
|
||||
var customAppsCount = await db.CustomApps
|
||||
.Include(a => a.Project)
|
||||
.Where(a => a.Project.DeveloperId == developer.Id)
|
||||
.CountAsync();
|
||||
|
||||
var stats = new DeveloperStats
|
||||
{
|
||||
TotalCustomApps = customAppsCount
|
||||
};
|
||||
|
||||
return Ok(stats);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<Developer>>> ListJoinedDevelopers()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
|
||||
var pubResponse = await ps.ListPublishersAsync(new ListPublishersRequest { AccountId = currentUser.Id });
|
||||
var pubIds = pubResponse.Publishers.Select(p => p.Id).Select(Guid.Parse).ToList();
|
||||
|
||||
var developerQuery = db.Developers
|
||||
.Where(d => pubIds.Contains(d.PublisherId))
|
||||
.AsQueryable();
|
||||
|
||||
var totalCount = await developerQuery.CountAsync();
|
||||
Response.Headers.Append("X-Total", totalCount.ToString());
|
||||
|
||||
var developers = await developerQuery.ToListAsync();
|
||||
|
||||
return Ok(await ds.LoadDeveloperPublisher(developers));
|
||||
}
|
||||
|
||||
[HttpPost("{name}/enroll")]
|
||||
[Authorize]
|
||||
[RequiredPermission("global", "developers.create")]
|
||||
public async Task<ActionResult<Developer>> EnrollDeveloperProgram(string name)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
|
||||
PublisherInfo? pub;
|
||||
try
|
||||
{
|
||||
var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Name = name });
|
||||
pub = PublisherInfo.FromProto(pubResponse.Publisher);
|
||||
} catch (RpcException ex)
|
||||
{
|
||||
return NotFound(ex.Status.Detail);
|
||||
}
|
||||
|
||||
// Check if the user is an owner of the publisher
|
||||
var permResponse = await ps.IsPublisherMemberAsync(new IsPublisherMemberRequest
|
||||
{
|
||||
PublisherId = pub.Id.ToString(),
|
||||
AccountId = currentUser.Id,
|
||||
Role = PublisherMemberRole.Owner
|
||||
});
|
||||
if (!permResponse.Valid) return StatusCode(403, "You must be the owner of the publisher to join the developer program");
|
||||
|
||||
var hasDeveloper = await db.Developers.AnyAsync(d => d.PublisherId == pub.Id);
|
||||
if (hasDeveloper) return BadRequest("Publisher is already in the developer program");
|
||||
|
||||
var developer = new Developer
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PublisherId = pub.Id
|
||||
};
|
||||
|
||||
db.Developers.Add(developer);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
_ = als.CreateActionLogAsync(new CreateActionLogRequest
|
||||
{
|
||||
Action = "developers.enroll",
|
||||
Meta =
|
||||
{
|
||||
{ "publisher_id", Google.Protobuf.WellKnownTypes.Value.ForString(pub.Id.ToString()) },
|
||||
{ "publisher_name", Google.Protobuf.WellKnownTypes.Value.ForString(pub.Name) }
|
||||
},
|
||||
AccountId = currentUser.Id,
|
||||
UserAgent = Request.Headers.UserAgent,
|
||||
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
|
||||
});
|
||||
|
||||
return Ok(await ds.LoadDeveloperPublisher(developer));
|
||||
}
|
||||
|
||||
public class DeveloperStats
|
||||
{
|
||||
public int TotalCustomApps { get; set; }
|
||||
}
|
||||
}
|
||||
75
DysonNetwork.Develop/Identity/DeveloperService.cs
Normal file
75
DysonNetwork.Develop/Identity/DeveloperService.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Grpc.Core;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
public class DeveloperService(
|
||||
AppDatabase db,
|
||||
PublisherService.PublisherServiceClient ps,
|
||||
ILogger<DeveloperService> logger)
|
||||
{
|
||||
public async Task<Developer> LoadDeveloperPublisher(Developer developer)
|
||||
{
|
||||
var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Id = developer.PublisherId.ToString() });
|
||||
developer.Publisher = PublisherInfo.FromProto(pubResponse.Publisher);
|
||||
return developer;
|
||||
}
|
||||
|
||||
|
||||
public async Task<IEnumerable<Developer>> LoadDeveloperPublisher(IEnumerable<Developer> developers)
|
||||
{
|
||||
var enumerable = developers.ToList();
|
||||
var pubIds = enumerable.Select(d => d.PublisherId).ToList();
|
||||
var pubRequest = new GetPublisherBatchRequest();
|
||||
pubIds.ForEach(x => pubRequest.Ids.Add(x.ToString()));
|
||||
var pubResponse = await ps.GetPublisherBatchAsync(pubRequest);
|
||||
var pubs = pubResponse.Publishers.ToDictionary(p => Guid.Parse(p.Id), PublisherInfo.FromProto);
|
||||
|
||||
return enumerable.Select(d =>
|
||||
{
|
||||
d.Publisher = pubs[d.PublisherId];
|
||||
return d;
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<Developer?> GetDeveloperByName(string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Name = name });
|
||||
var pubId = Guid.Parse(pubResponse.Publisher.Id);
|
||||
|
||||
var developer = await db.Developers.FirstOrDefaultAsync(d => d.PublisherId == pubId);
|
||||
return developer;
|
||||
}
|
||||
catch (RpcException ex)
|
||||
{
|
||||
logger.LogError(ex, "Developer {name} not found", name);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Developer?> GetDeveloperById(Guid id)
|
||||
{
|
||||
return await db.Developers.FirstOrDefaultAsync(d => d.Id == id);
|
||||
}
|
||||
|
||||
public async Task<bool> IsMemberWithRole(Guid pubId, Guid accountId, PublisherMemberRole role)
|
||||
{
|
||||
try
|
||||
{
|
||||
var permResponse = await ps.IsPublisherMemberAsync(new IsPublisherMemberRequest
|
||||
{
|
||||
PublisherId = pubId.ToString(),
|
||||
AccountId = accountId.ToString(),
|
||||
Role = role
|
||||
});
|
||||
return permResponse.Valid;
|
||||
}
|
||||
catch (RpcException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
203
DysonNetwork.Develop/Migrations/20250807133702_InitialMigration.Designer.cs
generated
Normal file
203
DysonNetwork.Develop/Migrations/20250807133702_InitialMigration.Designer.cs
generated
Normal file
@@ -0,0 +1,203 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DysonNetwork.Develop;
|
||||
using DysonNetwork.Develop.Identity;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NodaTime;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Develop.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDatabase))]
|
||||
[Migration("20250807133702_InitialMigration")]
|
||||
partial class InitialMigration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<CloudFileReferenceObject>("Background")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("background");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Guid>("DeveloperId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("developer_id");
|
||||
|
||||
b.Property<CustomAppLinks>("Links")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("links");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<CustomAppOauthConfig>("OauthConfig")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("oauth_config");
|
||||
|
||||
b.Property<CloudFileReferenceObject>("Picture")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("picture");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<VerificationMark>("Verification")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("verification");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_custom_apps");
|
||||
|
||||
b.HasIndex("DeveloperId")
|
||||
.HasDatabaseName("ix_custom_apps_developer_id");
|
||||
|
||||
b.ToTable("custom_apps", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AppId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("app_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<bool>("IsOidc")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_oidc");
|
||||
|
||||
b.Property<string>("Secret")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("secret");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_custom_app_secrets");
|
||||
|
||||
b.HasIndex("AppId")
|
||||
.HasDatabaseName("ix_custom_app_secrets_app_id");
|
||||
|
||||
b.ToTable("custom_app_secrets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("PublisherId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("publisher_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_developers");
|
||||
|
||||
b.ToTable("developers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer")
|
||||
.WithMany()
|
||||
.HasForeignKey("DeveloperId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_custom_apps_developers_developer_id");
|
||||
|
||||
b.Navigation("Developer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Identity.CustomApp", "App")
|
||||
.WithMany("Secrets")
|
||||
.HasForeignKey("AppId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_custom_app_secrets_custom_apps_app_id");
|
||||
|
||||
b.Navigation("App");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.Navigation("Secrets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System;
|
||||
using DysonNetwork.Develop.Identity;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NodaTime;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Develop.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialMigration : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "developers",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
publisher_id = table.Column<Guid>(type: "uuid", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_developers", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "custom_apps",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
slug = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||
status = table.Column<int>(type: "integer", nullable: false),
|
||||
picture = table.Column<CloudFileReferenceObject>(type: "jsonb", nullable: true),
|
||||
background = table.Column<CloudFileReferenceObject>(type: "jsonb", nullable: true),
|
||||
verification = table.Column<VerificationMark>(type: "jsonb", nullable: true),
|
||||
oauth_config = table.Column<CustomAppOauthConfig>(type: "jsonb", nullable: true),
|
||||
links = table.Column<CustomAppLinks>(type: "jsonb", nullable: true),
|
||||
developer_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_custom_apps", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_custom_apps_developers_developer_id",
|
||||
column: x => x.developer_id,
|
||||
principalTable: "developers",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "custom_app_secrets",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
secret = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||
is_oidc = table.Column<bool>(type: "boolean", nullable: false),
|
||||
app_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_custom_app_secrets", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_custom_app_secrets_custom_apps_app_id",
|
||||
column: x => x.app_id,
|
||||
principalTable: "custom_apps",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_custom_app_secrets_app_id",
|
||||
table: "custom_app_secrets",
|
||||
column: "app_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_custom_apps_developer_id",
|
||||
table: "custom_apps",
|
||||
column: "developer_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "custom_app_secrets");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "custom_apps");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "developers");
|
||||
}
|
||||
}
|
||||
}
|
||||
270
DysonNetwork.Develop/Migrations/20250818124844_AddDevProject.Designer.cs
generated
Normal file
270
DysonNetwork.Develop/Migrations/20250818124844_AddDevProject.Designer.cs
generated
Normal file
@@ -0,0 +1,270 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DysonNetwork.Develop;
|
||||
using DysonNetwork.Develop.Identity;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NodaTime;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Develop.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDatabase))]
|
||||
[Migration("20250818124844_AddDevProject")]
|
||||
partial class AddDevProject
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<CloudFileReferenceObject>("Background")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("background");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<CustomAppLinks>("Links")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("links");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<CustomAppOauthConfig>("OauthConfig")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("oauth_config");
|
||||
|
||||
b.Property<CloudFileReferenceObject>("Picture")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("picture");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("project_id");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<VerificationMark>("Verification")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("verification");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_custom_apps");
|
||||
|
||||
b.HasIndex("ProjectId")
|
||||
.HasDatabaseName("ix_custom_apps_project_id");
|
||||
|
||||
b.ToTable("custom_apps", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AppId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("app_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<bool>("IsOidc")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_oidc");
|
||||
|
||||
b.Property<string>("Secret")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("secret");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_custom_app_secrets");
|
||||
|
||||
b.HasIndex("AppId")
|
||||
.HasDatabaseName("ix_custom_app_secrets_app_id");
|
||||
|
||||
b.ToTable("custom_app_secrets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("PublisherId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("publisher_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_developers");
|
||||
|
||||
b.ToTable("developers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", 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>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Guid>("DeveloperId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("developer_id");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_dev_projects");
|
||||
|
||||
b.HasIndex("DeveloperId")
|
||||
.HasDatabaseName("ix_dev_projects_developer_id");
|
||||
|
||||
b.ToTable("dev_projects", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_custom_apps_dev_projects_project_id");
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Identity.CustomApp", "App")
|
||||
.WithMany("Secrets")
|
||||
.HasForeignKey("AppId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_custom_app_secrets_custom_apps_app_id");
|
||||
|
||||
b.Navigation("App");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer")
|
||||
.WithMany("Projects")
|
||||
.HasForeignKey("DeveloperId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_dev_projects_developers_developer_id");
|
||||
|
||||
b.Navigation("Developer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.Navigation("Secrets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
|
||||
{
|
||||
b.Navigation("Projects");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NodaTime;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Develop.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDevProject : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_custom_apps_developers_developer_id",
|
||||
table: "custom_apps");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "developer_id",
|
||||
table: "custom_apps",
|
||||
newName: "project_id");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "ix_custom_apps_developer_id",
|
||||
table: "custom_apps",
|
||||
newName: "ix_custom_apps_project_id");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "dev_projects",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
slug = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
|
||||
developer_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_dev_projects", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_dev_projects_developers_developer_id",
|
||||
column: x => x.developer_id,
|
||||
principalTable: "developers",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_dev_projects_developer_id",
|
||||
table: "dev_projects",
|
||||
column: "developer_id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_custom_apps_dev_projects_project_id",
|
||||
table: "custom_apps",
|
||||
column: "project_id",
|
||||
principalTable: "dev_projects",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_custom_apps_dev_projects_project_id",
|
||||
table: "custom_apps");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "dev_projects");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "project_id",
|
||||
table: "custom_apps",
|
||||
newName: "developer_id");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "ix_custom_apps_project_id",
|
||||
table: "custom_apps",
|
||||
newName: "ix_custom_apps_developer_id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_custom_apps_developers_developer_id",
|
||||
table: "custom_apps",
|
||||
column: "developer_id",
|
||||
principalTable: "developers",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
324
DysonNetwork.Develop/Migrations/20250819163227_AddBotAccount.Designer.cs
generated
Normal file
324
DysonNetwork.Develop/Migrations/20250819163227_AddBotAccount.Designer.cs
generated
Normal file
@@ -0,0 +1,324 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DysonNetwork.Develop;
|
||||
using DysonNetwork.Develop.Identity;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NodaTime;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Develop.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDatabase))]
|
||||
[Migration("20250819163227_AddBotAccount")]
|
||||
partial class AddBotAccount
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", 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<bool>("IsActive")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_active");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("project_id");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_bot_accounts");
|
||||
|
||||
b.HasIndex("ProjectId")
|
||||
.HasDatabaseName("ix_bot_accounts_project_id");
|
||||
|
||||
b.ToTable("bot_accounts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<CloudFileReferenceObject>("Background")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("background");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<CustomAppLinks>("Links")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("links");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<CustomAppOauthConfig>("OauthConfig")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("oauth_config");
|
||||
|
||||
b.Property<CloudFileReferenceObject>("Picture")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("picture");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("project_id");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<VerificationMark>("Verification")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("verification");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_custom_apps");
|
||||
|
||||
b.HasIndex("ProjectId")
|
||||
.HasDatabaseName("ix_custom_apps_project_id");
|
||||
|
||||
b.ToTable("custom_apps", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AppId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("app_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<bool>("IsOidc")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_oidc");
|
||||
|
||||
b.Property<string>("Secret")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("secret");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_custom_app_secrets");
|
||||
|
||||
b.HasIndex("AppId")
|
||||
.HasDatabaseName("ix_custom_app_secrets_app_id");
|
||||
|
||||
b.ToTable("custom_app_secrets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("PublisherId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("publisher_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_developers");
|
||||
|
||||
b.ToTable("developers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", 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>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Guid>("DeveloperId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("developer_id");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_dev_projects");
|
||||
|
||||
b.HasIndex("DeveloperId")
|
||||
.HasDatabaseName("ix_dev_projects_developer_id");
|
||||
|
||||
b.ToTable("dev_projects", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_bot_accounts_dev_projects_project_id");
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_custom_apps_dev_projects_project_id");
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Identity.CustomApp", "App")
|
||||
.WithMany("Secrets")
|
||||
.HasForeignKey("AppId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_custom_app_secrets_custom_apps_app_id");
|
||||
|
||||
b.Navigation("App");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer")
|
||||
.WithMany("Projects")
|
||||
.HasForeignKey("DeveloperId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_dev_projects_developers_developer_id");
|
||||
|
||||
b.Navigation("Developer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.Navigation("Secrets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
|
||||
{
|
||||
b.Navigation("Projects");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NodaTime;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Develop.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBotAccount : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "bot_accounts",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
slug = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
is_active = table.Column<bool>(type: "boolean", nullable: false),
|
||||
project_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_bot_accounts", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_bot_accounts_dev_projects_project_id",
|
||||
column: x => x.project_id,
|
||||
principalTable: "dev_projects",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_bot_accounts_project_id",
|
||||
table: "bot_accounts",
|
||||
column: "project_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "bot_accounts");
|
||||
}
|
||||
}
|
||||
}
|
||||
321
DysonNetwork.Develop/Migrations/AppDatabaseModelSnapshot.cs
Normal file
321
DysonNetwork.Develop/Migrations/AppDatabaseModelSnapshot.cs
Normal file
@@ -0,0 +1,321 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DysonNetwork.Develop;
|
||||
using DysonNetwork.Develop.Identity;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NodaTime;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Develop.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDatabase))]
|
||||
partial class AppDatabaseModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", 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<bool>("IsActive")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_active");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("project_id");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_bot_accounts");
|
||||
|
||||
b.HasIndex("ProjectId")
|
||||
.HasDatabaseName("ix_bot_accounts_project_id");
|
||||
|
||||
b.ToTable("bot_accounts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<CloudFileReferenceObject>("Background")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("background");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<CustomAppLinks>("Links")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("links");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<CustomAppOauthConfig>("OauthConfig")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("oauth_config");
|
||||
|
||||
b.Property<CloudFileReferenceObject>("Picture")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("picture");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("project_id");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<VerificationMark>("Verification")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("verification");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_custom_apps");
|
||||
|
||||
b.HasIndex("ProjectId")
|
||||
.HasDatabaseName("ix_custom_apps_project_id");
|
||||
|
||||
b.ToTable("custom_apps", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AppId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("app_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<bool>("IsOidc")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_oidc");
|
||||
|
||||
b.Property<string>("Secret")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("secret");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_custom_app_secrets");
|
||||
|
||||
b.HasIndex("AppId")
|
||||
.HasDatabaseName("ix_custom_app_secrets_app_id");
|
||||
|
||||
b.ToTable("custom_app_secrets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("PublisherId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("publisher_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_developers");
|
||||
|
||||
b.ToTable("developers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", 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>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Guid>("DeveloperId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("developer_id");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_dev_projects");
|
||||
|
||||
b.HasIndex("DeveloperId")
|
||||
.HasDatabaseName("ix_dev_projects_developer_id");
|
||||
|
||||
b.ToTable("dev_projects", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_bot_accounts_dev_projects_project_id");
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_custom_apps_dev_projects_project_id");
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Identity.CustomApp", "App")
|
||||
.WithMany("Secrets")
|
||||
.HasForeignKey("AppId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_custom_app_secrets_custom_apps_app_id");
|
||||
|
||||
b.Navigation("App");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer")
|
||||
.WithMany("Projects")
|
||||
.HasForeignKey("DeveloperId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_dev_projects_developers_developer_id");
|
||||
|
||||
b.Navigation("Developer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.Navigation("Secrets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
|
||||
{
|
||||
b.Navigation("Projects");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
34
DysonNetwork.Develop/Program.cs
Normal file
34
DysonNetwork.Develop/Program.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using DysonNetwork.Develop;
|
||||
using DysonNetwork.Shared.Auth;
|
||||
using DysonNetwork.Shared.Http;
|
||||
using DysonNetwork.Develop.Startup;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
|
||||
builder.ConfigureAppKestrel(builder.Configuration);
|
||||
|
||||
builder.Services.AddAppServices(builder.Configuration);
|
||||
builder.Services.AddAppAuthentication();
|
||||
builder.Services.AddAppSwagger();
|
||||
builder.Services.AddDysonAuth();
|
||||
builder.Services.AddPublisherService();
|
||||
builder.Services.AddAccountService();
|
||||
builder.Services.AddDriveService();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapDefaultEndpoints();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
app.ConfigureAppMiddleware(builder.Configuration);
|
||||
|
||||
app.Run();
|
||||
16
DysonNetwork.Develop/Project/DevProject.cs
Normal file
16
DysonNetwork.Develop/Project/DevProject.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Develop.Identity;
|
||||
using DysonNetwork.Shared.Data;
|
||||
|
||||
namespace DysonNetwork.Develop.Project;
|
||||
|
||||
public class DevProject : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(1024)] public string Slug { get; set; } = string.Empty;
|
||||
[MaxLength(1024)] public string Name { get; set; } = string.Empty;
|
||||
[MaxLength(4096)] public string Description { get; set; } = string.Empty;
|
||||
|
||||
public Developer Developer { get; set; } = null!;
|
||||
public Guid DeveloperId { get; set; }
|
||||
}
|
||||
107
DysonNetwork.Develop/Project/DevProjectController.cs
Normal file
107
DysonNetwork.Develop/Project/DevProjectController.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Develop.Identity;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
|
||||
namespace DysonNetwork.Develop.Project;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/developers/{pubName}/projects")]
|
||||
public class DevProjectController(DevProjectService projectService, DeveloperService developerService) : ControllerBase
|
||||
{
|
||||
public record DevProjectRequest(
|
||||
[MaxLength(1024)] string? Slug,
|
||||
[MaxLength(1024)] string? Name,
|
||||
[MaxLength(4096)] string? Description
|
||||
);
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ListProjects([FromRoute] string pubName)
|
||||
{
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
if (developer is null) return NotFound();
|
||||
|
||||
var projects = await projectService.GetProjectsByDeveloperAsync(developer.Id);
|
||||
return Ok(projects);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IActionResult> GetProject([FromRoute] string pubName, Guid id)
|
||||
{
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
if (developer is null) return NotFound();
|
||||
|
||||
var project = await projectService.GetProjectAsync(id, developer.Id);
|
||||
if (project is null) return NotFound();
|
||||
|
||||
return Ok(project);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CreateProject([FromRoute] string pubName, [FromBody] DevProjectRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to create a project");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Slug) || string.IsNullOrWhiteSpace(request.Name))
|
||||
return BadRequest("Slug and Name are required");
|
||||
|
||||
var project = await projectService.CreateProjectAsync(developer, request);
|
||||
return CreatedAtAction(
|
||||
nameof(GetProject),
|
||||
new { pubName, id = project.Id },
|
||||
project
|
||||
);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> UpdateProject(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid id,
|
||||
[FromBody] DevProjectRequest request
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
if (developer is null || developer.Id != accountId)
|
||||
return Forbid();
|
||||
|
||||
var project = await projectService.UpdateProjectAsync(id, developer.Id, request);
|
||||
if (project is null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(project);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> DeleteProject([FromRoute] string pubName, [FromRoute] Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
if (developer is null || developer.Id != accountId)
|
||||
return Forbid();
|
||||
|
||||
var success = await projectService.DeleteProjectAsync(id, developer.Id);
|
||||
if (!success)
|
||||
return NotFound();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
77
DysonNetwork.Develop/Project/DevProjectService.cs
Normal file
77
DysonNetwork.Develop/Project/DevProjectService.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using DysonNetwork.Develop.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
|
||||
namespace DysonNetwork.Develop.Project;
|
||||
|
||||
public class DevProjectService(
|
||||
AppDatabase db,
|
||||
FileReferenceService.FileReferenceServiceClient fileRefs,
|
||||
FileService.FileServiceClient files
|
||||
)
|
||||
{
|
||||
public async Task<DevProject> CreateProjectAsync(
|
||||
Developer developer,
|
||||
DevProjectController.DevProjectRequest request
|
||||
)
|
||||
{
|
||||
var project = new DevProject
|
||||
{
|
||||
Slug = request.Slug!,
|
||||
Name = request.Name!,
|
||||
Description = request.Description ?? string.Empty,
|
||||
DeveloperId = developer.Id
|
||||
};
|
||||
|
||||
db.DevProjects.Add(project);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
public async Task<DevProject?> GetProjectAsync(Guid id, Guid? developerId = null)
|
||||
{
|
||||
var query = db.DevProjects.AsQueryable();
|
||||
|
||||
if (developerId.HasValue)
|
||||
{
|
||||
query = query.Where(p => p.DeveloperId == developerId.Value);
|
||||
}
|
||||
|
||||
return await query.FirstOrDefaultAsync(p => p.Id == id);
|
||||
}
|
||||
|
||||
public async Task<List<DevProject>> GetProjectsByDeveloperAsync(Guid developerId)
|
||||
{
|
||||
return await db.DevProjects
|
||||
.Where(p => p.DeveloperId == developerId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<DevProject?> UpdateProjectAsync(
|
||||
Guid id,
|
||||
Guid developerId,
|
||||
DevProjectController.DevProjectRequest request
|
||||
)
|
||||
{
|
||||
var project = await GetProjectAsync(id, developerId);
|
||||
if (project == null) return null;
|
||||
|
||||
if (request.Slug != null) project.Slug = request.Slug;
|
||||
if (request.Name != null) project.Name = request.Name;
|
||||
if (request.Description != null) project.Description = request.Description;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return project;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteProjectAsync(Guid id, Guid developerId)
|
||||
{
|
||||
var project = await GetProjectAsync(id, developerId);
|
||||
if (project == null) return false;
|
||||
|
||||
db.DevProjects.Remove(project);
|
||||
await db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5094",
|
||||
"applicationUrl": "http://localhost:5156",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7034;http://0.0.0.0:5094",
|
||||
"applicationUrl": "https://localhost:7192;http://localhost:5156",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
34
DysonNetwork.Develop/Startup/ApplicationConfiguration.cs
Normal file
34
DysonNetwork.Develop/Startup/ApplicationConfiguration.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System.Net;
|
||||
using DysonNetwork.Develop.Identity;
|
||||
using DysonNetwork.Shared.Auth;
|
||||
using DysonNetwork.Shared.Http;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Prometheus;
|
||||
|
||||
namespace DysonNetwork.Develop.Startup;
|
||||
|
||||
public static class ApplicationConfiguration
|
||||
{
|
||||
public static WebApplication ConfigureAppMiddleware(this WebApplication app, IConfiguration configuration)
|
||||
{
|
||||
app.MapMetrics();
|
||||
app.MapOpenApi();
|
||||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
|
||||
app.UseRequestLocalization();
|
||||
|
||||
app.ConfigureForwardedHeaders(configuration);
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseMiddleware<PermissionMiddleware>();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.MapGrpcService<CustomAppServiceGrpc>();
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
79
DysonNetwork.Develop/Startup/ServiceCollectionExtensions.cs
Normal file
79
DysonNetwork.Develop/Startup/ServiceCollectionExtensions.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.SystemTextJson;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Develop.Identity;
|
||||
using DysonNetwork.Develop.Project;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace DysonNetwork.Develop.Startup;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddLocalization();
|
||||
|
||||
services.AddDbContext<AppDatabase>();
|
||||
services.AddSingleton<IClock>(SystemClock.Instance);
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddSingleton<ICacheService, CacheServiceRedis>();
|
||||
|
||||
services.AddHttpClient();
|
||||
|
||||
services.AddControllers().AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
|
||||
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||
|
||||
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
|
||||
});
|
||||
|
||||
services.AddGrpc(options => { options.EnableDetailedErrors = true; });
|
||||
|
||||
services.Configure<RequestLocalizationOptions>(options =>
|
||||
{
|
||||
var supportedCultures = new[]
|
||||
{
|
||||
new CultureInfo("en-US"),
|
||||
new CultureInfo("zh-Hans"),
|
||||
};
|
||||
|
||||
options.SupportedCultures = supportedCultures;
|
||||
options.SupportedUICultures = supportedCultures;
|
||||
});
|
||||
|
||||
services.AddScoped<DeveloperService>();
|
||||
services.AddScoped<CustomAppService>();
|
||||
services.AddScoped<DevProjectService>();
|
||||
services.AddScoped<BotAccountService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
|
||||
{
|
||||
services.AddCors();
|
||||
services.AddAuthorization();
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAppSwagger(this IServiceCollection services)
|
||||
{
|
||||
services.AddEndpointsApiExplorer();
|
||||
services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.SwaggerDoc("v1", new OpenApiInfo
|
||||
{
|
||||
Version = "v1",
|
||||
Title = "Develop API",
|
||||
});
|
||||
});
|
||||
services.AddOpenApi();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
26
DysonNetwork.Develop/appsettings.json
Normal file
26
DysonNetwork.Develop/appsettings.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"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_network_dev;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
||||
},
|
||||
"KnownProxies": [
|
||||
"127.0.0.1",
|
||||
"::1"
|
||||
],
|
||||
"Etcd": {
|
||||
"Insecure": true
|
||||
},
|
||||
"Service": {
|
||||
"Name": "DysonNetwork.Develop",
|
||||
"Url": "https://localhost:7192"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
using DysonNetwork.Drive.Billing;
|
||||
using DysonNetwork.Drive.Storage;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -16,6 +17,9 @@ public class AppDatabase(
|
||||
) : DbContext(options)
|
||||
{
|
||||
public DbSet<FilePool> Pools { get; set; } = null!;
|
||||
public DbSet<FileBundle> Bundles { get; set; } = null!;
|
||||
|
||||
public DbSet<QuotaRecord> QuotaRecords { get; set; } = null!;
|
||||
|
||||
public DbSet<CloudFile> Files { get; set; } = null!;
|
||||
public DbSet<CloudFileReference> FileReferences { get; set; } = null!;
|
||||
@@ -27,7 +31,6 @@ public class AppDatabase(
|
||||
opt => opt
|
||||
.ConfigureDataSource(optSource => optSource.EnableDynamicJson())
|
||||
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
|
||||
.UseNetTopologySuite()
|
||||
.UseNodaTime()
|
||||
).UseSnakeCaseNamingConvention();
|
||||
|
||||
|
||||
28
DysonNetwork.Drive/Billing/Quota.cs
Normal file
28
DysonNetwork.Drive/Billing/Quota.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using DysonNetwork.Shared.Data;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Drive.Billing;
|
||||
|
||||
/// <summary>
|
||||
/// The quota record stands for the extra quota that a user has.
|
||||
/// For normal users, the quota is 1GiB.
|
||||
/// For stellar program t1 users, the quota is 5GiB
|
||||
/// For stellar program t2 users, the quota is 10GiB
|
||||
/// For stellar program t3 users, the quota is 15GiB
|
||||
///
|
||||
/// If users want to increase the quota, they need to pay for it.
|
||||
/// Each 1NSD they paid for one GiB.
|
||||
///
|
||||
/// But the quota record unit is MiB, the minimal billable unit.
|
||||
/// </summary>
|
||||
public class QuotaRecord : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid AccountId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public long Quota { get; set; }
|
||||
|
||||
public Instant? ExpiredAt { get; set; }
|
||||
}
|
||||
66
DysonNetwork.Drive/Billing/QuotaController.cs
Normal file
66
DysonNetwork.Drive/Billing/QuotaController.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Drive.Billing;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/billing/quota")]
|
||||
public class QuotaController(AppDatabase db, QuotaService quota) : ControllerBase
|
||||
{
|
||||
public class QuotaDetails
|
||||
{
|
||||
public long BasedQuota { get; set; }
|
||||
public long ExtraQuota { get; set; }
|
||||
public long TotalQuota { get; set; }
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<QuotaDetails>> GetQuota()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
|
||||
var (based, extra) = await quota.GetQuotaVerbose(accountId);
|
||||
return Ok(new QuotaDetails
|
||||
{
|
||||
BasedQuota = based,
|
||||
ExtraQuota = extra,
|
||||
TotalQuota = based + extra
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("records")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<QuotaRecord>>> GetQuotaRecords(
|
||||
[FromQuery] bool expired = false,
|
||||
[FromQuery] int offset = 0,
|
||||
[FromQuery] int take = 20
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var query = db.QuotaRecords
|
||||
.Where(r => r.AccountId == accountId)
|
||||
.AsQueryable();
|
||||
if (!expired)
|
||||
query = query
|
||||
.Where(r => !r.ExpiredAt.HasValue || r.ExpiredAt > now);
|
||||
|
||||
var total = await query.CountAsync();
|
||||
Response.Headers.Append("X-Total", total.ToString());
|
||||
|
||||
var records = await query
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(records);
|
||||
}
|
||||
}
|
||||
69
DysonNetwork.Drive/Billing/QuotaService.cs
Normal file
69
DysonNetwork.Drive/Billing/QuotaService.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using DysonNetwork.Shared.Auth;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Drive.Billing;
|
||||
|
||||
public class QuotaService(
|
||||
AppDatabase db,
|
||||
UsageService usage,
|
||||
AccountService.AccountServiceClient accounts,
|
||||
ICacheService cache
|
||||
)
|
||||
{
|
||||
public async Task<(bool ok, long billable, long quota)> IsFileAcceptable(Guid accountId, double costMultiplier, long newFileSize)
|
||||
{
|
||||
// The billable unit is MiB
|
||||
var billableUnit = (long)Math.Ceiling(newFileSize / 1024.0 / 1024.0 * costMultiplier);
|
||||
var totalBillableUsage = await usage.GetTotalBillableUsage(accountId);
|
||||
var quota = await GetQuota(accountId);
|
||||
return (totalBillableUsage + billableUnit <= quota, billableUnit, quota);
|
||||
}
|
||||
|
||||
public async Task<long> GetQuota(Guid accountId)
|
||||
{
|
||||
var cacheKey = $"file:quota:{accountId}";
|
||||
var cachedResult = await cache.GetAsync<long?>(cacheKey);
|
||||
if (cachedResult.HasValue) return cachedResult.Value;
|
||||
|
||||
var (based, extra) = await GetQuotaVerbose(accountId);
|
||||
var quota = based + extra;
|
||||
await cache.SetAsync(cacheKey, quota);
|
||||
return quota;
|
||||
}
|
||||
|
||||
public async Task<(long based, long extra)> GetQuotaVerbose(Guid accountId)
|
||||
{
|
||||
|
||||
|
||||
var response = await accounts.GetAccountAsync(new GetAccountRequest { Id = accountId.ToString() });
|
||||
var perkSubscription = response.PerkSubscription;
|
||||
|
||||
// The base quota is 1GiB, T1 is 5GiB, T2 is 10GiB, T3 is 15GiB
|
||||
var basedQuota = 1L;
|
||||
if (perkSubscription != null)
|
||||
{
|
||||
var privilege = PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(perkSubscription.Identifier);
|
||||
basedQuota = privilege switch
|
||||
{
|
||||
1 => 5L,
|
||||
2 => 10L,
|
||||
3 => 15L,
|
||||
_ => basedQuota
|
||||
};
|
||||
}
|
||||
|
||||
// The based quota is in GiB, we need to convert it to MiB
|
||||
basedQuota *= 1024L;
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var extraQuota = await db.QuotaRecords
|
||||
.Where(e => e.AccountId == accountId)
|
||||
.Where(e => !e.ExpiredAt.HasValue || e.ExpiredAt > now)
|
||||
.SumAsync(e => e.Quota);
|
||||
|
||||
return (basedQuota, extraQuota);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,49 @@
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DysonNetwork.Drive.Billing;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/billing/usage")]
|
||||
public class UsageController(UsageService usageService) : ControllerBase
|
||||
public class UsageController(UsageService usage, QuotaService quota, ICacheService cache) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<TotalUsageDetails>> GetTotalUsage()
|
||||
{
|
||||
return await usageService.GetTotalUsage();
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
|
||||
var cacheKey = $"file:usage:{accountId}";
|
||||
|
||||
// Try to get from cache first
|
||||
var (found, cachedResult) = await cache.GetAsyncWithStatus<TotalUsageDetails>(cacheKey);
|
||||
if (found && cachedResult != null)
|
||||
return Ok(cachedResult);
|
||||
|
||||
// If not in cache, get from services
|
||||
var result = await usage.GetTotalUsage(accountId);
|
||||
var totalQuota = await quota.GetQuota(accountId);
|
||||
result.TotalQuota = totalQuota;
|
||||
|
||||
// Cache the result for 5 minutes
|
||||
await cache.SetAsync(cacheKey, result, TimeSpan.FromMinutes(5));
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpGet("{poolId:guid}")]
|
||||
public async Task<ActionResult<UsageDetails>> GetPoolUsage(Guid poolId)
|
||||
{
|
||||
var usageDetails = await usageService.GetPoolUsage(poolId);
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
|
||||
var usageDetails = await usage.GetPoolUsage(poolId, accountId);
|
||||
if (usageDetails == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
return usageDetails;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Drive.Billing;
|
||||
|
||||
@@ -11,64 +12,59 @@ public class UsageDetails
|
||||
public required long FileCount { get; set; }
|
||||
}
|
||||
|
||||
public class UsageDetailsWithPercentage : UsageDetails
|
||||
{
|
||||
public required double Percentage { get; set; }
|
||||
}
|
||||
|
||||
public class TotalUsageDetails
|
||||
{
|
||||
public required List<UsageDetailsWithPercentage> PoolUsages { get; set; }
|
||||
public required List<UsageDetails> PoolUsages { get; set; }
|
||||
public required long TotalUsageBytes { get; set; }
|
||||
public required double TotalCost { get; set; }
|
||||
public required long TotalFileCount { get; set; }
|
||||
|
||||
// Quota, cannot be loaded in the service, cause circular dependency
|
||||
// Let the controller do the calculation
|
||||
public long? TotalQuota { get; set; }
|
||||
public long? UsedQuota { get; set; }
|
||||
}
|
||||
|
||||
public class UsageService(AppDatabase db)
|
||||
{
|
||||
public async Task<TotalUsageDetails> GetTotalUsage()
|
||||
public async Task<TotalUsageDetails> GetTotalUsage(Guid accountId)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var fileQuery = db.Files
|
||||
.Where(f => !f.IsMarkedRecycle)
|
||||
.Where(f => !f.ExpiredAt.HasValue || f.ExpiredAt > now)
|
||||
.Where(f => f.AccountId == accountId)
|
||||
.AsQueryable();
|
||||
|
||||
var poolUsages = await db.Pools
|
||||
.Select(p => new UsageDetails
|
||||
{
|
||||
PoolId = p.Id,
|
||||
PoolName = p.Name,
|
||||
UsageBytes = db.Files
|
||||
UsageBytes = fileQuery
|
||||
.Where(f => f.PoolId == p.Id)
|
||||
.Sum(f => f.Size),
|
||||
Cost = db.Files
|
||||
Cost = fileQuery
|
||||
.Where(f => f.PoolId == p.Id)
|
||||
.Sum(f => f.Size) / 1024.0 / 1024.0 *
|
||||
(p.BillingConfig.CostMultiplier ?? 1.0),
|
||||
FileCount = db.Files
|
||||
FileCount = fileQuery
|
||||
.Count(f => f.PoolId == p.Id)
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var totalUsage = poolUsages.Sum(p => p.UsageBytes);
|
||||
var totalCost = poolUsages.Sum(p => p.Cost);
|
||||
var totalFileCount = poolUsages.Sum(p => p.FileCount);
|
||||
|
||||
var poolUsagesWithPercentage = poolUsages.Select(p => new UsageDetailsWithPercentage
|
||||
{
|
||||
PoolId = p.PoolId,
|
||||
PoolName = p.PoolName,
|
||||
UsageBytes = p.UsageBytes,
|
||||
Cost = p.Cost,
|
||||
FileCount = p.FileCount,
|
||||
Percentage = totalUsage > 0 ? (double)p.UsageBytes / totalUsage : 0
|
||||
}).ToList();
|
||||
|
||||
return new TotalUsageDetails
|
||||
{
|
||||
PoolUsages = poolUsagesWithPercentage,
|
||||
PoolUsages = poolUsages,
|
||||
TotalUsageBytes = totalUsage,
|
||||
TotalCost = totalCost,
|
||||
TotalFileCount = totalFileCount
|
||||
TotalFileCount = totalFileCount,
|
||||
UsedQuota = await GetTotalBillableUsage(accountId)
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<UsageDetails?> GetPoolUsage(Guid poolId)
|
||||
public async Task<UsageDetails?> GetPoolUsage(Guid poolId, Guid accountId)
|
||||
{
|
||||
var pool = await db.Pools.FindAsync(poolId);
|
||||
if (pool == null)
|
||||
@@ -76,12 +72,17 @@ public class UsageService(AppDatabase db)
|
||||
return null;
|
||||
}
|
||||
|
||||
var usageBytes = await db.Files
|
||||
.Where(f => f.PoolId == poolId)
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var fileQuery = db.Files
|
||||
.Where(f => !f.IsMarkedRecycle)
|
||||
.Where(f => f.ExpiredAt.HasValue && f.ExpiredAt > now)
|
||||
.Where(f => f.AccountId == accountId)
|
||||
.AsQueryable();
|
||||
|
||||
var usageBytes = await fileQuery
|
||||
.SumAsync(f => f.Size);
|
||||
|
||||
var fileCount = await db.Files
|
||||
.Where(f => f.PoolId == poolId)
|
||||
var fileCount = await fileQuery
|
||||
.CountAsync();
|
||||
|
||||
var cost = usageBytes / 1024.0 / 1024.0 *
|
||||
@@ -96,4 +97,25 @@ public class UsageService(AppDatabase db)
|
||||
FileCount = fileCount
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<long> GetTotalBillableUsage(Guid accountId)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var files = await db.Files
|
||||
.Where(f => f.AccountId == accountId)
|
||||
.Where(f => f.PoolId.HasValue)
|
||||
.Where(f => !f.IsMarkedRecycle)
|
||||
.Include(f => f.Pool)
|
||||
.Where(f => !f.ExpiredAt.HasValue || f.ExpiredAt > now)
|
||||
.Select(f => new
|
||||
{
|
||||
f.Size,
|
||||
Multiplier = f.Pool!.BillingConfig.CostMultiplier ?? 1.0
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var totalCost = files.Sum(f => f.Size * f.Multiplier) / 1024.0 / 1024.0;
|
||||
|
||||
return (long)Math.Ceiling(totalCost);
|
||||
}
|
||||
}
|
||||
50
DysonNetwork.Drive/Client/src/components/BundleSelect.vue
Normal file
50
DysonNetwork.Drive/Client/src/components/BundleSelect.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<n-select
|
||||
v-model:value="selectedBundle"
|
||||
:options="options"
|
||||
placeholder="Select a bundle"
|
||||
@update:value="handleBundleChange"
|
||||
filterable
|
||||
remote
|
||||
:loading="loading"
|
||||
@search="handleSearch"
|
||||
clearable
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NSelect } from 'naive-ui'
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const emit = defineEmits(['update:bundle'])
|
||||
|
||||
const selectedBundle = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
const options = ref<any[]>([])
|
||||
|
||||
async function fetchBundles(term: string | null = null) {
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await fetch(`/api/bundles/me?${term ? `term=${term}` : ''}`)
|
||||
const data = await resp.json()
|
||||
options.value = data.map((bundle: any) => ({
|
||||
label: bundle.name,
|
||||
value: bundle.id,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch bundles:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch(query: string) {
|
||||
fetchBundles(query)
|
||||
}
|
||||
|
||||
function handleBundleChange(value: string) {
|
||||
emit('update:bundle', value)
|
||||
}
|
||||
|
||||
onMounted(() => fetchBundles())
|
||||
</script>
|
||||
199
DysonNetwork.Drive/Client/src/components/FilePoolSelect.vue
Normal file
199
DysonNetwork.Drive/Client/src/components/FilePoolSelect.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<n-select
|
||||
:value="modelValue"
|
||||
@update:value="onUpdate"
|
||||
:options="pools ?? []"
|
||||
:render-label="renderPoolSelectLabel"
|
||||
:render-tag="renderSingleSelectTag"
|
||||
value-field="id"
|
||||
label-field="name"
|
||||
:placeholder="props.placeholder || 'Select a file pool to upload'"
|
||||
:size="props.size || 'large'"
|
||||
clearable
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
NSelect,
|
||||
NTag,
|
||||
NDivider,
|
||||
NTooltip,
|
||||
type SelectOption,
|
||||
type SelectRenderTag,
|
||||
} from 'naive-ui'
|
||||
import { h, onMounted, ref, watch } from 'vue'
|
||||
import type { SnFilePool } from '@/types/pool'
|
||||
import { formatBytes } from '@/views/format'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | null
|
||||
placeholder?: string | undefined
|
||||
size?: 'tiny' | 'small' | 'medium' | 'large' | undefined
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:pool'])
|
||||
|
||||
type SnFilePoolOption = SnFilePool & any
|
||||
|
||||
const pools = ref<SnFilePoolOption[] | undefined>()
|
||||
async function fetchPools() {
|
||||
const resp = await fetch('/api/pools')
|
||||
pools.value = await resp.json()
|
||||
}
|
||||
onMounted(() => fetchPools())
|
||||
|
||||
function onUpdate(value: string | null) {
|
||||
emit('update:modelValue', value)
|
||||
if (value === null) {
|
||||
emit('update:pool', null)
|
||||
return
|
||||
}
|
||||
if (pools.value) {
|
||||
const pool = pools.value.find((p) => p.id === value) ?? null
|
||||
emit('update:pool', pool)
|
||||
}
|
||||
}
|
||||
|
||||
watch(pools, (newPools) => {
|
||||
if (props.modelValue && newPools) {
|
||||
const pool = newPools.find((p) => p.id === props.modelValue) ?? null
|
||||
emit('update:pool', pool)
|
||||
}
|
||||
})
|
||||
|
||||
const renderSingleSelectTag: SelectRenderTag = ({ option }) => {
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
},
|
||||
[option.name as string],
|
||||
)
|
||||
}
|
||||
|
||||
const perkPrivilegeList = ['Stellar', 'Nova', 'Supernova']
|
||||
|
||||
function renderPoolSelectLabel(option: SelectOption & SnFilePool) {
|
||||
const policy: any = option.policy_config
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
padding: '8px 2px',
|
||||
},
|
||||
},
|
||||
[
|
||||
h('div', null, [option.name as string]),
|
||||
option.description &&
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
fontSize: '0.875rem',
|
||||
opacity: '0.75',
|
||||
},
|
||||
},
|
||||
option.description,
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
display: 'flex',
|
||||
marginBottom: '4px',
|
||||
fontSize: '0.75rem',
|
||||
opacity: '0.75',
|
||||
},
|
||||
},
|
||||
[
|
||||
policy.max_file_size && h('span', `Max ${formatBytes(policy.max_file_size)}`),
|
||||
policy.accept_types &&
|
||||
h(
|
||||
NTooltip,
|
||||
{},
|
||||
{
|
||||
trigger: () => h('span', `Accept limited types`),
|
||||
default: () => h('span', policy.accept_types.join(', ')),
|
||||
},
|
||||
),
|
||||
policy.require_privilege &&
|
||||
h('span', `Require ${perkPrivilegeList[policy.require_privilege - 1]} Program`),
|
||||
h('span', `Cost x${option.billing_config.cost_multiplier.toFixed(1)}`),
|
||||
]
|
||||
.filter((el) => el)
|
||||
.flatMap((el, idx, arr) =>
|
||||
idx < arr.length - 1 ? [el, h(NDivider, { vertical: true })] : [el],
|
||||
),
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
display: 'flex',
|
||||
gap: '0.25rem',
|
||||
marginTop: '2px',
|
||||
marginLeft: '-2px',
|
||||
marginRight: '-2px',
|
||||
},
|
||||
},
|
||||
[
|
||||
policy.public_usable &&
|
||||
h(
|
||||
NTag,
|
||||
{
|
||||
type: 'info',
|
||||
size: 'small',
|
||||
round: true,
|
||||
},
|
||||
{ default: () => 'Public Shared' },
|
||||
),
|
||||
policy.public_indexable &&
|
||||
h(
|
||||
NTag,
|
||||
{
|
||||
type: 'success',
|
||||
size: 'small',
|
||||
round: true,
|
||||
},
|
||||
{ default: () => 'Public Indexable' },
|
||||
),
|
||||
policy.allow_encryption &&
|
||||
h(
|
||||
NTag,
|
||||
{
|
||||
type: 'warning',
|
||||
size: 'small',
|
||||
round: true,
|
||||
},
|
||||
{ default: () => 'Allow Encryption' },
|
||||
),
|
||||
policy.allow_anonymous &&
|
||||
h(
|
||||
NTag,
|
||||
{
|
||||
type: 'info',
|
||||
size: 'small',
|
||||
round: true,
|
||||
},
|
||||
{ default: () => 'Allow Anonymous' },
|
||||
),
|
||||
policy.enable_recycle &&
|
||||
h(
|
||||
NTag,
|
||||
{
|
||||
type: 'info',
|
||||
size: 'small',
|
||||
round: true,
|
||||
},
|
||||
{ default: () => 'Recycle Enabled' },
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
</script>
|
||||
271
DysonNetwork.Drive/Client/src/components/UploadArea.vue
Normal file
271
DysonNetwork.Drive/Client/src/components/UploadArea.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-collapse-transition :show="showRecycleHint">
|
||||
<n-alert size="small" type="warning" title="Recycle Enabled" class="mb-3">
|
||||
You're uploading to a pool which enabled recycle. If the file you uploaded didn't referenced
|
||||
from the Solar Network. It will be marked and will be deleted some while later.
|
||||
</n-alert>
|
||||
</n-collapse-transition>
|
||||
|
||||
<n-collapse-transition :show="modeAdvanced">
|
||||
<n-card title="Advance Options" size="small" class="mb-3">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<p class="pl-1 mb-0.5">File Password</p>
|
||||
<n-input
|
||||
v-model:value="filePass"
|
||||
:disabled="!currentFilePool?.allow_encryption"
|
||||
placeholder="Enter password to protect the file"
|
||||
show-password-toggle
|
||||
size="large"
|
||||
type="password"
|
||||
class="mb-2"
|
||||
/>
|
||||
<p class="pl-1 text-xs opacity-75 mt-[-4px]">
|
||||
Only available for Stellar Program and certian file pool.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="pl-1 mb-0.5">File Expiration Date</p>
|
||||
<n-date-picker
|
||||
v-model:value="fileExpire"
|
||||
type="datetime"
|
||||
clearable
|
||||
:is-date-disabled="disablePreviousDate"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="currentFilePool?.policy_config?.enable_fast_upload || route.query.pool"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<p class="pl-1 mb-0.5">Fast Upload</p>
|
||||
<n-switch v-model:value="fastUpload" />
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-collapse-transition>
|
||||
|
||||
<n-upload
|
||||
multiple
|
||||
directory-dnd
|
||||
with-credentials
|
||||
show-preview-button
|
||||
list-type="image"
|
||||
show-download-button
|
||||
:custom-request="customRequest"
|
||||
:custom-download="customDownload"
|
||||
:create-thumbnail-url="createThumbnailUrl"
|
||||
@preview="customPreview"
|
||||
>
|
||||
<n-upload-dragger>
|
||||
<div style="margin-bottom: 12px">
|
||||
<n-icon size="48" :depth="3">
|
||||
<cloud-upload-round />
|
||||
</n-icon>
|
||||
</div>
|
||||
<n-text style="font-size: 16px"> Click or drag a file to this area to upload </n-text>
|
||||
<n-p depth="3" style="margin: 8px 0 0 0">
|
||||
Strictly prohibit from uploading sensitive information. For example, your bank card PIN or
|
||||
your credit card expiry date.
|
||||
</n-p>
|
||||
</n-upload-dragger>
|
||||
</n-upload>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
NUpload,
|
||||
NUploadDragger,
|
||||
NIcon,
|
||||
NText,
|
||||
NP,
|
||||
NInput,
|
||||
NCollapseTransition,
|
||||
NDatePicker,
|
||||
NAlert,
|
||||
NCard,
|
||||
NSwitch,
|
||||
type UploadCustomRequestOptions,
|
||||
type UploadSettledFileInfo,
|
||||
type UploadFileInfo,
|
||||
useMessage,
|
||||
} from 'naive-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { CloudUploadRound } from '@vicons/material'
|
||||
import type { SnFilePool } from '@/types/pool'
|
||||
|
||||
import * as tus from 'tus-js-client'
|
||||
|
||||
const props = defineProps<{
|
||||
filePool: string | null
|
||||
modeAdvanced: boolean
|
||||
pools: SnFilePool[]
|
||||
bundleId?: string
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const filePass = ref<string>('')
|
||||
const fileExpire = ref<number | null>(null)
|
||||
const fastUpload = ref<boolean>(false)
|
||||
|
||||
const effectiveFilePool = computed(() => (route.query.pool as string) || props.filePool)
|
||||
|
||||
const currentFilePool = computed(() => {
|
||||
if (!effectiveFilePool.value) return null
|
||||
return props.pools?.find((pool) => pool.id === effectiveFilePool.value) ?? null
|
||||
})
|
||||
const showRecycleHint = computed(() => {
|
||||
if (!effectiveFilePool.value) return true
|
||||
return currentFilePool.value?.policy_config?.enable_recycle || false
|
||||
})
|
||||
|
||||
const messageDisplay = useMessage()
|
||||
|
||||
async function customRequest({
|
||||
file,
|
||||
headers,
|
||||
withCredentials,
|
||||
onFinish,
|
||||
onError,
|
||||
onProgress,
|
||||
}: UploadCustomRequestOptions) {
|
||||
if (fastUpload.value) {
|
||||
const hash = await crypto.subtle.digest('SHA-256', await file.file!.arrayBuffer())
|
||||
const hashString = Array.from(new Uint8Array(hash))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
|
||||
const resp = await fetch('/api/files/fast', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: file.name,
|
||||
size: file.file?.size,
|
||||
hash: hashString,
|
||||
mime_type: file.file?.type,
|
||||
pool_id: effectiveFilePool.value,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!resp.ok) {
|
||||
messageDisplay.error(`Failed to get presigned URL: ${await resp.text()}`)
|
||||
onError()
|
||||
return
|
||||
}
|
||||
|
||||
const respData = await resp.json()
|
||||
const url = respData.fast_upload_link
|
||||
|
||||
try {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('PUT', url, true)
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
onProgress({ percent: (event.loaded / event.total) * 100 })
|
||||
}
|
||||
}
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
onFinish()
|
||||
} else {
|
||||
messageDisplay.error(`Upload failed: ${xhr.responseText}`)
|
||||
onError()
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => {
|
||||
messageDisplay.error('Upload failed due to a network error.')
|
||||
onError()
|
||||
}
|
||||
xhr.send(file.file)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
messageDisplay.error(`Upload failed: ${e}`)
|
||||
onError()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const requestHeaders: Record<string, string> = {}
|
||||
if (effectiveFilePool.value) requestHeaders['X-FilePool'] = effectiveFilePool.value
|
||||
if (filePass.value) requestHeaders['X-FilePass'] = filePass.value
|
||||
if (fileExpire.value) requestHeaders['X-FileExpire'] = fileExpire.value.toString()
|
||||
if (props.bundleId) requestHeaders['X-FileBundle'] = props.bundleId
|
||||
const upload = new tus.Upload(file.file as any, {
|
||||
endpoint: '/api/tus',
|
||||
retryDelays: [0, 3000, 5000, 10000, 20000],
|
||||
removeFingerprintOnSuccess: false,
|
||||
uploadDataDuringCreation: false,
|
||||
metadata: {
|
||||
filename: file.name,
|
||||
'content-type': file.type ?? 'application/octet-stream',
|
||||
},
|
||||
headers: {
|
||||
'X-DirectUpload': 'true',
|
||||
...requestHeaders,
|
||||
...headers,
|
||||
},
|
||||
onShouldRetry: () => false,
|
||||
onError: function (error) {
|
||||
if (error instanceof tus.DetailedError) {
|
||||
const failedBody = error.originalResponse?.getBody()
|
||||
if (failedBody != null)
|
||||
messageDisplay.error(`Upload failed: ${failedBody}`, {
|
||||
duration: 10000,
|
||||
closable: true,
|
||||
})
|
||||
}
|
||||
console.error('[DRIVE] Upload failed:', error)
|
||||
onError()
|
||||
},
|
||||
onProgress: function (bytesUploaded, bytesTotal) {
|
||||
onProgress({ percent: (bytesUploaded / bytesTotal) * 100 })
|
||||
},
|
||||
onSuccess: function (payload) {
|
||||
const rawInfo = payload.lastResponse.getHeader('x-fileinfo')
|
||||
const jsonInfo = JSON.parse(rawInfo as string)
|
||||
console.log('[DRIVE] Upload successful: ', jsonInfo)
|
||||
file.url = `/api/files/${jsonInfo.id}`
|
||||
file.type = jsonInfo.mime_type
|
||||
onFinish()
|
||||
},
|
||||
onBeforeRequest: function (req) {
|
||||
const xhr = req.getUnderlyingObject()
|
||||
xhr.withCredentials = withCredentials
|
||||
},
|
||||
})
|
||||
upload.findPreviousUploads().then(function (previousUploads) {
|
||||
if (previousUploads.length) {
|
||||
upload.resumeFromPreviousUpload(previousUploads[0])
|
||||
}
|
||||
upload.start()
|
||||
})
|
||||
}
|
||||
|
||||
function createThumbnailUrl(
|
||||
_file: File | null,
|
||||
fileInfo: UploadSettledFileInfo,
|
||||
): string | undefined {
|
||||
if (!fileInfo) return undefined
|
||||
return fileInfo.url ?? undefined
|
||||
}
|
||||
|
||||
function customDownload(file: UploadFileInfo) {
|
||||
const { url } = file
|
||||
if (!url) return
|
||||
window.open(url.replace('/api', ''), '_blank')
|
||||
}
|
||||
|
||||
function customPreview(file: UploadFileInfo, detail: { event: MouseEvent }) {
|
||||
detail.event.preventDefault()
|
||||
const { url } = file
|
||||
if (!url) return
|
||||
window.open(url.replace('/api', ''), '_blank')
|
||||
}
|
||||
|
||||
function disablePreviousDate(ts: number) {
|
||||
return ts <= Date.now()
|
||||
}
|
||||
</script>
|
||||
75
DysonNetwork.Drive/Client/src/components/form/BundleForm.vue
Normal file
75
DysonNetwork.Drive/Client/src/components/form/BundleForm.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<n-form :model="formValue" :rules="rules" ref="formRef">
|
||||
<n-form-item label="Slug" path="slug">
|
||||
<n-input v-model:value="formValue.slug" placeholder="Input Slug" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Name" path="name">
|
||||
<n-input v-model:value="formValue.name" placeholder="Input Name" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Description" path="description">
|
||||
<n-input
|
||||
v-model:value="formValue.description"
|
||||
placeholder="Input Description"
|
||||
type="textarea"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="Passcode" path="passcode">
|
||||
<n-input
|
||||
v-model:value="formValue.passcode"
|
||||
placeholder="Input Passcode"
|
||||
type="password"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="Expired At" path="expiredAt">
|
||||
<n-date-picker v-model:value="formValue.expiredAt" type="datetime" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NDatePicker,
|
||||
type FormInst,
|
||||
type FormRules,
|
||||
} from 'naive-ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const formRef = ref<FormInst | null>(null)
|
||||
|
||||
const props = defineProps<{ value: any }>()
|
||||
const formValue = ref(props.value)
|
||||
|
||||
const rules: FormRules = {
|
||||
slug: [
|
||||
{
|
||||
max: 1024,
|
||||
message: 'Slug can be at most 1024 characters long',
|
||||
},
|
||||
],
|
||||
name: [
|
||||
{
|
||||
max: 1024,
|
||||
message: 'Name can be at most 1024 characters long',
|
||||
},
|
||||
],
|
||||
description: [
|
||||
{
|
||||
max: 8192,
|
||||
message: 'Description can be at most 8192 characters long',
|
||||
},
|
||||
],
|
||||
passcode: [
|
||||
{
|
||||
max: 256,
|
||||
message: 'Passcode can be at most 256 characters long',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
formRef,
|
||||
})
|
||||
</script>
|
||||
7
DysonNetwork.Drive/Client/src/dy-prefetch.d.ts
vendored
Normal file
7
DysonNetwork.Drive/Client/src/dy-prefetch.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
export {}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
DyPrefetch?: any
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DataUsageRound } from '@vicons/material'
|
||||
import {
|
||||
DataUsageRound,
|
||||
AllInboxFilled,
|
||||
PermDataSettingRound,
|
||||
ShoppingBagRound,
|
||||
} from '@vicons/material'
|
||||
import { NIcon, NLayout, NLayoutSider, NMenu, type MenuOption } from 'naive-ui'
|
||||
import { h, type Component } from 'vue'
|
||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||
@@ -34,6 +39,21 @@ const menuOptions: MenuOption[] = [
|
||||
key: 'dashboardUsage',
|
||||
icon: renderIcon(DataUsageRound),
|
||||
},
|
||||
{
|
||||
label: 'Files',
|
||||
key: 'dashboardFiles',
|
||||
icon: renderIcon(AllInboxFilled),
|
||||
},
|
||||
{
|
||||
label: 'Bundles',
|
||||
key: 'dashboardBundles',
|
||||
icon: renderIcon(ShoppingBagRound),
|
||||
},
|
||||
{
|
||||
label: 'Quota',
|
||||
key: 'dashboardQuota',
|
||||
icon: renderIcon(PermDataSettingRound),
|
||||
},
|
||||
]
|
||||
|
||||
function updateMenuSelect(key: string) {
|
||||
|
||||
@@ -64,7 +64,7 @@ const guestOptions = [
|
||||
|
||||
const userOptions = computed(() => [
|
||||
{
|
||||
label: 'Usage',
|
||||
label: 'Dashboard',
|
||||
key: 'dashboardUsage',
|
||||
icon: () =>
|
||||
h(NIcon, null, {
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
import LayoutDefault from './layouts/default.vue'
|
||||
|
||||
import { RouterView } from 'vue-router'
|
||||
import { NGlobalStyle, NConfigProvider, NMessageProvider, lightTheme, darkTheme } from 'naive-ui'
|
||||
import {
|
||||
NGlobalStyle,
|
||||
NConfigProvider,
|
||||
NMessageProvider,
|
||||
NDialogProvider,
|
||||
NLoadingBarProvider,
|
||||
lightTheme,
|
||||
darkTheme,
|
||||
} from 'naive-ui'
|
||||
import { usePreferredDark } from '@vueuse/core'
|
||||
import { useUserStore } from './stores/user'
|
||||
import { onMounted } from 'vue'
|
||||
@@ -34,10 +42,14 @@ onMounted(() => {
|
||||
<template>
|
||||
<n-config-provider :theme-overrides="themeOverrides" :theme="isDark ? darkTheme : lightTheme">
|
||||
<n-global-style />
|
||||
<n-loading-bar-provider>
|
||||
<n-dialog-provider>
|
||||
<n-message-provider placement="bottom">
|
||||
<layout-default>
|
||||
<router-view />
|
||||
</layout-default>
|
||||
</n-message-provider>
|
||||
</n-dialog-provider>
|
||||
</n-loading-bar-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
@@ -15,6 +15,11 @@ const router = createRouter({
|
||||
name: 'files',
|
||||
component: () => import('../views/files.vue'),
|
||||
},
|
||||
{
|
||||
path: '/bundles/:bundleId',
|
||||
name: 'bundleDetails',
|
||||
component: () => import('../views/bundles.vue'),
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'dashboard',
|
||||
@@ -27,6 +32,24 @@ const router = createRouter({
|
||||
component: () => import('../views/dashboard/usage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'files',
|
||||
name: 'dashboardFiles',
|
||||
component: () => import('../views/dashboard/files.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'bundles',
|
||||
name: 'dashboardBundles',
|
||||
component: () => import('../views/dashboard/bundles.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'quotas',
|
||||
name: 'dashboardQuota',
|
||||
component: () => import('../views/dashboard/quotas.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -42,7 +65,7 @@ router.beforeEach(async (to, from, next) => {
|
||||
const servicesStore = useServicesStore()
|
||||
|
||||
// Initialize user state if not already initialized
|
||||
if (!userStore.user && localStorage.getItem('authToken')) {
|
||||
if (!userStore.user) {
|
||||
await userStore.fetchUser()
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export const useServicesStore = defineStore('services', () => {
|
||||
}
|
||||
|
||||
function getSerivceUrl(serviceName: string, ...parts: string[]): string | null {
|
||||
let baseUrl = services.value[serviceName] || null
|
||||
const baseUrl = services.value[serviceName] || null
|
||||
return baseUrl ? `${baseUrl}/${parts.join('/')}` : null
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface SnFilePool {
|
||||
description: string
|
||||
storage_config: StorageConfig
|
||||
billing_config: BillingConfig
|
||||
policy_config: any
|
||||
public_indexable: boolean
|
||||
public_usable: boolean
|
||||
no_optimization: boolean
|
||||
|
||||
255
DysonNetwork.Drive/Client/src/views/bundles.vue
Normal file
255
DysonNetwork.Drive/Client/src/views/bundles.vue
Normal file
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<section class="min-h-full relative flex items-center justify-center">
|
||||
<n-spin v-if="!bundleInfo && !error" />
|
||||
<n-result
|
||||
status="404"
|
||||
title="No bundle was found"
|
||||
:description="error"
|
||||
v-else-if="error === '404'"
|
||||
/>
|
||||
|
||||
<n-card class="max-w-md my-4 mx-8" v-else-if="error === '403'">
|
||||
<n-result
|
||||
status="403"
|
||||
title="Access Denied"
|
||||
description="This bundle is protected by a passcode"
|
||||
class="mt-5 mb-2"
|
||||
>
|
||||
<template #footer>
|
||||
<n-alert v-if="passcodeError" type="error" class="mb-3">
|
||||
{{ passcodeError }}
|
||||
</n-alert>
|
||||
<n-input
|
||||
v-model:value="passcode"
|
||||
type="password"
|
||||
show-password-on="mousedown"
|
||||
placeholder="Passcode"
|
||||
@keyup.enter="fetchBundleInfo"
|
||||
class="mb-3"
|
||||
/>
|
||||
<n-button type="primary" block @click="fetchBundleInfo">Access Bundle</n-button>
|
||||
</template>
|
||||
</n-result>
|
||||
</n-card>
|
||||
|
||||
<n-card class="max-w-4xl my-4 mx-8" v-else>
|
||||
<n-grid cols="1 m:2" x-gap="16" y-gap="16" responsive="screen">
|
||||
<n-gi>
|
||||
<n-card title="Content" size="small">
|
||||
<n-list
|
||||
size="small"
|
||||
v-if="bundleInfo.files && bundleInfo.files.length > 0"
|
||||
style="padding: 0"
|
||||
>
|
||||
<n-list-item v-for="file in bundleInfo.files" :key="file.id">
|
||||
<n-thing :title="file.name" :description="formatBytes(file.size)">
|
||||
<template #header-extra>
|
||||
<n-button text type="primary" @click="goToFileDetails(file.id)">View</n-button>
|
||||
</template>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
<n-empty v-else description="No files in this bundle" />
|
||||
<template #footer>
|
||||
<n-collapse-transition :show="!!downloadProgress">
|
||||
<n-progress
|
||||
type="line"
|
||||
:percentage="downloadProgress"
|
||||
indicator-placement="inside"
|
||||
:status="downloadStatus"
|
||||
processing
|
||||
class="mb-4"
|
||||
/>
|
||||
</n-collapse-transition>
|
||||
<n-button
|
||||
type="primary"
|
||||
block
|
||||
:disabled="!bundleInfo.files || bundleInfo.files.length === 0 || downloading"
|
||||
@click="downloadAllFiles"
|
||||
>
|
||||
Download All
|
||||
</n-button>
|
||||
</template>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
|
||||
<n-gi>
|
||||
<n-card size="small">
|
||||
<h3 class="text-lg">{{ bundleInfo.name }}</h3>
|
||||
<p class="mb-3" v-if="bundleInfo.description">{{ bundleInfo.description }}</p>
|
||||
<div class="flex gap-2">
|
||||
<span class="flex-grow-1 flex items-center gap-2">
|
||||
<n-icon>
|
||||
<calendar-today-round />
|
||||
</n-icon>
|
||||
Expires At
|
||||
</span>
|
||||
<span>{{
|
||||
bundleInfo.expiredAt ? new Date(bundleInfo.expiredAt).toLocaleString() : 'Never'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<span class="flex-grow-1 flex items-center gap-2">
|
||||
<n-icon>
|
||||
<lock-round />
|
||||
</n-icon>
|
||||
Passcode Protected
|
||||
</span>
|
||||
<span>{{ bundleInfo.passcode ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
</n-card>
|
||||
<n-input
|
||||
v-model:value="filePass"
|
||||
type="password"
|
||||
size="large"
|
||||
placeholder="File password file decrypt"
|
||||
class="mt-3"
|
||||
/>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
NCard,
|
||||
NResult,
|
||||
NSpin,
|
||||
NIcon,
|
||||
NGrid,
|
||||
NGi,
|
||||
NList,
|
||||
NListItem,
|
||||
NThing,
|
||||
NButton,
|
||||
NEmpty,
|
||||
NInput,
|
||||
NAlert,
|
||||
NProgress,
|
||||
NCollapseTransition,
|
||||
useMessage,
|
||||
} from 'naive-ui'
|
||||
import { CalendarTodayRound, LockRound } from '@vicons/material'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { formatBytes } from './format' // Assuming format.ts is in the same directory
|
||||
import { downloadAndDecryptFile } from './secure'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const error = ref<string | null>(null)
|
||||
const bundleId = route.params.bundleId
|
||||
const passcode = ref<string>('')
|
||||
const passcodeError = ref<string | null>(null)
|
||||
|
||||
const filePass = ref<string>('')
|
||||
|
||||
const downloading = ref(false)
|
||||
const downloadProgress = ref<number | undefined>()
|
||||
const downloadStatus = ref<'success' | 'error' | 'info'>('info')
|
||||
|
||||
watch(
|
||||
route,
|
||||
(value) => {
|
||||
if (value.query.passcode) passcode.value = value.query.passcode.toString()
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
const bundleInfo = ref<any>(null)
|
||||
async function fetchBundleInfo() {
|
||||
try {
|
||||
let url = '/api/bundles/' + bundleId
|
||||
if (passcode.value) {
|
||||
url += `?passcode=${passcode.value}`
|
||||
}
|
||||
const resp = await fetch(url)
|
||||
if (resp.status === 403) {
|
||||
error.value = '403'
|
||||
bundleInfo.value = null
|
||||
if (passcode.value) {
|
||||
passcodeError.value = 'Incorrect passcode.'
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!resp.ok) {
|
||||
throw new Error('Failed to fetch bundle info: ' + resp.statusText)
|
||||
}
|
||||
bundleInfo.value = await resp.json()
|
||||
error.value = null
|
||||
passcodeError.value = null
|
||||
} catch (err) {
|
||||
error.value = (err as Error).message
|
||||
}
|
||||
}
|
||||
onMounted(() => fetchBundleInfo())
|
||||
|
||||
function goToFileDetails(fileId: string) {
|
||||
router.push({ path: `/files/${fileId}`, query: { passcode: passcode.value } })
|
||||
}
|
||||
|
||||
const messageDisplay = useMessage()
|
||||
|
||||
async function downloadAllFiles() {
|
||||
if (!bundleInfo.value || !bundleInfo.value.files || bundleInfo.value.files.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
downloading.value = true
|
||||
downloadProgress.value = 0
|
||||
downloadStatus.value = 'info'
|
||||
|
||||
const totalFiles = bundleInfo.value.files.length
|
||||
let completedDownloads = 0
|
||||
|
||||
for (const file of bundleInfo.value.files) {
|
||||
let url = `/api/files/${file.id}`
|
||||
if (passcode.value) {
|
||||
url += `?passcode=${passcode.value}`
|
||||
}
|
||||
|
||||
if (file.is_encrypted) {
|
||||
downloadAndDecryptFile(file, filePass.value, file.name, () => {})
|
||||
.catch((err) => {
|
||||
messageDisplay.error('Download failed: ' + err.message, {
|
||||
closable: true,
|
||||
duration: 10000,
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
completedDownloads++
|
||||
downloadProgress.value = (completedDownloads / totalFiles) * 100
|
||||
})
|
||||
} else {
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to download ${file.name}: ${res.statusText}`)
|
||||
}
|
||||
const blob = await res.blob()
|
||||
const blobUrl = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = blobUrl
|
||||
a.download = file.name || 'download' // fallback name
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
window.URL.revokeObjectURL(blobUrl)
|
||||
|
||||
if (completedDownloads === totalFiles) {
|
||||
downloadStatus.value = 'success'
|
||||
}
|
||||
} catch (err) {
|
||||
messageDisplay.error(`Download failed for ${file.name}: ${err}`)
|
||||
downloadStatus.value = 'error'
|
||||
} finally {
|
||||
completedDownloads++
|
||||
downloadProgress.value = (completedDownloads / totalFiles) * 100
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
180
DysonNetwork.Drive/Client/src/views/dashboard/bundles.vue
Normal file
180
DysonNetwork.Drive/Client/src/views/dashboard/bundles.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<section class="h-full px-5 py-4">
|
||||
<n-data-table
|
||||
remote
|
||||
:row-key="(row) => row.id"
|
||||
:columns="tableColumns"
|
||||
:data="bundles"
|
||||
:loading="loading"
|
||||
:pagination="tablePagination"
|
||||
@page-change="handlePageChange"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
NDataTable,
|
||||
type DataTableColumns,
|
||||
type PaginationProps,
|
||||
useMessage,
|
||||
useLoadingBar,
|
||||
NButton,
|
||||
NIcon,
|
||||
NSpace,
|
||||
useDialog,
|
||||
} from 'naive-ui'
|
||||
import { h, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { DeleteRound } from '@vicons/material'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const bundles = ref<any[]>([])
|
||||
|
||||
const tableColumns: DataTableColumns<any> = [
|
||||
{
|
||||
title: 'Name',
|
||||
key: 'name',
|
||||
render(row: any) {
|
||||
return h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
router.push(`/bundles/${row.id}`)
|
||||
},
|
||||
},
|
||||
{
|
||||
default: () => row.name,
|
||||
},
|
||||
)
|
||||
},
|
||||
maxWidth: 80,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: 'Description',
|
||||
key: 'description',
|
||||
maxWidth: 180,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: 'Expired At',
|
||||
key: 'expired_at',
|
||||
render(row: any) {
|
||||
if (!row.expired_at) return 'Never'
|
||||
return new Date(row.expired_at).toLocaleString()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Created At',
|
||||
key: 'created_at',
|
||||
render(row: any) {
|
||||
return new Date(row.created_at).toLocaleString()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Updated At',
|
||||
key: 'updated_at',
|
||||
render(row: any) {
|
||||
return new Date(row.updated_at).toLocaleString()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Action',
|
||||
key: 'action',
|
||||
render(row: any) {
|
||||
return h(NSpace, {}, [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
circle: true,
|
||||
text: true,
|
||||
type: 'error',
|
||||
onClick: () => {
|
||||
askDeleteBundle(row)
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: () => h(NIcon, {}, { default: () => h(DeleteRound) }),
|
||||
},
|
||||
),
|
||||
])
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const tablePagination = ref<PaginationProps>({
|
||||
page: 1,
|
||||
itemCount: 0,
|
||||
pageSize: 10,
|
||||
showSizePicker: true,
|
||||
pageSizes: [10, 20, 30, 40, 50],
|
||||
})
|
||||
|
||||
async function fetchBundles() {
|
||||
if (loading.value) return
|
||||
try {
|
||||
loading.value = true
|
||||
const pag = tablePagination.value
|
||||
const response = await fetch(
|
||||
`/api/bundles/me?take=${pag.pageSize}&offset=${(pag.page! - 1) * pag.pageSize!}`,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok')
|
||||
}
|
||||
const data = await response.json()
|
||||
bundles.value = data
|
||||
tablePagination.value.itemCount = parseInt(response.headers.get('x-total') ?? '0')
|
||||
} catch (error) {
|
||||
messageDialog.error('Failed to fetch bundles: ' + (error as Error).message)
|
||||
console.error('Failed to fetch bundles:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(() => fetchBundles())
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
tablePagination.value.page = page
|
||||
fetchBundles()
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const messageDialog = useMessage()
|
||||
const loadingBar = useLoadingBar()
|
||||
const dialog = useDialog()
|
||||
|
||||
function askDeleteBundle(bundle: any) {
|
||||
dialog.warning({
|
||||
title: 'Confirm',
|
||||
content: `Are you sure you want to delete the bundle ${bundle.name}?`,
|
||||
positiveText: 'Sure',
|
||||
negativeText: 'Not Sure',
|
||||
onPositiveClick: () => {
|
||||
deleteBundle(bundle)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteBundle(bundle: any) {
|
||||
try {
|
||||
loadingBar.start()
|
||||
const response = await fetch(`/api/bundles/${bundle.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok')
|
||||
}
|
||||
tablePagination.value.page = 1
|
||||
await fetchBundles()
|
||||
loadingBar.finish()
|
||||
messageDialog.success('Bundle deleted successfully')
|
||||
} catch (error) {
|
||||
loadingBar.error()
|
||||
messageDialog.error('Failed to delete bundle: ' + (error as Error).message)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
304
DysonNetwork.Drive/Client/src/views/dashboard/files.vue
Normal file
304
DysonNetwork.Drive/Client/src/views/dashboard/files.vue
Normal file
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<section class="h-full px-5 py-4">
|
||||
<div class="flex items-center gap-4 mb-3">
|
||||
<file-pool-select
|
||||
v-model="filePool"
|
||||
placeholder="Filter by file pool"
|
||||
size="medium"
|
||||
class="max-w-[480px]"
|
||||
@update:pool="fetchFiles"
|
||||
/>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<n-switch size="large" v-model:value="showRecycled">
|
||||
<template #checked>Recycled</template>
|
||||
<template #unchecked>Unrecycled</template>
|
||||
</n-switch>
|
||||
<n-button
|
||||
@click="askDeleteRecycledFiles"
|
||||
v-if="showRecycled"
|
||||
type="error"
|
||||
circle
|
||||
size="small"
|
||||
>
|
||||
<n-icon>
|
||||
<delete-sweep-round />
|
||||
</n-icon>
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<n-data-table
|
||||
remote
|
||||
:row-key="(row) => row.id"
|
||||
:columns="tableColumns"
|
||||
:data="files"
|
||||
:loading="loading"
|
||||
:pagination="tablePagination"
|
||||
@page-change="handlePageChange"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
NDataTable,
|
||||
NIcon,
|
||||
NImage,
|
||||
NButton,
|
||||
NSpace,
|
||||
type DataTableColumns,
|
||||
type PaginationProps,
|
||||
useDialog,
|
||||
useMessage,
|
||||
useLoadingBar,
|
||||
NSwitch,
|
||||
NTooltip,
|
||||
} from 'naive-ui'
|
||||
import {
|
||||
AudioFileRound,
|
||||
InsertDriveFileRound,
|
||||
VideoFileRound,
|
||||
FileDownloadOutlined,
|
||||
DeleteRound,
|
||||
DeleteSweepRound,
|
||||
} from '@vicons/material'
|
||||
import { h, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { formatBytes } from '../format'
|
||||
import FilePoolSelect from '@/components/FilePoolSelect.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const files = ref<any[]>([])
|
||||
|
||||
const filePool = ref<string | null>(null)
|
||||
const showRecycled = ref(false)
|
||||
|
||||
const tableColumns: DataTableColumns<any> = [
|
||||
{
|
||||
title: 'Preview',
|
||||
key: 'preview',
|
||||
render(row: any) {
|
||||
switch (row.mime_type.split('/')[0]) {
|
||||
case 'image':
|
||||
return h(NImage, {
|
||||
src: '/api/files/' + row.id,
|
||||
width: 32,
|
||||
height: 32,
|
||||
objectFit: 'contain',
|
||||
style: { aspectRatio: 1 },
|
||||
})
|
||||
case 'video':
|
||||
return h(NIcon, { size: 32 }, { default: () => h(VideoFileRound) })
|
||||
case 'audio':
|
||||
return h(NIcon, { size: 32 }, { default: () => h(AudioFileRound) })
|
||||
default:
|
||||
return h(NIcon, { size: 32 }, { default: () => h(InsertDriveFileRound) })
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Name',
|
||||
key: 'name',
|
||||
maxWidth: 180,
|
||||
ellipsis: true,
|
||||
render(row: any) {
|
||||
return h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
router.push(`/files/${row.id}`)
|
||||
},
|
||||
},
|
||||
{
|
||||
default: () => row.name,
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Size',
|
||||
key: 'size',
|
||||
render(row: any) {
|
||||
return formatBytes(row.size)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Pool',
|
||||
key: 'pool',
|
||||
render(row: any) {
|
||||
if (!row.pool) return 'Unstored'
|
||||
return h(
|
||||
NTooltip,
|
||||
{},
|
||||
{
|
||||
default: () => h('span', row.pool.id),
|
||||
trigger: () => h('span', row.pool.name),
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Expired At',
|
||||
key: 'expired_at',
|
||||
render(row: any) {
|
||||
if (!row.expired_at) return 'Never'
|
||||
return new Date(row.expired_at).toLocaleString()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Uploaded At',
|
||||
key: 'created_at',
|
||||
render(row: any) {
|
||||
return new Date(row.created_at).toLocaleString()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Action',
|
||||
key: 'action',
|
||||
render(row: any) {
|
||||
return h(NSpace, {}, [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
circle: true,
|
||||
text: true,
|
||||
onClick: () => {
|
||||
window.open(`/api/files/${row.id}`, '_blank')
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: () => h(NIcon, {}, { default: () => h(FileDownloadOutlined) }),
|
||||
},
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
circle: true,
|
||||
text: true,
|
||||
type: 'error',
|
||||
onClick: () => {
|
||||
askDeleteFile(row)
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: () => h(NIcon, {}, { default: () => h(DeleteRound) }),
|
||||
},
|
||||
),
|
||||
])
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const tablePagination = ref<PaginationProps>({
|
||||
page: 1,
|
||||
itemCount: 0,
|
||||
pageSize: 10,
|
||||
showSizePicker: true,
|
||||
pageSizes: [10, 20, 30, 40, 50],
|
||||
})
|
||||
|
||||
async function fetchFiles() {
|
||||
if (loading.value) return
|
||||
try {
|
||||
loading.value = true
|
||||
const pag = tablePagination.value
|
||||
const response = await fetch(
|
||||
`/api/files/me?take=${pag.pageSize}&offset=${(pag.page! - 1) * pag.pageSize!}&recycled=${showRecycled.value}${filePool.value ? '&pool=' + filePool.value : ''}`,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok')
|
||||
}
|
||||
const data = await response.json()
|
||||
files.value = data
|
||||
tablePagination.value.itemCount = parseInt(response.headers.get('x-total') ?? '0')
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch files:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(() => fetchFiles())
|
||||
|
||||
watch(showRecycled, () => {
|
||||
tablePagination.value.itemCount = 0
|
||||
tablePagination.value.page = 1
|
||||
fetchFiles()
|
||||
})
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
tablePagination.value.page = page
|
||||
fetchFiles()
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const dialog = useDialog()
|
||||
const messageDialog = useMessage()
|
||||
const loadingBar = useLoadingBar()
|
||||
|
||||
function askDeleteFile(file: any) {
|
||||
dialog.warning({
|
||||
title: 'Confirm',
|
||||
content: `Are you sure you want delete ${file.name}? This will delete the stored file data immediately, there is no return.`,
|
||||
positiveText: 'Sure',
|
||||
negativeText: 'Not Sure',
|
||||
draggable: true,
|
||||
onPositiveClick: () => {
|
||||
deleteFile(file)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteFile(file: any) {
|
||||
try {
|
||||
loadingBar.start()
|
||||
const response = await fetch(`/api/files/${file.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok')
|
||||
}
|
||||
tablePagination.value.page = 1
|
||||
await fetchFiles()
|
||||
loadingBar.finish()
|
||||
messageDialog.success('File deleted successfully')
|
||||
} catch (error) {
|
||||
loadingBar.error()
|
||||
messageDialog.error('Failed to delete file: ' + (error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
function askDeleteRecycledFiles() {
|
||||
dialog.warning({
|
||||
title: 'Confirm',
|
||||
content: `Are you sure you want to delete all ${tablePagination.value.itemCount} marked recycled file(s) by system?`,
|
||||
positiveText: 'Sure',
|
||||
negativeText: 'Not Sure',
|
||||
draggable: true,
|
||||
onPositiveClick: () => {
|
||||
deleteRecycledFiles()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteRecycledFiles() {
|
||||
try {
|
||||
loadingBar.start()
|
||||
const response = await fetch('/api/files/me/recycle', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok')
|
||||
}
|
||||
const resp = await response.json()
|
||||
tablePagination.value.page = 1
|
||||
await fetchFiles()
|
||||
loadingBar.finish()
|
||||
messageDialog.success(`Recycled files deleted successfully, deleted count: ${resp.count}`)
|
||||
} catch (error) {
|
||||
loadingBar.error()
|
||||
messageDialog.error('Failed to delete recycled files: ' + (error as Error).message)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
101
DysonNetwork.Drive/Client/src/views/dashboard/quotas.vue
Normal file
101
DysonNetwork.Drive/Client/src/views/dashboard/quotas.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<section class="h-full px-5 py-4">
|
||||
<n-data-table
|
||||
remote
|
||||
:row-key="(row) => row.id"
|
||||
:columns="tableColumns"
|
||||
:data="quotas"
|
||||
:loading="loading"
|
||||
:pagination="tablePagination"
|
||||
@page-change="handlePageChange"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { NDataTable, type DataTableColumns, type PaginationProps, useMessage } from 'naive-ui'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { formatBytes } from '../format'
|
||||
|
||||
const quotas = ref<any[]>([])
|
||||
|
||||
const tableColumns: DataTableColumns<any> = [
|
||||
{
|
||||
title: 'Name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: 'Description',
|
||||
key: 'description',
|
||||
},
|
||||
{
|
||||
title: 'Quota',
|
||||
key: 'quota',
|
||||
render(row: any) {
|
||||
return formatBytes(row.quota * 1024 * 1024)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Expired At',
|
||||
key: 'expired_at',
|
||||
render(row: any) {
|
||||
if (!row.expired_at) return 'Never'
|
||||
return new Date(row.expired_at).toLocaleString()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Created At',
|
||||
key: 'created_at',
|
||||
render(row: any) {
|
||||
return new Date(row.created_at).toLocaleString()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Updated At',
|
||||
key: 'updated_at',
|
||||
render(row: any) {
|
||||
return new Date(row.updated_at).toLocaleString()
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const tablePagination = ref<PaginationProps>({
|
||||
page: 1,
|
||||
itemCount: 0,
|
||||
pageSize: 10,
|
||||
showSizePicker: true,
|
||||
pageSizes: [10, 20, 30, 40, 50],
|
||||
})
|
||||
|
||||
async function fetchQuotas() {
|
||||
if (loading.value) return
|
||||
try {
|
||||
loading.value = true
|
||||
const pag = tablePagination.value
|
||||
const response = await fetch(
|
||||
`/api/billing/quota/records?take=${pag.pageSize}&offset=${(pag.page! - 1) * pag.pageSize!}`,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok')
|
||||
}
|
||||
const data = await response.json()
|
||||
quotas.value = data
|
||||
tablePagination.value.itemCount = parseInt(response.headers.get('x-total') ?? '0')
|
||||
} catch (error) {
|
||||
messageDialog.error('Failed to fetch quotas: ' + (error as Error).message)
|
||||
console.error('Failed to fetch quotas:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(() => fetchQuotas())
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
tablePagination.value.page = page
|
||||
fetchQuotas()
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const messageDialog = useMessage()
|
||||
</script>
|
||||
@@ -3,7 +3,16 @@
|
||||
<div class="h-full flex justify-center items-center" v-if="!usage">
|
||||
<n-spin />
|
||||
</div>
|
||||
<n-grid cols="1 s:2 m:3 l:4" responsive="screen" :x-gap="16" :y-gap="16" v-else>
|
||||
<n-grid cols="1 s:2 l:4" responsive="screen" :x-gap="16" :y-gap="16" v-else>
|
||||
<n-gi span="4">
|
||||
<n-alert title="Billing Tips" size="small" type="info" closable>
|
||||
<p>
|
||||
The minimal billable unit is MiB, if your file is not enough 1 MiB it will be counted as
|
||||
1 MiB.
|
||||
</p>
|
||||
<p>The <b>1 MiB = 1024 KiB = 1,048,576 B</b></p>
|
||||
</n-alert>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-card class="h-stats">
|
||||
<n-statistic label="All Uploads" tabular-nums>
|
||||
@@ -25,23 +34,45 @@
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-card class="h-stats">
|
||||
<n-statistic label="Cost" tabular-nums>
|
||||
<n-number-animation :from="0" :to="usage.total_cost" :precision="2" />
|
||||
<template #suffix>NSD</template>
|
||||
<n-statistic label="Quota" tabular-nums>
|
||||
<n-number-animation :from="0" :to="usage.total_quota" />
|
||||
<template #suffix>MiB</template>
|
||||
</n-statistic>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-card class="h-stats">
|
||||
<n-statistic label="Pools" tabular-nums>
|
||||
<n-number-animation :from="0" :to="usage.pool_usages.length" />
|
||||
<div class="flex gap-2 justify-between items-end">
|
||||
<n-statistic label="Used Quota" tabular-nums>
|
||||
<n-number-animation :from="0" :to="quotaUsagePercentage" :precision="2" />
|
||||
<template #suffix>%</template>
|
||||
</n-statistic>
|
||||
<n-progress
|
||||
type="circle"
|
||||
:percentage="quotaUsagePercentage"
|
||||
:show-indicator="false"
|
||||
:stroke-width="16"
|
||||
style="width: 40px"
|
||||
/>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi span="2">
|
||||
<n-card class="ratio-video" title="Pool Usage">
|
||||
<n-card class="aspect-video" title="Pool Usage">
|
||||
<pie
|
||||
:data="chartData"
|
||||
:data="poolChartData"
|
||||
:options="{
|
||||
maintainAspectRatio: false,
|
||||
responsive: true,
|
||||
plugins: { legend: { position: isDesktop ? 'right' : 'bottom' } },
|
||||
}"
|
||||
/>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi span="2">
|
||||
<n-card class="aspect-video h-full" title="Verbose Quota">
|
||||
<pie
|
||||
:data="quotaChartData"
|
||||
:options="{
|
||||
maintainAspectRatio: false,
|
||||
responsive: true,
|
||||
@@ -55,7 +86,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NSpin, NCard, NStatistic, NGrid, NGi, NNumberAnimation } from 'naive-ui'
|
||||
import { NSpin, NCard, NStatistic, NGrid, NGi, NNumberAnimation, NAlert, NProgress } from 'naive-ui'
|
||||
import { Chart as ChartJS, Title, Tooltip, Legend, ArcElement } from 'chart.js'
|
||||
import { Pie } from 'vue-chartjs'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
@@ -66,7 +97,7 @@ ChartJS.register(Title, Tooltip, Legend, ArcElement)
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
const isDesktop = breakpoints.greaterOrEqual('md')
|
||||
|
||||
const chartData = computed(() => ({
|
||||
const poolChartData = computed(() => ({
|
||||
labels: usage.value.pool_usages.map((pool: any) => pool.pool_name),
|
||||
datasets: [
|
||||
{
|
||||
@@ -74,7 +105,7 @@ const chartData = computed(() => ({
|
||||
backgroundColor: '#7D80BAFF',
|
||||
data: usage.value.pool_usages.map((pool: any) => pool.usage_bytes),
|
||||
},
|
||||
]
|
||||
],
|
||||
}))
|
||||
|
||||
const usage = ref<any>()
|
||||
@@ -91,6 +122,36 @@ async function fetchUsage() {
|
||||
}
|
||||
onMounted(() => fetchUsage())
|
||||
|
||||
const verboseQuota = ref<
|
||||
{ based_quota: number; extra_quota: number; total_quota: number } | undefined
|
||||
>()
|
||||
async function fetchVerboseQuota() {
|
||||
try {
|
||||
const response = await fetch('/api/billing/quota')
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok')
|
||||
}
|
||||
verboseQuota.value = await response.json()
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch verbose data:', error)
|
||||
}
|
||||
}
|
||||
onMounted(() => fetchVerboseQuota())
|
||||
|
||||
const quotaChartData = computed(() => ({
|
||||
labels: ['Base Quota', 'Extra Quota'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Verbose Quota',
|
||||
backgroundColor: '#7D80BAFF',
|
||||
data: [verboseQuota.value?.based_quota ?? 0, verboseQuota.value?.extra_quota ?? 0],
|
||||
},
|
||||
],
|
||||
}))
|
||||
const quotaUsagePercentage = computed(
|
||||
() => (usage.value.used_quota / usage.value.total_quota) * 100,
|
||||
)
|
||||
|
||||
function toGigabytes(bytes: number): number {
|
||||
return bytes / (1024 * 1024 * 1024)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<section class="min-h-full relative flex items-center justify-center">
|
||||
<n-spin v-if="!fileInfo && !error" />
|
||||
<n-result status="404" title="No file was found" :description="error" v-else-if="error" />
|
||||
<n-card class="max-w-4xl my-4" v-else>
|
||||
<n-grid :cols="2" x-gap="16">
|
||||
<n-card class="max-w-4xl my-4 mx-8" v-else>
|
||||
<n-grid cols="1 m:2" x-gap="16" y-gap="16" responsive="screen">
|
||||
<n-gi>
|
||||
<div v-if="fileInfo.is_encrypted">
|
||||
<n-alert type="info" size="small" title="Encrypted file">
|
||||
@@ -14,6 +14,15 @@
|
||||
<div v-else>
|
||||
<n-image v-if="fileType === 'image'" :src="fileSource" class="w-full" />
|
||||
<video v-else-if="fileType === 'video'" :src="fileSource" controls class="w-full" />
|
||||
<audio v-else-if="fileType === 'audio'" :src="fileSource" controls class="w-full" />
|
||||
<n-result
|
||||
status="418"
|
||||
title="Preview Unavailable"
|
||||
description="How can you preview this file?"
|
||||
size="small"
|
||||
class="py-6"
|
||||
v-else
|
||||
/>
|
||||
</div>
|
||||
</n-gi>
|
||||
|
||||
@@ -77,7 +86,7 @@
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3" v-if="!progress">
|
||||
<div class="flex flex-col gap-3">
|
||||
<n-input
|
||||
v-if="fileInfo.is_encrypted"
|
||||
placeholder="Password"
|
||||
@@ -104,9 +113,14 @@
|
||||
</n-popover>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<n-progress processing :percentage="progress" />
|
||||
</div>
|
||||
<n-collapse-transition :show="!!progress">
|
||||
<n-progress
|
||||
:processing="!!progress && progress < 100"
|
||||
:percentage="progress"
|
||||
indicator-placement="inside"
|
||||
class="mt-4"
|
||||
/>
|
||||
</n-collapse-transition>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
@@ -156,6 +170,7 @@ const error = ref<string | null>(null)
|
||||
|
||||
const filePass = ref<string>('')
|
||||
const fileId = route.params.fileId
|
||||
const passcode = route.query.passcode as string | undefined
|
||||
|
||||
const progress = ref<number | undefined>(0)
|
||||
|
||||
@@ -168,7 +183,11 @@ const currentUrl = window.location.href
|
||||
const fileInfo = ref<any>(null)
|
||||
async function fetchFileInfo() {
|
||||
try {
|
||||
const resp = await fetch('/api/files/' + fileId + '/info')
|
||||
let url = '/api/files/' + fileId + '/info'
|
||||
if (passcode) {
|
||||
url += `?passcode=${passcode}`
|
||||
}
|
||||
const resp = await fetch(url)
|
||||
if (!resp.ok) {
|
||||
throw new Error('Failed to fetch file info: ' + resp.statusText)
|
||||
}
|
||||
@@ -183,9 +202,15 @@ const fileType = computed(() => {
|
||||
if (!fileInfo.value) return 'unknown'
|
||||
return fileInfo.value.mime_type?.split('/')[0] || 'unknown'
|
||||
})
|
||||
const fileSource = computed(() => `/api/files/${fileId}`)
|
||||
const fileSource = computed(() => {
|
||||
let url = `/api/files/${fileId}`
|
||||
if (passcode) {
|
||||
url += `?passcode=${passcode}`
|
||||
}
|
||||
return url
|
||||
})
|
||||
|
||||
function downloadFile() {
|
||||
async function downloadFile() {
|
||||
if (fileInfo.value.is_encrypted && !filePass.value) {
|
||||
messageDisplay.error('Please enter the password to download the file.')
|
||||
return
|
||||
@@ -198,7 +223,40 @@ function downloadFile() {
|
||||
progress.value = undefined
|
||||
})
|
||||
} else {
|
||||
window.open(fileSource.value, '_blank')
|
||||
const res = await fetch(fileSource.value)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to download ${fileInfo.value.name}: ${res.statusText}`)
|
||||
}
|
||||
|
||||
const contentLength = res.headers.get('content-length')
|
||||
if (!contentLength) {
|
||||
throw new Error('Content-Length response header is missing.')
|
||||
}
|
||||
|
||||
const total = parseInt(contentLength, 10)
|
||||
const reader = res.body!.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let received = 0
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (value) {
|
||||
chunks.push(value)
|
||||
received += value.length
|
||||
progress.value = (received / total) * 100
|
||||
}
|
||||
}
|
||||
|
||||
const blob = new Blob(chunks)
|
||||
const blobUrl = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = blobUrl
|
||||
a.download = fileInfo.value.name || 'download'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
window.URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,86 +1,70 @@
|
||||
<template>
|
||||
<section class="h-full relative flex items-center justify-center">
|
||||
<n-card class="max-w-lg" title="About" v-if="!userStore.user">
|
||||
<section class="h-full relative flex flex-col items-center justify-center">
|
||||
<n-card class="max-w-lg my-4 mx-8" title="About" v-if="!userStore.user">
|
||||
<p>Welcome to the <b>Solar Drive</b></p>
|
||||
<p>We help you upload, collect, and share files with ease in mind.</p>
|
||||
<p>To continue, login first.</p>
|
||||
|
||||
<p class="mt-4 opacity-75 text-xs">
|
||||
<span v-if="version == null">Loading...</span>
|
||||
<span v-else>
|
||||
v{{ version.version }} @
|
||||
{{ version.commit.substring(0, 6) }}
|
||||
{{ version.updatedAt }}
|
||||
</span>
|
||||
</p>
|
||||
</n-card>
|
||||
<n-card class="max-w-2xl" title="Upload to Solar Network" v-else>
|
||||
<template #header-extra>
|
||||
<div class="flex gap-2 items-center">
|
||||
|
||||
<n-card class="max-w-2xl" v-else content-style="padding: 0;">
|
||||
<n-tabs type="line" animated :tabs-padding="20" pane-style="padding: 20px">
|
||||
<template #suffix>
|
||||
<div class="flex gap-2 items-center me-4">
|
||||
<p>Advance Mode</p>
|
||||
<n-switch v-model:value="modeAdvanced" size="small" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-tab-pane name="direct" tab="Direct Upload" :disabled="isBundleMode">
|
||||
<div class="mb-3">
|
||||
<n-select
|
||||
v-model:value="filePool"
|
||||
:options="pools ?? []"
|
||||
:render-label="renderPoolSelectLabel"
|
||||
:render-tag="renderSingleSelectTag"
|
||||
value-field="id"
|
||||
label-field="name"
|
||||
placeholder="Select a file pool to upload"
|
||||
clearable
|
||||
size="large"
|
||||
<file-pool-select v-model="filePool" @update:pool="currentFilePool = $event" />
|
||||
</div>
|
||||
<upload-area
|
||||
:filePool="filePool"
|
||||
:pools="pools as SnFilePool[]"
|
||||
:modeAdvanced="modeAdvanced"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="bundle" tab="Bundle Upload">
|
||||
<div class="mb-3">
|
||||
<bundle-select v-model:bundle="selectedBundleId" :disabled="isBundleMode" />
|
||||
</div>
|
||||
|
||||
<n-collapse-transition :show="modeAdvanced">
|
||||
<n-card title="Advance Options" size="small" class="mb-3">
|
||||
<div>
|
||||
<p class="pl-1 mb-0.5">File Password</p>
|
||||
<n-input
|
||||
v-model:value="filePass"
|
||||
:disabled="!currentFilePool?.allow_encryption"
|
||||
placeholder="Enter password to protect the file"
|
||||
show-password-toggle
|
||||
size="large"
|
||||
type="password"
|
||||
class="mb-2"
|
||||
/>
|
||||
<p class="pl-1 text-xs opacity-75 mt-[-4px]">
|
||||
Only available for Stellar Program and certian file pool.
|
||||
</p>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-collapse-transition>
|
||||
<n-modal v-model:show="showCreateBundleModal" preset="dialog" title="Create New Bundle">
|
||||
<bundle-form ref="bundleFormRef" :value="newBundle" />
|
||||
<template #action>
|
||||
<n-button @click="showCreateBundleModal = false">Cancel</n-button>
|
||||
<n-button type="primary" @click="createBundle">Create</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<n-upload
|
||||
multiple
|
||||
directory-dnd
|
||||
with-credentials
|
||||
show-preview-button
|
||||
list-type="image"
|
||||
show-download-button
|
||||
:custom-request="customRequest"
|
||||
:custom-download="customDownload"
|
||||
:create-thumbnail-url="createThumbnailUrl"
|
||||
@preview="customPreview"
|
||||
<div class="flex justify-between">
|
||||
<n-button @click="showCreateBundleModal = true" class="mb-3" :disabled="isBundleMode">
|
||||
Create New Bundle
|
||||
</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="!selectedBundleId && !newBundleId && !isBundleMode"
|
||||
@click="isBundleMode ? cancelBundleUpload() : proceedToBundleUpload()"
|
||||
>
|
||||
<n-upload-dragger>
|
||||
<div style="margin-bottom: 12px">
|
||||
<n-icon size="48" :depth="3">
|
||||
<cloud-upload-round />
|
||||
</n-icon>
|
||||
{{ isBundleMode ? 'Cancel' : 'Proceed to Upload' }}
|
||||
</n-button>
|
||||
</div>
|
||||
<n-text style="font-size: 16px"> Click or drag a file to this area to upload </n-text>
|
||||
<n-p depth="3" style="margin: 8px 0 0 0">
|
||||
Strictly prohibit from uploading sensitive information. For example, your bank card PIN
|
||||
or your credit card expiry date.
|
||||
</n-p>
|
||||
</n-upload-dragger>
|
||||
</n-upload>
|
||||
|
||||
<div v-if="bundleUploadMode" class="mt-3">
|
||||
<div class="mb-3">
|
||||
<file-pool-select v-model="filePool" @update:pool="currentFilePool = $event" />
|
||||
</div>
|
||||
<upload-area
|
||||
:filePool="filePool"
|
||||
:pools="pools as SnFilePool[]"
|
||||
:modeAdvanced="modeAdvanced"
|
||||
:bundleId="currentBundleId!"
|
||||
/>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-card>
|
||||
|
||||
<p class="mt-4 opacity-75 text-xs">
|
||||
<span v-if="version == null">Loading...</span>
|
||||
@@ -90,39 +74,18 @@
|
||||
{{ version.updatedAt }}
|
||||
</span>
|
||||
</p>
|
||||
</n-card>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
NCard,
|
||||
NUpload,
|
||||
NUploadDragger,
|
||||
NIcon,
|
||||
NText,
|
||||
NP,
|
||||
NInput,
|
||||
NSwitch,
|
||||
NSelect,
|
||||
NTag,
|
||||
NCollapseTransition,
|
||||
type UploadCustomRequestOptions,
|
||||
type UploadSettledFileInfo,
|
||||
type SelectOption,
|
||||
type SelectRenderTag,
|
||||
type UploadFileInfo,
|
||||
useMessage,
|
||||
NDivider,
|
||||
NTooltip,
|
||||
} from 'naive-ui'
|
||||
import { computed, h, onMounted, ref } from 'vue'
|
||||
import { CloudUploadRound } from '@vicons/material'
|
||||
import { NCard, NSwitch, NTabs, NTabPane, NButton, NModal } from 'naive-ui'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import type { SnFilePool } from '@/types/pool'
|
||||
import { formatBytes } from './format'
|
||||
|
||||
import * as tus from 'tus-js-client'
|
||||
import FilePoolSelect from '@/components/FilePoolSelect.vue'
|
||||
import UploadArea from '@/components/UploadArea.vue'
|
||||
import BundleSelect from '@/components/BundleSelect.vue'
|
||||
import BundleForm from '@/components/form/BundleForm.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
@@ -142,214 +105,60 @@ async function fetchPools() {
|
||||
}
|
||||
onMounted(() => fetchPools())
|
||||
|
||||
const renderSingleSelectTag: SelectRenderTag = ({ option }) => {
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
},
|
||||
[option.name as string],
|
||||
)
|
||||
}
|
||||
|
||||
function renderPoolSelectLabel(option: SelectOption & SnFilePool) {
|
||||
const policy: any = option.policy_config
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
padding: '8px 2px',
|
||||
},
|
||||
},
|
||||
[
|
||||
h('div', null, [option.name as string]),
|
||||
option.description &&
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
fontSize: '0.875rem',
|
||||
opacity: '0.75',
|
||||
},
|
||||
},
|
||||
option.description,
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
display: 'flex',
|
||||
marginBottom: '4px',
|
||||
fontSize: '0.75rem',
|
||||
opacity: '0.75',
|
||||
},
|
||||
},
|
||||
[
|
||||
policy.max_file_size && h('span', `Max ${formatBytes(policy.max_file_size)}`),
|
||||
policy.accept_types &&
|
||||
h(
|
||||
NTooltip,
|
||||
{},
|
||||
{
|
||||
trigger: () => h('span', `Accept limited types`),
|
||||
default: () => h('span', policy.accept_types.join(', ')),
|
||||
},
|
||||
),
|
||||
].flatMap((el, idx, arr) =>
|
||||
idx < arr.length - 1 ? [el, h(NDivider, { vertical: true })] : [el],
|
||||
),
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
display: 'flex',
|
||||
gap: '0.25rem',
|
||||
marginTop: '2px',
|
||||
marginLeft: '-2px',
|
||||
marginRight: '-2px',
|
||||
},
|
||||
},
|
||||
[
|
||||
policy.public_usable &&
|
||||
h(
|
||||
NTag,
|
||||
{
|
||||
type: 'info',
|
||||
size: 'small',
|
||||
round: true,
|
||||
},
|
||||
{ default: () => 'Public Shared' },
|
||||
),
|
||||
policy.public_indexable &&
|
||||
h(
|
||||
NTag,
|
||||
{
|
||||
type: 'success',
|
||||
size: 'small',
|
||||
round: true,
|
||||
},
|
||||
{ default: () => 'Public Indexable' },
|
||||
),
|
||||
policy.allow_encryption &&
|
||||
h(
|
||||
NTag,
|
||||
{
|
||||
type: 'warning',
|
||||
size: 'small',
|
||||
round: true,
|
||||
},
|
||||
{ default: () => 'Allow Encryption' },
|
||||
),
|
||||
option.allow_anonymous &&
|
||||
h(
|
||||
NTag,
|
||||
{
|
||||
type: 'info',
|
||||
size: 'small',
|
||||
round: true,
|
||||
},
|
||||
{ default: () => 'Allow Anonymous' },
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
const modeAdvanced = ref(false)
|
||||
|
||||
const filePool = ref<string | null>(null)
|
||||
const filePass = ref<string>('')
|
||||
|
||||
const currentFilePool = computed(() => {
|
||||
if (!filePool.value) return null
|
||||
return pools.value?.find((pool) => pool.id === filePool.value) ?? null
|
||||
})
|
||||
|
||||
const messageDisplay = useMessage()
|
||||
const bundles = ref<any>([])
|
||||
const selectedBundleId = ref<string | null>(null)
|
||||
const showCreateBundleModal = ref(false)
|
||||
const newBundle = ref<any>({})
|
||||
const bundleFormRef = ref<any>(null)
|
||||
const bundleUploadMode = ref(false)
|
||||
const currentBundleId = ref<string | null>(null)
|
||||
const newBundleId = ref<string | null>(null)
|
||||
const isBundleMode = ref(false)
|
||||
|
||||
function customRequest({
|
||||
file,
|
||||
data,
|
||||
headers,
|
||||
withCredentials,
|
||||
action,
|
||||
onFinish,
|
||||
onError,
|
||||
onProgress,
|
||||
}: UploadCustomRequestOptions) {
|
||||
const requestHeaders: Record<string, string> = {}
|
||||
if (filePool.value) requestHeaders['X-FilePool'] = filePool.value
|
||||
if (filePass.value) requestHeaders['X-FilePass'] = filePass.value
|
||||
const upload = new tus.Upload(file.file, {
|
||||
endpoint: '/api/tus',
|
||||
retryDelays: [0, 3000, 5000, 10000, 20000],
|
||||
metadata: {
|
||||
filename: file.name,
|
||||
'content-type': file.type ?? 'application/octet-stream',
|
||||
},
|
||||
async function createBundle() {
|
||||
try {
|
||||
await bundleFormRef.value?.formRef?.validate()
|
||||
const resp = await fetch('/api/bundles', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...requestHeaders,
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
onError: function (error) {
|
||||
if (error instanceof tus.DetailedError) {
|
||||
const failedBody = error.originalResponse?.getBody()
|
||||
if (failedBody != null)
|
||||
messageDisplay.error(`Upload failed: ${failedBody}`, {
|
||||
duration: 10000,
|
||||
closable: true,
|
||||
body: JSON.stringify(newBundle.value),
|
||||
})
|
||||
if (!resp.ok) {
|
||||
throw new Error('Failed to create bundle')
|
||||
}
|
||||
console.error('[DRIVE] Upload failed:', error)
|
||||
onError()
|
||||
},
|
||||
onProgress: function (bytesUploaded, bytesTotal) {
|
||||
onProgress({ percent: (bytesUploaded / bytesTotal) * 100 })
|
||||
},
|
||||
onSuccess: function (payload) {
|
||||
const rawInfo = payload.lastResponse.getHeader('x-fileinfo')
|
||||
const jsonInfo = JSON.parse(rawInfo as string)
|
||||
console.log('[DRIVE] Upload successful: ', jsonInfo)
|
||||
file.url = `/api/files/${jsonInfo.id}`
|
||||
file.type = jsonInfo.mime_type
|
||||
onFinish()
|
||||
},
|
||||
onBeforeRequest: function (req) {
|
||||
const xhr = req.getUnderlyingObject()
|
||||
xhr.withCredentials = withCredentials
|
||||
},
|
||||
})
|
||||
upload.findPreviousUploads().then(function (previousUploads) {
|
||||
if (previousUploads.length) {
|
||||
upload.resumeFromPreviousUpload(previousUploads[0])
|
||||
const createdBundle = await resp.json()
|
||||
bundles.value.push(createdBundle)
|
||||
selectedBundleId.value = createdBundle.id
|
||||
newBundleId.value = createdBundle.id
|
||||
showCreateBundleModal.value = false
|
||||
newBundle.value = {}
|
||||
} catch (error) {
|
||||
console.error('Failed to create bundle:', error)
|
||||
}
|
||||
upload.start()
|
||||
})
|
||||
}
|
||||
|
||||
function createThumbnailUrl(
|
||||
_file: File | null,
|
||||
fileInfo: UploadSettledFileInfo,
|
||||
): string | undefined {
|
||||
if (!fileInfo) return undefined
|
||||
return fileInfo.url ?? undefined
|
||||
function proceedToBundleUpload() {
|
||||
currentBundleId.value = selectedBundleId.value || newBundleId.value
|
||||
bundleUploadMode.value = true
|
||||
isBundleMode.value = true
|
||||
}
|
||||
|
||||
function customDownload(file: UploadFileInfo) {
|
||||
const { url, name } = file
|
||||
if (!url) return
|
||||
window.open(url.replace('/api', ''), '_blank')
|
||||
}
|
||||
|
||||
function customPreview(file: UploadFileInfo, detail: { event: MouseEvent }) {
|
||||
detail.event.preventDefault()
|
||||
const { url, type } = file
|
||||
if (!url) return
|
||||
window.open(url.replace('/api', ''), '_blank')
|
||||
function cancelBundleUpload() {
|
||||
bundleUploadMode.value = false
|
||||
isBundleMode.value = false
|
||||
currentBundleId.value = null
|
||||
selectedBundleId.value = null
|
||||
newBundleId.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -17,15 +17,6 @@ export default defineConfig({
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: `assets/[name].js`,
|
||||
chunkFileNames: `assets/[name].js`,
|
||||
assetFileNames: `assets/[name].[ext]`,
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" />
|
||||
<PackageReference Include="FFMpegCore" Version="5.2.0" />
|
||||
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" />
|
||||
@@ -34,7 +35,6 @@
|
||||
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="9.0.4" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||
@@ -66,6 +66,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
275
DysonNetwork.Drive/Migrations/20250726172039_AddCloudFileExpiration.Designer.cs
generated
Normal file
275
DysonNetwork.Drive/Migrations/20250726172039_AddCloudFileExpiration.Designer.cs
generated
Normal file
@@ -0,0 +1,275 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DysonNetwork.Drive;
|
||||
using DysonNetwork.Drive.Storage;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NodaTime;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Drive.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDatabase))]
|
||||
[Migration("20250726172039_AddCloudFileExpiration")]
|
||||
partial class AddCloudFileExpiration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<Dictionary<string, object>>("FileMeta")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("file_meta");
|
||||
|
||||
b.Property<bool>("HasCompression")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("has_compression");
|
||||
|
||||
b.Property<bool>("HasThumbnail")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("has_thumbnail");
|
||||
|
||||
b.Property<string>("Hash")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("hash");
|
||||
|
||||
b.Property<bool>("IsEncrypted")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_encrypted");
|
||||
|
||||
b.Property<bool>("IsMarkedRecycle")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_marked_recycle");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("mime_type");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<Guid?>("PoolId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("pool_id");
|
||||
|
||||
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("sensitive_marks");
|
||||
|
||||
b.Property<long>("Size")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("size");
|
||||
|
||||
b.Property<string>("StorageId")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("storage_id");
|
||||
|
||||
b.Property<string>("StorageUrl")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("storage_url");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<Instant?>("UploadedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("uploaded_at");
|
||||
|
||||
b.Property<string>("UploadedTo")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("uploaded_to");
|
||||
|
||||
b.Property<Dictionary<string, object>>("UserMeta")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("user_meta");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_files");
|
||||
|
||||
b.HasIndex("PoolId")
|
||||
.HasDatabaseName("ix_files_pool_id");
|
||||
|
||||
b.ToTable("files", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("FileId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("file_id");
|
||||
|
||||
b.Property<string>("ResourceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("resource_id");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("Usage")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("usage");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_file_references");
|
||||
|
||||
b.HasIndex("FileId")
|
||||
.HasDatabaseName("ix_file_references_file_id");
|
||||
|
||||
b.ToTable("file_references", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid?>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<BillingConfig>("BillingConfig")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("billing_config");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("character varying(8192)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<PolicyConfig>("PolicyConfig")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("policy_config");
|
||||
|
||||
b.Property<RemoteStorageConfig>("StorageConfig")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("storage_config");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_pools");
|
||||
|
||||
b.ToTable("pools", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool")
|
||||
.WithMany()
|
||||
.HasForeignKey("PoolId")
|
||||
.HasConstraintName("fk_files_pools_pool_id");
|
||||
|
||||
b.Navigation("Pool");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
|
||||
.WithMany()
|
||||
.HasForeignKey("FileId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_file_references_files_file_id");
|
||||
|
||||
b.Navigation("File");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NodaTime;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Drive.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCloudFileExpiration : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Instant>(
|
||||
name: "expired_at",
|
||||
table: "files",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "expired_at",
|
||||
table: "files");
|
||||
}
|
||||
}
|
||||
}
|
||||
322
DysonNetwork.Drive/Migrations/20250727092028_AddQuotaRecord.Designer.cs
generated
Normal file
322
DysonNetwork.Drive/Migrations/20250727092028_AddQuotaRecord.Designer.cs
generated
Normal file
@@ -0,0 +1,322 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DysonNetwork.Drive;
|
||||
using DysonNetwork.Drive.Storage;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NodaTime;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Drive.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDatabase))]
|
||||
[Migration("20250727092028_AddQuotaRecord")]
|
||||
partial class AddQuotaRecord
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<long>("Quota")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("quota");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_quota_records");
|
||||
|
||||
b.ToTable("quota_records", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<Dictionary<string, object>>("FileMeta")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("file_meta");
|
||||
|
||||
b.Property<bool>("HasCompression")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("has_compression");
|
||||
|
||||
b.Property<bool>("HasThumbnail")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("has_thumbnail");
|
||||
|
||||
b.Property<string>("Hash")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("hash");
|
||||
|
||||
b.Property<bool>("IsEncrypted")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_encrypted");
|
||||
|
||||
b.Property<bool>("IsMarkedRecycle")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_marked_recycle");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("mime_type");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<Guid?>("PoolId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("pool_id");
|
||||
|
||||
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("sensitive_marks");
|
||||
|
||||
b.Property<long>("Size")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("size");
|
||||
|
||||
b.Property<string>("StorageId")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("storage_id");
|
||||
|
||||
b.Property<string>("StorageUrl")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("storage_url");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<Instant?>("UploadedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("uploaded_at");
|
||||
|
||||
b.Property<string>("UploadedTo")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("uploaded_to");
|
||||
|
||||
b.Property<Dictionary<string, object>>("UserMeta")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("user_meta");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_files");
|
||||
|
||||
b.HasIndex("PoolId")
|
||||
.HasDatabaseName("ix_files_pool_id");
|
||||
|
||||
b.ToTable("files", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("FileId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("file_id");
|
||||
|
||||
b.Property<string>("ResourceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("resource_id");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("Usage")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("usage");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_file_references");
|
||||
|
||||
b.HasIndex("FileId")
|
||||
.HasDatabaseName("ix_file_references_file_id");
|
||||
|
||||
b.ToTable("file_references", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid?>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<BillingConfig>("BillingConfig")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("billing_config");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("character varying(8192)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<PolicyConfig>("PolicyConfig")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("policy_config");
|
||||
|
||||
b.Property<RemoteStorageConfig>("StorageConfig")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("storage_config");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_pools");
|
||||
|
||||
b.ToTable("pools", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool")
|
||||
.WithMany()
|
||||
.HasForeignKey("PoolId")
|
||||
.HasConstraintName("fk_files_pools_pool_id");
|
||||
|
||||
b.Navigation("Pool");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
|
||||
.WithMany()
|
||||
.HasForeignKey("FileId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_file_references_files_file_id");
|
||||
|
||||
b.Navigation("File");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NodaTime;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Drive.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddQuotaRecord : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "quota_records",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
account_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
name = table.Column<string>(type: "text", nullable: false),
|
||||
description = table.Column<string>(type: "text", nullable: false),
|
||||
quota = table.Column<long>(type: "bigint", nullable: false),
|
||||
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_quota_records", x => x.id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "quota_records");
|
||||
}
|
||||
}
|
||||
}
|
||||
400
DysonNetwork.Drive/Migrations/20250727130951_AddFileBundle.Designer.cs
generated
Normal file
400
DysonNetwork.Drive/Migrations/20250727130951_AddFileBundle.Designer.cs
generated
Normal file
@@ -0,0 +1,400 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DysonNetwork.Drive;
|
||||
using DysonNetwork.Drive.Storage;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NodaTime;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Drive.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDatabase))]
|
||||
[Migration("20250727130951_AddFileBundle")]
|
||||
partial class AddFileBundle
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<long>("Quota")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("quota");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_quota_records");
|
||||
|
||||
b.ToTable("quota_records", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Guid?>("BundleId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("bundle_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<Dictionary<string, object>>("FileMeta")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("file_meta");
|
||||
|
||||
b.Property<bool>("HasCompression")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("has_compression");
|
||||
|
||||
b.Property<bool>("HasThumbnail")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("has_thumbnail");
|
||||
|
||||
b.Property<string>("Hash")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("hash");
|
||||
|
||||
b.Property<bool>("IsEncrypted")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_encrypted");
|
||||
|
||||
b.Property<bool>("IsMarkedRecycle")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_marked_recycle");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("mime_type");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<Guid?>("PoolId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("pool_id");
|
||||
|
||||
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("sensitive_marks");
|
||||
|
||||
b.Property<long>("Size")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("size");
|
||||
|
||||
b.Property<string>("StorageId")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("storage_id");
|
||||
|
||||
b.Property<string>("StorageUrl")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("storage_url");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<Instant?>("UploadedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("uploaded_at");
|
||||
|
||||
b.Property<string>("UploadedTo")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("uploaded_to");
|
||||
|
||||
b.Property<Dictionary<string, object>>("UserMeta")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("user_meta");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_files");
|
||||
|
||||
b.HasIndex("BundleId")
|
||||
.HasDatabaseName("ix_files_bundle_id");
|
||||
|
||||
b.HasIndex("PoolId")
|
||||
.HasDatabaseName("ix_files_pool_id");
|
||||
|
||||
b.ToTable("files", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("FileId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("file_id");
|
||||
|
||||
b.Property<string>("ResourceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("resource_id");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("Usage")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("usage");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_file_references");
|
||||
|
||||
b.HasIndex("FileId")
|
||||
.HasDatabaseName("ix_file_references_file_id");
|
||||
|
||||
b.ToTable("file_references", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("character varying(8192)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Passcode")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("passcode");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_bundles");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_bundles_slug");
|
||||
|
||||
b.ToTable("bundles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid?>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<BillingConfig>("BillingConfig")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("billing_config");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("character varying(8192)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<PolicyConfig>("PolicyConfig")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("policy_config");
|
||||
|
||||
b.Property<RemoteStorageConfig>("StorageConfig")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("storage_config");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_pools");
|
||||
|
||||
b.ToTable("pools", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Drive.Storage.FileBundle", "Bundle")
|
||||
.WithMany("Files")
|
||||
.HasForeignKey("BundleId")
|
||||
.HasConstraintName("fk_files_bundles_bundle_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool")
|
||||
.WithMany()
|
||||
.HasForeignKey("PoolId")
|
||||
.HasConstraintName("fk_files_pools_pool_id");
|
||||
|
||||
b.Navigation("Bundle");
|
||||
|
||||
b.Navigation("Pool");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
|
||||
.WithMany()
|
||||
.HasForeignKey("FileId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_file_references_files_file_id");
|
||||
|
||||
b.Navigation("File");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
|
||||
{
|
||||
b.Navigation("Files");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NodaTime;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Drive.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFileBundle : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "bundle_id",
|
||||
table: "files",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "bundles",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
slug = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
description = table.Column<string>(type: "character varying(8192)", maxLength: 8192, nullable: true),
|
||||
passcode = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||
account_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_bundles", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_files_bundle_id",
|
||||
table: "files",
|
||||
column: "bundle_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_bundles_slug",
|
||||
table: "bundles",
|
||||
column: "slug",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_files_bundles_bundle_id",
|
||||
table: "files",
|
||||
column: "bundle_id",
|
||||
principalTable: "bundles",
|
||||
principalColumn: "id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_files_bundles_bundle_id",
|
||||
table: "files");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "bundles");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_files_bundle_id",
|
||||
table: "files");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "bundle_id",
|
||||
table: "files");
|
||||
}
|
||||
}
|
||||
}
|
||||
404
DysonNetwork.Drive/Migrations/20250808170904_AddHiddenPool.Designer.cs
generated
Normal file
404
DysonNetwork.Drive/Migrations/20250808170904_AddHiddenPool.Designer.cs
generated
Normal file
@@ -0,0 +1,404 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DysonNetwork.Drive;
|
||||
using DysonNetwork.Drive.Storage;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NodaTime;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Drive.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDatabase))]
|
||||
[Migration("20250808170904_AddHiddenPool")]
|
||||
partial class AddHiddenPool
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<long>("Quota")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("quota");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_quota_records");
|
||||
|
||||
b.ToTable("quota_records", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Guid?>("BundleId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("bundle_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<Dictionary<string, object>>("FileMeta")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("file_meta");
|
||||
|
||||
b.Property<bool>("HasCompression")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("has_compression");
|
||||
|
||||
b.Property<bool>("HasThumbnail")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("has_thumbnail");
|
||||
|
||||
b.Property<string>("Hash")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("hash");
|
||||
|
||||
b.Property<bool>("IsEncrypted")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_encrypted");
|
||||
|
||||
b.Property<bool>("IsMarkedRecycle")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_marked_recycle");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("mime_type");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<Guid?>("PoolId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("pool_id");
|
||||
|
||||
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("sensitive_marks");
|
||||
|
||||
b.Property<long>("Size")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("size");
|
||||
|
||||
b.Property<string>("StorageId")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("storage_id");
|
||||
|
||||
b.Property<string>("StorageUrl")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("storage_url");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<Instant?>("UploadedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("uploaded_at");
|
||||
|
||||
b.Property<string>("UploadedTo")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("uploaded_to");
|
||||
|
||||
b.Property<Dictionary<string, object>>("UserMeta")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("user_meta");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_files");
|
||||
|
||||
b.HasIndex("BundleId")
|
||||
.HasDatabaseName("ix_files_bundle_id");
|
||||
|
||||
b.HasIndex("PoolId")
|
||||
.HasDatabaseName("ix_files_pool_id");
|
||||
|
||||
b.ToTable("files", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("FileId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("file_id");
|
||||
|
||||
b.Property<string>("ResourceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("resource_id");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("Usage")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("usage");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_file_references");
|
||||
|
||||
b.HasIndex("FileId")
|
||||
.HasDatabaseName("ix_file_references_file_id");
|
||||
|
||||
b.ToTable("file_references", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("character varying(8192)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Passcode")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("passcode");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_bundles");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_bundles_slug");
|
||||
|
||||
b.ToTable("bundles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid?>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<BillingConfig>("BillingConfig")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("billing_config");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("character varying(8192)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<bool>("IsHidden")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_hidden");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<PolicyConfig>("PolicyConfig")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("policy_config");
|
||||
|
||||
b.Property<RemoteStorageConfig>("StorageConfig")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("storage_config");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_pools");
|
||||
|
||||
b.ToTable("pools", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Drive.Storage.FileBundle", "Bundle")
|
||||
.WithMany("Files")
|
||||
.HasForeignKey("BundleId")
|
||||
.HasConstraintName("fk_files_bundles_bundle_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool")
|
||||
.WithMany()
|
||||
.HasForeignKey("PoolId")
|
||||
.HasConstraintName("fk_files_pools_pool_id");
|
||||
|
||||
b.Navigation("Bundle");
|
||||
|
||||
b.Navigation("Pool");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
|
||||
.WithMany()
|
||||
.HasForeignKey("FileId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_file_references_files_file_id");
|
||||
|
||||
b.Navigation("File");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
|
||||
{
|
||||
b.Navigation("Files");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Drive.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddHiddenPool : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "is_hidden",
|
||||
table: "pools",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "is_hidden",
|
||||
table: "pools");
|
||||
}
|
||||
}
|
||||
}
|
||||
404
DysonNetwork.Drive/Migrations/20250819164302_RemoveUploadedTo.Designer.cs
generated
Normal file
404
DysonNetwork.Drive/Migrations/20250819164302_RemoveUploadedTo.Designer.cs
generated
Normal file
@@ -0,0 +1,404 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DysonNetwork.Drive;
|
||||
using DysonNetwork.Drive.Storage;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NodaTime;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Drive.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDatabase))]
|
||||
[Migration("20250819164302_RemoveUploadedTo")]
|
||||
partial class RemoveUploadedTo
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<long>("Quota")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("quota");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_quota_records");
|
||||
|
||||
b.ToTable("quota_records", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Guid?>("BundleId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("bundle_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<Dictionary<string, object>>("FileMeta")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("file_meta");
|
||||
|
||||
b.Property<bool>("HasCompression")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("has_compression");
|
||||
|
||||
b.Property<bool>("HasThumbnail")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("has_thumbnail");
|
||||
|
||||
b.Property<string>("Hash")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("hash");
|
||||
|
||||
b.Property<bool>("IsEncrypted")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_encrypted");
|
||||
|
||||
b.Property<bool>("IsMarkedRecycle")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_marked_recycle");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("mime_type");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<Guid?>("PoolId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("pool_id");
|
||||
|
||||
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("sensitive_marks");
|
||||
|
||||
b.Property<long>("Size")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("size");
|
||||
|
||||
b.Property<string>("StorageId")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("storage_id");
|
||||
|
||||
b.Property<string>("StorageUrl")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("storage_url");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<Instant?>("UploadedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("uploaded_at");
|
||||
|
||||
b.Property<Dictionary<string, object>>("UserMeta")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("user_meta");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_files");
|
||||
|
||||
b.HasIndex("BundleId")
|
||||
.HasDatabaseName("ix_files_bundle_id");
|
||||
|
||||
b.HasIndex("PoolId")
|
||||
.HasDatabaseName("ix_files_pool_id");
|
||||
|
||||
b.ToTable("files", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("FileId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("file_id");
|
||||
|
||||
b.Property<string>("ResourceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("resource_id");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("Usage")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("usage");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_file_references");
|
||||
|
||||
b.HasIndex("FileId")
|
||||
.HasDatabaseName("ix_file_references_file_id");
|
||||
|
||||
b.ToTable("file_references", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("character varying(8192)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Passcode")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("passcode");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_bundles");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_bundles_slug");
|
||||
|
||||
b.ToTable("bundles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid?>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<BillingConfig>("BillingConfig")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("billing_config");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("character varying(8192)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<bool>("IsHidden")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_hidden");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<PolicyConfig>("PolicyConfig")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("policy_config");
|
||||
|
||||
b.Property<RemoteStorageConfig>("StorageConfig")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("storage_config");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_pools");
|
||||
|
||||
b.ToTable("pools", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Drive.Storage.FileBundle", "Bundle")
|
||||
.WithMany("Files")
|
||||
.HasForeignKey("BundleId")
|
||||
.HasConstraintName("fk_files_bundles_bundle_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool")
|
||||
.WithMany()
|
||||
.HasForeignKey("PoolId")
|
||||
.HasConstraintName("fk_files_pools_pool_id");
|
||||
|
||||
b.Navigation("Bundle");
|
||||
|
||||
b.Navigation("Pool");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
|
||||
.WithMany("References")
|
||||
.HasForeignKey("FileId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_file_references_files_file_id");
|
||||
|
||||
b.Navigation("File");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||
{
|
||||
b.Navigation("References");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
|
||||
{
|
||||
b.Navigation("Files");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Drive.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RemoveUploadedTo : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "uploaded_to",
|
||||
table: "files");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "uploaded_to",
|
||||
table: "files",
|
||||
type: "character varying(128)",
|
||||
maxLength: 128,
|
||||
nullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
403
DysonNetwork.Drive/Migrations/20250907070034_RemoveNetTopo.Designer.cs
generated
Normal file
403
DysonNetwork.Drive/Migrations/20250907070034_RemoveNetTopo.Designer.cs
generated
Normal file
@@ -0,0 +1,403 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DysonNetwork.Drive;
|
||||
using DysonNetwork.Drive.Storage;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NodaTime;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Drive.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDatabase))]
|
||||
[Migration("20250907070034_RemoveNetTopo")]
|
||||
partial class RemoveNetTopo
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<long>("Quota")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("quota");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_quota_records");
|
||||
|
||||
b.ToTable("quota_records", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Guid?>("BundleId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("bundle_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<Dictionary<string, object>>("FileMeta")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("file_meta");
|
||||
|
||||
b.Property<bool>("HasCompression")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("has_compression");
|
||||
|
||||
b.Property<bool>("HasThumbnail")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("has_thumbnail");
|
||||
|
||||
b.Property<string>("Hash")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("hash");
|
||||
|
||||
b.Property<bool>("IsEncrypted")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_encrypted");
|
||||
|
||||
b.Property<bool>("IsMarkedRecycle")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_marked_recycle");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("mime_type");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<Guid?>("PoolId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("pool_id");
|
||||
|
||||
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("sensitive_marks");
|
||||
|
||||
b.Property<long>("Size")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("size");
|
||||
|
||||
b.Property<string>("StorageId")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("storage_id");
|
||||
|
||||
b.Property<string>("StorageUrl")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("storage_url");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<Instant?>("UploadedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("uploaded_at");
|
||||
|
||||
b.Property<Dictionary<string, object>>("UserMeta")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("user_meta");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_files");
|
||||
|
||||
b.HasIndex("BundleId")
|
||||
.HasDatabaseName("ix_files_bundle_id");
|
||||
|
||||
b.HasIndex("PoolId")
|
||||
.HasDatabaseName("ix_files_pool_id");
|
||||
|
||||
b.ToTable("files", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("FileId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("file_id");
|
||||
|
||||
b.Property<string>("ResourceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("resource_id");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("Usage")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("usage");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_file_references");
|
||||
|
||||
b.HasIndex("FileId")
|
||||
.HasDatabaseName("ix_file_references_file_id");
|
||||
|
||||
b.ToTable("file_references", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("character varying(8192)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Passcode")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("passcode");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_bundles");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_bundles_slug");
|
||||
|
||||
b.ToTable("bundles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid?>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<BillingConfig>("BillingConfig")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("billing_config");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("character varying(8192)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<bool>("IsHidden")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_hidden");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<PolicyConfig>("PolicyConfig")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("policy_config");
|
||||
|
||||
b.Property<RemoteStorageConfig>("StorageConfig")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("storage_config");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_pools");
|
||||
|
||||
b.ToTable("pools", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Drive.Storage.FileBundle", "Bundle")
|
||||
.WithMany("Files")
|
||||
.HasForeignKey("BundleId")
|
||||
.HasConstraintName("fk_files_bundles_bundle_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool")
|
||||
.WithMany()
|
||||
.HasForeignKey("PoolId")
|
||||
.HasConstraintName("fk_files_pools_pool_id");
|
||||
|
||||
b.Navigation("Bundle");
|
||||
|
||||
b.Navigation("Pool");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
|
||||
.WithMany("References")
|
||||
.HasForeignKey("FileId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_file_references_files_file_id");
|
||||
|
||||
b.Navigation("File");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||
{
|
||||
b.Navigation("References");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
|
||||
{
|
||||
b.Navigation("Files");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Drive.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RemoveNetTopo : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterDatabase()
|
||||
.OldAnnotation("Npgsql:PostgresExtension:postgis", ",,");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterDatabase()
|
||||
.Annotation("Npgsql:PostgresExtension:postgis", ",,");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,14 +24,13 @@ namespace DysonNetwork.Drive.Migrations
|
||||
.HasAnnotation("ProductVersion", "9.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
@@ -46,11 +45,66 @@ namespace DysonNetwork.Drive.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<long>("Quota")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("quota");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_quota_records");
|
||||
|
||||
b.ToTable("quota_records", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Guid?>("BundleId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("bundle_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<Dictionary<string, object>>("FileMeta")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("file_meta");
|
||||
@@ -117,11 +171,6 @@ namespace DysonNetwork.Drive.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("uploaded_at");
|
||||
|
||||
b.Property<string>("UploadedTo")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("uploaded_to");
|
||||
|
||||
b.Property<Dictionary<string, object>>("UserMeta")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("user_meta");
|
||||
@@ -129,6 +178,9 @@ namespace DysonNetwork.Drive.Migrations
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_files");
|
||||
|
||||
b.HasIndex("BundleId")
|
||||
.HasDatabaseName("ix_files_bundle_id");
|
||||
|
||||
b.HasIndex("PoolId")
|
||||
.HasDatabaseName("ix_files_pool_id");
|
||||
|
||||
@@ -185,6 +237,65 @@ namespace DysonNetwork.Drive.Migrations
|
||||
b.ToTable("file_references", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("character varying(8192)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Passcode")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("passcode");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_bundles");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_bundles_slug");
|
||||
|
||||
b.ToTable("bundles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -215,6 +326,10 @@ namespace DysonNetwork.Drive.Migrations
|
||||
.HasColumnType("character varying(8192)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<bool>("IsHidden")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_hidden");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
@@ -243,18 +358,25 @@ namespace DysonNetwork.Drive.Migrations
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Drive.Storage.FileBundle", "Bundle")
|
||||
.WithMany("Files")
|
||||
.HasForeignKey("BundleId")
|
||||
.HasConstraintName("fk_files_bundles_bundle_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool")
|
||||
.WithMany()
|
||||
.HasForeignKey("PoolId")
|
||||
.HasConstraintName("fk_files_pools_pool_id");
|
||||
|
||||
b.Navigation("Bundle");
|
||||
|
||||
b.Navigation("Pool");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
|
||||
.WithMany()
|
||||
.WithMany("References")
|
||||
.HasForeignKey("FileId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
@@ -262,6 +384,16 @@ namespace DysonNetwork.Drive.Migrations
|
||||
|
||||
b.Navigation("File");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||
{
|
||||
b.Navigation("References");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
|
||||
{
|
||||
b.Navigation("Files");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,16 +10,19 @@ using tusdotnet.Stores;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
|
||||
// Configure Kestrel and server options
|
||||
builder.ConfigureAppKestrel(builder.Configuration);
|
||||
builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxValue);
|
||||
|
||||
// Add application services
|
||||
builder.Services.AddRegistryService(builder.Configuration);
|
||||
|
||||
builder.Services.AddAppServices(builder.Configuration);
|
||||
builder.Services.AddAppRateLimiting();
|
||||
builder.Services.AddAppAuthentication();
|
||||
builder.Services.AddAppSwagger();
|
||||
builder.Services.AddDysonAuth();
|
||||
builder.Services.AddAccountService();
|
||||
|
||||
builder.Services.AddAppFileStorage(builder.Configuration);
|
||||
|
||||
@@ -36,6 +39,8 @@ builder.Services.AddTransient<IPageDataProvider, VersionPageData>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapDefaultEndpoints();
|
||||
|
||||
// Run database migrations
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
@@ -46,9 +51,7 @@ using (var scope = app.Services.CreateScope())
|
||||
var tusDiskStore = app.Services.GetRequiredService<TusDiskStore>();
|
||||
|
||||
// Configure application middleware pipeline
|
||||
app.ConfigureAppMiddleware(tusDiskStore);
|
||||
|
||||
app.MapGatewayProxy();
|
||||
app.ConfigureAppMiddleware(tusDiskStore, builder.Environment.ContentRootPath);
|
||||
|
||||
app.MapPages(Path.Combine(app.Environment.WebRootPath, "dist", "index.html"));
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using DysonNetwork.Drive.Storage;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using tusdotnet;
|
||||
using tusdotnet.Interfaces;
|
||||
|
||||
@@ -6,7 +7,7 @@ namespace DysonNetwork.Drive.Startup;
|
||||
|
||||
public static class ApplicationBuilderExtensions
|
||||
{
|
||||
public static WebApplication ConfigureAppMiddleware(this WebApplication app, ITusStore tusStore)
|
||||
public static WebApplication ConfigureAppMiddleware(this WebApplication app, ITusStore tusStore, string contentRoot)
|
||||
{
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
@@ -18,6 +19,21 @@ public static class ApplicationBuilderExtensions
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
app.UseCors(opts =>
|
||||
opts.SetIsOriginAllowed(_ => true)
|
||||
.WithExposedHeaders("*")
|
||||
.WithHeaders("*")
|
||||
.AllowCredentials()
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
);
|
||||
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
FileProvider = new PhysicalFileProvider(Path.Combine(contentRoot, "wwwroot", "dist"))
|
||||
});
|
||||
|
||||
app.MapTus("/api/tus", _ => Task.FromResult(TusService.BuildConfiguration(tusStore, app.Configuration)));
|
||||
|
||||
return app;
|
||||
|
||||
72
DysonNetwork.Drive/Startup/BroadcastEventHandler.cs
Normal file
72
DysonNetwork.Drive/Startup/BroadcastEventHandler.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Drive.Storage;
|
||||
using DysonNetwork.Shared.Stream;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Client.JetStream.Models;
|
||||
using NATS.Net;
|
||||
|
||||
namespace DysonNetwork.Drive.Startup;
|
||||
|
||||
public class BroadcastEventHandler(
|
||||
INatsConnection nats,
|
||||
ILogger<BroadcastEventHandler> logger,
|
||||
IServiceProvider serviceProvider
|
||||
) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var js = nats.CreateJetStreamContext();
|
||||
|
||||
await js.EnsureStreamCreated("account_events", [AccountDeletedEvent.Type]);
|
||||
|
||||
var consumer = await js.CreateOrUpdateConsumerAsync("account_events",
|
||||
new ConsumerConfig("drive_account_deleted_handler"), cancellationToken: stoppingToken);
|
||||
|
||||
await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
var evt = JsonSerializer.Deserialize<AccountDeletedEvent>(msg.Data);
|
||||
if (evt == null)
|
||||
{
|
||||
await msg.AckAsync(cancellationToken: stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.LogInformation("Account deleted: {AccountId}", evt.AccountId);
|
||||
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var fs = scope.ServiceProvider.GetRequiredService<FileService>();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken: stoppingToken);
|
||||
try
|
||||
{
|
||||
var files = await db.Files
|
||||
.Where(p => p.AccountId == evt.AccountId)
|
||||
.ToListAsync(cancellationToken: stoppingToken);
|
||||
|
||||
await fs.DeleteFileDataBatchAsync(files);
|
||||
await db.Files
|
||||
.Where(p => p.AccountId == evt.AccountId)
|
||||
.ExecuteDeleteAsync(cancellationToken: stoppingToken);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken: stoppingToken);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken: stoppingToken);
|
||||
throw;
|
||||
}
|
||||
|
||||
await msg.AckAsync(cancellationToken: stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error processing AccountDeleted");
|
||||
await msg.NakAsync(cancellationToken: stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using DysonNetwork.Drive.Storage;
|
||||
using Quartz;
|
||||
|
||||
namespace DysonNetwork.Drive.Startup;
|
||||
@@ -14,6 +15,13 @@ public static class ScheduledJobsConfiguration
|
||||
.ForJob(appDatabaseRecyclingJob)
|
||||
.WithIdentity("AppDatabaseRecyclingTrigger")
|
||||
.WithCronSchedule("0 0 0 * * ?"));
|
||||
|
||||
var cloudFileUnusedRecyclingJob = new JobKey("CloudFileUnusedRecycling");
|
||||
q.AddJob<CloudFileUnusedRecyclingJob>(opts => opts.WithIdentity(cloudFileUnusedRecyclingJob));
|
||||
q.AddTrigger(opts => opts
|
||||
.ForJob(cloudFileUnusedRecyclingJob)
|
||||
.WithIdentity("CloudFileUnusedRecyclingTrigger")
|
||||
.WithCronSchedule("0 0 0 * * ?"));
|
||||
});
|
||||
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.RateLimiting;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
@@ -16,11 +17,6 @@ public static class ServiceCollectionExtensions
|
||||
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddDbContext<AppDatabase>(); // Assuming you'll have an AppDatabase
|
||||
services.AddSingleton<IConnectionMultiplexer>(_ =>
|
||||
{
|
||||
var connection = configuration.GetConnectionString("FastRetrieve")!;
|
||||
return ConnectionMultiplexer.Connect(connection);
|
||||
});
|
||||
services.AddSingleton<IClock>(SystemClock.Instance);
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddSingleton<ICacheService, CacheServiceRedis>(); // Uncomment if you have CacheServiceRedis
|
||||
@@ -40,6 +36,7 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
services.AddControllers().AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
|
||||
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||
|
||||
@@ -139,7 +136,9 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<Storage.FileService>();
|
||||
services.AddScoped<Storage.FileReferenceService>();
|
||||
services.AddScoped<Billing.UsageService>();
|
||||
services.AddScoped<Billing.UsageService>();
|
||||
services.AddScoped<Billing.QuotaService>();
|
||||
|
||||
services.AddHostedService<BroadcastEventHandler>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
153
DysonNetwork.Drive/Storage/BundleController.cs
Normal file
153
DysonNetwork.Drive/Storage/BundleController.cs
Normal file
@@ -0,0 +1,153 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Drive.Storage;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/bundles")]
|
||||
public class BundleController(AppDatabase db) : ControllerBase
|
||||
{
|
||||
public class BundleRequest
|
||||
{
|
||||
[MaxLength(1024)] public string? Slug { get; set; }
|
||||
[MaxLength(1024)] public string? Name { get; set; }
|
||||
[MaxLength(8192)] public string? Description { get; set; }
|
||||
[MaxLength(256)] public string? Passcode { get; set; }
|
||||
|
||||
public Instant? ExpiredAt { get; set; }
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<FileBundle>> GetBundle([FromRoute] Guid id, [FromQuery] string? passcode)
|
||||
{
|
||||
var bundle = await db.Bundles
|
||||
.Where(e => e.Id == id)
|
||||
.Include(e => e.Files)
|
||||
.FirstOrDefaultAsync();
|
||||
if (bundle is null) return NotFound();
|
||||
if (!bundle.VerifyPasscode(passcode)) return Forbid();
|
||||
|
||||
return Ok(bundle);
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<FileBundle>>> ListBundles(
|
||||
[FromQuery] string? term,
|
||||
[FromQuery] int offset = 0,
|
||||
[FromQuery] int take = 20
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
|
||||
var query = db.Bundles
|
||||
.Where(e => e.AccountId == accountId)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.AsQueryable();
|
||||
if (!string.IsNullOrEmpty(term))
|
||||
query = query.Where(e => EF.Functions.ILike(e.Name, $"%{term}%"));
|
||||
|
||||
var total = await query.CountAsync();
|
||||
Response.Headers.Append("X-Total", total.ToString());
|
||||
|
||||
var bundles = await query
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(bundles);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<FileBundle>> CreateBundle([FromBody] BundleRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
|
||||
if (currentUser.PerkSubscription is null && !string.IsNullOrEmpty(request.Slug))
|
||||
return StatusCode(403, "You must have a subscription to create a bundle with a custom slug");
|
||||
if (string.IsNullOrEmpty(request.Slug))
|
||||
request.Slug = Guid.NewGuid().ToString("N")[..6];
|
||||
if (string.IsNullOrEmpty(request.Name))
|
||||
request.Name = "Unnamed Bundle";
|
||||
|
||||
var bundle = new FileBundle
|
||||
{
|
||||
Slug = request.Slug,
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
Passcode = request.Passcode,
|
||||
ExpiredAt = request.ExpiredAt,
|
||||
AccountId = accountId
|
||||
}.HashPasscode();
|
||||
|
||||
db.Bundles.Add(bundle);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(bundle);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<FileBundle>> UpdateBundle([FromRoute] Guid id, [FromBody] BundleRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
|
||||
var bundle = await db.Bundles
|
||||
.Where(e => e.Id == id)
|
||||
.Where(e => e.AccountId == accountId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (bundle is null) return NotFound();
|
||||
|
||||
if (request.Slug != null && request.Slug != bundle.Slug)
|
||||
{
|
||||
if (currentUser.PerkSubscription is null)
|
||||
return StatusCode(403, "You must have a subscription to change the slug of a bundle");
|
||||
bundle.Slug = request.Slug;
|
||||
}
|
||||
|
||||
if (request.Name != null) bundle.Name = request.Name;
|
||||
if (request.Description != null) bundle.Description = request.Description;
|
||||
if (request.ExpiredAt != null) bundle.ExpiredAt = request.ExpiredAt;
|
||||
|
||||
if (request.Passcode != null)
|
||||
{
|
||||
bundle.Passcode = request.Passcode;
|
||||
bundle = bundle.HashPasscode();
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(bundle);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> DeleteBundle([FromRoute] Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
|
||||
var bundle = await db.Bundles
|
||||
.Where(e => e.Id == id)
|
||||
.Where(e => e.AccountId == accountId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (bundle is null) return NotFound();
|
||||
|
||||
db.Bundles.Remove(bundle);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await db.Files
|
||||
.Where(e => e.BundleId == id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(e => e.IsMarkedRecycle, true));
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,18 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Google.Protobuf;
|
||||
using Newtonsoft.Json;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
|
||||
namespace DysonNetwork.Drive.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// The class that used in jsonb columns which referenced the cloud file.
|
||||
/// The aim of this class is to store some properties that won't change to a file to reduce the database load.
|
||||
/// </summary>
|
||||
public class CloudFileReferenceObject : ModelBase, ICloudFile
|
||||
{
|
||||
public string Id { get; set; } = null!;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public Dictionary<string, object?> FileMeta { get; set; } = null!;
|
||||
public Dictionary<string, object>? UserMeta { get; set; } = null!;
|
||||
public string? MimeType { get; set; }
|
||||
public string? Hash { get; set; }
|
||||
public long Size { get; set; }
|
||||
public bool HasCompression { get; set; } = false;
|
||||
}
|
||||
|
||||
public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
|
||||
{
|
||||
/// The id generated by TuS, basically just UUID remove the dash lines
|
||||
[MaxLength(32)]
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString().Replace("-", string.Empty);
|
||||
|
||||
[MaxLength(1024)] public string Name { get; set; } = string.Empty;
|
||||
[MaxLength(4096)] public string? Description { get; set; }
|
||||
@@ -38,18 +21,17 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
|
||||
[Column(TypeName = "jsonb")] public List<ContentSensitiveMark>? SensitiveMarks { get; set; } = [];
|
||||
[MaxLength(256)] public string? MimeType { get; set; }
|
||||
[MaxLength(256)] public string? Hash { get; set; }
|
||||
public Instant? ExpiredAt { get; set; }
|
||||
public long Size { get; set; }
|
||||
public Instant? UploadedAt { get; set; }
|
||||
public bool HasCompression { get; set; } = false;
|
||||
public bool HasThumbnail { get; set; } = false;
|
||||
public bool IsEncrypted { get; set; } = false;
|
||||
|
||||
[JsonIgnore] public FilePool? Pool { get; set; }
|
||||
public FilePool? Pool { get; set; }
|
||||
public Guid? PoolId { get; set; }
|
||||
|
||||
[Obsolete("Deprecated, use PoolId instead. For database migration only.")]
|
||||
[MaxLength(128)]
|
||||
public string? UploadedTo { get; set; }
|
||||
[JsonIgnore] public FileBundle? Bundle { get; set; }
|
||||
public Guid? BundleId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The field is set to true if the recycling job plans to delete the file.
|
||||
@@ -71,6 +53,12 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
|
||||
[MaxLength(4096)]
|
||||
public string? StorageUrl { get; set; }
|
||||
|
||||
[NotMapped]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? FastUploadLink { get; set; }
|
||||
|
||||
public ICollection<CloudFileReference> References { get; set; } = new List<CloudFileReference>();
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
|
||||
public CloudFileReferenceObject ToReferenceObject()
|
||||
@@ -82,8 +70,9 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
|
||||
DeletedAt = DeletedAt,
|
||||
Id = Id,
|
||||
Name = Name,
|
||||
FileMeta = FileMeta,
|
||||
UserMeta = UserMeta,
|
||||
FileMeta = FileMeta ?? [],
|
||||
UserMeta = UserMeta ?? [],
|
||||
SensitiveMarks = SensitiveMarks,
|
||||
MimeType = MimeType,
|
||||
Hash = Hash,
|
||||
Size = Size,
|
||||
@@ -102,7 +91,7 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
|
||||
var proto = new Shared.Proto.CloudFile
|
||||
{
|
||||
Id = Id,
|
||||
Name = Name ?? string.Empty,
|
||||
Name = Name,
|
||||
MimeType = MimeType ?? string.Empty,
|
||||
Hash = Hash ?? string.Empty,
|
||||
Size = Size,
|
||||
@@ -111,13 +100,10 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
|
||||
ContentType = MimeType ?? string.Empty,
|
||||
UploadedAt = UploadedAt?.ToTimestamp(),
|
||||
// Convert file metadata
|
||||
FileMeta = ByteString.CopyFromUtf8(
|
||||
System.Text.Json.JsonSerializer.Serialize(FileMeta, GrpcTypeHelper.SerializerOptions)
|
||||
),
|
||||
FileMeta = GrpcTypeHelper.ConvertObjectToByteString(FileMeta),
|
||||
// Convert user metadata
|
||||
UserMeta = ByteString.CopyFromUtf8(
|
||||
System.Text.Json.JsonSerializer.Serialize(UserMeta, GrpcTypeHelper.SerializerOptions)
|
||||
)
|
||||
UserMeta = GrpcTypeHelper.ConvertObjectToByteString(UserMeta),
|
||||
SensitiveMarks = GrpcTypeHelper.ConvertObjectToByteString(SensitiveMarks)
|
||||
};
|
||||
|
||||
return proto;
|
||||
|
||||
@@ -7,19 +7,42 @@ namespace DysonNetwork.Drive.Storage;
|
||||
public class CloudFileUnusedRecyclingJob(
|
||||
AppDatabase db,
|
||||
FileReferenceService fileRefService,
|
||||
ILogger<CloudFileUnusedRecyclingJob> logger
|
||||
ILogger<CloudFileUnusedRecyclingJob> logger,
|
||||
IConfiguration configuration
|
||||
)
|
||||
: IJob
|
||||
{
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
logger.LogInformation("Cleaning tus cloud files...");
|
||||
var storePath = configuration["Tus:StorePath"];
|
||||
if (Directory.Exists(storePath))
|
||||
{
|
||||
var oneHourAgo = SystemClock.Instance.GetCurrentInstant() - Duration.FromHours(1);
|
||||
var files = Directory.GetFiles(storePath);
|
||||
foreach (var file in files)
|
||||
{
|
||||
var creationTime = File.GetCreationTime(file).ToUniversalTime();
|
||||
if (creationTime < oneHourAgo.ToDateTimeUtc())
|
||||
File.Delete(file);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("Marking unused cloud files...");
|
||||
|
||||
var recyclablePools = await db.Pools
|
||||
.Where(p => p.PolicyConfig.EnableRecycle)
|
||||
.Select(p => p.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
const int batchSize = 1000; // Process larger batches for efficiency
|
||||
var processedCount = 0;
|
||||
var markedCount = 0;
|
||||
var totalFiles = await db.Files.Where(f => !f.IsMarkedRecycle).CountAsync();
|
||||
var totalFiles = await db.Files
|
||||
.Where(f => f.PoolId.HasValue && recyclablePools.Contains(f.PoolId.Value))
|
||||
.Where(f => !f.IsMarkedRecycle)
|
||||
.CountAsync();
|
||||
|
||||
logger.LogInformation("Found {TotalFiles} files to check for unused status", totalFiles);
|
||||
|
||||
@@ -35,13 +58,12 @@ public class CloudFileUnusedRecyclingJob(
|
||||
{
|
||||
// Query for the next batch of files using keyset pagination
|
||||
var filesQuery = db.Files
|
||||
.Where(f => f.PoolId.HasValue && recyclablePools.Contains(f.PoolId.Value))
|
||||
.Where(f => !f.IsMarkedRecycle)
|
||||
.Where(f => f.CreatedAt <= ageThreshold); // Only process older files first
|
||||
|
||||
if (lastProcessedId != null)
|
||||
{
|
||||
filesQuery = filesQuery.Where(f => string.Compare(f.Id, lastProcessedId) > 0);
|
||||
}
|
||||
|
||||
var fileBatch = await filesQuery
|
||||
.OrderBy(f => f.Id) // Ensure consistent ordering for pagination
|
||||
@@ -84,10 +106,18 @@ public class CloudFileUnusedRecyclingJob(
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Progress: processed {ProcessedCount}/{TotalFiles} files, marked {MarkedCount} for recycling",
|
||||
processedCount, totalFiles, markedCount);
|
||||
processedCount,
|
||||
totalFiles,
|
||||
markedCount
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
var expiredCount = await db.Files
|
||||
.Where(f => f.ExpiredAt.HasValue && f.ExpiredAt.Value <= now)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(f => f.IsMarkedRecycle, true));
|
||||
markedCount += expiredCount;
|
||||
|
||||
logger.LogInformation("Completed marking {MarkedCount} files for recycling", markedCount);
|
||||
}
|
||||
}
|
||||
36
DysonNetwork.Drive/Storage/FileBundle.cs
Normal file
36
DysonNetwork.Drive/Storage/FileBundle.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Drive.Storage;
|
||||
|
||||
[Index(nameof(Slug), IsUnique = true)]
|
||||
public class FileBundle : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(1024)] public string Slug { get; set; } = null!;
|
||||
[MaxLength(1024)] public string Name { get; set; } = null!;
|
||||
[MaxLength(8192)] public string? Description { get; set; }
|
||||
[MaxLength(256)] public string? Passcode { get; set; }
|
||||
|
||||
public List<CloudFile> Files { get; set; } = new();
|
||||
|
||||
public Instant? ExpiredAt { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
|
||||
public FileBundle HashPasscode()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Passcode)) return this;
|
||||
Passcode = BCrypt.Net.BCrypt.HashPassword(Passcode);
|
||||
return this;
|
||||
}
|
||||
|
||||
public bool VerifyPasscode(string? passcode)
|
||||
{
|
||||
if (string.IsNullOrEmpty(Passcode)) return true;
|
||||
if (string.IsNullOrEmpty(passcode)) return false;
|
||||
return BCrypt.Net.BCrypt.Verify(passcode, Passcode);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using DysonNetwork.Drive.Billing;
|
||||
using DysonNetwork.Shared.Auth;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Grpc.Core;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -12,6 +14,7 @@ namespace DysonNetwork.Drive.Storage;
|
||||
public class FileController(
|
||||
AppDatabase db,
|
||||
FileService fs,
|
||||
QuotaService qs,
|
||||
IConfiguration configuration,
|
||||
IWebHostEnvironment env
|
||||
) : ControllerBase
|
||||
@@ -21,7 +24,9 @@ public class FileController(
|
||||
string id,
|
||||
[FromQuery] bool download = false,
|
||||
[FromQuery] bool original = false,
|
||||
[FromQuery] string? overrideMimeType = null
|
||||
[FromQuery] bool thumbnail = false,
|
||||
[FromQuery] string? overrideMimeType = null,
|
||||
[FromQuery] string? passcode = null
|
||||
)
|
||||
{
|
||||
// Support the file extension for client side data recognize
|
||||
@@ -35,6 +40,10 @@ public class FileController(
|
||||
|
||||
var file = await fs.GetFileAsync(id);
|
||||
if (file is null) return NotFound();
|
||||
if (file.IsMarkedRecycle) return StatusCode(StatusCodes.Status410Gone, "The file has been recycled.");
|
||||
|
||||
if (file.Bundle is not null && !file.Bundle.VerifyPasscode(passcode))
|
||||
return StatusCode(StatusCodes.Status403Forbidden, "The passcode is incorrect.");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(file.StorageUrl)) return Redirect(file.StorageUrl);
|
||||
|
||||
@@ -47,15 +56,26 @@ public class FileController(
|
||||
}
|
||||
|
||||
var pool = await fs.GetPoolAsync(file.PoolId.Value);
|
||||
if (pool is null) return StatusCode(StatusCodes.Status410Gone, "The pool of the file no longer exists or not accessible.");
|
||||
if (pool is null)
|
||||
return StatusCode(StatusCodes.Status410Gone, "The pool of the file no longer exists or not accessible.");
|
||||
var dest = pool.StorageConfig;
|
||||
|
||||
if (!pool.PolicyConfig.AllowAnonymous)
|
||||
if(HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
// TODO: Provide ability to add access log
|
||||
|
||||
var fileName = string.IsNullOrWhiteSpace(file.StorageId) ? file.Id : file.StorageId;
|
||||
|
||||
switch (thumbnail)
|
||||
{
|
||||
case true when file.HasThumbnail:
|
||||
fileName += ".thumbnail";
|
||||
break;
|
||||
case true when !file.HasThumbnail:
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (!original && file.HasCompression)
|
||||
fileName += ".compressed";
|
||||
|
||||
@@ -80,7 +100,8 @@ public class FileController(
|
||||
var client = fs.CreateMinioClient(dest);
|
||||
if (client is null)
|
||||
return BadRequest(
|
||||
"Failed to configure client for remote destination, file got an invalid storage remote.");
|
||||
"Failed to configure client for remote destination, file got an invalid storage remote."
|
||||
);
|
||||
|
||||
var headers = new Dictionary<string, string>();
|
||||
if (fileExtension is not null)
|
||||
@@ -129,6 +150,85 @@ public class FileController(
|
||||
return file;
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPatch("{id}/name")]
|
||||
public async Task<ActionResult<CloudFile>> UpdateFileName(string id, [FromBody] string name)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var file = await db.Files.FirstOrDefaultAsync(f => f.Id == id && f.AccountId == accountId);
|
||||
if (file is null) return NotFound();
|
||||
file.Name = name;
|
||||
await db.SaveChangesAsync();
|
||||
await fs._PurgeCacheAsync(file.Id);
|
||||
return file;
|
||||
}
|
||||
|
||||
public class MarkFileRequest
|
||||
{
|
||||
public List<ContentSensitiveMark>? SensitiveMarks { get; set; }
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPut("{id}/marks")]
|
||||
public async Task<ActionResult<CloudFile>> MarkFile(string id, [FromBody] MarkFileRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var file = await db.Files.FirstOrDefaultAsync(f => f.Id == id && f.AccountId == accountId);
|
||||
if (file is null) return NotFound();
|
||||
file.SensitiveMarks = request.SensitiveMarks;
|
||||
await db.SaveChangesAsync();
|
||||
await fs._PurgeCacheAsync(file.Id);
|
||||
return file;
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPut("{id}/meta")]
|
||||
public async Task<ActionResult<CloudFile>> UpdateFileMeta(string id, [FromBody] Dictionary<string, object?> meta)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var file = await db.Files.FirstOrDefaultAsync(f => f.Id == id && f.AccountId == accountId);
|
||||
if (file is null) return NotFound();
|
||||
file.UserMeta = meta;
|
||||
await db.SaveChangesAsync();
|
||||
await fs._PurgeCacheAsync(file.Id);
|
||||
return file;
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpGet("me")]
|
||||
public async Task<ActionResult<List<CloudFile>>> GetMyFiles(
|
||||
[FromQuery] Guid? pool,
|
||||
[FromQuery] bool recycled = false,
|
||||
[FromQuery] int offset = 0,
|
||||
[FromQuery] int take = 20
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
|
||||
var query = db.Files
|
||||
.Where(e => e.IsMarkedRecycle == recycled)
|
||||
.Where(e => e.AccountId == accountId)
|
||||
.Include(e => e.Pool)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.AsQueryable();
|
||||
|
||||
if (pool.HasValue) query = query.Where(e => e.PoolId == pool);
|
||||
|
||||
var total = await query.CountAsync();
|
||||
Response.Headers.Append("X-Total", total.ToString());
|
||||
|
||||
var files = await query
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(files);
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<ActionResult> DeleteFile(string id)
|
||||
@@ -142,11 +242,135 @@ public class FileController(
|
||||
.FirstOrDefaultAsync();
|
||||
if (file is null) return NotFound();
|
||||
|
||||
await fs.DeleteFileDataAsync(file, force: true);
|
||||
await fs.DeleteFileAsync(file);
|
||||
|
||||
db.Files.Remove(file);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpDelete("me/recycle")]
|
||||
public async Task<ActionResult> DeleteMyRecycledFiles()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
|
||||
var count = await fs.DeleteAccountRecycledFilesAsync(accountId);
|
||||
return Ok(new { Count = count });
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpDelete("recycle")]
|
||||
[RequiredPermission("maintenance", "files.delete.recycle")]
|
||||
public async Task<ActionResult> DeleteAllRecycledFiles()
|
||||
{
|
||||
var count = await fs.DeleteAllRecycledFilesAsync();
|
||||
return Ok(new { Count = count });
|
||||
}
|
||||
|
||||
public class CreateFastFileRequest
|
||||
{
|
||||
public string Name { get; set; } = null!;
|
||||
public long Size { get; set; }
|
||||
public string Hash { get; set; } = null!;
|
||||
public string? MimeType { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public Dictionary<string, object?>? UserMeta { get; set; }
|
||||
public Dictionary<string, object?>? FileMeta { get; set; }
|
||||
public List<ContentSensitiveMark>? SensitiveMarks { get; set; }
|
||||
public Guid PoolId { get; set; }
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPost("fast")]
|
||||
[RequiredPermission("global", "files.create")]
|
||||
public async Task<ActionResult<CloudFile>> CreateFastFile([FromBody] CreateFastFileRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
|
||||
var pool = await db.Pools.FirstOrDefaultAsync(p => p.Id == request.PoolId);
|
||||
if (pool is null) return BadRequest();
|
||||
if (!currentUser.IsSuperuser && pool.AccountId != accountId)
|
||||
return StatusCode(403, "You don't have permission to create files in this pool.");
|
||||
|
||||
if (!pool.PolicyConfig.EnableFastUpload)
|
||||
return StatusCode(
|
||||
403,
|
||||
"This pool does not allow fast upload"
|
||||
);
|
||||
|
||||
if (pool.PolicyConfig.RequirePrivilege > 0)
|
||||
{
|
||||
if (currentUser.PerkSubscription is null)
|
||||
{
|
||||
return StatusCode(
|
||||
403,
|
||||
$"You need to have join the Stellar Program to use this pool"
|
||||
);
|
||||
}
|
||||
|
||||
var privilege =
|
||||
PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier);
|
||||
if (privilege < pool.PolicyConfig.RequirePrivilege)
|
||||
{
|
||||
return StatusCode(
|
||||
403,
|
||||
$"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use this pool, you are tier {privilege}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (request.Size > pool.PolicyConfig.MaxFileSize)
|
||||
{
|
||||
return StatusCode(
|
||||
403,
|
||||
$"File size {request.Size} is larger than the pool's maximum file size {pool.PolicyConfig.MaxFileSize}"
|
||||
);
|
||||
}
|
||||
|
||||
var (ok, billableUnit, quota) = await qs.IsFileAcceptable(
|
||||
accountId,
|
||||
pool.BillingConfig.CostMultiplier ?? 1.0,
|
||||
request.Size
|
||||
);
|
||||
if (!ok)
|
||||
{
|
||||
return StatusCode(
|
||||
403,
|
||||
$"File size {billableUnit} is larger than the user's quota {quota}"
|
||||
);
|
||||
}
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
var file = new CloudFile
|
||||
{
|
||||
Name = request.Name,
|
||||
Size = request.Size,
|
||||
Hash = request.Hash,
|
||||
MimeType = request.MimeType,
|
||||
Description = request.Description,
|
||||
AccountId = accountId,
|
||||
UserMeta = request.UserMeta,
|
||||
FileMeta = request.FileMeta,
|
||||
SensitiveMarks = request.SensitiveMarks,
|
||||
PoolId = request.PoolId
|
||||
};
|
||||
db.Files.Add(file);
|
||||
await db.SaveChangesAsync();
|
||||
await fs._PurgeCacheAsync(file.Id);
|
||||
await transaction.CommitAsync();
|
||||
|
||||
file.FastUploadLink = await fs.CreateFastUploadLinkAsync(file);
|
||||
|
||||
return file;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,12 +48,10 @@ public class FileExpirationJob(AppDatabase db, FileService fileService, ILogger<
|
||||
if (remainingReferences == 0)
|
||||
{
|
||||
var file = await db.Files.FirstOrDefaultAsync(f => f.Id == fileId);
|
||||
if (file != null)
|
||||
{
|
||||
if (file == null) continue;
|
||||
logger.LogInformation("Deleting file {fileId} as all references have expired", fileId);
|
||||
await fileService.DeleteFileAsync(file);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Just purge the cache
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using NodaTime;
|
||||
|
||||
@@ -26,6 +27,8 @@ public class BillingConfig
|
||||
|
||||
public class PolicyConfig
|
||||
{
|
||||
public bool EnableFastUpload { get; set; } = false;
|
||||
public bool EnableRecycle { get; set; } = false;
|
||||
public bool PublicIndexable { get; set; } = false;
|
||||
public bool PublicUsable { get; set; } = false;
|
||||
public bool NoOptimization { get; set; } = false;
|
||||
@@ -45,6 +48,7 @@ public class FilePool : ModelBase, IIdentifiedResource
|
||||
[Column(TypeName = "jsonb")] public RemoteStorageConfig StorageConfig { get; set; } = new();
|
||||
[Column(TypeName = "jsonb")] public BillingConfig BillingConfig { get; set; } = new();
|
||||
[Column(TypeName = "jsonb")] public PolicyConfig PolicyConfig { get; set; } = new();
|
||||
public bool IsHidden { get; set; } = false;
|
||||
|
||||
public Guid? AccountId { get; set; }
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace DysonNetwork.Drive.Storage;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/pools")]
|
||||
public class FilePoolController(AppDatabase db) : ControllerBase
|
||||
public class FilePoolController(AppDatabase db, FileService fs) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize]
|
||||
@@ -18,8 +18,30 @@ public class FilePoolController(AppDatabase db) : ControllerBase
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var pools = await db.Pools
|
||||
.Where(p => p.PolicyConfig.PublicUsable || p.AccountId == accountId)
|
||||
.Where(p => !p.IsHidden || p.AccountId == accountId)
|
||||
.ToListAsync();
|
||||
pools = pools.Select(p =>
|
||||
{
|
||||
p.StorageConfig.SecretId = string.Empty;
|
||||
p.StorageConfig.SecretKey = string.Empty;
|
||||
return p;
|
||||
}).ToList();
|
||||
|
||||
return Ok(pools);
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpDelete("{id:guid}/recycle")]
|
||||
public async Task<ActionResult> DeleteFilePoolRecycledFiles(Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
|
||||
var pool = await fs.GetPoolAsync(id);
|
||||
if (pool is null) return NotFound();
|
||||
if (!currentUser.IsSuperuser && pool.AccountId != accountId) return Unauthorized();
|
||||
|
||||
var count = await fs.DeletePoolRecycledFilesAsync(id);
|
||||
return Ok(new { Count = count });
|
||||
}
|
||||
}
|
||||
@@ -32,19 +32,6 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
|
||||
if (duration.HasValue)
|
||||
finalExpiration = SystemClock.Instance.GetCurrentInstant() + duration.Value;
|
||||
|
||||
var file = await db.Files
|
||||
.Where(f => f.Id == fileId)
|
||||
.Include(f => f.Pool)
|
||||
.FirstOrDefaultAsync();
|
||||
if (file is null) throw new InvalidOperationException("File not found");
|
||||
if (file.Pool?.StorageConfig.Expiration != null)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var expectedDuration = finalExpiration - now;
|
||||
if (finalExpiration == null || expectedDuration > file.Pool.StorageConfig.Expiration)
|
||||
finalExpiration = now.Plus(file.Pool.StorageConfig.Expiration.Value);
|
||||
}
|
||||
|
||||
var reference = new CloudFileReference
|
||||
{
|
||||
FileId = fileId,
|
||||
@@ -203,10 +190,8 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
|
||||
.Where(r => r.ResourceId == resourceId && r.Usage == usage)
|
||||
.ToListAsync();
|
||||
|
||||
if (!references.Any())
|
||||
{
|
||||
if (references.Count == 0)
|
||||
return 0;
|
||||
}
|
||||
|
||||
var fileIds = references.Select(r => r.FileId).Distinct().ToList();
|
||||
|
||||
@@ -221,6 +206,28 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
public async Task<int> DeleteResourceReferencesBatchAsync(IEnumerable<string> resourceIds, string? usage = null)
|
||||
{
|
||||
var references = await db.FileReferences
|
||||
.Where(r => resourceIds.Contains(r.ResourceId))
|
||||
.If(usage != null, q => q.Where(q => q.Usage == usage))
|
||||
.ToListAsync();
|
||||
|
||||
if (references.Count == 0)
|
||||
return 0;
|
||||
|
||||
var fileIds = references.Select(r => r.FileId).Distinct().ToList();
|
||||
|
||||
db.FileReferences.RemoveRange(references);
|
||||
var deletedCount = await db.SaveChangesAsync();
|
||||
|
||||
// Purge caches
|
||||
var tasks = fileIds.Select(fileService._PurgeCacheAsync).ToList();
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a specific file reference
|
||||
/// </summary>
|
||||
|
||||
@@ -85,7 +85,7 @@ namespace DysonNetwork.Drive.Storage
|
||||
public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferences(
|
||||
DeleteResourceReferencesRequest request, ServerCallContext context)
|
||||
{
|
||||
var deletedCount = 0;
|
||||
int deletedCount;
|
||||
if (request.Usage is null)
|
||||
deletedCount = await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId);
|
||||
else
|
||||
@@ -94,6 +94,18 @@ namespace DysonNetwork.Drive.Storage
|
||||
return new DeleteResourceReferencesResponse { DeletedCount = deletedCount };
|
||||
}
|
||||
|
||||
public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferencesBatch(DeleteResourceReferencesBatchRequest request, ServerCallContext context)
|
||||
{
|
||||
var resourceIds = request.ResourceIds.ToList();
|
||||
int deletedCount;
|
||||
if (request.Usage is null)
|
||||
deletedCount = await fileReferenceService.DeleteResourceReferencesBatchAsync(resourceIds);
|
||||
else
|
||||
deletedCount =
|
||||
await fileReferenceService.DeleteResourceReferencesBatchAsync(resourceIds, request.Usage!);
|
||||
return new DeleteResourceReferencesResponse { DeletedCount = deletedCount };
|
||||
}
|
||||
|
||||
public override async Task<DeleteReferenceResponse> DeleteReference(DeleteReferenceRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Drawing;
|
||||
using System.Globalization;
|
||||
using FFMpegCore;
|
||||
using System.Security.Cryptography;
|
||||
@@ -11,6 +12,7 @@ using NetVips;
|
||||
using NodaTime;
|
||||
using tusdotnet.Stores;
|
||||
using System.Linq.Expressions;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore.Query;
|
||||
|
||||
namespace DysonNetwork.Drive.Storage;
|
||||
@@ -18,7 +20,6 @@ namespace DysonNetwork.Drive.Storage;
|
||||
public class FileService(
|
||||
AppDatabase db,
|
||||
IConfiguration configuration,
|
||||
TusDiskStore store,
|
||||
ILogger<FileService> logger,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ICacheService cache
|
||||
@@ -46,6 +47,7 @@ public class FileService(
|
||||
var file = await db.Files
|
||||
.Where(f => f.Id == fileId)
|
||||
.Include(f => f.Pool)
|
||||
.Include(f => f.Bundle)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (file != null)
|
||||
@@ -99,28 +101,56 @@ public class FileService(
|
||||
private const string TempFilePrefix = "dyn-cloudfile";
|
||||
|
||||
private static readonly string[] AnimatedImageTypes =
|
||||
["image/gif", "image/apng", "image/webp", "image/avif"];
|
||||
["image/gif", "image/apng", "image/avif"];
|
||||
|
||||
private static readonly string[] AnimatedImageExtensions =
|
||||
[".gif", ".apng", ".avif"];
|
||||
|
||||
public async Task<CloudFile> ProcessNewFileAsync(
|
||||
Account account,
|
||||
string fileId,
|
||||
string filePool,
|
||||
string? fileBundleId,
|
||||
Stream stream,
|
||||
string fileName,
|
||||
string? contentType,
|
||||
string? encryptPassword
|
||||
string? encryptPassword,
|
||||
Instant? expiredAt
|
||||
)
|
||||
{
|
||||
var accountId = Guid.Parse(account.Id);
|
||||
|
||||
var pool = await GetPoolAsync(Guid.Parse(filePool));
|
||||
if (pool is null) throw new InvalidOperationException("Pool not found");
|
||||
|
||||
if (pool.StorageConfig.Expiration is not null && expiredAt.HasValue)
|
||||
{
|
||||
var expectedExpiration = SystemClock.Instance.GetCurrentInstant() - expiredAt.Value;
|
||||
var effectiveExpiration = pool.StorageConfig.Expiration < expectedExpiration
|
||||
? pool.StorageConfig.Expiration
|
||||
: expectedExpiration;
|
||||
expiredAt = SystemClock.Instance.GetCurrentInstant() + effectiveExpiration;
|
||||
}
|
||||
|
||||
var bundle = fileBundleId is not null
|
||||
? await GetBundleAsync(Guid.Parse(fileBundleId), accountId)
|
||||
: null;
|
||||
if (fileBundleId is not null && bundle is null)
|
||||
{
|
||||
throw new InvalidOperationException("Bundle not found");
|
||||
}
|
||||
|
||||
if (bundle?.ExpiredAt != null)
|
||||
expiredAt = bundle.ExpiredAt.Value;
|
||||
|
||||
var ogFilePath = Path.GetFullPath(Path.Join(configuration.GetValue<string>("Tus:StorePath"), fileId));
|
||||
var fileSize = stream.Length;
|
||||
contentType ??= !fileName.Contains('.') ? "application/octet-stream" : MimeTypes.GetMimeType(fileName);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(encryptPassword))
|
||||
{
|
||||
if (!pool.PolicyConfig.AllowEncryption) throw new InvalidOperationException("Encryption is not allowed in this pool");
|
||||
if (!pool.PolicyConfig.AllowEncryption)
|
||||
throw new InvalidOperationException("Encryption is not allowed in this pool");
|
||||
var encryptedPath = Path.Combine(Path.GetTempPath(), $"{fileId}.encrypted");
|
||||
FileEncryptor.EncryptFile(ogFilePath, encryptedPath, encryptPassword);
|
||||
File.Delete(ogFilePath); // Delete original unencrypted
|
||||
@@ -137,30 +167,12 @@ public class FileService(
|
||||
MimeType = contentType,
|
||||
Size = fileSize,
|
||||
Hash = hash,
|
||||
ExpiredAt = expiredAt,
|
||||
BundleId = bundle?.Id,
|
||||
AccountId = Guid.Parse(account.Id),
|
||||
IsEncrypted = !string.IsNullOrWhiteSpace(encryptPassword) && pool.PolicyConfig.AllowEncryption
|
||||
};
|
||||
|
||||
var existingFile = await db.Files.AsNoTracking().FirstOrDefaultAsync(f => f.Hash == hash);
|
||||
file.StorageId = existingFile?.StorageId ?? file.Id;
|
||||
|
||||
if (existingFile is not null)
|
||||
{
|
||||
file.FileMeta = existingFile.FileMeta;
|
||||
file.HasCompression = existingFile.HasCompression;
|
||||
file.SensitiveMarks = existingFile.SensitiveMarks;
|
||||
file.MimeType = existingFile.MimeType;
|
||||
file.UploadedAt = existingFile.UploadedAt;
|
||||
file.PoolId = existingFile.PoolId;
|
||||
|
||||
db.Files.Add(file);
|
||||
await db.SaveChangesAsync();
|
||||
// Since the file content is a duplicate, we can delete the new upload and we are done.
|
||||
await stream.DisposeAsync();
|
||||
await store.DeleteFileAsync(file.Id, CancellationToken.None);
|
||||
return file;
|
||||
}
|
||||
|
||||
// Extract metadata on the current thread for a faster initial response
|
||||
if (!pool.PolicyConfig.NoMetadata)
|
||||
await ExtractMetadataAsync(file, ogFilePath, stream);
|
||||
@@ -168,6 +180,8 @@ public class FileService(
|
||||
db.Files.Add(file);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
file.StorageId ??= file.Id;
|
||||
|
||||
// Offload optimization (image conversion, thumbnailing) and uploading to a background task
|
||||
_ = Task.Run(() =>
|
||||
ProcessAndUploadInBackgroundAsync(file.Id, filePool, file.StorageId, contentType, ogFilePath, stream));
|
||||
@@ -242,6 +256,8 @@ public class FileService(
|
||||
var mediaInfo = await FFProbe.AnalyseAsync(filePath);
|
||||
file.FileMeta = new Dictionary<string, object?>
|
||||
{
|
||||
["width"] = mediaInfo.PrimaryVideoStream?.Width,
|
||||
["height"] = mediaInfo.PrimaryVideoStream?.Height,
|
||||
["duration"] = mediaInfo.Duration.TotalSeconds,
|
||||
["format_name"] = mediaInfo.Format.FormatName,
|
||||
["format_long_name"] = mediaInfo.Format.FormatLongName,
|
||||
@@ -252,12 +268,24 @@ public class FileService(
|
||||
// Add detailed stream information
|
||||
["video_streams"] = mediaInfo.VideoStreams.Select(s => new
|
||||
{
|
||||
s.AvgFrameRate, s.BitRate, s.CodecName, s.Duration, s.Height, s.Width, s.Language,
|
||||
s.PixelFormat, s.Rotation
|
||||
}).ToList(),
|
||||
s.AvgFrameRate,
|
||||
s.BitRate,
|
||||
s.CodecName,
|
||||
s.Duration,
|
||||
s.Height,
|
||||
s.Width,
|
||||
s.Language,
|
||||
s.PixelFormat,
|
||||
s.Rotation
|
||||
}).Where(s => double.IsNormal(s.AvgFrameRate)).ToList(),
|
||||
["audio_streams"] = mediaInfo.AudioStreams.Select(s => new
|
||||
{
|
||||
s.BitRate, s.Channels, s.ChannelLayout, s.CodecName, s.Duration, s.Language,
|
||||
s.BitRate,
|
||||
s.Channels,
|
||||
s.ChannelLayout,
|
||||
s.CodecName,
|
||||
s.Duration,
|
||||
s.Language,
|
||||
s.SampleRateHz
|
||||
})
|
||||
.ToList(),
|
||||
@@ -304,10 +332,19 @@ public class FileService(
|
||||
{
|
||||
logger.LogInformation("Processing file {FileId} in background...", fileId);
|
||||
|
||||
var fileExtension = Path.GetExtension(originalFilePath);
|
||||
|
||||
if (!pool.PolicyConfig.NoOptimization)
|
||||
switch (contentType.Split('/')[0])
|
||||
{
|
||||
case "image" when !AnimatedImageTypes.Contains(contentType):
|
||||
case "image":
|
||||
if (AnimatedImageTypes.Contains(contentType) || AnimatedImageExtensions.Contains(fileExtension))
|
||||
{
|
||||
logger.LogInformation("Skip optimize file {FileId} due to it is animated...", fileId);
|
||||
uploads.Add((originalFilePath, string.Empty, contentType, false));
|
||||
break;
|
||||
}
|
||||
|
||||
newMimeType = "image/webp";
|
||||
using (var vipsImage = Image.NewFromFile(originalFilePath))
|
||||
{
|
||||
@@ -345,19 +382,24 @@ public class FileService(
|
||||
|
||||
case "video":
|
||||
uploads.Add((originalFilePath, string.Empty, contentType, false));
|
||||
var thumbnailPath = Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{fileId}.thumbnail.webp");
|
||||
|
||||
var thumbnailPath = Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{fileId}.thumbnail.jpg");
|
||||
try
|
||||
{
|
||||
var mediaInfo = await FFProbe.AnalyseAsync(originalFilePath);
|
||||
var snapshotTime = mediaInfo.Duration > TimeSpan.FromSeconds(5)
|
||||
? TimeSpan.FromSeconds(5)
|
||||
: TimeSpan.FromSeconds(1);
|
||||
|
||||
await FFMpeg.SnapshotAsync(originalFilePath, thumbnailPath, captureTime: snapshotTime);
|
||||
await FFMpegArguments
|
||||
.FromFileInput(originalFilePath, verifyExists: true)
|
||||
.OutputToFile(thumbnailPath, overwrite: true, options => options
|
||||
.Seek(TimeSpan.FromSeconds(0))
|
||||
.WithFrameOutputCount(1)
|
||||
.WithCustomArgument("-q:v 2")
|
||||
)
|
||||
.NotifyOnOutput(line => logger.LogInformation("[FFmpeg] {Line}", line))
|
||||
.NotifyOnError(line => logger.LogWarning("[FFmpeg] {Line}", line))
|
||||
.ProcessAsynchronously();
|
||||
|
||||
if (File.Exists(thumbnailPath))
|
||||
{
|
||||
uploads.Add((thumbnailPath, ".thumbnail.webp", "image/webp", true));
|
||||
uploads.Add((thumbnailPath, ".thumbnail", "image/jpeg", true));
|
||||
hasThumbnail = true;
|
||||
}
|
||||
else
|
||||
@@ -369,6 +411,7 @@ public class FileService(
|
||||
{
|
||||
logger.LogError(ex, "Failed to generate thumbnail for video {FileId}", fileId);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -383,8 +426,14 @@ public class FileService(
|
||||
{
|
||||
var destPool = Guid.Parse(remoteId!);
|
||||
var uploadTasks = uploads.Select(item =>
|
||||
nfs.UploadFileToRemoteAsync(storageId, destPool, item.FilePath, item.Suffix, item.ContentType,
|
||||
item.SelfDestruct)
|
||||
nfs.UploadFileToRemoteAsync(
|
||||
storageId,
|
||||
destPool,
|
||||
item.FilePath,
|
||||
item.Suffix,
|
||||
item.ContentType,
|
||||
item.SelfDestruct
|
||||
)
|
||||
).ToList();
|
||||
|
||||
await Task.WhenAll(uploadTasks);
|
||||
@@ -410,21 +459,19 @@ public class FileService(
|
||||
}
|
||||
finally
|
||||
{
|
||||
await store.DeleteFileAsync(fileId, CancellationToken.None);
|
||||
await nfs._PurgeCacheAsync(fileId);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> HashFileAsync(string filePath, int chunkSize = 1024 * 1024)
|
||||
{
|
||||
using var stream = File.OpenRead(filePath);
|
||||
var fileSize = stream.Length;
|
||||
if (fileSize > chunkSize * 1024 * 5)
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
if (fileInfo.Length > chunkSize * 1024 * 5)
|
||||
return await HashFastApproximateAsync(filePath, chunkSize);
|
||||
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
using var md5 = MD5.Create();
|
||||
var hashBytes = await md5.ComputeHashAsync(stream);
|
||||
stream.Position = 0; // Reset stream position after reading
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
@@ -432,11 +479,6 @@ public class FileService(
|
||||
{
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
|
||||
// Scale the chunk size to kB level
|
||||
chunkSize *= 1024;
|
||||
|
||||
using var md5 = MD5.Create();
|
||||
|
||||
var buffer = new byte[chunkSize * 2];
|
||||
var fileLength = stream.Length;
|
||||
|
||||
@@ -448,21 +490,32 @@ public class FileService(
|
||||
bytesRead += await stream.ReadAsync(buffer.AsMemory(chunkSize, chunkSize));
|
||||
}
|
||||
|
||||
var hash = md5.ComputeHash(buffer, 0, bytesRead);
|
||||
var hash = MD5.HashData(buffer.AsSpan(0, bytesRead));
|
||||
stream.Position = 0; // Reset stream position
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public async Task UploadFileToRemoteAsync(string storageId, Guid targetRemote, string filePath,
|
||||
string? suffix = null, string? contentType = null, bool selfDestruct = false)
|
||||
private async Task UploadFileToRemoteAsync(
|
||||
string storageId,
|
||||
Guid targetRemote,
|
||||
string filePath,
|
||||
string? suffix = null,
|
||||
string? contentType = null,
|
||||
bool selfDestruct = false
|
||||
)
|
||||
{
|
||||
await using var fileStream = File.OpenRead(filePath);
|
||||
await UploadFileToRemoteAsync(storageId, targetRemote, fileStream, suffix, contentType);
|
||||
if (selfDestruct) File.Delete(filePath);
|
||||
}
|
||||
|
||||
public async Task UploadFileToRemoteAsync(string storageId, Guid targetRemote, Stream stream,
|
||||
string? suffix = null, string? contentType = null)
|
||||
private async Task UploadFileToRemoteAsync(
|
||||
string storageId,
|
||||
Guid targetRemote,
|
||||
Stream stream,
|
||||
string? suffix = null,
|
||||
string? contentType = null
|
||||
)
|
||||
{
|
||||
var dest = await GetRemoteStorageConfig(targetRemote);
|
||||
if (dest is null)
|
||||
@@ -474,7 +527,7 @@ public class FileService(
|
||||
var bucket = dest.Bucket;
|
||||
contentType ??= "application/octet-stream";
|
||||
|
||||
await client.PutObjectAsync(new PutObjectArgs()
|
||||
await client!.PutObjectAsync(new PutObjectArgs()
|
||||
.WithBucket(bucket)
|
||||
.WithObject(string.IsNullOrWhiteSpace(suffix) ? storageId : storageId + suffix)
|
||||
.WithStreamData(stream)
|
||||
@@ -527,18 +580,19 @@ public class FileService(
|
||||
|
||||
public async Task DeleteFileAsync(CloudFile file)
|
||||
{
|
||||
await DeleteFileDataAsync(file);
|
||||
|
||||
db.Remove(file);
|
||||
await db.SaveChangesAsync();
|
||||
await _PurgeCacheAsync(file.Id);
|
||||
|
||||
await DeleteFileDataAsync(file);
|
||||
}
|
||||
|
||||
public async Task DeleteFileDataAsync(CloudFile file)
|
||||
public async Task DeleteFileDataAsync(CloudFile file, bool force = false)
|
||||
{
|
||||
if (file.StorageId is null) return;
|
||||
if (!file.PoolId.HasValue) return;
|
||||
|
||||
if (!force)
|
||||
{
|
||||
// Check if any other file with the same storage ID is referenced
|
||||
var sameOriginFiles = await db.Files
|
||||
.Where(f => f.StorageId == file.StorageId && f.Id != file.Id)
|
||||
@@ -546,17 +600,11 @@ public class FileService(
|
||||
.ToListAsync();
|
||||
|
||||
// Check if any of these files are referenced
|
||||
var anyReferenced = false;
|
||||
if (sameOriginFiles.Count != 0)
|
||||
{
|
||||
anyReferenced = await db.FileReferences
|
||||
.Where(r => sameOriginFiles.Contains(r.FileId))
|
||||
.AnyAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
// If any other file with the same storage ID is referenced, don't delete the actual file data
|
||||
if (anyReferenced) return;
|
||||
|
||||
var dest = await GetRemoteStorageConfig(file.PoolId.Value);
|
||||
if (dest is null) throw new InvalidOperationException($"No remote storage configured for pool {file.PoolId}");
|
||||
var client = CreateMinioClient(dest);
|
||||
@@ -574,7 +622,6 @@ public class FileService(
|
||||
|
||||
if (file.HasCompression)
|
||||
{
|
||||
// Also remove the compressed version if it exists
|
||||
try
|
||||
{
|
||||
await client.RemoveObjectAsync(
|
||||
@@ -587,6 +634,67 @@ public class FileService(
|
||||
logger.LogWarning("Failed to delete compressed version of file {fileId}", file.Id);
|
||||
}
|
||||
}
|
||||
|
||||
if (file.HasThumbnail)
|
||||
{
|
||||
try
|
||||
{
|
||||
await client.RemoveObjectAsync(
|
||||
new RemoveObjectArgs().WithBucket(bucket).WithObject(objectId + ".thumbnail")
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors when deleting thumbnail
|
||||
logger.LogWarning("Failed to delete thumbnail of file {fileId}", file.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The most efficent way to delete file data (stored files) in batch.
|
||||
/// But this DO NOT check the storage id, so use with caution!
|
||||
/// </summary>
|
||||
/// <param name="files">Files to delete</param>
|
||||
/// <exception cref="InvalidOperationException">Something went wrong</exception>
|
||||
public async Task DeleteFileDataBatchAsync(List<CloudFile> files)
|
||||
{
|
||||
files = files.Where(f => f.PoolId.HasValue).ToList();
|
||||
|
||||
foreach (var fileGroup in files.GroupBy(f => f.PoolId!.Value))
|
||||
{
|
||||
// If any other file with the same storage ID is referenced, don't delete the actual file data
|
||||
var dest = await GetRemoteStorageConfig(fileGroup.Key);
|
||||
if (dest is null)
|
||||
throw new InvalidOperationException($"No remote storage configured for pool {fileGroup.Key}");
|
||||
var client = CreateMinioClient(dest);
|
||||
if (client is null)
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to configure client for remote destination '{fileGroup.Key}'"
|
||||
);
|
||||
|
||||
List<string> objectsToDelete = [];
|
||||
|
||||
foreach (var file in fileGroup)
|
||||
{
|
||||
objectsToDelete.Add(file.StorageId ?? file.Id);
|
||||
if (file.HasCompression) objectsToDelete.Add(file.StorageId ?? file.Id + ".compressed");
|
||||
if (file.HasThumbnail) objectsToDelete.Add(file.StorageId ?? file.Id + ".thumbnail");
|
||||
}
|
||||
|
||||
await client.RemoveObjectsAsync(
|
||||
new RemoveObjectsArgs().WithBucket(dest.Bucket).WithObjects(objectsToDelete)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<FileBundle?> GetBundleAsync(Guid id, Guid accountId)
|
||||
{
|
||||
var bundle = await db.Bundles
|
||||
.Where(e => e.Id == id)
|
||||
.Where(e => e.AccountId == accountId)
|
||||
.FirstOrDefaultAsync();
|
||||
return bundle;
|
||||
}
|
||||
|
||||
public async Task<FilePool?> GetPoolAsync(Guid destination)
|
||||
@@ -728,6 +836,72 @@ public class FileService(
|
||||
if (fieldName.EndsWith("-data")) return true;
|
||||
return gpsFields.Any(gpsField => fieldName.StartsWith(gpsField, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public async Task<int> DeleteAccountRecycledFilesAsync(Guid accountId)
|
||||
{
|
||||
var files = await db.Files
|
||||
.Where(f => f.AccountId == accountId && f.IsMarkedRecycle)
|
||||
.ToListAsync();
|
||||
var count = files.Count;
|
||||
var tasks = files.Select(f => DeleteFileDataAsync(f, true));
|
||||
await Task.WhenAll(tasks);
|
||||
var fileIds = files.Select(f => f.Id).ToList();
|
||||
await _PurgeCacheRangeAsync(fileIds);
|
||||
db.RemoveRange(files);
|
||||
await db.SaveChangesAsync();
|
||||
return count;
|
||||
}
|
||||
|
||||
public async Task<int> DeletePoolRecycledFilesAsync(Guid poolId)
|
||||
{
|
||||
var files = await db.Files
|
||||
.Where(f => f.PoolId == poolId && f.IsMarkedRecycle)
|
||||
.ToListAsync();
|
||||
var count = files.Count;
|
||||
var tasks = files.Select(f => DeleteFileDataAsync(f, true));
|
||||
await Task.WhenAll(tasks);
|
||||
var fileIds = files.Select(f => f.Id).ToList();
|
||||
await _PurgeCacheRangeAsync(fileIds);
|
||||
db.RemoveRange(files);
|
||||
await db.SaveChangesAsync();
|
||||
return count;
|
||||
}
|
||||
|
||||
public async Task<int> DeleteAllRecycledFilesAsync()
|
||||
{
|
||||
var files = await db.Files
|
||||
.Where(f => f.IsMarkedRecycle)
|
||||
.ToListAsync();
|
||||
var count = files.Count;
|
||||
var tasks = files.Select(f => DeleteFileDataAsync(f, true));
|
||||
await Task.WhenAll(tasks);
|
||||
var fileIds = files.Select(f => f.Id).ToList();
|
||||
await _PurgeCacheRangeAsync(fileIds);
|
||||
db.RemoveRange(files);
|
||||
await db.SaveChangesAsync();
|
||||
return count;
|
||||
}
|
||||
|
||||
public async Task<string> CreateFastUploadLinkAsync(CloudFile file)
|
||||
{
|
||||
if (file.PoolId is null) throw new InvalidOperationException("Pool ID is null");
|
||||
|
||||
var dest = await GetRemoteStorageConfig(file.PoolId.Value);
|
||||
if (dest is null) throw new InvalidOperationException($"No remote storage configured for pool {file.PoolId}");
|
||||
var client = CreateMinioClient(dest);
|
||||
if (client is null)
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to configure client for remote destination '{file.PoolId}'"
|
||||
);
|
||||
|
||||
var url = await client.PresignedPutObjectAsync(
|
||||
new PresignedPutObjectArgs()
|
||||
.WithBucket(dest.Bucket)
|
||||
.WithObject(file.Id)
|
||||
.WithExpiry(60 * 60 * 24)
|
||||
);
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Grpc.Core;
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Drive.Billing;
|
||||
using DysonNetwork.Shared.Auth;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NodaTime;
|
||||
using tusdotnet.Interfaces;
|
||||
using tusdotnet.Models;
|
||||
using tusdotnet.Models.Configuration;
|
||||
using tusdotnet.Stores;
|
||||
|
||||
namespace DysonNetwork.Drive.Storage;
|
||||
|
||||
public abstract class TusService
|
||||
{
|
||||
public static DefaultTusConfiguration BuildConfiguration(ITusStore store, IConfiguration configuration) => new()
|
||||
public static DefaultTusConfiguration BuildConfiguration(
|
||||
ITusStore store,
|
||||
IConfiguration configuration
|
||||
) => new()
|
||||
{
|
||||
Store = store,
|
||||
Events = new Events
|
||||
@@ -72,7 +76,7 @@ public abstract class TusService
|
||||
{
|
||||
eventContext.FailRequest(
|
||||
HttpStatusCode.Forbidden,
|
||||
$"You need to have join the Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use this pool"
|
||||
$"You need to have join the Stellar Program to use this pool"
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -83,11 +87,16 @@ public abstract class TusService
|
||||
{
|
||||
eventContext.FailRequest(
|
||||
HttpStatusCode.Forbidden,
|
||||
$"You need to have join the Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use this pool"
|
||||
$"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use this pool, you are tier {privilege}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var bundleId = eventContext.HttpContext.Request.Headers["X-FileBundle"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(bundleId) && !Guid.TryParse(bundleId, out _))
|
||||
{
|
||||
eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file bundle id");
|
||||
}
|
||||
},
|
||||
OnFileCompleteAsync = async eventContext =>
|
||||
{
|
||||
@@ -107,11 +116,17 @@ public abstract class TusService
|
||||
var fileStream = await file.GetContentAsync(eventContext.CancellationToken);
|
||||
|
||||
var filePool = httpContext.Request.Headers["X-FilePool"].FirstOrDefault();
|
||||
var bundleId = eventContext.HttpContext.Request.Headers["X-FileBundle"].FirstOrDefault();
|
||||
var encryptPassword = httpContext.Request.Headers["X-FilePass"].FirstOrDefault();
|
||||
|
||||
if (string.IsNullOrEmpty(filePool))
|
||||
filePool = configuration["Storage:PreferredRemote"];
|
||||
|
||||
Instant? expiredAt = null;
|
||||
var expiredString = httpContext.Request.Headers["X-FileExpire"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(expiredString) && int.TryParse(expiredString, out var expired))
|
||||
expiredAt = Instant.FromUnixTimeSeconds(expired);
|
||||
|
||||
try
|
||||
{
|
||||
var fileService = services.GetRequiredService<FileService>();
|
||||
@@ -119,10 +134,12 @@ public abstract class TusService
|
||||
user,
|
||||
file.Id,
|
||||
filePool!,
|
||||
bundleId,
|
||||
fileStream,
|
||||
fileName,
|
||||
contentType,
|
||||
encryptPassword
|
||||
encryptPassword,
|
||||
expiredAt
|
||||
);
|
||||
|
||||
using var finalScope = eventContext.HttpContext.RequestServices.CreateScope();
|
||||
@@ -133,10 +150,10 @@ public abstract class TusService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var logger = services.GetRequiredService<ILogger<TusService>>();
|
||||
eventContext.HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await eventContext.HttpContext.Response.WriteAsync(ex.Message);
|
||||
if (eventContext.Store is TusDiskStore disk)
|
||||
await disk.DeleteFileAsync(file.Id, eventContext.CancellationToken);
|
||||
logger.LogError(ex, "Error handling file upload...");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -146,14 +163,29 @@ public abstract class TusService
|
||||
},
|
||||
OnBeforeCreateAsync = async eventContext =>
|
||||
{
|
||||
var filePool = eventContext.HttpContext.Request.Headers["X-FilePool"].FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(filePool)) filePool = configuration["Storage:PreferredRemote"];
|
||||
if (!Guid.TryParse(filePool, out _))
|
||||
var httpContext = eventContext.HttpContext;
|
||||
if (httpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
{
|
||||
eventContext.FailRequest(HttpStatusCode.Unauthorized);
|
||||
return;
|
||||
}
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
|
||||
var poolId = eventContext.HttpContext.Request.Headers["X-FilePool"].FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(poolId)) poolId = configuration["Storage:PreferredRemote"];
|
||||
if (!Guid.TryParse(poolId, out _))
|
||||
{
|
||||
eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file pool id");
|
||||
return;
|
||||
}
|
||||
|
||||
var bundleId = eventContext.HttpContext.Request.Headers["X-FileBundle"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(bundleId) && !Guid.TryParse(bundleId, out _))
|
||||
{
|
||||
eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file bundle id");
|
||||
return;
|
||||
}
|
||||
|
||||
var metadata = eventContext.Metadata;
|
||||
var contentType = metadata.TryGetValue("content-type", out var ct) ? ct.GetString(Encoding.UTF8) : null;
|
||||
|
||||
@@ -162,7 +194,7 @@ public abstract class TusService
|
||||
var rejected = false;
|
||||
|
||||
var fs = scope.ServiceProvider.GetRequiredService<FileService>();
|
||||
var pool = await fs.GetPoolAsync(Guid.Parse(filePool!));
|
||||
var pool = await fs.GetPoolAsync(Guid.Parse(poolId!));
|
||||
if (pool is null)
|
||||
{
|
||||
eventContext.FailRequest(HttpStatusCode.BadRequest, "Pool not found");
|
||||
@@ -196,7 +228,26 @@ public abstract class TusService
|
||||
);
|
||||
rejected = true;
|
||||
}
|
||||
else if (!policy.AcceptTypes.Contains(contentType))
|
||||
else
|
||||
{
|
||||
var foundMatch = false;
|
||||
foreach (var acceptType in policy.AcceptTypes)
|
||||
{
|
||||
if (acceptType.EndsWith("/*", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var type = acceptType[..^2];
|
||||
if (!contentType.StartsWith($"{type}/", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
foundMatch = true;
|
||||
break;
|
||||
}
|
||||
else if (acceptType.Equals(contentType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foundMatch = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundMatch)
|
||||
{
|
||||
eventContext.FailRequest(
|
||||
HttpStatusCode.Forbidden,
|
||||
@@ -205,6 +256,7 @@ public abstract class TusService
|
||||
rejected = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!rejected && policy.MaxFileSize is not null)
|
||||
{
|
||||
@@ -218,11 +270,32 @@ public abstract class TusService
|
||||
}
|
||||
}
|
||||
|
||||
if (!rejected)
|
||||
{
|
||||
var quotaService = scope.ServiceProvider.GetRequiredService<QuotaService>();
|
||||
var (ok, billableUnit, quota) = await quotaService.IsFileAcceptable(
|
||||
accountId,
|
||||
pool.BillingConfig.CostMultiplier ?? 1.0,
|
||||
eventContext.UploadLength
|
||||
);
|
||||
if (!ok)
|
||||
{
|
||||
eventContext.FailRequest(
|
||||
HttpStatusCode.Forbidden,
|
||||
$"File size {billableUnit} MiB is exceeded the user's quota {quota} MiB"
|
||||
);
|
||||
rejected = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (rejected)
|
||||
logger.LogInformation("File rejected #{FileId}", eventContext.FileId);
|
||||
},
|
||||
OnCreateCompleteAsync = eventContext =>
|
||||
{
|
||||
var directUpload = eventContext.HttpContext.Request.Headers["X-DirectUpload"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(directUpload)) return Task.CompletedTask;
|
||||
|
||||
var gatewayUrl = configuration["GatewayUrl"];
|
||||
if (gatewayUrl is not null)
|
||||
eventContext.SetUploadUrl(new Uri(gatewayUrl + "/drive/tus/" + eventContext.FileId));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"Debug": true,
|
||||
"BaseUrl": "http://localhost:5071",
|
||||
"BaseUrl": "http://localhost:5090",
|
||||
"GatewayUrl": "http://localhost:5094",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
@@ -9,9 +10,7 @@
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
|
||||
"FastRetrieve": "localhost:6379",
|
||||
"Etcd": "etcd.orb.local:2379"
|
||||
"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": {
|
||||
@@ -129,8 +128,6 @@
|
||||
],
|
||||
"Service": {
|
||||
"Name": "DysonNetwork.Drive",
|
||||
"Url": "https://localhost:7092",
|
||||
"ClientCert": "../Certificates/client.crt",
|
||||
"ClientKey": "../Certificates/client.key"
|
||||
"Url": "https://localhost:7092"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
using System.Text;
|
||||
using dotnet_etcd.interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Yarp.ReverseProxy.Configuration;
|
||||
|
||||
namespace DysonNetwork.Gateway.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("/.well-known")]
|
||||
public class WellKnownController(
|
||||
IConfiguration configuration,
|
||||
IProxyConfigProvider proxyConfigProvider,
|
||||
IEtcdClient etcdClient)
|
||||
: ControllerBase
|
||||
{
|
||||
[HttpGet("domains")]
|
||||
public IActionResult GetDomainMappings()
|
||||
{
|
||||
var domainMappings = configuration.GetSection("DomainMappings").GetChildren()
|
||||
.ToDictionary(x => x.Key, x => x.Value);
|
||||
return Ok(domainMappings);
|
||||
}
|
||||
|
||||
[HttpGet("services")]
|
||||
public IActionResult GetServices()
|
||||
{
|
||||
var local = configuration.GetValue<bool>("LocalMode");
|
||||
var response = etcdClient.GetRange("/services/");
|
||||
var kvs = response.Kvs;
|
||||
|
||||
var serviceMap = kvs.ToDictionary(
|
||||
kv => Encoding.UTF8.GetString(kv.Key.ToByteArray()).Replace("/services/", ""),
|
||||
kv => Encoding.UTF8.GetString(kv.Value.ToByteArray())
|
||||
);
|
||||
|
||||
if (local) return Ok(serviceMap);
|
||||
|
||||
var domainMappings = configuration.GetSection("DomainMappings").GetChildren()
|
||||
.ToDictionary(x => x.Key, x => x.Value);
|
||||
foreach (var (key, _) in serviceMap.ToList())
|
||||
{
|
||||
if (!domainMappings.TryGetValue(key, out var domain)) continue;
|
||||
if (domain is not null)
|
||||
serviceMap[key] = "https://" + domain;
|
||||
}
|
||||
|
||||
return Ok(serviceMap);
|
||||
}
|
||||
|
||||
[HttpGet("routes")]
|
||||
public IActionResult GetProxyRules()
|
||||
{
|
||||
var config = proxyConfigProvider.GetConfig();
|
||||
var rules = config.Routes.Select(r => new
|
||||
{
|
||||
r.RouteId,
|
||||
r.ClusterId,
|
||||
Match = new
|
||||
{
|
||||
r.Match.Path,
|
||||
Hosts = r.Match.Hosts != null ? string.Join(", ", r.Match.Hosts) : null
|
||||
},
|
||||
Transforms = r.Transforms?.Select(t => t.Select(kv => $"{kv.Key}: {kv.Value}").ToList())
|
||||
}).ToList();
|
||||
|
||||
var clusters = config.Clusters.Select(c => new
|
||||
{
|
||||
c.ClusterId,
|
||||
Destinations = c.Destinations?.Select(d => new
|
||||
{
|
||||
d.Key,
|
||||
d.Value.Address
|
||||
}).ToList()
|
||||
}).ToList();
|
||||
|
||||
return Ok(new { Rules = rules, Clusters = clusters });
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["DysonNetwork.Gateway/DysonNetwork.Gateway.csproj", "DysonNetwork.Gateway/"]
|
||||
RUN dotnet restore "DysonNetwork.Gateway/DysonNetwork.Gateway.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/DysonNetwork.Gateway"
|
||||
RUN dotnet build "./DysonNetwork.Gateway.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "./DysonNetwork.Gateway.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "DysonNetwork.Gateway.dll"]
|
||||
@@ -1,23 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="dotnet-etcd" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" />
|
||||
<PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,24 +0,0 @@
|
||||
using DysonNetwork.Gateway.Startup;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddGateway(builder.Configuration);
|
||||
builder.Services.AddControllers();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseRequestTimeouts();
|
||||
app.UseCors(opts =>
|
||||
opts.SetIsOriginAllowed(_ => true)
|
||||
.WithExposedHeaders("*")
|
||||
.WithHeaders()
|
||||
.AllowCredentials()
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
);
|
||||
|
||||
app.MapControllers();
|
||||
app.MapReverseProxy();
|
||||
|
||||
app.Run();
|
||||
@@ -1,245 +0,0 @@
|
||||
using System.Text;
|
||||
using dotnet_etcd.interfaces;
|
||||
using Yarp.ReverseProxy.Configuration;
|
||||
using Yarp.ReverseProxy.Forwarder;
|
||||
|
||||
namespace DysonNetwork.Gateway;
|
||||
|
||||
public class RegistryProxyConfigProvider : IProxyConfigProvider, IDisposable
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly IEtcdClient _etcdClient;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<RegistryProxyConfigProvider> _logger;
|
||||
private readonly CancellationTokenSource _watchCts = new();
|
||||
private CancellationTokenSource _cts;
|
||||
private IProxyConfig _config;
|
||||
|
||||
public RegistryProxyConfigProvider(
|
||||
IEtcdClient etcdClient,
|
||||
IConfiguration configuration,
|
||||
ILogger<RegistryProxyConfigProvider> logger
|
||||
)
|
||||
{
|
||||
_etcdClient = etcdClient;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
_cts = new CancellationTokenSource();
|
||||
_config = LoadConfig();
|
||||
|
||||
// Watch for changes in etcd
|
||||
_etcdClient.WatchRange("/services/", _ =>
|
||||
{
|
||||
_logger.LogInformation("Etcd configuration changed. Reloading proxy config.");
|
||||
ReloadConfig();
|
||||
}, cancellationToken: _watchCts.Token);
|
||||
}
|
||||
|
||||
public IProxyConfig GetConfig() => _config;
|
||||
|
||||
private void ReloadConfig()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var oldCts = _cts;
|
||||
_cts = new CancellationTokenSource();
|
||||
_config = LoadConfig();
|
||||
oldCts.Cancel();
|
||||
oldCts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private IProxyConfig LoadConfig()
|
||||
{
|
||||
_logger.LogInformation("Generating new proxy config.");
|
||||
var response = _etcdClient.GetRange("/services/");
|
||||
var kvs = response.Kvs;
|
||||
|
||||
var serviceMap = kvs.ToDictionary(
|
||||
kv => Encoding.UTF8.GetString(kv.Key.ToByteArray()).Replace("/services/", ""),
|
||||
kv => Encoding.UTF8.GetString(kv.Value.ToByteArray())
|
||||
);
|
||||
|
||||
var clusters = new List<ClusterConfig>();
|
||||
var routes = new List<RouteConfig>();
|
||||
|
||||
var domainMappings = _configuration.GetSection("DomainMappings").GetChildren()
|
||||
.ToDictionary(x => x.Key, x => x.Value);
|
||||
|
||||
var pathAliases = _configuration.GetSection("PathAliases").GetChildren()
|
||||
.ToDictionary(x => x.Key, x => x.Value);
|
||||
|
||||
var directRoutes = _configuration.GetSection("DirectRoutes").Get<List<DirectRouteConfig>>() ??
|
||||
[];
|
||||
|
||||
_logger.LogInformation("Indexing {ServiceCount} services from Etcd.", kvs.Count);
|
||||
|
||||
var gatewayServiceName = _configuration["Service:Name"];
|
||||
|
||||
// Add direct routes
|
||||
foreach (var directRoute in directRoutes)
|
||||
{
|
||||
if (serviceMap.TryGetValue(directRoute.Service, out var serviceUrl))
|
||||
{
|
||||
var existingCluster = clusters.FirstOrDefault(c => c.ClusterId == directRoute.Service);
|
||||
if (existingCluster is null)
|
||||
{
|
||||
var cluster = new ClusterConfig
|
||||
{
|
||||
ClusterId = directRoute.Service,
|
||||
Destinations = new Dictionary<string, DestinationConfig>
|
||||
{
|
||||
{ "destination1", new DestinationConfig { Address = serviceUrl } }
|
||||
},
|
||||
HttpRequest = new ForwarderRequestConfig
|
||||
{
|
||||
ActivityTimeout = directRoute.IsWebSocket ? TimeSpan.FromHours(24) : TimeSpan.FromMinutes(2)
|
||||
}
|
||||
};
|
||||
clusters.Add(cluster);
|
||||
}
|
||||
|
||||
var route = new RouteConfig
|
||||
{
|
||||
RouteId = $"direct-{directRoute.Service}-{directRoute.Path.Replace("/", "-")}",
|
||||
ClusterId = directRoute.Service,
|
||||
Match = new RouteMatch { Path = directRoute.Path },
|
||||
Timeout = directRoute.IsWebSocket ? null : TimeSpan.FromSeconds(5),
|
||||
};
|
||||
routes.Add(route);
|
||||
_logger.LogInformation(" Added Direct Route: {Path} -> {Service}", directRoute.Path,
|
||||
directRoute.Service);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(" Direct route service {Service} not found in Etcd.", directRoute.Service);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var serviceName in serviceMap.Keys)
|
||||
{
|
||||
if (serviceName == gatewayServiceName)
|
||||
{
|
||||
_logger.LogInformation("Skipping gateway service: {ServiceName}", serviceName);
|
||||
continue;
|
||||
}
|
||||
|
||||
var serviceUrl = serviceMap[serviceName];
|
||||
|
||||
// Determine the path alias
|
||||
string? pathAlias;
|
||||
pathAlias = pathAliases.TryGetValue(serviceName, out var alias)
|
||||
? alias
|
||||
: serviceName.Split('.').Last().ToLowerInvariant();
|
||||
|
||||
_logger.LogInformation(" Service: {ServiceName}, URL: {ServiceUrl}, Path Alias: {PathAlias}", serviceName,
|
||||
serviceUrl, pathAlias);
|
||||
|
||||
// Check if the cluster already exists
|
||||
var existingCluster = clusters.FirstOrDefault(c => c.ClusterId == serviceName);
|
||||
if (existingCluster == null)
|
||||
{
|
||||
var cluster = new ClusterConfig
|
||||
{
|
||||
ClusterId = serviceName,
|
||||
Destinations = new Dictionary<string, DestinationConfig>
|
||||
{
|
||||
{ "destination1", new DestinationConfig { Address = serviceUrl } }
|
||||
}
|
||||
};
|
||||
clusters.Add(cluster);
|
||||
_logger.LogInformation(" Added Cluster: {ServiceName}", serviceName);
|
||||
}
|
||||
else if (existingCluster.Destinations is not null)
|
||||
{
|
||||
// Create a new cluster with merged destinations
|
||||
var newDestinations = new Dictionary<string, DestinationConfig>(existingCluster.Destinations)
|
||||
{
|
||||
{
|
||||
$"destination{existingCluster.Destinations.Count + 1}",
|
||||
new DestinationConfig { Address = serviceUrl }
|
||||
}
|
||||
};
|
||||
|
||||
var mergedCluster = new ClusterConfig
|
||||
{
|
||||
ClusterId = serviceName,
|
||||
Destinations = newDestinations
|
||||
};
|
||||
|
||||
// Replace the existing cluster with the merged one
|
||||
var index = clusters.IndexOf(existingCluster);
|
||||
clusters[index] = mergedCluster;
|
||||
|
||||
_logger.LogInformation(" Updated Cluster {ServiceName} with {DestinationCount} destinations",
|
||||
serviceName, mergedCluster.Destinations.Count);
|
||||
}
|
||||
|
||||
// Host-based routing
|
||||
if (domainMappings.TryGetValue(serviceName, out var domain))
|
||||
{
|
||||
var hostRoute = new RouteConfig
|
||||
{
|
||||
RouteId = $"{serviceName}-host",
|
||||
ClusterId = serviceName,
|
||||
Match = new RouteMatch
|
||||
{
|
||||
Hosts = [domain],
|
||||
Path = "/{**catch-all}"
|
||||
}
|
||||
};
|
||||
routes.Add(hostRoute);
|
||||
_logger.LogInformation(" Added Host-based Route: {Host}", domain);
|
||||
}
|
||||
|
||||
// Path-based routing
|
||||
var pathRoute = new RouteConfig
|
||||
{
|
||||
RouteId = $"{serviceName}-path",
|
||||
ClusterId = serviceName,
|
||||
Match = new RouteMatch { Path = $"/{pathAlias}/{{**catch-all}}" },
|
||||
Transforms = new List<Dictionary<string, string>>
|
||||
{
|
||||
new() { { "PathRemovePrefix", $"/{pathAlias}" } },
|
||||
new() { { "PathPrefix", "/api" } }
|
||||
},
|
||||
Timeout = TimeSpan.FromSeconds(5)
|
||||
};
|
||||
routes.Add(pathRoute);
|
||||
_logger.LogInformation(" Added Path-based Route: {Path}", pathRoute.Match.Path);
|
||||
}
|
||||
|
||||
return new CustomProxyConfig(
|
||||
routes,
|
||||
clusters,
|
||||
new Microsoft.Extensions.Primitives.CancellationChangeToken(_cts.Token)
|
||||
);
|
||||
}
|
||||
|
||||
private class CustomProxyConfig(
|
||||
IReadOnlyList<RouteConfig> routes,
|
||||
IReadOnlyList<ClusterConfig> clusters,
|
||||
Microsoft.Extensions.Primitives.IChangeToken changeToken
|
||||
)
|
||||
: IProxyConfig
|
||||
{
|
||||
public IReadOnlyList<RouteConfig> Routes { get; } = routes;
|
||||
public IReadOnlyList<ClusterConfig> Clusters { get; } = clusters;
|
||||
public Microsoft.Extensions.Primitives.IChangeToken ChangeToken { get; } = changeToken;
|
||||
}
|
||||
|
||||
public record DirectRouteConfig
|
||||
{
|
||||
public required string Path { get; set; }
|
||||
public required string Service { get; set; }
|
||||
public bool IsWebSocket { get; set; } = false;
|
||||
}
|
||||
|
||||
public virtual void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
_watchCts.Cancel();
|
||||
_watchCts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Yarp.ReverseProxy.Configuration;
|
||||
|
||||
namespace DysonNetwork.Gateway.Startup;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddGateway(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddRequestTimeouts();
|
||||
|
||||
services
|
||||
.AddReverseProxy()
|
||||
.ConfigureHttpClient((context, handler) =>
|
||||
{
|
||||
var caCert = X509CertificateLoader.LoadCertificateFromFile(configuration["CaCert"]!);
|
||||
handler.SslOptions = new SslClientAuthenticationOptions
|
||||
{
|
||||
// TODO: check the ca in the future, for now just trust it, i need sleep
|
||||
RemoteCertificateValidationCallback = (sender, cert, chain, errors) => true
|
||||
};
|
||||
});
|
||||
|
||||
services.AddRegistryService(configuration, addForwarder: false);
|
||||
services.AddSingleton<IProxyConfigProvider, RegistryProxyConfigProvider>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user