412 Commits

Author SHA1 Message Date
f6f0703cb3 Proper gRPC protocol 2025-09-18 01:02:25 +08:00
3d47b4e44e ⬆️ Save progress and say goodbye 2025-09-17 00:57:24 +08:00
71fe2a30e7 👔 Change the version tag for aspire based images 2025-09-16 23:36:55 +08:00
d8f57161ae 🔨 Add aspire build workflow 2025-09-16 23:36:26 +08:00
3caa79b9a7 ♻️ Remove the Sphere project depends on the Pass project. Move to the shared project instead. 2025-09-16 00:52:37 +08:00
49beb17925 🧱 Make .NET Aspire uses docker compose 2025-09-16 00:47:18 +08:00
bd8e13f25d ♻️ Replace use aspire redis 2025-09-15 01:44:18 +08:00
1128c9a0ba 🗑️ Remove useless connection strings 2025-09-15 01:39:42 +08:00
8dfe201afe 🐛 Fixes bugs, endless CA issue, and endless unsecure grpc 2025-09-15 01:37:17 +08:00
c1016e496a Gateway in Aspire 2025-09-15 01:14:43 +08:00
091097a858 ♻️ Remove etcd, replace with asprie. Move infra to aspire. Disable gateway for now 2025-09-15 00:16:13 +08:00
5c97733b3e 💥 Rename Pusher to Ring 2025-09-14 19:42:51 +08:00
4ee387ab76 ♻️ Replace normal streams with JetStream
🐛 Fix pass order didn't handled successfully
2025-09-14 19:25:53 +08:00
19bf17200d 🐛 Session auto renew 2025-09-13 16:33:43 +08:00
be6d97ec85 🐛 Session will expired 2025-09-13 16:31:23 +08:00
9d282b26f3 Remove jetstream 2025-09-11 19:14:30 +08:00
dbc2c54ab0 🐛 Fix jetstream 2025-09-11 18:52:59 +08:00
aa062932cf 🐛 Fix post reading issue 2025-09-11 18:34:35 +08:00
812dd03e85 ♻️ Use jetstream to handle events broadcast 2025-09-09 22:52:26 +08:00
06d639a114 🐛 Fix compile error 2025-09-09 00:56:51 +08:00
74f51036b1 🐛 Optimize order handling 2025-09-09 00:51:51 +08:00
8308325b73 🐛 Trying to fix wallet transactions history error 2025-09-09 00:34:59 +08:00
fa7010db3d Able to list awards 2025-09-09 00:32:34 +08:00
89320fc540 🐛 Fix subscription 2025-09-09 00:23:34 +08:00
5ec8d89563 Able to only remove automated status 2025-09-09 00:09:37 +08:00
0eeafb5352 👔 Update the automated status logic 2025-09-09 00:01:56 +08:00
ab2bdcc7ca Mix awarded score into ranks 2025-09-08 23:45:57 +08:00
c2b49e6642 Automated status 2025-09-08 23:33:35 +08:00
1a89c48790 🐛 Fix transaction query
 Add orderes query
