Compare commits

227 Commits

Author SHA1 Message Date
8e77b5bf2a 🎨 Updating gateway code formatting 2026-01-25 01:34:19 +08:00
db3a6ea9c5 Customizable gateway endpoints 2026-01-25 01:27:17 +08:00
b851b9f6e2 Shared project ring helper service 2026-01-24 10:56:29 +08:00
c58cbf8de9 🔨 Convert shared into a publishable package 2026-01-24 02:04:21 +08:00
fc2215ec63 🚚 Rename the DysonNetwork.Shared.Http module 2026-01-18 20:26:34 +08:00
a3c1d74501 Mini apps in develop 2026-01-18 01:57:24 +08:00
b7aac30384 🐛 Fix send notification api push notification twice 2026-01-15 01:24:30 +08:00
f217c0fb30 🐛 Fix some issues 2026-01-15 00:02:06 +08:00
c3304e0663 ♻️ Replace the impl of the reanalysis service 2026-01-14 23:50:15 +08:00
ca21acbff6 🐛 Ensure validation files will be updated 2026-01-14 23:38:32 +08:00
6488e2224e 🐛 Fix file index queries 2026-01-14 23:35:29 +08:00
fa81a0bbbb 🐛 Fix reanalysis query 2026-01-14 23:28:25 +08:00
9513a460d0 Improve the file validation speed 2026-01-14 23:09:17 +08:00
68d0881e34 🐛 Fix file reanalysis service didn't got configured 2026-01-14 22:19:43 +08:00
2daf8f5d77 Much larger batch in validation files 2026-01-14 19:43:43 +08:00
10e680ed07 Adjustable delay ms 2026-01-14 19:09:10 +08:00
0762eec540 File reanalysis progress logging 2026-01-14 19:02:16 +08:00
db5dcf19b2 Optimize reanalysis query 2026-01-14 01:37:40 +08:00
988d9695b3 Web feed verification 2026-01-14 01:33:03 +08:00
2401eed3ed 🐛 Fix the webfeed 2026-01-14 01:18:34 +08:00
0e1f13ae7d Improve file reanalysis speed 2026-01-14 01:04:35 +08:00
77440bd4b9 🎨 Optimize code structure in file reanalysis 2026-01-14 01:01:35 +08:00
625d4e1a13 🐛 Trying to fix reanalysis service 2026-01-14 00:50:55 +08:00
a4c2892a66 File reanalysis now validate the thumbnail / compressed 2026-01-14 00:06:28 +08:00
ab70816a07 🐛 Fix the usage counting 2026-01-13 23:53:35 +08:00
065b86403a 👔 Timeline shuffle post only present public posts only 2026-01-13 23:26:51 +08:00
fc1edf0ea3 ♻️ Update the usage counting since the pool id logic changed 2026-01-13 23:26:09 +08:00
5a99665e4e 👔 Exclude fediverse content in highlight post 2026-01-13 23:23:14 +08:00
28a13f7baf 👔 No longer create permission for owner 2026-01-13 22:51:13 +08:00
1173f98ec6 🐛 Fixes some issues in file controller 2026-01-13 19:36:18 +08:00
979e0cb3f2 👔 Adjust set file permission calls 2026-01-13 19:30:43 +08:00
69736f0850 🐛 Fix issues in getting file 2026-01-13 13:09:37 +08:00
39bf967186 Improve reanalysis service retries and query 2026-01-13 01:46:43 +08:00
5fdaa2c7f8 🐛 Improvements of file reanalysis service 2026-01-13 01:36:22 +08:00
03010b9151 👔 Mark the minio reanalysis file as deleted 2026-01-13 01:27:21 +08:00
1fb4b61e51 Improved the file reanalysis service 2026-01-13 01:19:59 +08:00
8e39004f68 👔 Remove account from file object 2026-01-13 01:08:20 +08:00
4b7740e606 Drive reanalysis service 2026-01-13 01:06:47 +08:00
0f74ed61fd 🐛 Fix file migration 2026-01-13 00:56:53 +08:00
2d1e43b02e 🐛 Trying to fix file migration again 2026-01-13 00:50:37 +08:00
dadf3c67bf 🐛 Trying to fix the file migration service 2026-01-13 00:39:52 +08:00
2e945ee477 👔 Change the file migration service to use fallback mode 2026-01-13 00:22:14 +08:00
0feb66e341 🐛 Fix file migration 2026-01-12 23:55:57 +08:00
87d9267285 🐛 Fix issues cause by new data structure between services of drive 2026-01-12 23:45:32 +08:00
c11bf579c4 🐛 Fix missing file permission creation 2026-01-12 19:32:50 +08:00
7085f43e54 Rollback file perm check for now 2026-01-12 12:41:55 +08:00
ed7d54c47a ♻️ Simplified publisher subscription 2026-01-12 00:52:48 +08:00
bd41568578 Drive file permission in real action 2026-01-12 00:48:09 +08:00
c052f17623 🐛 Fix dozens of issues in new drive system 2026-01-12 00:04:29 +08:00
6d5303f99c 🗑️ Clean up the cloud file table unused fields 2026-01-11 23:58:37 +08:00
de1175bdc7 🗑️ Clean up cloud files 2026-01-11 23:24:49 +08:00
b03e9bea5e ♻️ Completely remove the upload task 2026-01-10 23:44:51 +08:00
f3779cc788 🗑️ Remove the unused reference system 2026-01-10 22:55:05 +08:00
1aff1d7731 🐛 Fix some bugs that introduced in previous changes 2026-01-10 22:32:16 +08:00
98c100c864 ⚗️ Testing out the File Storing System v2 2026-01-10 16:54:22 +08:00
8177bda232 ♻️ File organize system v2 2026-01-10 14:34:53 +08:00
cb04e53b7e 💄 Optimize the post service 2026-01-08 23:12:01 +08:00
c16add5dfe 👔 Now regular relationship listing method will not giving requests 2026-01-07 01:32:24 +08:00
1fc9c68d80 👔 Update the requests endpoint to show both sent / received friend requests 2026-01-07 01:29:44 +08:00
cf736be61a 🐛 Fix some relationship issues 2026-01-07 01:04:11 +08:00
cc7992ead9 Able to inspect relationship status with admin permission 2026-01-06 22:57:54 +08:00
7a0ba166dc Delete relationship 2026-01-06 22:46:21 +08:00
0cc6f86f3b ♻️ Remove the discovery service in the Sphere. 2026-01-06 22:32:02 +08:00
6b592156c9 Support notification controller get content without marking as read 2026-01-04 00:21:25 +08:00
6fd77c5c31 Rollback post metadata field 2026-01-03 13:49:33 +08:00
94d91ec8b2 💄 Optimize create activitypub delivery 2026-01-03 01:48:45 +08:00
82b517ed2c 💄 Optimize Like activity delivery 2026-01-03 01:43:16 +08:00
1596897a5b 🐛 Fix insight register grpc service twice 2026-01-02 21:33:33 +08:00
9ecc64352c 🐛 Fix insight 2026-01-02 20:32:45 +08:00
9c75394aa6 🐛 Fix sticker service didn't in the DI 2026-01-02 18:16:18 +08:00
d036443a36 🐛 Ignore a random cross project entitiy somehow 2026-01-02 16:10:40 +08:00
6c4358a4ce 🗃️ Add web feed migration to insight 2026-01-02 15:56:28 +08:00
eeb583d78d ♻️ Better event broadcast status changes 2026-01-02 15:08:02 +08:00
c588b6f234 Truncate to plain text for html content as well 2026-01-02 14:14:27 +08:00
306934304e 💄 Improvments in the activitypub 2026-01-02 13:56:37 +08:00
24b1f24dea ♻️ Remove chat message in app database 2026-01-02 02:00:04 +08:00
913a6e7382 Optimize permission check in fediverse publishers 2026-01-02 01:37:17 +08:00
b90d1be552 🐛 Fix sphere 2026-01-02 01:36:04 +08:00
07b8c99682 ♻️ Move the web reader to insight completely 2026-01-02 01:23:45 +08:00
ede49333f8 ♻️ Move the web reader from Sphere to Insight (w.i.p) 2026-01-02 00:15:56 +08:00
c4b2b2f61f 💥 Change websocket service identifier for messager 2026-01-01 23:55:09 +08:00
501fce894e 🐛 Fix messager missing service component 2026-01-01 23:51:11 +08:00
aa85a28d04 🐛 Rollback some changes 2026-01-01 23:46:17 +08:00
c515ddff51 🐛 Fix messager do not have drive service in DI 2026-01-01 23:43:11 +08:00
b8686bd7e3 🗃️ Remove the unused upload tasks 2026-01-01 23:38:21 +08:00
50a3c2d038 Rollback some changes in drive 2026-01-01 23:37:51 +08:00
683fbf1a68 Doing some dangerous experiements to optimize memory usage 2026-01-01 23:14:17 +08:00
b3633538cd 🔨 Fix gha script 2026-01-01 22:51:18 +08:00
6212820d74 🔨 Add the Messager to the service grid 2026-01-01 22:28:59 +08:00
ab37bbc7b0 ♻️ Move the chat part of the Sphere service to the Messager service 2026-01-01 22:09:08 +08:00
c503083df7 🎉 Initialize the DysonNetwork.Messager service 2026-01-01 15:06:49 +08:00
466a52ecd9 🗃️ Merge migrations in sphere 2026-01-01 14:05:21 +08:00
c28d7bedd7 🐛 Use new isRelated to query relationship to avoid issues 2026-01-01 13:53:48 +08:00
8c19bd6a73 🧱 Relationship query supports isRelated 2026-01-01 13:50:55 +08:00
7c5c92a501 Activitypub supports reply and repost 2026-01-01 13:37:29 +08:00
fb15930611 Better ap object builder in post 2026-01-01 11:25:19 +08:00
a72dbcfc0c ♻️ Re-built like activity sending 2026-01-01 11:09:25 +08:00
923ec0a157 🐛 Fix actor should include instance 2026-01-01 02:36:47 +08:00
a0103ada64 🐛 Fix post controller didn't incldue the actor data 2026-01-01 02:32:05 +08:00
de3aa21909 :drunk: Optimize code of publisher actor 2026-01-01 02:15:49 +08:00
247296476c 🐛 Fix some issues in activitypub 2026-01-01 01:45:35 +08:00
a795ff6db8 Able to filter out the fediverse posts 2026-01-01 01:30:42 +08:00
d07e33cb75 🐛 Fix accept follow request in AP 2026-01-01 01:28:29 +08:00
78f1d0ecd3 🐛 Trying to fix activity handler 2026-01-01 01:26:14 +08:00
14d5254461 ♻️ Better delivery service for AP 2026-01-01 01:20:44 +08:00
c59fc011f4 🐛 Fix post service actor missing instance 2026-01-01 00:29:53 +08:00
3e59a102af ♻️ Refactor the pub delivery service 2026-01-01 00:27:09 +08:00
42b46243a4 Query params in the timeline controller to control fediverse content 2026-01-01 00:10:00 +08:00
3c83fdfc4d 🐛 Fix activitypub nested object issue 2026-01-01 00:03:38 +08:00
62a8153479 🔊 Add detailed activitypub logging 2025-12-31 23:47:42 +08:00
0ec49787fb 🐛 Fix outbox issue in activitypub 2025-12-31 23:46:25 +08:00
f49f17a9db 🔊 Add more proper logging for ap failure 2025-12-31 23:06:29 +08:00
c11b30d0bb ♻️ Refactor the follow activitypub process 2025-12-31 22:52:31 +08:00
add9fa49e5 🔊 Proper error handling in activitypub inbox 2025-12-31 22:42:57 +08:00
4815d31b31 ♻️ Update handling logic of activitypub 2025-12-31 22:31:50 +08:00
91764593c7 🐛 Fix outbox in activitypub 2025-12-31 19:03:13 +08:00
ebd7539c95 🐛 Fix the wrong asset base url in ap controller 2025-12-31 18:59:24 +08:00
6c8c52e3b2 👔 Web finger now only serve opt-in fediverse publishers 2025-12-31 18:57:25 +08:00
413ae80c96 🚚 Rename activitypub processor to handler 2025-12-31 18:33:49 +08:00
3da6de1feb 👔 Auto accept follows from activitypub 2025-12-31 18:32:37 +08:00
caf5468dad ♻️ Refactored fediverse relationships 2025-12-31 18:29:35 +08:00
2b6cf503a5 ♻️ Update unfollow 2025-12-31 13:10:42 +08:00
7991f88df5 🐛 Fix wrong usage of assets base url 2025-12-31 01:57:49 +08:00
a005cfb143 🐛 Fix wrong db query include 2025-12-31 01:51:24 +08:00
a11544c056 🐛 Fix get wrong followers 2025-12-31 01:47:25 +08:00
cd8e6714b2 🐛 Fix wrong db query 2025-12-31 01:37:28 +08:00
eb8d126261 🐛 Fix some issues in AP 2025-12-31 01:31:06 +08:00
71031e2222 🐛 Adjusted delivery of actor update 2025-12-31 01:21:47 +08:00
cb37edc0bb ♻️ Proper unfollow activity delivery 2025-12-31 01:18:38 +08:00
b9230699c5 Updated follow APIs for better usability 2025-12-31 00:58:13 +08:00
f2856c10a3 Add like event activitypub 2025-12-31 00:39:47 +08:00
0519f2a2e6 Post activitypub, and publisher update events 2025-12-31 00:24:08 +08:00
6aa6833163 ♻️ Refactor activitypub content storage 2025-12-30 23:37:57 +08:00
8dc01c8a85 🐛 Got key id instead of actor uri 2025-12-30 21:52:21 +08:00
67cd372b8d ♻️ Adjust in key lookup in verification 2025-12-30 21:30:25 +08:00
cfb4428e78 🐛 Fix some issues in the singature verification 2025-12-30 19:39:39 +08:00
1d95d637dd 🐛 Fix verify signature generating wrongly 2025-12-30 19:27:16 +08:00
f42fc1da1c ♻️ Update verify signature code in ap 2025-12-30 19:17:22 +08:00
30cbbf0139 🐛 Ignore content-type in incoming signing 2025-12-30 13:06:50 +08:00
d02edbd38d 🐛 Fix verify signing string failed due to gateway changed the host 2025-12-30 12:48:53 +08:00
10067f6141 🐛 Fix wrong inbox method 2025-12-30 01:52:21 +08:00
6a360fe697 ♻️ Move the keys store out of the publisher meta 2025-12-30 01:44:05 +08:00
777c0c089a 🐛 Servarl bug fixes 2025-12-30 01:35:24 +08:00
6fdf34787d ♻️ Improve the code in activitypub and webfinger 2025-12-30 00:53:19 +08:00
72b0739f41 ♻️ Better local actor 2025-12-30 00:31:09 +08:00
f556313f1d 🐛 Fix random error message: Cannot load library libgssapi_krb5.so.2 on startup 2025-12-30 00:21:29 +08:00
7fd75395f8 🐛 Fix activitypub public key generation 2025-12-30 00:13:04 +08:00
70260967be 🐛 Better override host 2025-12-30 00:08:04 +08:00
db94b21aef 🐛 Fix build issue in code 2025-12-29 23:31:10 +08:00
d8d94d0aec 🐛 Fix signature in AP again... 2025-12-29 23:28:49 +08:00
e7bf760888 🐛 Exclude content type from Ap signing 2025-12-29 23:02:15 +08:00
7f5b447b3c 🐛 Fix keypair inconsistence 2025-12-29 22:44:41 +08:00
84da11f301 🐛 Fix signature issue in activitypub outgoing process
🔊 Add more logging during activitypub process for debug
2025-12-29 22:31:24 +08:00
05a02046a9 Implmentations of activitypub missing features 2025-12-29 20:13:19 +08:00
ce20c5980b 🐛 Activitypub request didn't sign 2025-12-29 19:47:16 +08:00
bb71c558b1 🐛 Fix activitypub route in gateway 2025-12-29 19:28:13 +08:00
b76f614975 🐛 Fix actor search include localhost 2025-12-29 19:24:04 +08:00
fc89b46f98 🐛 Fix ap request sent failed 2025-12-29 02:02:58 +08:00
39587ed346 🐛 Fix activitypub bugs 2025-12-29 01:49:35 +08:00
f83327474e 🗃️ Fix migration didn't generate properly for enrich fediverse instance data 2025-12-29 01:32:55 +08:00
0961325642 Instance metadata fetch supports misskey 2025-12-29 01:29:46 +08:00
a63d21ed06 Enrich instance metadata fetching (for mastodon only now) 2025-12-29 01:26:07 +08:00
7b09e63918 Actor data will include instance data 2025-12-29 01:14:00 +08:00
cda48ea18d 🐛 Fix activitypub controller authorization issue 2025-12-29 01:09:57 +08:00
7cb471e978 Save uncategorized actor data in metadata 2025-12-29 00:51:19 +08:00
44a791db1f 🐛 Fix wrong implmentation in webfinger and actor uri 2025-12-29 00:23:49 +08:00
6cba70ee12 Activity pub also support XML webfinger 2025-12-29 00:08:16 +08:00
ceadb5ad9b 🐛 Fix web finger parsing 2025-12-28 23:45:09 +08:00
2e8a1d05a1 Better activitypub search user 2025-12-28 23:31:10 +08:00
df077b347e Gateway provide special routes for the ap 2025-12-28 22:49:52 +08:00
21108c19a9 📝 Sort docs 2025-12-28 22:24:22 +08:00
95472df02b ActivityPub actions 2025-12-28 22:18:50 +08:00
9f4a7a3fe8 🔨 Add some tests utilities of activity pub 2025-12-28 18:30:25 +08:00
2471fa2e75 ⚗️ Activity pub 2025-12-28 18:08:35 +08:00
f06d93a348 👔 Update post truncate logic 2025-12-27 23:03:26 +08:00
983f57c4c2 Rollback post truncate logic 2025-12-27 22:56:49 +08:00
00cd7ad2d8 🐛 Fix developer permission check, close #9 2025-12-27 22:53:17 +08:00
2bffbf18a3 🐛 Fix Sphere Rewind 2025-12-27 20:49:33 +08:00
07445ebc25 🗑️ Remove the gift redeemed notification 2025-12-27 20:43:40 +08:00
f83fb5d8a9 Better truncate service, close #7 2025-12-27 16:41:54 +08:00
a3e13d1581 🐛 Fix error caused by EF BulkOperations rc by removing it. 2025-12-27 16:19:14 +08:00
677d9761f9 🐛 Fix sphere swagger docs 2025-12-27 16:08:00 +08:00
23c435e036 🐛 Fix first generated rewind didn't have account data 2025-12-27 15:57:28 +08:00
fc61235d0c Support access with custom endpoint 2025-12-27 15:50:26 +08:00
6e1b67609a 🐛 Fix bugs related to new depedecies versions 2025-12-27 15:38:44 +08:00
4be054163b ⬆️ Upgrade dependecies 2025-12-27 15:30:31 +08:00
009f66154c 👔 Improve sphere word cloud again... 2025-12-27 15:10:01 +08:00
5d3bd1144d The rewind point now brings account data 2025-12-27 14:48:32 +08:00
334fa9b9a7 🗃️ Sharable rewind point migration 2025-12-27 14:25:08 +08:00
bb5d70eddb Sharable rewind point 2025-12-27 14:24:35 +08:00
b51a086031 👔 Adjust sphere segmenter again 2025-12-27 13:55:47 +08:00
27afe5da9f 👔 Optimize spehre rewind segmenter 2025-12-27 13:15:25 +08:00
9d1bc46bf1 👔 Update jieba config for cutting words 2025-12-27 01:54:49 +08:00
be176ef0c2 🐛 Fix sphere rewind 2025-12-27 01:45:37 +08:00
93e7b04e74 🐛 Update dict path for jieba 2025-12-27 01:37:26 +08:00
d9f10fd598 Word cloud in rewind 2025-12-27 01:26:56 +08:00
50518351bc Improvements, new data in rewind point
🐛 Fix most called rewind point unable to get real data
2025-12-27 01:19:24 +08:00
4443da5660 🐛 Fix sphere rewind 2025-12-26 01:16:29 +08:00
b193224a2c Add more data to rewind 2025-12-26 01:08:10 +08:00
4e9c5733d1 🐛 Fix sphere rewind InvalidOperationException 2025-12-25 23:31:29 +08:00
1c6b324b0d 🐛 Fix http client didn't ignore CA 2025-12-25 23:20:23 +08:00
ded3a70cb7 🐛 Fix rewind date issue 2025-12-25 23:14:40 +08:00
9e54b61eee 🐛 Fix account rewind service using wrong endpoint to call other services 2025-12-25 22:58:52 +08:00
43d89299c3 🗃️ Add rewind point migration 2025-12-25 22:51:59 +08:00
1af11b2a99 Account rewind controller 2025-12-25 22:47:01 +08:00
1a31d7cbe7 Chats in sphere rewind 2025-12-25 22:41:39 +08:00
f0d6772dca Pass rewind service and account rewind service 2025-12-25 22:26:57 +08:00
24836fc606 Rewind service basis and sphere service rewind 2025-12-25 21:36:26 +08:00
0bc77b948c 🚚 Rename the Stream to Queue in internal code 2025-12-25 19:11:39 +08:00
f792d43ab9 Fortune saying API 2025-12-24 23:51:48 +08:00
1b45be225a Notable days improvement (global days, recent days) 2025-12-24 23:32:14 +08:00
7811545726 💥 Update server readiness header 2025-12-24 23:07:57 +08:00
213608d4f0 Gateway readiness check 2025-12-24 22:09:03 +08:00
bca6a2ffde 🐛 Fix list members of chat and realm didn't show invite 2025-12-24 21:38:55 +08:00
885b895a3a 🐛 Fix DM permission check 2025-12-24 13:12:46 +08:00
08941a282b 🚚 Move the categories subscription listing API path 2025-12-24 00:10:07 +08:00
4fd455acbf 🐛 Fix the relationship listing order 2025-12-23 23:57:51 +08:00
5ff1539f18 🐛 Trying to fix the wrong relationship fetch 2025-12-23 23:56:24 +08:00
3c023a71b1 Subscription listing pagination & categories one 2025-12-23 23:34:14 +08:00
49d8eaa7b2 💥 Updated subscription API 2025-12-22 23:59:53 +08:00
16a37549fe 🐛 Fix publisher subscription status didn't include publisher 2025-12-22 23:40:09 +08:00
2aff62c64f 🐛 Fix chat room missing realms info 2025-12-21 22:39:59 +08:00
a49d485943 🚚 Use capitalized connection strings 2025-12-21 20:12:53 +08:00
4c65602465 ♻️ Update service discovery settings 2025-12-21 20:00:06 +08:00
4242953969 ♻️ Re-create the migrations for the Pass 2025-12-14 17:31:21 +08:00
c9530ac8b5 🚚 Rename GeoIP service 2025-12-14 03:19:08 +08:00
4ba7d38d78 📝 Update README 2025-12-14 03:14:40 +08:00
397 changed files with 35028 additions and 46635 deletions

43
.env.testing.example Normal file
View File

@@ -0,0 +1,43 @@
# ActivityPub Testing Environment Variables
# Solar Network Configuration
SOLAR_DOMAIN=solar.local
SOLAR_PORT=5000
SOLAR_URL=http://solar.local:5000
# Mastodon (Self-Hosted Test Instance)
MASTODON_DOMAIN=mastodon.local
MASTODON_PORT=3001
MASTODON_STREAMING_PORT=4000
MASTODON_URL=http://mastodon.local:3001
# Database
DB_CONNECTION_STRING=Host=localhost;Port=5432;Database=dyson_network;Username=postgres;Password=postgres
# Test Accounts
SOLAR_TEST_USERNAME=solaruser
MASTODON_TEST_USERNAME=testuser
MASTODON_TEST_PASSWORD=TestPassword123!
# ActivityPub Settings
ACTIVITYPUB_DOMAIN=solar.local
ACTIVITYPUB_ENABLE_FEDERATION=true
ACTIVITYPUB_SIGNATURE_ALGORITHM=rsa-sha256
# HTTP Settings
HTTP_TIMEOUT=30
HTTP_MAX_RETRIES=3
# Logging
LOG_LEVEL=Debug
ACTIVITYPUB_LOG_LEVEL=Trace
# Testing
TEST_SKIP_DATABASE_RESET=false
TEST_SKIP_MASTODON_SETUP=false
TEST_AUTO_ACCEPT_FOLLOWS=false
# Development (only in dev environment)
DEV_DISABLE_SIGNATURE_VERIFICATION=false
DEV_LOG_HTTP_BODIES=false
DEV_DISABLE_CORS=false

View File

@@ -27,8 +27,8 @@ jobs:
run: |
files="${{ steps.changed-files.outputs.files }}"
matrix="{\"include\":[]}"
services=("Sphere" "Pass" "Ring" "Drive" "Develop" "Gateway" "Insight" "Zone")
images=("sphere" "pass" "ring" "drive" "develop" "gateway" "insight" "zone")
services=("Sphere" "Pass" "Ring" "Drive" "Develop" "Gateway" "Insight" "Zone" "Messager")
images=("sphere" "pass" "ring" "drive" "develop" "gateway" "insight" "zone" "messager")
changed_services=()
for file in $files; do

View File

