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

@@ -1,103 +1,103 @@
name: Build and Push Microservices name: Build and Push Microservices
on: on:
push: push:
branches: branches:
- master - master
workflow_dispatch: workflow_dispatch:
jobs: jobs:
determine-changes: determine-changes:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
matrix: ${{ steps.changes.outputs.matrix }} matrix: ${{ steps.changes.outputs.matrix }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Get changed files - name: Get changed files
id: changed-files id: changed-files
run: | run: |
echo "files=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} | xargs)" >> $GITHUB_OUTPUT echo "files=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} | xargs)" >> $GITHUB_OUTPUT
- name: Determine changed services - name: Determine changed services
id: changes id: changes
run: | run: |
files="${{ steps.changed-files.outputs.files }}" files="${{ steps.changed-files.outputs.files }}"
matrix="{\"include\":[]}" matrix="{\"include\":[]}"
services=("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") images=("sphere" "pass" "ring" "drive" "develop" "gateway" "insight" "zone" "messager")
changed_services=() changed_services=()
for file in $files; do for file in $files; do
if [[ "$file" == DysonNetwork.Shared/* ]]; then if [[ "$file" == DysonNetwork.Shared/* ]]; then
changed_services=("${services[@]}") changed_services=("${services[@]}")
break break
fi fi
for i in "${!services[@]}"; do for i in "${!services[@]}"; do
if [[ "$file" == DysonNetwork.${services[$i]}/* ]]; then if [[ "$file" == DysonNetwork.${services[$i]}/* ]]; then
# check if service is already in changed_services # check if service is already in changed_services
if [[ ! " ${changed_services[@]} " =~ " ${services[$i]} " ]]; then if [[ ! " ${changed_services[@]} " =~ " ${services[$i]} " ]]; then
changed_services+=("${services[$i]}") changed_services+=("${services[$i]}")
fi fi
fi fi
done done
done done
if [ ${#changed_services[@]} -gt 0 ]; then if [ ${#changed_services[@]} -gt 0 ]; then
json_objects="" json_objects=""
for service in "${changed_services[@]}"; do for service in "${changed_services[@]}"; do
for i in "${!services[@]}"; do for i in "${!services[@]}"; do
if [[ "${services[$i]}" == "$service" ]]; then if [[ "${services[$i]}" == "$service" ]]; then
image="${images[$i]}" image="${images[$i]}"
break break
fi fi
done done
json_objects+="{\"service\":\"$service\",\"image\":\"$image\"}," json_objects+="{\"service\":\"$service\",\"image\":\"$image\"},"
done done
matrix="{\"include\":[${json_objects%,}]}" matrix="{\"include\":[${json_objects%,}]}"
fi fi
echo "matrix=$matrix" >> $GITHUB_OUTPUT echo "matrix=$matrix" >> $GITHUB_OUTPUT
build-and-push: build-and-push:
needs: determine-changes needs: determine-changes
if: ${{ needs.determine-changes.outputs.matrix != '{"include":[]}' }} if: ${{ needs.determine-changes.outputs.matrix != '{"include":[]}' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
packages: write packages: write
strategy: strategy:
matrix: ${{ fromJson(needs.determine-changes.outputs.matrix) }} matrix: ${{ fromJson(needs.determine-changes.outputs.matrix) }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup NBGV - name: Setup NBGV
uses: dotnet/nbgv@master uses: dotnet/nbgv@master
id: nbgv id: nbgv
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry - name: Log in to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image for ${{ matrix.service }} - name: Build and push Docker image for ${{ matrix.service }}
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: DysonNetwork.${{ matrix.service }}/Dockerfile file: DysonNetwork.${{ matrix.service }}/Dockerfile
push: true push: true
tags: | tags: |
ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-${{ matrix.image }}:${{ steps.nbgv.outputs.SimpleVersion }} ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-${{ matrix.image }}:${{ steps.nbgv.outputs.SimpleVersion }}
ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-${{ matrix.image }}:latest ghcr.io/${{ vars.PACKAGE_OWNER }}/dyson-${{ matrix.image }}:latest
platforms: linux/amd64 platforms: linux/amd64

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 isDev = builder.Environment.IsDevelopment();
var cache = builder.AddRedis("cache"); var cache = builder.AddRedis("Cache");
var queue = builder.AddNats("queue").WithJetStream(); var queue = builder.AddNats("Queue").WithJetStream();
var ringService = builder.AddProject<Projects.DysonNetwork_Ring>("ring"); var ringService = builder.AddProject<Projects.DysonNetwork_Ring>("ring");
var passService = builder.AddProject<Projects.DysonNetwork_Pass>("pass") var passService = builder.AddProject<Projects.DysonNetwork_Pass>("pass")
@@ -32,11 +32,26 @@ var zoneService = builder.AddProject<Projects.DysonNetwork_Zone>("zone")
.WithReference(sphereService) .WithReference(sphereService)
.WithReference(developService) .WithReference(developService)
.WithReference(insightService); .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); passService.WithReference(developService).WithReference(driveService);
List<IResourceBuilder<ProjectResource>> services = 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++) for (var idx = 0; idx < services.Count; idx++)
{ {
@@ -74,4 +89,4 @@ foreach (var service in services)
builder.AddDockerComposeEnvironment("docker-compose"); builder.AddDockerComposeEnvironment("docker-compose");
builder.Build().Run(); builder.Build().Run();

View File

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

View File

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

View File

@@ -1,4 +1,9 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base 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 USER $APP_UID
WORKDIR /app WORKDIR /app
EXPOSE 8080 EXPOSE 8080

View File

@@ -8,15 +8,15 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" /> <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="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>
<ItemGroup> <ItemGroup>
@@ -28,5 +28,9 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="MiniApp\" />
</ItemGroup>
</Project> </Project>

View File

@@ -8,7 +8,6 @@ namespace DysonNetwork.Develop.Identity;
public class CustomAppService( public class CustomAppService(
AppDatabase db, AppDatabase db,
FileReferenceService.FileReferenceServiceClient fileRefs,
FileService.FileServiceClient files FileService.FileServiceClient files
) )
{ {
@@ -47,15 +46,8 @@ public class CustomAppService(
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud."); throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
app.Picture = SnCloudFileReferenceObject.FromProtoValue(picture); app.Picture = SnCloudFileReferenceObject.FromProtoValue(picture);
// Create a new reference if (request.Status == Shared.Models.CustomAppStatus.Production)
await fileRefs.CreateReferenceAsync( await files.SetFilePublicAsync(new SetFilePublicRequest { FileId = request.PictureId });
new CreateReferenceRequest
{
FileId = picture.Id,
Usage = "custom-apps.picture",
ResourceId = app.ResourceIdentifier
}
);
} }
if (request.BackgroundId is not null) 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."); throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
app.Background = SnCloudFileReferenceObject.FromProtoValue(background); app.Background = SnCloudFileReferenceObject.FromProtoValue(background);
// Create a new reference if (request.Status == Shared.Models.CustomAppStatus.Production)
await fileRefs.CreateReferenceAsync( await files.SetFilePublicAsync(new SetFilePublicRequest { FileId = request.BackgroundId });
new CreateReferenceRequest
{
FileId = background.Id,
Usage = "custom-apps.background",
ResourceId = app.ResourceIdentifier
}
);
} }
db.CustomApps.Add(app); db.CustomApps.Add(app);
@@ -185,6 +170,7 @@ public class CustomAppService(
public async Task<SnCustomApp?> UpdateAppAsync(SnCustomApp app, CustomAppController.CustomAppRequest request) public async Task<SnCustomApp?> UpdateAppAsync(SnCustomApp app, CustomAppController.CustomAppRequest request)
{ {
var oldStatus = app.Status;
if (request.Slug is not null) if (request.Slug is not null)
app.Slug = request.Slug; app.Slug = request.Slug;
if (request.Name is not null) 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."); throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
app.Picture = SnCloudFileReferenceObject.FromProtoValue(picture); app.Picture = SnCloudFileReferenceObject.FromProtoValue(picture);
// Create a new reference if (app.Status == Shared.Models.CustomAppStatus.Production)
await fileRefs.CreateReferenceAsync( await files.SetFilePublicAsync(new SetFilePublicRequest { FileId = request.PictureId });
new CreateReferenceRequest
{
FileId = picture.Id,
Usage = "custom-apps.picture",
ResourceId = app.ResourceIdentifier
}
);
} }
if (request.BackgroundId is not null) 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."); throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
app.Background = SnCloudFileReferenceObject.FromProtoValue(background); app.Background = SnCloudFileReferenceObject.FromProtoValue(background);
// Create a new reference if (app.Status == Shared.Models.CustomAppStatus.Production)
await fileRefs.CreateReferenceAsync( await files.SetFilePublicAsync(new SetFilePublicRequest { FileId = request.BackgroundId });
new CreateReferenceRequest
{
FileId = background.Id,
Usage = "custom-apps.background",
ResourceId = app.ResourceIdentifier
}
);
} }
db.Update(app); db.Update(app);
await db.SaveChangesAsync(); 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; return app;
} }
@@ -257,12 +244,6 @@ public class CustomAppService(
db.CustomApps.Remove(app); db.CustomApps.Remove(app);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest
{
ResourceId = app.ResourceIdentifier
}
);
return true; 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 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "9.0.7") .HasAnnotation("ProductVersion", "10.0.1")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnBotAccount", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -66,7 +66,7 @@ namespace DysonNetwork.Develop.Migrations
b.ToTable("bot_accounts", (string)null); b.ToTable("bot_accounts", (string)null);
}); });
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnCustomApp", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -139,7 +139,7 @@ namespace DysonNetwork.Develop.Migrations
b.ToTable("custom_apps", (string)null); b.ToTable("custom_apps", (string)null);
}); });
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnCustomAppSecret", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -190,24 +190,7 @@ namespace DysonNetwork.Develop.Migrations
b.ToTable("custom_app_secrets", (string)null); b.ToTable("custom_app_secrets", (string)null);
}); });
modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnDevProject", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("PublisherId")
.HasColumnType("uuid")
.HasColumnName("publisher_id");
b.HasKey("Id")
.HasName("pk_developers");
b.ToTable("developers", (string)null);
});
modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -257,9 +240,73 @@ namespace DysonNetwork.Develop.Migrations
b.ToTable("dev_projects", (string)null); 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() .WithMany()
.HasForeignKey("ProjectId") .HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
@@ -269,9 +316,9 @@ namespace DysonNetwork.Develop.Migrations
b.Navigation("Project"); 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() .WithMany()
.HasForeignKey("ProjectId") .HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
@@ -281,9 +328,9 @@ namespace DysonNetwork.Develop.Migrations
b.Navigation("Project"); 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") .WithMany("Secrets")
.HasForeignKey("AppId") .HasForeignKey("AppId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
@@ -293,9 +340,9 @@ namespace DysonNetwork.Develop.Migrations
b.Navigation("App"); 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") .WithMany("Projects")
.HasForeignKey("DeveloperId") .HasForeignKey("DeveloperId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
@@ -305,12 +352,24 @@ namespace DysonNetwork.Develop.Migrations
b.Navigation("Developer"); 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"); b.Navigation("Secrets");
}); });
modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnDeveloper", b =>
{ {
b.Navigation("Projects"); 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.Develop;
using DysonNetwork.Shared.Auth; using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http;
using DysonNetwork.Develop.Startup; using DysonNetwork.Develop.Startup;
using DysonNetwork.Shared.Networking;
using DysonNetwork.Shared.Registry; using DysonNetwork.Shared.Registry;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,11 +23,12 @@ public class AppDatabase(
public DbSet<QuotaRecord> QuotaRecords { get; set; } = null!; public DbSet<QuotaRecord> QuotaRecords { get; set; } = null!;
public DbSet<SnCloudFile> Files { 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<SnCloudFileIndex> FileIndexes { get; set; }
public DbSet<PersistentTask> Tasks { get; set; } = null!; public DbSet<PersistentTask> Tasks { get; set; } = null!;
public DbSet<PersistentUploadTask> UploadTasks { get; set; } = null!; // Backward compatibility
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {

View File

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

View File

@@ -11,9 +11,9 @@
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" /> <PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" />
<PackageReference Include="FFMpegCore" Version="5.4.0" /> <PackageReference Include="FFMpegCore" Version="5.4.0" />
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" /> <PackageReference Include="Grpc.AspNetCore.Server" Version="2.76.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
@@ -27,14 +27,13 @@
<PackageReference Include="NetVips" Version="3.1.0" /> <PackageReference Include="NetVips" Version="3.1.0" />
<PackageReference Include="NetVips.Native.linux-x64" Version="8.17.3" /> <PackageReference Include="NetVips.Native.linux-x64" Version="8.17.3" />
<PackageReference Include="NetVips.Native.osx-arm64" 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.JsonNet" Version="3.2.0" />
<PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" /> <PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" /> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="Quartz" Version="3.15.1" /> <PackageReference Include="Quartz" Version="3.15.1" />
<PackageReference Include="Quartz.AspNetCore" Version="3.15.1" /> <PackageReference Include="Quartz.AspNetCore" Version="3.15.1" />
<PackageReference Include="Quartz.Extensions.Hosting" 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 --> <!-- 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" Version="2.88.9" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.9" /> <PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.9" />

View File

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

View File

@@ -141,6 +141,7 @@ public class FileIndexService(AppDatabase db)
return await db.FileIndexes return await db.FileIndexes
.Where(fi => fi.AccountId == accountId && fi.Path == normalizedPath) .Where(fi => fi.AccountId == accountId && fi.Path == normalizedPath)
.Include(fi => fi.File) .Include(fi => fi.File)
.ThenInclude(f => f.Object)
.ToListAsync(); .ToListAsync();
} }
@@ -154,6 +155,7 @@ public class FileIndexService(AppDatabase db)
return await db.FileIndexes return await db.FileIndexes
.Where(fi => fi.FileId == fileId) .Where(fi => fi.FileId == fileId)
.Include(fi => fi.File) .Include(fi => fi.File)
.ThenInclude(f => f.Object)
.ToListAsync(); .ToListAsync();
} }
@@ -167,6 +169,7 @@ public class FileIndexService(AppDatabase db)
return await db.FileIndexes return await db.FileIndexes
.Where(fi => fi.AccountId == accountId) .Where(fi => fi.AccountId == accountId)
.Include(fi => fi.File) .Include(fi => fi.File)
.ThenInclude(f => f.Object)
.ToListAsync(); .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 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "9.0.10") .HasAnnotation("ProductVersion", "10.0.1")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -100,12 +100,6 @@ namespace DysonNetwork.Drive.Migrations
.HasColumnType("character varying(1024)") .HasColumnType("character varying(1024)")
.HasColumnName("description"); .HasColumnName("description");
b.Property<string>("Discriminator")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("character varying(21)")
.HasColumnName("discriminator");
b.Property<string>("ErrorMessage") b.Property<string>("ErrorMessage")
.HasMaxLength(1024) .HasMaxLength(1024)
.HasColumnType("character varying(1024)") .HasColumnType("character varying(1024)")
@@ -173,60 +167,6 @@ namespace DysonNetwork.Drive.Migrations
.HasName("pk_tasks"); .HasName("pk_tasks");
b.ToTable("tasks", (string)null); 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 => modelBuilder.Entity("DysonNetwork.Shared.Models.FilePool", b =>
@@ -321,54 +261,25 @@ namespace DysonNetwork.Drive.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("expired_at"); .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") b.Property<bool>("IsMarkedRecycle")
.HasColumnType("boolean") .HasColumnType("boolean")
.HasColumnName("is_marked_recycle"); .HasColumnName("is_marked_recycle");
b.Property<string>("MimeType")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("mime_type");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(1024) .HasMaxLength(1024)
.HasColumnType("character varying(1024)") .HasColumnType("character varying(1024)")
.HasColumnName("name"); .HasColumnName("name");
b.Property<Guid?>("PoolId") b.Property<string>("ObjectId")
.HasColumnType("uuid") .HasMaxLength(32)
.HasColumnName("pool_id"); .HasColumnType("character varying(32)")
.HasColumnName("object_id");
b.Property<List<ContentSensitiveMark>>("SensitiveMarks") b.PrimitiveCollection<string>("SensitiveMarks")
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("sensitive_marks"); .HasColumnName("sensitive_marks");
b.Property<long>("Size")
.HasColumnType("bigint")
.HasColumnName("size");
b.Property<string>("StorageId") b.Property<string>("StorageId")
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)")
@@ -397,8 +308,8 @@ namespace DysonNetwork.Drive.Migrations
b.HasIndex("BundleId") b.HasIndex("BundleId")
.HasDatabaseName("ix_files_bundle_id"); .HasDatabaseName("ix_files_bundle_id");
b.HasIndex("PoolId") b.HasIndex("ObjectId")
.HasDatabaseName("ix_files_pool_id"); .HasDatabaseName("ix_files_object_id");
b.ToTable("files", (string)null); b.ToTable("files", (string)null);
}); });
@@ -509,78 +420,153 @@ namespace DysonNetwork.Drive.Migrations
b.ToTable("bundles", (string)null); 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") b.Property<Instant>("CreatedAt")
.HasColumnType("uuid") .HasColumnType("timestamp with time zone")
.HasColumnName("bundle_id"); .HasColumnName("created_at");
b.Property<long>("ChunkSize") b.Property<Instant?>("DeletedAt")
.HasColumnType("bigint") .HasColumnType("timestamp with time zone")
.HasColumnName("chunk_size"); .HasColumnName("deleted_at");
b.Property<int>("ChunksCount") b.Property<bool>("HasCompression")
.HasColumnType("integer") .HasColumnType("boolean")
.HasColumnName("chunks_count"); .HasColumnName("has_compression");
b.Property<int>("ChunksUploaded") b.Property<bool>("HasThumbnail")
.HasColumnType("integer") .HasColumnType("boolean")
.HasColumnName("chunks_uploaded"); .HasColumnName("has_thumbnail");
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") b.Property<string>("Hash")
.IsRequired() .HasMaxLength(256)
.HasColumnType("text") .HasColumnType("character varying(256)")
.HasColumnName("hash"); .HasColumnName("hash");
b.Property<string>("Path") b.Property<Dictionary<string, object>>("Meta")
.HasColumnType("text") .HasColumnType("jsonb")
.HasColumnName("path"); .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") .HasColumnType("uuid")
.HasColumnName("pool_id"); .HasColumnName("pool_id");
b.PrimitiveCollection<List<int>>("UploadedChunks") b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<string>("StorageId")
.IsRequired() .IsRequired()
.HasColumnType("integer[]") .HasMaxLength(128)
.HasColumnName("uploaded_chunks"); .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.HasKey("Id")
{ .HasName("pk_file_replicas");
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"); 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 => modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
@@ -590,14 +576,14 @@ namespace DysonNetwork.Drive.Migrations
.HasForeignKey("BundleId") .HasForeignKey("BundleId")
.HasConstraintName("fk_files_bundles_bundle_id"); .HasConstraintName("fk_files_bundles_bundle_id");
b.HasOne("DysonNetwork.Shared.Models.FilePool", "Pool") b.HasOne("DysonNetwork.Shared.Models.SnFileObject", "Object")
.WithMany() .WithMany()
.HasForeignKey("PoolId") .HasForeignKey("ObjectId")
.HasConstraintName("fk_files_pools_pool_id"); .HasConstraintName("fk_files_file_objects_object_id");
b.Navigation("Bundle"); b.Navigation("Bundle");
b.Navigation("Pool"); b.Navigation("Object");
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileIndex", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFileIndex", b =>
@@ -612,17 +598,39 @@ namespace DysonNetwork.Drive.Migrations
b.Navigation("File"); 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 => modelBuilder.Entity("DysonNetwork.Shared.Models.SnCloudFile", b =>
{ {
b.Navigation("FileIndexes"); b.Navigation("FileIndexes");
b.Navigation("References");
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b => modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileBundle", b =>
{ {
b.Navigation("Files"); b.Navigation("Files");
}); });
modelBuilder.Entity("DysonNetwork.Shared.Models.SnFileObject", b =>
{
b.Navigation("FileReplicas");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ using DysonNetwork.Drive.Storage;
using DysonNetwork.Drive.Storage.Model; using DysonNetwork.Drive.Storage.Model;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Stream; using DysonNetwork.Shared.Queue;
using FFMpegCore; using FFMpegCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NATS.Client.Core; using NATS.Client.Core;
@@ -156,18 +156,45 @@ public class BroadcastEventHandler(
logger.LogInformation("Processing file {FileId} in background...", fileId); 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 // Find the upload task associated with this file
var uploadTask = await scopedDb.Tasks var baseTask = await scopedDb.Tasks
.OfType<PersistentUploadTask>() .Where(t => t.Type == TaskType.FileUpload)
.FirstOrDefaultAsync(t => t.FileName == fileToUpdate.Name && t.FileSize == fileToUpdate.Size); .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); var fileExtension = Path.GetExtension(processingFilePath);
switch (contentType.Split('/')[0]) switch (contentType.Split('/')[0])
@@ -287,12 +314,26 @@ public class BroadcastEventHandler(
logger.LogInformation("Uploaded file {FileId} done!", fileId); logger.LogInformation("Uploaded file {FileId} done!", fileId);
var now = SystemClock.Instance.GetCurrentInstant(); 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 await scopedDb.Files.Where(f => f.Id == fileId).ExecuteUpdateAsync(setter => setter
.SetProperty(f => f.UploadedAt, now) .SetProperty(f => f.UploadedAt, now)
.SetProperty(f => f.PoolId, destPool) );
.SetProperty(f => f.MimeType, newMimeType)
.SetProperty(f => f.HasCompression, hasCompression) await scopedDb.FileObjects.Where(fo => fo.Id == fileId).ExecuteUpdateAsync(setter => setter
.SetProperty(f => f.HasThumbnail, hasThumbnail) .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 // Only delete temp file after successful upload and db update

View File

@@ -15,20 +15,27 @@ public static class ScheduledJobsConfiguration
.ForJob(appDatabaseRecyclingJob) .ForJob(appDatabaseRecyclingJob)
.WithIdentity("AppDatabaseRecyclingTrigger") .WithIdentity("AppDatabaseRecyclingTrigger")
.WithCronSchedule("0 0 0 * * ?")); .WithCronSchedule("0 0 0 * * ?"));
var cloudFileUnusedRecyclingJob = new JobKey("CloudFileUnusedRecycling"); var cloudFileUnusedRecyclingJob = new JobKey("CloudFileUnusedRecycling");
q.AddJob<CloudFileUnusedRecyclingJob>(opts => opts.WithIdentity(cloudFileUnusedRecyclingJob)); q.AddJob<CloudFileUnusedRecyclingJob>(opts => opts.WithIdentity(cloudFileUnusedRecyclingJob));
q.AddTrigger(opts => opts q.AddTrigger(opts => opts
.ForJob(cloudFileUnusedRecyclingJob) .ForJob(cloudFileUnusedRecyclingJob)
.WithIdentity("CloudFileUnusedRecyclingTrigger") .WithIdentity("CloudFileUnusedRecyclingTrigger")
.WithCronSchedule("0 0 0 * * ?")); .WithCronSchedule("0 0 0 * * ?"));
var persistentTaskCleanupJob = new JobKey("PersistentTaskCleanup"); var persistentTaskCleanupJob = new JobKey("PersistentTaskCleanup");
q.AddJob<PersistentTaskCleanupJob>(opts => opts.WithIdentity(persistentTaskCleanupJob)); q.AddJob<PersistentTaskCleanupJob>(opts => opts.WithIdentity(persistentTaskCleanupJob));
q.AddTrigger(opts => opts q.AddTrigger(opts => opts
.ForJob(persistentTaskCleanupJob) .ForJob(persistentTaskCleanupJob)
.WithIdentity("PersistentTaskCleanupTrigger") .WithIdentity("PersistentTaskCleanupTrigger")
.WithCronSchedule("0 0 2 * * ?")); // Run daily at 2 AM .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); services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);

View File

@@ -9,58 +9,64 @@ namespace DysonNetwork.Drive.Startup;
public static class ServiceCollectionExtensions 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.AddHttpContextAccessor();
services.AddHttpClient();
// Register gRPC services
services.AddGrpc(options =>
{ {
options.EnableDetailedErrors = true; // Will be adjusted in Program.cs services.AddDbContext<AppDatabase>();
options.MaxReceiveMessageSize = 16 * 1024 * 1024; // 16MB services.AddHttpContextAccessor();
options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB
});
services.AddGrpcReflection();
services.AddControllers().AddJsonOptions(options => services.AddHttpClient();
// Register gRPC services
services.AddGrpc(options =>
{
options.EnableDetailedErrors = true; // Will be adjusted in Program.cs
options.MaxReceiveMessageSize = 16 * 1024 * 1024; // 16MB
options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB
});
services.AddGrpcReflection();
services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
});
return services;
}
public IServiceCollection AddAppAuthentication()
{ {
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals; services.AddAuthorization();
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; return services;
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower; }
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); public IServiceCollection AddAppFlushHandlers()
}); {
services.AddSingleton<FlushBufferService>();
return services; return services;
} }
public static IServiceCollection AddAppAuthentication(this IServiceCollection services) public IServiceCollection AddAppBusinessServices(IConfiguration configuration)
{ {
services.AddAuthorization(); services.Configure<Storage.Options.FileReanalysisOptions>(configuration.GetSection("FileReanalysis"));
return services;
}
public static IServiceCollection AddAppFlushHandlers(this IServiceCollection services) services.AddScoped<Storage.FileService>();
{ services.AddScoped<Storage.FileReanalysisService>();
services.AddSingleton<FlushBufferService>(); services.AddScoped<Storage.PersistentTaskService>();
services.AddScoped<FileIndexService>();
services.AddScoped<Billing.UsageService>();
services.AddScoped<Billing.QuotaService>();
return services; services.AddHostedService<BroadcastEventHandler>();
} services.AddHostedService<Storage.FileReanalysisBackgroundService>();
public static IServiceCollection AddAppBusinessServices(this IServiceCollection services) return services;
{ }
services.AddScoped<Storage.FileService>();
services.AddScoped<Storage.FileReferenceService>();
services.AddScoped<Storage.PersistentTaskService>();
services.AddScoped<FileIndexService>();
services.AddScoped<Billing.UsageService>();
services.AddScoped<Billing.QuotaService>();
services.AddHostedService<BroadcastEventHandler>();
return services;
} }
} }

View File

@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using DysonNetwork.Shared.Models;
using Quartz; using Quartz;
namespace DysonNetwork.Drive.Storage; namespace DysonNetwork.Drive.Storage;
@@ -26,7 +27,7 @@ public class CloudFileUnusedRecyclingJob(
File.Delete(file); File.Delete(file);
} }
} }
logger.LogInformation("Marking unused cloud files..."); logger.LogInformation("Marking unused cloud files...");
var recyclablePools = await db.Pools var recyclablePools = await db.Pools
@@ -40,14 +41,16 @@ public class CloudFileUnusedRecyclingJob(
var markedCount = 0; var markedCount = 0;
var totalFiles = await db.Files var totalFiles = await db.Files
.Where(f => f.FileIndexes.Count == 0) .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) .Where(f => !f.IsMarkedRecycle)
.Include(f => f.Object)
.ThenInclude(o => o.FileReplicas)
.CountAsync(); .CountAsync();
logger.LogInformation("Found {TotalFiles} files to check for unused status", totalFiles); 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 // 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 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 // Instead of loading all files at once, use pagination
@@ -56,17 +59,18 @@ public class CloudFileUnusedRecyclingJob(
while (hasMoreFiles) while (hasMoreFiles)
{ {
// Query for the next batch of files using keyset pagination IQueryable<SnCloudFile> baseQuery = db.Files
var filesQuery = db.Files .Where(f => f.Object!.FileReplicas.Any(r => r.PoolId.HasValue && recyclablePools.Contains(r.PoolId.Value)))
.Where(f => f.PoolId.HasValue && recyclablePools.Contains(f.PoolId.Value))
.Where(f => !f.IsMarkedRecycle) .Where(f => !f.IsMarkedRecycle)
.Where(f => f.CreatedAt <= ageThreshold); // Only process older files first .Where(f => f.CreatedAt <= ageThreshold)
.Include(f => f.Object)
.ThenInclude(o => o.FileReplicas);
if (lastProcessedId != null) 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 var fileBatch = await baseQuery
.OrderBy(f => f.Id) // Ensure consistent ordering for pagination .OrderBy(f => f.Id)
.Take(batchSize) .Take(batchSize)
.Select(f => f.Id) .Select(f => f.Id)
.ToListAsync(); .ToListAsync();
@@ -80,13 +84,11 @@ public class CloudFileUnusedRecyclingJob(
processedCount += fileBatch.Count; processedCount += fileBatch.Count;
lastProcessedId = fileBatch.Last(); lastProcessedId = fileBatch.Last();
// Optimized query: Find files that have no references OR all references are expired // Optimized query: Find files that have no file object or no replicas
// This replaces the memory-intensive approach of loading all references // A file is considered "unused" if its file object has no replicas
var filesToMark = await db.Files var filesToMark = await db.Files
.Where(f => fileBatch.Contains(f.Id)) .Where(f => fileBatch.Contains(f.Id))
.Where(f => !db.FileReferences.Any(r => r.FileId == f.Id) || // No references at all .Where(f => f.Object == null || f.Object.FileReplicas.Count == 0)
!db.FileReferences.Any(r => r.FileId == f.Id && // OR has references but all are expired
(r.ExpiredAt == null || r.ExpiredAt > now)))
.Select(f => f.Id) .Select(f => f.Id)
.ToListAsync(); .ToListAsync();
@@ -112,7 +114,7 @@ public class CloudFileUnusedRecyclingJob(
); );
} }
} }
var expiredCount = await db.Files var expiredCount = await db.Files
.Where(f => f.ExpiredAt.HasValue && f.ExpiredAt.Value <= now) .Where(f => f.ExpiredAt.HasValue && f.ExpiredAt.Value <= now)
.ExecuteUpdateAsync(s => s.SetProperty(f => f.IsMarkedRecycle, true)); .ExecuteUpdateAsync(s => s.SetProperty(f => f.IsMarkedRecycle, true));

View File

@@ -1,3 +1,5 @@
using System.Security.Cryptography;
using System.Text;
using DysonNetwork.Shared.Auth; using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
@@ -14,10 +16,14 @@ public class FileController(
AppDatabase db, AppDatabase db,
FileService fs, FileService fs,
IConfiguration configuration, IConfiguration configuration,
IWebHostEnvironment env, IWebHostEnvironment env
FileReferenceService fileReferenceService
) : ControllerBase ) : 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}")] [HttpGet("{id}")]
public async Task<ActionResult> OpenFile( public async Task<ActionResult> OpenFile(
string id, string id,
@@ -32,7 +38,8 @@ public class FileController(
var file = await fs.GetFileAsync(fileId); var file = await fs.GetFileAsync(fileId);
if (file is null) return NotFound("File not found."); 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; if (accessResult is not null) return accessResult;
// Handle direct storage URL redirect // Handle direct storage URL redirect
@@ -47,7 +54,7 @@ public class FileController(
return await ServeRemoteFile(file, fileExtension, download, original, thumbnail, overrideMimeType); 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); if (!id.Contains('.')) return (id, null);
@@ -55,38 +62,186 @@ public class FileController(
return (parts.First(), parts.Last()); 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)) if (file.Bundle is not null && !file.Bundle.VerifyPasscode(passcode))
return StatusCode(StatusCodes.Status403Forbidden, "The passcode is incorrect."); 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) 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); var tempFilePath = Path.Combine(Path.GetTempPath(), file.Id);
if (System.IO.File.Exists(tempFilePath)) if (System.IO.File.Exists(tempFilePath))
{ {
if (file.IsEncrypted) return PhysicalFile(tempFilePath, file.MimeType ?? "application/octet-stream",
return Task.FromResult<ActionResult>(StatusCode(StatusCodes.Status403Forbidden, file.Name, enableRangeProcessing: true);
"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));
} }
// Fallback for tus uploads
var tusStorePath = configuration.GetValue<string>("Storage:Uploads"); var tusStorePath = configuration.GetValue<string>("Storage:Uploads");
if (string.IsNullOrEmpty(tusStorePath)) if (string.IsNullOrEmpty(tusStorePath))
return Task.FromResult<ActionResult>(StatusCode(StatusCodes.Status400BadRequest, return StatusCode(StatusCodes.Status400BadRequest,
"File is being processed. Please try again later.")); "File is being processed. Please try again later.");
var tusFilePath = Path.Combine(env.ContentRootPath, tusStorePath, file.Id); var tusFilePath = Path.Combine(env.ContentRootPath, tusStorePath, file.Id);
return System.IO.File.Exists(tusFilePath) if (System.IO.File.Exists(tusFilePath))
? Task.FromResult<ActionResult>(PhysicalFile(tusFilePath, file.MimeType ?? "application/octet-stream", {
file.Name, enableRangeProcessing: true)) return PhysicalFile(tusFilePath, file.MimeType ?? "application/octet-stream",
: Task.FromResult<ActionResult>(StatusCode(StatusCodes.Status400BadRequest, file.Name, enableRangeProcessing: true);
"File is being processed. Please try again later.")); }
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( private async Task<ActionResult> ServeRemoteFile(
@@ -98,11 +253,12 @@ public class FileController(
string? overrideMimeType 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, return StatusCode(StatusCodes.Status500InternalServerError,
"File is in an inconsistent state: uploaded but no pool ID."); "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) if (pool is null)
return StatusCode(StatusCodes.Status410Gone, "The pool of the file no longer exists or not accessible."); 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) private ActionResult? TryProxyRedirect(SnCloudFile file, RemoteStorageConfig dest, string fileName)
{ {
if (dest.ImageProxy is not null && (file.MimeType?.StartsWith("image/") ?? false)) if (dest.ImageProxy is not null && (file.MimeType?.StartsWith("image/") ?? false))
{
return Redirect(BuildProxyUrl(dest.ImageProxy, fileName)); return Redirect(BuildProxyUrl(dest.ImageProxy, fileName));
}
return dest.AccessProxy is not null ? Redirect(BuildProxyUrl(dest.AccessProxy, fileName)) : null; return dest.AccessProxy is not null ? Redirect(BuildProxyUrl(dest.AccessProxy, fileName)) : null;
} }
@@ -168,7 +322,7 @@ public class FileController(
string? overrideMimeType string? overrideMimeType
) )
{ {
var client = fs.CreateMinioClient(dest); var client = FileService.CreateMinioClient(dest);
if (client is null) if (client is null)
return BadRequest("Failed to configure client for remote destination, file got an invalid storage remote."); return BadRequest("Failed to configure client for remote destination, file got an invalid storage remote.");
@@ -182,6 +336,9 @@ public class FileController(
.WithHeaders(headers) .WithHeaders(headers)
); );
if (dest.AccessEndpoint is not null)
openUrl = openUrl.Replace($"{dest.Endpoint}/{dest.Bucket}", dest.AccessEndpoint);
return Redirect(openUrl); return Redirect(openUrl);
} }
@@ -231,17 +388,18 @@ public class FileController(
} }
[HttpGet("{id}/references")] [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); var file = await fs.GetFileAsync(id);
if (file is null) return NotFound("File not found."); if (file is null) return NotFound("File not found.");
// Check if user has access to the file var currentUser = HttpContext.Items["CurrentUser"] as Account;
var accessResult = await ValidateFileAccess(file, null); var accessResult = await ValidateFileAccess(file, null, currentUser);
if (accessResult is not null) return accessResult; if (accessResult is not null) return accessResult;
// Get references using the injected FileReferenceService var references = await db.Files
var references = await fileReferenceService.GetReferencesAsync(id); .Where(f => f.ObjectId == file.ObjectId && f.Id != file.Id)
.ToListAsync();
return Ok(references); return Ok(references);
} }
@@ -304,10 +462,10 @@ public class FileController(
var filesQuery = db.Files var filesQuery = db.Files
.Where(e => e.IsMarkedRecycle == recycled) .Where(e => e.IsMarkedRecycle == recycled)
.Where(e => e.AccountId == accountId) .Where(e => e.AccountId == accountId)
.Include(e => e.Pool) .Include(e => e.Object)
.AsQueryable(); .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)) if (!string.IsNullOrWhiteSpace(query))
{ {
@@ -387,4 +545,4 @@ public class FileController(
var count = await fs.DeleteAllRecycledFilesAsync(); var count = await fs.DeleteAllRecycledFilesAsync();
return Ok(new { Count = count }); return Ok(new { Count = count });
} }
} }

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

View File

@@ -1,4 +1,3 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Google.Protobuf.WellKnownTypes; using Google.Protobuf.WellKnownTypes;
using Grpc.Core; using Grpc.Core;
@@ -7,7 +6,7 @@ namespace DysonNetwork.Drive.Storage
{ {
public class FileServiceGrpc(FileService fileService) : Shared.Proto.FileService.FileServiceBase 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); var file = await fileService.GetFileAsync(request.Id);
return file?.ToProtoValue() ?? throw new RpcException(new Status(StatusCode.NotFound, "File not found")); 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()) } }; 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) ServerCallContext context)
{ {
var file = await fileService.GetFileAsync(request.File.Id); var file = await fileService.GetFileAsync(request.File.Id);
@@ -41,31 +40,22 @@ namespace DysonNetwork.Drive.Storage
return new Empty(); 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) public override async Task<Empty> PurgeCache(PurgeCacheRequest request, ServerCallContext context)
{ {
await fileService._PurgeCacheAsync(request.FileId); await fileService._PurgeCacheAsync(request.FileId);
return new Empty(); 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.Index;
using DysonNetwork.Drive.Storage.Model; using DysonNetwork.Drive.Storage.Model;
using DysonNetwork.Shared.Auth; using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Networking;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -64,7 +64,10 @@ public class FileUploadController(
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
// Check if a file with the same hash already exists // 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) if (existingFile != null)
{ {
// Create the file index if a path is provided, even for existing files // 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( public record FileUploadedEventPayload(
string FileId, string FileId,
Guid RemoteId, Guid RemoteId,
string StorageId, string? StorageId,
string ContentType, string? ContentType,
string ProcessingFilePath, string ProcessingFilePath,
bool IsTempFile bool IsTempFile
); );

View File

@@ -161,7 +161,6 @@ public class PersistentTask : ModelBase
public long? EstimatedDurationSeconds { get; set; } public long? EstimatedDurationSeconds { get; set; }
} }
// Backward compatibility - UploadTask inherits from PersistentTask
public class PersistentUploadTask : PersistentTask public class PersistentUploadTask : PersistentTask
{ {
public PersistentUploadTask() 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) if (cachedTask is not null)
return cachedTask; return cachedTask;
var task = await db.Tasks var baseTask = await db.Tasks
.OfType<PersistentUploadTask>() .FirstOrDefaultAsync(t => t.TaskId == taskId && t.Type == TaskType.FileUpload && t.Status == TaskStatus.InProgress);
.FirstOrDefaultAsync(t => t.TaskId == taskId && t.Status == TaskStatus.InProgress);
if (task is not null) if (baseTask is null)
await SetCacheAsync(task); return null;
var task = ConvertToUploadTask(baseTask);
await SetCacheAsync(task);
return 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> /// <summary>
/// Updates chunk upload progress /// Updates chunk upload progress
/// </summary> /// </summary>
@@ -697,8 +735,7 @@ public class PersistentTaskService(
// Use ExecuteUpdateAsync to update the Parameters dictionary directly // Use ExecuteUpdateAsync to update the Parameters dictionary directly
var updatedRows = await db.Tasks var updatedRows = await db.Tasks
.OfType<PersistentUploadTask>() .Where(t => t.TaskId == taskId && t.Type == TaskType.FileUpload)
.Where(t => t.TaskId == taskId)
.ExecuteUpdateAsync(setters => setters .ExecuteUpdateAsync(setters => setters
.SetProperty(t => t.Parameters, ParameterHelper.Untyped(parameters)) .SetProperty(t => t.Parameters, ParameterHelper.Untyped(parameters))
.SetProperty(t => t.LastActivity, now) .SetProperty(t => t.LastActivity, now)
@@ -754,7 +791,7 @@ public class PersistentTaskService(
int limit = 50 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 // Apply status filter
if (status.HasValue) if (status.HasValue)
@@ -766,19 +803,9 @@ public class PersistentTaskService(
var totalCount = await query.CountAsync(); var totalCount = await query.CountAsync();
// Apply sorting // Apply sorting
IOrderedQueryable<PersistentUploadTask> orderedQuery; IOrderedQueryable<PersistentTask> orderedQuery;
switch (sortBy?.ToLower()) 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": case "created":
orderedQuery = sortDescending orderedQuery = sortDescending
? query.OrderByDescending(t => t.CreatedAt) ? query.OrderByDescending(t => t.CreatedAt)
@@ -798,11 +825,27 @@ public class PersistentTaskService(
} }
// Apply pagination // Apply pagination
var items = await orderedQuery var baseTasks = await orderedQuery
.Skip(offset) .Skip(offset)
.Take(limit) .Take(limit)
.ToListAsync(); .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); return (items, totalCount);
} }
@@ -811,11 +854,12 @@ public class PersistentTaskService(
/// </summary> /// </summary>
public async Task<UserUploadStats> GetUserUploadStatsAsync(Guid accountId) public async Task<UserUploadStats> GetUserUploadStatsAsync(Guid accountId)
{ {
var tasks = await db.Tasks var baseTasks = await db.Tasks
.OfType<PersistentUploadTask>() .Where(t => t.Type == TaskType.FileUpload && t.AccountId == accountId)
.Where(t => t.AccountId == accountId)
.ToListAsync(); .ToListAsync();
var tasks = ConvertToUploadTasks(baseTasks);
var stats = new UserUploadStats var stats = new UserUploadStats
{ {
TotalTasks = tasks.Count, TotalTasks = tasks.Count,
@@ -850,8 +894,7 @@ public class PersistentTaskService(
public async Task<int> CleanupUserFailedTasksAsync(Guid accountId) public async Task<int> CleanupUserFailedTasksAsync(Guid accountId)
{ {
var failedTasks = await db.Tasks var failedTasks = await db.Tasks
.OfType<PersistentUploadTask>() .Where(t => t.Type == TaskType.FileUpload && t.AccountId == accountId &&
.Where(t => t.AccountId == accountId &&
(t.Status == TaskStatus.Failed || t.Status == TaskStatus.Expired)) (t.Status == TaskStatus.Failed || t.Status == TaskStatus.Expired))
.ToListAsync(); .ToListAsync();
@@ -883,12 +926,13 @@ public class PersistentTaskService(
/// </summary> /// </summary>
public async Task<List<PersistentUploadTask>> GetRecentUserTasksAsync(Guid accountId, int limit = 10) public async Task<List<PersistentUploadTask>> GetRecentUserTasksAsync(Guid accountId, int limit = 10)
{ {
return await db.Tasks var baseTasks = await db.Tasks
.OfType<PersistentUploadTask>() .Where(t => t.Type == TaskType.FileUpload && t.AccountId == accountId)
.Where(t => t.AccountId == accountId)
.OrderByDescending(t => t.LastActivity) .OrderByDescending(t => t.LastActivity)
.Take(limit) .Take(limit)
.ToListAsync(); .ToListAsync();
return ConvertToUploadTasks(baseTasks);
} }
/// <summary> /// <summary>

View File

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

View File

@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Gateway.Configuration;
[ApiController] [ApiController]
[Route("config")] [Route("config")]
public class ConfigurationController(IConfiguration configuration) : ControllerBase 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> </PropertyGroup>
<ItemGroup> <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"> <PackageReference Include="Nerdbank.GitVersioning" Version="3.9.50">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <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 System.Threading.RateLimiting;
using DysonNetwork.Shared.Http; using DysonNetwork.Gateway.Configuration;
using DysonNetwork.Gateway.Health;
using DysonNetwork.Shared.Networking;
using Yarp.ReverseProxy.Configuration; using Yarp.ReverseProxy.Configuration;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Options;
using NodaTime;
using NodaTime.Serialization.SystemTextJson;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -9,17 +16,23 @@ builder.AddServiceDefaults();
builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxValue, enableGrpc: false); 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 => builder.Services.AddCors(options =>
{ {
options.AddDefaultPolicy( options.AddDefaultPolicy(policy =>
policy => {
{ policy.SetIsOriginAllowed(origin => true)
policy.SetIsOriginAllowed(origin => true) .AllowAnyMethod()
.AllowAnyMethod() .AllowAnyHeader()
.AllowAnyHeader() .AllowCredentials()
.AllowCredentials() .WithExposedHeaders("X-Total", "X-NotReady");
.WithExposedHeaders("X-Total"); });
});
}); });
builder.Services.AddRateLimiter(options => builder.Services.AddRateLimiter(options =>
@@ -40,23 +53,22 @@ builder.Services.AddRateLimiter(options =>
}); });
options.OnRejected = async (context, token) => options.OnRejected = async (context, token) =>
{ {
// Log the rejected IP // Log the rejected IP
var logger = context.HttpContext.RequestServices var logger = context.HttpContext.RequestServices
.GetRequiredService<ILoggerFactory>() .GetRequiredService<ILoggerFactory>()
.CreateLogger("RateLimiter"); .CreateLogger("RateLimiter");
var ip = context.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; var ip = context.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
logger.LogWarning("Rate limit exceeded for IP: {IP}", ip); logger.LogWarning("Rate limit exceeded for IP: {IP}", ip);
// Respond to the client // Respond to the client
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsync( await context.HttpContext.Response.WriteAsync(
"Rate limit exceeded. Try again later.", token); "Rate limit exceeded. Try again later.", token);
}; };
}); });
var serviceNames = new[] { "ring", "pass", "drive", "sphere", "develop", "insight", "zone" };
var specialRoutes = new[] var specialRoutes = new[]
{ {
@@ -80,13 +92,19 @@ var specialRoutes = new[]
}, },
new RouteConfig new RouteConfig
{ {
RouteId = "drive-tus", RouteId = "sphere-webfinger",
ClusterId = "drive", ClusterId = "sphere",
Match = new RouteMatch { Path = "/api/tus" } 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 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", RouteId = $"{serviceName}-swagger",
ClusterId = serviceName, ClusterId = serviceName,
@@ -119,7 +137,7 @@ var swaggerRoutes = serviceNames.Select(serviceName => new RouteConfig
var routes = specialRoutes.Concat(apiRoutes).Concat(swaggerRoutes).ToArray(); 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, ClusterId = serviceName,
HealthCheck = new HealthCheckConfig HealthCheck = new HealthCheckConfig
@@ -131,7 +149,7 @@ var clusters = serviceNames.Select(serviceName => new ClusterConfig
Timeout = TimeSpan.FromSeconds(5), Timeout = TimeSpan.FromSeconds(5),
Path = "/health" Path = "/health"
}, },
Passive = new() Passive = new PassiveHealthCheckConfig
{ {
Enabled = true Enabled = true
} }
@@ -147,22 +165,40 @@ builder.Services
.LoadFromMemory(routes, clusters) .LoadFromMemory(routes, clusters)
.AddServiceDiscoveryDestinationResolver(); .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(); 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 var forwardedHeadersOptions = new ForwardedHeadersOptions
{ {
ForwardedHeaders = ForwardedHeaders.All ForwardedHeaders = ForwardedHeaders.All
}; };
forwardedHeadersOptions.KnownNetworks.Clear(); forwardedHeadersOptions.KnownIPNetworks.Clear();
forwardedHeadersOptions.KnownProxies.Clear(); forwardedHeadersOptions.KnownProxies.Clear();
app.UseForwardedHeaders(forwardedHeadersOptions); app.UseForwardedHeaders(forwardedHeadersOptions);
app.UseCors(); app.UseCors();
app.UseMiddleware<GatewayReadinessMiddleware>();
app.MapReverseProxy().RequireRateLimiting("fixed"); app.MapReverseProxy().RequireRateLimiting("fixed");
app.MapControllers(); app.MapControllers();
app.Run(); app.Run();

View File

@@ -6,11 +6,29 @@
} }
}, },
"Cache": { "Cache": {
"Serializer": "MessagePack" "Serializer": "JSON"
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"SiteUrl": "http://localhost:3000", "SiteUrl": "http://localhost:3000",
"Client": { "Client": {
"SomeSetting": "SomeValue" "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<SnThinkingThought> ThinkingThoughts { get; set; }
public DbSet<SnUnpaidAccount> UnpaidAccounts { 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) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
optionsBuilder.UseNpgsql( optionsBuilder.UseNpgsql(
@@ -38,6 +42,8 @@ public class AppDatabase(
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
modelBuilder.Ignore<SnAccount>();
modelBuilder.ApplySoftDeleteFilters(); 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 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 USER app
WORKDIR /app WORKDIR /app
EXPOSE 8080 EXPOSE 8080

View File

@@ -7,17 +7,27 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" /> <PackageReference Include="AngleSharp" Version="1.4.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11"> <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> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </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.Connectors.Ollama" Version="1.66.0-alpha" />
<PackageReference Include="Microsoft.SemanticKernel.Plugins.Web" 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" Version="3.15.1" />
<PackageReference Include="Quartz.AspNetCore" Version="3.15.1" /> <PackageReference Include="Quartz.AspNetCore" Version="3.15.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" /> <PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />
<PackageReference Include="System.ServiceModel.Syndication" Version="10.0.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -28,4 +38,8 @@
<Folder Include="Controllers\" /> <Folder Include="Controllers\" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Protobuf Remove="..\DysonNetwork.Shared\Proto\**" />
</ItemGroup>
</Project> </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 System.Collections.Generic;
using DysonNetwork.Insight; using DysonNetwork.Insight;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Models.Embed;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -20,7 +21,7 @@ namespace DysonNetwork.Insight.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "9.0.11") .HasAnnotation("ProductVersion", "10.0.1")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -143,6 +144,171 @@ namespace DysonNetwork.Insight.Migrations
b.ToTable("unpaid_accounts", (string)null); 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 => modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b =>
{ {
b.HasOne("DysonNetwork.Shared.Models.SnThinkingSequence", "Sequence") b.HasOne("DysonNetwork.Shared.Models.SnThinkingSequence", "Sequence")
@@ -154,6 +320,35 @@ namespace DysonNetwork.Insight.Migrations
b.Navigation("Sequence"); 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 #pragma warning restore 612, 618
} }
} }

View File

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

View File

@@ -1,7 +1,8 @@
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Quartz; using Quartz;
namespace DysonNetwork.Sphere.WebReader; namespace DysonNetwork.Insight.Reader;
[DisallowConcurrentExecution] [DisallowConcurrentExecution]
public class WebFeedScraperJob( public class WebFeedScraperJob(
@@ -15,7 +16,7 @@ public class WebFeedScraperJob(
{ {
logger.LogInformation("Starting web feed scraper job."); 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) 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.Auth;
using DysonNetwork.Shared.Models.Embed;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
namespace DysonNetwork.Sphere.WebReader; namespace DysonNetwork.Insight.Reader;
/// <summary> /// <summary>
/// Controller for web scraping and link preview services /// Controller for web scraping and link preview services

View File

@@ -1,4 +1,4 @@
namespace DysonNetwork.Sphere.WebReader; namespace DysonNetwork.Insight.Reader;
/// <summary> /// <summary>
/// Exception thrown when an error occurs during web reading operations /// 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;
using AngleSharp.Dom; using AngleSharp.Dom;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models.Embed;
using HtmlAgilityPack; using HtmlAgilityPack;
namespace DysonNetwork.Sphere.WebReader; namespace DysonNetwork.Insight.Reader;
/// <summary> /// <summary>
/// The service is amin to providing scrapping service to the Solar Network. /// 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; namespace DysonNetwork.Insight.Startup;
@@ -17,6 +18,11 @@ public static class ApplicationConfiguration
app.MapControllers(); app.MapControllers();
app.MapGrpcService<WebReaderGrpcService>();
app.MapGrpcService<WebArticleGrpcService>();
app.MapGrpcService<WebFeedGrpcService>();
app.MapGrpcReflectionService();
return app; return app;
} }
} }

View File

@@ -1,3 +1,4 @@
using DysonNetwork.Insight.Reader;
using DysonNetwork.Insight.Thought; using DysonNetwork.Insight.Thought;
using Quartz; using Quartz;
@@ -18,6 +19,20 @@ public static class ScheduledJobsConfiguration
.WithIntervalInMinutes(5) .WithIntervalInMinutes(5)
.RepeatForever()) .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); services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);

View File

@@ -1,7 +1,9 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DysonNetwork.Insight.Reader;
using DysonNetwork.Insight.Thought; using DysonNetwork.Insight.Thought;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry; using DysonNetwork.Shared.Registry;
using Microsoft.SemanticKernel; using Microsoft.SemanticKernel;
using NodaTime; using NodaTime;
@@ -11,60 +13,65 @@ namespace DysonNetwork.Insight.Startup;
public static class ServiceCollectionExtensions public static class ServiceCollectionExtensions
{ {
public static IServiceCollection AddAppServices(this IServiceCollection services) extension(IServiceCollection services)
{ {
services.AddDbContext<AppDatabase>(); public IServiceCollection AddAppServices()
services.AddHttpContextAccessor();
services.AddHttpClient();
// Register gRPC services
services.AddGrpc(options =>
{ {
options.EnableDetailedErrors = true; // Will be adjusted in Program.cs services.AddDbContext<AppDatabase>();
options.MaxReceiveMessageSize = 16 * 1024 * 1024; // 16MB services.AddHttpContextAccessor();
options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB
});
services.AddGrpcReflection();
// Register gRPC services services.AddHttpClient();
// Register OIDC services // Register gRPC services
services.AddControllers().AddJsonOptions(options => services.AddGrpc(options =>
{
options.EnableDetailedErrors = true; // Will be adjusted in Program.cs
options.MaxReceiveMessageSize = 16 * 1024 * 1024; // 16MB
options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB
});
services.AddGrpcReflection();
// Register gRPC services
// Register OIDC services
services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
});
return services;
}
public IServiceCollection AddAppAuthentication()
{ {
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals; services.AddAuthorization();
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; return services;
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower; }
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); public IServiceCollection AddAppFlushHandlers()
}); {
services.AddSingleton<FlushBufferService>();
return services; return services;
} }
public static IServiceCollection AddAppAuthentication(this IServiceCollection services) public IServiceCollection AddAppBusinessServices()
{ {
services.AddAuthorization(); return services;
return services; }
}
public static IServiceCollection AddAppFlushHandlers(this IServiceCollection services) public IServiceCollection AddThinkingServices(IConfiguration configuration)
{ {
services.AddSingleton<FlushBufferService>(); services.AddSingleton<ThoughtProvider>();
services.AddScoped<ThoughtService>();
services.AddScoped<Reader.WebFeedService>();
services.AddScoped<Reader.WebReaderService>();
return services; return services;
} }
public static IServiceCollection AddAppBusinessServices(this IServiceCollection services)
{
return services;
}
public static IServiceCollection AddThinkingServices(this IServiceCollection services, IConfiguration configuration)
{
services.AddSingleton<ThoughtProvider>();
services.AddScoped<ThoughtService>();
return services;
} }
} }

View File

@@ -1,6 +1,5 @@
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Microsoft.IdentityModel.Tokens;
using Microsoft.SemanticKernel; using Microsoft.SemanticKernel;
namespace DysonNetwork.Insight.Thought.Plugins; namespace DysonNetwork.Insight.Thought.Plugins;
@@ -24,6 +23,6 @@ public class SnAccountKernelPlugin(
var request = new LookupAccountBatchRequest(); var request = new LookupAccountBatchRequest();
request.Names.Add(username); request.Names.Add(username);
var response = await accountClient.LookupAccountBatchAsync(request); 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 "Insecure": true
}, },
"Cache": { "Cache": {
"Serializer": "MessagePack" "Serializer": "JSON"
}, },
"Thinking": { "Thinking": {
"DefaultService": "deepseek-chat", "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