2025-09-08 14:26:17 +08:00
8dddfe77cd 🐛 Fix The JSON value could not be converted to System.Decimal 2025-09-08 14:19:09 +08:00
8e8b011fdd 🐛 Trying to fix transaction history API 2025-09-08 13:43:39 +08:00
abd346bb97 🐛 Trying to fix payment award event 2025-09-08 13:42:15 +08:00
6386ec8caa 🐛 Fix transaction listing 2025-09-08 02:26:40 +08:00
ad062828ff 🐛 Fix bugs 2025-09-08 02:22:03 +08:00
92e4988114 🐛 Fix bugs 2025-09-08 02:04:13 +08:00
f9269d7558 🐛 Trying to fix unable create order from rpc 2025-09-07 23:41:05 +08:00
fa01b7027a Anonymous poll 2025-09-07 23:22:34 +08:00
eaa3a9c297 Post embed 2025-09-07 22:39:42 +08:00
6cedda9307 Post awarded notification 2025-09-07 22:06:33 +08:00
942ca73f8d 🐛 Trying to fix award post 2025-09-07 21:54:10 +08:00
da3f58f2ec 🗑️ Remove NetTopo 2025-09-07 15:01:06 +08:00
4a8521d59d 🐛 Refactor to fix GeoIP 2025-09-07 14:57:44 +08:00
d7ad84e199 Notable days next 2025-09-07 14:42:37 +08:00
52430c19a5 🐛 Enable JsonNumberHandling.AllowNamedFloatingPointLiterals global wide 2025-09-07 14:39:25 +08:00
9492b6cac6 Notable days (holiday) 2025-09-07 14:33:24 +08:00
5f324a2348 🐛 Ignore point data to avoid cycling 2025-09-07 12:23:03 +08:00
7452b14817 🐛 Trying to fix JSON float 2025-09-07 12:16:28 +08:00
4a27794ccc Account region 2025-09-07 01:55:34 +08:00
d2f5ba36ab 🐛 Fix GeoIP related issue 2025-09-07 01:44:50 +08:00
0117fdf084 But fix pusher missing grpc 2025-09-06 22:20:19 +08:00
02680d224a 🐛 Fix known proxies 2025-09-06 22:15:27 +08:00
68bfdebcbd ⚗️ Testing the new ranking algo 2025-09-06 16:24:18 +08:00
54907eede1 🐛 trying to fix IP issue 2025-09-06 16:10:15 +08:00
a21d19c3ef List publishers managed by account 2025-09-06 14:12:55 +08:00
df732616d5 IP Check endpoints 2025-09-06 14:06:41 +08:00
79a31ae060 ⚗️ Change the algorithm of ranking posts 2025-09-06 11:31:41 +08:00
6eacfcd8f2 Award post 2025-09-06 11:19:23 +08:00
5e328509bd 🗃️ Add post award database 2025-09-05 00:24:54 +08:00
9c078db564 ♻️ Move in-app wallet buy stellar program order confirm logic 2025-09-05 00:20:20 +08:00
ddd109c77c ♻️ Refactored order handling 2025-09-05 00:13:58 +08:00
3ee04d0b24 ⚗️ Adjust the algorithm for both the featured post and the activity feed 2025-09-03 23:44:27 +08:00
7f110313e9 🐛 Fix inconsistent post data in activity 2025-09-03 23:32:44 +08:00
bc2e87c56f 💄 Optimized activity feed 2025-09-03 00:32:44 +08:00
d7271a2d11 🐛 Fix odic stuff 2025-09-02 00:33:47 +08:00
c57d65db67 🐛 Fix wrong magic spell subject 2025-09-01 23:46:16 +08:00
edf3aab173 Make the resend magic spell easiler to do so 2025-09-01 23:45:37 +08:00
352746a141 🐛 Fix send factor code in mail 2025-09-01 23:25:50 +08:00
216c72ea36 🗑️ Remove some unused code 2025-09-01 22:52:43 +08:00
d0723b366b 🔊 Email service logging 2025-09-01 22:10:44 +08:00
fb6721cb1b 💄 Optimize punishment reason display 2025-08-26 20:32:07 +08:00
9fcb169c94 🐛 Fix chat room invites 2025-08-26 19:08:23 +08:00
572874431d 🐛 Fix sticker perm check 2025-08-26 14:48:30 +08:00
f595ac8001 🐛 Fix uploading file didn't uploaded 2025-08-26 13:02:51 +08:00
18674e0e1d Remove /cgi directly handled by gateway 2025-08-26 02:59:51 +08:00
da4c4d3a84 🐛 Fix bugs 2025-08-26 02:48:16 +08:00
aec01b117d 🐛 Fix chat service duplicate notifying 2025-08-26 00:15:39 +08:00
d299c32e35 ♻️ Clean up OIDC provider 2025-08-25 23:53:04 +08:00
344007af66 🔊 Logging more ip address 2025-08-25 23:42:41 +08:00
d4de5aeac2 🐛 Fix api key exists cause regular login 500 2025-08-25 23:30:41 +08:00
8ce5ba50f4 🐛 Fix api key cause 401 in other serivces 2025-08-25 23:20:27 +08:00
5a44952b27 🐛 Fix oidc token aud 2025-08-25 23:17:40 +08:00
c30946daf6 🐛 Still bug fixes in auth service 2025-08-25 23:01:17 +08:00
0221d7b294 🐛 Fix compress GIF wrongly 2025-08-25 22:42:14 +08:00
c44b0b64c3 🐛 Fix api key auth issue 2025-08-25 22:39:35 +08:00
442ee3bcfd 🐛 Fixes in auth service 2025-08-25 22:24:18 +08:00
081815c512 Trying to optimize pusher serivce 2025-08-25 21:48:07 +08:00
eab2a388ae 🐛 Fixes in authorize 2025-08-25 21:22:04 +08:00
5f7ab49abb 🛂 Add permission check in post pin / unpin 2025-08-25 20:04:21 +08:00
4ff89173b2 ♻️ Some optimzations for sync message endpoint 2025-08-25 19:24:42 +08:00
f2052410c7 Filtered realm posts 2025-08-25 17:47:30 +08:00
83a49be725 🐛 Fix websocket missing in notification 2025-08-25 17:43:37 +08:00
9b205a73fd 💄 Optimize post controller 2025-08-25 17:06:21 +08:00
d5157eb7e3 Post category tags subscriptions 2025-08-25 14:18:14 +08:00
75c92c51db 🐛 Dozens of bug fixes 2025-08-25 13:43:40 +08:00
915054fce0 Pinned post 2025-08-25 13:37:25 +08:00
63653680ba 👔 Update the algorithm to pick featured post 2025-08-25 13:06:09 +08:00
84c4df6620 👔 Prevent from creating duplicate featured record 2025-08-25 13:05:34 +08:00
8c748fd57a Bring OIDC back 2025-08-25 02:44:44 +08:00
4684550ebf App custom secret management 2025-08-24 23:50:57 +08:00
51db08f374 🐛 Fix develop API permission check 2025-08-24 21:53:41 +08:00
9f38a288b9 🐛 Fix save notification again.. 2025-08-24 18:05:42 +08:00
75a975049c 🐛 Fix get subscribed feed 2025-08-24 17:37:30 +08:00
f8c35c0350 🐛 Fix queue background service in pusher didn't save notification now 2025-08-24 16:59:27 +08:00
d9a5fed77f 🐛 Fix wrong queue name 2025-08-24 13:19:39 +08:00
7cb14940d9 🐛 Fix rotate key 2025-08-24 01:49:48 +08:00
953bf5d4de Bot controller has keys endpoints 2025-08-23 19:52:05 +08:00
d9620fd6a4 Bot transparency API 2025-08-23 17:55:42 +08:00
541e2dd14c 🐛 Fix bots errors 2025-08-23 17:06:52 +08:00
c7925d98c8 🐛 Fix bot account missing created / updated at 2025-08-23 14:25:46 +08:00
f759b19bcb 🐛 Fixes in bot 2025-08-23 14:20:21 +08:00
5d7429a416 ♻️ Refind bot account 2025-08-23 13:00:30 +08:00
fb7e52d6f3 Sticker pack includes preview stickers 2025-08-22 23:02:16 +08:00
50e888b075 🐛 Fix mark all read will reset the viewed at 2025-08-22 22:42:32 +08:00
76c8bbf307 🐛 Fix social credit cache didn't have base value 2025-08-22 22:41:38 +08:00
8f3825e92c Cache user social credits on profile 2025-08-22 22:28:48 +08:00
d1c3610ec8 🐛 Dozens of bug fixes 2025-08-22 19:55:16 +08:00
4b958a3c31 🗑️ Remove the old search API 2025-08-22 17:07:22 +08:00
1f9021d459 🎨 Disassmeble the activity service parts 2025-08-22 16:56:21 +08:00
7ad9deaf70 🎨 Adjust post shuffle query 2025-08-22 16:50:06 +08:00
c1c17b5f4e Optimize post categories, tags usage counting 2025-08-21 23:22:59 +08:00
d92220b4bc ♻️ Refactor NATS message handling 2025-08-21 18:47:23 +08:00
4d1972bc99 ♻️ Refactored the queue 2025-08-21 17:41:48 +08:00
83c052ec4e ♻️ Replace check in with recorded experience source 2025-08-21 02:30:59 +08:00
57a75fe9e6 Done with social credits 2025-08-21 02:28:39 +08:00
379bc37aff Social credit, leveling service 2025-08-21 01:30:39 +08:00
0217fbb13b Sorting post categories, tags with order 2025-08-20 19:06:18 +08:00
4e9943e6a2 🍱 Update database migrations 2025-08-20 18:50:23 +08:00
b3cc623168 Web feed subscription APIs 2025-08-20 18:41:11 +08:00
3ee5e5367d Web feed subcription 2025-08-20 14:21:25 +08:00
85fef30c7f Search with sticker packs 2025-08-20 14:02:34 +08:00
e8d8dcbb2d 💄 Better sticker marketplace listing 2025-08-20 14:00:15 +08:00
3b679d6134 API Keys 2025-08-20 13:41:06 +08:00
ec44b51ab6 Reply and forward gone indicator 2025-08-20 02:14:18 +08:00
2e52a13c30 🍱 Update migrations 2025-08-20 01:41:37 +08:00
1e8e2e9ea7 🐛 Fixes DI and lifetimes 2025-08-20 01:41:27 +08:00
9e8363c004 Drive resource recycler, delete files in batch 2025-08-20 00:11:52 +08:00
56c40ee001 File references deletion batch 2025-08-19 22:47:20 +08:00
e3dfccfee3 Account service account deleted broadcast message & sphere service clean up 2025-08-19 22:39:12 +08:00
d555fcaf17 🐛 Fix org publisher creation missing validation as well 2025-08-19 21:34:27 +08:00
2fdefae718 🐛 Fix publiser has no validate 2025-08-19 21:24:30 +08:00
e78858b7b4 Speed up the gateway loopback /cgi route by letting gateway directly handle it 2025-08-19 19:27:18 +08:00
636b674229 🧱 Add stream (NATS) message queue infra 2025-08-19 19:23:41 +08:00
fc6cee17d7 Add notification to friend request 2025-08-19 19:06:08 +08:00
7f7b47fb1c Invoke bot reciever service in Bot 2025-08-19 15:48:19 +08:00
bf181b88ec Account bot basis 2025-08-19 15:16:35 +08:00
c056938b6e 👔 Update link preview match regex 2025-08-18 21:17:00 +08:00
66eadf96b0 🐛 Fix randomly account got logged out 2025-08-18 20:56:25 +08:00
665595b8b4 Developer projects 2025-08-18 20:49:09 +08:00
29550401fd Add forwarded header across all gateway routes 2025-08-18 20:14:22 +08:00
1bb0012c40 🐛 Fix logout 2025-08-18 17:57:14 +08:00
2cea391ebf 🐛 Fix logout session 2025-08-18 17:52:40 +08:00
32e91da0b2 🐛 Fix circular dependecy 2025-08-18 16:34:07 +08:00
69b56b9658 🔊 Logging auth flow 2025-08-18 16:19:21 +08:00
83e3d77f79 🐛 Add forwarded headers to Gateway 2025-08-18 13:20:31 +08:00
38a8eecd50 🐛 Fix listing members with missing accounts 2025-08-18 11:38:45 +08:00
bd77137714 🐛 Fixes of withStatus 2025-08-18 01:39:33 +08:00
201126e5d0 🧱 Add new ApiError system 2025-08-18 01:10:49 +08:00
d4a2e5ef5b ♻️ Refactored auth controller 2025-08-18 00:14:18 +08:00
2761abf405 Login now send a notification 2025-08-17 23:43:13 +08:00
add16ffdad 👔 Post listing API now include the Realm 2025-08-17 23:33:25 +08:00
b49cd1c382 Realm and chat with status listing member API 2025-08-17 23:32:58 +08:00
aa9ae5c11e Account status GRPC API 2025-08-17 22:30:17 +08:00
8e8965eb3d 👔 Send factor code no longer requires hint 2025-08-17 21:20:42 +08:00
a0fe8fd0f0 👔 Remove replies in activities 2025-08-17 02:49:32 +08:00
855031a4fe 💄 Optimize get activities 2025-08-17 02:49:16 +08:00
adc2b20aeb 🐛 Fix activity post listing do not contains realm info 2025-08-17 02:41:44 +08:00
c860f10cf9 🔀 Merge pull request '更新 DysonNetwork.Pass/Resources/Localization/AccountEventResource.resx' (#6) from a123lsw-patch-2 into master
Reviewed-on: Solar/Swarm#6
2025-08-16 17:53:31 +00:00
d441eff2d2 Merge branch 'master' into a123lsw-patch-2 2025-08-16 17:53:25 +00:00
d31f36d3dc 🔀 Merge pull request '更新 DysonNetwork.Pass/Resources/Localization/AccountEventResource.zh-hans.resx' (#5) from a123lsw-patch-1 into master
Reviewed-on: Solar/Swarm#5
2025-08-16 17:53:21 +00:00
4fc7bd47f9 Merge branch 'master' into a123lsw-patch-1 2025-08-16 17:52:41 +00:00
a66037d947 Optimize push service 2025-08-17 00:27:51 +08:00
bb4e04df0b 🔊 Add websocket logger back 2025-08-17 00:19:16 +08:00
d3752caf1d 🐛 Fixes and optimize deliver message 2025-08-16 23:38:47 +08:00
614c77d7ce 🐛 Fix compile failed 2025-08-16 14:35:06 +08:00
5d13f08d47 Post include realm data 2025-08-16 14:31:06 +08:00
07ba148d9b 🐛 Fix challege pickup 2025-08-16 14:30:58 +08:00
917e2d5393 🐛 Fix post get API missing the reference post 2025-08-16 11:59:29 +08:00
e384763faf 🚨 Fix complier warnings 2025-08-16 01:12:38 +08:00
7fb199b187 🐛 Make send notification await 2025-08-16 00:03:41 +08:00
924e31aad5 🐛 Trying to fix chat invite 2025-08-15 16:46:08 +08:00
48f776e6ff Post slug 🐛 Fix duplicate device id 2025-08-15 12:19:36 +08:00
a27bda4720 🐛 Fix web didn't has device name 2025-08-15 12:10:59 +08:00
a7e0e1e369 💄 Update path param 2025-08-15 03:26:15 +08:00
5bb5018cc0 🐛 Fix logout device 2025-08-15 03:06:33 +08:00
a9aab6b7e5 🐛 Add missing logout device 2025-08-15 03:00:13 +08:00
651c06caac 🐛 Fix query without vector failed in post 2025-08-15 02:52:58 +08:00
e0d58085f3 Filter with the realm 2025-08-15 02:44:00 +08:00
cb420c2262 Realm post 2025-08-15 02:42:35 +08:00
6211f546b1 Post list shuffle mode 2025-08-15 02:23:14 +08:00
9070fe7fa3 Post controller has media filter 2025-08-15 02:22:11 +08:00
c86d7275ec New features to post listing API ♻️ Merge search and
listing API
2025-08-15 02:14:04 +08:00
9e1178b7a1 更新 DysonNetwork.Pass/Resources/Localization/AccountEventResource.resx 2025-08-14 16:04:28 +00:00
cd76cedb7b Optimize the post notification 2025-08-14 23:20:30 +08:00
f273445451 🗑️ Remove the client id migration code 2025-08-14 21:05:56 +08:00
740d9a33cf 🐛 Fix pusher service 2025-08-14 20:46:19 +08:00
792d703b6f 🐛 Disable data part to fcm to trying fix INVALID_ARGUMENT 2025-08-14 18:06:13 +08:00
f09832404d 🐛 Fix compile issue in Pusher 2025-08-14 17:43:41 +08:00
134b11e7f0 🐛 Fix notification missing websocket 2025-08-14 17:39:20 +08:00
8c01ec364c 🔊 Add more logging to push notification 2025-08-14 17:25:06 +08:00
27e6dde7c4 Mark all notifications read 2025-08-14 15:33:48 +08:00
b04b17c8ae Optimize push notification saving by introducing the flush buffer 2025-08-14 15:31:33 +08:00
b037ecad79 🔇 Lower some log level in pusher service 2025-08-14 15:10:41 +08:00
7ec3f25d43 🐛 Fix action logs 2025-08-14 02:29:16 +08:00
1778ab112d Authorized device 2025-08-14 02:21:59 +08:00
5f70d53c94 New authorized device 2025-08-14 02:10:32 +08:00
4b66e97bda 🐛 Bug fixes with ws controller 2025-08-13 17:32:13 +08:00
f8d8e485f1 ♻️ Refactored the authorized device (now client) 2025-08-13 15:27:31 +08:00
e21bf531e1 更新 DysonNetwork.Pass/Resources/Localization/AccountEventResource.zh-hans.resx
新增:洗胶片
2025-08-13 05:27:18 +00:00
76fdf14e79 ♻️ Refactored authorize device system (wip) (skip ci) 2025-08-13 02:04:26 +08:00
96cceafe77 🐛 Fix non-required field poll validate incorrect 2025-08-12 17:48:03 +08:00
58e34b20e1 📝 Update official instace URL 2025-08-12 16:06:16 +08:00
LittleSheep
e420b183ce 🔀 Merge pull request #4 from Linorman/master
Update README.md with polished version
2025-08-12 16:04:20 +08:00
Linorman
a08f058806 Update README.md with polished version 2025-08-12 15:41:15 +08:00
616491e6d8 Post featured record 2025-08-12 12:17:26 +08:00
05c6d67c03 👔 Refactor the featured post algo 2025-08-12 12:13:39 +08:00
e66130e893 🐛 Try fix animated image upload 2025-08-11 02:43:31 +08:00
5bb9bbac73 🐛 Trying to fix chat room remove member 2025-08-11 01:16:25 +08:00
8474fc7160 🐛 Stickers uses original file 2025-08-10 22:32:23 +08:00
ea8158cb50 ♻️ Optimize chat summary 2025-08-10 20:22:43 +08:00
65398c5fec 🐛 Fix update sticker 2025-08-10 20:08:45 +08:00
5181897463 🐛 Fix message sync 2025-08-10 19:18:55 +08:00
96c7927632 🐛 Trying to fix chat service 2025-08-10 18:44:04 +08:00
0eb3ffcdbe 💥 Sticker pack API follow other api publisher passing way 2025-08-10 13:24:31 +08:00
LittleSheep
736db75cfd 🔀 Merge pull request #3 from I21b/master
Update AccountEventResource.resx
2025-08-10 12:27:15 +08:00
0b44c4547c 💄 Optimize chat message notification 2025-08-10 12:24:34 +08:00
92
728ac9c166 Update AccountEventResource.resx
shorten sentence too long
2025-08-10 06:41:16 +09:00
360b58885e 👔 File controller now return now when client request thumbnail but file has not 2025-08-10 03:28:52 +08:00
09d412053f Add strike type punishment 2025-08-10 02:16:50 +08:00
e0107f189d 👔 Prevent duplicate contact method 2025-08-10 01:50:05 +08:00
42af09034c 🐛 Fix get perk subscription 2025-08-10 01:07:09 +08:00
963470b693 💥 Change the account profile link format 2025-08-10 00:56:48 +08:00
da57936d92 🐛 Fix some bugs 2025-08-09 23:58:23 +08:00
78cec27ef0 🐛 Trying to fix discovery 2025-08-09 23:22:14 +08:00
c3f5ed881f 🐛 Fix sticker still load image 2025-08-09 22:59:40 +08:00
1c52b4d661 🐛 Trying to fix more subscription issue 2025-08-09 22:52:02 +08:00
765be4f214 🐛 Fix wrong API path for delete status 2025-08-09 22:28:45 +08:00
91de6797c5 🐛 Another fix to prevent the subscription get wrong data 2025-08-09 21:50:42 +08:00
4bceb119ea 🐛 Fix subscription status wrong 2025-08-09 21:34:30 +08:00
14a5c01a6d 🐛 Fix captcha 2025-08-09 21:33:35 +08:00
83df727f8f Fast upload 2025-08-09 02:01:19 +08:00
3444e27a96 🐛 Fix developer service didn't get developer properly 2025-08-09 01:35:43 +08:00
865505f883 File fast upload creation check 2025-08-09 01:27:10 +08:00
0ed47be689 Fast upload API 2025-08-09 01:20:51 +08:00
d8c1c63e56 Hidden pool 2025-08-09 01:09:47 +08:00
2934225a6c 🔊 Developer service logging 2025-08-09 01:07:27 +08:00
LittleSheep
d1e5058dae 🔀 Merge pull request #2 from I21b/master
docs: Small change of AccountEventResource (en)
2025-08-08 23:50:29 +08:00
92
cbd58d3e72 make en match zh-hans (orig) FortuneTipNegativeContent_12 2025-08-09 00:46:35 +09:00
LittleSheep
735268fe46 🔀 Merge pull request #1 from I21b/master
docs: AccountEventResource en and zh-hans
2025-08-08 23:32:32 +08:00
7ddb904335 Public contacts 2025-08-08 23:31:05 +08:00
c514adfbbf Profile links 2025-08-08 23:28:24 +08:00
a32c06552f 👔 Change the post featured period counting to a week 2025-08-08 23:26:08 +08:00
92
aefc1aeb4f Merge branch 'Solsynth:master' into master 2025-08-09 00:22:34 +09:00
92
7fc36b5d22 "pass/res/l10n/" AccountEventResource.resx and zh-hans.resx 2025-08-09 00:20:20 +09:00
5fd52e7b9e Search post with categories and tags 2025-08-08 21:40:48 +08:00
e7d14d4687 Punishment block login and disable account 2025-08-08 15:42:17 +08:00
a57ae840ff Post category controller 2025-08-08 15:23:56 +08:00
009621a456 🐛 Fix developer missing publisher info 2025-08-08 15:07:37 +08:00
36ed0dc893 🐛 Fix last active info didn't flushed 2025-08-08 14:47:54 +08:00
8a1c490907 🐛 Fix highlight post sometimes empty 2025-08-08 14:32:46 +08:00
32054705d0 🐛 Fix develop service missing service 2025-08-08 03:10:22 +08:00
5859483654 ♻️ Update the websocket dupe conn handle 2025-08-08 03:06:20 +08:00
d0ca8db162 🐛 Fix post controller path issue 2025-08-08 02:58:33 +08:00
a3e138cc2d Featured post 2025-08-08 02:10:09 +08:00
1fab398778 🐛 Fix post service poll loading 2025-08-08 01:38:05 +08:00
77ccc9aeb5 Develop service 2025-08-08 00:47:26 +08:00
a6dfe8712c Delete chat room will delete others related resources as well 2025-08-07 21:30:02 +08:00
973b2f81ea 🐛 Prevent the LoadAccountMember sending the deleted account data to user 2025-08-07 21:22:34 +08:00
554f73b550 🔨 Add develop build 2025-08-07 20:33:37 +08:00
ee8e9df12e Complete the develop service 2025-08-07 20:30:34 +08:00
00cdd1bc5d ♻️ Extract the Developer to new service, add PublisherServiceGrpc 2025-08-07 17:16:38 +08:00
f1ea7c1c5a 🐛 Fix sticker open on gateway 2025-08-07 13:14:45 +08:00
d13e18534f 🐛 Fix open sticker 2025-08-07 12:52:27 +08:00
1dc33c5bd4 Update sticker controller 2025-08-07 02:47:29 +08:00
e09922c8df 👔 Make subscribed user no longer need captcha in check in 2025-08-07 02:37:09 +08:00
e85af628bf 🐛 Fixes embedding json loop 2025-08-06 18:07:46 +08:00
4f2e18ca27 🐛 Fix embeddable parsing 2025-08-06 17:55:30 +08:00
1105d6f11e Poll feedback 2025-08-06 14:46:21 +08:00
f2bba64ee5 💄 Optimize discovery search 2025-08-06 14:40:12 +08:00
ebbe14f293 ♻️ Refactor the embeddable to dictionary 2025-08-06 14:32:18 +08:00
681934a0dc 💄 Try optimize post embed DX 2025-08-06 13:38:49 +08:00
a52b09b787 🐛 Trying to fix poll answer cache 2025-08-06 02:59:17 +08:00
b0af3af059 🐛 Trying to fix poll update, again... 2025-08-06 02:50:54 +08:00
6bc5bcfd1a 🐛 Fix poll update 2025-08-06 02:41:00 +08:00
999ba52003 Sticker pack ownerships 2025-08-06 02:36:39 +08:00
e0ebed7c09 🐛 Fix poll controller agian... 2025-08-06 02:23:22 +08:00
e50ce2f515 🐛 Trying to fix poll update 2025-08-06 02:15:40 +08:00
5bb9ed5f04 🐛 Fix the god damn poll 2025-08-06 01:09:00 +08:00
4a36557714 🔊 More detail question type validation 2025-08-06 00:56:20 +08:00
1a93cdad46 🔊 Poll argument out of range message 2025-08-06 00:37:53 +08:00
2bbef9b9d1 🐛 Remove the cache in poll 2025-08-05 22:55:26 +08:00
22101c8280 🐛 Fix poll cache 2025-08-05 22:39:39 +08:00
256c6469a6 🐛 Fix the damn post loading 2025-08-05 22:31:02 +08:00
7367f372c0 🐛 Fix post load poll, again... 2025-08-05 22:18:58 +08:00
822a339532 🐛 Bug fixes in loading poll 2025-08-05 22:12:42 +08:00
5d2ad2479b 🐛 Fix post service load poll 2025-08-05 22:02:25 +08:00
795ca04d7c 🐛 Fix wrong params name (skip instead of offset) 2025-08-05 21:56:36 +08:00
111701a2c4 🐛 Fix mis use of Select function 2025-08-05 21:38:12 +08:00
a793a03a20 🐛 Ensure the member has account in response 2025-08-05 21:33:03 +08:00
d231b5f27e 🐛 Fix loading poll 2025-08-05 21:26:31 +08:00
709dc44d57 Post with polls 2025-08-05 19:53:19 +08:00
d7a39ab574 🐛 Fix poll didn't include questions when listing 2025-08-05 18:06:26 +08:00
18882c08d9 🐛 Trying to fix validation issue in poll 2025-08-05 17:59:58 +08:00
ce6f9a174f 🐛 Fix pub name 2025-08-05 17:49:52 +08:00
f5c8b75122 🐛 Fix missing sensitive marks 2025-08-05 02:38:13 +08:00
165d2e4d93 🐛 Fix cloudfile proto 2025-08-05 02:20:17 +08:00
9e9d0dc563 🐛 Fix bugs 2025-08-05 02:10:41 +08:00
a9a5082e1a File update APIs 2025-08-04 22:26:51 +08:00
eca9601a89 🐛 Fix prefetch change data properties case 2025-08-04 17:32:48 +08:00
6bfe784b3f 🐛 Fix pfp page 2025-08-04 17:20:02 +08:00
6524a56eeb 🐛 Fix sphere webpage load issue 2025-08-04 02:58:08 +08:00
b7f853d84f 🔨 Trying to fix build... 2025-08-04 02:42:28 +08:00
473155b68d 🐛 Fix bugs in msbuild... 2025-08-04 02:37:46 +08:00
608b93fb61 🐛 Trying to fix build, again... 2025-08-04 02:24:30 +08:00
4a36b30d6b 🐛 Fix build again... 2025-08-04 02:19:58 +08:00
72b26c6a2c 🐛 Fix build 2025-08-04 02:12:25 +08:00
7fc86441d1 Page data 2025-08-04 02:07:18 +08:00
1a05f16299 Post detail page 2025-08-04 01:46:26 +08:00
db5d631049 🐛 Fix sphere webpage loading 2025-08-03 22:20:35 +08:00
2d7dd26882 Post with image / media 2025-08-03 22:11:31 +08:00
b0834f48d4 Basic posting 2025-08-03 21:37:18 +08:00
7d3236550c 🎉 Initial Commit for the Sphere webpage 2025-08-03 20:11:30 +08:00
adf62fb42b Pool support wildcard in accept types 2025-08-03 19:48:47 +08:00
14c6913af7 💄 Open webpage connection auth as popup 2025-08-03 13:12:46 +08:00
192ea0fcdd 🐛 Fix discord oidc 2025-08-03 13:10:15 +08:00
189abd4982 🐛 Fix afdian oidc 2025-08-03 12:56:45 +08:00
3df66dabd9 🐛 Fix callback is not centered 2025-08-03 12:39:10 +08:00
f46f70b33c 🚨 Fix pass webpage compile error 2025-08-03 12:33:55 +08:00
e689d15688 💄 Optimize webpage connections experience 2025-08-03 12:29:12 +08:00
3d236c35c9 💄 Optimize the account profile webpage 2025-08-03 02:07:31 +08:00
665538bdd3 Make the prefetch supports typescript and opengraph.
 Use prefetch in Solarpass pfp
2025-08-02 22:15:06 +08:00
be7d7536fc User profile page webpage 2025-08-02 20:30:48 +08:00
a932108c87 Poll stats 2025-08-02 18:45:19 +08:00
71eccbb466 Poll answer and un-answer 2025-08-02 18:18:48 +08:00
700803f7a6 Poll and its CRUD 2025-08-02 17:54:51 +08:00
1f38d827c5 Able to transfer post
♻️ Move the publisher name to query string
2025-08-02 17:37:51 +08:00
8d73c0f289 🐛 Optimize chat summary 2025-08-01 21:42:24 +08:00
f9884e32fb 🐛 Add realm clean up after deleted 2025-08-01 20:45:28 +08:00
27b6f2022f Filter post with type 2025-08-01 17:13:13 +08:00
98b5808b09 👔 Add conditions to notify subscribers new post 2025-08-01 12:54:53 +08:00
f4df8c0c3b 🐛 Fix auth session cache made auth result missing perk subscriptions 2025-08-01 02:04:10 +08:00
882c14df06 👔 Disable post rank for now 2025-07-31 21:21:46 +08:00
b3ed98322b Able to get post reactions list 2025-07-31 20:51:28 +08:00
4cfd4387b6 Reaction made status 2025-07-31 20:48:44 +08:00
89406870bd 🐛 Edit the web scraper corn job 2025-07-31 20:33:58 +08:00
c747d03aff 📝 Fix wrong parameters' name 2025-07-31 20:31:43 +08:00
77df275ac0 🐛 Fixes of translation api 2025-07-31 20:25:04 +08:00
d7dcb7221f 🐛 Fix translate controller shows unexpected unauthorized 2025-07-31 20:16:13 +08:00
92a8709df0 Translation now with cache 2025-07-31 20:08:23 +08:00
e3499ff283 🐛 Fixes in notification 2025-07-31 16:42:35 +08:00
0306b54a0f 🔊 Add logging to the last active info flush 2025-07-31 16:36:48 +08:00
3afbeacffb 🐛 Fix get featured reply 2025-07-31 16:33:28 +08:00
3e7376c1f7 🐛 Fix translation API mapping 2025-07-31 15:25:10 +08:00
fd81e8389c 💄 Optimize translate request 2025-07-31 15:21:47 +08:00
00dda8faf9 🐛 Fix bugs 2025-07-31 15:15:30 +08:00
6b1dda41bc Translation 2025-07-31 15:02:46 +08:00
fd1c47196d 🗃️ Update migration for back dated check in 2025-07-31 15:02:41 +08:00
7383a5cff8 Back dated check in 2025-07-31 14:39:59 +08:00
49fe70b0aa Featured post 2025-07-31 11:27:52 +08:00
8e6e3e6289 🐛 Fix update message response missing account 2025-07-30 23:01:55 +08:00
cb681681e1 👔 Optimize the post activity fetching 2025-07-30 17:55:04 +08:00
1e25982c08 Able to access thumbnail 2025-07-30 17:40:12 +08:00
e243b0f47a 🐛 Fix file deletion 2025-07-30 16:39:12 +08:00
6f0a42820b 🐛 Fixes in drive video thumbnail 2025-07-30 16:36:16 +08:00
c1fc6837db 🐛 Fix chat notification 2025-07-29 23:43:38 +08:00
51697c31cb ♻️ Refactor notification meta 2025-07-29 23:23:40 +08:00
409c83b030 Putting back the view mark flush handler 2025-07-29 23:15:11 +08:00
acb293ec8f 🐛 Fix chat websocket packet 2025-07-29 23:08:41 +08:00
162967e68b 🐛 Fix grpc type helper make mistakes on nodatime 2025-07-29 22:38:13 +08:00
11266ac69a 🐛 Fixes in websocket push 2025-07-29 22:22:56 +08:00
03b4b7f3b9 🐛 Fix gha permission is missing 2025-07-29 22:05:04 +08:00
2649aeeee8 🐛 Fix gha again... 2025-07-29 21:54:40 +08:00
3e76ef62b3 🔨 Update the gha workflow 2025-07-29 21:53:01 +08:00
284cb23d4d 🔨 Trying to fix the docker build gha upper case username issue 2025-07-29 21:45:02 +08:00
24f0d8f151 🔨 Replace the docker hub to use ghcr 2025-07-29 21:40:00 +08:00
9d63a3b81c 🔊 Add flush buffer service logs 2025-07-29 20:56:52 +08:00
f1b594bdf2 ♻️ Refactor the websocket system 2025-07-29 20:43:17 +08:00
1f7b19938b 🐛 Fix websocket event loop somehow 2025-07-29 18:12:51 +08:00
05c6410550 The websocket handle the ping / pong 2025-07-29 17:14:35 +08:00
4246fea03f 👔 Remove the activity timeout control, make client send heartbeat instead 2025-07-29 17:06:19 +08:00
83059374e9 🐛 Fix wrong counting usage 2025-07-29 14:43:40 +08:00
28f6893c68 🐛 Optimize the bundle name too long 2025-07-28 15:00:07 +08:00
d881a75e48 🐛 Optimize the file name if too long 2025-07-28 14:59:27 +08:00
fe5a455b68 🐛 Fix response didn't contains pool 2025-07-28 01:58:08 +08:00
0d4473da69 🐛 Drive related bug fixes 2025-07-28 01:41:26 +08:00
f1b62d354f 🐛 Fix CORS 2025-07-28 01:25:51 +08:00
6ef1533abf 🚨 Bug fix drive frontend 2025-07-28 01:08:33 +08:00
32f7b0221d Better downloading in drive 2025-07-28 01:07:43 +08:00
8b1bb7fcfd File bundle 2025-07-28 00:37:54 +08:00
e31a5ea017 File bundle 2025-07-27 22:45:17 +08:00
7442b8416f 🐛 Fix file hash overflow 2025-07-27 19:27:12 +08:00
c875c82bdc Quota and better drive dashboard 2025-07-27 18:08:39 +08:00
4a0117906a 🐛 Trying to fix upload offset not found in frontend 2025-07-27 14:25:45 +08:00
f74b1cf46a 🐛 Fix frontend drive error 2025-07-27 13:59:10 +08:00
52addc91df ♻️ Changed the way to clean upload folder 2025-07-27 13:44:21 +08:00
e1ebd44ea8 🐛 Fix storage id is null 2025-07-27 13:10:33 +08:00
e428e04435 Speed up delete of recycled files by skipping check 2025-07-27 12:46:03 +08:00
b405a46005 👔 Still allow user to get recycled files 2025-07-27 12:35:52 +08:00
4c0e0b5ee9 Recycled files action 2025-07-27 12:30:13 +08:00
e7e6c258e2 Some drive service changes 2025-07-27 12:03:41 +08:00
05284760a7 :drunk: Update the stellar program tier error 2025-07-27 03:32:28 +08:00
4c0d381be2 🐛 Fix bugs 2025-07-27 03:29:37 +08:00
42b300fefb 🐛 Fix drive web broke 2025-07-27 02:55:38 +08:00
0c08bfed5b 🐛 Censor some credentials file in pool 2025-07-27 02:43:23 +08:00
57c72bdfbf 🔨 Update build script 2025-07-27 02:21:30 +08:00
1fd3b39c75 🐛 Trying to fix unusable captcha 2025-07-27 02:20:29 +08:00
f80cabfa75 🐛 Fix linter issue 2025-07-27 02:18:54 +08:00
2d728e4b07 Improved dashboard of drive 2025-07-27 02:14:48 +08:00
7ff9605460 📱 Responsive drive file page 2025-07-27 01:49:18 +08:00
d3bf9739b5 💄 Improvement drive web preview 2025-07-27 01:48:00 +08:00
4e68ab4ef0 File expiration 2025-07-27 01:43:54 +08:00
71accd725e 👔 Fix file list order 2025-07-27 00:39:57 +08:00
46612b28aa File management 2025-07-27 00:29:59 +08:00
444 changed files with 89510 additions and 4918 deletions

3
.aspire/settings.json Normal file
View File

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

35
.env Normal file
View File

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

View File

@@ -1,4 +1,4 @@
name: Build and Push Microservices name: Aspire Publish Workflow
on: on:
push: push:
@@ -7,132 +7,54 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
build-sphere: publish:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 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: - name: Setup .NET
runs-on: ubuntu-latest uses: actions/setup-dotnet@v3
steps:
- name: Checkout repository
uses: actions/checkout@v3
with: with:
fetch-depth: 0 dotnet-version: "9.0.x"
- name: Setup NBGV
uses: dotnet/nbgv@master
id: nbgv
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to 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
build-pusher: - name: Log in to GitHub Container Registry
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 uses: docker/login-action@v3
with: with:
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }} registry: ghcr.io
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }} username: ${{ github.actor }}
- name: Build and push DysonNetwork.Pusher Docker image password: ${{ secrets.GITHUB_TOKEN }}
uses: docker/build-push-action@v6
with:
file: DysonNetwork.Pusher/Dockerfile
context: .
push: true
tags: xsheep2010/dyson-pusher:latest
platforms: linux/amd64
build-drive: - name: Install Aspire CLI
runs-on: ubuntu-latest run: dotnet tool install -g Aspire.Cli --prerelease
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
build-gateway: - name: Build and Publish Aspire Application
runs-on: ubuntu-latest run: aspire publish --project ./DysonNetwork.Control/DysonNetwork.Control.csproj --output publish
steps:
- name: Checkout repository - name: Tag and Push Images
uses: actions/checkout@v3 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: with:
fetch-depth: 0 name: aspire-publish-output
- name: Setup NBGV path: ./publish/
uses: dotnet/nbgv@master
id: nbgv - name: Upload Docker Compose file
- name: Set up Docker Buildx uses: actions/upload-artifact@v3
uses: docker/setup-buildx-action@v3
- name: Log in to DockerHub
uses: docker/login-action@v3
with: with:
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }} name: docker-compose-output
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }} path: ./publish/docker-compose.yml
- 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}
}