@@ -1,613 +0,0 @@
# Wallet Funds API Documentation
## Overview
The Wallet Funds API provides red packet functionality for the DysonNetwork platform, allowing users to create and distribute funds among multiple recipients with expiration and claiming mechanisms.
## Authentication
All endpoints require Bearer token authentication:
```
Authorization: Bearer {jwt_token}
```
## Data Types
### Enums
#### FundSplitType
```typescript
enum FundSplitType {
Even = 0, // Equal distribution
Random = 1 // Lucky draw distribution
}
```
#### FundStatus
```typescript
enum FundStatus {
Created = 0, // Fund created, waiting for claims
PartiallyReceived = 1, // Some recipients claimed
FullyReceived = 2, // All recipients claimed
Expired = 3, // Fund expired, unclaimed amounts refunded
Refunded = 4 // Legacy status
}
```
### Request/Response Models
#### CreateFundRequest
```typescript
interface CreateFundRequest {
recipientAccountIds: string[]; // UUIDs of recipients
currency: string; // e.g., "points", "golds"
totalAmount: number; // Total amount to distribute
splitType: FundSplitType; // Even or Random
message?: string; // Optional message
expirationHours?: number; // Optional: hours until expiration (default: 24)
pinCode: string; // Required: 6-digit PIN code for security
}
```
#### SnWalletFund
```typescript
interface SnWalletFund {
id: string; // UUID
currency: string;
totalAmount: number;
splitType: FundSplitType;
status: FundStatus;
message?: string;
creatorAccountId: string; // UUID
creatorAccount: SnAccount; // Creator account details (includes profile)
recipients: SnWalletFundRecipient[];
expiredAt: string; // ISO 8601 timestamp
createdAt: string; // ISO 8601 timestamp
updatedAt: string; // ISO 8601 timestamp
}
```
#### SnWalletFundRecipient
```typescript
interface SnWalletFundRecipient {
id: string; // UUID
fundId: string; // UUID
recipientAccountId: string; // UUID
recipientAccount: SnAccount; // Recipient account details (includes profile)
amount: number; // Allocated amount
isReceived: boolean;
receivedAt?: string; // ISO 8601 timestamp (if claimed)
createdAt: string; // ISO 8601 timestamp
updatedAt: string; // ISO 8601 timestamp
}
```
#### SnWalletTransaction
```typescript
interface SnWalletTransaction {
id: string; // UUID
payerWalletId?: string; // UUID (null for system transfers)
payeeWalletId?: string; // UUID (null for system transfers)
currency: string;
amount: number;
remarks?: string;
type: TransactionType;
createdAt: string; // ISO 8601 timestamp
updatedAt: string; // ISO 8601 timestamp
}
```
#### Error Response
```typescript
interface ErrorResponse {
type: string; // Error type
title: string; // Error title
status: number; // HTTP status code
detail: string; // Error details
instance?: string; // Request instance
}
```
## API Endpoints
### 1. Create Fund
Creates a new fund (red packet) for distribution among recipients.
**Endpoint:** `POST /api/wallets/funds`
**Request Body:** `CreateFundRequest`
**Response:** `SnWalletFund` (201 Created)
**Example Request:**
```bash
curl -X POST "/api/wallets/funds" \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{
"recipientAccountIds": [
"550e8400-e29b-41d4-a716-446655440000",
"550e8400-e29b-41d4-a716-446655440001",
"550e8400-e29b-41d4-a716-446655440002"
],
"currency": "points",
"totalAmount": 100.00,
"splitType": "Even",
"message": "Happy New Year! 🎉",
"expirationHours": 48,
"pinCode": "123456"
}'
```
**Example Response:**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440003",
"currency": "points",
"totalAmount": 100.00,
"splitType": 0,
"status": 0,
"message": "Happy New Year! 🎉",
"creatorAccountId": "550e8400-e29b-41d4-a716-446655440004",
"creatorAccount": {
"id": "550e8400-e29b-41d4-a716-446655440004",
"username": "creator_user"
},
"recipients": [
{
"id": "550e8400-e29b-41d4-a716-446655440005",
"fundId": "550e8400-e29b-41d4-a716-446655440003",
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440000",
"amount": 33.34,
"isReceived": false,
"createdAt": "2025-10-03T22:00:00Z",
"updatedAt": "2025-10-03T22:00:00Z"
},
{
"id": "550e8400-e29b-41d4-a716-446655440006",
"fundId": "550e8400-e29b-41d4-a716-446655440003",
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440001",
"amount": 33.33,
"isReceived": false,
"createdAt": "2025-10-03T22:00:00Z",
"updatedAt": "2025-10-03T22:00:00Z"
},
{
"id": "550e8400-e29b-41d4-a716-446655440007",
"fundId": "550e8400-e29b-41d4-a716-446655440003",
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440002",
"amount": 33.33,
"isReceived": false,
"createdAt": "2025-10-03T22:00:00Z",
"updatedAt": "2025-10-03T22:00:00Z"
}
],
"expiredAt": "2025-10-05T22:00:00Z",
"createdAt": "2025-10-03T22:00:00Z",
"updatedAt": "2025-10-03T22:00:00Z"
}
```
**Error Responses:**
- `400 Bad Request`: Invalid parameters, insufficient funds, invalid recipients
- `401 Unauthorized`: Missing or invalid authentication
- `403 Forbidden`: Invalid PIN code
- `422 Unprocessable Entity`: Business logic violations
---
### 2. Get Funds
Retrieves funds that the authenticated user is involved in (as creator or recipient).
**Endpoint:** `GET /api/wallets/funds`
**Query Parameters:**
- `offset` (number, optional): Pagination offset (default: 0)
- `take` (number, optional): Number of items to return (default: 20, max: 100)
- `status` (FundStatus, optional): Filter by fund status
**Response:** `SnWalletFund[]` (200 OK)
**Headers:**
- `X-Total`: Total number of funds matching the criteria
**Example Request:**
```bash
curl -X GET "/api/wallets/funds?offset=0&take=10&status=0" \
-H "Authorization: Bearer {token}"
```
**Example Response:**
```json
[
{
"id": "550e8400-e29b-41d4-a716-446655440003",
"currency": "points",
"totalAmount": 100.00,
"splitType": 0,
"status": 0,
"message": "Happy New Year! 🎉",
"creatorAccountId": "550e8400-e29b-41d4-a716-446655440004",
"creatorAccount": {
"id": "550e8400-e29b-41d4-a716-446655440004",
"username": "creator_user"
},
"recipients": [
{
"id": "550e8400-e29b-41d4-a716-446655440005",
"fundId": "550e8400-e29b-41d4-a716-446655440003",
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440000",
"amount": 33.34,
"isReceived": false
}
],
"expiredAt": "2025-10-05T22:00:00Z",
"createdAt": "2025-10-03T22:00:00Z",
"updatedAt": "2025-10-03T22:00:00Z"
}
]
```
**Error Responses:**
- `401 Unauthorized`: Missing or invalid authentication
---
### 3. Get Fund
Retrieves details of a specific fund.
**Endpoint:** `GET /api/wallets/funds/{id}`
**Path Parameters:**
- `id` (string): Fund UUID
**Response:** `SnWalletFund` (200 OK)
**Example Request:**
```bash
curl -X GET "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003" \
-H "Authorization: Bearer {token}"
```
**Example Response:** (Same as create fund response)
**Error Responses:**
- `401 Unauthorized`: Missing or invalid authentication
- `403 Forbidden`: User doesn't have permission to view this fund
- `404 Not Found`: Fund not found
---
### 4. Receive Fund
Claims the authenticated user's portion of a fund.
**Endpoint:** `POST /api/wallets/funds/{id}/receive`
**Path Parameters:**
- `id` (string): Fund UUID
**Response:** `SnWalletTransaction` (200 OK)
**Example Request:**
```bash
curl -X POST "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive" \
-H "Authorization: Bearer {token}"
```
**Example Response:**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440008",
"payerWalletId": null,
"payeeWalletId": "550e8400-e29b-41d4-a716-446655440009",
"currency": "points",
"amount": 33.34,
"remarks": "Received fund portion from 550e8400-e29b-41d4-a716-446655440004",
"type": 1,
"createdAt": "2025-10-03T22:05:00Z",
"updatedAt": "2025-10-03T22:05:00Z"
}
```
**Error Responses:**
- `400 Bad Request`: Fund expired, already claimed, not a recipient
- `401 Unauthorized`: Missing or invalid authentication
- `404 Not Found`: Fund not found
---
### 5. Get Wallet Overview
Retrieves a summarized overview of wallet transactions grouped by type for graphing/charting purposes.
**Endpoint:** `GET /api/wallets/overview`
**Query Parameters:**
- `startDate` (string, optional): Start date in ISO 8601 format (e.g., "2025-01-01T00:00:00Z")
- `endDate` (string, optional): End date in ISO 8601 format (e.g., "2025-12-31T23:59:59Z")
**Response:** `WalletOverview` (200 OK)
**Example Request:**
```bash
curl -X GET "/api/wallets/overview?startDate=2025-01-01T00:00:00Z&endDate=2025-12-31T23:59:59Z" \
-H "Authorization: Bearer {token}"
```
**Example Response:**
```json
{
"accountId": "550e8400-e29b-41d4-a716-446655440000",
"startDate": "2025-01-01T00:00:00.0000000Z",
"endDate": "2025-12-31T23:59:59.0000000Z",
"summary": {
"System": {
"type": "System",
"currencies": {
"points": {
"currency": "points",
"income": 150.00,
"spending": 0.00,
"net": 150.00
}
}
},
"Transfer": {
"type": "Transfer",
"currencies": {
"points": {
"currency": "points",
"income": 25.00,
"spending": 75.00,
"net": -50.00
},
"golds": {
"currency": "golds",
"income": 0.00,
"spending": 10.00,
"net": -10.00
}
}
},
"Order": {
"type": "Order",
"currencies": {
"points": {
"currency": "points",
"income": 0.00,
"spending": 200.00,
"net": -200.00
}
}
}
},
"totalIncome": 175.00,
"totalSpending": 285.00,
"netTotal": -110.00
}
```
**Response Fields:**
- `accountId`: User's account UUID
- `startDate`/`endDate`: Date range applied (ISO 8601 format)
- `summary`: Object keyed by transaction type
- `type`: Transaction type name
- `currencies`: Object keyed by currency code
- `currency`: Currency name
- `income`: Total money received
- `spending`: Total money spent
- `net`: Income minus spending
- `totalIncome`: Sum of all income across all types/currencies
- `totalSpending`: Sum of all spending across all types/currencies
- `netTotal`: Overall net (totalIncome - totalSpending)
**Error Responses:**
- `401 Unauthorized`: Missing or invalid authentication
## Error Codes
### Common Error Types
#### Validation Errors
```json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Bad Request",
"status": 400,
"detail": "At least one recipient is required",
"instance": "/api/wallets/funds"
}
```
#### Insufficient Funds
```json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Bad Request",
"status": 400,
"detail": "Insufficient funds",
"instance": "/api/wallets/funds"
}
```
#### Fund Not Available
```json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Bad Request",
"status": 400,
"detail": "Fund is no longer available",
"instance": "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive"
}
```
#### Already Claimed
```json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Bad Request",
"status": 400,
"detail": "You have already received this fund",
"instance": "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive"
}
```
## Rate Limiting
- **Create Fund**: 10 requests per minute per user
- **Get Funds**: 60 requests per minute per user
- **Get Fund**: 60 requests per minute per user
- **Receive Fund**: 30 requests per minute per user
## Webhooks/Notifications
The system integrates with the platform's notification system:
- **Fund Created**: Creator receives confirmation
- **Fund Claimed**: Creator receives notification when someone claims
- **Fund Expired**: Creator receives refund notification
## SDK Examples
### JavaScript/TypeScript
```typescript
// Create a fund
const createFund = async (fundData: CreateFundRequest): Promise<SnWalletFund> => {
const response = await fetch('/api/wallets/funds', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(fundData)
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
};
// Get user's funds
const getFunds = async (params?: {
offset?: number;
take?: number;
status?: FundStatus;
}): Promise<SnWalletFund[]> => {
const queryParams = new URLSearchParams();
if (params?.offset) queryParams.set('offset', params.offset.toString());
if (params?.take) queryParams.set('take', params.take.toString());
if (params?.status !== undefined) queryParams.set('status', params.status.toString());
const response = await fetch(`/api/wallets/funds?${queryParams}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
};
// Claim a fund
const receiveFund = async (fundId: string): Promise<SnWalletTransaction> => {
const response = await fetch(`/api/wallets/funds/${fundId}/receive`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
};
```
### Python
```python
import requests
from typing import List, Optional
from enum import Enum
class FundSplitType(Enum):
EVEN = 0
RANDOM = 1
class FundStatus(Enum):
CREATED = 0
PARTIALLY_RECEIVED = 1
FULLY_RECEIVED = 2
EXPIRED = 3
REFUNDED = 4
def create_fund(token: str, fund_data: dict) -> dict:
"""Create a new fund"""
response = requests.post(
'/api/wallets/funds',
json=fund_data,
headers={
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json'
}
)
response.raise_for_status()
return response.json()
def get_funds(
token: str,
offset: int = 0,
take: int = 20,
status: Optional[FundStatus] = None
) -> List[dict]:
"""Get user's funds"""
params = {'offset': offset, 'take': take}
if status is not None:
params['status'] = status.value
response = requests.get(
'/api/wallets/funds',
params=params,
headers={'Authorization': f'Bearer {token}'}
)
response.raise_for_status()
return response.json()
def receive_fund(token: str, fund_id: str) -> dict:
"""Claim a fund portion"""
response = requests.post(
f'/api/wallets/funds/{fund_id}/receive',
headers={'Authorization': f'Bearer {token}'}
)
response.raise_for_status()
return response.json()
```
## Changelog
### Version 1.0.0
- Initial release with basic red packet functionality
- Support for even and random split types
- 24-hour expiration with automatic refunds
- RESTful API endpoints
- Comprehensive error handling
## Support
For API support or questions:
- Check the main documentation at `README_WALLET_FUNDS.md`
- Review error messages for specific guidance
- Contact the development team for technical issues

View File

@@ -4,8 +4,8 @@ var builder = DistributedApplication.CreateBuilder(args);
var isDev = builder.Environment.IsDevelopment();
var cache = builder.AddRedis("cache");
var queue = builder.AddNats("queue").WithJetStream();
var cache = builder.AddRedis("Cache");
var queue = builder.AddNats("Queue").WithJetStream();
var ringService = builder.AddProject<Projects.DysonNetwork_Ring>("ring");
var passService = builder.AddProject<Projects.DysonNetwork_Pass>("pass")
@@ -32,11 +32,26 @@ var zoneService = builder.AddProject<Projects.DysonNetwork_Zone>("zone")
.WithReference(sphereService)
.WithReference(developService)
.WithReference(insightService);
var messagerService = builder.AddProject<Projects.DysonNetwork_Messager>("messager")
.WithReference(passService)
.WithReference(ringService)
.WithReference(sphereService)
.WithReference(developService)
.WithReference(driveService);
passService.WithReference(developService).WithReference(driveService);
List<IResourceBuilder<ProjectResource>> services =
[ringService, passService, driveService, sphereService, developService, insightService, zoneService];
[
ringService,
passService,
driveService,
sphereService,
developService,
insightService,
zoneService,
messagerService
];
for (var idx = 0; idx < services.Count; idx++)
{

View File

@@ -1,5 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="13.0.0"/>
<Sdk Name="Aspire.AppHost.Sdk" Version="13.1.0"/>
<PropertyGroup>
<OutputType>Exe</OutputType>
@@ -11,10 +11,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.0.0"/>
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.1.0" />
<PackageReference Include="Aspire.Hosting.Docker" Version="13.0.0-preview.1.25560.3"/>
<PackageReference Include="Aspire.Hosting.Nats" Version="13.0.0"/>
<PackageReference Include="Aspire.Hosting.Redis" Version="13.0.0"/>
<PackageReference Include="Aspire.Hosting.Nats" Version="13.1.0"/>
<PackageReference Include="Aspire.Hosting.Redis" Version="13.1.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.Develop\DysonNetwork.Develop.csproj"/>
@@ -25,5 +25,6 @@
<ProjectReference Include="..\DysonNetwork.Gateway\DysonNetwork.Gateway.csproj"/>
<ProjectReference Include="..\DysonNetwork.Insight\DysonNetwork.Insight.csproj"/>
<ProjectReference Include="..\DysonNetwork.Zone\DysonNetwork.Zone.csproj"/>
<ProjectReference Include="..\DysonNetwork.Messager\DysonNetwork.Messager.csproj"/>
</ItemGroup>
</Project>

View File

@@ -18,6 +18,7 @@ public class AppDatabase(
public DbSet<SnCustomApp> CustomApps { get; set; } = null!;
public DbSet<SnCustomAppSecret> CustomAppSecrets { get; set; } = null!;
public DbSet<SnBotAccount> BotAccounts { get; set; } = null!;
public DbSet<SnMiniApp> MiniApps { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{

View File

@@ -1,4 +1,9 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libkrb5-3 \
libgssapi-krb5-2 \
&& rm -rf /var/lib/apt/lists/*
USER $APP_UID
WORKDIR /app
EXPOSE 8080

View File

@@ -8,15 +8,15 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" />
<PackageReference Include="NodaTime" Version="3.2.2"/>
<PackageReference Include="NodaTime" Version="3.2.3" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/>
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0"/>
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.76.0" />
</ItemGroup>
<ItemGroup>
@@ -29,4 +29,8 @@
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="MiniApp\" />
</ItemGroup>
</Project>

View File

@@ -8,7 +8,6 @@ namespace DysonNetwork.Develop.Identity;
public class CustomAppService(
AppDatabase db,
FileReferenceService.FileReferenceServiceClient fileRefs,
FileService.FileServiceClient files
)
{
@@ -47,15 +46,8 @@ public class CustomAppService(
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
app.Picture = SnCloudFileReferenceObject.FromProtoValue(picture);
// Create a new reference
await fileRefs.CreateReferenceAsync(
new CreateReferenceRequest
{
FileId = picture.Id,
Usage = "custom-apps.picture",
ResourceId = app.ResourceIdentifier
}
);
if (request.Status == Shared.Models.CustomAppStatus.Production)
await files.SetFilePublicAsync(new SetFilePublicRequest { FileId = request.PictureId });
}
if (request.BackgroundId is not null)
{
@@ -66,15 +58,8 @@ public class CustomAppService(
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
app.Background = SnCloudFileReferenceObject.FromProtoValue(background);
// Create a new reference
await fileRefs.CreateReferenceAsync(
new CreateReferenceRequest
{
FileId = background.Id,
Usage = "custom-apps.background",
ResourceId = app.ResourceIdentifier
}
);
if (request.Status == Shared.Models.CustomAppStatus.Production)
await files.SetFilePublicAsync(new SetFilePublicRequest { FileId = request.BackgroundId });
}
db.CustomApps.Add(app);
@@ -185,6 +170,7 @@ public class CustomAppService(
public async Task<SnCustomApp?> UpdateAppAsync(SnCustomApp app, CustomAppController.CustomAppRequest request)
{
var oldStatus = app.Status;
if (request.Slug is not null)
app.Slug = request.Slug;
if (request.Name is not null)
@@ -210,15 +196,8 @@ public class CustomAppService(
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
app.Picture = SnCloudFileReferenceObject.FromProtoValue(picture);
// Create a new reference
await fileRefs.CreateReferenceAsync(
new CreateReferenceRequest
{
FileId = picture.Id,
Usage = "custom-apps.picture",
ResourceId = app.ResourceIdentifier
}
);
if (app.Status == Shared.Models.CustomAppStatus.Production)
await files.SetFilePublicAsync(new SetFilePublicRequest { FileId = request.PictureId });
}
if (request.BackgroundId is not null)
{
@@ -229,20 +208,28 @@ public class CustomAppService(
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
app.Background = SnCloudFileReferenceObject.FromProtoValue(background);
// Create a new reference
await fileRefs.CreateReferenceAsync(
new CreateReferenceRequest
{
FileId = background.Id,
Usage = "custom-apps.background",
ResourceId = app.ResourceIdentifier
}
);
if (app.Status == Shared.Models.CustomAppStatus.Production)
await files.SetFilePublicAsync(new SetFilePublicRequest { FileId = request.BackgroundId });
}
db.Update(app);
await db.SaveChangesAsync();
if (oldStatus != Shared.Models.CustomAppStatus.Production && app.Status == Shared.Models.CustomAppStatus.Production)
{
if (app.Picture is not null)
await files.SetFilePublicAsync(new SetFilePublicRequest { FileId = app.Picture.Id });
if (app.Background is not null)
await files.SetFilePublicAsync(new SetFilePublicRequest { FileId = app.Background.Id });
}
else if (oldStatus == Shared.Models.CustomAppStatus.Production && app.Status != Shared.Models.CustomAppStatus.Production)
{
if (app.Picture is not null)
await files.UnsetFilePublicAsync(new UnsetFilePublicRequest { FileId = app.Picture.Id });
if (app.Background is not null)
await files.UnsetFilePublicAsync(new UnsetFilePublicRequest { FileId = app.Background.Id });
}
return app;
}
@@ -257,12 +244,6 @@ public class CustomAppService(
db.CustomApps.Remove(app);
await db.SaveChangesAsync();
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest
{
ResourceId = app.ResourceIdentifier
}
);
return true;
}
}

View File

@@ -0,0 +1,382 @@
// <auto-generated />
using System;
using DysonNetwork.Develop;
using DysonNetwork.Shared.Models;
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("20260117175714_AddMiniApp")]
partial class AddMiniApp
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.1")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Shared.Models.SnBotAccount", 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.Shared.Models.SnCustomApp", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<SnCloudFileReferenceObject>("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<SnCustomAppLinks>("Links")
.HasColumnType("jsonb")
.HasColumnName("links");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<SnCustomAppOauthConfig>("OauthConfig")
.HasColumnType("jsonb")
.HasColumnName("oauth_config");
b.Property<SnCloudFileReferenceObject>("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<SnVerificationMark>("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.Shared.Models.SnCustomAppSecret", 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.Shared.Models.SnDevProject", 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.Shared.Models.SnDeveloper", 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.Shared.Models.SnMiniApp", 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<MiniAppManifest>("Manifest")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("manifest");
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>("Stage")
.HasColumnType("integer")
.HasColumnName("stage");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_mini_apps");
b.HasIndex("ProjectId")
.HasDatabaseName("ix_mini_apps_project_id");
b.ToTable("mini_apps", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnBotAccount", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnDevProject", "Project")
.WithMany()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_bot_accounts_dev_projects_project_id");
b.Navigation("Project");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCustomApp", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnDevProject", "Project")
.WithMany()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_custom_apps_dev_projects_project_id");
b.Navigation("Project");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCustomAppSecret", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnCustomApp", "App")
.WithMany("Secrets")
.HasForeignKey("AppId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_custom_app_secrets_custom_apps_app_id");
b.Navigation("App");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnDevProject", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnDeveloper", "Developer")
.WithMany("Projects")
.HasForeignKey("DeveloperId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_dev_projects_developers_developer_id");
b.Navigation("Developer");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnMiniApp", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnDevProject", "Project")
.WithMany()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_mini_apps_dev_projects_project_id");
b.Navigation("Project");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCustomApp", b =>
{
b.Navigation("Secrets");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnDeveloper", b =>
{
b.Navigation("Projects");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,53 @@
using System;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Develop.Migrations
{
/// <inheritdoc />
public partial class AddMiniApp : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "mini_apps",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
slug = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
stage = table.Column<int>(type: "integer", nullable: false),
manifest = table.Column<MiniAppManifest>(type: "jsonb", 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_mini_apps", x => x.id);
table.ForeignKey(
name: "fk_mini_apps_dev_projects_project_id",
column: x => x.project_id,
principalTable: "dev_projects",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_mini_apps_project_id",
table: "mini_apps",
column: "project_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "mini_apps");
}
}
}

View File

@@ -19,12 +19,12 @@ namespace DysonNetwork.Develop.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.7")
.HasAnnotation("ProductVersion", "10.0.1")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b =>
modelBuilder.Entity("DysonNetwork.Shared.Models.SnBotAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -66,7 +66,7 @@ namespace DysonNetwork.Develop.Migrations
b.ToTable("bot_accounts", (string)null);
});
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCustomApp", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -139,7 +139,7 @@ namespace DysonNetwork.Develop.Migrations
b.ToTable("custom_apps", (string)null);
});
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCustomAppSecret", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -190,24 +190,7 @@ namespace DysonNetwork.Develop.Migrations
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 =>
modelBuilder.Entity("DysonNetwork.Shared.Models.SnDevProject", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -257,9 +240,73 @@ namespace DysonNetwork.Develop.Migrations
b.ToTable("dev_projects", (string)null);
});
modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b =>
modelBuilder.Entity("DysonNetwork.Shared.Models.SnDeveloper", b =>
{
b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
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.Shared.Models.SnMiniApp", 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<MiniAppManifest>("Manifest")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("manifest");
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>("Stage")
.HasColumnType("integer")
.HasColumnName("stage");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_mini_apps");
b.HasIndex("ProjectId")
.HasDatabaseName("ix_mini_apps_project_id");
b.ToTable("mini_apps", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnBotAccount", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnDevProject", "Project")
.WithMany()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
@@ -269,9 +316,9 @@ namespace DysonNetwork.Develop.Migrations
b.Navigation("Project");
});
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCustomApp", b =>
{
b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
b.HasOne("DysonNetwork.Shared.Models.SnDevProject", "Project")
.WithMany()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
@@ -281,9 +328,9 @@ namespace DysonNetwork.Develop.Migrations
b.Navigation("Project");
});
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCustomAppSecret", b =>
{
b.HasOne("DysonNetwork.Develop.Identity.CustomApp", "App")
b.HasOne("DysonNetwork.Shared.Models.SnCustomApp", "App")
.WithMany("Secrets")
.HasForeignKey("AppId")
.OnDelete(DeleteBehavior.Cascade)
@@ -293,9 +340,9 @@ namespace DysonNetwork.Develop.Migrations
b.Navigation("App");
});
modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b =>
modelBuilder.Entity("DysonNetwork.Shared.Models.SnDevProject", b =>
{
b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer")
b.HasOne("DysonNetwork.Shared.Models.SnDeveloper", "Developer")
.WithMany("Projects")
.HasForeignKey("DeveloperId")
.OnDelete(DeleteBehavior.Cascade)
@@ -305,12 +352,24 @@ namespace DysonNetwork.Develop.Migrations
b.Navigation("Developer");
});
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
modelBuilder.Entity("DysonNetwork.Shared.Models.SnMiniApp", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnDevProject", "Project")
.WithMany()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_mini_apps_dev_projects_project_id");
b.Navigation("Project");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCustomApp", b =>
{
b.Navigation("Secrets");
});
modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
modelBuilder.Entity("DysonNetwork.Shared.Models.SnDeveloper", b =>
{
b.Navigation("Projects");
});

View File

@@ -0,0 +1,185 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Develop.MiniApp;
[ApiController]
[Route("/api/developers/{pubName}/projects/{projectId:guid}/miniapps")]
public class MiniAppController(MiniAppService miniAppService, Identity.DeveloperService ds, DevProjectService projectService)
: ControllerBase
{
public record MiniAppRequest(
[MaxLength(1024)] string? Slug,
MiniAppStage? Stage,
MiniAppManifest? Manifest
);
public record CreateMiniAppRequest(
[Required]
[MinLength(2)]
[MaxLength(1024)]
[RegularExpression(@"^[A-Za-z0-9_-]+$",
ErrorMessage = "Slug can only contain letters, numbers, underscores, and hyphens.")]
string Slug,
MiniAppStage Stage = MiniAppStage.Development,
[Required] MiniAppManifest Manifest = null!
);
[HttpGet]
[Authorize]
public async Task<IActionResult> ListMiniApps([FromRoute] string pubName, [FromRoute] Guid projectId)
{
if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser)
return Unauthorized();
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null) return NotFound("Developer not found");
var accountId = Guid.Parse(currentUser.Id);
if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, Shared.Proto.PublisherMemberRole.Viewer))
return StatusCode(403, "You must be a viewer of the developer to list mini apps");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null) return NotFound("Project not found or you don't have access");
var miniApps = await miniAppService.GetMiniAppsByProjectAsync(projectId);
return Ok(miniApps);
}
[HttpGet("{miniAppId:guid}")]
[Authorize]
public async Task<IActionResult> GetMiniApp([FromRoute] string pubName, [FromRoute] Guid projectId,
[FromRoute] Guid miniAppId)
{
if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser)
return Unauthorized();
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null) return NotFound("Developer not found");
var accountId = Guid.Parse(currentUser.Id);
if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, Shared.Proto.PublisherMemberRole.Viewer))
return StatusCode(403, "You must be a viewer of the developer to view mini app 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 miniApp = await miniAppService.GetMiniAppByIdAsync(miniAppId);
if (miniApp == null || miniApp.ProjectId != projectId)
return NotFound("Mini app not found");
return Ok(miniApp);
}
[HttpPost]
[Authorize]
public async Task<IActionResult> CreateMiniApp(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromBody] CreateMiniAppRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Shared.Proto.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), Shared.Proto.PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to create a mini app");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null)
return NotFound("Project not found or you don't have access");
try
{
var miniApp = await miniAppService.CreateMiniAppAsync(projectId, request.Slug, request.Stage, request.Manifest);
return CreatedAtAction(
nameof(GetMiniApp),
new { pubName, projectId, miniAppId = miniApp.Id },
miniApp
);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[HttpPatch("{miniAppId:guid}")]
[Authorize]
public async Task<IActionResult> UpdateMiniApp(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid miniAppId,
[FromBody] MiniAppRequest request
)
{
if (HttpContext.Items["CurrentUser"] is not Shared.Proto.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), Shared.Proto.PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to update a mini 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 miniApp = await miniAppService.GetMiniAppByIdAsync(miniAppId);
if (miniApp == null || miniApp.ProjectId != projectId)
return NotFound("Mini app not found");
try
{
miniApp = await miniAppService.UpdateMiniAppAsync(miniApp, request.Slug, request.Stage, request.Manifest);
return Ok(miniApp);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[HttpDelete("{miniAppId:guid}")]
[Authorize]
public async Task<IActionResult> DeleteMiniApp(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid miniAppId
)
{
if (HttpContext.Items["CurrentUser"] is not Shared.Proto.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), Shared.Proto.PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to delete a mini 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 miniApp = await miniAppService.GetMiniAppByIdAsync(miniAppId);
if (miniApp == null || miniApp.ProjectId != projectId)
return NotFound("Mini app not found");
var result = await miniAppService.DeleteMiniAppAsync(miniAppId);
if (!result)
return NotFound("Failed to delete mini app");
return NoContent();
}
}

View File

@@ -0,0 +1,22 @@
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Develop.MiniApp;
[ApiController]
[Route("api/miniapps")]
public class MiniAppPublicController(MiniAppService miniAppService, Identity.DeveloperService developerService) : ControllerBase
{
[HttpGet("{slug}")]
public async Task<ActionResult<SnMiniApp>> GetMiniAppBySlug([FromRoute] string slug)
{
var miniApp = await miniAppService.GetMiniAppBySlugAsync(slug);
if (miniApp is null) return NotFound("Mini app not found");
var developer = await developerService.GetDeveloperById(miniApp.Project.DeveloperId);
if (developer is null) return NotFound("Developer not found");
miniApp.Developer = await developerService.LoadDeveloperPublisher(developer);
return Ok(miniApp);
}
}

View File

@@ -0,0 +1,92 @@
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Develop.MiniApp;
public class MiniAppService(AppDatabase db)
{
public async Task<SnMiniApp?> GetMiniAppByIdAsync(Guid id)
{
return await db.MiniApps
.Include(m => m.Project)
.FirstOrDefaultAsync(m => m.Id == id);
}
public async Task<SnMiniApp?> GetMiniAppBySlugAsync(string slug)
{
return await db.MiniApps
.Include(m => m.Project)
.FirstOrDefaultAsync(m => m.Slug == slug);
}
public async Task<List<SnMiniApp>> GetMiniAppsByProjectAsync(Guid projectId)
{
return await db.MiniApps
.Where(m => m.ProjectId == projectId)
.ToListAsync();
}
public async Task<SnMiniApp> CreateMiniAppAsync(Guid projectId, string slug, MiniAppStage stage, MiniAppManifest manifest)
{
var project = await db.DevProjects.FindAsync(projectId);
if (project == null)
throw new ArgumentException("Project not found");
// Check if a mini app with this slug already exists globally
var existingMiniApp = await db.MiniApps
.FirstOrDefaultAsync(m => m.Slug == slug);
if (existingMiniApp != null)
throw new InvalidOperationException("A mini app with this slug already exists.");
var miniApp = new SnMiniApp
{
Id = Guid.NewGuid(),
Slug = slug,
Stage = stage,
Manifest = manifest,
ProjectId = projectId,
Project = project
};
db.MiniApps.Add(miniApp);
await db.SaveChangesAsync();
return miniApp;
}
public async Task<SnMiniApp> UpdateMiniAppAsync(SnMiniApp miniApp, string? slug, MiniAppStage? stage, MiniAppManifest? manifest)
{
if (slug != null && slug != miniApp.Slug)
{
// Check if another mini app with this slug already exists globally
var existingMiniApp = await db.MiniApps
.FirstOrDefaultAsync(m => m.Slug == slug && m.Id != miniApp.Id);
if (existingMiniApp != null)
throw new InvalidOperationException("A mini app with this slug already exists.");
miniApp.Slug = slug;
}
if (stage.HasValue) miniApp.Stage = stage.Value;
if (manifest != null) miniApp.Manifest = manifest;
db.Update(miniApp);
await db.SaveChangesAsync();
return miniApp;
}
public async Task<bool> DeleteMiniAppAsync(Guid id)
{
var miniApp = await db.MiniApps.FindAsync(id);
if (miniApp == null)
return false;
db.MiniApps.Remove(miniApp);
await db.SaveChangesAsync();
return true;
}
}

View File

@@ -1,7 +1,7 @@
using DysonNetwork.Develop;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http;
using DysonNetwork.Develop.Startup;
using DysonNetwork.Shared.Networking;
using DysonNetwork.Shared.Registry;
using Microsoft.EntityFrameworkCore;

View File

@@ -8,7 +8,7 @@ namespace DysonNetwork.Develop.Project;
[ApiController]
[Route("/api/developers/{pubName}/projects")]
public class DevProjectController(DevProjectService projectService, DeveloperService developerService) : ControllerBase
public class DevProjectController(DevProjectService ps, DeveloperService ds) : ControllerBase
{
public record DevProjectRequest(
[MaxLength(1024)] string? Slug,
@@ -19,20 +19,20 @@ public class DevProjectController(DevProjectService projectService, DeveloperSer
[HttpGet]
public async Task<IActionResult> ListProjects([FromRoute] string pubName)
{
var developer = await developerService.GetDeveloperByName(pubName);
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null) return NotFound();
var projects = await projectService.GetProjectsByDeveloperAsync(developer.Id);
var projects = await ps.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);
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null) return NotFound();
var project = await projectService.GetProjectAsync(id, developer.Id);
var project = await ps.GetProjectAsync(id, developer.Id);
if (project is null) return NotFound();
return Ok(project);
@@ -45,17 +45,17 @@ public class DevProjectController(DevProjectService projectService, DeveloperSer
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await developerService.GetDeveloperByName(pubName);
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
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 project");
if (string.IsNullOrWhiteSpace(request.Slug) || string.IsNullOrWhiteSpace(request.Name))
return BadRequest("Slug and Name are required");
var project = await projectService.CreateProjectAsync(developer, request);
var project = await ps.CreateProjectAsync(developer, request);
return CreatedAtAction(
nameof(GetProject),
new { pubName, id = project.Id },
@@ -74,12 +74,15 @@ public class DevProjectController(DevProjectService projectService, DeveloperSer
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await developerService.GetDeveloperByName(pubName);
var developer = await ds.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 (developer is null)
return Forbid();
if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, PublisherMemberRole.Manager))
return StatusCode(403, "You must be an manager of the developer to update a project");
var project = await ps.UpdateProjectAsync(id, developer.Id, request);
if (project is null)
return NotFound();
@@ -93,12 +96,14 @@ public class DevProjectController(DevProjectService projectService, DeveloperSer
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await developerService.GetDeveloperByName(pubName);
var developer = await ds.GetDeveloperByName(pubName);
var accountId = Guid.Parse(currentUser.Id);
if (developer is null || developer.Id != accountId)
if (developer is null)
return Forbid();
if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, PublisherMemberRole.Manager))
return StatusCode(403, "You must be an manager of the developer to delete a project");
var success = await projectService.DeleteProjectAsync(id, developer.Id);
var success = await ps.DeleteProjectAsync(id, developer.Id);
if (!success)
return NotFound();

View File

@@ -1,6 +1,6 @@
using DysonNetwork.Develop.Identity;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Networking;
namespace DysonNetwork.Develop.Startup;

View File

@@ -48,6 +48,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<CustomAppService>();
services.AddScoped<DevProjectService>();
services.AddScoped<BotAccountService>();
services.AddScoped<MiniApp.MiniAppService>();
return services;
}

View File

@@ -20,9 +20,6 @@
"PublicBasePath": "/develop"
},
"Cache": {
"Serializer": "MessagePack"
},
"Etcd": {
"Insecure": true
"Serializer": "JSON"
}
}

View File

@@ -23,11 +23,12 @@ public class AppDatabase(
public DbSet<QuotaRecord> QuotaRecords { get; set; } = null!;
public DbSet<SnCloudFile> Files { get; set; } = null!;
public DbSet<SnCloudFileReference> FileReferences { get; set; } = null!;
public DbSet<SnFileObject> FileObjects { get; set; } = null!;
public DbSet<SnFileReplica> FileReplicas { get; set; } = null!;
public DbSet<SnFilePermission> FilePermissions { get; set; } = null!;
public DbSet<SnCloudFileIndex> FileIndexes { get; set; }
public DbSet<PersistentTask> Tasks { get; set; } = null!;
public DbSet<PersistentUploadTask> UploadTasks { get; set; } = null!; // Backward compatibility
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{

View File

@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using NodaTime;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Drive.Billing;
@@ -29,29 +30,47 @@ 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)
})
var replicaData = await db.FileReplicas
.Where(r => r.Status == SnFileReplicaStatus.Available)
.Where(r => r.PoolId.HasValue)
.Join(
db.Files.Where(f => f.AccountId == accountId)
.Where(f => !f.IsMarkedRecycle)
.Where(f => !f.ExpiredAt.HasValue || f.ExpiredAt > now),
r => r.ObjectId,
f => f.Id,
(r, f) => new { r.PoolId, r.ObjectId }
)
.Join(
db.FileObjects,
x => x.ObjectId,
o => o.Id,
(x, o) => new { x.PoolId, o.Size }
)
.ToListAsync();
var poolUsages = replicaData
.GroupBy(r => r.PoolId!.Value)
.Select(g =>
{
var poolId = g.Key;
var pool = db.Pools.Local.FirstOrDefault(p => p.Id == poolId)
?? db.Pools.Find(poolId);
var multiplier = pool?.BillingConfig.CostMultiplier ?? 1.0;
var totalBytes = g.Sum(x => x.Size);
return new UsageDetails
{
PoolId = poolId,
PoolName = pool?.Name ?? "Unknown",
UsageBytes = totalBytes,
Cost = totalBytes * multiplier / 1024.0 / 1024.0,
FileCount = g.Count()
};
})
.ToList();
var totalUsage = poolUsages.Sum(p => p.UsageBytes);
var totalFileCount = poolUsages.Sum(p => p.FileCount);
@@ -73,17 +92,27 @@ public class UsageService(AppDatabase db)
}
var now = SystemClock.Instance.GetCurrentInstant();
var fileQuery = db.Files
var replicaData = await db.FileReplicas
.Where(r => r.PoolId == poolId)
.Where(r => r.Status == SnFileReplicaStatus.Available)
.Join(
db.Files.Where(f => f.AccountId == accountId)
.Where(f => !f.IsMarkedRecycle)
.Where(f => f.ExpiredAt.HasValue && f.ExpiredAt > now)
.Where(f => f.AccountId == accountId)
.AsQueryable();
.Where(f => !f.ExpiredAt.HasValue || f.ExpiredAt > now),
r => r.ObjectId,
f => f.Id,
(r, f) => r.ObjectId
)
.Distinct()
.ToListAsync();
var usageBytes = await fileQuery
.SumAsync(f => f.Size);
var fileCount = replicaData.Count;
var fileCount = await fileQuery
.CountAsync();
var objectIds = replicaData.Distinct().ToList();
var usageBytes = await db.FileObjects
.Where(o => objectIds.Contains(o.Id))
.SumAsync(o => o.Size);
var cost = usageBytes / 1024.0 / 1024.0 *
(pool.BillingConfig.CostMultiplier ?? 1.0);
@@ -101,20 +130,23 @@ public class UsageService(AppDatabase db)
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;
var billingData = await (from f in db.Files
where f.AccountId == accountId
where !f.IsMarkedRecycle
where !f.ExpiredAt.HasValue || f.ExpiredAt > now
from r in f.Object!.FileReplicas
where r.Status == SnFileReplicaStatus.Available
where r.PoolId.HasValue
join p in db.Pools on r.PoolId equals p.Id
join o in db.FileObjects on r.ObjectId equals o.Id
select new
{
Size = o.Size,
Multiplier = p.BillingConfig.CostMultiplier ?? 1.0
}).ToListAsync();
var totalCost = billingData.Sum(x => x.Size * x.Multiplier) / 1024.0 / 1024.0;
return (long)Math.Ceiling(totalCost);
}

View File

@@ -11,9 +11,9 @@
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" />
<PackageReference Include="FFMpegCore" Version="5.4.0" />
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.76.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@@ -27,14 +27,13 @@
<PackageReference Include="NetVips" Version="3.1.0" />
<PackageReference Include="NetVips.Native.linux-x64" Version="8.17.3" />
<PackageReference Include="NetVips.Native.osx-arm64" Version="8.17.3" />
<PackageReference Include="NodaTime" Version="3.2.2" />
<PackageReference Include="NodaTime" Version="3.2.3" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
<PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="Quartz" Version="3.15.1" />
<PackageReference Include="Quartz.AspNetCore" Version="3.15.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />
<PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.2" />
<!-- Pin the SkiaSharp version at the 2.88.9 due to the BlurHash need this specific version -->
<PackageReference Include="SkiaSharp" Version="2.88.9" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.9" />

View File

@@ -1,8 +1,8 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Drive.Storage;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Networking;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -56,8 +56,8 @@ public class FileIndexController(
{
"name" => orderDesc ? fileIndexes.OrderByDescending(fi => fi.File.Name).ToList()
: fileIndexes.OrderBy(fi => fi.File.Name).ToList(),
"size" => orderDesc ? fileIndexes.OrderByDescending(fi => fi.File.Size).ToList()
: fileIndexes.OrderBy(fi => fi.File.Size).ToList(),
"size" => orderDesc ? fileIndexes.OrderByDescending(fi => fi.File.Object!.Size).ToList()
: fileIndexes.OrderBy(fi => fi.File.Object!.Size).ToList(),
_ => orderDesc ? fileIndexes.OrderByDescending(fi => fi.File.CreatedAt).ToList()
: fileIndexes.OrderBy(fi => fi.File.CreatedAt).ToList()
};
@@ -211,31 +211,31 @@ public class FileIndexController(
try
{
var filesQuery = db.Files
var baseQuery = db.Files
.Where(f => f.AccountId == accountId
&& f.IsMarkedRecycle == recycled
&& !db.FileIndexes.Any(fi => fi.FileId == f.Id && fi.AccountId == accountId)
)
.Include(f => f.Object)
.AsQueryable();
// Apply sorting
filesQuery = order.ToLower() switch
{
"name" => orderDesc ? filesQuery.OrderByDescending(f => f.Name)
: filesQuery.OrderBy(f => f.Name),
"size" => orderDesc ? filesQuery.OrderByDescending(f => f.Size)
: filesQuery.OrderBy(f => f.Size),
_ => orderDesc ? filesQuery.OrderByDescending(f => f.CreatedAt)
: filesQuery.OrderBy(f => f.CreatedAt)
};
if (pool.HasValue) filesQuery = filesQuery.Where(f => f.PoolId == pool);
if (pool.HasValue) baseQuery = baseQuery.Where(f => f.Object!.FileReplicas.Any(r => r.PoolId == pool.Value));
if (!string.IsNullOrWhiteSpace(query))
{
filesQuery = filesQuery.Where(f => f.Name.Contains(query));
baseQuery = baseQuery.Where(f => f.Name.Contains(query));
}
var filesQuery = order.ToLower() switch
{
"name" => orderDesc ? baseQuery.OrderByDescending(f => f.Name)
: baseQuery.OrderBy(f => f.Name),
"size" => orderDesc ? baseQuery.OrderByDescending(f => f.Object.Size)
: baseQuery.OrderBy(f => f.Object.Size),
_ => orderDesc ? baseQuery.OrderByDescending(f => f.CreatedAt)
: baseQuery.OrderBy(f => f.CreatedAt)
};
var totalCount = await filesQuery.CountAsync();
Response.Headers.Append("X-Total", totalCount.ToString());
@@ -545,6 +545,7 @@ public class FileIndexController(
var fileIndexes = await db.FileIndexes
.Where(fi => fi.AccountId == accountId)
.Include(fi => fi.File)
.ThenInclude(f => f.Object)
.Where(fi =>
(string.IsNullOrEmpty(path) || fi.Path == FileIndexService.NormalizePath(path)) &&
(fi.File.Name.ToLower().Contains(searchTerm) ||

View File

@@ -141,6 +141,7 @@ public class FileIndexService(AppDatabase db)
return await db.FileIndexes
.Where(fi => fi.AccountId == accountId && fi.Path == normalizedPath)
.Include(fi => fi.File)
.ThenInclude(f => f.Object)
.ToListAsync();
}
@@ -154,6 +155,7 @@ public class FileIndexService(AppDatabase db)
return await db.FileIndexes
.Where(fi => fi.FileId == fileId)
.Include(fi => fi.File)
.ThenInclude(f => f.Object)
.ToListAsync();
}
@@ -167,6 +169,7 @@ public class FileIndexService(AppDatabase db)
return await db.FileIndexes
.Where(fi => fi.AccountId == accountId)
.Include(fi => fi.File)
.ThenInclude(f => f.Object)
.ToListAsync();
}

View File

@@ -0,0 +1,560 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using DysonNetwork.Drive;
using DysonNetwork.Shared.Models;
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("20260101153809_RemoveUploadTask")]
partial class RemoveUploadTask
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.1")
.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.Model.PersistentTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("description");
b.Property<string>("ErrorMessage")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("error_message");
b.Property<long?>("EstimatedDurationSeconds")
.HasColumnType("bigint")
.HasColumnName("estimated_duration_seconds");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<Instant>("LastActivity")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_activity");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("name");
b.Property<Dictionary<string, object>>("Parameters")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("parameters");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<double>("Progress")
.HasColumnType("double precision")
.HasColumnName("progress");
b.Property<Dictionary<string, object>>("Results")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("results");
b.Property<Instant?>("StartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("started_at");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<string>("TaskId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("task_id");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_tasks");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.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.Shared.Models.SnCloudFile", 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.PrimitiveCollection<string>("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.Shared.Models.SnCloudFileIndex", 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>("FileId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("file_id");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("path");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_indexes");
b.HasIndex("FileId")
.HasDatabaseName("ix_file_indexes_file_id");
b.HasIndex("Path", "AccountId")
.HasDatabaseName("ix_file_indexes_path_account_id");
b.ToTable("file_indexes", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileReference", 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.Shared.Models.SnFileBundle", 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.Shared.Models.SnCloudFile", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnFileBundle", "Bundle")
.WithMany("Files")
.HasForeignKey("BundleId")
.HasConstraintName("fk_files_bundles_bundle_id");
b.HasOne("DysonNetwork.Shared.Models.FilePool", "Pool")
.WithMany()
.HasForeignKey("PoolId")
.HasConstraintName("fk_files_pools_pool_id");
b.Navigation("Bundle");
b.Navigation("Pool");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileIndex", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnCloudFile", "File")
.WithMany("FileIndexes")
.HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_indexes_files_file_id");
b.Navigation("File");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileReference", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnCloudFile", "File")
.WithMany("References")
.HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_references_files_file_id");
b.Navigation("File");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
{
b.Navigation("FileIndexes");
b.Navigation("References");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b =>
{
b.Navigation("Files");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,155 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Drive.Migrations
{
/// <inheritdoc />
public partial class RemoveUploadTask : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "bundle_id",
table: "tasks");
migrationBuilder.DropColumn(
name: "chunk_size",
table: "tasks");
migrationBuilder.DropColumn(
name: "chunks_count",
table: "tasks");
migrationBuilder.DropColumn(
name: "chunks_uploaded",
table: "tasks");
migrationBuilder.DropColumn(
name: "content_type",
table: "tasks");
migrationBuilder.DropColumn(
name: "discriminator",
table: "tasks");
migrationBuilder.DropColumn(
name: "encrypt_password",
table: "tasks");
migrationBuilder.DropColumn(
name: "file_name",
table: "tasks");
migrationBuilder.DropColumn(
name: "file_size",
table: "tasks");
migrationBuilder.DropColumn(
name: "hash",
table: "tasks");
migrationBuilder.DropColumn(
name: "path",
table: "tasks");
migrationBuilder.DropColumn(
name: "pool_id",
table: "tasks");
migrationBuilder.DropColumn(
name: "uploaded_chunks",
table: "tasks");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "bundle_id",
table: "tasks",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "chunk_size",
table: "tasks",
type: "bigint",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "chunks_count",
table: "tasks",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "chunks_uploaded",
table: "tasks",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "content_type",
table: "tasks",
type: "character varying(128)",
maxLength: 128,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "discriminator",
table: "tasks",
type: "character varying(21)",
maxLength: 21,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "encrypt_password",
table: "tasks",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "file_name",
table: "tasks",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<long>(
name: "file_size",
table: "tasks",
type: "bigint",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "hash",
table: "tasks",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "path",
table: "tasks",
type: "text",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "pool_id",
table: "tasks",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<List<int>>(
name: "uploaded_chunks",
table: "tasks",
type: "integer[]",
nullable: true);
}
}
}

View File

@@ -0,0 +1,632 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using DysonNetwork.Drive;
using DysonNetwork.Shared.Models;
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("20260101154612_RollbackRemoveUploadTask")]
partial class RollbackRemoveUploadTask
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.1")
.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.Model.PersistentTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("description");
b.Property<string>("Discriminator")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("character varying(21)")
.HasColumnName("discriminator");
b.Property<string>("ErrorMessage")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("error_message");
b.Property<long?>("EstimatedDurationSeconds")
.HasColumnType("bigint")
.HasColumnName("estimated_duration_seconds");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<Instant>("LastActivity")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_activity");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("name");
b.Property<Dictionary<string, object>>("Parameters")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("parameters");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<double>("Progress")
.HasColumnType("double precision")
.HasColumnName("progress");
b.Property<Dictionary<string, object>>("Results")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("results");
b.Property<Instant?>("StartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("started_at");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<string>("TaskId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("task_id");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_tasks");
b.ToTable("tasks", (string)null);
b.HasDiscriminator().HasValue("PersistentTask");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("DysonNetwork.Shared.Models.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.Shared.Models.SnCloudFile", 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.PrimitiveCollection<string>("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.Shared.Models.SnCloudFileIndex", 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>("FileId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("file_id");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("path");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_indexes");
b.HasIndex("FileId")
.HasDatabaseName("ix_file_indexes_file_id");
b.HasIndex("Path", "AccountId")
.HasDatabaseName("ix_file_indexes_path_account_id");
b.ToTable("file_indexes", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileReference", 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.Shared.Models.SnFileBundle", 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.Model.PersistentUploadTask", b =>
{
b.HasBaseType("DysonNetwork.Drive.Storage.Model.PersistentTask");
b.Property<Guid?>("BundleId")
.HasColumnType("uuid")
.HasColumnName("bundle_id");
b.Property<long>("ChunkSize")
.HasColumnType("bigint")
.HasColumnName("chunk_size");
b.Property<int>("ChunksCount")
.HasColumnType("integer")
.HasColumnName("chunks_count");
b.Property<int>("ChunksUploaded")
.HasColumnType("integer")
.HasColumnName("chunks_uploaded");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("content_type");
b.Property<string>("EncryptPassword")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("encrypt_password");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("file_name");
b.Property<long>("FileSize")
.HasColumnType("bigint")
.HasColumnName("file_size");
b.Property<string>("Hash")
.IsRequired()
.HasColumnType("text")
.HasColumnName("hash");
b.Property<string>("Path")
.HasColumnType("text")
.HasColumnName("path");
b.Property<Guid>("PoolId")
.HasColumnType("uuid")
.HasColumnName("pool_id");
b.PrimitiveCollection<List<int>>("UploadedChunks")
.IsRequired()
.HasColumnType("integer[]")
.HasColumnName("uploaded_chunks");
b.HasDiscriminator().HasValue("PersistentUploadTask");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnFileBundle", "Bundle")
.WithMany("Files")
.HasForeignKey("BundleId")
.HasConstraintName("fk_files_bundles_bundle_id");
b.HasOne("DysonNetwork.Shared.Models.FilePool", "Pool")
.WithMany()
.HasForeignKey("PoolId")
.HasConstraintName("fk_files_pools_pool_id");
b.Navigation("Bundle");
b.Navigation("Pool");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileIndex", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnCloudFile", "File")
.WithMany("FileIndexes")
.HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_indexes_files_file_id");
b.Navigation("File");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileReference", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnCloudFile", "File")
.WithMany("References")
.HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_references_files_file_id");
b.Navigation("File");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
{
b.Navigation("FileIndexes");
b.Navigation("References");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b =>
{
b.Navigation("Files");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,155 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Drive.Migrations
{
/// <inheritdoc />
public partial class RollbackRemoveUploadTask : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "bundle_id",
table: "tasks",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "chunk_size",
table: "tasks",
type: "bigint",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "chunks_count",
table: "tasks",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "chunks_uploaded",
table: "tasks",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "content_type",
table: "tasks",
type: "character varying(128)",
maxLength: 128,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "discriminator",
table: "tasks",
type: "character varying(21)",
maxLength: 21,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "encrypt_password",
table: "tasks",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "file_name",
table: "tasks",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<long>(
name: "file_size",
table: "tasks",
type: "bigint",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "hash",
table: "tasks",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "path",
table: "tasks",
type: "text",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "pool_id",
table: "tasks",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<List<int>>(
name: "uploaded_chunks",
table: "tasks",
type: "integer[]",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "bundle_id",
table: "tasks");
migrationBuilder.DropColumn(
name: "chunk_size",
table: "tasks");
migrationBuilder.DropColumn(
name: "chunks_count",
table: "tasks");
migrationBuilder.DropColumn(
name: "chunks_uploaded",
table: "tasks");
migrationBuilder.DropColumn(
name: "content_type",
table: "tasks");
migrationBuilder.DropColumn(
name: "discriminator",
table: "tasks");
migrationBuilder.DropColumn(
name: "encrypt_password",
table: "tasks");
migrationBuilder.DropColumn(
name: "file_name",
table: "tasks");
migrationBuilder.DropColumn(
name: "file_size",
table: "tasks");
migrationBuilder.DropColumn(
name: "hash",
table: "tasks");
migrationBuilder.DropColumn(
name: "path",
table: "tasks");
migrationBuilder.DropColumn(
name: "pool_id",
table: "tasks");
migrationBuilder.DropColumn(
name: "uploaded_chunks",
table: "tasks");
}
}
}

View File

@@ -0,0 +1,762 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using DysonNetwork.Drive;
using DysonNetwork.Shared.Models;
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("20260110084758_RemoveFileReferencesAndAddFileObjectOwner")]
partial class RemoveFileReferencesAndAddFileObjectOwner
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.1")
.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.Model.PersistentTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("description");
b.Property<string>("Discriminator")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("character varying(21)")
.HasColumnName("discriminator");
b.Property<string>("ErrorMessage")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("error_message");
b.Property<long?>("EstimatedDurationSeconds")
.HasColumnType("bigint")
.HasColumnName("estimated_duration_seconds");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<Instant>("LastActivity")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_activity");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("name");
b.Property<Dictionary<string, object>>("Parameters")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("parameters");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<double>("Progress")
.HasColumnType("double precision")
.HasColumnName("progress");
b.Property<Dictionary<string, object>>("Results")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("results");
b.Property<Instant?>("StartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("started_at");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<string>("TaskId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("task_id");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_tasks");
b.ToTable("tasks", (string)null);
b.HasDiscriminator().HasValue("PersistentTask");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("DysonNetwork.Shared.Models.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.Shared.Models.SnCloudFile", 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<string>("ObjectId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("object_id");
b.Property<Guid?>("PoolId")
.HasColumnType("uuid")
.HasColumnName("pool_id");
b.PrimitiveCollection<string>("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("ObjectId")
.HasDatabaseName("ix_files_object_id");
b.HasIndex("PoolId")
.HasDatabaseName("ix_files_pool_id");
b.ToTable("files", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileIndex", 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>("FileId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("file_id");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("path");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_indexes");
b.HasIndex("FileId")
.HasDatabaseName("ix_file_indexes_file_id");
b.HasIndex("Path", "AccountId")
.HasDatabaseName("ix_file_indexes_path_account_id");
b.ToTable("file_indexes", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", 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.Shared.Models.SnFileObject", 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<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<Dictionary<string, object>>("Meta")
.HasColumnType("jsonb")
.HasColumnName("meta");
b.Property<string>("MimeType")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("mime_type");
b.Property<long>("Size")
.HasColumnType("bigint")
.HasColumnName("size");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_objects");
b.ToTable("file_objects", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFilePermission", 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>("FileId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("file_id");
b.Property<int>("Permission")
.HasColumnType("integer")
.HasColumnName("permission");
b.Property<string>("SubjectId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("subject_id");
b.Property<int>("SubjectType")
.HasColumnType("integer")
.HasColumnName("subject_type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_permissions");
b.ToTable("file_permissions", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileReplica", 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>("IsPrimary")
.HasColumnType("boolean")
.HasColumnName("is_primary");
b.Property<string>("ObjectId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("object_id");
b.Property<Guid>("PoolId")
.HasColumnType("uuid")
.HasColumnName("pool_id");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<string>("StorageId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("storage_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_replicas");
b.HasIndex("ObjectId")
.HasDatabaseName("ix_file_replicas_object_id");
b.HasIndex("PoolId")
.HasDatabaseName("ix_file_replicas_pool_id");
b.ToTable("file_replicas", (string)null);
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.Model.PersistentUploadTask", b =>
{
b.HasBaseType("DysonNetwork.Drive.Storage.Model.PersistentTask");
b.Property<Guid?>("BundleId")
.HasColumnType("uuid")
.HasColumnName("bundle_id");
b.Property<long>("ChunkSize")
.HasColumnType("bigint")
.HasColumnName("chunk_size");
b.Property<int>("ChunksCount")
.HasColumnType("integer")
.HasColumnName("chunks_count");
b.Property<int>("ChunksUploaded")
.HasColumnType("integer")
.HasColumnName("chunks_uploaded");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("content_type");
b.Property<string>("EncryptPassword")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("encrypt_password");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("file_name");
b.Property<long>("FileSize")
.HasColumnType("bigint")
.HasColumnName("file_size");
b.Property<string>("Hash")
.IsRequired()
.HasColumnType("text")
.HasColumnName("hash");
b.Property<string>("Path")
.HasColumnType("text")
.HasColumnName("path");
b.Property<Guid>("PoolId")
.HasColumnType("uuid")
.HasColumnName("pool_id");
b.PrimitiveCollection<List<int>>("UploadedChunks")
.IsRequired()
.HasColumnType("integer[]")
.HasColumnName("uploaded_chunks");
b.HasDiscriminator().HasValue("PersistentUploadTask");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnFileBundle", "Bundle")
.WithMany("Files")
.HasForeignKey("BundleId")
.HasConstraintName("fk_files_bundles_bundle_id");
b.HasOne("DysonNetwork.Shared.Models.SnFileObject", "Object")
.WithMany()
.HasForeignKey("ObjectId")
.HasConstraintName("fk_files_file_objects_object_id");
b.HasOne("DysonNetwork.Shared.Models.FilePool", "Pool")
.WithMany()
.HasForeignKey("PoolId")
.HasConstraintName("fk_files_pools_pool_id");
b.Navigation("Bundle");
b.Navigation("Object");
b.Navigation("Pool");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileIndex", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnCloudFile", "File")
.WithMany("FileIndexes")
.HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_indexes_files_file_id");
b.Navigation("File");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileReplica", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnFileObject", "Object")
.WithMany("FileReplicas")
.HasForeignKey("ObjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_replicas_file_objects_object_id");
b.HasOne("DysonNetwork.Shared.Models.FilePool", "Pool")
.WithMany()
.HasForeignKey("PoolId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_replicas_pools_pool_id");
b.Navigation("Object");
b.Navigation("Pool");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
{
b.Navigation("FileIndexes");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b =>
{
b.Navigation("Files");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileObject", b =>
{
b.Navigation("FileReplicas");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,173 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Drive.Migrations
{
/// <inheritdoc />
public partial class RemoveFileReferencesAndAddFileObjectOwner : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "file_references");
migrationBuilder.AddColumn<string>(
name: "object_id",
table: "files",
type: "character varying(32)",
maxLength: 32,
nullable: true);
migrationBuilder.CreateTable(
name: "file_objects",
columns: table => new
{
id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
size = table.Column<long>(type: "bigint", nullable: false),
meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true),
mime_type = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
hash = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
has_compression = table.Column<bool>(type: "boolean", nullable: false),
has_thumbnail = table.Column<bool>(type: "boolean", 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_file_objects", x => x.id);
});
migrationBuilder.CreateTable(
name: "file_permissions",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
file_id = table.Column<string>(type: "text", nullable: false),
subject_type = table.Column<int>(type: "integer", nullable: false),
subject_id = table.Column<string>(type: "text", nullable: false),
permission = table.Column<int>(type: "integer", 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_file_permissions", x => x.id);
});
migrationBuilder.CreateTable(
name: "file_replicas",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
object_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
pool_id = table.Column<Guid>(type: "uuid", nullable: false),
storage_id = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
status = table.Column<int>(type: "integer", nullable: false),
is_primary = table.Column<bool>(type: "boolean", 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_file_replicas", x => x.id);
table.ForeignKey(
name: "fk_file_replicas_file_objects_object_id",
column: x => x.object_id,
principalTable: "file_objects",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_file_replicas_pools_pool_id",
column: x => x.pool_id,
principalTable: "pools",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_files_object_id",
table: "files",
column: "object_id");
migrationBuilder.CreateIndex(
name: "ix_file_replicas_object_id",
table: "file_replicas",
column: "object_id");
migrationBuilder.CreateIndex(
name: "ix_file_replicas_pool_id",
table: "file_replicas",
column: "pool_id");
migrationBuilder.AddForeignKey(
name: "fk_files_file_objects_object_id",
table: "files",
column: "object_id",
principalTable: "file_objects",
principalColumn: "id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_files_file_objects_object_id",
table: "files");
migrationBuilder.DropTable(
name: "file_permissions");
migrationBuilder.DropTable(
name: "file_replicas");
migrationBuilder.DropTable(
name: "file_objects");
migrationBuilder.DropIndex(
name: "ix_files_object_id",
table: "files");
migrationBuilder.DropColumn(
name: "object_id",
table: "files");
migrationBuilder.CreateTable(
name: "file_references",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
file_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
resource_id = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
usage = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_file_references", x => x.id);
table.ForeignKey(
name: "fk_file_references_files_file_id",
column: x => x.file_id,
principalTable: "files",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_file_references_file_id",
table: "file_references",
column: "file_id");
}
}
}

View File

@@ -0,0 +1,760 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using DysonNetwork.Drive;
using DysonNetwork.Shared.Models;
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("20260110142132_NullableReplicaPoolId")]
partial class NullableReplicaPoolId
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.1")
.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.Model.PersistentTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("description");
b.Property<string>("Discriminator")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("character varying(21)")
.HasColumnName("discriminator");
b.Property<string>("ErrorMessage")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("error_message");
b.Property<long?>("EstimatedDurationSeconds")
.HasColumnType("bigint")
.HasColumnName("estimated_duration_seconds");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<Instant>("LastActivity")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_activity");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("name");
b.Property<Dictionary<string, object>>("Parameters")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("parameters");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<double>("Progress")
.HasColumnType("double precision")
.HasColumnName("progress");
b.Property<Dictionary<string, object>>("Results")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("results");
b.Property<Instant?>("StartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("started_at");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<string>("TaskId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("task_id");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_tasks");
b.ToTable("tasks", (string)null);
b.HasDiscriminator().HasValue("PersistentTask");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("DysonNetwork.Shared.Models.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.Shared.Models.SnCloudFile", 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<string>("ObjectId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("object_id");
b.Property<Guid?>("PoolId")
.HasColumnType("uuid")
.HasColumnName("pool_id");
b.PrimitiveCollection<string>("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("ObjectId")
.HasDatabaseName("ix_files_object_id");
b.HasIndex("PoolId")
.HasDatabaseName("ix_files_pool_id");
b.ToTable("files", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileIndex", 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>("FileId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("file_id");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("path");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_indexes");
b.HasIndex("FileId")
.HasDatabaseName("ix_file_indexes_file_id");
b.HasIndex("Path", "AccountId")
.HasDatabaseName("ix_file_indexes_path_account_id");
b.ToTable("file_indexes", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", 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.Shared.Models.SnFileObject", 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<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<Dictionary<string, object>>("Meta")
.HasColumnType("jsonb")
.HasColumnName("meta");
b.Property<string>("MimeType")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("mime_type");
b.Property<long>("Size")
.HasColumnType("bigint")
.HasColumnName("size");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_objects");
b.ToTable("file_objects", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFilePermission", 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>("FileId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("file_id");
b.Property<int>("Permission")
.HasColumnType("integer")
.HasColumnName("permission");
b.Property<string>("SubjectId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("subject_id");
b.Property<int>("SubjectType")
.HasColumnType("integer")
.HasColumnName("subject_type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_permissions");
b.ToTable("file_permissions", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileReplica", 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>("IsPrimary")
.HasColumnType("boolean")
.HasColumnName("is_primary");
b.Property<string>("ObjectId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("object_id");
b.Property<Guid?>("PoolId")
.HasColumnType("uuid")
.HasColumnName("pool_id");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<string>("StorageId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("storage_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_replicas");
b.HasIndex("ObjectId")
.HasDatabaseName("ix_file_replicas_object_id");
b.HasIndex("PoolId")
.HasDatabaseName("ix_file_replicas_pool_id");
b.ToTable("file_replicas", (string)null);
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.Model.PersistentUploadTask", b =>
{
b.HasBaseType("DysonNetwork.Drive.Storage.Model.PersistentTask");
b.Property<Guid?>("BundleId")
.HasColumnType("uuid")
.HasColumnName("bundle_id");
b.Property<long>("ChunkSize")
.HasColumnType("bigint")
.HasColumnName("chunk_size");
b.Property<int>("ChunksCount")
.HasColumnType("integer")
.HasColumnName("chunks_count");
b.Property<int>("ChunksUploaded")
.HasColumnType("integer")
.HasColumnName("chunks_uploaded");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("content_type");
b.Property<string>("EncryptPassword")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("encrypt_password");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("file_name");
b.Property<long>("FileSize")
.HasColumnType("bigint")
.HasColumnName("file_size");
b.Property<string>("Hash")
.IsRequired()
.HasColumnType("text")
.HasColumnName("hash");
b.Property<string>("Path")
.HasColumnType("text")
.HasColumnName("path");
b.Property<Guid>("PoolId")
.HasColumnType("uuid")
.HasColumnName("pool_id");
b.PrimitiveCollection<List<int>>("UploadedChunks")
.IsRequired()
.HasColumnType("integer[]")
.HasColumnName("uploaded_chunks");
b.HasDiscriminator().HasValue("PersistentUploadTask");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnFileBundle", "Bundle")
.WithMany("Files")
.HasForeignKey("BundleId")
.HasConstraintName("fk_files_bundles_bundle_id");
b.HasOne("DysonNetwork.Shared.Models.SnFileObject", "Object")
.WithMany()
.HasForeignKey("ObjectId")
.HasConstraintName("fk_files_file_objects_object_id");
b.HasOne("DysonNetwork.Shared.Models.FilePool", "Pool")
.WithMany()
.HasForeignKey("PoolId")
.HasConstraintName("fk_files_pools_pool_id");
b.Navigation("Bundle");
b.Navigation("Object");
b.Navigation("Pool");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileIndex", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnCloudFile", "File")
.WithMany("FileIndexes")
.HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_indexes_files_file_id");
b.Navigation("File");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileReplica", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnFileObject", "Object")
.WithMany("FileReplicas")
.HasForeignKey("ObjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_replicas_file_objects_object_id");
b.HasOne("DysonNetwork.Shared.Models.FilePool", "Pool")
.WithMany()
.HasForeignKey("PoolId")
.HasConstraintName("fk_file_replicas_pools_pool_id");
b.Navigation("Object");
b.Navigation("Pool");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
{
b.Navigation("FileIndexes");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b =>
{
b.Navigation("Files");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileObject", b =>
{
b.Navigation("FileReplicas");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,60 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Drive.Migrations
{
/// <inheritdoc />
public partial class NullableReplicaPoolId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_file_replicas_pools_pool_id",
table: "file_replicas");
migrationBuilder.AlterColumn<Guid>(
name: "pool_id",
table: "file_replicas",
type: "uuid",
nullable: true,
oldClrType: typeof(Guid),
oldType: "uuid");
migrationBuilder.AddForeignKey(
name: "fk_file_replicas_pools_pool_id",
table: "file_replicas",
column: "pool_id",
principalTable: "pools",
principalColumn: "id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_file_replicas_pools_pool_id",
table: "file_replicas");
migrationBuilder.AlterColumn<Guid>(
name: "pool_id",
table: "file_replicas",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
oldClrType: typeof(Guid),
oldType: "uuid",
oldNullable: true);
migrationBuilder.AddForeignKey(
name: "fk_file_replicas_pools_pool_id",
table: "file_replicas",
column: "pool_id",
principalTable: "pools",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
}
}
}

View File

@@ -0,0 +1,688 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using DysonNetwork.Drive;
using DysonNetwork.Shared.Models;
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("20260110154021_RemoveUploadTaskAgain")]
partial class RemoveUploadTaskAgain
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.1")
.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.Model.PersistentTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("description");
b.Property<string>("ErrorMessage")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("error_message");
b.Property<long?>("EstimatedDurationSeconds")
.HasColumnType("bigint")
.HasColumnName("estimated_duration_seconds");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<Instant>("LastActivity")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_activity");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("name");
b.Property<Dictionary<string, object>>("Parameters")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("parameters");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<double>("Progress")
.HasColumnType("double precision")
.HasColumnName("progress");
b.Property<Dictionary<string, object>>("Results")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("results");
b.Property<Instant?>("StartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("started_at");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<string>("TaskId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("task_id");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_tasks");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.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.Shared.Models.SnCloudFile", 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<string>("ObjectId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("object_id");
b.Property<Guid?>("PoolId")
.HasColumnType("uuid")
.HasColumnName("pool_id");
b.PrimitiveCollection<string>("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("ObjectId")
.HasDatabaseName("ix_files_object_id");
b.HasIndex("PoolId")
.HasDatabaseName("ix_files_pool_id");
b.ToTable("files", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileIndex", 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>("FileId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("file_id");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("path");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_indexes");
b.HasIndex("FileId")
.HasDatabaseName("ix_file_indexes_file_id");
b.HasIndex("Path", "AccountId")
.HasDatabaseName("ix_file_indexes_path_account_id");
b.ToTable("file_indexes", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", 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.Shared.Models.SnFileObject", 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<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<Dictionary<string, object>>("Meta")
.HasColumnType("jsonb")
.HasColumnName("meta");
b.Property<string>("MimeType")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("mime_type");
b.Property<long>("Size")
.HasColumnType("bigint")
.HasColumnName("size");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_objects");
b.ToTable("file_objects", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFilePermission", 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>("FileId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("file_id");
b.Property<int>("Permission")
.HasColumnType("integer")
.HasColumnName("permission");
b.Property<string>("SubjectId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("subject_id");
b.Property<int>("SubjectType")
.HasColumnType("integer")
.HasColumnName("subject_type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_permissions");
b.ToTable("file_permissions", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileReplica", 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>("IsPrimary")
.HasColumnType("boolean")
.HasColumnName("is_primary");
b.Property<string>("ObjectId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("object_id");
b.Property<Guid?>("PoolId")
.HasColumnType("uuid")
.HasColumnName("pool_id");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<string>("StorageId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("storage_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_replicas");
b.HasIndex("ObjectId")
.HasDatabaseName("ix_file_replicas_object_id");
b.HasIndex("PoolId")
.HasDatabaseName("ix_file_replicas_pool_id");
b.ToTable("file_replicas", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnFileBundle", "Bundle")
.WithMany("Files")
.HasForeignKey("BundleId")
.HasConstraintName("fk_files_bundles_bundle_id");
b.HasOne("DysonNetwork.Shared.Models.SnFileObject", "Object")
.WithMany()
.HasForeignKey("ObjectId")
.HasConstraintName("fk_files_file_objects_object_id");
b.HasOne("DysonNetwork.Shared.Models.FilePool", "Pool")
.WithMany()
.HasForeignKey("PoolId")
.HasConstraintName("fk_files_pools_pool_id");
b.Navigation("Bundle");
b.Navigation("Object");
b.Navigation("Pool");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileIndex", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnCloudFile", "File")
.WithMany("FileIndexes")
.HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_indexes_files_file_id");
b.Navigation("File");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileReplica", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnFileObject", "Object")
.WithMany("FileReplicas")
.HasForeignKey("ObjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_replicas_file_objects_object_id");
b.HasOne("DysonNetwork.Shared.Models.FilePool", "Pool")
.WithMany()
.HasForeignKey("PoolId")
.HasConstraintName("fk_file_replicas_pools_pool_id");
b.Navigation("Object");
b.Navigation("Pool");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
{
b.Navigation("FileIndexes");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b =>
{
b.Navigation("Files");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileObject", b =>
{
b.Navigation("FileReplicas");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,155 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Drive.Migrations
{
/// <inheritdoc />
public partial class RemoveUploadTaskAgain : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "bundle_id",
table: "tasks");
migrationBuilder.DropColumn(
name: "chunk_size",
table: "tasks");
migrationBuilder.DropColumn(
name: "chunks_count",
table: "tasks");
migrationBuilder.DropColumn(
name: "chunks_uploaded",
table: "tasks");
migrationBuilder.DropColumn(
name: "content_type",
table: "tasks");
migrationBuilder.DropColumn(
name: "discriminator",
table: "tasks");
migrationBuilder.DropColumn(
name: "encrypt_password",
table: "tasks");
migrationBuilder.DropColumn(
name: "file_name",
table: "tasks");
migrationBuilder.DropColumn(
name: "file_size",
table: "tasks");
migrationBuilder.DropColumn(
name: "hash",
table: "tasks");
migrationBuilder.DropColumn(
name: "path",
table: "tasks");
migrationBuilder.DropColumn(
name: "pool_id",
table: "tasks");
migrationBuilder.DropColumn(
name: "uploaded_chunks",
table: "tasks");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "bundle_id",
table: "tasks",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "chunk_size",
table: "tasks",
type: "bigint",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "chunks_count",
table: "tasks",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "chunks_uploaded",
table: "tasks",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "content_type",
table: "tasks",
type: "character varying(128)",
maxLength: 128,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "discriminator",
table: "tasks",
type: "character varying(21)",
maxLength: 21,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "encrypt_password",
table: "tasks",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "file_name",
table: "tasks",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<long>(
name: "file_size",
table: "tasks",
type: "bigint",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "hash",
table: "tasks",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "path",
table: "tasks",
type: "text",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "pool_id",
table: "tasks",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<List<int>>(
name: "uploaded_chunks",
table: "tasks",
type: "integer[]",
nullable: true);
}
}
}

View File

@@ -0,0 +1,658 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using DysonNetwork.Drive;
using DysonNetwork.Shared.Models;
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("20260111152243_CleanCloudFile")]
partial class CleanCloudFile
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.1")
.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.Model.PersistentTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("description");
b.Property<string>("ErrorMessage")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("error_message");
b.Property<long?>("EstimatedDurationSeconds")
.HasColumnType("bigint")
.HasColumnName("estimated_duration_seconds");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<Instant>("LastActivity")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_activity");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("name");
b.Property<Dictionary<string, object>>("Parameters")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("parameters");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<double>("Progress")
.HasColumnType("double precision")
.HasColumnName("progress");
b.Property<Dictionary<string, object>>("Results")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("results");
b.Property<Instant?>("StartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("started_at");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<string>("TaskId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("task_id");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_tasks");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.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.Shared.Models.SnCloudFile", 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<bool>("IsMarkedRecycle")
.HasColumnType("boolean")
.HasColumnName("is_marked_recycle");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<string>("ObjectId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("object_id");
b.Property<Guid?>("PoolId")
.HasColumnType("uuid")
.HasColumnName("pool_id");
b.PrimitiveCollection<string>("SensitiveMarks")
.HasColumnType("jsonb")
.HasColumnName("sensitive_marks");
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("ObjectId")
.HasDatabaseName("ix_files_object_id");
b.HasIndex("PoolId")
.HasDatabaseName("ix_files_pool_id");
b.ToTable("files", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileIndex", 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>("FileId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("file_id");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("path");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_indexes");
b.HasIndex("FileId")
.HasDatabaseName("ix_file_indexes_file_id");
b.HasIndex("Path", "AccountId")
.HasDatabaseName("ix_file_indexes_path_account_id");
b.ToTable("file_indexes", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", 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.Shared.Models.SnFileObject", 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<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<Dictionary<string, object>>("Meta")
.HasColumnType("jsonb")
.HasColumnName("meta");
b.Property<string>("MimeType")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("mime_type");
b.Property<long>("Size")
.HasColumnType("bigint")
.HasColumnName("size");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_objects");
b.ToTable("file_objects", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFilePermission", 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>("FileId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("file_id");
b.Property<int>("Permission")
.HasColumnType("integer")
.HasColumnName("permission");
b.Property<string>("SubjectId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("subject_id");
b.Property<int>("SubjectType")
.HasColumnType("integer")
.HasColumnName("subject_type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_permissions");
b.ToTable("file_permissions", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileReplica", 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>("IsPrimary")
.HasColumnType("boolean")
.HasColumnName("is_primary");
b.Property<string>("ObjectId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("object_id");
b.Property<Guid?>("PoolId")
.HasColumnType("uuid")
.HasColumnName("pool_id");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<string>("StorageId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("storage_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_replicas");
b.HasIndex("ObjectId")
.HasDatabaseName("ix_file_replicas_object_id");
b.HasIndex("PoolId")
.HasDatabaseName("ix_file_replicas_pool_id");
b.ToTable("file_replicas", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnFileBundle", "Bundle")
.WithMany("Files")
.HasForeignKey("BundleId")
.HasConstraintName("fk_files_bundles_bundle_id");
b.HasOne("DysonNetwork.Shared.Models.SnFileObject", "Object")
.WithMany()
.HasForeignKey("ObjectId")
.HasConstraintName("fk_files_file_objects_object_id");
b.HasOne("DysonNetwork.Shared.Models.FilePool", "Pool")
.WithMany()
.HasForeignKey("PoolId")
.HasConstraintName("fk_files_pools_pool_id");
b.Navigation("Bundle");
b.Navigation("Object");
b.Navigation("Pool");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileIndex", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnCloudFile", "File")
.WithMany("FileIndexes")
.HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_indexes_files_file_id");
b.Navigation("File");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileReplica", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnFileObject", "Object")
.WithMany("FileReplicas")
.HasForeignKey("ObjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_replicas_file_objects_object_id");
b.HasOne("DysonNetwork.Shared.Models.FilePool", "Pool")
.WithMany()
.HasForeignKey("PoolId")
.HasConstraintName("fk_file_replicas_pools_pool_id");
b.Navigation("Object");
b.Navigation("Pool");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
{
b.Navigation("FileIndexes");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b =>
{
b.Navigation("Files");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileObject", b =>
{
b.Navigation("FileReplicas");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,95 @@
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Drive.Migrations
{
/// <inheritdoc />
public partial class CleanCloudFile : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "file_meta",
table: "files");
migrationBuilder.DropColumn(
name: "has_compression",
table: "files");
migrationBuilder.DropColumn(
name: "has_thumbnail",
table: "files");
migrationBuilder.DropColumn(
name: "hash",
table: "files");
migrationBuilder.DropColumn(
name: "is_encrypted",
table: "files");
migrationBuilder.DropColumn(
name: "mime_type",
table: "files");
migrationBuilder.DropColumn(
name: "size",
table: "files");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Dictionary<string, object>>(
name: "file_meta",
table: "files",
type: "jsonb",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "has_compression",
table: "files",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "has_thumbnail",
table: "files",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "hash",
table: "files",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "is_encrypted",
table: "files",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "mime_type",
table: "files",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<long>(
name: "size",
table: "files",
type: "bigint",
nullable: false,
defaultValue: 0L);
}
}
}

View File

@@ -0,0 +1,654 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using DysonNetwork.Drive;
using DysonNetwork.Shared.Models;
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("20260112170805_RemoveAccountFromFileObject")]
partial class RemoveAccountFromFileObject
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.1")
.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.Model.PersistentTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("description");
b.Property<string>("ErrorMessage")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("error_message");
b.Property<long?>("EstimatedDurationSeconds")
.HasColumnType("bigint")
.HasColumnName("estimated_duration_seconds");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<Instant>("LastActivity")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_activity");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("name");
b.Property<Dictionary<string, object>>("Parameters")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("parameters");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<double>("Progress")
.HasColumnType("double precision")
.HasColumnName("progress");
b.Property<Dictionary<string, object>>("Results")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("results");
b.Property<Instant?>("StartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("started_at");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<string>("TaskId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("task_id");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_tasks");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.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.Shared.Models.SnCloudFile", 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<bool>("IsMarkedRecycle")
.HasColumnType("boolean")
.HasColumnName("is_marked_recycle");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<string>("ObjectId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("object_id");
b.Property<Guid?>("PoolId")
.HasColumnType("uuid")
.HasColumnName("pool_id");
b.PrimitiveCollection<string>("SensitiveMarks")
.HasColumnType("jsonb")
.HasColumnName("sensitive_marks");
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("ObjectId")
.HasDatabaseName("ix_files_object_id");
b.HasIndex("PoolId")
.HasDatabaseName("ix_files_pool_id");
b.ToTable("files", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileIndex", 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>("FileId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("file_id");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("path");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_indexes");
b.HasIndex("FileId")
.HasDatabaseName("ix_file_indexes_file_id");
b.HasIndex("Path", "AccountId")
.HasDatabaseName("ix_file_indexes_path_account_id");
b.ToTable("file_indexes", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", 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.Shared.Models.SnFileObject", b =>
{
b.Property<string>("Id")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.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>("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<Dictionary<string, object>>("Meta")
.HasColumnType("jsonb")
.HasColumnName("meta");
b.Property<string>("MimeType")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("mime_type");
b.Property<long>("Size")
.HasColumnType("bigint")
.HasColumnName("size");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_objects");
b.ToTable("file_objects", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFilePermission", 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>("FileId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("file_id");
b.Property<int>("Permission")
.HasColumnType("integer")
.HasColumnName("permission");
b.Property<string>("SubjectId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("subject_id");
b.Property<int>("SubjectType")
.HasColumnType("integer")
.HasColumnName("subject_type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_permissions");
b.ToTable("file_permissions", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileReplica", 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>("IsPrimary")
.HasColumnType("boolean")
.HasColumnName("is_primary");
b.Property<string>("ObjectId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("object_id");
b.Property<Guid?>("PoolId")
.HasColumnType("uuid")
.HasColumnName("pool_id");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<string>("StorageId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("storage_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_replicas");
b.HasIndex("ObjectId")
.HasDatabaseName("ix_file_replicas_object_id");
b.HasIndex("PoolId")
.HasDatabaseName("ix_file_replicas_pool_id");
b.ToTable("file_replicas", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnFileBundle", "Bundle")
.WithMany("Files")
.HasForeignKey("BundleId")
.HasConstraintName("fk_files_bundles_bundle_id");
b.HasOne("DysonNetwork.Shared.Models.SnFileObject", "Object")
.WithMany()
.HasForeignKey("ObjectId")
.HasConstraintName("fk_files_file_objects_object_id");
b.HasOne("DysonNetwork.Shared.Models.FilePool", "Pool")
.WithMany()
.HasForeignKey("PoolId")
.HasConstraintName("fk_files_pools_pool_id");
b.Navigation("Bundle");
b.Navigation("Object");
b.Navigation("Pool");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileIndex", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnCloudFile", "File")
.WithMany("FileIndexes")
.HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_indexes_files_file_id");
b.Navigation("File");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileReplica", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnFileObject", "Object")
.WithMany("FileReplicas")
.HasForeignKey("ObjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_replicas_file_objects_object_id");
b.HasOne("DysonNetwork.Shared.Models.FilePool", "Pool")
.WithMany()
.HasForeignKey("PoolId")
.HasConstraintName("fk_file_replicas_pools_pool_id");
b.Navigation("Object");
b.Navigation("Pool");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
{
b.Navigation("FileIndexes");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b =>
{
b.Navigation("Files");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileObject", b =>
{
b.Navigation("FileReplicas");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,30 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Drive.Migrations
{
/// <inheritdoc />
public partial class RemoveAccountFromFileObject : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "account_id",
table: "file_objects");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "account_id",
table: "file_objects",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
}
}
}

View File

@@ -0,0 +1,640 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using DysonNetwork.Drive;
using DysonNetwork.Shared.Models;
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("20260113152536_RemovePoolFromCloudFile")]
partial class RemovePoolFromCloudFile
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.1")
.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.Model.PersistentTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("description");
b.Property<string>("ErrorMessage")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("error_message");
b.Property<long?>("EstimatedDurationSeconds")
.HasColumnType("bigint")
.HasColumnName("estimated_duration_seconds");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<Instant>("LastActivity")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_activity");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("name");
b.Property<Dictionary<string, object>>("Parameters")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("parameters");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<double>("Progress")
.HasColumnType("double precision")
.HasColumnName("progress");
b.Property<Dictionary<string, object>>("Results")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("results");
b.Property<Instant?>("StartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("started_at");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<string>("TaskId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("task_id");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_tasks");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.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.Shared.Models.SnCloudFile", 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<bool>("IsMarkedRecycle")
.HasColumnType("boolean")
.HasColumnName("is_marked_recycle");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<string>("ObjectId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("object_id");
b.PrimitiveCollection<string>("SensitiveMarks")
.HasColumnType("jsonb")
.HasColumnName("sensitive_marks");
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("ObjectId")
.HasDatabaseName("ix_files_object_id");
b.ToTable("files", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileIndex", 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>("FileId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("file_id");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("path");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_indexes");
b.HasIndex("FileId")
.HasDatabaseName("ix_file_indexes_file_id");
b.HasIndex("Path", "AccountId")
.HasDatabaseName("ix_file_indexes_path_account_id");
b.ToTable("file_indexes", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", 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.Shared.Models.SnFileObject", b =>
{
b.Property<string>("Id")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.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>("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<Dictionary<string, object>>("Meta")
.HasColumnType("jsonb")
.HasColumnName("meta");
b.Property<string>("MimeType")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("mime_type");
b.Property<long>("Size")
.HasColumnType("bigint")
.HasColumnName("size");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_objects");
b.ToTable("file_objects", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFilePermission", 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>("FileId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("file_id");
b.Property<int>("Permission")
.HasColumnType("integer")
.HasColumnName("permission");
b.Property<string>("SubjectId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("subject_id");
b.Property<int>("SubjectType")
.HasColumnType("integer")
.HasColumnName("subject_type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_permissions");
b.ToTable("file_permissions", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileReplica", 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>("IsPrimary")
.HasColumnType("boolean")
.HasColumnName("is_primary");
b.Property<string>("ObjectId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("object_id");
b.Property<Guid?>("PoolId")
.HasColumnType("uuid")
.HasColumnName("pool_id");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<string>("StorageId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("storage_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_replicas");
b.HasIndex("ObjectId")
.HasDatabaseName("ix_file_replicas_object_id");
b.HasIndex("PoolId")
.HasDatabaseName("ix_file_replicas_pool_id");
b.ToTable("file_replicas", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnFileBundle", "Bundle")
.WithMany("Files")
.HasForeignKey("BundleId")
.HasConstraintName("fk_files_bundles_bundle_id");
b.HasOne("DysonNetwork.Shared.Models.SnFileObject", "Object")
.WithMany()
.HasForeignKey("ObjectId")
.HasConstraintName("fk_files_file_objects_object_id");
b.Navigation("Bundle");
b.Navigation("Object");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileIndex", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnCloudFile", "File")
.WithMany("FileIndexes")
.HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_indexes_files_file_id");
b.Navigation("File");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileReplica", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnFileObject", "Object")
.WithMany("FileReplicas")
.HasForeignKey("ObjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_replicas_file_objects_object_id");
b.HasOne("DysonNetwork.Shared.Models.FilePool", "Pool")
.WithMany()
.HasForeignKey("PoolId")
.HasConstraintName("fk_file_replicas_pools_pool_id");
b.Navigation("Object");
b.Navigation("Pool");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
{
b.Navigation("FileIndexes");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b =>
{
b.Navigation("Files");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileObject", b =>
{
b.Navigation("FileReplicas");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Drive.Migrations
{
/// <inheritdoc />
public partial class RemovePoolFromCloudFile : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_files_pools_pool_id",
table: "files");
migrationBuilder.DropIndex(
name: "ix_files_pool_id",
table: "files");
migrationBuilder.DropColumn(
name: "pool_id",
table: "files");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "pool_id",
table: "files",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "ix_files_pool_id",
table: "files",
column: "pool_id");
migrationBuilder.AddForeignKey(
name: "fk_files_pools_pool_id",
table: "files",
column: "pool_id",
principalTable: "pools",
principalColumn: "id");
}
}
}

View File

@@ -20,7 +20,7 @@ namespace DysonNetwork.Drive.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.10")
.HasAnnotation("ProductVersion", "10.0.1")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -100,12 +100,6 @@ namespace DysonNetwork.Drive.Migrations
.HasColumnType("character varying(1024)")
.HasColumnName("description");
b.Property<string>("Discriminator")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("character varying(21)")
.HasColumnName("discriminator");
b.Property<string>("ErrorMessage")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
@@ -173,60 +167,6 @@ namespace DysonNetwork.Drive.Migrations
.HasName("pk_tasks");
b.ToTable("tasks", (string)null);
b.HasDiscriminator().HasValue("PersistentTask");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileReference", 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.Shared.Models.FilePool", b =>
@@ -321,54 +261,25 @@ namespace DysonNetwork.Drive.Migrations
.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<string>("ObjectId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("object_id");
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
b.PrimitiveCollection<string>("SensitiveMarks")
.HasColumnType("jsonb")
.HasColumnName("sensitive_marks");
b.Property<long>("Size")
.HasColumnType("bigint")
.HasColumnName("size");
b.Property<string>("StorageId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
@@ -397,8 +308,8 @@ namespace DysonNetwork.Drive.Migrations
b.HasIndex("BundleId")
.HasDatabaseName("ix_files_bundle_id");
b.HasIndex("PoolId")
.HasDatabaseName("ix_files_pool_id");
b.HasIndex("ObjectId")
.HasDatabaseName("ix_files_object_id");
b.ToTable("files", (string)null);
});
@@ -509,78 +420,153 @@ namespace DysonNetwork.Drive.Migrations
b.ToTable("bundles", (string)null);
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.Model.PersistentUploadTask", b =>
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileObject", b =>
{
b.HasBaseType("DysonNetwork.Drive.Storage.Model.PersistentTask");
b.Property<string>("Id")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("id");
b.Property<Guid?>("BundleId")
.HasColumnType("uuid")
.HasColumnName("bundle_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long>("ChunkSize")
.HasColumnType("bigint")
.HasColumnName("chunk_size");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<int>("ChunksCount")
.HasColumnType("integer")
.HasColumnName("chunks_count");
b.Property<bool>("HasCompression")
.HasColumnType("boolean")
.HasColumnName("has_compression");
b.Property<int>("ChunksUploaded")
.HasColumnType("integer")
.HasColumnName("chunks_uploaded");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("content_type");
b.Property<string>("EncryptPassword")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("encrypt_password");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("file_name");
b.Property<long>("FileSize")
.HasColumnType("bigint")
.HasColumnName("file_size");
b.Property<bool>("HasThumbnail")
.HasColumnType("boolean")
.HasColumnName("has_thumbnail");
b.Property<string>("Hash")
.IsRequired()
.HasColumnType("text")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("hash");
b.Property<string>("Path")
.HasColumnType("text")
.HasColumnName("path");
b.Property<Dictionary<string, object>>("Meta")
.HasColumnType("jsonb")
.HasColumnName("meta");
b.Property<Guid>("PoolId")
b.Property<string>("MimeType")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("mime_type");
b.Property<long>("Size")
.HasColumnType("bigint")
.HasColumnName("size");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_objects");
b.ToTable("file_objects", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFilePermission", 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>("FileId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("file_id");
b.Property<int>("Permission")
.HasColumnType("integer")
.HasColumnName("permission");
b.Property<string>("SubjectId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("subject_id");
b.Property<int>("SubjectType")
.HasColumnType("integer")
.HasColumnName("subject_type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_file_permissions");
b.ToTable("file_permissions", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileReplica", 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>("IsPrimary")
.HasColumnType("boolean")
.HasColumnName("is_primary");
b.Property<string>("ObjectId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("object_id");
b.Property<Guid?>("PoolId")
.HasColumnType("uuid")
.HasColumnName("pool_id");
b.PrimitiveCollection<List<int>>("UploadedChunks")
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<string>("StorageId")
.IsRequired()
.HasColumnType("integer[]")
.HasColumnName("uploaded_chunks");
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("storage_id");
b.HasDiscriminator().HasValue("PersistentUploadTask");
});
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileReference", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnCloudFile", "File")
.WithMany("References")
.HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_references_files_file_id");
b.HasKey("Id")
.HasName("pk_file_replicas");
b.Navigation("File");
b.HasIndex("ObjectId")
.HasDatabaseName("ix_file_replicas_object_id");
b.HasIndex("PoolId")
.HasDatabaseName("ix_file_replicas_pool_id");
b.ToTable("file_replicas", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
@@ -590,14 +576,14 @@ namespace DysonNetwork.Drive.Migrations
.HasForeignKey("BundleId")
.HasConstraintName("fk_files_bundles_bundle_id");
b.HasOne("DysonNetwork.Shared.Models.FilePool", "Pool")
b.HasOne("DysonNetwork.Shared.Models.SnFileObject", "Object")
.WithMany()
.HasForeignKey("PoolId")
.HasConstraintName("fk_files_pools_pool_id");
.HasForeignKey("ObjectId")
.HasConstraintName("fk_files_file_objects_object_id");
b.Navigation("Bundle");
b.Navigation("Pool");
b.Navigation("Object");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileIndex", b =>
@@ -612,17 +598,39 @@ namespace DysonNetwork.Drive.Migrations
b.Navigation("File");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileReplica", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnFileObject", "Object")
.WithMany("FileReplicas")
.HasForeignKey("ObjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_replicas_file_objects_object_id");
b.HasOne("DysonNetwork.Shared.Models.FilePool", "Pool")
.WithMany()
.HasForeignKey("PoolId")
.HasConstraintName("fk_file_replicas_pools_pool_id");
b.Navigation("Object");
b.Navigation("Pool");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
{
b.Navigation("FileIndexes");
b.Navigation("References");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b =>
{
b.Navigation("Files");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileObject", b =>
{
b.Navigation("FileReplicas");
});
#pragma warning restore 612, 618
}
}

View File

@@ -1,7 +1,7 @@
using DysonNetwork.Drive;
using DysonNetwork.Drive.Startup;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Networking;
using DysonNetwork.Shared.Registry;
using Microsoft.EntityFrameworkCore;
@@ -21,7 +21,7 @@ builder.Services.AddRingService();
builder.Services.AddAccountService();
builder.Services.AddAppFlushHandlers();
builder.Services.AddAppBusinessServices();
builder.Services.AddAppBusinessServices(builder.Configuration);
builder.Services.AddAppScheduledJobs();
builder.AddSwaggerManifest(

View File

@@ -16,7 +16,6 @@ public static class ApplicationBuilderExtensions
{
// Map your gRPC services here
app.MapGrpcService<FileServiceGrpc>();
app.MapGrpcService<FileReferenceServiceGrpc>();
app.MapGrpcReflectionService();
return app;

View File

@@ -3,7 +3,7 @@ using DysonNetwork.Drive.Storage;
using DysonNetwork.Drive.Storage.Model;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Stream;
using DysonNetwork.Shared.Queue;
using FFMpegCore;
using Microsoft.EntityFrameworkCore;
using NATS.Client.Core;
@@ -156,18 +156,45 @@ public class BroadcastEventHandler(
logger.LogInformation("Processing file {FileId} in background...", fileId);
var fileToUpdate = await scopedDb.Files.AsNoTracking().FirstAsync(f => f.Id == fileId);
var fileToUpdate = await scopedDb.Files
.AsNoTracking()
.Include(f => f.Object)
.FirstAsync(f => f.Id == fileId);
// Find the upload task associated with this file
var uploadTask = await scopedDb.Tasks
.OfType<PersistentUploadTask>()
.FirstOrDefaultAsync(t => t.FileName == fileToUpdate.Name && t.FileSize == fileToUpdate.Size);
var baseTask = await scopedDb.Tasks
.Where(t => t.Type == TaskType.FileUpload)
.FirstOrDefaultAsync();
if (fileToUpdate.IsEncrypted)
var uploadTask = baseTask != null ? new PersistentUploadTask
{
uploads.Add((processingFilePath, string.Empty, contentType, false));
Id = baseTask.Id,
TaskId = baseTask.TaskId,
Name = baseTask.Name,
Description = baseTask.Description,
Type = baseTask.Type,
Status = baseTask.Status,
AccountId = baseTask.AccountId,
Progress = baseTask.Progress,
Parameters = baseTask.Parameters,
Results = baseTask.Results,
ErrorMessage = baseTask.ErrorMessage,
StartedAt = baseTask.StartedAt,
CompletedAt = baseTask.CompletedAt,
ExpiredAt = baseTask.ExpiredAt,
LastActivity = baseTask.LastActivity,
Priority = baseTask.Priority,
EstimatedDurationSeconds = baseTask.EstimatedDurationSeconds,
CreatedAt = baseTask.CreatedAt,
UpdatedAt = baseTask.UpdatedAt
} : null;
if (uploadTask != null && (uploadTask.FileName != fileToUpdate.Name || uploadTask.FileSize != fileToUpdate.Size))
{
uploadTask = null;
}
else if (!pool.PolicyConfig.NoOptimization)
if (!pool.PolicyConfig.NoOptimization)
{
var fileExtension = Path.GetExtension(processingFilePath);
switch (contentType.Split('/')[0])
@@ -287,12 +314,26 @@ public class BroadcastEventHandler(
logger.LogInformation("Uploaded file {FileId} done!", fileId);
var now = SystemClock.Instance.GetCurrentInstant();
var newReplica = new SnFileReplica
{
Id = Guid.NewGuid(),
ObjectId = fileId,
PoolId = destPool,
StorageId = storageId,
Status = SnFileReplicaStatus.Available,
IsPrimary = false
};
scopedDb.FileReplicas.Add(newReplica);
await scopedDb.Files.Where(f => f.Id == fileId).ExecuteUpdateAsync(setter => setter
.SetProperty(f => f.UploadedAt, now)
.SetProperty(f => f.PoolId, destPool)
.SetProperty(f => f.MimeType, newMimeType)
.SetProperty(f => f.HasCompression, hasCompression)
.SetProperty(f => f.HasThumbnail, hasThumbnail)
);
await scopedDb.FileObjects.Where(fo => fo.Id == fileId).ExecuteUpdateAsync(setter => setter
.SetProperty(fo => fo.MimeType, newMimeType)
.SetProperty(fo => fo.HasCompression, hasCompression)
.SetProperty(fo => fo.HasThumbnail, hasThumbnail)
);
// Only delete temp file after successful upload and db update

View File

@@ -29,6 +29,13 @@ public static class ScheduledJobsConfiguration
.ForJob(persistentTaskCleanupJob)
.WithIdentity("PersistentTaskCleanupTrigger")
.WithCronSchedule("0 0 2 * * ?")); // Run daily at 2 AM
var fileObjectCleanupJob = new JobKey("FileObjectCleanup");
q.AddJob<FileObjectCleanupJob>(opts => opts.WithIdentity(fileObjectCleanupJob));
q.AddTrigger(opts => opts
.ForJob(fileObjectCleanupJob)
.WithIdentity("FileObjectCleanupTrigger")
.WithCronSchedule("0 0 1 * * ?")); // Run daily at 1 AM
});
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);

View File

@@ -9,9 +9,11 @@ namespace DysonNetwork.Drive.Startup;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
extension(IServiceCollection services)
{
services.AddDbContext<AppDatabase>(); // Assuming you'll have an AppDatabase
public IServiceCollection AddAppServices(IConfiguration configuration)
{
services.AddDbContext<AppDatabase>();
services.AddHttpContextAccessor();
services.AddHttpClient();
@@ -37,30 +39,34 @@ public static class ServiceCollectionExtensions
return services;
}
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
public IServiceCollection AddAppAuthentication()
{
services.AddAuthorization();
return services;
}
public static IServiceCollection AddAppFlushHandlers(this IServiceCollection services)
public IServiceCollection AddAppFlushHandlers()
{
services.AddSingleton<FlushBufferService>();
return services;
}
public static IServiceCollection AddAppBusinessServices(this IServiceCollection services)
public IServiceCollection AddAppBusinessServices(IConfiguration configuration)
{
services.Configure<Storage.Options.FileReanalysisOptions>(configuration.GetSection("FileReanalysis"));
services.AddScoped<Storage.FileService>();
services.AddScoped<Storage.FileReferenceService>();
services.AddScoped<Storage.FileReanalysisService>();
services.AddScoped<Storage.PersistentTaskService>();
services.AddScoped<FileIndexService>();
services.AddScoped<Billing.UsageService>();
services.AddScoped<Billing.QuotaService>();
services.AddHostedService<BroadcastEventHandler>();
services.AddHostedService<Storage.FileReanalysisBackgroundService>();
return services;
}
}
}

View File

@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using NodaTime;
using DysonNetwork.Shared.Models;
using Quartz;
namespace DysonNetwork.Drive.Storage;
@@ -40,14 +41,16 @@ public class CloudFileUnusedRecyclingJob(
var markedCount = 0;
var totalFiles = await db.Files
.Where(f => f.FileIndexes.Count == 0)
.Where(f => f.PoolId.HasValue && recyclablePools.Contains(f.PoolId.Value))
.Where(f => f.Object!.FileReplicas.Any(r => r.PoolId.HasValue && recyclablePools.Contains(r.PoolId.Value)))
.Where(f => !f.IsMarkedRecycle)
.Include(f => f.Object)
.ThenInclude(o => o.FileReplicas)
.CountAsync();
logger.LogInformation("Found {TotalFiles} files to check for unused status", totalFiles);
// Define a timestamp to limit the age of files we're processing in this run
// This spreads the processing across multiple job runs for very large databases
// This spreads processing across multiple job runs for very large databases
var ageThreshold = now - Duration.FromDays(30); // Process files up to 90 days old in this run
// Instead of loading all files at once, use pagination
@@ -56,17 +59,18 @@ public class CloudFileUnusedRecyclingJob(
while (hasMoreFiles)
{
// Query for the next batch of files using keyset pagination
var filesQuery = db.Files
.Where(f => f.PoolId.HasValue && recyclablePools.Contains(f.PoolId.Value))
IQueryable<SnCloudFile> baseQuery = db.Files
.Where(f => f.Object!.FileReplicas.Any(r => r.PoolId.HasValue && recyclablePools.Contains(r.PoolId.Value)))
.Where(f => !f.IsMarkedRecycle)
.Where(f => f.CreatedAt <= ageThreshold); // Only process older files first
.Where(f => f.CreatedAt <= ageThreshold)
.Include(f => f.Object)
.ThenInclude(o => o.FileReplicas);
if (lastProcessedId != null)
filesQuery = filesQuery.Where(f => string.Compare(f.Id, lastProcessedId) > 0);
baseQuery = baseQuery.Where(f => string.Compare(f.Id, lastProcessedId) > 0);
var fileBatch = await filesQuery
.OrderBy(f => f.Id) // Ensure consistent ordering for pagination
var fileBatch = await baseQuery
.OrderBy(f => f.Id)
.Take(batchSize)
.Select(f => f.Id)
.ToListAsync();
@@ -80,13 +84,11 @@ public class CloudFileUnusedRecyclingJob(
processedCount += fileBatch.Count;
lastProcessedId = fileBatch.Last();
// Optimized query: Find files that have no references OR all references are expired
// This replaces the memory-intensive approach of loading all references
// Optimized query: Find files that have no file object or no replicas
// A file is considered "unused" if its file object has no replicas
var filesToMark = await db.Files
.Where(f => fileBatch.Contains(f.Id))
.Where(f => !db.FileReferences.Any(r => r.FileId == f.Id) || // No references at all
!db.FileReferences.Any(r => r.FileId == f.Id && // OR has references but all are expired
(r.ExpiredAt == null || r.ExpiredAt > now)))
.Where(f => f.Object == null || f.Object.FileReplicas.Count == 0)
.Select(f => f.Id)
.ToListAsync();

View File

@@ -1,3 +1,5 @@
using System.Security.Cryptography;
using System.Text;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
@@ -14,10 +16,14 @@ public class FileController(
AppDatabase db,
FileService fs,
IConfiguration configuration,
IWebHostEnvironment env,
FileReferenceService fileReferenceService
IWebHostEnvironment env
) : ControllerBase
{
private string AccessTokenSecret => configuration["AccessToken:Secret"]
?? "dyson-network-default-access-token-secret-change-in-production";
private static readonly TimeSpan LocalSignedUrlExpiry = TimeSpan.FromMinutes(10);
[HttpGet("{id}")]
public async Task<ActionResult> OpenFile(
string id,
@@ -32,7 +38,8 @@ public class FileController(
var file = await fs.GetFileAsync(fileId);
if (file is null) return NotFound("File not found.");
var accessResult = await ValidateFileAccess(file, passcode);
var currentUser = HttpContext.Items["CurrentUser"] as Account;
var accessResult = await ValidateFileAccess(file, passcode, currentUser);
if (accessResult is not null) return accessResult;
// Handle direct storage URL redirect
@@ -47,7 +54,7 @@ public class FileController(
return await ServeRemoteFile(file, fileExtension, download, original, thumbnail, overrideMimeType);
}
private (string fileId, string? extension) ParseFileId(string id)
private static (string fileId, string? extension) ParseFileId(string id)
{
if (!id.Contains('.')) return (id, null);
@@ -55,38 +62,186 @@ public class FileController(
return (parts.First(), parts.Last());
}
private async Task<ActionResult?> ValidateFileAccess(SnCloudFile file, string? passcode)
private async Task<ActionResult?> ValidateFileAccess(SnCloudFile file, string? passcode,
Account? currentUser = null)
{
if (file.Bundle is not null && !file.Bundle.VerifyPasscode(passcode))
return StatusCode(StatusCodes.Status403Forbidden, "The passcode is incorrect.");
return null;
var hasAccess = await CheckFilePermissionAsync(file, currentUser, SnFilePermissionLevel.Read);
return !hasAccess
? StatusCode(StatusCodes.Status403Forbidden, "You don't have permission to access this file.")
: null;
}
private async Task<bool> CheckFilePermissionAsync(
SnCloudFile file,
Account? currentUser,
SnFilePermissionLevel requiredLevel
)
{
if (currentUser?.IsSuperuser == true)
return true;
Guid? accountId = currentUser is not null ? Guid.Parse(currentUser.Id) : null;
if (file.AccountId == accountId)
return true;
var permissions = await db.FilePermissions
.Where(p => p.FileId == file.Id)
.ToListAsync();
foreach (var perm in permissions)
{
switch (perm.SubjectType)
{
case SnFilePermissionType.Anyone:
case SnFilePermissionType.Someone when currentUser != null && perm.SubjectId == currentUser.Id:
if (requiredLevel == SnFilePermissionLevel.Read ||
(requiredLevel == SnFilePermissionLevel.Write && perm.Permission == SnFilePermissionLevel.Write))
return true;
break;
}
}
return false;
}
private async Task<bool> HasWritePermissionAsync(SnCloudFile file, Account? currentUser)
{
if (currentUser?.IsSuperuser == true)
return true;
if (currentUser is not null && file.AccountId == Guid.Parse(currentUser.Id))
return true;
var permissions = await db.FilePermissions
.Where(p => p.FileId == file.Id)
.ToListAsync();
foreach (var perm in permissions)
{
if (perm.Permission != SnFilePermissionLevel.Write) continue;
switch (perm.SubjectType)
{
case SnFilePermissionType.Anyone:
return true;
case SnFilePermissionType.Someone when currentUser != null && perm.SubjectId == currentUser.Id:
return true;
}
}
return false;
}
private Task<ActionResult> ServeLocalFile(SnCloudFile file)
{
// Try temp storage first
var currentUser = HttpContext.Items["CurrentUser"] as Account;
var hasWritePermission = Task.Run(() => HasWritePermissionAsync(file, currentUser)).GetAwaiter().GetResult();
var accessToken = GenerateLocalSignedToken(file.Id, currentUser?.Id, hasWritePermission);
var gatewayUrl = configuration["GatewayUrl"];
var accessUrl = $"{gatewayUrl}/drive/files/{file.Id}/access?token={accessToken}";
return Task.FromResult<ActionResult>(Redirect(accessUrl));
}
[HttpGet("{id}/access")]
public async Task<ActionResult> AccessFile(string id, [FromQuery] string token)
{
var validation = ValidateLocalSignedToken(token);
if (!validation.IsValid)
return StatusCode(StatusCodes.Status403Forbidden, "Invalid or expired access token.");
if (validation.FileId != id)
return StatusCode(StatusCodes.Status400BadRequest, "Token mismatch.");
var file = await fs.GetFileAsync(id);
if (file is null) return NotFound("File not found.");
var tempFilePath = Path.Combine(Path.GetTempPath(), file.Id);
if (System.IO.File.Exists(tempFilePath))
{
if (file.IsEncrypted)
return Task.FromResult<ActionResult>(StatusCode(StatusCodes.Status403Forbidden,
"Encrypted files cannot be accessed before they are processed and stored."));
return Task.FromResult<ActionResult>(PhysicalFile(tempFilePath, file.MimeType ?? "application/octet-stream",
file.Name, enableRangeProcessing: true));
return PhysicalFile(tempFilePath, file.MimeType ?? "application/octet-stream",
file.Name, enableRangeProcessing: true);
}
// Fallback for tus uploads
var tusStorePath = configuration.GetValue<string>("Storage:Uploads");
if (string.IsNullOrEmpty(tusStorePath))
return Task.FromResult<ActionResult>(StatusCode(StatusCodes.Status400BadRequest,
"File is being processed. Please try again later."));
return StatusCode(StatusCodes.Status400BadRequest,
"File is being processed. Please try again later.");
var tusFilePath = Path.Combine(env.ContentRootPath, tusStorePath, file.Id);
return System.IO.File.Exists(tusFilePath)
? Task.FromResult<ActionResult>(PhysicalFile(tusFilePath, file.MimeType ?? "application/octet-stream",
file.Name, enableRangeProcessing: true))
: Task.FromResult<ActionResult>(StatusCode(StatusCodes.Status400BadRequest,
"File is being processed. Please try again later."));
if (System.IO.File.Exists(tusFilePath))
{
return PhysicalFile(tusFilePath, file.MimeType ?? "application/octet-stream",
file.Name, enableRangeProcessing: true);
}
return StatusCode(StatusCodes.Status400BadRequest,
"File is being processed. Please try again later.");
}
private string GenerateLocalSignedToken(string fileId, string? userId, bool hasWritePermission)
{
var expiry = DateTimeOffset.UtcNow.Add(LocalSignedUrlExpiry).ToUnixTimeSeconds();
var payload = $"{fileId}|{userId ?? ""}|{expiry}|{hasWritePermission}";
var payloadBytes = Encoding.UTF8.GetBytes(payload);
var payloadBase64 = Convert.ToBase64String(payloadBytes);
var signature = ComputeHmacSignature(payloadBase64);
var token = $"{payloadBase64}.{signature}";
return Uri.EscapeDataString(token);
}
private (bool IsValid, string FileId, string? UserId, bool HasWritePermission) ValidateLocalSignedToken(
string token)
{
try
{
var tokenDecoded = Uri.UnescapeDataString(token);
var parts = tokenDecoded.Split('.');
if (parts.Length != 2)
return (false, string.Empty, null, false);
var payloadBase64 = parts[0];
var providedSignature = parts[1];
var expectedSignature = ComputeHmacSignature(payloadBase64);
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expectedSignature),
Encoding.UTF8.GetBytes(providedSignature)))
return (false, string.Empty, null, false);
var payloadBytes = Convert.FromBase64String(payloadBase64);
var payload = Encoding.UTF8.GetString(payloadBytes);
var payloadParts = payload.Split('|');
if (payloadParts.Length < 4)
return (false, string.Empty, null, false);
var fileId = payloadParts[0];
var userId = string.IsNullOrEmpty(payloadParts[1]) ? null : payloadParts[1];
var expiry = long.Parse(payloadParts[2]);
var hasWritePermission = bool.Parse(payloadParts[3]);
if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > expiry)
return (false, string.Empty, null, false);
return (true, fileId, userId, hasWritePermission);
}
catch
{
return (false, string.Empty, null, false);
}
}
private string ComputeHmacSignature(string data)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(AccessTokenSecret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(data));
return Convert.ToHexString(hash).ToLowerInvariant();
}
private async Task<ActionResult> ServeRemoteFile(
@@ -98,11 +253,12 @@ public class FileController(
string? overrideMimeType
)
{
if (!file.PoolId.HasValue)
var primaryReplica = file.Object?.FileReplicas.FirstOrDefault(r => r.IsPrimary);
if (primaryReplica == null || primaryReplica.PoolId == null)
return StatusCode(StatusCodes.Status500InternalServerError,
"File is in an inconsistent state: uploaded but no pool ID.");
var pool = await fs.GetPoolAsync(file.PoolId.Value);
var pool = await fs.GetPoolAsync(primaryReplica.PoolId.Value);
if (pool is null)
return StatusCode(StatusCodes.Status410Gone, "The pool of the file no longer exists or not accessible.");
@@ -145,9 +301,7 @@ public class FileController(
private ActionResult? TryProxyRedirect(SnCloudFile file, RemoteStorageConfig dest, string fileName)
{
if (dest.ImageProxy is not null && (file.MimeType?.StartsWith("image/") ?? false))
{
return Redirect(BuildProxyUrl(dest.ImageProxy, fileName));
}
return dest.AccessProxy is not null ? Redirect(BuildProxyUrl(dest.AccessProxy, fileName)) : null;
}
@@ -168,7 +322,7 @@ public class FileController(
string? overrideMimeType
)
{
var client = fs.CreateMinioClient(dest);
var client = FileService.CreateMinioClient(dest);
if (client is null)
return BadRequest("Failed to configure client for remote destination, file got an invalid storage remote.");
@@ -182,6 +336,9 @@ public class FileController(
.WithHeaders(headers)
);
if (dest.AccessEndpoint is not null)
openUrl = openUrl.Replace($"{dest.Endpoint}/{dest.Bucket}", dest.AccessEndpoint);
return Redirect(openUrl);
}
@@ -231,17 +388,18 @@ public class FileController(
}
[HttpGet("{id}/references")]
public async Task<ActionResult<List<Shared.Models.SnCloudFileReference>>> GetFileReferences(string id)
public async Task<ActionResult<List<SnCloudFile>>> GetFileReferences(string id)
{
var file = await fs.GetFileAsync(id);
if (file is null) return NotFound("File not found.");
// Check if user has access to the file
var accessResult = await ValidateFileAccess(file, null);
var currentUser = HttpContext.Items["CurrentUser"] as Account;
var accessResult = await ValidateFileAccess(file, null, currentUser);
if (accessResult is not null) return accessResult;
// Get references using the injected FileReferenceService
var references = await fileReferenceService.GetReferencesAsync(id);
var references = await db.Files
.Where(f => f.ObjectId == file.ObjectId && f.Id != file.Id)
.ToListAsync();
return Ok(references);
}
@@ -304,10 +462,10 @@ public class FileController(
var filesQuery = db.Files
.Where(e => e.IsMarkedRecycle == recycled)
.Where(e => e.AccountId == accountId)
.Include(e => e.Pool)
.Include(e => e.Object)
.AsQueryable();
if (pool.HasValue) filesQuery = filesQuery.Where(e => e.PoolId == pool);
if (pool.HasValue) filesQuery = filesQuery.Where(e => e.Object!.FileReplicas.Any(r => r.PoolId == pool.Value));
if (!string.IsNullOrWhiteSpace(query))
{

View File

@@ -1,70 +0,0 @@
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Quartz;
namespace DysonNetwork.Drive.Storage;
/// <summary>
/// Job responsible for cleaning up expired file references
/// </summary>
public class FileExpirationJob(AppDatabase db, FileService fileService, ILogger<FileExpirationJob> logger) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
var now = SystemClock.Instance.GetCurrentInstant();
logger.LogInformation("Running file reference expiration job at {now}", now);
// Delete expired references in bulk and get affected file IDs
var affectedFileIds = await db.FileReferences
.Where(r => r.ExpiredAt < now && r.ExpiredAt != null)
.Select(r => r.FileId)
.Distinct()
.ToListAsync();
if (!affectedFileIds.Any())
{
logger.LogInformation("No expired file references found");
return;
}
logger.LogInformation("Found expired references for {count} files", affectedFileIds.Count);
// Delete expired references in bulk
var deletedReferencesCount = await db.FileReferences
.Where(r => r.ExpiredAt < now && r.ExpiredAt != null)
.ExecuteDeleteAsync();
logger.LogInformation("Deleted {count} expired file references", deletedReferencesCount);
// Find files that now have no remaining references (bulk operation)
var filesToDelete = await db.Files
.Where(f => affectedFileIds.Contains(f.Id))
.Where(f => !db.FileReferences.Any(r => r.FileId == f.Id))
.Select(f => f.Id)
.ToListAsync();
if (filesToDelete.Any())
{
logger.LogInformation("Deleting {count} files that have no remaining references", filesToDelete.Count);
// Get files for deletion
var files = await db.Files
.Where(f => filesToDelete.Contains(f.Id))
.ToListAsync();
// Delete files and their data in parallel
var deleteTasks = files.Select(f => fileService.DeleteFileAsync(f));
await Task.WhenAll(deleteTasks);
}
// Purge cache for files that still have references
var filesWithRemainingRefs = affectedFileIds.Except(filesToDelete).ToList();
if (filesWithRemainingRefs.Any())
{
var cachePurgeTasks = filesWithRemainingRefs.Select(fileService._PurgeCacheAsync);
await Task.WhenAll(cachePurgeTasks);
}
logger.LogInformation("Completed file reference expiration job");
}
}

View File

@@ -0,0 +1,97 @@
using Microsoft.EntityFrameworkCore;
using Minio.DataModel.Args;
using NodaTime;
using Quartz;
namespace DysonNetwork.Drive.Storage;
/// <summary>
/// Job responsible for cleaning up orphaned file objects
/// When no SnCloudFile references a SnFileObject, the file object is considered orphaned
/// and should be deleted from disk and database
/// </summary>
public class FileObjectCleanupJob(AppDatabase db, FileService fileService, ILogger<FileObjectCleanupJob> logger) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
var now = SystemClock.Instance.GetCurrentInstant();
logger.LogInformation("Running file object cleanup job at {now}", now);
// Find orphaned file objects (objects with no cloud files referencing them)
var referencedObjectIds = await db.Files
.Where(f => f.ObjectId != null)
.Select(f => f.ObjectId)
.Distinct()
.ToListAsync();
var orphanedObjects = await db.FileObjects
.Where(fo => !referencedObjectIds.Contains(fo.Id))
.ToListAsync();
if (!orphanedObjects.Any())
{
logger.LogInformation("No orphaned file objects found");
return;
}
logger.LogInformation("Found {count} orphaned file objects", orphanedObjects.Count);
// Delete orphaned objects and their data
foreach (var fileObject in orphanedObjects)
{
try
{
var replicas = await db.FileReplicas
.Where(r => r.ObjectId == fileObject.Id)
.ToListAsync();
foreach (var replica in replicas.Where(r => r.PoolId.HasValue))
{
var dest = await fileService.GetRemoteStorageConfig(replica.PoolId!.Value);
if (dest == null) continue;
var client = FileService.CreateMinioClient(dest);
if (client == null) continue;
try
{
await client.RemoveObjectAsync(
new RemoveObjectArgs()
.WithBucket(dest.Bucket)
.WithObject(replica.StorageId)
);
if (fileObject.HasCompression)
{
await client.RemoveObjectAsync(
new RemoveObjectArgs()
.WithBucket(dest.Bucket)
.WithObject(replica.StorageId + ".compressed")
);
}
if (fileObject.HasThumbnail)
{
await client.RemoveObjectAsync(
new RemoveObjectArgs()
.WithBucket(dest.Bucket)
.WithObject(replica.StorageId + ".thumbnail")
);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delete orphaned file object {ObjectId} from remote storage", fileObject.Id);
}
}
db.FileReplicas.RemoveRange(replicas);
db.FileObjects.Remove(fileObject);
await db.SaveChangesAsync();
logger.LogInformation("Deleted orphaned file object {ObjectId}", fileObject.Id);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to clean up orphaned file object {ObjectId}", fileObject.Id);
}
}
logger.LogInformation("Completed file object cleanup job");
}
}

View File

@@ -0,0 +1,27 @@
namespace DysonNetwork.Drive.Storage;
public class FileReanalysisBackgroundService(FileReanalysisService reanalysisService, ILogger<FileReanalysisBackgroundService> logger, IConfiguration config) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("File reanalysis background service started");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await reanalysisService.ProcessNextFileAsync();
}
catch (Exception ex)
{
logger.LogError(ex, "Error during file reanalysis");
}
// Wait configured milliseconds before processing next file
var delayMs = config.GetValue("FileReanalysis:DelayMs", 10000);
await Task.Delay(TimeSpan.FromMilliseconds(delayMs), stoppingToken);
}
logger.LogInformation("File reanalysis background service stopped");
}
}

View File

@@ -0,0 +1,577 @@
using System.Globalization;
using System.Security.Cryptography;
using DysonNetwork.Drive.Storage.Options;
using FFMpegCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Minio;
using Minio.DataModel.Args;
using Minio.Exceptions;
using NetVips;
using DysonNetwork.Shared.Models;
using NodaTime;
namespace DysonNetwork.Drive.Storage;
public class FileReanalysisService(
AppDatabase db,
ILogger<FileReanalysisService> logger,
IOptions<FileReanalysisOptions> options)
{
private readonly FileReanalysisOptions _options = options.Value;
private readonly HashSet<string> _failedFileIds = [];
private readonly Dictionary<string, HashSet<string>> _bucketObjectCache = new();
private int _totalProcessed = 0;
private int _reanalysisSuccess = 0;
private int _reanalysisFailure = 0;
private int _validationCompressionProcessed = 0;
private int _validationThumbnailProcessed = 0;
private async Task<List<SnCloudFile>> GetFilesNeedingReanalysisAsync(int limit = 1000)
{
var now = SystemClock.Instance.GetCurrentInstant();
var deadline = now.Minus(Duration.FromMinutes(30));
return await db.Files
.Where(f => f.ObjectId != null)
.Include(f => f.Object)
.ThenInclude(f => f.FileReplicas)
.Where(f => ((f.Object!.MimeType == null || !f.Object.MimeType.StartsWith("application/")) &&
(f.Object!.Meta == null || f.Object.Meta.Count == 0)) || f.Object.Size == 0 ||
f.Object.Hash == null)
.Where(f => f.Object!.FileReplicas.Count > 0)
.Where(f => f.CreatedAt <= deadline)
.OrderBy(f => f.Object!.UpdatedAt)
.Skip(_failedFileIds.Count)
.Take(limit)
.ToListAsync();
}
private async Task<List<SnCloudFile>> GetFilesNeedingCompressionValidationAsync(int offset, int limit = 1000)
{
return await db.Files
.Where(f => f.ObjectId != null)
.Include(f => f.Object)
.ThenInclude(o => o!.FileReplicas)
.Where(f => f.Object!.HasCompression)
.Where(f => f.Object!.FileReplicas.Any(r => r.IsPrimary))
.Take(limit)
.Skip(offset)
.ToListAsync();
}
private async Task<List<SnCloudFile>> GetFilesNeedingThumbnailValidationAsync(int offset, int limit = 1000)
{
return await db.Files
.Where(f => f.ObjectId != null)
.Include(f => f.Object)
.ThenInclude(o => o!.FileReplicas)
.Where(f => f.Object!.HasThumbnail)
.Where(f => f.Object!.FileReplicas.Any(r => r.IsPrimary))
.Take(limit)
.Skip(offset)
.ToListAsync();
}
private async Task<bool> ReanalyzeFileAsync(SnCloudFile file)
{
logger.LogInformation("Starting reanalysis for file {FileId}: {FileName}", file.Id, file.Name);
if (file.Object == null)
{
logger.LogWarning("File {FileId} missing object, skipping reanalysis", file.Id);
return true; // not a failure
}
if (file.Object.MimeType != null && file.Object.MimeType.StartsWith("application/") && file.Object.Size != 0 &&
file.Object.Hash != null)
{
logger.LogInformation("File {FileId} already reanalyzed, no need for reanalysis", file.Id);
return true; // skip
}
var primaryReplica = file.Object.FileReplicas.FirstOrDefault(r => r.IsPrimary);
if (primaryReplica == null)
{
logger.LogWarning("File {FileId} has no primary replica, skipping reanalysis", file.Id);
return true; // not a failure
}
var tempPath = Path.Combine(Path.GetTempPath(), $"reanalysis_{file.Id}_{Guid.NewGuid()}");
try
{
await DownloadFileAsync(file, primaryReplica, tempPath);
var fileInfo = new FileInfo(tempPath);
var actualSize = fileInfo.Length;
var actualHash = await HashFileAsync(tempPath);
var meta = await ExtractMetadataAsync(file, tempPath);
if (meta == null && !string.IsNullOrEmpty(file.MimeType) && (file.MimeType.StartsWith("image/") ||
file.MimeType.StartsWith("video/") ||
file.MimeType.StartsWith("audio/")))
{
logger.LogWarning("Failed to extract metadata for supported MIME type {MimeType} on file {FileId}",
file.MimeType, file.Id);
}
var updated = false;
if (file.Object.Size == 0 || file.Object.Size != actualSize)
{
file.Object.Size = actualSize;
updated = true;
}
if (string.IsNullOrEmpty(file.Object.Hash) || file.Object.Hash != actualHash)
{
file.Object.Hash = actualHash;
updated = true;
}
if (meta is { Count: > 0 })
{
file.Object.Meta = meta;
updated = true;
}
if (updated)
{
db.FileObjects.Update(file.Object);
await db.SaveChangesAsync();
var metaCount = meta?.Count ?? 0;
logger.LogInformation("Successfully reanalyzed file {FileId}, updated metadata with {MetaCount} fields",
file.Id, metaCount);
}
else
{
logger.LogInformation("File {FileId} already up to date", file.Id);
}
return true;
}
catch (ObjectNotFoundException)
{
logger.LogWarning("File {FileId} not found in remote storage, deleting record", file.Id);
db.Files.Remove(file);
await db.SaveChangesAsync();
return true; // handled
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to reanalyze file {FileId}", file.Id);
return false; // failure
}
finally
{
if (File.Exists(tempPath))
File.Delete(tempPath);
}
}
private async Task ValidateBatchCompressionAndThumbnailAsync(
List<SnCloudFile> files,
bool validateCompression,
bool validateThumbnail
)
{
var poolIds = files.Select(f => f.Object!.FileReplicas.First(r => r.IsPrimary).PoolId)
.Where(pid => pid.HasValue)
.Select(pid => pid!.Value)
.Distinct()
.ToList();
var pools = await db.Pools.Where(p => poolIds.Contains(p.Id)).ToDictionaryAsync(p => p.Id);
var groupedByPool = files.GroupBy(f => f.Object!.FileReplicas.First(r => r.IsPrimary).PoolId);
foreach (var group in groupedByPool)
{
if (!group.Key.HasValue) continue;
var poolId = group.Key.Value;
var poolFiles = group.ToList();
if (!pools.TryGetValue(poolId, out var pool))
{
logger.LogWarning("No pool found for pool {PoolId}, skipping batch validation", poolId);
continue;
}
var dest = pool.StorageConfig;
var client = CreateMinioClient(dest);
if (client == null)
{
logger.LogWarning("Failed to create Minio client for pool {PoolId}, skipping batch validation", poolId);
continue;
}
foreach (var file in poolFiles)
{
if (file.Object == null) continue;
var primaryReplica = file.Object.FileReplicas.FirstOrDefault(r => r.IsPrimary);
if (primaryReplica == null) continue;
var baseStorageId = primaryReplica.StorageId;
if (validateCompression && file.Object.HasCompression)
{
try
{
var statArgs = new StatObjectArgs()
.WithBucket(dest.Bucket)
.WithObject(baseStorageId + ".compressed");
await client.StatObjectAsync(statArgs);
}
catch (ObjectNotFoundException)
{
logger.LogInformation(
"File {FileId} has compression flag but compressed version not found, setting HasCompression to false",
file.Id);
await db.FileObjects
.Where(f => f.Id == file.ObjectId!)
.ExecuteUpdateAsync(p => p.SetProperty(c => c.HasCompression, false));
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to stat compressed version for file {FileId}", file.Id);
}
}
if (validateThumbnail && file.Object.HasThumbnail)
{
try
{
var statArgs = new StatObjectArgs()
.WithBucket(dest.Bucket)
.WithObject(baseStorageId + ".thumbnail");
await client.StatObjectAsync(statArgs);
}
catch (ObjectNotFoundException)
{
logger.LogInformation(
"File {FileId} has thumbnail flag but thumbnail not found, setting HasThumbnail to false",
file.Id);
await db.FileObjects
.Where(f => f.Id == file.ObjectId!)
.ExecuteUpdateAsync(p => p.SetProperty(c => c.HasThumbnail, false));
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to stat thumbnail for file {FileId}", file.Id);
}
}
}
}
}
public async Task ProcessNextFileAsync()
{
List<SnCloudFile> reanalysisFiles = [];
if (_options.Enabled)
{
reanalysisFiles = await GetFilesNeedingReanalysisAsync(10);
reanalysisFiles = reanalysisFiles.Where(f => !_failedFileIds.Contains(f.Id.ToString())).ToList();
if (reanalysisFiles.Count > 0)
{
var file = reanalysisFiles[0];
var success = await ReanalyzeFileAsync(file);
if (!success)
{
logger.LogWarning("Failed to reanalyze file {FileId}, skipping for now", file.Id);
_failedFileIds.Add(file.Id);
_reanalysisFailure++;
}
else
{
_reanalysisSuccess++;
}
_totalProcessed++;
var successRate = (_reanalysisSuccess + _reanalysisFailure) > 0
? (double)_reanalysisSuccess / (_reanalysisSuccess + _reanalysisFailure) * 100
: 0;
logger.LogInformation(
"Reanalysis progress: {ReanalysisSuccess} succeeded, {ReanalysisFailure} failed ({SuccessRate:F1}%)",
_reanalysisSuccess, _reanalysisFailure, successRate);
return;
}
}
else
{
logger.LogDebug("File reanalysis is disabled, skipping reanalysis but continuing with validation");
}
if (_options.ValidateCompression)
{
var compressionFiles = await GetFilesNeedingCompressionValidationAsync(_validationCompressionProcessed);
if (compressionFiles.Count > 0)
{
await ValidateBatchCompressionAndThumbnailAsync(compressionFiles, true, false);
_validationCompressionProcessed += compressionFiles.Count;
_totalProcessed += compressionFiles.Count;
logger.LogInformation("Batch compression validation progress: {ValidationProcessed} processed",
_validationCompressionProcessed);
return;
}
}
if (_options.ValidateThumbnails)
{
var thumbnailFiles = await GetFilesNeedingThumbnailValidationAsync(_validationThumbnailProcessed);
if (thumbnailFiles.Count > 0)
{
await ValidateBatchCompressionAndThumbnailAsync(thumbnailFiles, false, true);
_validationThumbnailProcessed += thumbnailFiles.Count;
_totalProcessed += thumbnailFiles.Count;
logger.LogInformation("Batch thumbnail validation progress: {ValidationProcessed} processed",
_validationThumbnailProcessed);
return;
}
}
if (reanalysisFiles.Count > 0 && !_options.Enabled)
{
logger.LogInformation("Reanalysis is disabled, no other work to do");
}
else
{
logger.LogInformation("No files found needing reanalysis or validation");
}
}
private async Task DownloadFileAsync(SnCloudFile file, SnFileReplica replica, string tempPath)
{
if (replica.PoolId == null)
{
throw new InvalidOperationException($"Replica for file {file.Id} has no pool ID");
}
var pool = await db.Pools.FindAsync(replica.PoolId.Value);
if (pool == null)
{
throw new InvalidOperationException($"No remote storage configured for pool {replica.PoolId}");
}
var dest = pool.StorageConfig;
var client = CreateMinioClient(dest);
if (client == null)
{
throw new InvalidOperationException($"Failed to create Minio client for pool {replica.PoolId}");
}
await using var fileStream = File.Create(tempPath);
var getObjectArgs = new GetObjectArgs()
.WithBucket(dest.Bucket)
.WithObject(replica.StorageId)
.WithCallbackStream(async (stream, cancellationToken) =>
{
await stream.CopyToAsync(fileStream, cancellationToken);
});
await client.GetObjectAsync(getObjectArgs);
logger.LogDebug("Downloaded file {FileId} to {TempPath}", file.Id, tempPath);
}
private async Task<Dictionary<string, object?>?> ExtractMetadataAsync(SnCloudFile file, string filePath)
{
var mimeType = file.MimeType;
if (string.IsNullOrEmpty(mimeType))
{
logger.LogWarning("File {FileId} has no MIME type, skipping metadata extraction", file.Id);
return null;
}
switch (mimeType.Split('/')[0])
{
case "image":
return await ExtractImageMetadataAsync(file, filePath);
case "video":
case "audio":
return await ExtractMediaMetadataAsync(file, filePath);
default:
logger.LogDebug("Skipping metadata extraction for unsupported MIME type {MimeType} on file {FileId}",
mimeType, file.Id);
return null;
}
}
private async Task<Dictionary<string, object?>?> ExtractImageMetadataAsync(SnCloudFile file, string filePath)
{
try
{
string? blurhash = null;
try
{
blurhash = BlurHashSharp.SkiaSharp.BlurHashEncoder.Encode(3, 3, filePath);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to generate blurhash for file {FileId}, skipping", file.Id);
}
await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
stream.Position = 0;
using var vipsImage = Image.NewFromStream(stream);
var width = vipsImage.Width;
var height = vipsImage.Height;
var orientation = 1;
try
{
orientation = vipsImage.Get("orientation") as int? ?? 1;
}
catch
{
// ignored
}
var meta = new Dictionary<string, object?>
{
["format"] = vipsImage.Get("vips-loader") ?? "unknown",
["width"] = width,
["height"] = height,
["orientation"] = orientation,
};
if (blurhash != null)
{
meta["blurhash"] = blurhash;
}
var exif = new Dictionary<string, object>();
foreach (var field in vipsImage.GetFields())
{
if (IsIgnoredField(field)) continue;
var value = vipsImage.Get(field);
if (field.StartsWith("exif-"))
exif[field.Replace("exif-", "")] = value;
else
meta[field] = value;
}
if (orientation is 6 or 8) (width, height) = (height, width);
meta["exif"] = exif;
meta["ratio"] = height != 0 ? (double)width / height : 0;
return meta;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to analyze image file {FileId}", file.Id);
return null;
}
}
private async Task<Dictionary<string, object?>?> ExtractMediaMetadataAsync(SnCloudFile file, string filePath)
{
try
{
var mediaInfo = await FFProbe.AnalyseAsync(filePath);
var meta = new Dictionary<string, object?>
{
["width"] = mediaInfo.PrimaryVideoStream?.Width,
["height"] = mediaInfo.PrimaryVideoStream?.Height,
["duration"] = mediaInfo.Duration.TotalSeconds,
["format_name"] = mediaInfo.Format.FormatName,
["format_long_name"] = mediaInfo.Format.FormatLongName,
["start_time"] = mediaInfo.Format.StartTime.ToString(),
["bit_rate"] = mediaInfo.Format.BitRate.ToString(CultureInfo.InvariantCulture),
["tags"] = mediaInfo.Format.Tags ?? new Dictionary<string, string>(),
["chapters"] = mediaInfo.Chapters,
["video_streams"] = mediaInfo.VideoStreams.Select(s => new
{
s.AvgFrameRate,
s.BitRate,
s.CodecName,
s.Duration,
s.Height,
s.Width,
s.Language,
s.PixelFormat,
s.Rotation
}).Where(s => double.IsNormal(s.AvgFrameRate)).ToList(),
["audio_streams"] = mediaInfo.AudioStreams.Select(s => new
{
s.BitRate,
s.Channels,
s.ChannelLayout,
s.CodecName,
s.Duration,
s.Language,
s.SampleRateHz
})
.ToList(),
};
if (mediaInfo.PrimaryVideoStream is not null)
meta["ratio"] = (double)mediaInfo.PrimaryVideoStream.Width /
mediaInfo.PrimaryVideoStream.Height;
return meta;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to analyze media file {FileId}", file.Id);
return null;
}
}
private static async Task<string> HashFileAsync(string filePath, int chunkSize = 1024 * 1024)
{
var fileInfo = new FileInfo(filePath);
if (fileInfo.Length > chunkSize * 1024 * 5)
return await HashFastApproximateAsync(filePath, chunkSize);
await using var stream = File.OpenRead(filePath);
using var md5 = MD5.Create();
var hashBytes = await md5.ComputeHashAsync(stream);
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
private static async Task<string> HashFastApproximateAsync(string filePath, int chunkSize = 1024 * 1024)
{
await using var stream = File.OpenRead(filePath);
var buffer = new byte[chunkSize * 2];
var fileLength = stream.Length;
var bytesRead = await stream.ReadAsync(buffer.AsMemory(0, chunkSize));
if (fileLength > chunkSize)
{
stream.Seek(-chunkSize, SeekOrigin.End);
bytesRead += await stream.ReadAsync(buffer.AsMemory(chunkSize, chunkSize));
}
var hash = MD5.HashData(buffer.AsSpan(0, bytesRead));
stream.Position = 0;
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static bool IsIgnoredField(string fieldName)
{
var gpsFields = new[]
{
"gps-latitude", "gps-longitude", "gps-altitude", "gps-latitude-ref", "gps-longitude-ref",
"gps-altitude-ref", "gps-timestamp", "gps-datestamp", "gps-speed", "gps-speed-ref", "gps-track",
"gps-track-ref", "gps-img-direction", "gps-img-direction-ref", "gps-dest-latitude",
"gps-dest-longitude", "gps-dest-latitude-ref", "gps-dest-longitude-ref", "gps-processing-method",
"gps-area-information"
};
if (fieldName.StartsWith("exif-GPS")) return true;
if (fieldName.StartsWith("ifd3-GPS")) return true;
if (fieldName.EndsWith("-data")) return true;
return gpsFields.Any(gpsField => fieldName.StartsWith(gpsField, StringComparison.OrdinalIgnoreCase));
}
private IMinioClient? CreateMinioClient(RemoteStorageConfig dest)
{
var client = new MinioClient()
.WithEndpoint(dest.Endpoint)
.WithRegion(dest.Region)
.WithCredentials(dest.SecretId, dest.SecretKey);
if (dest.EnableSsl) client = client.WithSSL();
return client.Build();
}
}

View File

@@ -1,524 +0,0 @@
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Drive.Storage;
public class FileReferenceService(AppDatabase db, FileService fileService, ICacheService cache)
{
private const string CacheKeyPrefix = "file:ref:";
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15);
/// <summary>
/// Creates a new reference to a file for a specific resource
/// </summary>
/// <param name="fileId">The ID of the file to reference</param>
/// <param name="usage">The usage context (e.g., "avatar", "post-attachment")</param>
/// <param name="resourceId">The ID of the resource using the file</param>
/// <param name="expiredAt">Optional expiration time for the file</param>
/// <param name="duration">Optional duration after which the file expires (alternative to expiredAt)</param>
/// <returns>The created file reference</returns>
public async Task<SnCloudFileReference> CreateReferenceAsync(
string fileId,
string usage,
string resourceId,
Instant? expiredAt = null,
Duration? duration = null
)
{
// Calculate expiration time if needed
var finalExpiration = expiredAt;
if (duration.HasValue)
finalExpiration = SystemClock.Instance.GetCurrentInstant() + duration.Value;
var reference = new SnCloudFileReference
{
FileId = fileId,
Usage = usage,
ResourceId = resourceId,
ExpiredAt = finalExpiration
};
db.FileReferences.Add(reference);
await db.SaveChangesAsync();
await fileService._PurgeCacheAsync(fileId);
return reference;
}
public async Task<List<SnCloudFileReference>> CreateReferencesAsync(
List<string> fileId,
string usage,
string resourceId,
Instant? expiredAt = null,
Duration? duration = null
)
{
var now = SystemClock.Instance.GetCurrentInstant();
var data = fileId.Select(id => new SnCloudFileReference
{
FileId = id,
Usage = usage,
ResourceId = resourceId,
ExpiredAt = expiredAt ?? now + duration,
CreatedAt = now,
UpdatedAt = now
}).ToList();
await db.BulkInsertAsync(data);
return data;
}
/// <summary>
/// Gets all references to a file
/// </summary>
/// <param name="fileId">The ID of the file</param>
/// <returns>A list of all references to the file</returns>
public async Task<List<SnCloudFileReference>> GetReferencesAsync(string fileId)
{
var cacheKey = $"{CacheKeyPrefix}list:{fileId}";
var cachedReferences = await cache.GetAsync<List<SnCloudFileReference>>(cacheKey);
if (cachedReferences is not null)
return cachedReferences;
var references = await db.FileReferences
.Where(r => r.FileId == fileId)
.ToListAsync();
await cache.SetAsync(cacheKey, references, CacheDuration);
return references;
}
public async Task<Dictionary<string, List<SnCloudFileReference>>> GetReferencesAsync(IEnumerable<string> fileIds)
{
var fileIdList = fileIds.ToList();
var result = new Dictionary<string, List<SnCloudFileReference>>();
// Check cache for each file ID
var uncachedFileIds = new List<string>();
foreach (var fileId in fileIdList)
{
var cacheKey = $"{CacheKeyPrefix}list:{fileId}";
var cachedReferences = await cache.GetAsync<List<SnCloudFileReference>>(cacheKey);
if (cachedReferences is not null)
{
result[fileId] = cachedReferences;
}
else
{
uncachedFileIds.Add(fileId);
}
}
// Fetch uncached references from database
if (uncachedFileIds.Any())
{
var dbReferences = await db.FileReferences
.Where(r => uncachedFileIds.Contains(r.FileId))
.GroupBy(r => r.FileId)
.ToDictionaryAsync(r => r.Key, r => r.ToList());
// Cache the results
foreach (var kvp in dbReferences)
{
var cacheKey = $"{CacheKeyPrefix}list:{kvp.Key}";
await cache.SetAsync(cacheKey, kvp.Value, CacheDuration);
result[kvp.Key] = kvp.Value;
}
}
return result;
}
/// <summary>
/// Gets the number of references to a file
/// </summary>
/// <param name="fileId">The ID of the file</param>
/// <returns>The number of references to the file</returns>
public async Task<int> GetReferenceCountAsync(string fileId)
{
var cacheKey = $"{CacheKeyPrefix}count:{fileId}";
var cachedCount = await cache.GetAsync<int?>(cacheKey);
if (cachedCount.HasValue)
return cachedCount.Value;
var count = await db.FileReferences
.Where(r => r.FileId == fileId)
.CountAsync();
await cache.SetAsync(cacheKey, count, CacheDuration);
return count;
}
/// <summary>
/// Gets all references for a specific resource
/// </summary>
/// <param name="resourceId">The ID of the resource</param>
/// <returns>A list of file references associated with the resource</returns>
public async Task<List<SnCloudFileReference>> GetResourceReferencesAsync(string resourceId)
{
var cacheKey = $"{CacheKeyPrefix}resource:{resourceId}";
var cachedReferences = await cache.GetAsync<List<SnCloudFileReference>>(cacheKey);
if (cachedReferences is not null)
return cachedReferences;
var references = await db.FileReferences
.Where(r => r.ResourceId == resourceId)
.ToListAsync();
await cache.SetAsync(cacheKey, references, CacheDuration);
return references;
}
/// <summary>
/// Gets all file references for a specific usage context
/// </summary>
/// <param name="usage">The usage context</param>
/// <returns>A list of file references with the specified usage</returns>
public async Task<List<SnCloudFileReference>> GetUsageReferencesAsync(string usage)
{
var cacheKey = $"{CacheKeyPrefix}usage:{usage}";
var cachedReferences = await cache.GetAsync<List<SnCloudFileReference>>(cacheKey);
if (cachedReferences is not null)
return cachedReferences;
var references = await db.FileReferences
.Where(r => r.Usage == usage)
.ToListAsync();
await cache.SetAsync(cacheKey, references, CacheDuration);
return references;
}
/// <summary>
/// Deletes references for a specific resource
/// </summary>
/// <param name="resourceId">The ID of the resource</param>
/// <returns>The number of deleted references</returns>
public async Task<int> DeleteResourceReferencesAsync(string resourceId)
{
var references = await db.FileReferences
.Where(r => r.ResourceId == resourceId)
.ToListAsync();
var fileIds = references.Select(r => r.FileId).Distinct().ToList();
db.FileReferences.RemoveRange(references);
var deletedCount = await db.SaveChangesAsync();
// Purge caches
var tasks = fileIds.Select(fileService._PurgeCacheAsync).ToList();
tasks.Add(PurgeCacheForResourceAsync(resourceId));
await Task.WhenAll(tasks);
return deletedCount;
}
/// <summary>
/// Deletes references for a specific resource and usage
/// </summary>
/// <param name="resourceId">The ID of the resource</param>
/// <param name="usage">The usage context</param>
/// <returns>The number of deleted references</returns>
public async Task<int> DeleteResourceReferencesAsync(string resourceId, string usage)
{
var references = await db.FileReferences
.Where(r => r.ResourceId == resourceId && r.Usage == usage)
.ToListAsync();
if (references.Count == 0)
return 0;
var fileIds = references.Select(r => r.FileId).Distinct().ToList();
db.FileReferences.RemoveRange(references);
var deletedCount = await db.SaveChangesAsync();
// Purge caches
var tasks = fileIds.Select(fileService._PurgeCacheAsync).ToList();
tasks.Add(PurgeCacheForResourceAsync(resourceId));
await Task.WhenAll(tasks);
return deletedCount;
}
public async Task<int> DeleteResourceReferencesBatchAsync(IEnumerable<string> resourceIds, string? usage = null)
{
var resourceIdList = resourceIds.ToList();
var references = await db.FileReferences
.Where(r => resourceIdList.Contains(r.ResourceId))
.If(usage != null, q => q.Where(q => q.Usage == usage))
.ToListAsync();
if (references.Count == 0)
return 0;
var fileIds = references.Select(r => r.FileId).Distinct().ToList();
db.FileReferences.RemoveRange(references);
var deletedCount = await db.SaveChangesAsync();
// Purge caches for files and resources
var tasks = fileIds.Select(fileService._PurgeCacheAsync).ToList();
tasks.AddRange(resourceIdList.Select(PurgeCacheForResourceAsync));
await Task.WhenAll(tasks);
return deletedCount;
}
/// <summary>
/// Deletes a specific file reference
/// </summary>
/// <param name="referenceId">The ID of the reference to delete</param>
/// <returns>True if the reference was deleted, false otherwise</returns>
public async Task<bool> DeleteReferenceAsync(Guid referenceId)
{
var reference = await db.FileReferences
.FirstOrDefaultAsync(r => r.Id == referenceId);
if (reference == null)
return false;
db.FileReferences.Remove(reference);
await db.SaveChangesAsync();
// Purge caches
await fileService._PurgeCacheAsync(reference.FileId);
await PurgeCacheForResourceAsync(reference.ResourceId);
await PurgeCacheForFileAsync(reference.FileId);
return true;
}
/// <summary>
/// Updates the files referenced by a resource
/// </summary>
/// <param name="resourceId">The ID of the resource</param>
/// <param name="newFileIds">The new list of file IDs</param>
/// <param name="usage">The usage context</param>
/// <param name="expiredAt">Optional expiration time for newly added files</param>
/// <param name="duration">Optional duration after which newly added files expire</param>
/// <returns>A list of the updated file references</returns>
public async Task<List<SnCloudFileReference>> UpdateResourceFilesAsync(
string resourceId,
IEnumerable<string>? newFileIds,
string usage,
Instant? expiredAt = null,
Duration? duration = null)
{
if (newFileIds == null)
return new List<SnCloudFileReference>();
var existingReferences = await db.FileReferences
.Where(r => r.ResourceId == resourceId && r.Usage == usage)
.ToListAsync();
var existingFileIds = existingReferences.Select(r => r.FileId).ToHashSet();
var newFileIdsList = newFileIds.ToList();
var newFileIdsSet = newFileIdsList.ToHashSet();
// Files to remove
var toRemove = existingReferences
.Where(r => !newFileIdsSet.Contains(r.FileId))
.ToList();
// Files to add
var toAdd = newFileIdsList
.Where(id => !existingFileIds.Contains(id))
.Select(id => new SnCloudFileReference
{
FileId = id,
Usage = usage,
ResourceId = resourceId
})
.ToList();
// Apply changes
if (toRemove.Any())
db.FileReferences.RemoveRange(toRemove);
if (toAdd.Any())
db.FileReferences.AddRange(toAdd);
await db.SaveChangesAsync();
// Update expiration for newly added references if specified
if ((expiredAt.HasValue || duration.HasValue) && toAdd.Any())
{
var finalExpiration = expiredAt;
if (duration.HasValue)
{
finalExpiration = SystemClock.Instance.GetCurrentInstant() + duration.Value;
}
// Update newly added references with the expiration time
var referenceIds = await db.FileReferences
.Where(r => toAdd.Select(a => a.FileId).Contains(r.FileId) &&
r.ResourceId == resourceId &&
r.Usage == usage)
.Select(r => r.Id)
.ToListAsync();
await db.FileReferences
.Where(r => referenceIds.Contains(r.Id))
.ExecuteUpdateAsync(setter => setter.SetProperty(
r => r.ExpiredAt,
_ => finalExpiration
));
}
// Purge caches
var allFileIds = existingFileIds.Union(newFileIdsSet).ToList();
var tasks = allFileIds.Select(fileService._PurgeCacheAsync).ToList();
tasks.Add(PurgeCacheForResourceAsync(resourceId));
await Task.WhenAll(tasks);
// Return updated references
return await db.FileReferences
.Where(r => r.ResourceId == resourceId && r.Usage == usage)
.ToListAsync();
}
/// <summary>
/// Gets all files referenced by a resource
/// </summary>
/// <param name="resourceId">The ID of the resource</param>
/// <param name="usage">Optional filter by usage context</param>
/// <returns>A list of files referenced by the resource</returns>
public async Task<List<SnCloudFile>> GetResourceFilesAsync(string resourceId, string? usage = null)
{
var query = db.FileReferences.Where(r => r.ResourceId == resourceId);
if (usage != null)
query = query.Where(r => r.Usage == usage);
var references = await query.ToListAsync();
var fileIds = references.Select(r => r.FileId).ToList();
return await db.Files
.Where(f => fileIds.Contains(f.Id))
.ToListAsync();
}
/// <summary>
/// Purges all caches related to a resource
/// </summary>
private async Task PurgeCacheForResourceAsync(string resourceId)
{
var cacheKey = $"{CacheKeyPrefix}resource:{resourceId}";
await cache.RemoveAsync(cacheKey);
}
/// <summary>
/// Purges all caches related to a file
/// </summary>
private async Task PurgeCacheForFileAsync(string fileId)
{
var cacheKeys = new[]
{
$"{CacheKeyPrefix}list:{fileId}",
$"{CacheKeyPrefix}count:{fileId}"
};
var tasks = cacheKeys.Select(cache.RemoveAsync);
await Task.WhenAll(tasks);
}
/// <summary>
/// Updates the expiration time for a file reference
/// </summary>
/// <param name="referenceId">The ID of the reference</param>
/// <param name="expiredAt">The new expiration time, or null to remove expiration</param>
/// <returns>True if the reference was found and updated, false otherwise</returns>
public async Task<bool> SetReferenceExpirationAsync(Guid referenceId, Instant? expiredAt)
{
var reference = await db.FileReferences
.FirstOrDefaultAsync(r => r.Id == referenceId);
if (reference == null)
return false;
reference.ExpiredAt = expiredAt;
await db.SaveChangesAsync();
await PurgeCacheForFileAsync(reference.FileId);
await PurgeCacheForResourceAsync(reference.ResourceId);
return true;
}
/// <summary>
/// Updates the expiration time for all references to a file
/// </summary>
/// <param name="fileId">The ID of the file</param>
/// <param name="expiredAt">The new expiration time, or null to remove expiration</param>
/// <returns>The number of references updated</returns>
public async Task<int> SetFileReferencesExpirationAsync(string fileId, Instant? expiredAt)
{
var rowsAffected = await db.FileReferences
.Where(r => r.FileId == fileId)
.ExecuteUpdateAsync(setter => setter.SetProperty(
r => r.ExpiredAt,
_ => expiredAt
));
if (rowsAffected > 0)
{
await fileService._PurgeCacheAsync(fileId);
await PurgeCacheForFileAsync(fileId);
}
return rowsAffected;
}
/// <summary>
/// Get all file references for a specific resource and usage type
/// </summary>
/// <param name="resourceId">The resource ID</param>
/// <param name="usageType">The usage type</param>
/// <returns>List of file references</returns>
public async Task<List<SnCloudFileReference>> GetResourceReferencesAsync(string resourceId, string usageType)
{
return await db.FileReferences
.Where(r => r.ResourceId == resourceId && r.Usage == usageType)
.ToListAsync();
}
/// <summary>
/// Check if a file has any references
/// </summary>
/// <param name="fileId">The file ID to check</param>
/// <returns>True if the file has references, false otherwise</returns>
public async Task<bool> HasFileReferencesAsync(string fileId)
{
return await db.FileReferences.AnyAsync(r => r.FileId == fileId);
}
/// <summary>
/// Updates the expiration time for a file reference using a duration from now
/// </summary>
/// <param name="referenceId">The ID of the reference</param>
/// <param name="duration">The duration after which the reference expires, or null to remove expiration</param>
/// <returns>True if the reference was found and updated, false otherwise</returns>
public async Task<bool> SetReferenceExpirationDurationAsync(Guid referenceId, Duration? duration)
{
Instant? expiredAt = null;
if (duration.HasValue)
{
expiredAt = SystemClock.Instance.GetCurrentInstant() + duration.Value;
}
return await SetReferenceExpirationAsync(referenceId, expiredAt);
}
}

View File

@@ -1,174 +0,0 @@
using DysonNetwork.Shared.Proto;
using Grpc.Core;
using NodaTime;
using Duration = NodaTime.Duration;
namespace DysonNetwork.Drive.Storage;
public class FileReferenceServiceGrpc(FileReferenceService fileReferenceService)
: Shared.Proto.FileReferenceService.FileReferenceServiceBase
{
public override async Task<Shared.Proto.CloudFileReference> CreateReference(CreateReferenceRequest request,
ServerCallContext context)
{
Instant? expiredAt = null;
if (request.ExpiredAt != null)
expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
else if (request.Duration != null)
expiredAt = SystemClock.Instance.GetCurrentInstant() +
Duration.FromTimeSpan(request.Duration.ToTimeSpan());
var reference = await fileReferenceService.CreateReferenceAsync(
request.FileId,
request.Usage,
request.ResourceId,
expiredAt
);
return reference.ToProtoValue();
}
public override async Task<CreateReferenceBatchResponse> CreateReferenceBatch(CreateReferenceBatchRequest request,
ServerCallContext context)
{
Instant? expiredAt = null;
if (request.ExpiredAt != null)
expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
else if (request.Duration != null)
expiredAt = SystemClock.Instance.GetCurrentInstant() +
Duration.FromTimeSpan(request.Duration.ToTimeSpan());
var references = await fileReferenceService.CreateReferencesAsync(
request.FilesId.ToList(),
request.Usage,
request.ResourceId,
expiredAt
);
var response = new CreateReferenceBatchResponse();
response.References.AddRange(references.Select(r => r.ToProtoValue()));
return response;
}
public override async Task<GetReferencesResponse> GetReferences(GetReferencesRequest request,
ServerCallContext context)
{
var references = await fileReferenceService.GetReferencesAsync(request.FileId);
var response = new GetReferencesResponse();
response.References.AddRange(references.Select(r => r.ToProtoValue()));
return response;
}
public override async Task<GetReferenceCountResponse> GetReferenceCount(GetReferenceCountRequest request,
ServerCallContext context)
{
var count = await fileReferenceService.GetReferenceCountAsync(request.FileId);
return new GetReferenceCountResponse { Count = count };
}
public override async Task<GetReferencesResponse> GetResourceReferences(GetResourceReferencesRequest request,
ServerCallContext context)
{
var references = await fileReferenceService.GetResourceReferencesAsync(request.ResourceId, request.Usage);
var response = new GetReferencesResponse();
response.References.AddRange(references.Select(r => r.ToProtoValue()));
return response;
}
public override async Task<GetResourceFilesResponse> GetResourceFiles(GetResourceFilesRequest request,
ServerCallContext context)
{
var files = await fileReferenceService.GetResourceFilesAsync(request.ResourceId, request.Usage);
var response = new GetResourceFilesResponse();
response.Files.AddRange(files.Select(f => f.ToProtoValue()));
return response;
}
public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferences(
DeleteResourceReferencesRequest request, ServerCallContext context)
{
int deletedCount;
if (request.Usage is null)
deletedCount = await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId);
else
deletedCount =
await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId, request.Usage!);
return new DeleteResourceReferencesResponse { DeletedCount = deletedCount };
}
public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferencesBatch(DeleteResourceReferencesBatchRequest request, ServerCallContext context)
{
var resourceIds = request.ResourceIds.ToList();
int deletedCount;
if (request.Usage is null)
deletedCount = await fileReferenceService.DeleteResourceReferencesBatchAsync(resourceIds);
else
deletedCount =
await fileReferenceService.DeleteResourceReferencesBatchAsync(resourceIds, request.Usage!);
return new DeleteResourceReferencesResponse { DeletedCount = deletedCount };
}
public override async Task<DeleteReferenceResponse> DeleteReference(DeleteReferenceRequest request,
ServerCallContext context)
{
var success = await fileReferenceService.DeleteReferenceAsync(Guid.Parse(request.ReferenceId));
return new DeleteReferenceResponse { Success = success };
}
public override async Task<UpdateResourceFilesResponse> UpdateResourceFiles(UpdateResourceFilesRequest request,
ServerCallContext context)
{
Instant? expiredAt = null;
if (request.ExpiredAt != null)
{
expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
}
else if (request.Duration != null)
{
expiredAt = SystemClock.Instance.GetCurrentInstant() +
Duration.FromTimeSpan(request.Duration.ToTimeSpan());
}
var references = await fileReferenceService.UpdateResourceFilesAsync(
request.ResourceId,
request.FileIds,
request.Usage,
expiredAt
);
var response = new UpdateResourceFilesResponse();
response.References.AddRange(references.Select(r => r.ToProtoValue()));
return response;
}
public override async Task<SetReferenceExpirationResponse> SetReferenceExpiration(
SetReferenceExpirationRequest request, ServerCallContext context)
{
Instant? expiredAt = null;
if (request.ExpiredAt != null)
{
expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
}
else if (request.Duration != null)
{
expiredAt = SystemClock.Instance.GetCurrentInstant() +
Duration.FromTimeSpan(request.Duration.ToTimeSpan());
}
var success =
await fileReferenceService.SetReferenceExpirationAsync(Guid.Parse(request.ReferenceId), expiredAt);
return new SetReferenceExpirationResponse { Success = success };
}
public override async Task<SetFileReferencesExpirationResponse> SetFileReferencesExpiration(
SetFileReferencesExpirationRequest request, ServerCallContext context)
{
var expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
var updatedCount = await fileReferenceService.SetFileReferencesExpirationAsync(request.FileId, expiredAt);
return new SetFileReferencesExpirationResponse { UpdatedCount = updatedCount };
}
public override async Task<HasFileReferencesResponse> HasFileReferences(HasFileReferencesRequest request,
ServerCallContext context)
{
var hasReferences = await fileReferenceService.HasFileReferencesAsync(request.FileId);
return new HasFileReferencesResponse { HasReferences = hasReferences };
}
}

View File

@@ -30,7 +30,7 @@ public class FileService(
public async Task<SnCloudFile?> GetFileAsync(string fileId)
{
var cacheKey = $"{CacheKeyPrefix}{fileId}";
var cacheKey = string.Concat(CacheKeyPrefix, fileId);
var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey);
if (cachedFile is not null)
@@ -38,8 +38,9 @@ public class FileService(
var file = await db.Files
.Where(f => f.Id == fileId)
.Include(f => f.Pool)
.Include(f => f.Bundle)
.Include(f => f.Object)
.ThenInclude(o => o.FileReplicas)
.FirstOrDefaultAsync();
if (file != null)
@@ -55,7 +56,7 @@ public class FileService(
foreach (var fileId in fileIds)
{
var cacheKey = $"{CacheKeyPrefix}{fileId}";
var cacheKey = string.Concat(CacheKeyPrefix, fileId);
var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey);
if (cachedFile != null)
@@ -68,12 +69,14 @@ public class FileService(
{
var dbFiles = await db.Files
.Where(f => uncachedIds.Contains(f.Id))
.Include(f => f.Pool)
.Include(f => f.Bundle)
.Include(f => f.Object)
.ThenInclude(o => o.FileReplicas)
.ToListAsync();
foreach (var file in dbFiles)
{
var cacheKey = $"{CacheKeyPrefix}{file.Id}";
var cacheKey = string.Concat(CacheKeyPrefix, file.Id);
await cache.SetAsync(cacheKey, file, CacheDuration);
cachedFiles[file.Id] = file;
}
@@ -106,7 +109,9 @@ public class FileService(
var (managedTempPath, fileSize, finalContentType) =
await PrepareFileAsync(fileId, filePath, fileName, contentType);
var file = CreateFileObject(fileId, fileName, finalContentType, fileSize, finalExpiredAt, bundle, accountId);
var fileObject = CreateFileObject(fileId, accountId, finalContentType, fileSize);
var file = CreateCloudFile(fileId, fileName, fileObject, finalExpiredAt, bundle, accountId);
if (!pool.PolicyConfig.NoMetadata)
{
@@ -114,11 +119,11 @@ public class FileService(
}
var (processingPath, isTempFile) =
await ProcessEncryptionAsync(fileId, managedTempPath, encryptPassword, pool, file);
await ProcessEncryptionAsync(fileId, managedTempPath, encryptPassword, pool, fileObject);
file.Hash = await HashFileAsync(processingPath);
fileObject.Hash = await HashFileAsync(processingPath);
await SaveFileToDatabaseAsync(file);
await SaveFileToDatabaseAsync(file, fileObject, pool.Id);
await PublishFileUploadedEventAsync(file, pool, processingPath, isTempFile);
@@ -178,11 +183,25 @@ public class FileService(
return (managedTempPath, fileSize, finalContentType);
}
private SnCloudFile CreateFileObject(
private SnFileObject CreateFileObject(
string fileId,
Guid accountId,
string contentType,
long fileSize
)
{
return new SnFileObject
{
Id = fileId,
MimeType = contentType,
Size = fileSize,
};
}
private SnCloudFile CreateCloudFile(
string fileId,
string fileName,
string contentType,
long fileSize,
SnFileObject fileObject,
Instant? expiredAt,
SnFileBundle? bundle,
Guid accountId
@@ -192,24 +211,24 @@ public class FileService(
{
Id = fileId,
Name = fileName,
MimeType = contentType,
Size = fileSize,
Object = fileObject,
ObjectId = fileId,
ExpiredAt = expiredAt,
BundleId = bundle?.Id,
AccountId = accountId,
};
}
private async Task<(string processingPath, bool isTempFile)> ProcessEncryptionAsync(
private Task<(string processingPath, bool isTempFile)> ProcessEncryptionAsync(
string fileId,
string managedTempPath,
string? encryptPassword,
FilePool pool,
SnCloudFile file
SnFileObject fileObject
)
{
if (string.IsNullOrWhiteSpace(encryptPassword))
return (managedTempPath, true);
return Task.FromResult((managedTempPath, true));
if (!pool.PolicyConfig.AllowEncryption)
throw new InvalidOperationException("Encryption is not allowed in this pool");
@@ -219,17 +238,31 @@ public class FileService(
File.Delete(managedTempPath);
file.IsEncrypted = true;
file.MimeType = "application/octet-stream";
file.Size = new FileInfo(encryptedPath).Length;
fileObject.MimeType = "application/octet-stream";
fileObject.Size = new FileInfo(encryptedPath).Length;
return (encryptedPath, true);
return Task.FromResult((encryptedPath, true));
}
private async Task SaveFileToDatabaseAsync(SnCloudFile file)
private async Task SaveFileToDatabaseAsync(SnCloudFile file, SnFileObject fileObject, Guid poolId)
{
var replica = new SnFileReplica
{
Id = Guid.NewGuid(),
ObjectId = file.Id,
PoolId = poolId,
StorageId = file.StorageId ?? file.Id,
Status = SnFileReplicaStatus.Available,
IsPrimary = true
};
db.Files.Add(file);
db.FileObjects.Add(fileObject);
db.FileReplicas.Add(replica);
await db.SaveChangesAsync();
file.ObjectId = file.Id;
file.StorageId ??= file.Id;
}
@@ -252,6 +285,8 @@ public class FileService(
private async Task ExtractMetadataAsync(SnCloudFile file, string filePath)
{
if (file.Object == null) return;
switch (file.MimeType?.Split('/')[0])
{
case "image":
@@ -297,11 +332,11 @@ public class FileService(
if (orientation is 6 or 8) (width, height) = (height, width);
meta["exif"] = exif;
meta["ratio"] = height != 0 ? (double)width / height : 0;
file.FileMeta = meta;
file.Object.Meta = meta;
}
catch (Exception ex)
{
file.FileMeta = new Dictionary<string, object?>();
file.Object.Meta = new Dictionary<string, object?>();
logger.LogError(ex, "Failed to analyze image file {FileId}", file.Id);
}
@@ -312,7 +347,7 @@ public class FileService(
try
{
var mediaInfo = await FFProbe.AnalyseAsync(filePath);
file.FileMeta = new Dictionary<string, object?>
file.Object.Meta = new Dictionary<string, object?>
{
["width"] = mediaInfo.PrimaryVideoStream?.Width,
["height"] = mediaInfo.PrimaryVideoStream?.Height,
@@ -348,7 +383,7 @@ public class FileService(
.ToList(),
};
if (mediaInfo.PrimaryVideoStream is not null)
file.FileMeta["ratio"] = (double)mediaInfo.PrimaryVideoStream.Width /
file.Object.Meta["ratio"] = (double)mediaInfo.PrimaryVideoStream.Width /
mediaInfo.PrimaryVideoStream.Height;
}
catch (Exception ex)
@@ -470,8 +505,20 @@ public class FileService(
await db.Files.Where(f => f.Id == file.Id).ExecuteUpdateAsync(updatable.ToSetPropertyCalls());
if (updateMask.Paths.Contains("file_meta"))
{
await db.FileObjects
.Where(fo => fo.Id == file.ObjectId)
.ExecuteUpdateAsync(setter => setter
.SetProperty(fo => fo.Meta, file.FileMeta));
}
await _PurgeCacheAsync(file.Id);
return await db.Files.AsNoTracking().FirstAsync(f => f.Id == file.Id);
return await db.Files
.AsNoTracking()
.Include(f => f.Object)
.ThenInclude(o => o.FileReplicas)
.FirstAsync(f => f.Id == file.Id);
}
public async Task DeleteFileAsync(SnCloudFile file, bool skipData = false)
@@ -481,17 +528,46 @@ public class FileService(
await _PurgeCacheAsync(file.Id);
if (!skipData)
{
var hasOtherReferences = await db.Files
.AnyAsync(f => f.ObjectId == file.ObjectId && f.Id != file.Id);
if (!hasOtherReferences)
await DeleteFileDataAsync(file);
}
}
public async Task DeleteFileDataAsync(SnCloudFile file, bool force = false)
{
if (!file.PoolId.HasValue) return;
if (file.ObjectId == null) return;
var replicas = await db.FileReplicas
.Where(r => r.ObjectId == file.ObjectId)
.ToListAsync();
if (replicas.Count == 0)
{
logger.LogWarning("No replicas found for file object {ObjectId}", file.ObjectId);
return;
}
var primaryReplica = replicas.FirstOrDefault(r => r.IsPrimary);
if (primaryReplica == null)
{
logger.LogWarning("No primary replica found for file object {ObjectId}", file.ObjectId);
return;
}
if (primaryReplica.PoolId == null)
{
logger.LogWarning("Primary replica has no pool ID for file object {ObjectId}", file.ObjectId);
return;
}
if (!force)
{
var sameOriginFiles = await db.Files
.Where(f => f.StorageId == file.StorageId && f.Id != file.Id)
.Where(f => f.ObjectId == file.ObjectId && f.Id != file.Id)
.Select(f => f.Id)
.ToListAsync();
@@ -499,16 +575,16 @@ public class FileService(
return;
}
var dest = await GetRemoteStorageConfig(file.PoolId.Value);
if (dest is null) throw new InvalidOperationException($"No remote storage configured for pool {file.PoolId}");
var dest = await GetRemoteStorageConfig(primaryReplica.PoolId.Value);
if (dest is null) throw new InvalidOperationException($"No remote storage configured for pool {primaryReplica.PoolId}");
var client = CreateMinioClient(dest);
if (client is null)
throw new InvalidOperationException(
$"Failed to configure client for remote destination '{file.PoolId}'"
$"Failed to configure client for remote destination '{primaryReplica.PoolId}'"
);
var bucket = dest.Bucket;
var objectId = file.StorageId ?? file.Id;
var objectId = primaryReplica.StorageId;
await client.RemoveObjectAsync(
new RemoveObjectArgs().WithBucket(bucket).WithObject(objectId)
@@ -541,36 +617,55 @@ public class FileService(
logger.LogWarning("Failed to delete thumbnail of file {fileId}", file.Id);
}
}
db.FileReplicas.RemoveRange(replicas);
var fileObject = await db.FileObjects.FindAsync(file.ObjectId);
if (fileObject != null) db.FileObjects.Remove(fileObject);
await db.SaveChangesAsync();
}
public async Task DeleteFileDataBatchAsync(List<SnCloudFile> files)
{
files = files.Where(f => f.PoolId.HasValue).ToList();
files = files.Where(f => f.ObjectId != null).ToList();
foreach (var fileGroup in files.GroupBy(f => f.PoolId!.Value))
var objectIds = files.Select(f => f.ObjectId).Distinct().ToList();
var replicas = await db.FileReplicas
.Where(r => objectIds.Contains(r.ObjectId))
.ToListAsync();
foreach (var poolGroup in replicas.Where(r => r.PoolId.HasValue).GroupBy(r => r.PoolId!.Value))
{
var dest = await GetRemoteStorageConfig(fileGroup.Key);
var dest = await GetRemoteStorageConfig(poolGroup.Key);
if (dest is null)
throw new InvalidOperationException($"No remote storage configured for pool {fileGroup.Key}");
throw new InvalidOperationException($"No remote storage configured for pool {poolGroup.Key}");
var client = CreateMinioClient(dest);
if (client is null)
throw new InvalidOperationException(
$"Failed to configure client for remote destination '{fileGroup.Key}'"
$"Failed to configure client for remote destination '{poolGroup.Key}'"
);
List<string> objectsToDelete = [];
foreach (var file in fileGroup)
foreach (var replica in poolGroup)
{
objectsToDelete.Add(file.StorageId ?? file.Id);
if (file.HasCompression) objectsToDelete.Add(file.StorageId ?? file.Id + ".compressed");
if (file.HasThumbnail) objectsToDelete.Add(file.StorageId ?? file.Id + ".thumbnail");
var file = files.First(f => f.ObjectId == replica.ObjectId);
objectsToDelete.Add(replica.StorageId);
if (file.HasCompression) objectsToDelete.Add(replica.StorageId + ".compressed");
if (file.HasThumbnail) objectsToDelete.Add(replica.StorageId + ".thumbnail");
}
await client.RemoveObjectsAsync(
new RemoveObjectsArgs().WithBucket(dest.Bucket).WithObjects(objectsToDelete)
);
db.FileReplicas.RemoveRange(poolGroup);
}
var fileObjects = await db.FileObjects
.Where(fo => objectIds.Contains(fo.Id))
.ToListAsync();
db.FileObjects.RemoveRange(fileObjects);
await db.SaveChangesAsync();
}
private async Task<SnFileBundle?> GetBundleAsync(Guid id, Guid accountId)
@@ -607,7 +702,7 @@ public class FileService(
return await GetRemoteStorageConfig(id);
}
public IMinioClient? CreateMinioClient(RemoteStorageConfig dest)
public static IMinioClient? CreateMinioClient(RemoteStorageConfig dest)
{
var client = new MinioClient()
.WithEndpoint(dest.Endpoint)
@@ -620,72 +715,16 @@ public class FileService(
internal async Task _PurgeCacheAsync(string fileId)
{
var cacheKey = $"{CacheKeyPrefix}{fileId}";
var cacheKey = string.Concat(CacheKeyPrefix, fileId);
await cache.RemoveAsync(cacheKey);
}
internal async Task _PurgeCacheRangeAsync(IEnumerable<string> fileIds)
private async Task _PurgeCacheRangeAsync(IEnumerable<string> fileIds)
{
var tasks = fileIds.Select(_PurgeCacheAsync);
await Task.WhenAll(tasks);
}
public async Task<List<SnCloudFile?>> LoadFromReference(List<SnCloudFileReferenceObject> references)
{
var cachedFiles = new Dictionary<string, SnCloudFile>();
var uncachedIds = new List<string>();
foreach (var reference in references)
{
var cacheKey = $"{CacheKeyPrefix}{reference.Id}";
var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey);
if (cachedFile != null)
{
cachedFiles[reference.Id] = cachedFile;
}
else
{
uncachedIds.Add(reference.Id);
}
}
if (uncachedIds.Count > 0)
{
var dbFiles = await db.Files
.Where(f => uncachedIds.Contains(f.Id))
.ToListAsync();
foreach (var file in dbFiles)
{
var cacheKey = $"{CacheKeyPrefix}{file.Id}";
await cache.SetAsync(cacheKey, file, CacheDuration);
cachedFiles[file.Id] = file;
}
}
return
[
.. references
.Select(r => cachedFiles.GetValueOrDefault(r.Id))
.Where(f => f != null)
];
}
public async Task<int> GetReferenceCountAsync(string fileId)
{
return await db.FileReferences
.Where(r => r.FileId == fileId)
.CountAsync();
}
public async Task<bool> IsReferencedAsync(string fileId)
{
return await db.FileReferences
.Where(r => r.FileId == fileId)
.AnyAsync();
}
private static bool IsIgnoredField(string fieldName)
{
var gpsFields = new[]
@@ -709,8 +748,6 @@ public class FileService(
.Where(f => f.AccountId == accountId && f.IsMarkedRecycle)
.ToListAsync();
var count = files.Count;
var tasks = files.Select(f => DeleteFileDataAsync(f, true));
await Task.WhenAll(tasks);
var fileIds = files.Select(f => f.Id).ToList();
await _PurgeCacheRangeAsync(fileIds);
db.RemoveRange(files);
@@ -724,8 +761,6 @@ public class FileService(
.Where(f => f.AccountId == accountId && fileIds.Contains(f.Id))
.ToListAsync();
var count = files.Count;
var tasks = files.Select(f => DeleteFileDataAsync(f, true));
await Task.WhenAll(tasks);
var fileIdsList = files.Select(f => f.Id).ToList();
await _PurgeCacheRangeAsync(fileIdsList);
db.RemoveRange(files);
@@ -735,12 +770,16 @@ public class FileService(
public async Task<int> DeletePoolRecycledFilesAsync(Guid poolId)
{
var fileIdsWithReplicas = await db.FileReplicas
.Where(r => r.PoolId == poolId)
.Select(r => r.ObjectId)
.Distinct()
.ToListAsync();
var files = await db.Files
.Where(f => f.PoolId == poolId && f.IsMarkedRecycle)
.Where(f => fileIdsWithReplicas.Contains(f.Id) && f.IsMarkedRecycle)
.ToListAsync();
var count = files.Count;
var tasks = files.Select(f => DeleteFileDataAsync(f, true));
await Task.WhenAll(tasks);
var fileIds = files.Select(f => f.Id).ToList();
await _PurgeCacheRangeAsync(fileIds);
db.RemoveRange(files);
@@ -754,8 +793,6 @@ public class FileService(
.Where(f => f.IsMarkedRecycle)
.ToListAsync();
var count = files.Count;
var tasks = files.Select(f => DeleteFileDataAsync(f, true));
await Task.WhenAll(tasks);
var fileIds = files.Select(f => f.Id).ToList();
await _PurgeCacheRangeAsync(fileIds);
db.RemoveRange(files);
@@ -763,25 +800,43 @@ public class FileService(
return count;
}
public async Task<string> CreateFastUploadLinkAsync(SnCloudFile file)
public async Task SetPublicAsync(string fileId)
{
if (file.PoolId is null) throw new InvalidOperationException("Pool ID is null");
var existingPermission = await db.FilePermissions
.FirstOrDefaultAsync(p =>
p.FileId == fileId &&
p.SubjectType == SnFilePermissionType.Anyone &&
p.Permission == SnFilePermissionLevel.Read);
var dest = await GetRemoteStorageConfig(file.PoolId.Value);
if (dest is null) throw new InvalidOperationException($"No remote storage configured for pool {file.PoolId}");
var client = CreateMinioClient(dest);
if (client is null)
throw new InvalidOperationException(
$"Failed to configure client for remote destination '{file.PoolId}'"
);
if (existingPermission != null)
return;
var url = await client.PresignedPutObjectAsync(
new PresignedPutObjectArgs()
.WithBucket(dest.Bucket)
.WithObject(file.Id)
.WithExpiry(60 * 60 * 24)
);
return url;
var permission = new SnFilePermission
{
Id = Guid.NewGuid(),
FileId = fileId,
SubjectType = SnFilePermissionType.Anyone,
SubjectId = string.Empty,
Permission = SnFilePermissionLevel.Read
};
db.FilePermissions.Add(permission);
await db.SaveChangesAsync();
}
public async Task UnsetPublicAsync(string fileId)
{
var permission = await db.FilePermissions
.FirstOrDefaultAsync(p =>
p.FileId == fileId &&
p.SubjectType == SnFilePermissionType.Anyone &&
p.Permission == SnFilePermissionLevel.Read);
if (permission == null)
return;
db.FilePermissions.Remove(permission);
await db.SaveChangesAsync();
}
}
@@ -793,13 +848,12 @@ file class UpdatableCloudFile(SnCloudFile file)
public Dictionary<string, object?>? UserMeta { get; set; } = file.UserMeta;
public bool IsMarkedRecycle { get; set; } = file.IsMarkedRecycle;
public Expression<Func<SetPropertyCalls<SnCloudFile>, SetPropertyCalls<SnCloudFile>>> ToSetPropertyCalls()
public Action<UpdateSettersBuilder<SnCloudFile>> ToSetPropertyCalls()
{
var userMeta = UserMeta ?? [];
return setter => setter
.SetProperty(f => f.Name, Name)
.SetProperty(f => f.Description, Description)
.SetProperty(f => f.FileMeta, FileMeta)
.SetProperty(f => f.UserMeta, userMeta)
.SetProperty(f => f.IsMarkedRecycle, IsMarkedRecycle);
}

View File

@@ -1,4 +1,3 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
@@ -7,7 +6,7 @@ namespace DysonNetwork.Drive.Storage
{
public class FileServiceGrpc(FileService fileService) : Shared.Proto.FileService.FileServiceBase
{
public override async Task<Shared.Proto.CloudFile> GetFile(GetFileRequest request, ServerCallContext context)
public override async Task<CloudFile> GetFile(GetFileRequest request, ServerCallContext context)
{
var file = await fileService.GetFileAsync(request.Id);
return file?.ToProtoValue() ?? throw new RpcException(new Status(StatusCode.NotFound, "File not found"));
@@ -19,7 +18,7 @@ namespace DysonNetwork.Drive.Storage
return new GetFileBatchResponse { Files = { files.Select(f => f.ToProtoValue()) } };
}
public override async Task<Shared.Proto.CloudFile> UpdateFile(UpdateFileRequest request,
public override async Task<CloudFile> UpdateFile(UpdateFileRequest request,
ServerCallContext context)
{
var file = await fileService.GetFileAsync(request.File.Id);
@@ -41,31 +40,22 @@ namespace DysonNetwork.Drive.Storage
return new Empty();
}
public override async Task<LoadFromReferenceResponse> LoadFromReference(
LoadFromReferenceRequest request,
ServerCallContext context
)
{
// Assuming CloudFileReferenceObject is a simple class/struct that holds an ID
// You might need to define this or adjust the LoadFromReference method in FileService
var references = request.ReferenceIds.Select(id => new SnCloudFileReferenceObject { Id = id }).ToList();
var files = await fileService.LoadFromReference(references);
var response = new LoadFromReferenceResponse();
response.Files.AddRange(files.Where(f => f != null).Select(f => f!.ToProtoValue()));
return response;
}
public override async Task<IsReferencedResponse> IsReferenced(IsReferencedRequest request,
ServerCallContext context)
{
var isReferenced = await fileService.IsReferencedAsync(request.FileId);
return new IsReferencedResponse { IsReferenced = isReferenced };
}
public override async Task<Empty> PurgeCache(PurgeCacheRequest request, ServerCallContext context)
{
await fileService._PurgeCacheAsync(request.FileId);
return new Empty();
}
public override async Task<Empty> SetFilePublic(SetFilePublicRequest request, ServerCallContext context)
{
await fileService.SetPublicAsync(request.FileId);
return new Empty();
}
public override async Task<Empty> UnsetFilePublic(UnsetFilePublicRequest request, ServerCallContext context)
{
await fileService.UnsetPublicAsync(request.FileId);
return new Empty();
}
}
}

View File

@@ -3,8 +3,8 @@ using DysonNetwork.Drive.Billing;
using DysonNetwork.Drive.Index;
using DysonNetwork.Drive.Storage.Model;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Networking;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -64,7 +64,10 @@ public class FileUploadController(
var accountId = Guid.Parse(currentUser.Id);
// Check if a file with the same hash already exists
var existingFile = await db.Files.FirstOrDefaultAsync(f => f.Hash == request.Hash);
var existingFile = await db.Files
.Include(f => f.Object)
.Where(f => f.Object != null && f.Object.Hash == request.Hash)
.FirstOrDefaultAsync();
if (existingFile != null)
{
// Create the file index if a path is provided, even for existing files

View File

@@ -8,8 +8,8 @@ public static class FileUploadedEvent
public record FileUploadedEventPayload(
string FileId,
Guid RemoteId,
string StorageId,
string ContentType,
string? StorageId,
string? ContentType,
string ProcessingFilePath,
bool IsTempFile
);

View File

@@ -161,7 +161,6 @@ public class PersistentTask : ModelBase
public long? EstimatedDurationSeconds { get; set; }
}
// Backward compatibility - UploadTask inherits from PersistentTask
public class PersistentUploadTask : PersistentTask
{
public PersistentUploadTask()

View File

@@ -0,0 +1,8 @@
namespace DysonNetwork.Drive.Storage.Options;
public class FileReanalysisOptions
{
public bool Enabled { get; init; } = true;
public bool ValidateCompression { get; init; } = true;
public bool ValidateThumbnails { get; init; } = true;
}

View File

@@ -664,16 +664,54 @@ public class PersistentTaskService(
if (cachedTask is not null)
return cachedTask;
var task = await db.Tasks
.OfType<PersistentUploadTask>()
.FirstOrDefaultAsync(t => t.TaskId == taskId && t.Status == TaskStatus.InProgress);
var baseTask = await db.Tasks
.FirstOrDefaultAsync(t => t.TaskId == taskId && t.Type == TaskType.FileUpload && t.Status == TaskStatus.InProgress);
if (task is not null)
if (baseTask is null)
return null;
var task = ConvertToUploadTask(baseTask);
await SetCacheAsync(task);
return task;
}
/// <summary>
/// Converts a base PersistentTask to PersistentUploadTask
/// </summary>
private PersistentUploadTask ConvertToUploadTask(PersistentTask baseTask)
{
return new PersistentUploadTask
{
Id = baseTask.Id,
TaskId = baseTask.TaskId,
Name = baseTask.Name,
Description = baseTask.Description,
Type = baseTask.Type,
Status = baseTask.Status,
AccountId = baseTask.AccountId,
Progress = baseTask.Progress,
Parameters = baseTask.Parameters,
Results = baseTask.Results,
ErrorMessage = baseTask.ErrorMessage,
StartedAt = baseTask.StartedAt,
CompletedAt = baseTask.CompletedAt,
ExpiredAt = baseTask.ExpiredAt,
LastActivity = baseTask.LastActivity,
Priority = baseTask.Priority,
EstimatedDurationSeconds = baseTask.EstimatedDurationSeconds,
CreatedAt = baseTask.CreatedAt,
UpdatedAt = baseTask.UpdatedAt
};
}
/// <summary>
/// Converts a list of base PersistentTasks to PersistentUploadTasks
/// </summary>
private List<PersistentUploadTask> ConvertToUploadTasks(List<PersistentTask> baseTasks)
{
return baseTasks.Select(ConvertToUploadTask).ToList();
}
/// <summary>
/// Updates chunk upload progress
/// </summary>
@@ -697,8 +735,7 @@ public class PersistentTaskService(
// Use ExecuteUpdateAsync to update the Parameters dictionary directly
var updatedRows = await db.Tasks
.OfType<PersistentUploadTask>()
.Where(t => t.TaskId == taskId)
.Where(t => t.TaskId == taskId && t.Type == TaskType.FileUpload)
.ExecuteUpdateAsync(setters => setters
.SetProperty(t => t.Parameters, ParameterHelper.Untyped(parameters))
.SetProperty(t => t.LastActivity, now)
@@ -754,7 +791,7 @@ public class PersistentTaskService(
int limit = 50
)
{
var query = db.Tasks.OfType<PersistentUploadTask>().Where(t => t.AccountId == accountId);
var query = db.Tasks.Where(t => t.Type == TaskType.FileUpload && t.AccountId == accountId);
// Apply status filter
if (status.HasValue)
@@ -766,19 +803,9 @@ public class PersistentTaskService(
var totalCount = await query.CountAsync();
// Apply sorting
IOrderedQueryable<PersistentUploadTask> orderedQuery;
IOrderedQueryable<PersistentTask> orderedQuery;
switch (sortBy?.ToLower())
{
case "filename":
orderedQuery = sortDescending
? query.OrderByDescending(t => t.FileName)
: query.OrderBy(t => t.FileName);
break;
case "filesize":
orderedQuery = sortDescending
? query.OrderByDescending(t => t.FileSize)
: query.OrderBy(t => t.FileSize);
break;
case "created":
orderedQuery = sortDescending
? query.OrderByDescending(t => t.CreatedAt)
@@ -798,11 +825,27 @@ public class PersistentTaskService(
}
// Apply pagination
var items = await orderedQuery
var baseTasks = await orderedQuery
.Skip(offset)
.Take(limit)
.ToListAsync();
var items = ConvertToUploadTasks(baseTasks);
// Sort by derived properties if needed (filename, filesize)
if (sortBy?.ToLower() == "filename")
{
items = sortDescending
? items.OrderByDescending(t => t.FileName).ToList()
: items.OrderBy(t => t.FileName).ToList();
}
else if (sortBy?.ToLower() == "filesize")
{
items = sortDescending
? items.OrderByDescending(t => t.FileSize).ToList()
: items.OrderBy(t => t.FileSize).ToList();
}
return (items, totalCount);
}
@@ -811,11 +854,12 @@ public class PersistentTaskService(
/// </summary>
public async Task<UserUploadStats> GetUserUploadStatsAsync(Guid accountId)
{
var tasks = await db.Tasks
.OfType<PersistentUploadTask>()
.Where(t => t.AccountId == accountId)
var baseTasks = await db.Tasks
.Where(t => t.Type == TaskType.FileUpload && t.AccountId == accountId)
.ToListAsync();
var tasks = ConvertToUploadTasks(baseTasks);
var stats = new UserUploadStats
{
TotalTasks = tasks.Count,
@@ -850,8 +894,7 @@ public class PersistentTaskService(
public async Task<int> CleanupUserFailedTasksAsync(Guid accountId)
{
var failedTasks = await db.Tasks
.OfType<PersistentUploadTask>()
.Where(t => t.AccountId == accountId &&
.Where(t => t.Type == TaskType.FileUpload && t.AccountId == accountId &&
(t.Status == TaskStatus.Failed || t.Status == TaskStatus.Expired))
.ToListAsync();
@@ -883,12 +926,13 @@ public class PersistentTaskService(
/// </summary>
public async Task<List<PersistentUploadTask>> GetRecentUserTasksAsync(Guid accountId, int limit = 10)
{
return await db.Tasks
.OfType<PersistentUploadTask>()
.Where(t => t.AccountId == accountId)
var baseTasks = await db.Tasks
.Where(t => t.Type == TaskType.FileUpload && t.AccountId == accountId)
.OrderByDescending(t => t.LastActivity)
.Take(limit)
.ToListAsync();
return ConvertToUploadTasks(baseTasks);
}
/// <summary>

View File

@@ -74,11 +74,6 @@
"FromName": "Alphabot",
"SubjectPrefix": "Solar Network"
},
"RealtimeChat": {
"Endpoint": "https://solar-network-im44o8gq.livekit.cloud",
"ApiKey": "APIs6TiL8wj3A4j",
"ApiSecret": "SffxRneIwTnlHPtEf3zicmmv3LUEl7xXael4PvWZrEhE"
},
"GeoIp": {
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
},
@@ -112,10 +107,19 @@
}
},
"Cache": {
"Serializer": "MessagePack"
"Serializer": "JSON"
},
"AccessToken": {
"Secret": "dyson-network-default-access-token-secret-change-in-production"
},
"KnownProxies": [
"127.0.0.1",
"::1"
]
],
"FileReanalysis": {
"Enabled": true,
"ValidateCompression": true,
"ValidateThumbnails": true,
"DelayMs": 10000
}
}

View File

@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Gateway.Configuration;
[ApiController]
[Route("config")]
public class ConfigurationController(IConfiguration configuration) : ControllerBase

View File

@@ -0,0 +1,54 @@
namespace DysonNetwork.Gateway.Configuration;
public class GatewayEndpointsOptions
{
public const string SectionName = "Endpoints";
/// <summary>
/// List of all services that the gateway should manage.
/// If not specified, defaults to the built-in service list.
/// </summary>
public List<string>? ServiceNames { get; set; }
/// <summary>
/// List of core services that are essential for the application to function.
/// If not specified, defaults to the built-in core service list.
/// </summary>
public List<string>? CoreServiceNames { get; set; }
/// <summary>
/// Default service names used when no configuration is provided.
/// </summary>
public static readonly string[] DefaultServiceNames =
[
"ring",
"pass",
"drive",
"sphere",
"develop",
"insight",
"zone",
"messager"
];
/// <summary>
/// Default core service names used when no configuration is provided.
/// </summary>
public static readonly string[] DefaultCoreServiceNames =
[
"ring",
"pass",
"drive",
"sphere"
];
/// <summary>
/// Gets the effective service names, using configuration if available, otherwise defaults.
/// </summary>
public string[] GetServiceNames() => ServiceNames?.ToArray() ?? DefaultServiceNames;
/// <summary>
/// Gets the effective core service names, using configuration if available, otherwise defaults.
/// </summary>
public string[] GetCoreServiceNames() => CoreServiceNames?.ToArray() ?? DefaultCoreServiceNames;
}

View File

@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery.Yarp" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery.Yarp" Version="10.1.0" />
<PackageReference Include="Nerdbank.GitVersioning" Version="3.9.50">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@@ -0,0 +1,53 @@
namespace DysonNetwork.Gateway.Health;
public abstract class GatewayConstant
{
// Default service names used when no configuration is provided
private static readonly string[] DefaultServiceNames =
[
"ring",
"pass",
"drive",
"sphere",
"develop",
"insight",
"zone",
"messager"
];
// Default core service names used when no configuration is provided
private static readonly string[] DefaultCoreServiceNames =
[
"ring",
"pass",
"drive",
"sphere"
];
// Configuration-driven service names with fallback to defaults
public static string[] ServiceNames { get; private set; } = DefaultServiceNames;
// Configuration-driven core service names with fallback to defaults
public static string[] CoreServiceNames { get; private set; } = DefaultCoreServiceNames;
/// <summary>
/// Initializes the service names from configuration options.
/// This method should be called during application startup.
/// </summary>
/// <param name="options">The gateway endpoints options containing configuration</param>
public static void InitializeFromConfiguration(DysonNetwork.Gateway.Configuration.GatewayEndpointsOptions options)
{
ServiceNames = options.GetServiceNames();
CoreServiceNames = options.GetCoreServiceNames();
}
/// <summary>
/// Resets the service names to their default values.
/// Useful for testing or when configuration is not available.
/// </summary>
public static void ResetToDefaults()
{
ServiceNames = DefaultServiceNames;
CoreServiceNames = DefaultCoreServiceNames;
}
}

View File

@@ -0,0 +1,60 @@
using NodaTime;
namespace DysonNetwork.Gateway.Health;
public class GatewayHealthAggregator(IHttpClientFactory httpClientFactory, GatewayReadinessStore store)
: BackgroundService
{
private async Task<ServiceHealthState> CheckService(string serviceName)
{
var client = httpClientFactory.CreateClient("health");
var now = SystemClock.Instance.GetCurrentInstant();
try
{
// Use the service discovery to lookup service
// The service defaults give every single service a health endpoint that we can use here
using var response = await client.GetAsync($"http://{serviceName}/health");
if (response.IsSuccessStatusCode)
{
return new ServiceHealthState(
serviceName,
true,
now,
null
);
}
return new ServiceHealthState(
serviceName,
false,
now,
$"StatusCode: {(int)response.StatusCode}"
);
}
catch (Exception ex)
{
return new ServiceHealthState(
serviceName,
false,
now,
ex.Message
);
}
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
foreach (var service in GatewayConstant.ServiceNames)
{
var result = await CheckService(service);
store.Update(result);
}
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}

View File

@@ -0,0 +1,35 @@
namespace DysonNetwork.Gateway.Health;
using Microsoft.AspNetCore.Http;
public sealed class GatewayReadinessMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context, GatewayReadinessStore store)
{
if (context.Request.Path.StartsWithSegments("/health"))
{
await next(context);
return;
}
var readiness = store.Current;
// Only core services participate in readiness gating
var notReadyCoreServices = readiness.Services
.Where(kv => GatewayConstant.CoreServiceNames.Contains(kv.Key))
.Where(kv => !kv.Value.IsHealthy)
.Select(kv => kv.Key)
.ToArray();
if (notReadyCoreServices.Length > 0)
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
var unavailableServices = string.Join(", ", notReadyCoreServices);
context.Response.Headers["X-NotReady"] = unavailableServices;
await context.Response.WriteAsync("Solar Network is warming up. Try again later please.");
return;
}
await next(context);
}
}

View File

@@ -0,0 +1,112 @@
using NodaTime;
namespace DysonNetwork.Gateway.Health;
public record ServiceHealthState(
string ServiceName,
bool IsHealthy,
Instant LastChecked,
string? Error
);
public record GatewayReadinessState(
bool IsReady,
IReadOnlyDictionary<string, ServiceHealthState> Services,
Instant LastUpdated
);
public class GatewayReadinessStore
{
private readonly Lock _lock = new();
private readonly Dictionary<string, ServiceHealthState> _services = new();
public GatewayReadinessState Current { get; private set; } = new(
IsReady: false,
Services: new Dictionary<string, ServiceHealthState>(),
LastUpdated: SystemClock.Instance.GetCurrentInstant()
);
public IReadOnlyCollection<string> ServiceNames => _services.Keys;
public GatewayReadinessStore()
{
InitializeServices(GatewayConstant.ServiceNames);
}
/// <summary>
/// Reinitializes the store with new service names from configuration.
/// This method should be called when configuration changes.
/// </summary>
/// <param name="serviceNames">The new service names to track</param>
public void ReinitializeServices(string[] serviceNames)
{
lock (_lock)
{
// Preserve existing health states for services that still exist
var existingStates = new Dictionary<string, ServiceHealthState>(_services);
_services.Clear();
foreach (var name in serviceNames)
{
// Use existing state if available, otherwise create new unhealthy state
if (existingStates.TryGetValue(name, out var existingState))
{
_services[name] = existingState;
}
else
{
_services[name] = new ServiceHealthState(
name,
IsHealthy: false,
LastChecked: SystemClock.Instance.GetCurrentInstant(),
Error: "Not checked yet"
);
}
}
RecalculateLocked();
}
}
private void InitializeServices(IEnumerable<string> serviceNames)
{
lock (_lock)
{
_services.Clear();
foreach (var name in serviceNames)
{
_services[name] = new ServiceHealthState(
name,
IsHealthy: false,
LastChecked: SystemClock.Instance.GetCurrentInstant(),
Error: "Not checked yet"
);
}
RecalculateLocked();
}
}
public void Update(ServiceHealthState state)
{
lock (_lock)
{
_services[state.ServiceName] = state;
RecalculateLocked();
}
}
private void RecalculateLocked()
{
var isReady = _services.Count > 0 && _services.Values.All(s => s.IsHealthy);
Current = new GatewayReadinessState(
IsReady: isReady,
Services: new Dictionary<string, ServiceHealthState>(_services),
LastUpdated: SystemClock.Instance.GetCurrentInstant()
);
}
}

View File

@@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Gateway.Health;
[ApiController]
[Route("/health")]
public class GatewayStatusController(GatewayReadinessStore readinessStore) : ControllerBase
{
[HttpGet]
public ActionResult<GatewayReadinessState> GetHealthStatus()
{
return Ok(readinessStore.Current);
}
}

View File

@@ -1,7 +1,14 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using DysonNetwork.Shared.Http;
using DysonNetwork.Gateway.Configuration;
using DysonNetwork.Gateway.Health;
using DysonNetwork.Shared.Networking;
using Yarp.ReverseProxy.Configuration;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Options;
using NodaTime;
using NodaTime.Serialization.SystemTextJson;
var builder = WebApplication.CreateBuilder(args);
@@ -9,16 +16,22 @@ builder.AddServiceDefaults();
builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxValue, enableGrpc: false);
builder.Services.AddSingleton<GatewayReadinessStore>();
builder.Services.AddHostedService<GatewayHealthAggregator>();
// Add configuration options for gateway endpoints
builder.Services.Configure<DysonNetwork.Gateway.Configuration.GatewayEndpointsOptions>(
builder.Configuration.GetSection(DysonNetwork.Gateway.Configuration.GatewayEndpointsOptions.SectionName));
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(
policy =>
options.AddDefaultPolicy(policy =>
{
policy.SetIsOriginAllowed(origin => true)
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials()
.WithExposedHeaders("X-Total");
.WithExposedHeaders("X-Total", "X-NotReady");
});
});
@@ -56,7 +69,6 @@ builder.Services.AddRateLimiter(options =>
};
});
var serviceNames = new[] { "ring", "pass", "drive", "sphere", "develop", "insight", "zone" };
var specialRoutes = new[]
{
@@ -80,13 +92,19 @@ var specialRoutes = new[]
},
new RouteConfig
{
RouteId = "drive-tus",
ClusterId = "drive",
Match = new RouteMatch { Path = "/api/tus" }
}
RouteId = "sphere-webfinger",
ClusterId = "sphere",
Match = new RouteMatch { Path = "/.well-known/webfinger" }
},
new RouteConfig
{
RouteId = "sphere-activitypub",
ClusterId = "sphere",
Match = new RouteMatch { Path = "/activitypub/{**catch-all}" }
},
};
var apiRoutes = serviceNames.Select(serviceName =>
var apiRoutes = GatewayConstant.ServiceNames.Select(serviceName =>
{
var apiPath = serviceName switch
{
@@ -105,7 +123,7 @@ var apiRoutes = serviceNames.Select(serviceName =>
};
});
var swaggerRoutes = serviceNames.Select(serviceName => new RouteConfig
var swaggerRoutes = GatewayConstant.ServiceNames.Select(serviceName => new RouteConfig
{
RouteId = $"{serviceName}-swagger",
ClusterId = serviceName,
@@ -119,7 +137,7 @@ var swaggerRoutes = serviceNames.Select(serviceName => new RouteConfig
var routes = specialRoutes.Concat(apiRoutes).Concat(swaggerRoutes).ToArray();
var clusters = serviceNames.Select(serviceName => new ClusterConfig
var clusters = GatewayConstant.ServiceNames.Select(serviceName => new ClusterConfig
{
ClusterId = serviceName,
HealthCheck = new HealthCheckConfig
@@ -131,7 +149,7 @@ var clusters = serviceNames.Select(serviceName => new ClusterConfig
Timeout = TimeSpan.FromSeconds(5),
Path = "/health"
},
Passive = new()
Passive = new PassiveHealthCheckConfig
{
Enabled = true
}
@@ -147,20 +165,38 @@ builder.Services
.LoadFromMemory(routes, clusters)
.AddServiceDiscoveryDestinationResolver();
builder.Services.AddControllers();
builder.Services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
});
var app = builder.Build();
// Initialize GatewayConstant with configuration values
var gatewayEndpointsOptions = app.Services
.GetRequiredService<IOptions<GatewayEndpointsOptions>>().Value;
GatewayConstant.InitializeFromConfiguration(gatewayEndpointsOptions);
// Reinitialize the readiness store with configured service names
var readinessStore = app.Services.GetRequiredService<GatewayReadinessStore>();
readinessStore.ReinitializeServices(GatewayConstant.ServiceNames);
var forwardedHeadersOptions = new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.All
};
forwardedHeadersOptions.KnownNetworks.Clear();
forwardedHeadersOptions.KnownIPNetworks.Clear();
forwardedHeadersOptions.KnownProxies.Clear();
app.UseForwardedHeaders(forwardedHeadersOptions);
app.UseCors();
app.UseMiddleware<GatewayReadinessMiddleware>();
app.MapReverseProxy().RequireRateLimiting("fixed");
app.MapControllers();

View File

@@ -6,11 +6,29 @@
}
},
"Cache": {
"Serializer": "MessagePack"
"Serializer": "JSON"
},
"AllowedHosts": "*",
"SiteUrl": "http://localhost:3000",
"Client": {
"SomeSetting": "SomeValue"
},
"Endpoints": {
"ServiceNames": [
"ring",
"pass",
"drive",
"sphere",
"develop",
"insight",
"zone",
"messager"
],
"CoreServiceNames": [
"ring",
"pass",
"drive",
"sphere"
]
}
}

View File

@@ -15,6 +15,10 @@ public class AppDatabase(
public DbSet<SnThinkingThought> ThinkingThoughts { get; set; }
public DbSet<SnUnpaidAccount> UnpaidAccounts { get; set; }
public DbSet<SnWebArticle> WebArticles { get; set; }
public DbSet<SnWebFeed> WebFeeds { get; set; }
public DbSet<SnWebFeedSubscription> WebFeedSubscriptions { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseNpgsql(
@@ -38,6 +42,8 @@ public class AppDatabase(
{
base.OnModelCreating(modelBuilder);
modelBuilder.Ignore<SnAccount>();
modelBuilder.ApplySoftDeleteFilters();
}
}

View File

@@ -1,6 +1,9 @@
#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libkrb5-3 \
libgssapi-krb5-2 \
&& rm -rf /var/lib/apt/lists/*
USER app
WORKDIR /app
EXPOSE 8080

View File

@@ -7,17 +7,27 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<PackageReference Include="AngleSharp" Version="1.4.0" />
<PackageReference Include="Google.Protobuf" Version="3.33.2" />
<PackageReference Include="Grpc.AspNetCore.Server.ClientFactory" Version="2.76.0" />
<PackageReference Include="Grpc.AspNetCore.Server.Reflection" Version="2.76.0" />
<PackageReference Include="Grpc.Net.Client" Version="2.76.0" />
<PackageReference Include="Grpc.Tools" Version="2.76.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.SemanticKernel" Version="1.67.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.SemanticKernel" Version="1.68.0" />
<PackageReference Include="Microsoft.SemanticKernel.Connectors.Ollama" Version="1.66.0-alpha" />
<PackageReference Include="Microsoft.SemanticKernel.Plugins.Web" Version="1.66.0-alpha" />
<PackageReference Include="Quartz" Version="3.15.1" />
<PackageReference Include="Quartz.AspNetCore" Version="3.15.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />
<PackageReference Include="System.ServiceModel.Syndication" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
@@ -28,4 +38,8 @@
<Folder Include="Controllers\" />
</ItemGroup>
<ItemGroup>
<Protobuf Remove="..\DysonNetwork.Shared\Proto\**" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,358 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using DysonNetwork.Insight;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Models.Embed;
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.Insight.Migrations
{
[DbContext(typeof(AppDatabase))]
[Migration("20260102075604_AddWebFeed")]
partial class AddWebFeed
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.1")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingSequence", 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<bool>("IsPublic")
.HasColumnType("boolean")
.HasColumnName("is_public");
b.Property<long>("PaidToken")
.HasColumnType("bigint")
.HasColumnName("paid_token");
b.Property<string>("Topic")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("topic");
b.Property<long>("TotalToken")
.HasColumnType("bigint")
.HasColumnName("total_token");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_thinking_sequences");
b.ToTable("thinking_sequences", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", 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<List<SnCloudFileReferenceObject>>("Files")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("files");
b.Property<string>("ModelName")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("model_name");
b.Property<List<SnThinkingMessagePart>>("Parts")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("parts");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<Guid>("SequenceId")
.HasColumnType("uuid")
.HasColumnName("sequence_id");
b.Property<long>("TokenCount")
.HasColumnType("bigint")
.HasColumnName("token_count");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_thinking_thoughts");
b.HasIndex("SequenceId")
.HasDatabaseName("ix_thinking_thoughts_sequence_id");
b.ToTable("thinking_thoughts", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnUnpaidAccount", b =>
{
b.Property<Guid>("AccountId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<DateTime>("MarkedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("marked_at");
b.HasKey("AccountId")
.HasName("pk_unpaid_accounts");
b.ToTable("unpaid_accounts", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWebArticle", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Author")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("author");
b.Property<string>("Content")
.HasColumnType("text")
.HasColumnName("content");
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<Guid>("FeedId")
.HasColumnType("uuid")
.HasColumnName("feed_id");
b.Property<Dictionary<string, object>>("Meta")
.HasColumnType("jsonb")
.HasColumnName("meta");
b.Property<LinkEmbed>("Preview")
.HasColumnType("jsonb")
.HasColumnName("preview");
b.Property<DateTime?>("PublishedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("published_at");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("title");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("url");
b.HasKey("Id")
.HasName("pk_web_articles");
b.HasIndex("FeedId")
.HasDatabaseName("ix_web_articles_feed_id");
b.ToTable("web_articles", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWebFeed", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<WebFeedConfig>("Config")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("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")
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("description");
b.Property<LinkEmbed>("Preview")
.HasColumnType("jsonb")
.HasColumnName("preview");
b.Property<Guid>("PublisherId")
.HasColumnType("uuid")
.HasColumnName("publisher_id");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("title");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("url");
b.Property<string>("VerificationKey")
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("verification_key");
b.Property<Instant?>("VerifiedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("verified_at");
b.HasKey("Id")
.HasName("pk_web_feeds");
b.ToTable("web_feeds", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWebFeedSubscription", 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<Guid>("FeedId")
.HasColumnType("uuid")
.HasColumnName("feed_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_web_feed_subscriptions");
b.HasIndex("FeedId")
.HasDatabaseName("ix_web_feed_subscriptions_feed_id");
b.ToTable("web_feed_subscriptions", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnThinkingSequence", "Sequence")
.WithMany()
.HasForeignKey("SequenceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_thinking_thoughts_thinking_sequences_sequence_id");
b.Navigation("Sequence");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWebArticle", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnWebFeed", "Feed")
.WithMany("Articles")
.HasForeignKey("FeedId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_web_articles_web_feeds_feed_id");
b.Navigation("Feed");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWebFeedSubscription", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnWebFeed", "Feed")
.WithMany()
.HasForeignKey("FeedId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_web_feed_subscriptions_web_feeds_feed_id");
b.Navigation("Feed");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWebFeed", b =>
{
b.Navigation("Articles");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Models.Embed;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Insight.Migrations
{
/// <inheritdoc />
public partial class AddWebFeed : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "web_feeds",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
url = table.Column<string>(type: "character varying(8192)", maxLength: 8192, nullable: false),
title = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
description = table.Column<string>(type: "character varying(8192)", maxLength: 8192, nullable: true),
verified_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
verification_key = table.Column<string>(type: "character varying(8192)", maxLength: 8192, nullable: true),
preview = table.Column<LinkEmbed>(type: "jsonb", nullable: true),
config = table.Column<WebFeedConfig>(type: "jsonb", nullable: false),
publisher_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_web_feeds", x => x.id);
});
migrationBuilder.CreateTable(
name: "web_articles",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
title = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
url = table.Column<string>(type: "character varying(8192)", maxLength: 8192, nullable: false),
author = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true),
preview = table.Column<LinkEmbed>(type: "jsonb", nullable: true),
content = table.Column<string>(type: "text", nullable: true),
published_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
feed_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_web_articles", x => x.id);
table.ForeignKey(
name: "fk_web_articles_web_feeds_feed_id",
column: x => x.feed_id,
principalTable: "web_feeds",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "web_feed_subscriptions",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
feed_id = table.Column<Guid>(type: "uuid", nullable: false),
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_web_feed_subscriptions", x => x.id);
table.ForeignKey(
name: "fk_web_feed_subscriptions_web_feeds_feed_id",
column: x => x.feed_id,
principalTable: "web_feeds",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_web_articles_feed_id",
table: "web_articles",
column: "feed_id");
migrationBuilder.CreateIndex(
name: "ix_web_feed_subscriptions_feed_id",
table: "web_feed_subscriptions",
column: "feed_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "web_articles");
migrationBuilder.DropTable(
name: "web_feed_subscriptions");
migrationBuilder.DropTable(
name: "web_feeds");
}
}
}

View File

@@ -3,6 +3,7 @@ using System;
using System.Collections.Generic;
using DysonNetwork.Insight;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Models.Embed;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -20,7 +21,7 @@ namespace DysonNetwork.Insight.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.11")
.HasAnnotation("ProductVersion", "10.0.1")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -143,6 +144,171 @@ namespace DysonNetwork.Insight.Migrations
b.ToTable("unpaid_accounts", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWebArticle", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Author")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("author");
b.Property<string>("Content")
.HasColumnType("text")
.HasColumnName("content");
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<Guid>("FeedId")
.HasColumnType("uuid")
.HasColumnName("feed_id");
b.Property<Dictionary<string, object>>("Meta")
.HasColumnType("jsonb")
.HasColumnName("meta");
b.Property<LinkEmbed>("Preview")
.HasColumnType("jsonb")
.HasColumnName("preview");
b.Property<DateTime?>("PublishedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("published_at");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("title");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("url");
b.HasKey("Id")
.HasName("pk_web_articles");
b.HasIndex("FeedId")
.HasDatabaseName("ix_web_articles_feed_id");
b.ToTable("web_articles", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWebFeed", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<WebFeedConfig>("Config")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("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")
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("description");
b.Property<LinkEmbed>("Preview")
.HasColumnType("jsonb")
.HasColumnName("preview");
b.Property<Guid>("PublisherId")
.HasColumnType("uuid")
.HasColumnName("publisher_id");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("title");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("url");
b.Property<string>("VerificationKey")
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("verification_key");
b.Property<Instant?>("VerifiedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("verified_at");
b.HasKey("Id")
.HasName("pk_web_feeds");
b.ToTable("web_feeds", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWebFeedSubscription", 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<Guid>("FeedId")
.HasColumnType("uuid")
.HasColumnName("feed_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_web_feed_subscriptions");
b.HasIndex("FeedId")
.HasDatabaseName("ix_web_feed_subscriptions_feed_id");
b.ToTable("web_feed_subscriptions", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnThinkingSequence", "Sequence")
@@ -154,6 +320,35 @@ namespace DysonNetwork.Insight.Migrations
b.Navigation("Sequence");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWebArticle", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnWebFeed", "Feed")
.WithMany("Articles")
.HasForeignKey("FeedId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_web_articles_web_feeds_feed_id");
b.Navigation("Feed");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWebFeedSubscription", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnWebFeed", "Feed")
.WithMany()
.HasForeignKey("FeedId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_web_feed_subscriptions_web_feeds_feed_id");
b.Navigation("Feed");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWebFeed", b =>
{
b.Navigation("Articles");
});
#pragma warning restore 612, 618
}
}

View File

@@ -1,7 +1,7 @@
using DysonNetwork.Insight;
using DysonNetwork.Insight.Startup;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Networking;
using DysonNetwork.Shared.Registry;
using Microsoft.EntityFrameworkCore;

View File

@@ -0,0 +1,33 @@
using DysonNetwork.Shared.Models.Embed;
using DysonNetwork.Shared.Proto;
using EmbedLinkEmbed = DysonNetwork.Shared.Models.Embed.LinkEmbed;
namespace DysonNetwork.Insight.Reader;
public class ScrapedArticle
{
public EmbedLinkEmbed LinkEmbed { get; set; } = null!;
public string? Content { get; set; }
public Shared.Proto.ScrapedArticle ToProtoValue()
{
var proto = new Shared.Proto.ScrapedArticle
{
LinkEmbed = LinkEmbed.ToProtoValue()
};
if (!string.IsNullOrEmpty(Content))
proto.Content = Content;
return proto;
}
public static ScrapedArticle FromProtoValue(Shared.Proto.ScrapedArticle proto)
{
return new ScrapedArticle
{
LinkEmbed = EmbedLinkEmbed.FromProtoValue(proto.LinkEmbed),
Content = proto.Content == "" ? null : proto.Content
};
}
}

View File

@@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.WebReader;
namespace DysonNetwork.Insight.Reader;
[ApiController]
[Route("/api/feeds/articles")]

View File

@@ -0,0 +1,90 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Grpc.Core;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Insight.Reader;
public class WebArticleGrpcService(AppDatabase db) : WebArticleService.WebArticleServiceBase
{
public override async Task<GetWebArticleResponse> GetWebArticle(
GetWebArticleRequest request,
ServerCallContext context
)
{
if (!Guid.TryParse(request.Id, out var id))
throw new RpcException(new Status(StatusCode.InvalidArgument, "invalid id"));
var article = await db.WebArticles
.Include(a => a.Feed)
.FirstOrDefaultAsync(a => a.Id == id);
return article == null
? throw new RpcException(new Status(StatusCode.NotFound, "article not found"))
: new GetWebArticleResponse { Article = article.ToProtoValue() };
}
public override async Task<GetWebArticleBatchResponse> GetWebArticleBatch(
GetWebArticleBatchRequest request,
ServerCallContext context
)
{
var ids = request.Ids
.Where(s => !string.IsNullOrWhiteSpace(s) && Guid.TryParse(s, out _))
.Select(Guid.Parse)
.ToList();
if (ids.Count == 0)
return new GetWebArticleBatchResponse();
var articles = await db.WebArticles
.Include(a => a.Feed)
.Where(a => ids.Contains(a.Id))
.ToListAsync();
var response = new GetWebArticleBatchResponse();
response.Articles.AddRange(articles.Select(a => a.ToProtoValue()));
return response;
}
public override async Task<ListWebArticlesResponse> ListWebArticles(
ListWebArticlesRequest request,
ServerCallContext context
)
{
if (!Guid.TryParse(request.FeedId, out var feedId))
throw new RpcException(new Status(StatusCode.InvalidArgument, "invalid feed_id"));
var query = db.WebArticles
.Include(a => a.Feed)
.Where(a => a.FeedId == feedId);
var articles = await query.ToListAsync();
var response = new ListWebArticlesResponse
{
TotalSize = articles.Count
};
response.Articles.AddRange(articles.Select(a => a.ToProtoValue()));
return response;
}
public override async Task<GetRecentArticlesResponse> GetRecentArticles(
GetRecentArticlesRequest request,
ServerCallContext context
)
{
var limit = request.Limit > 0 ? request.Limit : 20;
var articles = await db.WebArticles
.Include(a => a.Feed)
.OrderByDescending(a => a.PublishedAt ?? DateTime.MinValue)
.ThenByDescending(a => a.CreatedAt)
.Take(limit)
.ToListAsync();
var response = new GetRecentArticlesResponse();
response.Articles.AddRange(articles.Select(a => a.ToProtoValue()));
return response;
}
}

View File

@@ -1,14 +1,17 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using WebFeedConfig = DysonNetwork.Shared.Models.WebFeedConfig;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.WebReader;
namespace DysonNetwork.Insight.Reader;
[Authorize]
[ApiController]
[Route("/api/publishers/{pubName}/feeds")]
public class WebFeedController(WebFeedService webFeed, Publisher.PublisherService ps) : ControllerBase
public class WebFeedController(WebFeedService webFeed, RemotePublisherService ps) : ControllerBase
{
public record WebFeedRequest(
[MaxLength(8192)] string? Url,
@@ -125,4 +128,63 @@ public class WebFeedController(WebFeedService webFeed, Publisher.PublisherServic
await webFeed.ScrapeFeedAsync(feed);
return Ok();
}
[HttpPost("{id:guid}/verify/init")]
[Authorize]
public async Task<ActionResult<WebFeedVerificationInitResult>> InitVerification([FromRoute] string pubName, Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var publisher = await ps.GetPublisherByName(pubName);
if (publisher is null) return NotFound();
var accountId = Guid.Parse(currentUser.Id);
if (!await ps.IsMemberWithRole(publisher.Id, accountId, Shared.Models.PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the publisher to verify a web feed");
var feed = await webFeed.GetFeedAsync(id, publisherId: publisher.Id);
if (feed == null)
return NotFound();
try
{
var result = await webFeed.GenerateVerificationCodeAsync(id);
return Ok(result);
}
catch (InvalidOperationException ex)
{
return NotFound(ex.Message);
}
}
[HttpPost("{id:guid}/verify")]
[Authorize]
public async Task<ActionResult<WebFeedVerificationResult>> VerifyOwnership([FromRoute] string pubName, Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var publisher = await ps.GetPublisherByName(pubName);
if (publisher is null) return NotFound();
var accountId = Guid.Parse(currentUser.Id);
if (!await ps.IsMemberWithRole(publisher.Id, accountId, Shared.Models.PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the publisher to verify a web feed");
var feed = await webFeed.GetFeedAsync(id, publisherId: publisher.Id);
if (feed == null)
return NotFound();
try
{
var result = await webFeed.VerifyOwnershipAsync(id);
if (!result.Success)
return BadRequest(result.Message);
return Ok(result);
}
catch (InvalidOperationException ex)
{
return NotFound(ex.Message);
}
}
}

View File

@@ -0,0 +1,55 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Grpc.Core;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Insight.Reader;
public class WebFeedGrpcService(WebFeedService service, AppDatabase db)
: Shared.Proto.WebFeedService.WebFeedServiceBase
{
public override async Task<GetWebFeedResponse> GetWebFeed(
GetWebFeedRequest request,
ServerCallContext context
)
{
SnWebFeed? feed = null;
switch (request.IdentifierCase)
{
case GetWebFeedRequest.IdentifierOneofCase.Id:
if (!string.IsNullOrWhiteSpace(request.Id) && Guid.TryParse(request.Id, out var id))
feed = await service.GetFeedAsync(id);
break;
case GetWebFeedRequest.IdentifierOneofCase.Url:
feed = await db.WebFeeds.FirstOrDefaultAsync(f => f.Url == request.Url);
break;
case GetWebFeedRequest.IdentifierOneofCase.None:
break;
default:
throw new ArgumentOutOfRangeException();
}
return feed == null
? throw new RpcException(new Status(StatusCode.NotFound, "feed not found"))
: new GetWebFeedResponse { Feed = feed.ToProtoValue() };
}
public override async Task<ListWebFeedsResponse> ListWebFeeds(
ListWebFeedsRequest request,
ServerCallContext context
)
{
if (!Guid.TryParse(request.PublisherId, out var publisherId))
throw new RpcException(new Status(StatusCode.InvalidArgument, "invalid publisher_id"));
var feeds = await service.GetFeedsByPublisherAsync(publisherId);
var response = new ListWebFeedsResponse
{
TotalSize = feeds.Count
};
response.Feeds.AddRange(feeds.Select(f => f.ToProtoValue()));
return response;
}
}

View File

@@ -1,9 +1,10 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.WebReader;
namespace DysonNetwork.Insight.Reader;
[ApiController]
[Route("/api/feeds")]
@@ -39,7 +40,7 @@ public class WebFeedPublicController(
return Ok(existingSubscription);
// Create new subscription
var subscription = new WebFeedSubscription
var subscription = new SnWebFeedSubscription
{
FeedId = feedId,
AccountId = accountId
@@ -83,7 +84,7 @@ public class WebFeedPublicController(
/// <returns>Subscription status</returns>
[HttpGet("{feedId:guid}/subscription")]
[Authorize]
public async Task<ActionResult<WebFeedSubscription>> GetSubscriptionStatus(Guid feedId)
public async Task<ActionResult<SnWebFeedSubscription>> GetSubscriptionStatus(Guid feedId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
@@ -105,7 +106,7 @@ public class WebFeedPublicController(
/// <returns>List of subscribed feeds</returns>
[HttpGet("subscribed")]
[Authorize]
public async Task<ActionResult<WebFeed>> GetSubscribedFeeds(
public async Task<ActionResult<SnWebFeed>> GetSubscribedFeeds(
[FromQuery] int offset = 0,
[FromQuery] int take = 20
)
@@ -118,7 +119,6 @@ public class WebFeedPublicController(
var query = db.WebFeedSubscriptions
.Where(s => s.AccountId == accountId)
.Include(s => s.Feed)
.ThenInclude(f => f.Publisher)
.OrderByDescending(s => s.CreatedAt);
var totalCount = await query.CountAsync();
@@ -137,7 +137,7 @@ public class WebFeedPublicController(
/// </summary>
[HttpGet]
[Authorize]
public async Task<ActionResult<WebFeed>> GetWebFeedArticles(
public async Task<ActionResult<SnWebFeed>> GetWebFeedArticles(
[FromQuery] int offset = 0,
[FromQuery] int take = 20
)
@@ -154,7 +154,6 @@ public class WebFeedPublicController(
var query = db.WebFeeds
.Where(f => subscribedFeedIds.Contains(f.Id))
.Include(f => f.Publisher)
.OrderByDescending(f => f.CreatedAt);
var totalCount = await query.CountAsync();
@@ -174,7 +173,7 @@ public class WebFeedPublicController(
/// <returns>Feed metadata</returns>
[AllowAnonymous]
[HttpGet("{feedId:guid}")]
public async Task<ActionResult<WebFeed>> GetFeedById(Guid feedId)
public async Task<ActionResult<SnWebFeed>> GetFeedById(Guid feedId)
{
var feed = await webFeed.GetFeedAsync(feedId);
if (feed == null)
@@ -192,7 +191,7 @@ public class WebFeedPublicController(
/// <returns>List of articles from the feed</returns>
[AllowAnonymous]
[HttpGet("{feedId:guid}/articles")]
public async Task<ActionResult<WebArticle>> GetFeedArticles(
public async Task<ActionResult<SnWebArticle>> GetFeedArticles(
[FromRoute] Guid feedId,
[FromQuery] int offset = 0,
[FromQuery] int take = 20
@@ -206,8 +205,7 @@ public class WebFeedPublicController(
var query = db.WebArticles
.Where(a => a.FeedId == feedId)
.OrderByDescending(a => a.CreatedAt)
.Include(a => a.Feed)
.ThenInclude(f => f.Publisher);
.Include(a => a.Feed);
var totalCount = await query.CountAsync();
var articles = await query
@@ -224,7 +222,7 @@ public class WebFeedPublicController(
/// </summary>
[HttpGet("explore")]
[Authorize]
public async Task<ActionResult<WebFeed>> ExploreFeeds(
public async Task<ActionResult<SnWebFeed>> ExploreFeeds(
[FromQuery] int offset = 0,
[FromQuery] int take = 20,
[FromQuery] string? query = null
@@ -236,7 +234,6 @@ public class WebFeedPublicController(
var accountId = Guid.Parse(currentUser.Id);
var feedsQuery = db.WebFeeds
.Include(f => f.Publisher)
.OrderByDescending(f => f.CreatedAt)
.AsQueryable();

View File

@@ -1,7 +1,8 @@
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Quartz;
namespace DysonNetwork.Sphere.WebReader;
namespace DysonNetwork.Insight.Reader;
[DisallowConcurrentExecution]
public class WebFeedScraperJob(
@@ -15,7 +16,7 @@ public class WebFeedScraperJob(
{
logger.LogInformation("Starting web feed scraper job.");
var feeds = await database.Set<WebFeed>().ToListAsync(context.CancellationToken);
var feeds = await database.Set<SnWebFeed>().ToListAsync(context.CancellationToken);
foreach (var feed in feeds)
{

View File

@@ -0,0 +1,323 @@
using System.ServiceModel.Syndication;
using System.Xml;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Models.Embed;
using DysonNetwork.Shared.Registry;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Insight.Reader;
public class WebFeedService(
AppDatabase database,
IHttpClientFactory httpClientFactory,
ILogger<WebFeedService> logger,
WebReaderService readerService,
RemotePublisherService remotePublisherService
)
{
private const string VerificationFileName = "solar-network-feed.txt";
private static readonly TimeZoneInfo UtcZone = TimeZoneInfo.Utc;
public async Task<SnWebFeed> CreateWebFeedAsync(SnPublisher publisher, WebFeedController.WebFeedRequest request)
{
var feed = new SnWebFeed
{
Url = request.Url!,
Title = request.Title!,
Description = request.Description,
Config = request.Config ?? new WebFeedConfig(),
PublisherId = publisher.Id,
Publisher = publisher
};
database.WebFeeds.Add(feed);
await database.SaveChangesAsync();
return feed;
}
private async Task<SnPublisher?> LoadPublisherAsync(Guid publisherId, CancellationToken cancellationToken)
{
try
{
return await remotePublisherService.GetPublisher(id: publisherId.ToString(), cancellationToken: cancellationToken);
}
catch (Grpc.Core.RpcException)
{
return null;
}
}
public async Task<SnWebFeed?> GetFeedAsync(Guid id, Guid? publisherId = null)
{
var query = database.WebFeeds
.Where(a => a.Id == id)
.AsQueryable();
if (publisherId.HasValue)
query = query.Where(a => a.PublisherId == publisherId.Value);
var feed = await query.FirstOrDefaultAsync();
if (feed != null)
{
feed.Publisher = await LoadPublisherAsync(feed.PublisherId, CancellationToken.None) ?? new SnPublisher();
}
return feed;
}
public async Task<List<SnWebFeed>> GetFeedsByPublisherAsync(Guid publisherId)
{
var feeds = await database.WebFeeds.Where(a => a.PublisherId == publisherId).ToListAsync();
foreach (var feed in feeds)
{
feed.Publisher = await LoadPublisherAsync(feed.PublisherId, CancellationToken.None) ?? new SnPublisher();
}
return feeds;
}
public async Task<SnWebFeed> UpdateFeedAsync(SnWebFeed feed, WebFeedController.WebFeedRequest request)
{
if (request.Url is not null)
feed.Url = request.Url;
if (request.Title is not null)
feed.Title = request.Title;
if (request.Description is not null)
feed.Description = request.Description;
if (request.Config is not null)
feed.Config = request.Config;
database.Update(feed);
await database.SaveChangesAsync();
feed.Publisher = await LoadPublisherAsync(feed.PublisherId, CancellationToken.None) ?? new SnPublisher();
return feed;
}
public async Task<bool> DeleteFeedAsync(Guid id)
{
var feed = await database.WebFeeds.FindAsync(id);
if (feed == null)
{
return false;
}
database.WebFeeds.Remove(feed);
await database.SaveChangesAsync();
return true;
}
public async Task ScrapeFeedAsync(SnWebFeed feed, CancellationToken cancellationToken = default)
{
var httpClient = httpClientFactory.CreateClient();
var response = await httpClient.GetAsync(feed.Url, cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var reader = XmlReader.Create(stream);
var syndicationFeed = SyndicationFeed.Load(reader);
if (syndicationFeed == null)
{
logger.LogWarning("Could not parse syndication feed for {FeedUrl}", feed.Url);
return;
}
foreach (var item in syndicationFeed.Items)
{
var itemUrl = item.Links.FirstOrDefault()?.Uri.ToString();
if (string.IsNullOrEmpty(itemUrl))
continue;
var articleExists = await database.Set<SnWebArticle>()
.AnyAsync(a => a.FeedId == feed.Id && a.Url == itemUrl, cancellationToken);
if (articleExists)
continue;
var content = (item.Content as TextSyndicationContent)?.Text ?? item.Summary.Text;
LinkEmbed preview;
if (feed.Config.ScrapPage)
{
var scrapedArticle = await readerService.ScrapeArticleAsync(itemUrl, cancellationToken);
preview = scrapedArticle.LinkEmbed;
if (scrapedArticle.Content is not null)
content = scrapedArticle.Content;
}
else
{
preview = await readerService.GetLinkPreviewAsync(itemUrl, cancellationToken);
}
var newArticle = new SnWebArticle
{
FeedId = feed.Id,
Title = item.Title.Text,
Url = itemUrl,
Author = item.Authors.FirstOrDefault()?.Name,
Content = content,
PublishedAt = item.LastUpdatedTime.UtcDateTime,
Preview = preview,
};
database.WebArticles.Add(newArticle);
}
await database.SaveChangesAsync(cancellationToken);
}
public async Task<WebFeedVerificationInitResult> GenerateVerificationCodeAsync(Guid feedId)
{
var feed = await database.WebFeeds.FindAsync(feedId);
if (feed == null)
throw new InvalidOperationException($"Feed with ID {feedId} not found");
var domain = GetDomainFromUrl(feed.Url);
var verificationCode = GenerateVerificationCode();
var verificationUrl = $"https://{domain}/.well-known/{VerificationFileName}";
feed.VerificationKey = verificationCode;
await database.SaveChangesAsync();
return new WebFeedVerificationInitResult
{
VerificationUrl = verificationUrl,
Code = verificationCode,
Instructions = $"Create a file at '{verificationUrl}' containing only this verification code."
};
}
public async Task<WebFeedVerificationResult> VerifyOwnershipAsync(Guid feedId, CancellationToken cancellationToken = default)
{
var feed = await database.WebFeeds.FindAsync(feedId);
if (feed == null)
throw new InvalidOperationException($"Feed with ID {feedId} not found");
if (string.IsNullOrEmpty(feed.VerificationKey))
return new WebFeedVerificationResult
{
Success = false,
Message = "No verification code generated. Please call the init endpoint first."
};
var domain = GetDomainFromUrl(feed.Url);
var verificationUrl = $"https://{domain}/.well-known/{VerificationFileName}";
try
{
using var httpClient = httpClientFactory.CreateClient();
httpClient.Timeout = TimeSpan.FromSeconds(30);
var response = await httpClient.GetAsync(verificationUrl, cancellationToken);
if (!response.IsSuccessStatusCode)
{
await RevokeVerificationAsync(feed, "Verification file not found or inaccessible");
return new WebFeedVerificationResult
{
Success = false,
Message = $"Verification file not found (HTTP {response.StatusCode}). Verification status has been revoked."
};
}
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var trimmedContent = content.Trim();
if (trimmedContent != feed.VerificationKey)
{
await RevokeVerificationAsync(feed, "Verification code mismatch");
return new WebFeedVerificationResult
{
Success = false,
Message = "Verification code does not match. Verification status has been revoked."
};
}
feed.VerifiedAt = SystemClock.Instance.GetCurrentInstant();
feed.VerificationKey = null;
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Successfully verified ownership of feed {FeedId} at {Url}", feedId, feed.Url);
return new WebFeedVerificationResult
{
Success = true,
VerifiedAt = feed.VerifiedAt.Value.ToDateTimeUtc(),
Message = "Website ownership verified successfully."
};
}
catch (TaskCanceledException)
{
await RevokeVerificationAsync(feed, "Verification request timed out");
return new WebFeedVerificationResult
{
Success = false,
Message = "Verification request timed out. Verification status has been revoked."
};
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error during verification for feed {FeedId}", feedId);
await RevokeVerificationAsync(feed, $"Verification error: {ex.Message}");
return new WebFeedVerificationResult
{
Success = false,
Message = $"Error during verification: {ex.Message}. Verification status has been revoked."
};
}
}
public async Task RevokeVerificationAsync(SnWebFeed feed, string reason)
{
logger.LogWarning("Revoking verification for feed {FeedId}: {Reason}", feed.Id, reason);
feed.VerifiedAt = null;
feed.VerificationKey = null;
await database.SaveChangesAsync();
}
public async Task VerifyAllFeedsAsync(CancellationToken cancellationToken = default)
{
var verifiedFeeds = await database.WebFeeds
.Where(f => f.VerifiedAt.HasValue)
.ToListAsync(cancellationToken);
logger.LogInformation("Starting periodic verification check for {Count} feeds", verifiedFeeds.Count);
foreach (var feed in verifiedFeeds)
{
if (cancellationToken.IsCancellationRequested)
break;
await VerifyOwnershipAsync(feed.Id, cancellationToken);
}
logger.LogInformation("Completed periodic verification check");
}
private static string GenerateVerificationCode()
{
var timestamp = DateTimeOffset.UtcNow.ToString("yyyyMMdd");
var randomPart = Guid.NewGuid().ToString("N")[..16];
return $"dn_{timestamp}_{randomPart}";
}
private static string GetDomainFromUrl(string url)
{
var uri = new Uri(url);
return uri.Host;
}
}
public class WebFeedVerificationInitResult
{
public string VerificationUrl { get; set; } = string.Empty;
public string Code { get; set; } = string.Empty;
public string Instructions { get; set; } = string.Empty;
}
public class WebFeedVerificationResult
{
public bool Success { get; set; }
public DateTime? VerifiedAt { get; set; }
public string Message { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore;
using Quartz;
namespace DysonNetwork.Insight.Reader;
[DisallowConcurrentExecution]
public class WebFeedVerificationJob(
WebFeedService webFeedService,
ILogger<WebFeedVerificationJob> logger
)
: IJob
{
public async Task Execute(IJobExecutionContext context)
{
logger.LogInformation("Starting web feed verification job.");
try
{
await webFeedService.VerifyAllFeedsAsync(context.CancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Error during web feed verification job");
}
logger.LogInformation("Web feed verification job finished.");
}
}

View File

@@ -1,9 +1,10 @@
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Models.Embed;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace DysonNetwork.Sphere.WebReader;
namespace DysonNetwork.Insight.Reader;
/// <summary>
/// Controller for web scraping and link preview services

View File

@@ -1,4 +1,4 @@
namespace DysonNetwork.Sphere.WebReader;
namespace DysonNetwork.Insight.Reader;
/// <summary>
/// Exception thrown when an error occurs during web reading operations

View File

@@ -0,0 +1,49 @@
using DysonNetwork.Shared.Proto;
using Grpc.Core;
namespace DysonNetwork.Insight.Reader;
public class WebReaderGrpcService(WebReaderService service) : Shared.Proto.WebReaderService.WebReaderServiceBase
{
public override async Task<ScrapeArticleResponse> ScrapeArticle(
ScrapeArticleRequest request,
ServerCallContext context
)
{
if (string.IsNullOrWhiteSpace(request.Url))
throw new RpcException(new Status(StatusCode.InvalidArgument, "url is required"));
var scrapedArticle = await service.ScrapeArticleAsync(request.Url, context.CancellationToken);
return new ScrapeArticleResponse { Article = scrapedArticle.ToProtoValue() };
}
public override async Task<GetLinkPreviewResponse> GetLinkPreview(
GetLinkPreviewRequest request,
ServerCallContext context
)
{
if (string.IsNullOrWhiteSpace(request.Url))
throw new RpcException(new Status(StatusCode.InvalidArgument, "url is required"));
var linkEmbed = await service.GetLinkPreviewAsync(
request.Url,
context.CancellationToken,
bypassCache: request.BypassCache
);
return new GetLinkPreviewResponse { Preview = linkEmbed.ToProtoValue() };
}
public override async Task<InvalidateLinkPreviewCacheResponse> InvalidateLinkPreviewCache(
InvalidateLinkPreviewCacheRequest request,
ServerCallContext context
)
{
if (string.IsNullOrWhiteSpace(request.Url))
throw new RpcException(new Status(StatusCode.InvalidArgument, "url is required"));
await service.InvalidateCacheForUrlAsync(request.Url);
return new InvalidateLinkPreviewCacheResponse { Success = true };
}
}

View File

@@ -2,9 +2,10 @@ using System.Globalization;
using AngleSharp;
using AngleSharp.Dom;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models.Embed;
using HtmlAgilityPack;
namespace DysonNetwork.Sphere.WebReader;
namespace DysonNetwork.Insight.Reader;
/// <summary>
/// The service is amin to providing scrapping service to the Solar Network.

View File

@@ -1,4 +1,5 @@
using DysonNetwork.Shared.Http;
using DysonNetwork.Insight.Reader;
using DysonNetwork.Shared.Networking;
namespace DysonNetwork.Insight.Startup;
@@ -17,6 +18,11 @@ public static class ApplicationConfiguration
app.MapControllers();
app.MapGrpcService<WebReaderGrpcService>();
app.MapGrpcService<WebArticleGrpcService>();
app.MapGrpcService<WebFeedGrpcService>();
app.MapGrpcReflectionService();
return app;
}
}

View File

@@ -1,3 +1,4 @@
using DysonNetwork.Insight.Reader;
using DysonNetwork.Insight.Thought;
using Quartz;
@@ -18,6 +19,20 @@ public static class ScheduledJobsConfiguration
.WithIntervalInMinutes(5)
.RepeatForever())
);
q.AddJob<WebFeedScraperJob>(opts => opts.WithIdentity("WebFeedScraper").StoreDurably());
q.AddTrigger(opts => opts
.ForJob("WebFeedScraper")
.WithIdentity("WebFeedScraperTrigger")
.WithCronSchedule("0 0 0 * * ?")
);
q.AddJob<WebFeedVerificationJob>(opts => opts.WithIdentity("WebFeedVerification").StoreDurably());
q.AddTrigger(opts => opts
.ForJob("WebFeedVerification")
.WithIdentity("WebFeedVerificationTrigger")
.WithCronSchedule("0 0 4 * * ?")
);
});
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);

View File

@@ -1,7 +1,9 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Insight.Reader;
using DysonNetwork.Insight.Thought;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using Microsoft.SemanticKernel;
using NodaTime;
@@ -11,7 +13,9 @@ namespace DysonNetwork.Insight.Startup;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddAppServices(this IServiceCollection services)
extension(IServiceCollection services)
{
public IServiceCollection AddAppServices()
{
services.AddDbContext<AppDatabase>();
services.AddHttpContextAccessor();
@@ -42,29 +46,32 @@ public static class ServiceCollectionExtensions
return services;
}
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
public IServiceCollection AddAppAuthentication()
{
services.AddAuthorization();
return services;
}
public static IServiceCollection AddAppFlushHandlers(this IServiceCollection services)
public IServiceCollection AddAppFlushHandlers()
{
services.AddSingleton<FlushBufferService>();
return services;
}
public static IServiceCollection AddAppBusinessServices(this IServiceCollection services)
public IServiceCollection AddAppBusinessServices()
{
return services;
}
public static IServiceCollection AddThinkingServices(this IServiceCollection services, IConfiguration configuration)
public IServiceCollection AddThinkingServices(IConfiguration configuration)
{
services.AddSingleton<ThoughtProvider>();
services.AddScoped<ThoughtService>();
services.AddScoped<Reader.WebFeedService>();
services.AddScoped<Reader.WebReaderService>();
return services;
}
}
}

View File

@@ -1,6 +1,5 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.IdentityModel.Tokens;
using Microsoft.SemanticKernel;
namespace DysonNetwork.Insight.Thought.Plugins;
@@ -24,6 +23,6 @@ public class SnAccountKernelPlugin(
var request = new LookupAccountBatchRequest();
request.Names.Add(username);
var response = await accountClient.LookupAccountBatchAsync(request);
return response.Accounts.IsNullOrEmpty() ? null : SnAccount.FromProtoValue(response.Accounts[0]);
return response.Accounts.Count == 0 ? null : SnAccount.FromProtoValue(response.Accounts[0]);
}
}

View File

@@ -20,7 +20,7 @@
"Insecure": true
},
"Cache": {
"Serializer": "MessagePack"
"Serializer": "JSON"
},
"Thinking": {
"DefaultService": "deepseek-chat",

5
DysonNetwork.Messager/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
Keys
Uploads
DataProtection-Keys
.DS_Store

View File

@@ -0,0 +1,139 @@
using System.Linq.Expressions;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Query;
using NodaTime;
using Quartz;
namespace DysonNetwork.Messager;
public class AppDatabase(
DbContextOptions<AppDatabase> options,
IConfiguration configuration
) : DbContext(options)
{
public DbSet<SnChatRoom> ChatRooms { get; set; } = null!;
public DbSet<SnChatMember> ChatMembers { get; set; } = null!;
public DbSet<SnChatMessage> ChatMessages { get; set; } = null!;
public DbSet<SnRealtimeCall> ChatRealtimeCall { get; set; } = null!;
public DbSet<SnChatReaction> ChatReactions { 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);
modelBuilder.Entity<SnChatMember>()
.HasKey(pm => new { pm.Id });
modelBuilder.Entity<SnChatMember>()
.HasAlternateKey(pm => new { pm.ChatRoomId, pm.AccountId });
modelBuilder.Entity<SnChatMember>()
.HasOne(pm => pm.ChatRoom)
.WithMany(p => p.Members)
.HasForeignKey(pm => pm.ChatRoomId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<SnChatMessage>()
.HasOne(m => m.ForwardedMessage)
.WithMany()
.HasForeignKey(m => m.ForwardedMessageId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SnChatMessage>()
.HasOne(m => m.RepliedMessage)
.WithMany()
.HasForeignKey(m => m.RepliedMessageId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SnRealtimeCall>()
.HasOne(m => m.Room)
.WithMany()
.HasForeignKey(m => m.RoomId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<SnRealtimeCall>()
.HasOne(m => m.Sender)
.WithMany()
.HasForeignKey(m => m.SenderId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.ApplySoftDeleteFilters();
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
this.ApplyAuditableAndSoftDelete();
return await base.SaveChangesAsync(cancellationToken);
}
}
public class AppDatabaseRecyclingJob(AppDatabase db, ILogger<AppDatabaseRecyclingJob> logger) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
var now = SystemClock.Instance.GetCurrentInstant();
logger.LogInformation("Deleting soft-deleted records...");
var threshold = now - Duration.FromDays(7);
var entityTypes = db.Model.GetEntityTypes()
.Where(t => typeof(ModelBase).IsAssignableFrom(t.ClrType) && t.ClrType != typeof(ModelBase))
.Select(t => t.ClrType);
foreach (var entityType in entityTypes)
{
var set = (IQueryable)db.GetType().GetMethod(nameof(DbContext.Set), Type.EmptyTypes)!
.MakeGenericMethod(entityType).Invoke(db, null)!;
var parameter = Expression.Parameter(entityType, "e");
var property = Expression.Property(parameter, nameof(ModelBase.DeletedAt));
var condition = Expression.LessThan(property, Expression.Constant(threshold, typeof(Instant?)));
var notNull = Expression.NotEqual(property, Expression.Constant(null, typeof(Instant?)));
var finalCondition = Expression.AndAlso(notNull, condition);
var lambda = Expression.Lambda(finalCondition, parameter);
var queryable = set.Provider.CreateQuery(
Expression.Call(
typeof(Queryable),
"Where",
[entityType],
set.Expression,
Expression.Quote(lambda)
)
);
var toListAsync = typeof(EntityFrameworkQueryableExtensions)
.GetMethod(nameof(EntityFrameworkQueryableExtensions.ToListAsync))!
.MakeGenericMethod(entityType);
var items = await (dynamic)toListAsync.Invoke(null, [queryable, CancellationToken.None])!;
db.RemoveRange(items);
}
await db.SaveChangesAsync();
}
}
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);
}
}

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