420 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
02af78ca99 Usage drive 2025-07-26 23:21:57 +08:00
f40d1dc1b2 Setup drive dashboard 2025-07-26 22:01:17 +08:00
b0683576b9 File pool policy check 2025-07-26 19:46:38 +08:00
eaf0b366d3 🐛 Trying to fix the message sender sometimes missing data 2025-07-26 16:26:07 +08:00
cf9903e500 Sharable file 2025-07-26 15:17:01 +08:00
186e9c00aa FIle detail page 2025-07-26 14:32:37 +08:00
f1867e7916 File upload frontpage and download decryption 2025-07-26 03:11:42 +08:00
0486c0d0e5 File encryption
 Shared login status across sites
2025-07-26 01:37:23 +08:00
466 changed files with 90907 additions and 4714 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:
push:
@@ -7,132 +7,54 @@ on:
workflow_dispatch:
jobs:
build-sphere:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup NBGV
uses: dotnet/nbgv@master
id: nbgv
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to DockerHub
uses: docker/login-action@v3
with:
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
- name: Build and push DysonNetwork.Sphere Docker image
uses: docker/build-push-action@v6
with:
file: DysonNetwork.Sphere/Dockerfile
context: .
push: true
tags: xsheep2010/dyson-sphere:latest
platforms: linux/amd64
build-pass:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
fetch-depth: 0
- name: Setup NBGV
uses: dotnet/nbgv@master
id: nbgv
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to DockerHub
uses: docker/login-action@v3
with:
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
- name: Build and push DysonNetwork.Pass Docker image
uses: docker/build-push-action@v6
with:
file: DysonNetwork.Pass/Dockerfile
context: .
push: true
tags: xsheep2010/dyson-pass:latest
platforms: linux/amd64
dotnet-version: "9.0.x"
build-pusher:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup NBGV
uses: dotnet/nbgv@master
id: nbgv
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to DockerHub
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
- name: Build and push DysonNetwork.Pusher Docker image
uses: docker/build-push-action@v6
with:
file: DysonNetwork.Pusher/Dockerfile
context: .
push: true
tags: xsheep2010/dyson-pusher:latest
platforms: linux/amd64
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
build-drive:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup NBGV
uses: dotnet/nbgv@master
id: nbgv
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to DockerHub
uses: docker/login-action@v3
with:
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
- name: Build and push DysonNetwork.Drive Docker image
uses: docker/build-push-action@v6
with:
file: DysonNetwork.Drive/Dockerfile
context: .
push: true
tags: xsheep2010/dyson-drive:latest
platforms: linux/amd64
- name: Install Aspire CLI
run: dotnet tool install -g Aspire.Cli --prerelease
build-gateway:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Build and Publish Aspire Application
run: aspire publish --project ./DysonNetwork.Control/DysonNetwork.Control.csproj --output publish
- name: Tag and Push Images
run: |
IMAGES=( "sphere" "pass" "ring" "drive" "develop" )
for image in "${IMAGES[@]}"; do
IMAGE_NAME="ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-$image:alpha"
SOURCE_IMAGE_NAME="$image:latest" # Aspire's default local image name
echo "Tagging and pushing $SOURCE_IMAGE_NAME to $IMAGE_NAME..."
docker tag $SOURCE_IMAGE_NAME $IMAGE_NAME
docker push $IMAGE_NAME
done
- name: Upload Aspire Publish Directory
uses: actions/upload-artifact@v3
with:
fetch-depth: 0
- name: Setup NBGV
uses: dotnet/nbgv@master
id: nbgv
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to DockerHub
uses: docker/login-action@v3
name: aspire-publish-output
path: ./publish/
- name: Upload Docker Compose file
uses: actions/upload-artifact@v3
with:
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
- name: Build and push DysonNetwork.Gateway Docker image
uses: docker/build-push-action@v6
with:
file: DysonNetwork.Gateway/Dockerfile
context: .
push: true
tags: xsheep2010/dyson-gateway:latest
platforms: linux/amd64
name: docker-compose-output
path: ./publish/docker-compose.yml

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

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

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,2 +1,3 @@
/Uploads/
/wwwroot/dist
/Client/node_modules/
/wwwroot/dist

View File

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

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

@@ -0,0 +1,49 @@
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Drive.Billing;
[ApiController]
[Route("api/billing/usage")]
public class UsageController(UsageService usage, QuotaService quota, ICacheService cache) : ControllerBase
{
[HttpGet]
[Authorize]
public async Task<ActionResult<TotalUsageDetails>> GetTotalUsage()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var cacheKey = $"file:usage:{accountId}";
// Try to get from cache first
var (found, cachedResult) = await cache.GetAsyncWithStatus<TotalUsageDetails>(cacheKey);
if (found && cachedResult != null)
return Ok(cachedResult);
// If not in cache, get from services
var result = await usage.GetTotalUsage(accountId);
var totalQuota = await quota.GetQuota(accountId);
result.TotalQuota = totalQuota;
// Cache the result for 5 minutes
await cache.SetAsync(cacheKey, result, TimeSpan.FromMinutes(5));
return Ok(result);
}
[Authorize]
[HttpGet("{poolId:guid}")]
public async Task<ActionResult<UsageDetails>> GetPoolUsage(Guid poolId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var usageDetails = await usage.GetPoolUsage(poolId, accountId);
if (usageDetails == null)
return NotFound();
return usageDetails;
}
}

View File