View 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"]

View 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>

View 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;
}
}

View 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);
}
}

View File

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

View File

@@ -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> ?? [];
}
}

View 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(),
};
}
}

View 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);
}
}
}

View File

@@ -1,8 +1,11 @@
using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text;
namespace DysonNetwork.Sphere.Developer; namespace DysonNetwork.Develop.Identity;
public class CustomAppService( public class CustomAppService(
AppDatabase db, AppDatabase db,
@@ -11,10 +14,17 @@ public class CustomAppService(
) )
{ {
public async Task<CustomApp?> CreateAppAsync( public async Task<CustomApp?> CreateAppAsync(
Publisher.Publisher pub, Guid projectId,
CustomAppController.CustomAppRequest request 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 var app = new CustomApp
{ {
Slug = request.Slug!, Slug = request.Slug!,
@@ -23,7 +33,7 @@ public class CustomAppService(
Status = request.Status ?? CustomAppStatus.Developing, Status = request.Status ?? CustomAppStatus.Developing,
Links = request.Links, Links = request.Links,
OauthConfig = request.OauthConfig, OauthConfig = request.OauthConfig,
PublisherId = pub.Id ProjectId = projectId
}; };
if (request.PictureId is not null) if (request.PictureId is not null)
@@ -74,17 +84,104 @@ public class CustomAppService(
return app; 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(); var query = db.CustomApps.AsQueryable();
if (publisherId.HasValue)
query = query.Where(a => a.PublisherId == publisherId.Value); if (projectId.HasValue)
return await query.FirstOrDefaultAsync(); {
query = query.Where(a => a.ProjectId == projectId.Value);
}
return await query.FirstOrDefaultAsync(a => a.Id == id);
} }
public async Task<List<CustomApp>> GetAppsByPublisherAsync(Guid publisherId) 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) public async Task<CustomApp?> UpdateAppAsync(CustomApp app, CustomAppController.CustomAppRequest request)

View 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 };
}
}

View 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;
}
}

View 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; }
}
}

View 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;
}
}
}

View 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
}
}
}

View File

@@ -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");
}
}
}

View 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
}
}
}

View File

@@ -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);
}
}
}

View 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
}
}
}

View File

@@ -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");
}
}
}

View 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
}
}
}

View 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();

View 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; }
}

View 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();
}
}

View 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;
}
}

View File

@@ -5,7 +5,7 @@
"commandName": "Project", "commandName": "Project",
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": false, "launchBrowser": false,
"applicationUrl": "http://localhost:5094", "applicationUrl": "http://localhost:5156",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }
@@ -14,7 +14,7 @@
"commandName": "Project", "commandName": "Project",
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": false, "launchBrowser": false,
"applicationUrl": "https://localhost:7034;http://0.0.0.0:5094", "applicationUrl": "https://localhost:7192;http://localhost:5156",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }

View 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;
}
}

View 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;
}
}

View 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"
}
}

View File

@@ -1,5 +1,6 @@
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Reflection; using System.Reflection;
using DysonNetwork.Drive.Billing;
using DysonNetwork.Drive.Storage; using DysonNetwork.Drive.Storage;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -16,6 +17,9 @@ public class AppDatabase(
) : DbContext(options) ) : DbContext(options)
{ {
public DbSet<FilePool> Pools { get; set; } = null!; 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<CloudFile> Files { get; set; } = null!;
public DbSet<CloudFileReference> FileReferences { get; set; } = null!; public DbSet<CloudFileReference> FileReferences { get; set; } = null!;
@@ -27,7 +31,6 @@ public class AppDatabase(
opt => opt opt => opt
.ConfigureDataSource(optSource => optSource.EnableDynamicJson()) .ConfigureDataSource(optSource => optSource.EnableDynamicJson())
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
.UseNetTopologySuite()
.UseNodaTime() .UseNodaTime()
).UseSnakeCaseNamingConvention(); ).UseSnakeCaseNamingConvention();

View File

@@ -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; }
}

View 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);
}
}

View 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);
}
}

View File

@@ -1,25 +1,49 @@
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Drive.Billing; namespace DysonNetwork.Drive.Billing;
[ApiController] [ApiController]
[Route("api/billing/usage")] [Route("api/billing/usage")]
public class UsageController(UsageService usageService) : ControllerBase public class UsageController(UsageService usage, QuotaService quota, ICacheService cache) : ControllerBase
{ {
[HttpGet] [HttpGet]
[Authorize]
public async Task<ActionResult<TotalUsageDetails>> GetTotalUsage() 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}")] [HttpGet("{poolId:guid}")]
public async Task<ActionResult<UsageDetails>> GetPoolUsage(Guid poolId) 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) if (usageDetails == null)
{
return NotFound(); return NotFound();
}
return usageDetails; return usageDetails;
} }
} }

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Drive.Billing; namespace DysonNetwork.Drive.Billing;
@@ -11,77 +12,77 @@ public class UsageDetails
public required long FileCount { get; set; } public required long FileCount { get; set; }
} }
public class UsageDetailsWithPercentage : UsageDetails
{
public required double Percentage { get; set; }
}
public class TotalUsageDetails 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 long TotalUsageBytes { get; set; }
public required double TotalCost { get; set; }
public required long TotalFileCount { 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 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 var poolUsages = await db.Pools
.Select(p => new UsageDetails .Select(p => new UsageDetails
{ {
PoolId = p.Id, PoolId = p.Id,
PoolName = p.Name, PoolName = p.Name,
UsageBytes = db.Files UsageBytes = fileQuery
.Where(f => f.PoolId == p.Id) .Where(f => f.PoolId == p.Id)
.Sum(f => f.Size), .Sum(f => f.Size),
Cost = db.Files Cost = fileQuery
.Where(f => f.PoolId == p.Id) .Where(f => f.PoolId == p.Id)
.Sum(f => f.Size) / 1024.0 / 1024.0 * .Sum(f => f.Size) / 1024.0 / 1024.0 *
(p.BillingConfig.CostMultiplier ?? 1.0), (p.BillingConfig.CostMultiplier ?? 1.0),
FileCount = db.Files FileCount = fileQuery
.Count(f => f.PoolId == p.Id) .Count(f => f.PoolId == p.Id)
}) })
.ToListAsync(); .ToListAsync();
var totalUsage = poolUsages.Sum(p => p.UsageBytes); var totalUsage = poolUsages.Sum(p => p.UsageBytes);
var totalCost = poolUsages.Sum(p => p.Cost);
var totalFileCount = poolUsages.Sum(p => p.FileCount); 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 return new TotalUsageDetails
{ {
PoolUsages = poolUsagesWithPercentage, PoolUsages = poolUsages,
TotalUsageBytes = totalUsage, 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); var pool = await db.Pools.FindAsync(poolId);
if (pool == null) if (pool == null)
{ {
return null; return null;
} }
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 db.Files var usageBytes = await fileQuery
.Where(f => f.PoolId == poolId)
.SumAsync(f => f.Size); .SumAsync(f => f.Size);
var fileCount = await db.Files var fileCount = await fileQuery
.Where(f => f.PoolId == poolId)
.CountAsync(); .CountAsync();
var cost = usageBytes / 1024.0 / 1024.0 * var cost = usageBytes / 1024.0 / 1024.0 *
@@ -96,4 +97,25 @@ public class UsageService(AppDatabase db)
FileCount = fileCount 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);
}
} }

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,7 @@
export {}
declare global {
interface Window {
DyPrefetch?: any
}
}

View File