@@ -0,0 +1,121 @@
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Drive.Billing;
public class UsageDetails
{
public required Guid PoolId { get; set; }
public required string PoolName { get; set; }
public required long UsageBytes { get; set; }
public required double Cost { get; set; }
public required long FileCount { get; set; }
}
public class TotalUsageDetails
{
public required List<UsageDetails> PoolUsages { get; set; }
public required long TotalUsageBytes { get; set; }
public required long TotalFileCount { get; set; }
// Quota, cannot be loaded in the service, cause circular dependency
// Let the controller do the calculation
public long? TotalQuota { get; set; }
public long? UsedQuota { get; set; }
}
public class UsageService(AppDatabase db)
{
public async Task<TotalUsageDetails> GetTotalUsage(Guid accountId)
{
var now = SystemClock.Instance.GetCurrentInstant();
var fileQuery = db.Files
.Where(f => !f.IsMarkedRecycle)
.Where(f => !f.ExpiredAt.HasValue || f.ExpiredAt > now)
.Where(f => f.AccountId == accountId)
.AsQueryable();
var poolUsages = await db.Pools
.Select(p => new UsageDetails
{
PoolId = p.Id,
PoolName = p.Name,
UsageBytes = fileQuery
.Where(f => f.PoolId == p.Id)
.Sum(f => f.Size),
Cost = fileQuery
.Where(f => f.PoolId == p.Id)
.Sum(f => f.Size) / 1024.0 / 1024.0 *
(p.BillingConfig.CostMultiplier ?? 1.0),
FileCount = fileQuery
.Count(f => f.PoolId == p.Id)
})
.ToListAsync();
var totalUsage = poolUsages.Sum(p => p.UsageBytes);
var totalFileCount = poolUsages.Sum(p => p.FileCount);
return new TotalUsageDetails
{
PoolUsages = poolUsages,
TotalUsageBytes = totalUsage,
TotalFileCount = totalFileCount,
UsedQuota = await GetTotalBillableUsage(accountId)
};
}
public async Task<UsageDetails?> GetPoolUsage(Guid poolId, Guid accountId)
{
var pool = await db.Pools.FindAsync(poolId);
if (pool == 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 fileQuery
.SumAsync(f => f.Size);
var fileCount = await fileQuery
.CountAsync();
var cost = usageBytes / 1024.0 / 1024.0 *
(pool.BillingConfig.CostMultiplier ?? 1.0);
return new UsageDetails
{
PoolId = pool.Id,
PoolName = pool.Name,
UsageBytes = usageBytes,
Cost = cost,
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

@@ -8,6 +8,7 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
**/node_modules/highlight.js/
.DS_Store
dist
dist-ssr

View File

@@ -11,9 +11,12 @@
"@vueuse/core": "^13.5.0",
"aspnet-prerendering": "^3.0.1",
"cfturnstile-vue3": "^2.0.0",
"chart.js": "^4.5.0",
"pinia": "^3.0.3",
"tailwindcss": "^4.1.11",
"tus-js-client": "^4.3.1",
"vue": "^3.5.17",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.5.1",
},
"devDependencies": {
@@ -161,6 +164,8 @@
"@juggle/resize-observer": ["@juggle/resize-observer@3.4.0", "", {}, "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="],
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
@@ -387,6 +392,8 @@
"browserslist": ["browserslist@4.25.1", "", { "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
@@ -397,12 +404,16 @@
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chart.js": ["chart.js@4.5.0", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ=="],
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"combine-errors": ["combine-errors@3.0.3", "", { "dependencies": { "custom-error-instance": "2.1.1", "lodash.uniqby": "4.5.0" } }, "sha512-C8ikRNRMygCwaTx+Ek3Yr+OuZzgZjduCOfSQBjbM8V3MfgcjSTeto/GXP6PAwKvJz/v15b7GHZvx5rOlczFw/Q=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
@@ -417,6 +428,8 @@
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"custom-error-instance": ["custom-error-instance@2.1.1", "", {}, "sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg=="],
"date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="],
"date-fns-tz": ["date-fns-tz@3.2.0", "", { "peerDependencies": { "date-fns": "^3.0.0 || ^4.0.0" } }, "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ=="],
@@ -557,7 +570,7 @@
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
@@ -571,6 +584,8 @@
"jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="],
"js-base64": ["js-base64@3.7.7", "", {}, "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
@@ -625,8 +640,24 @@
"lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="],
"lodash._baseiteratee": ["lodash._baseiteratee@4.7.0", "", { "dependencies": { "lodash._stringtopath": "~4.8.0" } }, "sha512-nqB9M+wITz0BX/Q2xg6fQ8mLkyfF7MU7eE+MNBNjTHFKeKaZAPEzEg+E8LWxKWf1DQVflNEn9N49yAuqKh2mWQ=="],
"lodash._basetostring": ["lodash._basetostring@4.12.0", "", {}, "sha512-SwcRIbyxnN6CFEEK4K1y+zuApvWdpQdBHM/swxP962s8HIxPO3alBH5t3m/dl+f4CMUug6sJb7Pww8d13/9WSw=="],
"lodash._baseuniq": ["lodash._baseuniq@4.6.0", "", { "dependencies": { "lodash._createset": "~4.0.0", "lodash._root": "~3.0.0" } }, "sha512-Ja1YevpHZctlI5beLA7oc5KNDhGcPixFhcqSiORHNsp/1QTv7amAXzw+gu4YOvErqVlMVyIJGgtzeepCnnur0A=="],
"lodash._createset": ["lodash._createset@4.0.3", "", {}, "sha512-GTkC6YMprrJZCYU3zcqZj+jkXkrXzq3IPBcF/fIPpNEAB4hZEtXU8zp/RwKOvZl43NUmwDbyRk3+ZTbeRdEBXA=="],
"lodash._root": ["lodash._root@3.0.1", "", {}, "sha512-O0pWuFSK6x4EXhM1dhZ8gchNtG7JMqBtrHdoUFUWXD7dJnNSUze1GuyQr5sOs0aCvgGeI3o/OJW8f4ca7FDxmQ=="],
"lodash._stringtopath": ["lodash._stringtopath@4.8.0", "", { "dependencies": { "lodash._basetostring": "~4.12.0" } }, "sha512-SXL66C731p0xPDC5LZg4wI5H+dJo/EO4KTqOMwLYCH3+FmmfAKJEZCm6ohGpI+T1xwsDsJCfL4OnhorllvlTPQ=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"lodash.throttle": ["lodash.throttle@4.1.1", "", {}, "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="],
"lodash.uniqby": ["lodash.uniqby@4.5.0", "", { "dependencies": { "lodash._baseiteratee": "~4.7.0", "lodash._baseuniq": "~4.6.0" } }, "sha512-IRt7cfTtHy6f1aRVA5n7kT8rgN3N1nH6MOWLcHfpWG2SH19E3JksLK38MktLxZDhlAjCP9jpIXkOnRXlu6oByQ=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
@@ -715,14 +746,22 @@
"pretty-ms": ["pretty-ms@9.2.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg=="],
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"querystringify": ["querystringify@2.2.0", "", {}, "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"read-package-json-fast": ["read-package-json-fast@4.0.0", "", { "dependencies": { "json-parse-even-better-errors": "^4.0.0", "npm-normalize-package-bin": "^4.0.0" } }, "sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg=="],
"requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
@@ -781,6 +820,8 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tus-js-client": ["tus-js-client@4.3.1", "", { "dependencies": { "buffer-from": "^1.1.2", "combine-errors": "^3.0.3", "is-stream": "^2.0.0", "js-base64": "^3.7.2", "lodash.throttle": "^4.1.1", "proper-lockfile": "^4.1.2", "url-parse": "^1.5.7" } }, "sha512-ZLeYmjrkaU1fUsKbIi8JML52uAocjEZtBx4DKjRrqzrZa0O4MYwT6db+oqePlspV+FxXJAyFBc/L5gwUi2OFsg=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
@@ -797,6 +838,8 @@
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"url-parse": ["url-parse@1.5.10", "", { "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" } }, "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vdirs": ["vdirs@0.1.8", "", { "dependencies": { "evtd": "^0.2.2" }, "peerDependencies": { "vue": "^3.0.11" } }, "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw=="],
@@ -817,6 +860,8 @@
"vue": ["vue@3.5.17", "", { "dependencies": { "@vue/compiler-dom": "3.5.17", "@vue/compiler-sfc": "3.5.17", "@vue/runtime-dom": "3.5.17", "@vue/server-renderer": "3.5.17", "@vue/shared": "3.5.17" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-LbHV3xPN9BeljML+Xctq4lbz2lVHCR6DtbpTf5XIO6gugpXUN49j2QQPcMj086r9+AkJ0FfUT8xjulKKBkkr9g=="],
"vue-chartjs": ["vue-chartjs@5.3.2", "", { "peerDependencies": { "chart.js": "^4.1.1", "vue": "^3.0.0-0 || ^2.7.0" } }, "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw=="],
"vue-eslint-parser": ["vue-eslint-parser@10.2.0", "", { "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "esquery": "^1.6.0", "semver": "^7.6.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0" } }, "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw=="],
"vue-router": ["vue-router@4.5.1", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.2.0" } }, "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw=="],
@@ -879,8 +924,12 @@
"css-render/csstype": ["csstype@3.0.11", "", {}, "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw=="],
"execa/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"get-stream/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
@@ -889,6 +938,8 @@
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"vue-router/@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],

View File

@@ -2,9 +2,9 @@
<html lang="">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<link rel="icon" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Solarpass</title>
<title>Solar Network Drive</title>
<app-data />
</head>
<body>

View File

@@ -22,9 +22,12 @@
"@vueuse/core": "^13.5.0",
"aspnet-prerendering": "^3.0.1",
"cfturnstile-vue3": "^2.0.0",
"chart.js": "^4.5.0",
"pinia": "^3.0.3",
"tailwindcss": "^4.1.11",
"tus-js-client": "^4.3.1",
"vue": "^3.5.17",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.5.1"
},
"devDependencies": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

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

@@ -0,0 +1,62 @@
<template>
<n-layout has-sider class="h-full">
<n-layout-sider bordered collapse-mode="width" :collapsed-width="64" :width="240" show-trigger>
<n-menu
:collapsed-width="64"
:collapsed-icon-size="22"
:options="menuOptions"
:value="route.name as string"
@update:value="updateMenuSelect"
/>
</n-layout-sider>
<n-layout>
<router-view />
</n-layout>
</n-layout>
</template>
<script setup lang="ts">
import {
DataUsageRound,
AllInboxFilled,
PermDataSettingRound,
ShoppingBagRound,
} from '@vicons/material'
import { NIcon, NLayout, NLayoutSider, NMenu, type MenuOption } from 'naive-ui'
import { h, type Component } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
function renderIcon(icon: Component) {
return () => h(NIcon, null, { default: () => h(icon) })
}
const menuOptions: MenuOption[] = [
{
label: 'Usage',
key: 'dashboardUsage',
icon: renderIcon(DataUsageRound),
},
{
label: 'Files',
key: 'dashboardFiles',
icon: renderIcon(AllInboxFilled),
},
{
label: 'Bundles',
key: 'dashboardBundles',
icon: renderIcon(ShoppingBagRound),
},
{
label: 'Quota',
key: 'dashboardQuota',
icon: renderIcon(PermDataSettingRound),
},
]
function updateMenuSelect(key: string) {
router.push({ name: key })
}
</script>

View File

@@ -15,7 +15,7 @@
</n-dropdown>
</div>
</n-layout-header>
<n-layout-content embedded content-style="padding: 24px;">
<n-layout-content embedded>
<router-view />
</n-layout-content>
</n-layout>
@@ -27,18 +27,17 @@ import { NLayout, NLayoutHeader, NLayoutContent, NButton, NDropdown, NIcon } fro
import {
LogInOutlined,
PersonAddAlt1Outlined,
LogOutOutlined,
PersonOutlineRound,
DataUsageRound,
} from '@vicons/material'
import { useUserStore } from '@/stores/user'
import { useRoute, useRouter } from 'vue-router'
import { useServicesStore } from '@/stores/services'
const userStore = useUserStore()
const route = useRoute()
const router = useRouter()
// Initialize user state on component mount
userStore.initialize()
const router = useRouter()
const route = useRoute()
const hideUserMenu = computed(() => {
return ['captcha', 'spells', 'login', 'create-account'].includes(route.name as string)
@@ -64,6 +63,14 @@ const guestOptions = [
]
const userOptions = computed(() => [
{
label: 'Dashboard',
key: 'dashboardUsage',
icon: () =>
h(NIcon, null, {
default: () => h(DataUsageRound),
}),
},
{
label: 'Profile',
key: 'profile',
@@ -72,30 +79,23 @@ const userOptions = computed(() => [
default: () => h(PersonOutlineRound),
}),
},
{
label: 'Logout',
key: 'logout',
icon: () =>
h(NIcon, null, {
default: () => h(LogOutOutlined),
}),
},
])
const servicesStore = useServicesStore()
function handleGuestMenuSelect(key: string) {
if (key === 'login') {
router.push('/login')
window.open(servicesStore.getSerivceUrl('DysonNetwork.Pass', 'login')!, '_blank')
} else if (key === 'create-account') {
router.push('/create-account')
window.open(servicesStore.getSerivceUrl('DysonNetwork.Pass', 'create-account')!, '_blank')
}
}
function handleUserMenuSelect(key: string) {
if (key === 'logout') {
userStore.logout()
router.push('/login')
} else if (key === 'profile') {
router.push('/accounts/me') // Assuming you have a profile page
if (key === 'profile') {
window.open(servicesStore.getSerivceUrl('DysonNetwork.Pass', 'accounts/me')!, '_blank')
} else {
router.push({ name: key })
}
}
</script>

View File

@@ -2,10 +2,19 @@
import LayoutDefault from './layouts/default.vue'
import { RouterView } from 'vue-router'
import { NGlobalStyle, NConfigProvider, NMessageProvider, lightTheme, darkTheme } from 'naive-ui'
import {
NGlobalStyle,
NConfigProvider,
NMessageProvider,
NDialogProvider,
NLoadingBarProvider,
lightTheme,
darkTheme,
} from 'naive-ui'
import { usePreferredDark } from '@vueuse/core'
import { useUserStore } from './stores/user'
import { onMounted } from 'vue'
import { useServicesStore } from './stores/services'
const themeOverrides = {
common: {
@@ -20,19 +29,27 @@ const themeOverrides = {
const isDark = usePreferredDark()
const userStore = useUserStore()
const servicesStore = useServicesStore()
onMounted(() => {
userStore.initialize()
userStore.fetchUser()
servicesStore.fetchServices()
})
</script>
<template>
<n-config-provider :theme-overrides="themeOverrides" :theme="isDark ? darkTheme : lightTheme">
<n-global-style />
<n-message-provider placement="bottom">
<layout-default>
<router-view />
</layout-default>
</n-message-provider>
<n-loading-bar-provider>
<n-dialog-provider>
<n-message-provider placement="bottom">
<layout-default>
<router-view />
</layout-default>
</n-message-provider>
</n-dialog-provider>
</n-loading-bar-provider>
</n-config-provider>
</template>

View File

@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useServicesStore } from '@/stores/services'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -7,21 +8,76 @@ const router = createRouter({
{
path: '/',
name: 'index',
component: () => import('../views/index.vue')
}
]
component: () => import('../views/index.vue'),
},
{
path: '/files/:fileId',
name: 'files',
component: () => import('../views/files.vue'),
},
{
path: '/bundles/:bundleId',
name: 'bundleDetails',
component: () => import('../views/bundles.vue'),
},
{
path: '/dashboard',
name: 'dashboard',
component: () => import('../layouts/dashboard.vue'),
meta: { requiresAuth: true },
children: [
{
path: 'usage',
name: 'dashboardUsage',
component: () => import('../views/dashboard/usage.vue'),
meta: { requiresAuth: true },
},
{
path: 'files',
name: 'dashboardFiles',
component: () => import('../views/dashboard/files.vue'),
meta: { requiresAuth: true },
},
{
path: 'bundles',
name: 'dashboardBundles',
component: () => import('../views/dashboard/bundles.vue'),
meta: { requiresAuth: true },
},
{
path: 'quotas',
name: 'dashboardQuota',
component: () => import('../views/dashboard/quotas.vue'),
meta: { requiresAuth: true },
},
],
},
{
path: '/:notFound(.*)',
name: 'errorNotFound',
component: () => import('../views/not-found.vue'),
},
],
})
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
const servicesStore = useServicesStore()
// Initialize user state if not already initialized
if (!userStore.user && localStorage.getItem('authToken')) {
await userStore.initialize()
if (!userStore.user) {
await userStore.fetchUser()
}
if (to.matched.some((record) => record.meta.requiresAuth) && !userStore.isAuthenticated) {
next({ name: 'login', query: { redirect: to.fullPath } })
window.open(
servicesStore.getSerivceUrl(
'DysonNetwork.Pass',
'login?redirect=' + encodeURIComponent(window.location.href),
)!,
'_blank',
)
next('/')
} else {
next()
}

View File

@@ -1,3 +1,27 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useServicesStore = defineStore('services', () => {})
export const useServicesStore = defineStore('services', () => {
const services = ref<Record<string, string>>({})
async function fetchServices() {
try {
const response = await fetch('/cgi/.well-known/services')
if (!response.ok) {
throw new Error('Network response was not ok')
}
const data = await response.json()
services.value = data
} catch (error) {
console.error('Failed to fetch services:', error)
services.value = {}
}
}
function getSerivceUrl(serviceName: string, ...parts: string[]): string | null {
const baseUrl = services.value[serviceName] || null
return baseUrl ? `${baseUrl}/${parts.join('/')}` : null
}
return { services, fetchServices, getSerivceUrl }
})

View File

@@ -11,19 +11,17 @@ export const useUserStore = defineStore('user', () => {
const isAuthenticated = computed(() => !!user.value)
// Actions
async function fetchUser() {
async function fetchUser(reload = true) {
if (!reload && user.value) return
isLoading.value = true
error.value = null
try {
const response = await fetch('/api/accounts/me', {
const response = await fetch('/cgi/id/accounts/me', {
credentials: 'include',
})
if (!response.ok) {
// If the token is invalid, clear it and the user state
if (response.status === 401) {
logout()
}
throw new Error('Failed to fetch user information.')
}
@@ -36,15 +34,24 @@ export const useUserStore = defineStore('user', () => {
}
}
function logout() {
user.value = null
localStorage.removeItem('authToken')
// Optionally, redirect to login page
// router.push('/login')
}
function initialize() {
const allowedOrigin = import.meta.env.DEV ? window.location.origin : 'https://id.solian.app'
window.addEventListener('message', (event) => {
// IMPORTANT: Always check the origin of the message for security!
// This prevents malicious scripts from sending fake login status updates.
// Ensure event.origin exactly matches your identity service's origin.
if (event.origin !== allowedOrigin) {
console.warn(`[SYNC] Message received from unexpected origin: ${event.origin}. Ignoring.`)
return // Ignore messages from unknown origins
}
async function initialize() {
await fetchUser()
// Check if the message is the type we're expecting
if (event.data && event.data.type === 'DY:LOGIN_STATUS_CHANGE') {
const { loggedIn } = event.data
console.log(`[SYNC] Received login status change: ${loggedIn}`)
fetchUser() // Re-fetch user data on login status change
}
})
}
return {
@@ -53,7 +60,6 @@ export const useUserStore = defineStore('user', () => {
error,
isAuthenticated,
fetchUser,
logout,
initialize,
}
})

View File

@@ -0,0 +1,37 @@
export interface SnFilePool {
id: string
name: string
description: string
storage_config: StorageConfig
billing_config: BillingConfig
policy_config: any
public_indexable: boolean
public_usable: boolean
no_optimization: boolean
no_metadata: boolean
allow_encryption: boolean
allow_anonymous: boolean
require_privilege: number
account_id: null
resource_identifier: string
created_at: Date
updated_at: Date
deleted_at: null
}
export interface BillingConfig {
cost_multiplier: number
}
export interface StorageConfig {
region: string
bucket: string
endpoint: string
secret_id: string
secret_key: string
enable_signed: boolean
enable_ssl: boolean
image_proxy: null
access_proxy: null
expiration: null
}

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

@@ -0,0 +1,164 @@
<template>
<section class="h-full container-fluid mx-auto py-4 px-5">
<div class="h-full flex justify-center items-center" v-if="!usage">
<n-spin />
</div>
<n-grid cols="1 s:2 l:4" responsive="screen" :x-gap="16" :y-gap="16" v-else>
<n-gi span="4">
<n-alert title="Billing Tips" size="small" type="info" closable>
<p>
The minimal billable unit is MiB, if your file is not enough 1 MiB it will be counted as
1 MiB.
</p>
<p>The <b>1 MiB = 1024 KiB = 1,048,576 B</b></p>
</n-alert>
</n-gi>
<n-gi>
<n-card class="h-stats">
<n-statistic label="All Uploads" tabular-nums>
<n-number-animation
:from="0"
:to="toGigabytes(usage.total_usage_bytes)"
:precision="3"
/>
<template #suffix>GiB</template>
</n-statistic>
</n-card>
</n-gi>
<n-gi>
<n-card class="h-stats">
<n-statistic label="All Files" tabular-nums>
<n-number-animation :from="0" :to="usage.total_file_count" />
</n-statistic>
</n-card>
</n-gi>
<n-gi>
<n-card class="h-stats">
<n-statistic label="Quota" tabular-nums>
<n-number-animation :from="0" :to="usage.total_quota" />
<template #suffix>MiB</template>
</n-statistic>
</n-card>
</n-gi>
<n-gi>
<n-card class="h-stats">
<div class="flex gap-2 justify-between items-end">
<n-statistic label="Used Quota" tabular-nums>
<n-number-animation :from="0" :to="quotaUsagePercentage" :precision="2" />
<template #suffix>%</template>
</n-statistic>
<n-progress
type="circle"
:percentage="quotaUsagePercentage"
:show-indicator="false"
:stroke-width="16"
style="width: 40px"
/>
</div>
</n-card>
</n-gi>
<n-gi span="2">
<n-card class="aspect-video" title="Pool Usage">
<pie
:data="poolChartData"
:options="{
maintainAspectRatio: false,
responsive: true,
plugins: { legend: { position: isDesktop ? 'right' : 'bottom' } },
}"
/>
</n-card>
</n-gi>
<n-gi span="2">
<n-card class="aspect-video h-full" title="Verbose Quota">
<pie
:data="quotaChartData"
:options="{
maintainAspectRatio: false,
responsive: true,
plugins: { legend: { position: isDesktop ? 'right' : 'bottom' } },
}"
/>
</n-card>
</n-gi>
</n-grid>
</section>
</template>
<script setup lang="ts">
import { NSpin, NCard, NStatistic, NGrid, NGi, NNumberAnimation, NAlert, NProgress } from 'naive-ui'
import { Chart as ChartJS, Title, Tooltip, Legend, ArcElement } from 'chart.js'
import { Pie } from 'vue-chartjs'
import { computed, onMounted, ref } from 'vue'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
ChartJS.register(Title, Tooltip, Legend, ArcElement)
const breakpoints = useBreakpoints(breakpointsTailwind)
const isDesktop = breakpoints.greaterOrEqual('md')
const poolChartData = computed(() => ({
labels: usage.value.pool_usages.map((pool: any) => pool.pool_name),
datasets: [
{
label: 'Pool Usage',
backgroundColor: '#7D80BAFF',
data: usage.value.pool_usages.map((pool: any) => pool.usage_bytes),
},
],
}))
const usage = ref<any>()
async function fetchUsage() {
try {
const response = await fetch('/api/billing/usage')
if (!response.ok) {
throw new Error('Network response was not ok')
}
usage.value = await response.json()
} catch (error) {
console.error('Failed to fetch usage data:', error)
}
}
onMounted(() => fetchUsage())
const verboseQuota = ref<
{ based_quota: number; extra_quota: number; total_quota: number } | undefined
>()
async function fetchVerboseQuota() {
try {
const response = await fetch('/api/billing/quota')
if (!response.ok) {
throw new Error('Network response was not ok')
}
verboseQuota.value = await response.json()
} catch (error) {
console.error('Failed to fetch verbose data:', error)
}
}
onMounted(() => fetchVerboseQuota())
const quotaChartData = computed(() => ({
labels: ['Base Quota', 'Extra Quota'],
datasets: [
{
label: 'Verbose Quota',
backgroundColor: '#7D80BAFF',
data: [verboseQuota.value?.based_quota ?? 0, verboseQuota.value?.extra_quota ?? 0],
},
],
}))
const quotaUsagePercentage = computed(
() => (usage.value.used_quota / usage.value.total_quota) * 100,
)
function toGigabytes(bytes: number): number {
return bytes / (1024 * 1024 * 1024)
}
</script>
<style scoped>
.h-stats {
height: 105px;
}
</style>

View File

@@ -0,0 +1,262 @@
<template>
<section class="min-h-full relative flex items-center justify-center">
<n-spin v-if="!fileInfo && !error" />
<n-result status="404" title="No file was found" :description="error" v-else-if="error" />
<n-card class="max-w-4xl my-4 mx-8" v-else>
<n-grid cols="1 m:2" x-gap="16" y-gap="16" responsive="screen">
<n-gi>
<div v-if="fileInfo.is_encrypted">
<n-alert type="info" size="small" title="Encrypted file">
The file has been encrypted. Preview not available. Please enter the password to
download it.
</n-alert>
</div>
<div v-else>
<n-image v-if="fileType === 'image'" :src="fileSource" class="w-full" />
<video v-else-if="fileType === 'video'" :src="fileSource" controls class="w-full" />
<audio v-else-if="fileType === 'audio'" :src="fileSource" controls class="w-full" />
<n-result
status="418"
title="Preview Unavailable"
description="How can you preview this file?"
size="small"
class="py-6"
v-else
/>
</div>
</n-gi>
<n-gi>
<div class="mb-3">
<n-card title="File Infomation" size="small">
<div class="flex gap-2">
<span class="flex-grow-1 flex items-center gap-2">
<n-icon>
<info-round />
</n-icon>
File Type
</span>
<span>{{ fileInfo.mime_type }} ({{ fileType }})</span>
</div>
<div class="flex gap-2">
<span class="flex-grow-1 flex items-center gap-2">
<n-icon>
<data-usage-round />
</n-icon>
File Size
</span>
<span>{{ formatBytes(fileInfo.size) }}</span>
</div>
<div class="flex gap-2">
<span class="flex-grow-1 flex items-center gap-2">
<n-icon>
<file-upload-outlined />
</n-icon>
Uploaded At
</span>
<span>{{ new Date(fileInfo.created_at).toLocaleString() }}</span>
</div>
<div class="flex gap-2">
<span class="flex-grow-1 flex items-center gap-2">
<n-icon>
<details-round />
</n-icon>
Techical Info
</span>
<n-button text size="small" @click="showTechDetails = !showTechDetails">
{{ showTechDetails ? 'Hide' : 'Show' }}
</n-button>
</div>
<n-collapse-transition :show="showTechDetails">
<div v-if="showTechDetails" class="mt-2 flex flex-col gap-1">
<p class="text-xs opacity-75">#{{ fileInfo.id }}</p>
<n-card size="small" content-style="padding: 0" embedded>
<div class="overflow-x-auto px-4 py-2">
<n-code
:code="JSON.stringify(fileInfo.file_meta, null, 4)"
language="json"
:hljs="hljs"
/>
</div>
</n-card>
</div>
</n-collapse-transition>
</n-card>
</div>
<div class="flex flex-col gap-3">
<n-input
v-if="fileInfo.is_encrypted"
placeholder="Password"
v-model:value="filePass"
type="password"
/>
<div class="flex gap-2">
<n-button class="flex-grow-1" @click="downloadFile">Download</n-button>
<n-popover placement="bottom" trigger="hover">
<template #trigger>
<n-button>
<n-icon>
<qr-code-round />
</n-icon>
</n-button>
</template>
<n-qr-code
type="svg"
:value="currentUrl"
:size="160"
icon-src="/favicon.png"
error-correction-level="H"
/>
</n-popover>
</div>
</div>
<n-collapse-transition :show="!!progress">
<n-progress
:processing="!!progress && progress < 100"
:percentage="progress"
indicator-placement="inside"
class="mt-4"
/>
</n-collapse-transition>
</n-gi>
</n-grid>
</n-card>
</section>
</template>
<script setup lang="ts">
import {
NCard,
NInput,
NButton,
NProgress,
NResult,
NSpin,
NImage,
NAlert,
NIcon,
NCollapseTransition,
NCode,
NGrid,
NGi,
NPopover,
NQrCode,
useMessage,
} from 'naive-ui'
import {
DataUsageRound,
InfoRound,
DetailsRound,
FileUploadOutlined,
QrCodeRound,
} from '@vicons/material'
import { useRoute } from 'vue-router'
import { computed, onMounted, ref } from 'vue'
import { downloadAndDecryptFile } from './secure'
import { formatBytes } from './format'
import hljs from 'highlight.js/lib/core'
import json from 'highlight.js/lib/languages/json'
hljs.registerLanguage('json', json)
const route = useRoute()
const error = ref<string | null>(null)
const filePass = ref<string>('')
const fileId = route.params.fileId
const passcode = route.query.passcode as string | undefined
const progress = ref<number | undefined>(0)
const showTechDetails = ref<boolean>(false)
const messageDisplay = useMessage()
const currentUrl = window.location.href
const fileInfo = ref<any>(null)
async function fetchFileInfo() {
try {
let url = '/api/files/' + fileId + '/info'
if (passcode) {
url += `?passcode=${passcode}`
}
const resp = await fetch(url)
if (!resp.ok) {
throw new Error('Failed to fetch file info: ' + resp.statusText)
}
fileInfo.value = await resp.json()
} catch (err) {
error.value = (err as Error).message
}
}
onMounted(() => fetchFileInfo())
const fileType = computed(() => {
if (!fileInfo.value) return 'unknown'
return fileInfo.value.mime_type?.split('/')[0] || 'unknown'
})
const fileSource = computed(() => {
let url = `/api/files/${fileId}`
if (passcode) {
url += `?passcode=${passcode}`
}
return url
})
async function downloadFile() {
if (fileInfo.value.is_encrypted && !filePass.value) {
messageDisplay.error('Please enter the password to download the file.')
return
}
if (fileInfo.value.is_encrypted) {
downloadAndDecryptFile(fileSource.value, filePass.value, fileInfo.value.name, (p: number) => {
progress.value = p * 100
}).catch((err) => {
messageDisplay.error('Download failed: ' + err.message, { closable: true, duration: 10000 })
progress.value = undefined
})
} else {
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>

View File

@@ -0,0 +1,8 @@
export function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return '0 Bytes'
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}

View File

@@ -1,37 +1,164 @@
<template>
<section class="h-full relative flex items-center justify-center">
<n-card class="max-w-lg" title="About">
<section class="h-full relative flex flex-col items-center justify-center">
<n-card class="max-w-lg my-4 mx-8" title="About" v-if="!userStore.user">
<p>Welcome to the <b>Solar Drive</b></p>
<p>
We help you upload, collect, and share files with ease in mind.
</p>
<p 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>
<p>We help you upload, collect, and share files with ease in mind.</p>
<p>To continue, login first.</p>
</n-card>
<n-card class="max-w-2xl" v-else content-style="padding: 0;">
<n-tabs type="line" animated :tabs-padding="20" pane-style="padding: 20px">
<template #suffix>
<div class="flex gap-2 items-center me-4">
<p>Advance Mode</p>
<n-switch v-model:value="modeAdvanced" size="small" />
</div>
</template>
<n-tab-pane name="direct" tab="Direct Upload" :disabled="isBundleMode">
<div class="mb-3">
<file-pool-select v-model="filePool" @update:pool="currentFilePool = $event" />
</div>
<upload-area
:filePool="filePool"
:pools="pools as SnFilePool[]"
:modeAdvanced="modeAdvanced"
/>
</n-tab-pane>
<n-tab-pane name="bundle" tab="Bundle Upload">
<div class="mb-3">
<bundle-select v-model:bundle="selectedBundleId" :disabled="isBundleMode" />
</div>
<n-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!"
/>
</div>
</n-tab-pane>
</n-tabs>
</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>
</template>
<script setup lang="ts">
import { NCard } from 'naive-ui'
import { onMounted, ref } from 'vue'
import { NCard, NSwitch, NTabs, NTabPane, NButton, NModal } from 'naive-ui'
import { computed, onMounted, ref } from 'vue'
import { useUserStore } from '@/stores/user'
import type { SnFilePool } from '@/types/pool'
import FilePoolSelect from '@/components/FilePoolSelect.vue'
import UploadArea from '@/components/UploadArea.vue'
import BundleSelect from '@/components/BundleSelect.vue'
import BundleForm from '@/components/form/BundleForm.vue'
const userStore = useUserStore()
const version = ref<any>(null)
async function fetchVersion() {
const resp = await fetch('/api/version')
version.value = await resp.json()
}
onMounted(() => fetchVersion())
</script>
<style scoped>
/* Add any specific styles here if needed, though Tailwind should handle most. */
</style>
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())
const modeAdvanced = ref(false)
const filePool = ref<string | null>(null)
const currentFilePool = computed(() => {
if (!filePool.value) return null
return pools.value?.find((pool) => pool.id === filePool.value) ?? null
})
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)
async function createBundle() {
try {
await bundleFormRef.value?.formRef?.validate()
const resp = await fetch('/api/bundles', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newBundle.value),
})
if (!resp.ok) {
throw new Error('Failed to create bundle')
}
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 proceedToBundleUpload() {
currentBundleId.value = selectedBundleId.value || newBundleId.value
bundleUploadMode.value = true
isBundleMode.value = true
}
function cancelBundleUpload() {
bundleUploadMode.value = false
isBundleMode.value = false
currentBundleId.value = null
selectedBundleId.value = null
newBundleId.value = null
}
</script>

View File

@@ -0,0 +1,16 @@
<template>
<section class="h-full flex items-center justify-center">
<n-result status="404" title="404" description="Page not found">
<template #footer>
<n-button @click="router.push('/')">Go to Home</n-button>
</template>
</n-result>
</section>
</template>
<script lang="ts" setup>
import { NResult, NButton } from 'naive-ui'
import { useRouter } from 'vue-router';
const router = useRouter()
</script>

View File

@@ -0,0 +1,94 @@
export async function downloadAndDecryptFile(
url: string,
password: string,
fileName: string,
onProgress?: (progress: number) => void,
): Promise<void> {
const response = await fetch(url)
if (!response.ok) throw new Error(`Failed to fetch: ${response.status}`)
const contentLength = +(response.headers.get('Content-Length') || 0)
const reader = response.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
if (contentLength && onProgress) {
onProgress(received / contentLength)
}
}
}
const fullBuffer = new Uint8Array(received)
let offset = 0
for (const chunk of chunks) {
fullBuffer.set(chunk, offset)
offset += chunk.length
}
const decryptedBytes = await decryptFile(fullBuffer, password)
// Create a blob and trigger a download
const blob = new Blob([decryptedBytes])
const downloadUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = downloadUrl
a.download = fileName
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(downloadUrl)
}
export async function decryptFile(fileBuffer: Uint8Array, password: string): Promise<Uint8Array> {
const salt = fileBuffer.slice(0, 16)
const nonce = fileBuffer.slice(16, 28)
const tag = fileBuffer.slice(28, 44)
const ciphertext = fileBuffer.slice(44)
const enc = new TextEncoder()
const keyMaterial = await crypto.subtle.importKey(
'raw',
enc.encode(password),
{ name: 'PBKDF2' },
false,
['deriveKey'],
)
const key = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt'],
)
const fullCiphertext = new Uint8Array(ciphertext.length + tag.length)
fullCiphertext.set(ciphertext)
fullCiphertext.set(tag, ciphertext.length)
let decrypted: ArrayBuffer
try {
decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: nonce, tagLength: 128 },
key,
fullCiphertext,
)
} catch {
throw new Error('Incorrect password or corrupted file.')
}
const magic = new TextEncoder().encode('DYSON1')
const decryptedBytes = new Uint8Array(decrypted)
for (let i = 0; i < magic.length; i++) {
if (decryptedBytes[i] !== magic[i]) {
throw new Error('Incorrect password or corrupted file.')
}
}
return decryptedBytes.slice(magic.length)
}

View File

@@ -17,23 +17,14 @@ export default defineConfig({
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
rollupOptions: {
output: {
entryFileNames: `assets/[name].js`,
chunkFileNames: `assets/[name].js`,
assetFileNames: `assets/[name].[ext]`,
},
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:5216',
target: 'http://localhost:5090',
changeOrigin: true,
},
'/cgi': {
target: 'http://localhost:5216',
target: 'http://localhost:5090',
changeOrigin: true,
}
},

View File

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

View File

@@ -16,7 +16,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace DysonNetwork.Drive.Migrations
{
[DbContext(typeof(AppDatabase))]
[Migration("20250725163615_AddCloudFilePool")]
[Migration("20250726103203_AddCloudFilePool")]
partial class AddCloudFilePool
{
/// <inheritdoc />
@@ -55,7 +55,6 @@ namespace DysonNetwork.Drive.Migrations
.HasColumnName("description");
b.Property<Dictionary<string, object>>("FileMeta")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("file_meta");
@@ -72,6 +71,10 @@ namespace DysonNetwork.Drive.Migrations
.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");
@@ -192,6 +195,10 @@ namespace DysonNetwork.Drive.Migrations
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid?>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<BillingConfig>("BillingConfig")
.IsRequired()
.HasColumnType("jsonb")
@@ -211,6 +218,11 @@ namespace DysonNetwork.Drive.Migrations
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<PolicyConfig>("PolicyConfig")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("policy_config");
b.Property<RemoteStorageConfig>("StorageConfig")
.IsRequired()
.HasColumnType("jsonb")

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using DysonNetwork.Drive.Storage;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
@@ -13,6 +14,28 @@ namespace DysonNetwork.Drive.Migrations
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<Dictionary<string, object>>(
name: "file_meta",
table: "files",
type: "jsonb",
nullable: true,
oldClrType: typeof(Dictionary<string, object>),
oldType: "jsonb");
migrationBuilder.AddColumn<bool>(
name: "has_thumbnail",
table: "files",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "is_encrypted",
table: "files",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<Guid>(
name: "pool_id",
table: "files",
@@ -27,6 +50,8 @@ namespace DysonNetwork.Drive.Migrations
name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
storage_config = table.Column<RemoteStorageConfig>(type: "jsonb", nullable: false),
billing_config = table.Column<BillingConfig>(type: "jsonb", nullable: false),
policy_config = table.Column<PolicyConfig>(type: "jsonb", nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
@@ -63,9 +88,26 @@ namespace DysonNetwork.Drive.Migrations
name: "ix_files_pool_id",
table: "files");
migrationBuilder.DropColumn(
name: "has_thumbnail",
table: "files");
migrationBuilder.DropColumn(
name: "is_encrypted",
table: "files");
migrationBuilder.DropColumn(
name: "pool_id",
table: "files");
migrationBuilder.AlterColumn<Dictionary<string, object>>(
name: "file_meta",
table: "files",
type: "jsonb",
nullable: false,
oldClrType: typeof(Dictionary<string, object>),
oldType: "jsonb",
oldNullable: true);
}
}
}

View File

@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using DysonNetwork.Drive;
using DysonNetwork.Drive.Storage;
using DysonNetwork.Shared.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,8 +16,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace DysonNetwork.Drive.Migrations
{
[DbContext(typeof(AppDatabase))]
[Migration("20250725051034_UpdateCloudFileThumbnail")]
partial class UpdateCloudFileThumbnail
[Migration("20250726120323_AddFilePoolDescription")]
partial class AddFilePoolDescription
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -54,7 +55,6 @@ namespace DysonNetwork.Drive.Migrations
.HasColumnName("description");
b.Property<Dictionary<string, object>>("FileMeta")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("file_meta");
@@ -71,6 +71,10 @@ namespace DysonNetwork.Drive.Migrations
.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");
@@ -86,6 +90,10 @@ namespace DysonNetwork.Drive.Migrations
.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");
@@ -124,6 +132,9 @@ namespace DysonNetwork.Drive.Migrations
b.HasKey("Id")
.HasName("pk_files");
b.HasIndex("PoolId")
.HasDatabaseName("ix_files_pool_id");
b.ToTable("files", (string)null);
});
@@ -177,6 +188,72 @@ namespace DysonNetwork.Drive.Migrations
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")

View File

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

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

@@ -5,14 +5,14 @@
namespace DysonNetwork.Drive.Migrations
{
/// <inheritdoc />
public partial class UpdateCloudFileThumbnail : Migration
public partial class AddHiddenPool : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "has_thumbnail",
table: "files",
name: "is_hidden",
table: "pools",
type: "boolean",
nullable: false,
defaultValue: false);
@@ -22,8 +22,8 @@ namespace DysonNetwork.Drive.Migrations
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "has_thumbnail",
table: "files");
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("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b =>
{
b.Property<string>("Id")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
@@ -46,13 +45,67 @@ namespace DysonNetwork.Drive.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text")
.HasColumnName("description");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<long>("Quota")
.HasColumnType("bigint")
.HasColumnName("quota");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_quota_records");
b.ToTable("quota_records", (string)null);
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
{
b.Property<string>("Id")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Guid?>("BundleId")
.HasColumnType("uuid")
.HasColumnName("bundle_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("description");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<Dictionary<string, object>>("FileMeta")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("file_meta");
@@ -69,6 +122,10 @@ namespace DysonNetwork.Drive.Migrations
.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");
@@ -114,11 +171,6 @@ namespace DysonNetwork.Drive.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("uploaded_at");
b.Property<string>("UploadedTo")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("uploaded_to");
b.Property<Dictionary<string, object>>("UserMeta")
.HasColumnType("jsonb")
.HasColumnName("user_meta");
@@ -126,6 +178,9 @@ namespace DysonNetwork.Drive.Migrations
b.HasKey("Id")
.HasName("pk_files");
b.HasIndex("BundleId")
.HasDatabaseName("ix_files_bundle_id");
b.HasIndex("PoolId")
.HasDatabaseName("ix_files_pool_id");
@@ -182,6 +237,65 @@ namespace DysonNetwork.Drive.Migrations
b.ToTable("file_references", (string)null);
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("description");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<string>("Passcode")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("passcode");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("slug");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_bundles");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_bundles_slug");
b.ToTable("bundles", (string)null);
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b =>
{
b.Property<Guid>("Id")
@@ -189,6 +303,10 @@ namespace DysonNetwork.Drive.Migrations
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid?>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<BillingConfig>("BillingConfig")
.IsRequired()
.HasColumnType("jsonb")
@@ -202,12 +320,27 @@ namespace DysonNetwork.Drive.Migrations
.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")
@@ -225,18 +358,25 @@ namespace DysonNetwork.Drive.Migrations
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
{
b.HasOne("DysonNetwork.Drive.Storage.FileBundle", "Bundle")
.WithMany("Files")
.HasForeignKey("BundleId")
.HasConstraintName("fk_files_bundles_bundle_id");
b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool")
.WithMany()
.HasForeignKey("PoolId")
.HasConstraintName("fk_files_pools_pool_id");
b.Navigation("Bundle");
b.Navigation("Pool");
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
{
b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
.WithMany()
.WithMany("References")
.HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
@@ -244,6 +384,16 @@ namespace DysonNetwork.Drive.Migrations
b.Navigation("File");
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
{
b.Navigation("References");
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
{
b.Navigation("Files");
});
#pragma warning restore 612, 618
}
}

View File

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

View File

@@ -1,4 +1,5 @@
using DysonNetwork.Drive.Storage;
using Microsoft.Extensions.FileProviders;
using tusdotnet;
using tusdotnet.Interfaces;
@@ -6,7 +7,7 @@ namespace DysonNetwork.Drive.Startup;
public static class ApplicationBuilderExtensions
{
public static WebApplication ConfigureAppMiddleware(this WebApplication app, ITusStore tusStore)
public static WebApplication ConfigureAppMiddleware(this WebApplication app, ITusStore tusStore, string contentRoot)
{
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
@@ -18,6 +19,21 @@ public static class ApplicationBuilderExtensions
app.UseAuthorization();
app.MapControllers();
app.UseCors(opts =>
opts.SetIsOriginAllowed(_ => true)
.WithExposedHeaders("*")
.WithHeaders("*")
.AllowCredentials()
.AllowAnyHeader()
.AllowAnyMethod()
);
app.UseDefaultFiles();
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(Path.Combine(contentRoot, "wwwroot", "dist"))
});
app.MapTus("/api/tus", _ => Task.FromResult(TusService.BuildConfiguration(tusStore, app.Configuration)));
return app;

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;
namespace DysonNetwork.Drive.Startup;
@@ -14,6 +15,13 @@ public static class ScheduledJobsConfiguration
.ForJob(appDatabaseRecyclingJob)
.WithIdentity("AppDatabaseRecyclingTrigger")
.WithCronSchedule("0 0 0 * * ?"));
var cloudFileUnusedRecyclingJob = new JobKey("CloudFileUnusedRecycling");
q.AddJob<CloudFileUnusedRecyclingJob>(opts => opts.WithIdentity(cloudFileUnusedRecyclingJob));
q.AddTrigger(opts => opts
.ForJob(cloudFileUnusedRecyclingJob)
.WithIdentity("CloudFileUnusedRecyclingTrigger")
.WithCronSchedule("0 0 0 * * ?"));
});
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);

View File

@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using DysonNetwork.Shared.Cache;
using Microsoft.AspNetCore.RateLimiting;
@@ -16,11 +17,6 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<AppDatabase>(); // Assuming you'll have an AppDatabase
services.AddSingleton<IConnectionMultiplexer>(_ =>
{
var connection = configuration.GetConnectionString("FastRetrieve")!;
return ConnectionMultiplexer.Connect(connection);
});
services.AddSingleton<IClock>(SystemClock.Instance);
services.AddHttpContextAccessor();
services.AddSingleton<ICacheService, CacheServiceRedis>(); // Uncomment if you have CacheServiceRedis
@@ -40,6 +36,7 @@ public static class ServiceCollectionExtensions
services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
@@ -138,6 +135,10 @@ public static class ServiceCollectionExtensions
{
services.AddScoped<Storage.FileService>();
services.AddScoped<Storage.FileReferenceService>();
services.AddScoped<Billing.UsageService>();
services.AddScoped<Billing.QuotaService>();
services.AddHostedService<BroadcastEventHandler>();
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,54 +1,37 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto;
using Google.Protobuf;
using Newtonsoft.Json;
using NodaTime;
using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Drive.Storage;
/// <summary>
/// The class that used in jsonb columns which referenced the cloud file.
/// The aim of this class is to store some properties that won't change to a file to reduce the database load.
/// </summary>
public class CloudFileReferenceObject : ModelBase, ICloudFile
{
public string Id { get; set; } = null!;
public string Name { get; set; } = string.Empty;
public Dictionary<string, object?> FileMeta { get; set; } = null!;
public Dictionary<string, object>? UserMeta { get; set; } = null!;
public string? MimeType { get; set; }
public string? Hash { get; set; }
public long Size { get; set; }
public bool HasCompression { get; set; } = false;
}
public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
{
/// The id generated by TuS, basically just UUID remove the dash lines
[MaxLength(32)]
public string Id { get; set; } = Guid.NewGuid().ToString();
public string Id { get; set; } = Guid.NewGuid().ToString().Replace("-", string.Empty);
[MaxLength(1024)] public string Name { get; set; } = string.Empty;
[MaxLength(4096)] public string? Description { get; set; }
[Column(TypeName = "jsonb")] public Dictionary<string, object?> FileMeta { get; set; } = null!;
[Column(TypeName = "jsonb")] public Dictionary<string, object>? UserMeta { get; set; } = null!;
[Column(TypeName = "jsonb")] public Dictionary<string, object?>? FileMeta { get; set; }
[Column(TypeName = "jsonb")] public Dictionary<string, object?>? UserMeta { get; set; }
[Column(TypeName = "jsonb")] public List<ContentSensitiveMark>? SensitiveMarks { get; set; } = [];
[MaxLength(256)] public string? MimeType { get; set; }
[MaxLength(256)] public string? Hash { get; set; }
public Instant? ExpiredAt { get; set; }
public long Size { get; set; }
public Instant? UploadedAt { get; set; }
public bool HasCompression { get; set; } = false;
public bool HasThumbnail { get; set; } = false;
public bool IsEncrypted { get; set; } = false;
[JsonIgnore] public FilePool? Pool { get; set; }
public FilePool? Pool { get; set; }
public Guid? PoolId { get; set; }
[Obsolete("Deprecated, use PoolId instead. For database migration only.")]
[MaxLength(128)]
public string? UploadedTo { get; set; }
[JsonIgnore] public FileBundle? Bundle { get; set; }
public Guid? BundleId { get; set; }
/// <summary>
/// The field is set to true if the recycling job plans to delete the file.
@@ -58,7 +41,7 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
/// The object name which stored remotely,
/// 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,
/// The embedding file means the file is store on another site,
/// or it is a webpage (based on mimetype)
@@ -70,6 +53,12 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
[MaxLength(4096)]
public string? StorageUrl { get; set; }
[NotMapped]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? FastUploadLink { get; set; }
public ICollection<CloudFileReference> References { get; set; } = new List<CloudFileReference>();
public Guid AccountId { get; set; }
public CloudFileReferenceObject ToReferenceObject()
@@ -81,8 +70,9 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
DeletedAt = DeletedAt,
Id = Id,
Name = Name,
FileMeta = FileMeta,
UserMeta = UserMeta,
FileMeta = FileMeta ?? [],
UserMeta = UserMeta ?? [],
SensitiveMarks = SensitiveMarks,
MimeType = MimeType,
Hash = Hash,
Size = Size,
@@ -101,7 +91,7 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
var proto = new Shared.Proto.CloudFile
{
Id = Id,
Name = Name ?? string.Empty,
Name = Name,
MimeType = MimeType ?? string.Empty,
Hash = Hash ?? string.Empty,
Size = Size,
@@ -110,13 +100,10 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
ContentType = MimeType ?? string.Empty,
UploadedAt = UploadedAt?.ToTimestamp(),
// Convert file metadata
FileMeta = ByteString.CopyFromUtf8(
System.Text.Json.JsonSerializer.Serialize(FileMeta, GrpcTypeHelper.SerializerOptions)
),
FileMeta = GrpcTypeHelper.ConvertObjectToByteString(FileMeta),
// Convert user metadata
UserMeta = ByteString.CopyFromUtf8(
System.Text.Json.JsonSerializer.Serialize(UserMeta, GrpcTypeHelper.SerializerOptions)
)
UserMeta = GrpcTypeHelper.ConvertObjectToByteString(UserMeta),
SensitiveMarks = GrpcTypeHelper.ConvertObjectToByteString(SensitiveMarks)
};
return proto;
@@ -152,4 +139,4 @@ public class CloudFileReference : ModelBase
ExpiredAt = ExpiredAt?.ToTimestamp()
};
}
}
}

View File

@@ -7,19 +7,42 @@ namespace DysonNetwork.Drive.Storage;
public class CloudFileUnusedRecyclingJob(
AppDatabase db,
FileReferenceService fileRefService,
ILogger<CloudFileUnusedRecyclingJob> logger
ILogger<CloudFileUnusedRecyclingJob> logger,
IConfiguration configuration
)
: IJob
{
public async Task Execute(IJobExecutionContext context)
{
logger.LogInformation("Cleaning tus cloud files...");
var storePath = configuration["Tus:StorePath"];
if (Directory.Exists(storePath))
{
var oneHourAgo = SystemClock.Instance.GetCurrentInstant() - Duration.FromHours(1);
var files = Directory.GetFiles(storePath);
foreach (var file in files)
{
var creationTime = File.GetCreationTime(file).ToUniversalTime();
if (creationTime < oneHourAgo.ToDateTimeUtc())
File.Delete(file);
}
}
logger.LogInformation("Marking unused cloud files...");
var recyclablePools = await db.Pools
.Where(p => p.PolicyConfig.EnableRecycle)
.Select(p => p.Id)
.ToListAsync();
var now = SystemClock.Instance.GetCurrentInstant();
const int batchSize = 1000; // Process larger batches for efficiency
var processedCount = 0;
var markedCount = 0;
var totalFiles = await db.Files.Where(f => !f.IsMarkedRecycle).CountAsync();
var totalFiles = await db.Files
.Where(f => f.PoolId.HasValue && recyclablePools.Contains(f.PoolId.Value))
.Where(f => !f.IsMarkedRecycle)
.CountAsync();
logger.LogInformation("Found {TotalFiles} files to check for unused status", totalFiles);
@@ -35,13 +58,12 @@ public class CloudFileUnusedRecyclingJob(
{
// Query for the next batch of files using keyset pagination
var filesQuery = db.Files
.Where(f => f.PoolId.HasValue && recyclablePools.Contains(f.PoolId.Value))
.Where(f => !f.IsMarkedRecycle)
.Where(f => f.CreatedAt <= ageThreshold); // Only process older files first
if (lastProcessedId != null)
{
filesQuery = filesQuery.Where(f => string.Compare(f.Id, lastProcessedId) > 0);
}
var fileBatch = await filesQuery
.OrderBy(f => f.Id) // Ensure consistent ordering for pagination
@@ -84,9 +106,17 @@ public class CloudFileUnusedRecyclingJob(
{
logger.LogInformation(
"Progress: processed {ProcessedCount}/{TotalFiles} files, marked {MarkedCount} for recycling",
processedCount, totalFiles, markedCount);
processedCount,
totalFiles,
markedCount
);
}
}
var expiredCount = await db.Files
.Where(f => f.ExpiredAt.HasValue && f.ExpiredAt.Value <= now)
.ExecuteUpdateAsync(s => s.SetProperty(f => f.IsMarkedRecycle, true));
markedCount += expiredCount;
logger.LogInformation("Completed marking {MarkedCount} files for recycling", markedCount);
}

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,3 +1,6 @@
using DysonNetwork.Drive.Billing;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -11,6 +14,7 @@ namespace DysonNetwork.Drive.Storage;
public class FileController(
AppDatabase db,
FileService fs,
QuotaService qs,
IConfiguration configuration,
IWebHostEnvironment env
) : ControllerBase
@@ -20,7 +24,9 @@ public class FileController(
string id,
[FromQuery] bool download = false,
[FromQuery] bool original = false,
[FromQuery] string? overrideMimeType = null
[FromQuery] bool thumbnail = false,
[FromQuery] string? overrideMimeType = null,
[FromQuery] string? passcode = null
)
{
// Support the file extension for client side data recognize
@@ -34,6 +40,10 @@ public class FileController(
var file = await fs.GetFileAsync(id);
if (file is null) return NotFound();
if (file.IsMarkedRecycle) return StatusCode(StatusCodes.Status410Gone, "The file has been recycled.");
if (file.Bundle is not null && !file.Bundle.VerifyPasscode(passcode))
return StatusCode(StatusCodes.Status403Forbidden, "The passcode is incorrect.");
if (!string.IsNullOrWhiteSpace(file.StorageUrl)) return Redirect(file.StorageUrl);
@@ -45,9 +55,27 @@ public class FileController(
return PhysicalFile(filePath, file.MimeType ?? "application/octet-stream", file.Name);
}
var dest = await fs.GetRemoteStorageConfig(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.");
var dest = pool.StorageConfig;
if (!pool.PolicyConfig.AllowAnonymous)
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
// TODO: Provide ability to add access log
var fileName = string.IsNullOrWhiteSpace(file.StorageId) ? file.Id : file.StorageId;
switch (thumbnail)
{
case true when file.HasThumbnail:
fileName += ".thumbnail";
break;
case true when !file.HasThumbnail:
return NotFound();
}
if (!original && file.HasCompression)
fileName += ".compressed";
@@ -72,7 +100,8 @@ public class FileController(
var client = fs.CreateMinioClient(dest);
if (client is null)
return BadRequest(
"Failed to configure client for remote destination, file got an invalid storage remote.");
"Failed to configure client for remote destination, file got an invalid storage remote."
);
var headers = new Dictionary<string, string>();
if (fileExtension is not null)
@@ -115,12 +144,91 @@ public class FileController(
[HttpGet("{id}/info")]
public async Task<ActionResult<CloudFile>> GetFileInfo(string id)
{
var file = await db.Files.FindAsync(id);
var file = await fs.GetFileAsync(id);
if (file is null) return NotFound();
return file;
}
[Authorize]
[HttpPatch("{id}/name")]
public async Task<ActionResult<CloudFile>> UpdateFileName(string id, [FromBody] string name)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var file = await db.Files.FirstOrDefaultAsync(f => f.Id == id && f.AccountId == accountId);
if (file is null) return NotFound();
file.Name = name;
await db.SaveChangesAsync();
await fs._PurgeCacheAsync(file.Id);
return file;
}
public class MarkFileRequest
{
public List<ContentSensitiveMark>? SensitiveMarks { get; set; }
}
[Authorize]
[HttpPut("{id}/marks")]
public async Task<ActionResult<CloudFile>> MarkFile(string id, [FromBody] MarkFileRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var file = await db.Files.FirstOrDefaultAsync(f => f.Id == id && f.AccountId == accountId);
if (file is null) return NotFound();
file.SensitiveMarks = request.SensitiveMarks;
await db.SaveChangesAsync();
await fs._PurgeCacheAsync(file.Id);
return file;
}
[Authorize]
[HttpPut("{id}/meta")]
public async Task<ActionResult<CloudFile>> UpdateFileMeta(string id, [FromBody] Dictionary<string, object?> meta)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var file = await db.Files.FirstOrDefaultAsync(f => f.Id == id && f.AccountId == accountId);
if (file is null) return NotFound();
file.UserMeta = meta;
await db.SaveChangesAsync();
await fs._PurgeCacheAsync(file.Id);
return file;
}
[Authorize]
[HttpGet("me")]
public async Task<ActionResult<List<CloudFile>>> GetMyFiles(
[FromQuery] Guid? pool,
[FromQuery] bool recycled = false,
[FromQuery] int offset = 0,
[FromQuery] int take = 20
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var query = db.Files
.Where(e => e.IsMarkedRecycle == recycled)
.Where(e => e.AccountId == accountId)
.Include(e => e.Pool)
.OrderByDescending(e => e.CreatedAt)
.AsQueryable();
if (pool.HasValue) query = query.Where(e => e.PoolId == pool);
var total = await query.CountAsync();
Response.Headers.Append("X-Total", total.ToString());
var files = await query
.Skip(offset)
.Take(take)
.ToListAsync();
return Ok(files);
}
[Authorize]
[HttpDelete("{id}")]
public async Task<ActionResult> DeleteFile(string id)
@@ -134,11 +242,135 @@ public class FileController(
.FirstOrDefaultAsync();
if (file is null) return NotFound();
await fs.DeleteFileDataAsync(file, force: true);
await fs.DeleteFileAsync(file);
db.Files.Remove(file);
await db.SaveChangesAsync();
return NoContent();
}
[Authorize]
[HttpDelete("me/recycle")]
public async Task<ActionResult> DeleteMyRecycledFiles()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var count = await fs.DeleteAccountRecycledFilesAsync(accountId);
return Ok(new { Count = count });
}
[Authorize]
[HttpDelete("recycle")]
[RequiredPermission("maintenance", "files.delete.recycle")]
public async Task<ActionResult> DeleteAllRecycledFiles()
{
var count = await fs.DeleteAllRecycledFilesAsync();
return Ok(new { Count = count });
}
public class CreateFastFileRequest
{
public string Name { get; set; } = null!;
public long Size { get; set; }
public string Hash { get; set; } = null!;
public string? MimeType { get; set; }
public string? Description { get; set; }
public Dictionary<string, object?>? UserMeta { get; set; }
public Dictionary<string, object?>? FileMeta { get; set; }
public List<ContentSensitiveMark>? SensitiveMarks { get; set; }
public Guid PoolId { get; set; }
}
[Authorize]
[HttpPost("fast")]
[RequiredPermission("global", "files.create")]
public async Task<ActionResult<CloudFile>> CreateFastFile([FromBody] CreateFastFileRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var pool = await db.Pools.FirstOrDefaultAsync(p => p.Id == request.PoolId);
if (pool is null) return BadRequest();
if (!currentUser.IsSuperuser && pool.AccountId != accountId)
return StatusCode(403, "You don't have permission to create files in this pool.");
if (!pool.PolicyConfig.EnableFastUpload)
return StatusCode(
403,
"This pool does not allow fast upload"
);
if (pool.PolicyConfig.RequirePrivilege > 0)
{
if (currentUser.PerkSubscription is null)
{
return StatusCode(
403,
$"You need to have join the Stellar Program to use this pool"
);
}
var privilege =
PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier);
if (privilege < pool.PolicyConfig.RequirePrivilege)
{
return StatusCode(
403,
$"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use this pool, you are tier {privilege}"
);
}
}
if (request.Size > pool.PolicyConfig.MaxFileSize)
{
return StatusCode(
403,
$"File size {request.Size} is larger than the pool's maximum file size {pool.PolicyConfig.MaxFileSize}"
);
}
var (ok, billableUnit, quota) = await qs.IsFileAcceptable(
accountId,
pool.BillingConfig.CostMultiplier ?? 1.0,
request.Size
);
if (!ok)
{
return StatusCode(
403,
$"File size {billableUnit} is larger than the user's quota {quota}"
);
}
await using var transaction = await db.Database.BeginTransactionAsync();
try
{
var file = new CloudFile
{
Name = request.Name,
Size = request.Size,
Hash = request.Hash,
MimeType = request.MimeType,
Description = request.Description,
AccountId = accountId,
UserMeta = request.UserMeta,
FileMeta = request.FileMeta,
SensitiveMarks = request.SensitiveMarks,
PoolId = request.PoolId
};
db.Files.Add(file);
await db.SaveChangesAsync();
await fs._PurgeCacheAsync(file.Id);
await transaction.CommitAsync();
file.FastUploadLink = await fs.CreateFastUploadLinkAsync(file);
return file;
}
catch (Exception)
{
await transaction.RollbackAsync();
throw;
}
}
}

View File

@@ -0,0 +1,60 @@
using System.Security.Cryptography;
namespace DysonNetwork.Drive.Storage;
public static class FileEncryptor
{
public static void EncryptFile(string inputPath, string outputPath, string password)
{
var salt = RandomNumberGenerator.GetBytes(16);
var key = DeriveKey(password, salt, 32);
var nonce = RandomNumberGenerator.GetBytes(12); // For AES-GCM
using var aes = new AesGcm(key, 16); // Specify 16-byte tag size explicitly
var plaintext = File.ReadAllBytes(inputPath);
var magic = "DYSON1"u8.ToArray();
var contentWithMagic = new byte[magic.Length + plaintext.Length];
Buffer.BlockCopy(magic, 0, contentWithMagic, 0, magic.Length);
Buffer.BlockCopy(plaintext, 0, contentWithMagic, magic.Length, plaintext.Length);
var ciphertext = new byte[contentWithMagic.Length];
var tag = new byte[16];
aes.Encrypt(nonce, contentWithMagic, ciphertext, tag);
// Save as: [salt (16)][nonce (12)][tag (16)][ciphertext]
using var fs = new FileStream(outputPath, FileMode.Create, FileAccess.Write);
fs.Write(salt);
fs.Write(nonce);
fs.Write(tag);
fs.Write(ciphertext);
}
public static void DecryptFile(string inputPath, string outputPath, string password)
{
var input = File.ReadAllBytes(inputPath);
var salt = input[..16];
var nonce = input[16..28];
var tag = input[28..44];
var ciphertext = input[44..];
var key = DeriveKey(password, salt, 32);
var decrypted = new byte[ciphertext.Length];
using var aes = new AesGcm(key, 16); // Specify 16-byte tag size explicitly
aes.Decrypt(nonce, ciphertext, tag, decrypted);
var magic = "DYSON1"u8.ToArray();
if (magic.Where((t, i) => decrypted[i] != t).Any())
throw new CryptographicException("Incorrect password or corrupted file.");
var plaintext = decrypted[magic.Length..];
File.WriteAllBytes(outputPath, plaintext);
}
private static byte[] DeriveKey(string password, byte[] salt, int keyBytes)
{
using var pbkdf2 = new Rfc2898DeriveBytes(password, salt, 100_000, HashAlgorithmName.SHA256);
return pbkdf2.GetBytes(keyBytes);
}
}

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