@@ -16,7 +16,12 @@
</template> </template>
<script setup lang="ts"> <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 { NIcon, NLayout, NLayoutSider, NMenu, type MenuOption } from 'naive-ui'
import { h, type Component } from 'vue' import { h, type Component } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router' import { RouterView, useRoute, useRouter } from 'vue-router'
@@ -34,6 +39,21 @@ const menuOptions: MenuOption[] = [
key: 'dashboardUsage', key: 'dashboardUsage',
icon: renderIcon(DataUsageRound), 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) { function updateMenuSelect(key: string) {

View File

@@ -64,7 +64,7 @@ const guestOptions = [
const userOptions = computed(() => [ const userOptions = computed(() => [
{ {
label: 'Usage', label: 'Dashboard',
key: 'dashboardUsage', key: 'dashboardUsage',
icon: () => icon: () =>
h(NIcon, null, { h(NIcon, null, {

View File

@@ -2,7 +2,15 @@
import LayoutDefault from './layouts/default.vue' import LayoutDefault from './layouts/default.vue'
import { RouterView } from 'vue-router' 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 { usePreferredDark } from '@vueuse/core'
import { useUserStore } from './stores/user' import { useUserStore } from './stores/user'
import { onMounted } from 'vue' import { onMounted } from 'vue'
@@ -34,10 +42,14 @@ onMounted(() => {
<template> <template>
<n-config-provider :theme-overrides="themeOverrides" :theme="isDark ? darkTheme : lightTheme"> <n-config-provider :theme-overrides="themeOverrides" :theme="isDark ? darkTheme : lightTheme">
<n-global-style /> <n-global-style />
<n-message-provider placement="bottom"> <n-loading-bar-provider>
<layout-default> <n-dialog-provider>
<router-view /> <n-message-provider placement="bottom">
</layout-default> <layout-default>
</n-message-provider> <router-view />
</layout-default>
</n-message-provider>
</n-dialog-provider>
</n-loading-bar-provider>
</n-config-provider> </n-config-provider>
</template> </template>

View File

@@ -15,6 +15,11 @@ const router = createRouter({
name: 'files', name: 'files',
component: () => import('../views/files.vue'), component: () => import('../views/files.vue'),
}, },
{
path: '/bundles/:bundleId',
name: 'bundleDetails',
component: () => import('../views/bundles.vue'),
},
{ {
path: '/dashboard', path: '/dashboard',
name: 'dashboard', name: 'dashboard',
@@ -27,6 +32,24 @@ const router = createRouter({
component: () => import('../views/dashboard/usage.vue'), component: () => import('../views/dashboard/usage.vue'),
meta: { requiresAuth: true }, 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() const servicesStore = useServicesStore()
// Initialize user state if not already initialized // Initialize user state if not already initialized
if (!userStore.user && localStorage.getItem('authToken')) { if (!userStore.user) {
await userStore.fetchUser() await userStore.fetchUser()
} }

View File

@@ -19,7 +19,7 @@ export const useServicesStore = defineStore('services', () => {
} }
function getSerivceUrl(serviceName: string, ...parts: string[]): string | null { 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 return baseUrl ? `${baseUrl}/${parts.join('/')}` : null
} }

View File

@@ -4,6 +4,7 @@ export interface SnFilePool {
description: string description: string
storage_config: StorageConfig storage_config: StorageConfig
billing_config: BillingConfig billing_config: BillingConfig
policy_config: any
public_indexable: boolean public_indexable: boolean
public_usable: boolean public_usable: boolean
no_optimization: boolean no_optimization: boolean

View 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>

View 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>

View 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>

View 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>

View File

@@ -3,7 +3,16 @@
<div class="h-full flex justify-center items-center" v-if="!usage"> <div class="h-full flex justify-center items-center" v-if="!usage">
<n-spin /> <n-spin />
</div> </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-gi>
<n-card class="h-stats"> <n-card class="h-stats">
<n-statistic label="All Uploads" tabular-nums> <n-statistic label="All Uploads" tabular-nums>
@@ -25,23 +34,45 @@
</n-gi> </n-gi>
<n-gi> <n-gi>
<n-card class="h-stats"> <n-card class="h-stats">
<n-statistic label="Cost" tabular-nums> <n-statistic label="Quota" tabular-nums>
<n-number-animation :from="0" :to="usage.total_cost" :precision="2" /> <n-number-animation :from="0" :to="usage.total_quota" />
<template #suffix>NSD</template> <template #suffix>MiB</template>
</n-statistic> </n-statistic>
</n-card> </n-card>
</n-gi> </n-gi>
<n-gi> <n-gi>
<n-card class="h-stats"> <n-card class="h-stats">
<n-statistic label="Pools" tabular-nums> <div class="flex gap-2 justify-between items-end">
<n-number-animation :from="0" :to="usage.pool_usages.length" /> <n-statistic label="Used Quota" tabular-nums>
</n-statistic> <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-card>
</n-gi> </n-gi>
<n-gi span="2"> <n-gi span="2">
<n-card class="ratio-video" title="Pool Usage"> <n-card class="aspect-video" title="Pool Usage">
<pie <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="{ :options="{
maintainAspectRatio: false, maintainAspectRatio: false,
responsive: true, responsive: true,
@@ -55,7 +86,7 @@
</template> </template>
<script setup lang="ts"> <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 { Chart as ChartJS, Title, Tooltip, Legend, ArcElement } from 'chart.js'
import { Pie } from 'vue-chartjs' import { Pie } from 'vue-chartjs'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
@@ -66,7 +97,7 @@ ChartJS.register(Title, Tooltip, Legend, ArcElement)
const breakpoints = useBreakpoints(breakpointsTailwind) const breakpoints = useBreakpoints(breakpointsTailwind)
const isDesktop = breakpoints.greaterOrEqual('md') const isDesktop = breakpoints.greaterOrEqual('md')
const chartData = computed(() => ({ const poolChartData = computed(() => ({
labels: usage.value.pool_usages.map((pool: any) => pool.pool_name), labels: usage.value.pool_usages.map((pool: any) => pool.pool_name),
datasets: [ datasets: [
{ {
@@ -74,7 +105,7 @@ const chartData = computed(() => ({
backgroundColor: '#7D80BAFF', backgroundColor: '#7D80BAFF',
data: usage.value.pool_usages.map((pool: any) => pool.usage_bytes), data: usage.value.pool_usages.map((pool: any) => pool.usage_bytes),
}, },
] ],
})) }))
const usage = ref<any>() const usage = ref<any>()
@@ -91,6 +122,36 @@ async function fetchUsage() {
} }
onMounted(() => 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 { function toGigabytes(bytes: number): number {
return bytes / (1024 * 1024 * 1024) return bytes / (1024 * 1024 * 1024)
} }

View File

@@ -2,8 +2,8 @@
<section class="min-h-full relative flex items-center justify-center"> <section class="min-h-full relative flex items-center justify-center">
<n-spin v-if="!fileInfo && !error" /> <n-spin v-if="!fileInfo && !error" />
<n-result status="404" title="No file was found" :description="error" v-else-if="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-card class="max-w-4xl my-4 mx-8" v-else>
<n-grid :cols="2" x-gap="16"> <n-grid cols="1 m:2" x-gap="16" y-gap="16" responsive="screen">
<n-gi> <n-gi>
<div v-if="fileInfo.is_encrypted"> <div v-if="fileInfo.is_encrypted">
<n-alert type="info" size="small" title="Encrypted file"> <n-alert type="info" size="small" title="Encrypted file">
@@ -14,6 +14,15 @@
<div v-else> <div v-else>
<n-image v-if="fileType === 'image'" :src="fileSource" class="w-full" /> <n-image v-if="fileType === 'image'" :src="fileSource" class="w-full" />
<video v-else-if="fileType === 'video'" :src="fileSource" controls 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> </div>
</n-gi> </n-gi>
@@ -77,7 +86,7 @@
</n-card> </n-card>
</div> </div>
<div class="flex flex-col gap-3" v-if="!progress"> <div class="flex flex-col gap-3">
<n-input <n-input
v-if="fileInfo.is_encrypted" v-if="fileInfo.is_encrypted"
placeholder="Password" placeholder="Password"
@@ -104,9 +113,14 @@
</n-popover> </n-popover>
</div> </div>
</div> </div>
<div v-else> <n-collapse-transition :show="!!progress">
<n-progress processing :percentage="progress" /> <n-progress
</div> :processing="!!progress && progress < 100"
:percentage="progress"
indicator-placement="inside"
class="mt-4"
/>
</n-collapse-transition>
</n-gi> </n-gi>
</n-grid> </n-grid>
</n-card> </n-card>
@@ -156,6 +170,7 @@ const error = ref<string | null>(null)
const filePass = ref<string>('') const filePass = ref<string>('')
const fileId = route.params.fileId const fileId = route.params.fileId
const passcode = route.query.passcode as string | undefined
const progress = ref<number | undefined>(0) const progress = ref<number | undefined>(0)
@@ -168,7 +183,11 @@ const currentUrl = window.location.href
const fileInfo = ref<any>(null) const fileInfo = ref<any>(null)
async function fetchFileInfo() { async function fetchFileInfo() {
try { 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) { if (!resp.ok) {
throw new Error('Failed to fetch file info: ' + resp.statusText) throw new Error('Failed to fetch file info: ' + resp.statusText)
} }
@@ -183,9 +202,15 @@ const fileType = computed(() => {
if (!fileInfo.value) return 'unknown' if (!fileInfo.value) return 'unknown'
return fileInfo.value.mime_type?.split('/')[0] || '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) { if (fileInfo.value.is_encrypted && !filePass.value) {
messageDisplay.error('Please enter the password to download the file.') messageDisplay.error('Please enter the password to download the file.')
return return
@@ -198,7 +223,40 @@ function downloadFile() {
progress.value = undefined progress.value = undefined
}) })
} else { } 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> </script>

View File

@@ -1,128 +1,91 @@
<template> <template>
<section class="h-full relative flex items-center justify-center"> <section class="h-full relative flex flex-col items-center justify-center">
<n-card class="max-w-lg" title="About" v-if="!userStore.user"> <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>Welcome to the <b>Solar Drive</b></p>
<p>We help you upload, collect, and share files with ease in mind.</p> <p>We help you upload, collect, and share files with ease in mind.</p>
<p>To continue, login first.</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>
<n-card class="max-w-2xl" title="Upload to Solar Network" v-else>
<template #header-extra>
<div class="flex gap-2 items-center">
<p>Advance Mode</p>
<n-switch v-model:value="modeAdvanced" size="small" />
</div>
</template>
<div class="mb-3"> <n-card class="max-w-2xl" v-else content-style="padding: 0;">
<n-select <n-tabs type="line" animated :tabs-padding="20" pane-style="padding: 20px">
v-model:value="filePool" <template #suffix>
:options="pools ?? []" <div class="flex gap-2 items-center me-4">
:render-label="renderPoolSelectLabel" <p>Advance Mode</p>
:render-tag="renderSingleSelectTag" <n-switch v-model:value="modeAdvanced" size="small" />
value-field="id" </div>
label-field="name" </template>
placeholder="Select a file pool to upload"
clearable
size="large"
/>
</div>
<n-collapse-transition :show="modeAdvanced"> <n-tab-pane name="direct" tab="Direct Upload" :disabled="isBundleMode">
<n-card title="Advance Options" size="small" class="mb-3"> <div class="mb-3">
<div> <file-pool-select v-model="filePool" @update:pool="currentFilePool = $event" />
<p class="pl-1 mb-0.5">File Password</p> </div>
<n-input <upload-area
v-model:value="filePass" :filePool="filePool"
:disabled="!currentFilePool?.allow_encryption" :pools="pools as SnFilePool[]"
placeholder="Enter password to protect the file" :modeAdvanced="modeAdvanced"
show-password-toggle />
size="large" </n-tab-pane>
type="password" <n-tab-pane name="bundle" tab="Bundle Upload">
class="mb-2" <div class="mb-3">
<bundle-select v-model:bundle="selectedBundleId" :disabled="isBundleMode" />
</div>
<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>
<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()"
>
{{ isBundleMode ? 'Cancel' : 'Proceed to Upload' }}
</n-button>
</div>
<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!"
/> />
<p class="pl-1 text-xs opacity-75 mt-[-4px]">
Only available for Stellar Program and certian file pool.
</p>
</div> </div>
</n-card> </n-tab-pane>
</n-collapse-transition> </n-tabs>
<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>
<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>
<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>
</section> </section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { import { NCard, NSwitch, NTabs, NTabPane, NButton, NModal } from 'naive-ui'
NCard, import { computed, onMounted, ref } from 'vue'
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 { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import type { SnFilePool } from '@/types/pool' import type { SnFilePool } from '@/types/pool'
import { formatBytes } from './format' import FilePoolSelect from '@/components/FilePoolSelect.vue'
import UploadArea from '@/components/UploadArea.vue'
import * as tus from 'tus-js-client' import BundleSelect from '@/components/BundleSelect.vue'
import BundleForm from '@/components/form/BundleForm.vue'
const userStore = useUserStore() const userStore = useUserStore()
@@ -142,214 +105,60 @@ async function fetchPools() {
} }
onMounted(() => 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 modeAdvanced = ref(false)
const filePool = ref<string | null>(null) const filePool = ref<string | null>(null)
const filePass = ref<string>('')
const currentFilePool = computed(() => { const currentFilePool = computed(() => {
if (!filePool.value) return null if (!filePool.value) return null
return pools.value?.find((pool) => pool.id === filePool.value) ?? 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({ async function createBundle() {
file, try {
data, await bundleFormRef.value?.formRef?.validate()
headers, const resp = await fetch('/api/bundles', {
withCredentials, method: 'POST',
action, headers: {
onFinish, 'Content-Type': 'application/json',
onError, },
onProgress, body: JSON.stringify(newBundle.value),
}: UploadCustomRequestOptions) { })
const requestHeaders: Record<string, string> = {} if (!resp.ok) {
if (filePool.value) requestHeaders['X-FilePool'] = filePool.value throw new Error('Failed to create bundle')
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',
},
headers: {
...requestHeaders,
...headers,
},
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() 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)
}
} }
function createThumbnailUrl( function proceedToBundleUpload() {
_file: File | null, currentBundleId.value = selectedBundleId.value || newBundleId.value
fileInfo: UploadSettledFileInfo, bundleUploadMode.value = true
): string | undefined { isBundleMode.value = true
if (!fileInfo) return undefined
return fileInfo.url ?? undefined
} }
function customDownload(file: UploadFileInfo) { function cancelBundleUpload() {
const { url, name } = file bundleUploadMode.value = false
if (!url) return isBundleMode.value = false
window.open(url.replace('/api', ''), '_blank') currentBundleId.value = null
} selectedBundleId.value = null
newBundleId.value = null
function customPreview(file: UploadFileInfo, detail: { event: MouseEvent }) {
detail.event.preventDefault()
const { url, type } = file
if (!url) return
window.open(url.replace('/api', ''), '_blank')
} }
</script> </script>

View File

@@ -17,15 +17,6 @@ export default defineConfig({
'@': fileURLToPath(new URL('./src', import.meta.url)), '@': fileURLToPath(new URL('./src', import.meta.url)),
}, },
}, },
build: {
rollupOptions: {
output: {
entryFileNames: `assets/[name].js`,
chunkFileNames: `assets/[name].js`,
assetFileNames: `assets/[name].[ext]`,
},
},
},
server: { server: {
proxy: { proxy: {
'/api': { '/api': {

View File

@@ -8,6 +8,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" /> <PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" />
<PackageReference Include="FFMpegCore" Version="5.2.0" /> <PackageReference Include="FFMpegCore" Version="5.2.0" />
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" /> <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" />
@@ -34,7 +35,6 @@
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" /> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" /> <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" /> <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
@@ -66,6 +66,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -0,0 +1,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
}
}
}

View File

@@ -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");
}
}
}

View 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
}
}
}

View File

@@ -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");
}
}
}

View 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
}
}
}

View File

@@ -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");
}
}
}

View 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
}
}
}

View File

@@ -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");
}
}
}

View 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
}
}
}

View File

@@ -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);
}
}
}

View File

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

View File

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

View File

@@ -24,14 +24,13 @@ namespace DysonNetwork.Drive.Migrations
.HasAnnotation("ProductVersion", "9.0.7") .HasAnnotation("ProductVersion", "9.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b =>
{ {
b.Property<string>("Id") b.Property<Guid>("Id")
.HasMaxLength(32) .ValueGeneratedOnAdd()
.HasColumnType("character varying(32)") .HasColumnType("uuid")
.HasColumnName("id"); .HasColumnName("id");
b.Property<Guid>("AccountId") b.Property<Guid>("AccountId")
@@ -46,11 +45,66 @@ namespace DysonNetwork.Drive.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at"); .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") b.Property<string>("Description")
.HasMaxLength(4096) .HasMaxLength(4096)
.HasColumnType("character varying(4096)") .HasColumnType("character varying(4096)")
.HasColumnName("description"); .HasColumnName("description");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<Dictionary<string, object>>("FileMeta") b.Property<Dictionary<string, object>>("FileMeta")
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("file_meta"); .HasColumnName("file_meta");
@@ -117,11 +171,6 @@ namespace DysonNetwork.Drive.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("uploaded_at"); .HasColumnName("uploaded_at");
b.Property<string>("UploadedTo")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("uploaded_to");
b.Property<Dictionary<string, object>>("UserMeta") b.Property<Dictionary<string, object>>("UserMeta")
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("user_meta"); .HasColumnName("user_meta");
@@ -129,6 +178,9 @@ namespace DysonNetwork.Drive.Migrations
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_files"); .HasName("pk_files");
b.HasIndex("BundleId")
.HasDatabaseName("ix_files_bundle_id");
b.HasIndex("PoolId") b.HasIndex("PoolId")
.HasDatabaseName("ix_files_pool_id"); .HasDatabaseName("ix_files_pool_id");
@@ -185,6 +237,65 @@ namespace DysonNetwork.Drive.Migrations
b.ToTable("file_references", (string)null); 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 => modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -215,6 +326,10 @@ namespace DysonNetwork.Drive.Migrations
.HasColumnType("character varying(8192)") .HasColumnType("character varying(8192)")
.HasColumnName("description"); .HasColumnName("description");
b.Property<bool>("IsHidden")
.HasColumnType("boolean")
.HasColumnName("is_hidden");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(1024) .HasMaxLength(1024)
@@ -243,18 +358,25 @@ namespace DysonNetwork.Drive.Migrations
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => 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") b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool")
.WithMany() .WithMany()
.HasForeignKey("PoolId") .HasForeignKey("PoolId")
.HasConstraintName("fk_files_pools_pool_id"); .HasConstraintName("fk_files_pools_pool_id");
b.Navigation("Bundle");
b.Navigation("Pool"); b.Navigation("Pool");
}); });
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
{ {
b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File") b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
.WithMany() .WithMany("References")
.HasForeignKey("FileId") .HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired() .IsRequired()
@@ -262,6 +384,16 @@ namespace DysonNetwork.Drive.Migrations
b.Navigation("File"); 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 #pragma warning restore 612, 618
} }
} }

View File

@@ -10,16 +10,19 @@ using tusdotnet.Stores;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
// Configure Kestrel and server options // Configure Kestrel and server options
builder.ConfigureAppKestrel(builder.Configuration); builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxValue);
// Add application services // Add application services
builder.Services.AddRegistryService(builder.Configuration);
builder.Services.AddAppServices(builder.Configuration); builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppRateLimiting(); builder.Services.AddAppRateLimiting();
builder.Services.AddAppAuthentication(); builder.Services.AddAppAuthentication();
builder.Services.AddAppSwagger(); builder.Services.AddAppSwagger();
builder.Services.AddDysonAuth(); builder.Services.AddDysonAuth();
builder.Services.AddAccountService();
builder.Services.AddAppFileStorage(builder.Configuration); builder.Services.AddAppFileStorage(builder.Configuration);
@@ -36,6 +39,8 @@ builder.Services.AddTransient<IPageDataProvider, VersionPageData>();
var app = builder.Build(); var app = builder.Build();
app.MapDefaultEndpoints();
// Run database migrations // Run database migrations
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
@@ -46,9 +51,7 @@ using (var scope = app.Services.CreateScope())
var tusDiskStore = app.Services.GetRequiredService<TusDiskStore>(); var tusDiskStore = app.Services.GetRequiredService<TusDiskStore>();
// Configure application middleware pipeline // Configure application middleware pipeline
app.ConfigureAppMiddleware(tusDiskStore); app.ConfigureAppMiddleware(tusDiskStore, builder.Environment.ContentRootPath);
app.MapGatewayProxy();
app.MapPages(Path.Combine(app.Environment.WebRootPath, "dist", "index.html")); app.MapPages(Path.Combine(app.Environment.WebRootPath, "dist", "index.html"));

View File

@@ -1,4 +1,5 @@
using DysonNetwork.Drive.Storage; using DysonNetwork.Drive.Storage;
using Microsoft.Extensions.FileProviders;
using tusdotnet; using tusdotnet;
using tusdotnet.Interfaces; using tusdotnet.Interfaces;
@@ -6,7 +7,7 @@ namespace DysonNetwork.Drive.Startup;
public static class ApplicationBuilderExtensions 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. // Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
@@ -18,6 +19,21 @@ public static class ApplicationBuilderExtensions
app.UseAuthorization(); app.UseAuthorization();
app.MapControllers(); 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))); app.MapTus("/api/tus", _ => Task.FromResult(TusService.BuildConfiguration(tusStore, app.Configuration)));
return app; return app;

View 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);
}
}
}
}

View File

@@ -1,3 +1,4 @@
using DysonNetwork.Drive.Storage;
using Quartz; using Quartz;
namespace DysonNetwork.Drive.Startup; namespace DysonNetwork.Drive.Startup;
@@ -14,6 +15,13 @@ public static class ScheduledJobsConfiguration
.ForJob(appDatabaseRecyclingJob) .ForJob(appDatabaseRecyclingJob)
.WithIdentity("AppDatabaseRecyclingTrigger") .WithIdentity("AppDatabaseRecyclingTrigger")
.WithCronSchedule("0 0 0 * * ?")); .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); services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);

View File

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

View 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();
}
}

View File

@@ -1,35 +1,18 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Google.Protobuf;
using Newtonsoft.Json;
using NodaTime; using NodaTime;
using NodaTime.Serialization.Protobuf; using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Drive.Storage; 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 public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
{ {
/// The id generated by TuS, basically just UUID remove the dash lines /// The id generated by TuS, basically just UUID remove the dash lines
[MaxLength(32)] [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(1024)] public string Name { get; set; } = string.Empty;
[MaxLength(4096)] public string? Description { get; set; } [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; } = []; [Column(TypeName = "jsonb")] public List<ContentSensitiveMark>? SensitiveMarks { get; set; } = [];
[MaxLength(256)] public string? MimeType { get; set; } [MaxLength(256)] public string? MimeType { get; set; }
[MaxLength(256)] public string? Hash { get; set; } [MaxLength(256)] public string? Hash { get; set; }
public Instant? ExpiredAt { get; set; }
public long Size { get; set; } public long Size { get; set; }
public Instant? UploadedAt { get; set; } public Instant? UploadedAt { get; set; }
public bool HasCompression { get; set; } = false; public bool HasCompression { get; set; } = false;
public bool HasThumbnail { get; set; } = false; public bool HasThumbnail { get; set; } = false;
public bool IsEncrypted { 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; } public Guid? PoolId { get; set; }
[JsonIgnore] public FileBundle? Bundle { get; set; }
[Obsolete("Deprecated, use PoolId instead. For database migration only.")] public Guid? BundleId { get; set; }
[MaxLength(128)]
public string? UploadedTo { get; set; }
/// <summary> /// <summary>
/// The field is set to true if the recycling job plans to delete the file. /// The field is set to true if the recycling job plans to delete the file.
@@ -59,7 +41,7 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
/// The object name which stored remotely, /// The object name which stored remotely,
/// multiple cloud file may have same storage id to indicate they are the same file /// multiple cloud file may have same storage id to indicate they are the same file
/// ///
/// If the storage id was null and the uploaded at is not null, means it is an embedding file, /// If the storage id was null and the uploaded at is not null, means it is an embedding file,
/// The embedding file means the file is store on another site, /// The embedding file means the file is store on another site,
/// or it is a webpage (based on mimetype) /// or it is a webpage (based on mimetype)
@@ -71,6 +53,12 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
[MaxLength(4096)] [MaxLength(4096)]
public string? StorageUrl { get; set; } 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 Guid AccountId { get; set; }
public CloudFileReferenceObject ToReferenceObject() public CloudFileReferenceObject ToReferenceObject()
@@ -82,8 +70,9 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
DeletedAt = DeletedAt, DeletedAt = DeletedAt,
Id = Id, Id = Id,
Name = Name, Name = Name,
FileMeta = FileMeta, FileMeta = FileMeta ?? [],
UserMeta = UserMeta, UserMeta = UserMeta ?? [],
SensitiveMarks = SensitiveMarks,
MimeType = MimeType, MimeType = MimeType,
Hash = Hash, Hash = Hash,
Size = Size, Size = Size,
@@ -102,7 +91,7 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
var proto = new Shared.Proto.CloudFile var proto = new Shared.Proto.CloudFile
{ {
Id = Id, Id = Id,
Name = Name ?? string.Empty, Name = Name,
MimeType = MimeType ?? string.Empty, MimeType = MimeType ?? string.Empty,
Hash = Hash ?? string.Empty, Hash = Hash ?? string.Empty,
Size = Size, Size = Size,
@@ -111,13 +100,10 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
ContentType = MimeType ?? string.Empty, ContentType = MimeType ?? string.Empty,
UploadedAt = UploadedAt?.ToTimestamp(), UploadedAt = UploadedAt?.ToTimestamp(),
// Convert file metadata // Convert file metadata
FileMeta = ByteString.CopyFromUtf8( FileMeta = GrpcTypeHelper.ConvertObjectToByteString(FileMeta),
System.Text.Json.JsonSerializer.Serialize(FileMeta, GrpcTypeHelper.SerializerOptions)
),
// Convert user metadata // Convert user metadata
UserMeta = ByteString.CopyFromUtf8( UserMeta = GrpcTypeHelper.ConvertObjectToByteString(UserMeta),
System.Text.Json.JsonSerializer.Serialize(UserMeta, GrpcTypeHelper.SerializerOptions) SensitiveMarks = GrpcTypeHelper.ConvertObjectToByteString(SensitiveMarks)
)
}; };
return proto; return proto;
@@ -153,4 +139,4 @@ public class CloudFileReference : ModelBase
ExpiredAt = ExpiredAt?.ToTimestamp() ExpiredAt = ExpiredAt?.ToTimestamp()
}; };
} }
} }

View File

@@ -7,19 +7,42 @@ namespace DysonNetwork.Drive.Storage;
public class CloudFileUnusedRecyclingJob( public class CloudFileUnusedRecyclingJob(
AppDatabase db, AppDatabase db,
FileReferenceService fileRefService, FileReferenceService fileRefService,
ILogger<CloudFileUnusedRecyclingJob> logger ILogger<CloudFileUnusedRecyclingJob> logger,
IConfiguration configuration
) )
: IJob : IJob
{ {
public async Task Execute(IJobExecutionContext context) 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..."); 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(); var now = SystemClock.Instance.GetCurrentInstant();
const int batchSize = 1000; // Process larger batches for efficiency const int batchSize = 1000; // Process larger batches for efficiency
var processedCount = 0; var processedCount = 0;
var markedCount = 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); 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 // Query for the next batch of files using keyset pagination
var filesQuery = db.Files var filesQuery = db.Files
.Where(f => f.PoolId.HasValue && recyclablePools.Contains(f.PoolId.Value))
.Where(f => !f.IsMarkedRecycle) .Where(f => !f.IsMarkedRecycle)
.Where(f => f.CreatedAt <= ageThreshold); // Only process older files first .Where(f => f.CreatedAt <= ageThreshold); // Only process older files first
if (lastProcessedId != null) if (lastProcessedId != null)
{
filesQuery = filesQuery.Where(f => string.Compare(f.Id, lastProcessedId) > 0); filesQuery = filesQuery.Where(f => string.Compare(f.Id, lastProcessedId) > 0);
}
var fileBatch = await filesQuery var fileBatch = await filesQuery
.OrderBy(f => f.Id) // Ensure consistent ordering for pagination .OrderBy(f => f.Id) // Ensure consistent ordering for pagination
@@ -84,9 +106,17 @@ public class CloudFileUnusedRecyclingJob(
{ {
logger.LogInformation( logger.LogInformation(
"Progress: processed {ProcessedCount}/{TotalFiles} files, marked {MarkedCount} for recycling", "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); logger.LogInformation("Completed marking {MarkedCount} files for recycling", markedCount);
} }

View 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);
}
}

View File

@@ -1,5 +1,7 @@
using DysonNetwork.Drive.Billing;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Grpc.Core;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -12,6 +14,7 @@ namespace DysonNetwork.Drive.Storage;
public class FileController( public class FileController(
AppDatabase db, AppDatabase db,
FileService fs, FileService fs,
QuotaService qs,
IConfiguration configuration, IConfiguration configuration,
IWebHostEnvironment env IWebHostEnvironment env
) : ControllerBase ) : ControllerBase
@@ -21,7 +24,9 @@ public class FileController(
string id, string id,
[FromQuery] bool download = false, [FromQuery] bool download = false,
[FromQuery] bool original = 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 // Support the file extension for client side data recognize
@@ -35,6 +40,10 @@ public class FileController(
var file = await fs.GetFileAsync(id); var file = await fs.GetFileAsync(id);
if (file is null) return NotFound(); 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); if (!string.IsNullOrWhiteSpace(file.StorageUrl)) return Redirect(file.StorageUrl);
@@ -47,15 +56,26 @@ public class FileController(
} }
var pool = await fs.GetPoolAsync(file.PoolId.Value); 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; var dest = pool.StorageConfig;
if (!pool.PolicyConfig.AllowAnonymous) if (!pool.PolicyConfig.AllowAnonymous)
if(HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser)
// TODO: Provide ability to add access log return Unauthorized();
// TODO: Provide ability to add access log
var fileName = string.IsNullOrWhiteSpace(file.StorageId) ? file.Id : file.StorageId; 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) if (!original && file.HasCompression)
fileName += ".compressed"; fileName += ".compressed";
@@ -80,7 +100,8 @@ public class FileController(
var client = fs.CreateMinioClient(dest); var client = fs.CreateMinioClient(dest);
if (client is null) if (client is null)
return BadRequest( 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>(); var headers = new Dictionary<string, string>();
if (fileExtension is not null) if (fileExtension is not null)
@@ -129,6 +150,85 @@ public class FileController(
return file; 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] [Authorize]
[HttpDelete("{id}")] [HttpDelete("{id}")]
public async Task<ActionResult> DeleteFile(string id) public async Task<ActionResult> DeleteFile(string id)
@@ -142,11 +242,135 @@ public class FileController(
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (file is null) return NotFound(); if (file is null) return NotFound();
await fs.DeleteFileDataAsync(file, force: true);
await fs.DeleteFileAsync(file); await fs.DeleteFileAsync(file);
db.Files.Remove(file);
await db.SaveChangesAsync();
return NoContent(); 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;
}
}
} }

View File

@@ -48,11 +48,9 @@ public class FileExpirationJob(AppDatabase db, FileService fileService, ILogger<
if (remainingReferences == 0) if (remainingReferences == 0)
{ {
var file = await db.Files.FirstOrDefaultAsync(f => f.Id == fileId); 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);
logger.LogInformation("Deleting file {fileId} as all references have expired", fileId); await fileService.DeleteFileAsync(file);
await fileService.DeleteFileAsync(file);
}
} }
else else
{ {

View File

@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
using NodaTime; using NodaTime;
@@ -26,6 +27,8 @@ public class BillingConfig
public class PolicyConfig public class PolicyConfig
{ {
public bool EnableFastUpload { get; set; } = false;
public bool EnableRecycle { get; set; } = false;
public bool PublicIndexable { get; set; } = false; public bool PublicIndexable { get; set; } = false;
public bool PublicUsable { get; set; } = false; public bool PublicUsable { get; set; } = false;
public bool NoOptimization { 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 RemoteStorageConfig StorageConfig { get; set; } = new();
[Column(TypeName = "jsonb")] public BillingConfig BillingConfig { get; set; } = new(); [Column(TypeName = "jsonb")] public BillingConfig BillingConfig { get; set; } = new();
[Column(TypeName = "jsonb")] public PolicyConfig PolicyConfig { get; set; } = new(); [Column(TypeName = "jsonb")] public PolicyConfig PolicyConfig { get; set; } = new();
public bool IsHidden { get; set; } = false;
public Guid? AccountId { get; set; } public Guid? AccountId { get; set; }

View File

@@ -7,7 +7,7 @@ namespace DysonNetwork.Drive.Storage;
[ApiController] [ApiController]
[Route("/api/pools")] [Route("/api/pools")]
public class FilePoolController(AppDatabase db) : ControllerBase public class FilePoolController(AppDatabase db, FileService fs) : ControllerBase
{ {
[HttpGet] [HttpGet]
[Authorize] [Authorize]
@@ -18,8 +18,30 @@ public class FilePoolController(AppDatabase db) : ControllerBase
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
var pools = await db.Pools var pools = await db.Pools
.Where(p => p.PolicyConfig.PublicUsable || p.AccountId == accountId) .Where(p => p.PolicyConfig.PublicUsable || p.AccountId == accountId)
.Where(p => !p.IsHidden || p.AccountId == accountId)
.ToListAsync(); .ToListAsync();
pools = pools.Select(p =>
{
p.StorageConfig.SecretId = string.Empty;
p.StorageConfig.SecretKey = string.Empty;
return p;
}).ToList();
return Ok(pools); 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 });
}
} }

View File

@@ -32,19 +32,6 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
if (duration.HasValue) if (duration.HasValue)
finalExpiration = SystemClock.Instance.GetCurrentInstant() + duration.Value; 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 var reference = new CloudFileReference
{ {
FileId = fileId, FileId = fileId,
@@ -203,10 +190,8 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
.Where(r => r.ResourceId == resourceId && r.Usage == usage) .Where(r => r.ResourceId == resourceId && r.Usage == usage)
.ToListAsync(); .ToListAsync();
if (!references.Any()) if (references.Count == 0)
{
return 0; return 0;
}
var fileIds = references.Select(r => r.FileId).Distinct().ToList(); var fileIds = references.Select(r => r.FileId).Distinct().ToList();
@@ -220,6 +205,28 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
return deletedCount; 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> /// <summary>
/// Deletes a specific file reference /// Deletes a specific file reference

View File

@@ -85,7 +85,7 @@ namespace DysonNetwork.Drive.Storage
public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferences( public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferences(
DeleteResourceReferencesRequest request, ServerCallContext context) DeleteResourceReferencesRequest request, ServerCallContext context)
{ {
var deletedCount = 0; int deletedCount;
if (request.Usage is null) if (request.Usage is null)
deletedCount = await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId); deletedCount = await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId);
else else
@@ -93,6 +93,18 @@ namespace DysonNetwork.Drive.Storage
await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId, request.Usage!); await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId, request.Usage!);
return new DeleteResourceReferencesResponse { DeletedCount = deletedCount }; 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, public override async Task<DeleteReferenceResponse> DeleteReference(DeleteReferenceRequest request,
ServerCallContext context) ServerCallContext context)

View File

@@ -1,3 +1,4 @@
using System.Drawing;
using System.Globalization; using System.Globalization;
using FFMpegCore; using FFMpegCore;
using System.Security.Cryptography; using System.Security.Cryptography;
@@ -11,6 +12,7 @@ using NetVips;
using NodaTime; using NodaTime;
using tusdotnet.Stores; using tusdotnet.Stores;
using System.Linq.Expressions; using System.Linq.Expressions;
using DysonNetwork.Shared.Data;
using Microsoft.EntityFrameworkCore.Query; using Microsoft.EntityFrameworkCore.Query;
namespace DysonNetwork.Drive.Storage; namespace DysonNetwork.Drive.Storage;
@@ -18,7 +20,6 @@ namespace DysonNetwork.Drive.Storage;
public class FileService( public class FileService(
AppDatabase db, AppDatabase db,
IConfiguration configuration, IConfiguration configuration,
TusDiskStore store,
ILogger<FileService> logger, ILogger<FileService> logger,
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
ICacheService cache ICacheService cache
@@ -46,6 +47,7 @@ public class FileService(
var file = await db.Files var file = await db.Files
.Where(f => f.Id == fileId) .Where(f => f.Id == fileId)
.Include(f => f.Pool) .Include(f => f.Pool)
.Include(f => f.Bundle)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (file != null) if (file != null)
@@ -99,28 +101,56 @@ public class FileService(
private const string TempFilePrefix = "dyn-cloudfile"; private const string TempFilePrefix = "dyn-cloudfile";
private static readonly string[] AnimatedImageTypes = 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( public async Task<CloudFile> ProcessNewFileAsync(
Account account, Account account,
string fileId, string fileId,
string filePool, string filePool,
string? fileBundleId,
Stream stream, Stream stream,
string fileName, string fileName,
string? contentType, string? contentType,
string? encryptPassword string? encryptPassword,
Instant? expiredAt
) )
{ {
var accountId = Guid.Parse(account.Id);
var pool = await GetPoolAsync(Guid.Parse(filePool)); var pool = await GetPoolAsync(Guid.Parse(filePool));
if (pool is null) throw new InvalidOperationException("Pool not found"); 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 ogFilePath = Path.GetFullPath(Path.Join(configuration.GetValue<string>("Tus:StorePath"), fileId));
var fileSize = stream.Length; var fileSize = stream.Length;
contentType ??= !fileName.Contains('.') ? "application/octet-stream" : MimeTypes.GetMimeType(fileName); contentType ??= !fileName.Contains('.') ? "application/octet-stream" : MimeTypes.GetMimeType(fileName);
if (!string.IsNullOrWhiteSpace(encryptPassword)) 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"); var encryptedPath = Path.Combine(Path.GetTempPath(), $"{fileId}.encrypted");
FileEncryptor.EncryptFile(ogFilePath, encryptedPath, encryptPassword); FileEncryptor.EncryptFile(ogFilePath, encryptedPath, encryptPassword);
File.Delete(ogFilePath); // Delete original unencrypted File.Delete(ogFilePath); // Delete original unencrypted
@@ -137,30 +167,12 @@ public class FileService(
MimeType = contentType, MimeType = contentType,
Size = fileSize, Size = fileSize,
Hash = hash, Hash = hash,
ExpiredAt = expiredAt,
BundleId = bundle?.Id,
AccountId = Guid.Parse(account.Id), AccountId = Guid.Parse(account.Id),
IsEncrypted = !string.IsNullOrWhiteSpace(encryptPassword) && pool.PolicyConfig.AllowEncryption 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 // Extract metadata on the current thread for a faster initial response
if (!pool.PolicyConfig.NoMetadata) if (!pool.PolicyConfig.NoMetadata)
await ExtractMetadataAsync(file, ogFilePath, stream); await ExtractMetadataAsync(file, ogFilePath, stream);
@@ -168,6 +180,8 @@ public class FileService(
db.Files.Add(file); db.Files.Add(file);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
file.StorageId ??= file.Id;
// Offload optimization (image conversion, thumbnailing) and uploading to a background task // Offload optimization (image conversion, thumbnailing) and uploading to a background task
_ = Task.Run(() => _ = Task.Run(() =>
ProcessAndUploadInBackgroundAsync(file.Id, filePool, file.StorageId, contentType, ogFilePath, stream)); ProcessAndUploadInBackgroundAsync(file.Id, filePool, file.StorageId, contentType, ogFilePath, stream));
@@ -242,6 +256,8 @@ public class FileService(
var mediaInfo = await FFProbe.AnalyseAsync(filePath); var mediaInfo = await FFProbe.AnalyseAsync(filePath);
file.FileMeta = new Dictionary<string, object?> file.FileMeta = new Dictionary<string, object?>
{ {
["width"] = mediaInfo.PrimaryVideoStream?.Width,
["height"] = mediaInfo.PrimaryVideoStream?.Height,
["duration"] = mediaInfo.Duration.TotalSeconds, ["duration"] = mediaInfo.Duration.TotalSeconds,
["format_name"] = mediaInfo.Format.FormatName, ["format_name"] = mediaInfo.Format.FormatName,
["format_long_name"] = mediaInfo.Format.FormatLongName, ["format_long_name"] = mediaInfo.Format.FormatLongName,
@@ -252,12 +268,24 @@ public class FileService(
// Add detailed stream information // Add detailed stream information
["video_streams"] = mediaInfo.VideoStreams.Select(s => new ["video_streams"] = mediaInfo.VideoStreams.Select(s => new
{ {
s.AvgFrameRate, s.BitRate, s.CodecName, s.Duration, s.Height, s.Width, s.Language, s.AvgFrameRate,
s.PixelFormat, s.Rotation s.BitRate,
}).ToList(), 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 ["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 s.SampleRateHz
}) })
.ToList(), .ToList(),
@@ -304,10 +332,19 @@ public class FileService(
{ {
logger.LogInformation("Processing file {FileId} in background...", fileId); logger.LogInformation("Processing file {FileId} in background...", fileId);
var fileExtension = Path.GetExtension(originalFilePath);
if (!pool.PolicyConfig.NoOptimization) if (!pool.PolicyConfig.NoOptimization)
switch (contentType.Split('/')[0]) 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"; newMimeType = "image/webp";
using (var vipsImage = Image.NewFromFile(originalFilePath)) using (var vipsImage = Image.NewFromFile(originalFilePath))
{ {
@@ -345,19 +382,24 @@ public class FileService(
case "video": case "video":
uploads.Add((originalFilePath, string.Empty, contentType, false)); 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 try
{ {
var mediaInfo = await FFProbe.AnalyseAsync(originalFilePath); await FFMpegArguments
var snapshotTime = mediaInfo.Duration > TimeSpan.FromSeconds(5) .FromFileInput(originalFilePath, verifyExists: true)
? TimeSpan.FromSeconds(5) .OutputToFile(thumbnailPath, overwrite: true, options => options
: TimeSpan.FromSeconds(1); .Seek(TimeSpan.FromSeconds(0))
.WithFrameOutputCount(1)
await FFMpeg.SnapshotAsync(originalFilePath, thumbnailPath, captureTime: snapshotTime); .WithCustomArgument("-q:v 2")
)
.NotifyOnOutput(line => logger.LogInformation("[FFmpeg] {Line}", line))
.NotifyOnError(line => logger.LogWarning("[FFmpeg] {Line}", line))
.ProcessAsynchronously();
if (File.Exists(thumbnailPath)) if (File.Exists(thumbnailPath))
{ {
uploads.Add((thumbnailPath, ".thumbnail.webp", "image/webp", true)); uploads.Add((thumbnailPath, ".thumbnail", "image/jpeg", true));
hasThumbnail = true; hasThumbnail = true;
} }
else else
@@ -369,6 +411,7 @@ public class FileService(
{ {
logger.LogError(ex, "Failed to generate thumbnail for video {FileId}", fileId); logger.LogError(ex, "Failed to generate thumbnail for video {FileId}", fileId);
} }
break; break;
default: default:
@@ -383,8 +426,14 @@ public class FileService(
{ {
var destPool = Guid.Parse(remoteId!); var destPool = Guid.Parse(remoteId!);
var uploadTasks = uploads.Select(item => var uploadTasks = uploads.Select(item =>
nfs.UploadFileToRemoteAsync(storageId, destPool, item.FilePath, item.Suffix, item.ContentType, nfs.UploadFileToRemoteAsync(
item.SelfDestruct) storageId,
destPool,
item.FilePath,
item.Suffix,
item.ContentType,
item.SelfDestruct
)
).ToList(); ).ToList();
await Task.WhenAll(uploadTasks); await Task.WhenAll(uploadTasks);
@@ -410,32 +459,25 @@ public class FileService(
} }
finally finally
{ {
await store.DeleteFileAsync(fileId, CancellationToken.None);
await nfs._PurgeCacheAsync(fileId); await nfs._PurgeCacheAsync(fileId);
} }
} }
private static async Task<string> HashFileAsync(string filePath, int chunkSize = 1024 * 1024) private static async Task<string> HashFileAsync(string filePath, int chunkSize = 1024 * 1024)
{ {
using var stream = File.OpenRead(filePath); var fileInfo = new FileInfo(filePath);
var fileSize = stream.Length; if (fileInfo.Length > chunkSize * 1024 * 5)
if (fileSize > chunkSize * 1024 * 5)
return await HashFastApproximateAsync(filePath, chunkSize); return await HashFastApproximateAsync(filePath, chunkSize);
await using var stream = File.OpenRead(filePath);
using var md5 = MD5.Create(); using var md5 = MD5.Create();
var hashBytes = await md5.ComputeHashAsync(stream); var hashBytes = await md5.ComputeHashAsync(stream);
stream.Position = 0; // Reset stream position after reading
return Convert.ToHexString(hashBytes).ToLowerInvariant(); return Convert.ToHexString(hashBytes).ToLowerInvariant();
} }
private static async Task<string> HashFastApproximateAsync(string filePath, int chunkSize = 1024 * 1024) private static async Task<string> HashFastApproximateAsync(string filePath, int chunkSize = 1024 * 1024)
{ {
await using var stream = File.OpenRead(filePath); 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 buffer = new byte[chunkSize * 2];
var fileLength = stream.Length; var fileLength = stream.Length;
@@ -448,21 +490,32 @@ public class FileService(
bytesRead += await stream.ReadAsync(buffer.AsMemory(chunkSize, chunkSize)); 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 stream.Position = 0; // Reset stream position
return Convert.ToHexString(hash).ToLowerInvariant(); return Convert.ToHexString(hash).ToLowerInvariant();
} }
public async Task UploadFileToRemoteAsync(string storageId, Guid targetRemote, string filePath, private async Task UploadFileToRemoteAsync(
string? suffix = null, string? contentType = null, bool selfDestruct = false) string storageId,
Guid targetRemote,
string filePath,
string? suffix = null,
string? contentType = null,
bool selfDestruct = false
)
{ {
await using var fileStream = File.OpenRead(filePath); await using var fileStream = File.OpenRead(filePath);
await UploadFileToRemoteAsync(storageId, targetRemote, fileStream, suffix, contentType); await UploadFileToRemoteAsync(storageId, targetRemote, fileStream, suffix, contentType);
if (selfDestruct) File.Delete(filePath); if (selfDestruct) File.Delete(filePath);
} }
public async Task UploadFileToRemoteAsync(string storageId, Guid targetRemote, Stream stream, private async Task UploadFileToRemoteAsync(
string? suffix = null, string? contentType = null) string storageId,
Guid targetRemote,
Stream stream,
string? suffix = null,
string? contentType = null
)
{ {
var dest = await GetRemoteStorageConfig(targetRemote); var dest = await GetRemoteStorageConfig(targetRemote);
if (dest is null) if (dest is null)
@@ -474,7 +527,7 @@ public class FileService(
var bucket = dest.Bucket; var bucket = dest.Bucket;
contentType ??= "application/octet-stream"; contentType ??= "application/octet-stream";
await client.PutObjectAsync(new PutObjectArgs() await client!.PutObjectAsync(new PutObjectArgs()
.WithBucket(bucket) .WithBucket(bucket)
.WithObject(string.IsNullOrWhiteSpace(suffix) ? storageId : storageId + suffix) .WithObject(string.IsNullOrWhiteSpace(suffix) ? storageId : storageId + suffix)
.WithStreamData(stream) .WithStreamData(stream)
@@ -527,36 +580,31 @@ public class FileService(
public async Task DeleteFileAsync(CloudFile file) public async Task DeleteFileAsync(CloudFile file)
{ {
await DeleteFileDataAsync(file);
db.Remove(file); db.Remove(file);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await _PurgeCacheAsync(file.Id); 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 (!file.PoolId.HasValue) return;
// Check if any other file with the same storage ID is referenced if (!force)
var sameOriginFiles = await db.Files
.Where(f => f.StorageId == file.StorageId && f.Id != file.Id)
.Select(f => f.Id)
.ToListAsync();
// Check if any of these files are referenced
var anyReferenced = false;
if (sameOriginFiles.Count != 0)
{ {
anyReferenced = await db.FileReferences // Check if any other file with the same storage ID is referenced
.Where(r => sameOriginFiles.Contains(r.FileId)) var sameOriginFiles = await db.Files
.AnyAsync(); .Where(f => f.StorageId == file.StorageId && f.Id != file.Id)
.Select(f => f.Id)
.ToListAsync();
// Check if any of these files are referenced
if (sameOriginFiles.Count != 0)
return;
} }
// If any other file with the same storage ID is referenced, don't delete the actual file data // 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); var dest = await GetRemoteStorageConfig(file.PoolId.Value);
if (dest is null) throw new InvalidOperationException($"No remote storage configured for pool {file.PoolId}"); if (dest is null) throw new InvalidOperationException($"No remote storage configured for pool {file.PoolId}");
var client = CreateMinioClient(dest); var client = CreateMinioClient(dest);
@@ -574,7 +622,6 @@ public class FileService(
if (file.HasCompression) if (file.HasCompression)
{ {
// Also remove the compressed version if it exists
try try
{ {
await client.RemoveObjectAsync( await client.RemoveObjectAsync(
@@ -587,6 +634,67 @@ public class FileService(
logger.LogWarning("Failed to delete compressed version of file {fileId}", file.Id); 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) public async Task<FilePool?> GetPoolAsync(Guid destination)
@@ -728,6 +836,72 @@ public class FileService(
if (fieldName.EndsWith("-data")) return true; if (fieldName.EndsWith("-data")) return true;
return gpsFields.Any(gpsField => fieldName.StartsWith(gpsField, StringComparison.OrdinalIgnoreCase)); 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> /// <summary>

View File

@@ -1,3 +1,4 @@
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Google.Protobuf.WellKnownTypes; using Google.Protobuf.WellKnownTypes;
using Grpc.Core; using Grpc.Core;

View File

@@ -1,20 +1,24 @@
using System.Net; using System.Net;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Drive.Billing;
using DysonNetwork.Shared.Auth; using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using NodaTime;
using tusdotnet.Interfaces; using tusdotnet.Interfaces;
using tusdotnet.Models; using tusdotnet.Models;
using tusdotnet.Models.Configuration; using tusdotnet.Models.Configuration;
using tusdotnet.Stores;
namespace DysonNetwork.Drive.Storage; namespace DysonNetwork.Drive.Storage;
public abstract class TusService public abstract class TusService
{ {
public static DefaultTusConfiguration BuildConfiguration(ITusStore store, IConfiguration configuration) => new() public static DefaultTusConfiguration BuildConfiguration(
ITusStore store,
IConfiguration configuration
) => new()
{ {
Store = store, Store = store,
Events = new Events Events = new Events
@@ -72,7 +76,7 @@ public abstract class TusService
{ {
eventContext.FailRequest( eventContext.FailRequest(
HttpStatusCode.Forbidden, 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; return;
} }
@@ -83,11 +87,16 @@ public abstract class TusService
{ {
eventContext.FailRequest( eventContext.FailRequest(
HttpStatusCode.Forbidden, 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 => OnFileCompleteAsync = async eventContext =>
{ {
@@ -107,11 +116,17 @@ public abstract class TusService
var fileStream = await file.GetContentAsync(eventContext.CancellationToken); var fileStream = await file.GetContentAsync(eventContext.CancellationToken);
var filePool = httpContext.Request.Headers["X-FilePool"].FirstOrDefault(); 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(); var encryptPassword = httpContext.Request.Headers["X-FilePass"].FirstOrDefault();
if (string.IsNullOrEmpty(filePool)) if (string.IsNullOrEmpty(filePool))
filePool = configuration["Storage:PreferredRemote"]; 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 try
{ {
var fileService = services.GetRequiredService<FileService>(); var fileService = services.GetRequiredService<FileService>();
@@ -119,10 +134,12 @@ public abstract class TusService
user, user,
file.Id, file.Id,
filePool!, filePool!,
bundleId,
fileStream, fileStream,
fileName, fileName,
contentType, contentType,
encryptPassword encryptPassword,
expiredAt
); );
using var finalScope = eventContext.HttpContext.RequestServices.CreateScope(); using var finalScope = eventContext.HttpContext.RequestServices.CreateScope();
@@ -133,10 +150,10 @@ public abstract class TusService
} }
catch (Exception ex) catch (Exception ex)
{ {
var logger = services.GetRequiredService<ILogger<TusService>>();
eventContext.HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest; eventContext.HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await eventContext.HttpContext.Response.WriteAsync(ex.Message); await eventContext.HttpContext.Response.WriteAsync(ex.Message);
if (eventContext.Store is TusDiskStore disk) logger.LogError(ex, "Error handling file upload...");
await disk.DeleteFileAsync(file.Id, eventContext.CancellationToken);
} }
finally finally
{ {
@@ -146,14 +163,29 @@ public abstract class TusService
}, },
OnBeforeCreateAsync = async eventContext => OnBeforeCreateAsync = async eventContext =>
{ {
var filePool = eventContext.HttpContext.Request.Headers["X-FilePool"].FirstOrDefault(); var httpContext = eventContext.HttpContext;
if (string.IsNullOrEmpty(filePool)) filePool = configuration["Storage:PreferredRemote"]; if (httpContext.Items["CurrentUser"] is not Account currentUser)
if (!Guid.TryParse(filePool, out _)) {
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"); eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file pool id");
return; 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 metadata = eventContext.Metadata;
var contentType = metadata.TryGetValue("content-type", out var ct) ? ct.GetString(Encoding.UTF8) : null; 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 rejected = false;
var fs = scope.ServiceProvider.GetRequiredService<FileService>(); 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) if (pool is null)
{ {
eventContext.FailRequest(HttpStatusCode.BadRequest, "Pool not found"); eventContext.FailRequest(HttpStatusCode.BadRequest, "Pool not found");
@@ -196,13 +228,33 @@ public abstract class TusService
); );
rejected = true; rejected = true;
} }
else if (!policy.AcceptTypes.Contains(contentType)) else
{ {
eventContext.FailRequest( var foundMatch = false;
HttpStatusCode.Forbidden, foreach (var acceptType in policy.AcceptTypes)
$"Content type {contentType} is not allowed by the pool's policy" {
); if (acceptType.EndsWith("/*", StringComparison.OrdinalIgnoreCase))
rejected = true; {
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,
$"Content type {contentType} is not allowed by the pool's policy"
);
rejected = true;
}
} }
} }
@@ -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) if (rejected)
logger.LogInformation("File rejected #{FileId}", eventContext.FileId); logger.LogInformation("File rejected #{FileId}", eventContext.FileId);
}, },
OnCreateCompleteAsync = eventContext => OnCreateCompleteAsync = eventContext =>
{ {
var directUpload = eventContext.HttpContext.Request.Headers["X-DirectUpload"].FirstOrDefault();
if (!string.IsNullOrEmpty(directUpload)) return Task.CompletedTask;
var gatewayUrl = configuration["GatewayUrl"]; var gatewayUrl = configuration["GatewayUrl"];
if (gatewayUrl is not null) if (gatewayUrl is not null)
eventContext.SetUploadUrl(new Uri(gatewayUrl + "/drive/tus/" + eventContext.FileId)); eventContext.SetUploadUrl(new Uri(gatewayUrl + "/drive/tus/" + eventContext.FileId));

View File

@@ -1,6 +1,7 @@
{ {
"Debug": true, "Debug": true,
"BaseUrl": "http://localhost:5071", "BaseUrl": "http://localhost:5090",
"GatewayUrl": "http://localhost:5094",
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
@@ -9,9 +10,7 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": { "ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60", "App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
"FastRetrieve": "localhost:6379",
"Etcd": "etcd.orb.local:2379"
}, },
"Authentication": { "Authentication": {
"Schemes": { "Schemes": {
@@ -129,8 +128,6 @@
], ],
"Service": { "Service": {
"Name": "DysonNetwork.Drive", "Name": "DysonNetwork.Drive",
"Url": "https://localhost:7092", "Url": "https://localhost:7092"
"ClientCert": "../Certificates/client.crt",
"ClientKey": "../Certificates/client.key"
} }
} }

View File

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

View File

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

View File

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

View File

@@ -1,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();

View File

@@ -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();
}
}

View File

@@ -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