Compare commits

...

408 Commits

Author SHA1 Message Date
2a3918134f 🐛 More file endpoint override options 2025-07-10 21:16:03 +08:00
734e5ca4a0 File opening response type overriding 2025-07-10 19:49:55 +08:00
ff0789904d Improved open file endpoint 2025-07-10 19:28:15 +08:00
17330fc104 🐛 Fixes for websocket 2025-07-10 15:57:00 +08:00
7c0ad46deb 🐛 Fix api redirect 2025-07-10 15:30:30 +08:00
b8fcd0d94f Post web view 2025-07-10 15:15:20 +08:00
fc6edd7378 💥 Add /api prefix for json endpoints with redirect 2025-07-10 14:18:02 +08:00
1f2cdb146d 🐛 Serval bug fixes 2025-07-10 12:53:45 +08:00
be236a27c6 🐛 Serval bug fixes and improvement to web page 2025-07-10 03:08:39 +08:00
99c36ae548 💄 Optimized the authorized page style 2025-07-10 02:28:27 +08:00
ed2961a5d5 💄 Restyled web pages 2025-07-10 01:53:44 +08:00
08b5ffa02f 🐛 Fix afdian got wrong URL to request 2025-07-09 22:51:14 +08:00
837a123c3b 🐛 Trying to fix payment handler 2025-07-09 22:00:06 +08:00
ad1166190f 🐛 Bug fixes 2025-07-09 21:43:39 +08:00
8e8c938132 🐛 Fix restore purchase in afdian 2025-07-09 21:39:38 +08:00
8e5b6ace45 Skip subscribed check in message 2025-07-07 13:08:31 +08:00
5757526ea5 Get user blocked users infra 2025-07-03 21:57:16 +08:00
6a9cd0905d Unblock user 2025-07-03 21:47:17 +08:00
082a096470 🐛 Fix wrong pagination query param name 2025-07-03 13:14:19 +08:00
3a72347432 🐛 Fix inconsistent between authorized feed and unauthorized feed 2025-07-03 00:43:02 +08:00
19b1e957dd 🐛 Trying to fix push notification 2025-07-03 00:05:00 +08:00
6449926334 Subscription required level, optimized cancellation logic 2025-07-02 21:58:44 +08:00
fb885e138d 💄 Optimized subscriptions 2025-07-02 21:30:35 +08:00
5bdc21ebc5 💄 Optimize activity service 2025-07-02 21:09:35 +08:00
f177377fe3 Return the complete data while auto completion 2025-07-02 01:16:59 +08:00
0df4864888 Auto completion handler for account and stickers 2025-07-01 23:51:26 +08:00
29b0ad184e 🐛 Fix search didn't contains description in post 2025-07-01 23:42:45 +08:00
ad730832db Searching in posts 2025-07-01 23:39:13 +08:00
71fcc26534 🐛 Bug fixes on web article 2025-07-01 22:35:46 +08:00
fb8fc69920 🐛 Fix web article loop 2025-07-01 00:57:05 +08:00
05bf2cd055 Web articles 2025-07-01 00:44:48 +08:00
ccb8a4e3f4 Bug fixes on web feed & scraping 2025-06-30 23:26:05 +08:00
ca5be5a01c ♻️ Refined web feed APIs 2025-06-29 22:02:26 +08:00
c4ea15097e ♻️ Refined custom apps 2025-06-29 20:32:08 +08:00
cdeed3c318 🐛 Fixes custom app 2025-06-29 19:38:29 +08:00
a53fcb10dd Developer programs APIs 2025-06-29 18:37:23 +08:00
c0879d30d4 Oidc auto approval and session reuse 2025-06-29 17:46:17 +08:00
0226bf8fa3 Complete oauth / oidc 2025-06-29 17:29:24 +08:00
217b434cc4 🐛 Dozen of bugs fixes 2025-06-29 16:35:01 +08:00
f8295c6a18 ♻️ Refactored oidc 2025-06-29 11:53:44 +08:00
d4fa08d320 Support OIDC 2025-06-29 03:47:58 +08:00
8bd0ea0fa1 💄 Optimized profile page 2025-06-29 00:58:08 +08:00
9ab31d79ce 💄 Optimized web version styling 2025-06-29 00:44:59 +08:00
ee5d6ef821 Support factor hint in web login 2025-06-29 00:38:00 +08:00
d7b443e678 Web version login support send factor code 2025-06-29 00:32:55 +08:00
98b2eeb13d Web version login 2025-06-28 22:53:07 +08:00
ec3961d546 🐛 Fix subscription notification send twice 2025-06-28 22:08:53 +08:00
a5dae37525 Publisher features flags APIs 2025-06-28 20:56:53 +08:00
933d762f24 Custom apps developer APIs 2025-06-28 19:20:36 +08:00
8251a9ec7d Publisher myself endpoint 2025-06-28 18:53:04 +08:00
38243f9eba 🐛 Fix publisher member has no account in response 2025-06-28 18:40:20 +08:00
b0b7afd6b3 Publisher members APIs 2025-06-28 18:13:32 +08:00
6237fd6140 🐛 Fix file analyze didn't generate video ratio 2025-06-28 03:00:53 +08:00
2e8d6a3667 🐛 Bug fixes 2025-06-28 01:28:20 +08:00
ac496777ed 🐛 Fix update realm failed caused by picture 2025-06-27 23:41:51 +08:00
19ddc1b363 👔 Discovery only shows community realms 2025-06-27 23:30:37 +08:00
661b612537 Chat and realm members with status 2025-06-27 23:24:27 +08:00
8432436fcf 🐛 Fix circular dependency in service injection 2025-06-27 23:16:28 +08:00
2a28948418 Chat room subscribe 2025-06-27 22:55:00 +08:00
5dd138949e 🐛 Fix update chat room failed 2025-06-27 22:54:50 +08:00
f540544a47 Activity debug include 2025-06-27 22:35:23 +08:00
9f8eec792b 🐛 Fix missing pagination query in discovery controller 2025-06-27 18:02:12 +08:00
0bdd429d87 🐛 Fix realm chat controllers path typo 2025-06-27 17:37:13 +08:00
b2203fb464 Publisher's recommendation (discovery) 2025-06-27 16:14:25 +08:00
c5bbd58f5c 👔 Reduce the chance to display realm discovery 2025-06-27 15:54:56 +08:00
35a9dcffbc 🐛 Fix post modeling data infiniting loop 2025-06-27 01:11:19 +08:00
1d50f225c1 🐛 Fix analyzing images failed due to duplicate meta keys 2025-06-27 00:57:00 +08:00
b7263b9804 💥 Update activity data format 2025-06-26 22:28:18 +08:00
c63d6e0fbc 💄 Optimize realm discovery 2025-06-26 19:59:09 +08:00
cebd1bd65a 💄 Optimized file analyze 2025-06-26 19:21:26 +08:00
da58e10d88 Ranked posts 2025-06-26 19:17:28 +08:00
d492c9ce1f Realm tags and discovery 2025-06-26 19:00:55 +08:00
f170793928 💄 Optimized web articles 2025-06-26 18:34:51 +08:00
1a137fbb6a Web articles and feed 2025-06-26 17:36:45 +08:00
21cf212d8f Abuse report 2025-06-25 23:49:18 +08:00
c6cb2a0dc3 🗃️ Remove the fk of session in action logs in order to fix logout 2025-06-25 00:03:50 +08:00
d9747daab9 👔 Optimize the file service handling for image 2025-06-24 23:58:16 +08:00
d91b705b9a 🐛 Fix the localization file 2025-06-23 03:06:52 +08:00
5ce3598cc9 🐛 Bug fixes in restore purchase with afdian 2025-06-23 02:59:41 +08:00
1b45f07419 🐛 Fixes notification 2025-06-23 02:46:49 +08:00
6bec0a672e 🐛 Fix create subscription from order will make the identifier null 2025-06-23 02:40:24 +08:00
c338512c16 🐛 Fix afdian webhook 2025-06-23 02:31:22 +08:00
9444913b72 🐛 Bug fixes for afdian webhook 2025-06-23 02:22:45 +08:00
50bfec59ee 🐛 Fix json library inconsistent cause the field name different 2025-06-23 02:08:03 +08:00
a97bf15362 🐛 Trying to fix afdian webhook 2025-06-23 01:58:12 +08:00
feb612afcd Payment and subscription notification 2025-06-23 01:34:53 +08:00
049a5c9b6f 🐛 Trying to fix bugs on afdian oidc 2025-06-23 01:11:21 +08:00
694bc77921 🔊 Add more logs on afdian oidc 2025-06-23 00:57:10 +08:00
be0b48cfd9 Afdian as payment handler 2025-06-23 00:29:37 +08:00
a23338c263 🐛 Fix afdian oauth 2025-06-22 22:02:14 +08:00
c5ef9b065b 🐛 Bug fixes 2025-06-22 21:42:28 +08:00
5990b17b4c Subscription status validation on profile 2025-06-22 20:09:52 +08:00
de7a2cea09 Complete subscriptions 2025-06-22 19:56:42 +08:00
698442ad13 Subscription and stellar program 2025-06-22 17:57:19 +08:00
9fd6016308 Subscription service 2025-06-22 03:15:16 +08:00
516090a5f8 🐛 Fixes afdian service 2025-06-22 02:35:12 +08:00
6b0e5f919d Add afdian as OIDC provider 2025-06-22 02:22:19 +08:00
c6450757be Add pin code 2025-06-22 00:18:50 +08:00
38abe16ba6 🐛 Fix bugs of subscription 2025-06-22 00:08:27 +08:00
bf40b51c41 🐛 Fix web reader can't get opengraph data 2025-06-22 00:06:19 +08:00
f50894a3d1 🐛 Fix publisher subscription cache and status validation 2025-06-21 23:46:59 +08:00
d1fb0b9b55 Filter on activities 2025-06-21 22:21:20 +08:00
f1a47fd079 Chat message also preview links 2025-06-21 15:19:49 +08:00
546b65f4c6 🐛 Fixed post service 2025-06-21 14:47:21 +08:00
1baa3109bc 🎨 Removed unused dependency injected services arguments in constructor 2025-06-21 14:32:59 +08:00
eadf25f389 🐛 Bug fixes in post embed links 2025-06-21 14:30:19 +08:00
d385abbf57 🐛 Trying to fix post link auto preview 2025-06-21 14:17:55 +08:00
a431fbbd51 🐛 Fix the logger service broke the post service 2025-06-21 14:09:56 +08:00
d83c69620f Adding the link preview automatically to post 2025-06-21 14:02:32 +08:00
cb8e720af1 🎨 Unified the cache key styling 2025-06-21 13:48:20 +08:00
5f30b56ef8 Link scrapping for preview 2025-06-21 13:46:30 +08:00
95010e4188 🐛 Fix set cache with group broken the cache 2025-06-21 13:46:21 +08:00
3824fba8e5 👔 Auto removal for expired relationships 2025-06-21 12:00:18 +08:00
aefc38c5a3 🐛 Fixing the post truncate 2025-06-21 11:46:08 +08:00
bcd107ae2c 🐛 Fix the included post did not truncated 2025-06-21 11:05:12 +08:00
700c818df8 🐛 Fix bugs on relationship api 2025-06-20 01:26:30 +08:00
27276c66c5 Post reactions and replies counting
🎨 Improve the styles of post service
2025-06-20 00:31:16 +08:00
abc89dc782 🎨 Splitting up startup steps in Program 2025-06-20 00:00:40 +08:00
15edd74a9f 👔 The read packet of message no longer forwarded 2025-06-19 23:54:55 +08:00
cfa63c7c93 Marking views 2025-06-19 23:54:25 +08:00
eb1c283971 ♻️ Refactor the last active flush handler 2025-06-19 23:47:50 +08:00
ea599fb15b 👔 Continue to remove GPS EXIF in photos 2025-06-19 23:44:11 +08:00
e40514e440 🐛 Fix discord oidc since it has no discovery endpoint 2025-06-19 01:56:16 +08:00
ca2d37eb39 Challenge retrieve api 2025-06-18 01:26:34 +08:00
2a5926a94a ♻️ Refactor the state and auth system 2025-06-18 01:23:06 +08:00
aba0f6b5e2 🐛 Fixes the callback page 2025-06-17 23:27:49 +08:00
3b9db74a34 💄 Update the default callback uri 2025-06-17 23:16:51 +08:00
5c02c63f70 🐛 Remove PKCE on Google OIDC 2025-06-17 23:08:58 +08:00
ada84f85e9 🐛 Fix url didn't decoded 2025-06-17 21:44:47 +08:00
3aad515ab8 🐛 Fix PCKE state broke the callback 2025-06-17 21:30:58 +08:00
5e455599fd 🐛 Fix remain use session 2025-06-17 21:18:06 +08:00
b634d587a7 🗑️ Remove unused session system 2025-06-17 21:11:25 +08:00
50d8f74a98 🐛 Trying to fix connection issues 2025-06-17 20:01:20 +08:00
1a5d0bbfc0 🐛 Fix bugs 2025-06-17 00:40:05 +08:00
b868d0c153 🐛 Fix oidc services didn't registered 2025-06-17 00:06:28 +08:00
60a8338c9a Able to connect with the auth endpoint for oidc 2025-06-16 23:42:55 +08:00
fe04b12561 Add cache to oidc discovery 2025-06-16 23:15:45 +08:00
47caff569d Add microsoft OIDC 2025-06-16 23:10:54 +08:00
b27b6b8c1b 🐛 Fixes bugs 2025-06-16 01:43:54 +08:00
c806c5d139 Self-contained oidc receiver page 2025-06-16 00:12:19 +08:00
70aeb5e0cb 🔨 Fix the dockerfile missing install ffprobe (ffmpeg) 2025-06-15 23:53:59 +08:00
90eca43284 Enrich the user connections 2025-06-15 23:49:26 +08:00
44ff09c119 Manual setup account connections
🐛 Fix infinite oauth token reconnect websocket due to missing device id
🐛 Fix IP forwarded headers didn't work
2025-06-15 23:35:36 +08:00
16ff5588b9 Login with Apple 2025-06-15 17:29:30 +08:00
bf013a108b 🧱 OAuth login infra 2025-06-15 13:11:45 +08:00
d00917fb39 🐛 Fix message notification missing action uri 2025-06-14 17:06:30 +08:00
c8b1c1ba55 Notification action URIs 2025-06-14 16:57:23 +08:00
e7942fc687 🐛 Fix notification via Send didn't push via ws 2025-06-14 16:47:03 +08:00
1f2e9b1de8 ♻️ Refactored publishers 2025-06-14 16:19:45 +08:00
2821beb1b7 🐛 Bug fixes on relationship API 2025-06-14 12:24:49 +08:00
fcab12f175 Get another user with current user direct message API 2025-06-14 11:52:55 +08:00
f5b04fa745 Get user's relationship API one to one 2025-06-14 11:49:20 +08:00
8af2dddb45 🐛 Fix get the posts repeatly 2025-06-13 00:36:12 +08:00
34902d0486 🗃️ Migrate previous changes 2025-06-12 01:01:52 +08:00
ffb3f83b96 Add the cloud file recycling job back to online with data safety. 2025-06-12 00:58:16 +08:00
2e09e63022 🗃️ Subscriptions modeling 2025-06-12 00:48:38 +08:00
ebac6698ff Enrich user profile (skip ci)
💥 Un-migrated database changes, DO NOT PUSH TO PRODUCTION
2025-06-11 23:35:04 +08:00
45f8cab555 📝 Add license and README 2025-06-11 23:23:45 +08:00
3db32caf7e 🗃️ Applies previous changes to database 2025-06-10 23:23:14 +08:00
ee14c942f2 Verification mark 2025-06-10 23:17:02 +08:00
ee36ad41d0 Activable badge 2025-06-10 23:04:46 +08:00
2fb29284fb 🐛 Fixes on notify level 2025-06-10 00:39:00 +08:00
922bf110ac Notify level actual take actions 2025-06-10 00:03:13 +08:00
9e17be38d8 Notify level API on chat
🗃️ Enrich the settings of chat members
🐛 Fix role settings on both chat & realm
2025-06-09 23:32:37 +08:00
0c48694493 🐛 Fix CORS expose headers 2025-06-09 20:31:20 +08:00
eef64df81a 👔 Text sanitizer no longer remove new lines and tabs 2025-06-09 01:40:06 +08:00
f155b4e2ac 🐛 Didn't deliver websocket packet to who send that message 2025-06-09 01:27:46 +08:00
877dd04b1f 🐛 Fixes for activities API 2025-06-09 01:22:00 +08:00
f96de0d325 🐛 Fix TUS broken for the web cause by the CORS policy 2025-06-08 23:57:13 +08:00
b8341734df ♻️ Refactored activities 2025-06-08 23:52:02 +08:00
39533cced3 🐛 Bug fixes on video uploading 2025-06-08 20:22:32 +08:00
4bae2ea427 🐛 Fix newly create account's auth factor has not been activated. 2025-06-08 19:46:29 +08:00
de64f64c0e Account contact can be primary 2025-06-08 19:42:52 +08:00
144b7fcfc2 Account contacts APIs
💄 Redesign emails
2025-06-08 17:18:23 +08:00
b1faabb07b Improved typing indicator 2025-06-08 12:14:23 +08:00
9d534660af ♻️ Better updating device name 2025-06-07 22:55:47 +08:00
3a978441b6 Sanitize text to remove hidden unicode and control characters 2025-06-07 22:06:57 +08:00
a8503735d1 Account devices 2025-06-07 22:06:38 +08:00
026c405cd4 👔 New calculation formula for authenticate steps 2025-06-07 20:59:26 +08:00
5a0c6dc4b0 Optimized risk detection
🐛 Fix bugs
2025-06-07 18:21:51 +08:00
b69dd659d4 🐛 Fix last active task didn't set up 2025-06-07 16:51:51 +08:00
b1c12685c8 Last seen at
👔 Update register account validation
2025-06-07 16:35:22 +08:00
0e78f7f7d2 🐛 Fixes and overhaul the auth experience 2025-06-07 12:14:42 +08:00
2f051d0615 🐛 Another bug fix to fix bug fix... 2025-06-07 02:45:33 +08:00
8938d347c6 🐛 Fix disable factor did not update 2025-06-07 02:37:35 +08:00
af39694be6 Implementation of email code and in app code 2025-06-07 02:16:13 +08:00
3c123be6a7 🐛 Yeah, bug fixes 2025-06-06 01:08:44 +08:00
bb7c9ca9d8 🐛 Yeah, another bug fix 2025-06-06 01:00:22 +08:00
aef6c60621 🐛 Bug fixes in auth factor endpoints 2025-06-06 00:55:20 +08:00
b6aa0e83a3 The post scanner now migrate outdated attachments again 2025-06-06 00:21:19 +08:00
f62d86d4a7 🐛 Fix create auth factor missing account id 2025-06-05 01:48:19 +08:00
f961469db1 🐛 Fix sending notification didn't set culture info for localization 2025-06-05 00:20:54 +08:00
a98bfec86f 👔 Auth factor no longer include secret and config in response 2025-06-04 23:20:21 +08:00
eacb7c8f2f 🐛 Trying to fix stickers 2025-06-04 01:48:26 +08:00
1f01a4088c Auto rotate the uploaded image according to EXIF 2025-06-04 01:44:35 +08:00
2f9df8009b More auth factors, sessions api 2025-06-04 01:11:50 +08:00
db9b04ef47 🐛 Fix sticker loading 2025-06-04 00:50:10 +08:00
49d5ee6184 Auth factors APIs 2025-06-03 23:50:34 +08:00
e62b2cc5ff 🗃️ Update auth factor db 2025-06-03 23:39:55 +08:00
c4f6798fd0 Able to list sticker packs with pubName 2025-06-03 23:33:06 +08:00
130ad8f186 👔 Adjust compression rate of webp uploaded 2025-06-03 00:30:35 +08:00
09e4150294 🗃️ Fix notification push subscription unique key 2025-06-03 00:30:23 +08:00
b25b08b5c5 🐛 Fixes bugs 2025-06-02 22:05:38 +08:00
2be92d503e 🐛 Ah bug fixes 2025-06-02 21:34:47 +08:00
9b7a3be5c9 🐛 Ah bug fixes 2025-06-02 21:30:27 +08:00
740f5ad3fc 🐛 Another bug fix 2025-06-02 21:22:38 +08:00
5487b4e607 🐛 Fix migrate has no value 2025-06-02 20:55:55 +08:00
2691c5d9ac 🐛 Bug fixes 2025-06-02 20:33:43 +08:00
0550c4f6de Now the migration helps recreate mis-references post attachments 2025-06-02 20:29:03 +08:00
782cf56927 🐛 Temporarily disable the entire recycle files service 2025-06-02 20:08:28 +08:00
abd44b8ddb 🐛 Temporarily disable deleting files on recycling 2025-06-02 20:05:59 +08:00
3f2e86916d 🗑️ Remove duplicate datasource builder 2025-06-02 19:59:47 +08:00
140b4eb699 References migrations for stickers 2025-06-02 12:56:57 +08:00
7eabaf6a0d 🐛 Fix get chat controller 2025-06-02 12:40:34 +08:00
88f9157ff8 🐛 Bug fixes and fixes 2025-06-02 11:56:10 +08:00
bf5ae17741 🐛 Fix unable to update publishers 2025-06-02 11:34:05 +08:00
f5fb133e99 🐛 Fix again for included non-exists anymore attachments field 2025-06-02 02:56:58 +08:00
568afc981e Skip compress animated image 2025-06-02 02:54:32 +08:00
d48a2a8fe5 🐛 Fix queries still include the Attachments 2025-06-02 02:36:43 +08:00
28ba9871bf 🐛 Purge cache of a cloud file after uploaded 2025-06-02 01:00:22 +08:00
1307114b76 🎨 Extract tus service out of Program.cs 2025-06-02 00:59:13 +08:00
3c52a6d787 ✈️ Better migration to new cloud files reference system 2025-06-02 00:49:19 +08:00
00229fd406 💥 ♻️ Refactor cloud files' references, and loading system 2025-06-01 19:18:23 +08:00
02ae634690 🐛 Trying to fix chat response missing sender 2025-06-01 03:22:11 +08:00
7dee2a15e7 💥 Update push notification 2025-06-01 02:51:39 +08:00
57775eb0a1 🐛 Trying to fix apns push notification sound 2025-06-01 01:56:36 +08:00
ca57faa7c3 🐛 Fix chat notification subject 2025-06-01 01:47:30 +08:00
7040b236e9 🐛 Fix send notification from api missing created at / updated at 2025-06-01 01:34:46 +08:00
6dc0f523e0 🐛 Trying to fix notification sound 2025-06-01 01:33:18 +08:00
7c351de594 🐛 Fix the push subscription won't update 2025-06-01 01:28:46 +08:00
fb2f138925 🐛 Fix failed to push when no push subscription 2025-06-01 01:19:15 +08:00
9a96cf68bb 🐛 Fix wrong platform code on push notification 2025-06-01 01:17:05 +08:00
a78e92a23a ♻️ Refactor the notification service to use gorush as push service 2025-06-01 01:04:20 +08:00
7fa0dfdcad :arrow_heavy_plus: Add OpenTelemetry 2025-05-31 19:57:46 +08:00
28ff78d3e2 🔊 Add logs to notification pushing 2025-05-31 19:54:10 +08:00
6965744d5a 🐛 Trying to fix bugs... 2025-05-31 13:18:17 +08:00
b8c15bde1a 👔 The direct message no longer has name by default 2025-05-31 12:17:26 +08:00
c3095f2a9b ♻️ Refactor the publisher loading in posts 2025-05-31 12:11:45 +08:00
7656a8b298 🐛 Trying to fix the message notification... 2025-05-31 11:54:23 +08:00
1024721e0e 🐛 Fix realtime call somethings account missing profile 2025-05-31 02:52:19 +08:00
ed2e9571ab 🐛 Fix the cloud file deletion 2025-05-31 02:48:36 +08:00
6670c69fda 🐛 Add priority to chat notification 2025-05-31 02:46:35 +08:00
a0cd779f85 🐛 Fix chat notification and listing members 2025-05-30 23:02:55 +08:00
472221302d 🐛 Trying to fix message notification again... 2025-05-30 22:49:03 +08:00
b7960e3060 🐛 Trying to fix chat notification 2025-05-30 21:42:00 +08:00
0bbd322c2e 🐛 Trying to fix message notification 2025-05-30 13:22:50 +08:00
8beeac09ef Optimize the message notifications 2025-05-30 01:52:09 +08:00
fac9c3ae88 🐛 Fix listing post replies 2025-05-29 13:06:57 +08:00
14dd610b3e 🐛 Bug fixes 2025-05-29 03:33:02 +08:00
9f5e0d8b80 📝 Update OpenAPI spec for Dyson Token 2025-05-29 02:09:56 +08:00
c5d7535bd2 💥 Change auth token scheme 2025-05-29 02:08:45 +08:00
06a97c57c0 Post notifications, and cloudfile allocation 2025-05-29 01:35:55 +08:00
5f69d8ac80 Better cloud file allocation and free in chat 2025-05-29 01:29:15 +08:00
2778626b1f 🗃️ Cloud file usage 2025-05-29 01:19:54 +08:00
7f4c756365 🎨 Split the account current related endpoints 2025-05-29 01:12:51 +08:00
6a426efde9 💥 The newly crafted Dyson Token 2025-05-28 23:21:32 +08:00
7e309bb5c7 APNS sound with mutable content 2025-05-28 01:59:16 +08:00
bb739c1d90 More & localized notifications 2025-05-28 01:50:14 +08:00
39d9d8a839 🐛 Bug fixes in apple push notifications 2025-05-28 01:27:18 +08:00
bf6dbfdca0 ♻️ Change the way to load publisher account 2025-05-27 22:55:56 +08:00
acece9cbce Include account info for personal publishers 2025-05-27 22:49:33 +08:00
fcaeb9afbe Reset password 2025-05-27 22:41:38 +08:00
25c721a42b Revert create message when call ended by webhook to prevent circular deps 2025-05-27 02:27:04 +08:00
093055f9ab 🐛 Fix update call participants missing profile 2025-05-27 02:18:39 +08:00
c21cdeba74 The call ended by webhook now sends end message 2025-05-27 02:16:35 +08:00
b913682866 🐛 Fix update participant can't get account id 2025-05-27 02:01:30 +08:00
7d5a804865 🐛 Fix prometheus was not added 2025-05-27 01:27:08 +08:00
315b20182c Add Prometheus 2025-05-27 00:37:02 +08:00
3004536cc1 🐛 Fix trimmed publish cause unable to run 2025-05-26 19:28:38 +08:00
99f2e724a6 Trying to fix memory usage 2025-05-26 19:20:53 +08:00
d76e0dd83b 🐛 Fix wrong end call sender 2025-05-26 01:55:59 +08:00
e20666160f Now the JoinResponse include ChatMember details 2025-05-25 20:48:47 +08:00
cfe29f5def 🐛 Fix permission control 2025-05-25 20:36:42 +08:00
33767a6d7f Optimize caching on chat member
🐛 Trying to fix uploading file permission check
2025-05-25 20:18:27 +08:00
cbe913e535 Realtime call participants
🐛 Fix update, delete message wont send websocket packet
2025-05-25 19:48:33 +08:00
b4c26f2d55 🐛 Fixes distributed lock 2025-05-25 16:09:37 +08:00
916d9500a2 🐛 Fix message from call missing nonce 2025-05-25 15:54:39 +08:00
c562f52538 🐛 Trying to fix check in locking 2025-05-25 12:26:12 +08:00
68399dd371 🐛 Fix post-reply will still create normal activities
🐛 Fix publisher get by name endpoint requires authorization
2025-05-25 12:12:37 +08:00
185ab13ec9 🐛 Trying to fix permission that inherits from groups. 2025-05-25 12:07:17 +08:00
9e7ba820c4 Implement realtime chat 2025-05-25 05:51:13 +08:00
59bc9edd4b Account deletion 2025-05-24 23:29:36 +08:00
80b7812a87 🐛 Fixes on magic spell services 2025-05-24 22:59:05 +08:00
363c1aedf4 🐛 Trying to fix Newtonsoft parse NodaTime 2025-05-24 18:35:23 +08:00
445e5d3705 🐛 Replace the serializer in cache service with newtonsoft json to solve JsonIgnore issue 2025-05-24 18:29:20 +08:00
460ce62452 ♻️ Refactor cache system with redis
🐛 Add lock to check in prevent multiple at the same time
2025-05-24 17:29:24 +08:00
d4da5d7afc 🐛 Prevent user from creating empty post 2025-05-24 16:43:34 +08:00
1cc7a7473a Notification administration APIs 2025-05-24 16:05:26 +08:00
8da8c4bedd Message attachments expires at 2025-05-24 03:21:39 +08:00
2eff4364c9 Sort chat rooms by last message created at 2025-05-24 02:12:32 +08:00
b905d674b7 🗃️ Add migration for chat read at refactor 2025-05-24 01:31:25 +08:00
213d81a5ca ♻️ Refactor the last read at system of chat 2025-05-24 01:29:17 +08:00
1b2ca34aad ⬇️ Downgrade skia sharp to fit the blurhashsharp requirements 2025-05-23 18:50:18 +08:00
4bbd695e27 ⬆️ Upgrade skiasharp 2025-05-23 13:18:54 +08:00
f439dca094 🔨 Remove the copying libSkiaSharp 2025-05-23 13:12:10 +08:00
da14504a69 🔨 Modify dockerfile to add c deps 2025-05-23 12:52:07 +08:00
4e5ad12e36 🐛 Trying to fix skia sharp inside the docker 2025-05-23 02:17:44 +08:00
44b309878b 🐛 Fix build script 2025-05-23 02:13:25 +08:00
81bf2c9650 🐛 Trying to fix skia sharp 2025-05-23 02:08:20 +08:00
4e672c9f96 🐛 Fix skiasharp deps 2025-05-23 02:03:10 +08:00
55f853c411 🐛 Trying to fix flush read receipts 2025-05-23 01:58:15 +08:00
a6ca869f29 🐛 Bug fixes 2025-05-23 01:46:35 +08:00
19174de873 ❇️ Chat room summary api 2025-05-23 00:04:31 +08:00
c3390d7248 🐛 Fix open files with storage id 2025-05-22 02:29:58 +08:00
8e8a120a90 🐛 Fix account profile relationship 2025-05-22 02:12:48 +08:00
aa0d2ab3c4 🐛 Fix ensure profile created maintenance method 2025-05-22 01:59:45 +08:00
95b3ab6bcd Optimize bulk insert on conflict options 2025-05-22 01:55:02 +08:00
288d66221a 🐛 Trying to fix bugs 2025-05-22 01:36:15 +08:00
2399bf0309 Ensure profile created maintenance method 2025-05-22 01:31:36 +08:00
b0a616c17c 🗃️ Enrich user profile on database 2025-05-21 22:29:09 +08:00
79fbbc283a 🗃️ Merge migrations 2025-05-21 00:06:08 +08:00
b1e3f91acd 🐛 Fixes some bugs 2025-05-21 00:01:36 +08:00
61f7764510 Unread count notification APIs 2025-05-20 01:51:59 +08:00
793043aba2 🐛 The compile error has been fixed by ChatGPT which I dont know how did it made it 2025-05-19 21:56:06 +08:00
99f5d931c3 🙈 Add ignores 2025-05-19 13:13:39 +08:00
6bd125408e 🐛 Tryin' to fix build
Some checks failed
Build and Push Dyson Sphere / build (push) Has been cancelled
2025-05-19 13:09:40 +08:00
7845e4c0d7 🐛 Fix dockerfile...
Some checks failed
Build and Push Dyson Sphere / build (push) Has been cancelled
2025-05-18 23:44:37 +08:00
9f8a83a4cf 🐛 Tryin' to fix dockerfile
Some checks failed
Build and Push Dyson Sphere / build (push) Has been cancelled
2025-05-18 23:30:25 +08:00
ce53f28f19 🐛 Fix github actions
Some checks failed
Build and Push Dyson Sphere / build (push) Has been cancelled
2025-05-18 22:53:39 +08:00
353edc58a7 🐛 Fix github actions docker build use wrong env var
Some checks failed
Build and Push Dyson Sphere / build (push) Has been cancelled
2025-05-18 22:45:37 +08:00
2fca5310be 🔨 Add github actions
Some checks failed
Build and Push Dyson Sphere / build (push) Has been cancelled
2025-05-18 22:40:07 +08:00
a86a0fef37 Better joining and leaving 2025-05-18 20:52:22 +08:00
8d246a19ad Better leaving realm and chat 2025-05-18 20:31:54 +08:00
cf9084b8c0 🐛 Bug fixes 💄 Optimizations 2025-05-18 20:05:15 +08:00
5b9b28d77a No longer save file with same hash 2025-05-18 16:52:00 +08:00
18fde9f16c 🐛 Fixes on image processing 2025-05-18 13:01:38 +08:00
4e794ceb9b 🐛 Fix message attachment did not marked 2025-05-18 12:53:32 +08:00
b40282e43a 🗑️ Remove imagesharp 2025-05-18 12:47:26 +08:00
205ccd66b3 Sped up and reduce storage usage of read receipt 2025-05-18 12:14:23 +08:00
fdfdffa382 Optimize action log flushing 2025-05-18 12:00:05 +08:00
c597df3937 Typing indicator, mark as read server-side 2025-05-18 05:35:14 +08:00
5951dab6f1 💥 Make chat room name, description optional 2025-05-17 23:10:39 +08:00
27f934c634 DM groups 2025-05-17 22:36:46 +08:00
3d197b667a 🐛 Fix update basic info didn't apply 2025-05-17 21:31:55 +08:00
b5226a72f2 🌐 Localizable notification & email 2025-05-17 18:50:14 +08:00
6728bd5607 💄 Optimized landing email 2025-05-17 17:36:34 +08:00
d3b56b741e 🧱 Render email based on razor components 2025-05-17 17:01:10 +08:00
cbef69ba5e Add api docs link to landing page 2025-05-17 15:44:19 +08:00
a77d00c3b9 💄 Restyled web pages
🧱 Add tailwindcss
2025-05-17 15:40:39 +08:00
d59dba9c02 🐛 Fix invites do not preload direct message members 2025-05-17 03:23:01 +08:00
8ab17569ee Account leveling 2025-05-17 02:31:25 +08:00
6fe0b9b50a 🐛 Fix relationship get status wrongly 2025-05-17 00:52:35 +08:00
b489a79df2 🐛 Fixes for relationships 2025-05-17 00:09:35 +08:00
88977ccda3 ♻️ Rebuilt own auth infra 2025-05-16 23:38:33 +08:00
aabe8269f5 Action logs 2025-05-16 01:41:24 +08:00
6358c49090 🐛 Fix wallet infinite loop 2025-05-16 00:12:17 +08:00
0db003abc2 Check in rewards NSD & payment 2025-05-15 22:03:51 +08:00
d7d4fde06a Wallet, payment, developer apps, feature flags of publishers
♻️ Simplified the permission check of chat room, realm, publishers
2025-05-15 00:26:15 +08:00
9576870373 💥 Switch all id to uuid 2025-05-14 20:03:52 +08:00
aeeed24290 💥 Changes to subscription api 2025-05-14 18:57:50 +08:00
d1d4eb180f Filter post by publishers 2025-05-14 00:50:06 +08:00
73fc7b3f47 Badges 2025-05-13 00:07:38 +08:00
b275f06061 Organization publishers, subscriptions to publishers 2025-05-12 21:48:16 +08:00
b20bc3c443 Lookup stickers and open directly
 Optimize lookup stickers' performance
2025-05-11 22:27:50 +08:00
3d5d4db3e3 🐛 Fixes for sticker & sticker packs 2025-05-11 22:13:13 +08:00
eab775e224 Stickers & packs api 2025-05-10 21:28:25 +08:00
790dcafeb0 🐛 Fix bugs 2025-05-10 14:10:21 +08:00
4fd8a588fa Event Calendar 2025-05-09 01:41:09 +08:00
b370c69670 🐛 Fix localization 2025-05-08 23:04:21 +08:00
d70b081752 🐛 Activity / localization bug fixes 2025-05-08 22:14:24 +08:00
9b589af816 Account check in 2025-05-08 01:56:35 +08:00
891dbfb255 🧱 Localization infrastructure 2025-05-08 01:55:32 +08:00
ee7dc31b20 Add account statuses 2025-05-07 01:21:12 +08:00
fb07071603 Chat realtime calls 2025-05-07 00:47:57 +08:00
02aee07116 🐛 Post reaction fixes 2025-05-05 13:12:20 +08:00
2206676214 🐛 Fixes bugs 2025-05-05 01:49:59 +08:00
7e7c8fe556 Uses markdown in post as rich text 2025-05-05 01:07:10 +08:00
1c361b94f3 Post reactions 2025-05-05 00:58:28 +08:00
5844dfb657 Realm member listing apis 2025-05-04 23:36:47 +08:00
573f984e2c Direct messages 2025-05-04 15:09:44 +08:00
d0a92bc8b3 ♻️ Optimization in file uploading 2025-05-04 14:48:33 +08:00
fa5c59a9c8 Chat room member managements 2025-05-03 20:42:52 +08:00
196547e50f 🗃️ Add nonce column to chat messages and fix column typo
This migration adds a new "nonce" column to the "chat_messages" table to ensure message uniqueness or integrity. Additionally, it corrects a typo in the "members_mentioned" column name to improve consistency and clarity.
2025-05-03 13:16:18 +08:00
f6acb3f2f0 🗑️ remove Casbin dependency and related configurations
Remove Casbin package references, configurations, and unused imports across multiple files. This change simplifies the codebase by eliminating unnecessary dependencies and reducing complexity.

 add new chat features and improve message handling

Introduce new chat features including message notifications, nicknames, and improved message handling. Enhance the WebSocket service to support new packet handlers and improve message delivery.

🗃️ add new migrations for chat-related changes

Add new migrations to support the latest chat features, including changes to chat members, messages, and reactions. These migrations ensure the database schema is up-to-date with the latest code changes.
2025-05-03 02:02:16 +08:00
46054dfb7b ♻️ Better way to vectorize quill delta 2025-05-03 00:06:01 +08:00
17de9a0f23 Add chat message handling and WebSocket integration
Introduce new `ChatService` for managing chat messages, including marking messages as read and checking read status. Add `WebSocketPacket` class for handling WebSocket communication and integrate it with `WebSocketService` to process chat-related packets. Enhance `ChatRoom` and `ChatMember` models with additional fields and relationships. Update `AppDatabase` to include new chat-related entities and adjust permissions for chat creation.
2025-05-02 19:51:32 +08:00
da6a891b5f Chat controller 2025-05-02 12:07:09 +08:00
8b5ca265b8 :drunk: Vibe coded the controller which doesn't work 2025-05-01 18:11:44 +00:00
b675b0550b 🗃️ Chat room modeling 2025-05-02 01:27:49 +08:00
e4031478b5 Realms 2025-05-02 01:06:59 +08:00
a6463feee4 Vibe coded the RealmControler 2025-05-01 16:17:22 +00:00
30db6ad9f1 🗃️ Realm database modeling 2025-05-02 00:08:21 +08:00
24f1a3a9e9 Truncated the post's body to prevent them from being too long 2025-05-01 23:49:17 +08:00
f7e926ad24 Listing post activities 2025-05-01 23:13:31 +08:00
b1543f5b08 File compression duplicate 2025-05-01 19:19:58 +08:00
0f9e865c0b Compressed duplication of image files 2025-05-01 15:29:52 +08:00
bf64afd849 Activity-based browsing 2025-05-01 14:59:28 +08:00
42b5129aa4 🐛 Fixes System.NotSupportedException: WebSockets are not supported 2025-05-01 01:22:02 +08:00
84a88222bd 🐛 Bug fixes and improvements 2025-05-01 00:47:26 +08:00
758186f674 🐛 Fix swaggergen 2025-04-30 01:08:59 +08:00
3637225d23 🧱 Vide coded the websocket controller 2025-04-29 17:07:00 +00:00
8417d766e3 🐛 Bug fixes in permission management 2025-04-29 22:18:40 +08:00
35792efa9f 🐛 Fix magic spell and email service 2025-04-29 21:54:34 +08:00
0ebeab672b Magic spell for one time code
🗑️ Drop the usage of casbin
♻️ Refactor the permission service
♻️ Refactor the flow of creating an account
🧱 Email infra structure
2025-04-29 20:37:10 +08:00
82288fa52c 🐛 Fix the permission group member uses the singular form as the name 2025-04-28 00:15:39 +08:00
e8c3219ef0 🗃️ Set up permission nodes db models 2025-04-28 00:13:18 +08:00
d343ac5fb8 🧱 Setup for the websocket 2025-04-27 23:56:57 +08:00
bd7e589681 Notification APIs 2025-04-27 23:52:41 +08:00
cb7179aa27 Notification service 2025-04-27 23:44:22 +08:00
3080e273cb Add rate limiter 2025-04-26 12:24:32 +08:00
38b7e8c1a1 🐛 Bug fixes 2025-04-25 23:13:15 +08:00
c8e9f73746 🐛 Fix unable to inject http client factory 2025-04-23 22:17:02 +08:00
a008a74d77 Recaptcha 2025-04-23 01:18:12 +08:00
31db3d5388 🐛 Fix bugs 2025-04-23 00:30:25 +08:00
c43ff6be7b 🐛 Bug fixes on posts and publishers 2025-04-19 23:51:27 +08:00
fb1de3da9e Posting 2025-04-19 19:55:41 +08:00
0e3b88c51c Publisher API 2025-04-19 14:41:34 +08:00
f9701764f3 Relationships controllers 2025-04-17 23:54:35 +08:00
276 changed files with 130659 additions and 2326 deletions

View File

@ -21,5 +21,7 @@
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
**/node_modules
LICENSE
README.md
README.md

33
.github/workflows/docker-build.yml vendored Normal file
View File

@ -0,0 +1,33 @@
name: Build and Push Dyson Sphere
on:
push:
branches:
- master
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest # x86_64 (default), avoids arm64 native module issues
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to DockerHub
uses: docker/login-action@v3
with:
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
file: DysonNetwork.Sphere/Dockerfile
context: .
push: true
tags: xsheep2010/dyson-sphere:latest
platforms: linux/amd64

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ obj/
riderModule.iml
/_ReSharper.Caches/
.idea
.DS_Store

58
.idx/dev.nix Normal file
View File

@ -0,0 +1,58 @@
# To learn more about how to use Nix to configure your environment
# see: https://firebase.google.com/docs/studio/customize-workspace
{ pkgs, ... }: {
# Which nixpkgs channel to use.
channel = "stable-24.05"; # or "unstable"
# Use https://search.nixos.org/packages to find packages
packages = [
pkgs.icu # The deps of dotnet somehow
pkgs.dotnetCorePackages.sdk_9_0_1xx
# pkgs.go
# pkgs.python311
# pkgs.python311Packages.pip
# pkgs.nodejs_20
# pkgs.nodePackages.nodemon
];
# Sets environment variables in the workspace
env = {};
idx = {
# Search for the extensions you want on https://open-vsx.org/ and use "publisher.id"
extensions = [
"k--kato.intellij-idea-keybindings"
# "vscodevim.vim"
];
# Enable previews
previews = {
enable = true;
previews = {
# web = {
# # Example: run "npm run dev" with PORT set to IDX's defined port for previews,
# # and show it in IDX's web preview panel
# command = ["npm" "run" "dev"];
# manager = "web";
# env = {
# # Environment variables to set for your server
# PORT = "$PORT";
# };
# };
};
};
# Workspace lifecycle hooks
workspace = {
# Runs when a workspace is first created
onCreate = {
# Example: install JS dependencies from NPM
# npm-install = "npm install";
};
# Runs when the workspace is (re)started
onStart = {
# Example: start a background task to watch and re-build backend code
# watch-backend = "npm run watch-backend";
};
};
};
}

BIN
DysonNetwork.Sphere/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -1,2 +1,9 @@
Keys
Uploads
Uploads
DataProtection-Keys
node_modules
bun.lock
package-lock.json
.DS_Store

View File

@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public enum AbuseReportType
{
Copyright,
Harassment,
Impersonation,
OffensiveContent,
Spam,
PrivacyViolation,
IllegalContent,
Other
}
public class AbuseReport : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(4096)] public string ResourceIdentifier { get; set; } = null!;
public AbuseReportType Type { get; set; }
[MaxLength(8192)] public string Reason { get; set; } = null!;
public Instant? ResolvedAt { get; set; }
[MaxLength(8192)] public string? Resolution { get; set; }
public Guid AccountId { get; set; }
public Account Account { get; set; } = null!;
}

View File

@ -1,49 +1,109 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Wallet;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using OtpNet;
namespace DysonNetwork.Sphere.Account;
[Index(nameof(Name), IsUnique = true)]
public class Account : ModelBase
{
public long Id { get; set; }
public Guid Id { get; set; }
[MaxLength(256)] public string Name { get; set; } = string.Empty;
[MaxLength(256)] public string Nick { get; set; } = string.Empty;
[MaxLength(32)] public string Language { get; set; } = string.Empty;
public Instant? ActivatedAt { get; set; }
public bool IsSuperuser { get; set; } = false;
public Profile Profile { get; set; } = null!;
public ICollection<AccountContact> Contacts { get; set; } = new List<AccountContact>();
public ICollection<Badge> Badges { get; set; } = new List<Badge>();
[JsonIgnore] public ICollection<AccountAuthFactor> AuthFactors { get; set; } = new List<AccountAuthFactor>();
[JsonIgnore] public ICollection<AccountConnection> Connections { get; set; } = new List<AccountConnection>();
[JsonIgnore] public ICollection<Auth.Session> Sessions { get; set; } = new List<Auth.Session>();
[JsonIgnore] public ICollection<Auth.Challenge> Challenges { get; set; } = new List<Auth.Challenge>();
[JsonIgnore] public ICollection<Relationship> OutgoingRelationships { get; set; } = new List<Relationship>();
[JsonIgnore] public ICollection<Relationship> IncomingRelationships { get; set; } = new List<Relationship>();
[JsonIgnore] public ICollection<Subscription> Subscriptions { get; set; } = new List<Subscription>();
}
public abstract class Leveling
{
public static readonly List<int> ExperiencePerLevel =
[
0, // Level 0
100, // Level 1
250, // Level 2
500, // Level 3
1000, // Level 4
2000, // Level 5
4000, // Level 6
8000, // Level 7
16000, // Level 8
32000, // Level 9
64000, // Level 10
128000, // Level 11
256000, // Level 12
512000, // Level 13
1024000 // Level 14
];
}
public class Profile : ModelBase
{
public long Id { get; set; }
public Guid Id { get; set; }
[MaxLength(256)] public string? FirstName { get; set; }
[MaxLength(256)] public string? MiddleName { get; set; }
[MaxLength(256)] public string? LastName { get; set; }
[MaxLength(4096)] public string? Bio { get; set; }
[MaxLength(1024)] public string? Gender { get; set; }
[MaxLength(1024)] public string? Pronouns { get; set; }
[MaxLength(1024)] public string? TimeZone { get; set; }
[MaxLength(1024)] public string? Location { get; set; }
public Instant? Birthday { get; set; }
public Instant? LastSeenAt { get; set; }
public Storage.CloudFile? Picture { get; set; }
public Storage.CloudFile? Background { get; set; }
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
[Column(TypeName = "jsonb")] public BadgeReferenceObject? ActiveBadge { get; set; }
[Column(TypeName = "jsonb")] public SubscriptionReferenceObject? StellarMembership { get; set; }
public int Experience { get; set; } = 0;
[NotMapped] public int Level => Leveling.ExperiencePerLevel.Count(xp => Experience >= xp) - 1;
[NotMapped]
public double LevelingProgress => Level >= Leveling.ExperiencePerLevel.Count - 1
? 100
: (Experience - Leveling.ExperiencePerLevel[Level]) * 100.0 /
(Leveling.ExperiencePerLevel[Level + 1] - Leveling.ExperiencePerLevel[Level]);
// Outdated fields, for backward compability
[MaxLength(32)] public string? PictureId { get; set; }
[MaxLength(32)] public string? BackgroundId { get; set; }
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!;
}
public class AccountContact : ModelBase
{
public long Id { get; set; }
public Guid Id { get; set; }
public AccountContactType Type { get; set; }
public Instant? VerifiedAt { get; set; }
public bool IsPrimary { get; set; } = false;
[MaxLength(1024)] public string Content { get; set; } = string.Empty;
public Guid AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!;
}
@ -56,10 +116,25 @@ public enum AccountContactType
public class AccountAuthFactor : ModelBase
{
public long Id { get; set; }
public Guid Id { get; set; }
public AccountAuthFactorType Type { get; set; }
public string? Secret { get; set; } = null;
[JsonIgnore] [MaxLength(8196)] public string? Secret { get; set; }
[JsonIgnore]
[Column(TypeName = "jsonb")]
public Dictionary<string, object>? Config { get; set; } = new();
/// <summary>
/// The trustworthy stands for how safe is this auth factor.
/// Basically, it affects how many steps it can complete in authentication.
/// Besides, users may need to use some high-trustworthy level auth factors when confirming some dangerous operations.
/// </summary>
public int Trustworthy { get; set; } = 1;
public Instant? EnabledAt { get; set; }
public Instant? ExpiredAt { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!;
public AccountAuthFactor HashSecret(int cost = 12)
@ -73,8 +148,27 @@ public class AccountAuthFactor : ModelBase
{
if (Secret == null)
throw new InvalidOperationException("Auth factor with no secret cannot be verified with password.");
return BCrypt.Net.BCrypt.Verify(password, Secret);
switch (Type)
{
case AccountAuthFactorType.Password:
case AccountAuthFactorType.PinCode:
return BCrypt.Net.BCrypt.Verify(password, Secret);
case AccountAuthFactorType.TimedCode:
var otp = new Totp(Base32Encoding.ToBytes(Secret));
return otp.VerifyTotp(DateTime.UtcNow, password, out _, new VerificationWindow(previous: 5, future: 5));
case AccountAuthFactorType.EmailCode:
case AccountAuthFactorType.InAppCode:
default:
throw new InvalidOperationException("Unsupported verification type, use CheckDeliveredCode instead.");
}
}
/// <summary>
/// This dictionary will be returned to the client and should only be set when it just created.
/// Useful for passing the client some data to finishing setup and recovery code.
/// </summary>
[NotMapped]
public Dictionary<string, object>? CreatedResponse { get; set; }
}
public enum AccountAuthFactorType
@ -82,5 +176,21 @@ public enum AccountAuthFactorType
Password,
EmailCode,
InAppCode,
TimedCode
TimedCode,
PinCode,
}
public class AccountConnection : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(4096)] public string Provider { get; set; } = null!;
[MaxLength(8192)] public string ProvidedIdentifier { get; set; } = null!;
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; } = new();
[JsonIgnore] [MaxLength(4096)] public string? AccessToken { get; set; }
[JsonIgnore] [MaxLength(4096)] public string? RefreshToken { get; set; }
public Instant? LastUsedAt { get; set; }
public Guid AccountId { get; set; }
public Account Account { get; set; } = null!;
}

View File

@ -1,30 +1,63 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Permission;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using NodaTime.Extensions;
using System.Collections.Generic;
namespace DysonNetwork.Sphere.Account;
[ApiController]
[Route("/accounts")]
public class AccountController(AppDatabase db, FileService fs) : ControllerBase
[Route("/api/accounts")]
public class AccountController(
AppDatabase db,
AuthService auth,
AccountService accounts,
AccountEventService events
) : ControllerBase
{
[HttpGet("{name}")]
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Account?>> GetByName(string name)
{
var account = await db.Accounts.Where(a => a.Name == name).FirstOrDefaultAsync();
var account = await db.Accounts
.Include(e => e.Badges)
.Include(e => e.Profile)
.Where(a => a.Name == name)
.FirstOrDefaultAsync();
return account is null ? new NotFoundResult() : account;
}
[HttpGet("{name}/badges")]
[ProducesResponseType<List<Badge>>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<List<Badge>>> GetBadgesByName(string name)
{
var account = await db.Accounts
.Include(e => e.Badges)
.Where(a => a.Name == name)
.FirstOrDefaultAsync();
return account is null ? NotFound() : account.Badges.ToList();
}
public class AccountCreateRequest
{
[Required] [MaxLength(256)] public string Name { get; set; } = string.Empty;
[Required]
[MinLength(2)]
[MaxLength(256)]
[RegularExpression(@"^[A-Za-z0-9_-]+$",
ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.")
]
public string Name { get; set; } = string.Empty;
[Required] [MaxLength(256)] public string Nick { get; set; } = string.Empty;
[EmailAddress]
[RegularExpression(@"^[^+]+@[^@]+\.[^@]+$", ErrorMessage = "Email address cannot contain '+' symbol.")]
[Required]
[MaxLength(1024)]
public string Email { get; set; } = string.Empty;
@ -33,6 +66,10 @@ public class AccountController(AppDatabase db, FileService fs) : ControllerBase
[MinLength(4)]
[MaxLength(128)]
public string Password { get; set; } = string.Empty;
[MaxLength(128)] public string Language { get; set; } = "en-us";
[Required] public string CaptchaToken { get; set; } = string.Empty;
}
[HttpPost]
@ -40,136 +77,101 @@ public class AccountController(AppDatabase db, FileService fs) : ControllerBase
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<Account>> CreateAccount([FromBody] AccountCreateRequest request)
{
var dupeNameCount = await db.Accounts.Where(a => a.Name == request.Name).CountAsync();
if (dupeNameCount > 0)
return BadRequest("The name is already taken.");
if (!await auth.ValidateCaptcha(request.CaptchaToken)) return BadRequest("Invalid captcha token.");
var account = new Account
try
{
Name = request.Name,
Nick = request.Nick,
Contacts = new List<AccountContact>
{
new()
{
Type = AccountContactType.Email,
Content = request.Email
}
},
AuthFactors = new List<AccountAuthFactor>
{
new AccountAuthFactor
{
Type = AccountAuthFactorType.Password,
Secret = request.Password
}.HashSecret()
},
Profile = new Profile()
};
await db.Accounts.AddAsync(account);
await db.SaveChangesAsync();
return account;
var account = await accounts.CreateAccount(
request.Name,
request.Nick,
request.Email,
request.Password,
request.Language
);
return Ok(account);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[Authorize]
[HttpGet("me")]
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
public async Task<ActionResult<Account>> GetMe()
public class RecoveryPasswordRequest
{
var userIdClaim = User.FindFirst("user_id")?.Value;
long? userId = long.TryParse(userIdClaim, out var id) ? id : null;
if (userId is null) return new BadRequestObjectResult("Invalid or missing user_id claim.");
[Required] public string Account { get; set; } = null!;
[Required] public string CaptchaToken { get; set; } = null!;
}
var account = await db.Accounts
[HttpPost("recovery/password")]
public async Task<ActionResult> RequestResetPassword([FromBody] RecoveryPasswordRequest request)
{
if (!await auth.ValidateCaptcha(request.CaptchaToken)) return BadRequest("Invalid captcha token.");
var account = await accounts.LookupAccount(request.Account);
if (account is null) return BadRequest("Unable to find the account.");
try
{
await accounts.RequestPasswordReset(account);
}
catch (InvalidOperationException)
{
return BadRequest("You already requested password reset within 24 hours.");
}
return Ok();
}
public class StatusRequest
{
public StatusAttitude Attitude { get; set; }
public bool IsInvisible { get; set; }
public bool IsNotDisturb { get; set; }
[MaxLength(1024)] public string? Label { get; set; }
public Instant? ClearedAt { get; set; }
}
[HttpGet("{name}/statuses")]
public async Task<ActionResult<Status>> GetOtherStatus(string name)
{
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name);
if (account is null) return BadRequest();
var status = await events.GetStatus(account.Id);
status.IsInvisible = false; // Keep the invisible field not available for other users
return Ok(status);
}
[HttpGet("{name}/calendar")]
public async Task<ActionResult<List<DailyEventResponse>>> GetOtherEventCalendar(
string name,
[FromQuery] int? month,
[FromQuery] int? year
)
{
var currentDate = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
month ??= currentDate.Month;
year ??= currentDate.Year;
if (month is < 1 or > 12) return BadRequest("Invalid month.");
if (year < 1) return BadRequest("Invalid year.");
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name);
if (account is null) return BadRequest();
var calendar = await events.GetEventCalendar(account, month.Value, year.Value, replaceInvisible: true);
return Ok(calendar);
}
[HttpGet("search")]
public async Task<List<Account>> Search([FromQuery] string query, [FromQuery] int take = 20)
{
if (string.IsNullOrWhiteSpace(query))
return [];
return await db.Accounts
.Include(e => e.Profile)
.Include(e => e.Profile.Picture)
.Include(e => e.Profile.Background)
.Where(e => e.Id == userId)
.FirstOrDefaultAsync();
return Ok(account);
}
public class BasicInfoRequest
{
[MaxLength(256)] public string? Nick { get; set; }
[MaxLength(32)] public string? Language { get; set; }
}
[Authorize]
[HttpPatch("me")]
public async Task<ActionResult<Account>> UpdateBasicInfo([FromBody] BasicInfoRequest request)
{
var userIdClaim = User.FindFirst("user_id")?.Value;
long? userId = long.TryParse(userIdClaim, out var id) ? id : null;
if (userId is null) return new BadRequestObjectResult("Invalid or missing user_id claim.");
var account = await db.Accounts.FindAsync(userId);
if (account is null) return BadRequest("Unable to get your account.");
if (request.Nick is not null) account.Nick = request.Nick;
if (request.Language is not null) account.Language = request.Language;
await db.SaveChangesAsync();
return account;
}
public class ProfileRequest
{
[MaxLength(256)] public string? FirstName { get; set; }
[MaxLength(256)] public string? MiddleName { get; set; }
[MaxLength(256)] public string? LastName { get; set; }
[MaxLength(4096)] public string? Bio { get; set; }
public string? PictureId { get; set; }
public string? BackgroundId { get; set; }
}
[Authorize]
[HttpPatch("me/profile")]
public async Task<ActionResult<Profile>> UpdateProfile([FromBody] ProfileRequest request)
{
var userIdClaim = User.FindFirst("user_id")?.Value;
long? userId = long.TryParse(userIdClaim, out var id) ? id : null;
if (userId is null) return new BadRequestObjectResult("Invalid or missing user_id claim.");
var profile = await db.AccountProfiles
.Where(p => p.Account.Id == userId)
.Include(profile => profile.Background)
.Include(profile => profile.Picture)
.FirstOrDefaultAsync();
if (profile is null) return BadRequest("Unable to get your account.");
if (request.FirstName is not null) profile.FirstName = request.FirstName;
if (request.MiddleName is not null) profile.MiddleName = request.MiddleName;
if (request.LastName is not null) profile.LastName = request.LastName;
if (request.Bio is not null) profile.Bio = request.Bio;
if (request.PictureId is not null)
{
var picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync();
if (picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud.");
if (profile.Picture is not null)
await fs.MarkUsageAsync(profile.Picture, -1);
profile.Picture = picture;
await fs.MarkUsageAsync(picture, 1);
}
if (request.BackgroundId is not null)
{
var background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync();
if (background is null) return BadRequest("Invalid background id, unable to find the file on cloud.");
if (profile.Background is not null)
await fs.MarkUsageAsync(profile.Background, -1);
profile.Background = background;
await fs.MarkUsageAsync(background, 1);
}
db.Update(profile);
await db.SaveChangesAsync();
return profile;
.Where(a => EF.Functions.ILike(a.Name, $"%{query}%") ||
EF.Functions.ILike(a.Nick, $"%{query}%"))
.Take(take)
.ToListAsync();
}
}

View File

@ -0,0 +1,703 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Org.BouncyCastle.Utilities;
namespace DysonNetwork.Sphere.Account;
[Authorize]
[ApiController]
[Route("/api/accounts/me")]
public class AccountCurrentController(
AppDatabase db,
AccountService accounts,
FileReferenceService fileRefService,
AccountEventService events,
AuthService auth
) : ControllerBase
{
[HttpGet]
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
public async Task<ActionResult<Account>> GetCurrentIdentity()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var account = await db.Accounts
.Include(e => e.Badges)
.Include(e => e.Profile)
.Where(e => e.Id == userId)
.FirstOrDefaultAsync();
return Ok(account);
}
public class BasicInfoRequest
{
[MaxLength(256)] public string? Nick { get; set; }
[MaxLength(32)] public string? Language { get; set; }
}
[HttpPatch]
public async Task<ActionResult<Account>> UpdateBasicInfo([FromBody] BasicInfoRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var account = await db.Accounts.FirstAsync(a => a.Id == currentUser.Id);
if (request.Nick is not null) account.Nick = request.Nick;
if (request.Language is not null) account.Language = request.Language;
await db.SaveChangesAsync();
await accounts.PurgeAccountCache(currentUser);
return currentUser;
}
public class ProfileRequest
{
[MaxLength(256)] public string? FirstName { get; set; }
[MaxLength(256)] public string? MiddleName { get; set; }
[MaxLength(256)] public string? LastName { get; set; }
[MaxLength(1024)] public string? Gender { get; set; }
[MaxLength(1024)] public string? Pronouns { get; set; }
[MaxLength(1024)] public string? TimeZone { get; set; }
[MaxLength(1024)] public string? Location { get; set; }
[MaxLength(4096)] public string? Bio { get; set; }
public Instant? Birthday { get; set; }
[MaxLength(32)] public string? PictureId { get; set; }
[MaxLength(32)] public string? BackgroundId { get; set; }
}
[HttpPatch("profile")]
public async Task<ActionResult<Profile>> UpdateProfile([FromBody] ProfileRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var profile = await db.AccountProfiles
.Where(p => p.Account.Id == userId)
.FirstOrDefaultAsync();
if (profile is null) return BadRequest("Unable to get your account.");
if (request.FirstName is not null) profile.FirstName = request.FirstName;
if (request.MiddleName is not null) profile.MiddleName = request.MiddleName;
if (request.LastName is not null) profile.LastName = request.LastName;
if (request.Bio is not null) profile.Bio = request.Bio;
if (request.Gender is not null) profile.Gender = request.Gender;
if (request.Pronouns is not null) profile.Pronouns = request.Pronouns;
if (request.Birthday is not null) profile.Birthday = request.Birthday;
if (request.Location is not null) profile.Location = request.Location;
if (request.TimeZone is not null) profile.TimeZone = request.TimeZone;
if (request.PictureId is not null)
{
var picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync();
if (picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud.");
var profileResourceId = $"profile:{profile.Id}";
// Remove old references for the profile picture
if (profile.Picture is not null)
{
var oldPictureRefs =
await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.picture");
foreach (var oldRef in oldPictureRefs)
{
await fileRefService.DeleteReferenceAsync(oldRef.Id);
}
}
profile.Picture = picture.ToReferenceObject();
// Create new reference
await fileRefService.CreateReferenceAsync(
picture.Id,
"profile.picture",
profileResourceId
);
}
if (request.BackgroundId is not null)
{
var background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync();
if (background is null) return BadRequest("Invalid background id, unable to find the file on cloud.");
var profileResourceId = $"profile:{profile.Id}";
// Remove old references for the profile background
if (profile.Background is not null)
{
var oldBackgroundRefs =
await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.background");
foreach (var oldRef in oldBackgroundRefs)
{
await fileRefService.DeleteReferenceAsync(oldRef.Id);
}
}
profile.Background = background.ToReferenceObject();
// Create new reference
await fileRefService.CreateReferenceAsync(
background.Id,
"profile.background",
profileResourceId
);
}
db.Update(profile);
await db.SaveChangesAsync();
await accounts.PurgeAccountCache(currentUser);
return profile;
}
[HttpDelete]
public async Task<ActionResult> RequestDeleteAccount()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
try
{
await accounts.RequestAccountDeletion(currentUser);
}
catch (InvalidOperationException)
{
return BadRequest("You already requested account deletion within 24 hours.");
}
return Ok();
}
[HttpGet("statuses")]
public async Task<ActionResult<Status>> GetCurrentStatus()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var status = await events.GetStatus(currentUser.Id);
return Ok(status);
}
[HttpPatch("statuses")]
[RequiredPermission("global", "accounts.statuses.update")]
public async Task<ActionResult<Status>> UpdateStatus([FromBody] AccountController.StatusRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var now = SystemClock.Instance.GetCurrentInstant();
var status = await db.AccountStatuses
.Where(e => e.AccountId == currentUser.Id)
.Where(e => e.ClearedAt == null || e.ClearedAt > now)
.OrderByDescending(e => e.CreatedAt)
.FirstOrDefaultAsync();
if (status is null) return NotFound();
status.Attitude = request.Attitude;
status.IsInvisible = request.IsInvisible;
status.IsNotDisturb = request.IsNotDisturb;
status.Label = request.Label;
status.ClearedAt = request.ClearedAt;
db.Update(status);
await db.SaveChangesAsync();
events.PurgeStatusCache(currentUser.Id);
return status;
}
[HttpPost("statuses")]
[RequiredPermission("global", "accounts.statuses.create")]
public async Task<ActionResult<Status>> CreateStatus([FromBody] AccountController.StatusRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var status = new Status
{
AccountId = currentUser.Id,
Attitude = request.Attitude,
IsInvisible = request.IsInvisible,
IsNotDisturb = request.IsNotDisturb,
Label = request.Label,
ClearedAt = request.ClearedAt
};
return await events.CreateStatus(currentUser, status);
}
[HttpDelete("me/statuses")]
public async Task<ActionResult> DeleteStatus()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var now = SystemClock.Instance.GetCurrentInstant();
var status = await db.AccountStatuses
.Where(s => s.AccountId == currentUser.Id)
.Where(s => s.ClearedAt == null || s.ClearedAt > now)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync();
if (status is null) return NotFound();
await events.ClearStatus(currentUser, status);
return NoContent();
}
[HttpGet("check-in")]
public async Task<ActionResult<CheckInResult>> GetCheckInResult()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var now = SystemClock.Instance.GetCurrentInstant();
var today = now.InUtc().Date;
var startOfDay = today.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
var endOfDay = today.PlusDays(1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
var result = await db.AccountCheckInResults
.Where(x => x.AccountId == userId)
.Where(x => x.CreatedAt >= startOfDay && x.CreatedAt < endOfDay)
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync();
return result is null ? NotFound() : Ok(result);
}
[HttpPost("check-in")]
public async Task<ActionResult<CheckInResult>> DoCheckIn([FromBody] string? captchaToken)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var isAvailable = await events.CheckInDailyIsAvailable(currentUser);
if (!isAvailable)
return BadRequest("Check-in is not available for today.");
try
{
var needsCaptcha = await events.CheckInDailyDoAskCaptcha(currentUser);
return needsCaptcha switch
{
true when string.IsNullOrWhiteSpace(captchaToken) => StatusCode(423,
"Captcha is required for this check-in."),
true when !await auth.ValidateCaptcha(captchaToken!) => BadRequest("Invalid captcha token."),
_ => await events.CheckInDaily(currentUser)
};
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("calendar")]
public async Task<ActionResult<List<DailyEventResponse>>> GetEventCalendar([FromQuery] int? month,
[FromQuery] int? year)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var currentDate = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
month ??= currentDate.Month;
year ??= currentDate.Year;
if (month is < 1 or > 12) return BadRequest("Invalid month.");
if (year < 1) return BadRequest("Invalid year.");
var calendar = await events.GetEventCalendar(currentUser, month.Value, year.Value);
return Ok(calendar);
}
[HttpGet("actions")]
[ProducesResponseType<List<ActionLog>>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<List<ActionLog>>> GetActionLogs(
[FromQuery] int take = 20,
[FromQuery] int offset = 0
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var query = db.ActionLogs
.Where(log => log.AccountId == currentUser.Id)
.OrderByDescending(log => log.CreatedAt);
var total = await query.CountAsync();
Response.Headers.Append("X-Total", total.ToString());
var logs = await query
.Skip(offset)
.Take(take)
.ToListAsync();
return Ok(logs);
}
[HttpGet("factors")]
public async Task<ActionResult<List<AccountAuthFactor>>> GetAuthFactors()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var factors = await db.AccountAuthFactors
.Include(f => f.Account)
.Where(f => f.Account.Id == currentUser.Id)
.ToListAsync();
return Ok(factors);
}
public class AuthFactorRequest
{
public AccountAuthFactorType Type { get; set; }
public string? Secret { get; set; }
}
[HttpPost("factors")]
[Authorize]
public async Task<ActionResult<AccountAuthFactor>> CreateAuthFactor([FromBody] AuthFactorRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (await accounts.CheckAuthFactorExists(currentUser, request.Type))
return BadRequest($"Auth factor with type {request.Type} is already exists.");
var factor = await accounts.CreateAuthFactor(currentUser, request.Type, request.Secret);
return Ok(factor);
}
[HttpPost("factors/{id:guid}/enable")]
[Authorize]
public async Task<ActionResult<AccountAuthFactor>> EnableAuthFactor(Guid id, [FromBody] string? code)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var factor = await db.AccountAuthFactors
.Where(f => f.AccountId == currentUser.Id && f.Id == id)
.FirstOrDefaultAsync();
if (factor is null) return NotFound();
try
{
factor = await accounts.EnableAuthFactor(factor, code);
return Ok(factor);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpPost("factors/{id:guid}/disable")]
[Authorize]
public async Task<ActionResult<AccountAuthFactor>> DisableAuthFactor(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var factor = await db.AccountAuthFactors
.Where(f => f.AccountId == currentUser.Id && f.Id == id)
.FirstOrDefaultAsync();
if (factor is null) return NotFound();
try
{
factor = await accounts.DisableAuthFactor(factor);
return Ok(factor);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpDelete("factors/{id:guid}")]
[Authorize]
public async Task<ActionResult<AccountAuthFactor>> DeleteAuthFactor(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var factor = await db.AccountAuthFactors
.Where(f => f.AccountId == currentUser.Id && f.Id == id)
.FirstOrDefaultAsync();
if (factor is null) return NotFound();
try
{
await accounts.DeleteAuthFactor(factor);
return NoContent();
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
public class AuthorizedDevice
{
public string? Label { get; set; }
public string UserAgent { get; set; } = null!;
public string DeviceId { get; set; } = null!;
public ChallengePlatform Platform { get; set; }
public List<Session> Sessions { get; set; } = [];
}
[HttpGet("devices")]
[Authorize]
public async Task<ActionResult<List<AuthorizedDevice>>> GetDevices()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
// Group sessions by the related DeviceId, then create an AuthorizedDevice for each group.
var deviceGroups = await db.AuthSessions
.Where(s => s.Account.Id == currentUser.Id)
.Include(s => s.Challenge)
.GroupBy(s => s.Challenge.DeviceId!)
.Select(g => new AuthorizedDevice
{
DeviceId = g.Key!,
UserAgent = g.First(x => x.Challenge.UserAgent != null).Challenge.UserAgent!,
Platform = g.First().Challenge.Platform!,
Label = g.Where(x => !string.IsNullOrWhiteSpace(x.Label)).Select(x => x.Label).FirstOrDefault(),
Sessions = g
.OrderByDescending(x => x.LastGrantedAt)
.ToList()
})
.ToListAsync();
deviceGroups = deviceGroups
.OrderByDescending(s => s.Sessions.First().LastGrantedAt)
.ToList();
return Ok(deviceGroups);
}
[HttpGet("sessions")]
[Authorize]
public async Task<ActionResult<List<Session>>> GetSessions(
[FromQuery] int take = 20,
[FromQuery] int offset = 0
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
var query = db.AuthSessions
.Include(session => session.Account)
.Include(session => session.Challenge)
.Where(session => session.Account.Id == currentUser.Id);
var total = await query.CountAsync();
Response.Headers.Append("X-Total", total.ToString());
Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
var sessions = await query
.OrderByDescending(x => x.LastGrantedAt)
.Skip(offset)
.Take(take)
.ToListAsync();
return Ok(sessions);
}
[HttpDelete("sessions/{id:guid}")]
[Authorize]
public async Task<ActionResult<Session>> DeleteSession(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
try
{
await accounts.DeleteSession(currentUser, id);
return NoContent();
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpDelete("sessions/current")]
[Authorize]
public async Task<ActionResult<Session>> DeleteCurrentSession()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
try
{
await accounts.DeleteSession(currentUser, currentSession.Id);
return NoContent();
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpPatch("sessions/{id:guid}/label")]
public async Task<ActionResult<Session>> UpdateSessionLabel(Guid id, [FromBody] string label)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
try
{
await accounts.UpdateSessionLabel(currentUser, id, label);
return NoContent();
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpPatch("sessions/current/label")]
public async Task<ActionResult<Session>> UpdateCurrentSessionLabel([FromBody] string label)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
try
{
await accounts.UpdateSessionLabel(currentUser, currentSession.Id, label);
return NoContent();
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("contacts")]
[Authorize]
public async Task<ActionResult<List<AccountContact>>> GetContacts()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var contacts = await db.AccountContacts
.Where(c => c.AccountId == currentUser.Id)
.ToListAsync();
return Ok(contacts);
}
public class AccountContactRequest
{
[Required] public AccountContactType Type { get; set; }
[Required] public string Content { get; set; } = null!;
}
[HttpPost("contacts")]
[Authorize]
public async Task<ActionResult<AccountContact>> CreateContact([FromBody] AccountContactRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
try
{
var contact = await accounts.CreateContactMethod(currentUser, request.Type, request.Content);
return Ok(contact);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpPost("contacts/{id:guid}/verify")]
[Authorize]
public async Task<ActionResult<AccountContact>> VerifyContact(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var contact = await db.AccountContacts
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
.FirstOrDefaultAsync();
if (contact is null) return NotFound();
try
{
await accounts.VerifyContactMethod(currentUser, contact);
return Ok(contact);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpPost("contacts/{id:guid}/primary")]
[Authorize]
public async Task<ActionResult<AccountContact>> SetPrimaryContact(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var contact = await db.AccountContacts
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
.FirstOrDefaultAsync();
if (contact is null) return NotFound();
try
{
contact = await accounts.SetContactMethodPrimary(currentUser, contact);
return Ok(contact);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpDelete("contacts/{id:guid}")]
[Authorize]
public async Task<ActionResult<AccountContact>> DeleteContact(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var contact = await db.AccountContacts
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
.FirstOrDefaultAsync();
if (contact is null) return NotFound();
try
{
await accounts.DeleteContactMethod(currentUser, contact);
return NoContent();
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("badges")]
[ProducesResponseType<List<Badge>>(StatusCodes.Status200OK)]
[Authorize]
public async Task<ActionResult<List<Badge>>> GetBadges()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var badges = await db.Badges
.Where(b => b.AccountId == currentUser.Id)
.ToListAsync();
return Ok(badges);
}
[HttpPost("badges/{id:guid}/active")]
[Authorize]
public async Task<ActionResult<Badge>> ActivateBadge(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
try
{
await accounts.ActiveBadge(currentUser, id);
return Ok();
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
}

View File

@ -0,0 +1,339 @@
using System.Globalization;
using DysonNetwork.Sphere.Activity;
using DysonNetwork.Sphere.Connection;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Wallet;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Localization;
using NodaTime;
using Org.BouncyCastle.Asn1.X509;
namespace DysonNetwork.Sphere.Account;
public class AccountEventService(
AppDatabase db,
WebSocketService ws,
ICacheService cache,
PaymentService payment,
IStringLocalizer<Localization.AccountEventResource> localizer
)
{
private static readonly Random Random = new();
private const string StatusCacheKey = "AccountStatus_";
public void PurgeStatusCache(Guid userId)
{
var cacheKey = $"{StatusCacheKey}{userId}";
cache.RemoveAsync(cacheKey);
}
public async Task<Status> GetStatus(Guid userId)
{
var cacheKey = $"{StatusCacheKey}{userId}";
var cachedStatus = await cache.GetAsync<Status>(cacheKey);
if (cachedStatus is not null)
{
cachedStatus!.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId);
return cachedStatus;
}
var now = SystemClock.Instance.GetCurrentInstant();
var status = await db.AccountStatuses
.Where(e => e.AccountId == userId)
.Where(e => e.ClearedAt == null || e.ClearedAt > now)
.OrderByDescending(e => e.CreatedAt)
.FirstOrDefaultAsync();
var isOnline = ws.GetAccountIsConnected(userId);
if (status is not null)
{
status.IsOnline = !status.IsInvisible && isOnline;
await cache.SetWithGroupsAsync(cacheKey, status, [$"{AccountService.AccountCachePrefix}{status.AccountId}"],
TimeSpan.FromMinutes(5));
return status;
}
if (isOnline)
{
return new Status
{
Attitude = StatusAttitude.Neutral,
IsOnline = true,
IsCustomized = false,
Label = "Online",
AccountId = userId,
};
}
return new Status
{
Attitude = StatusAttitude.Neutral,
IsOnline = false,
IsCustomized = false,
Label = "Offline",
AccountId = userId,
};
}
public async Task<Dictionary<Guid, Status>> GetStatuses(List<Guid> userIds)
{
var results = new Dictionary<Guid, Status>();
var cacheMissUserIds = new List<Guid>();
foreach (var userId in userIds)
{
var cacheKey = $"{StatusCacheKey}{userId}";
var cachedStatus = await cache.GetAsync<Status>(cacheKey);
if (cachedStatus != null)
{
cachedStatus.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId);
results[userId] = cachedStatus;
}
else
{
cacheMissUserIds.Add(userId);
}
}
if (cacheMissUserIds.Any())
{
var now = SystemClock.Instance.GetCurrentInstant();
var statusesFromDb = await db.AccountStatuses
.Where(e => cacheMissUserIds.Contains(e.AccountId))
.Where(e => e.ClearedAt == null || e.ClearedAt > now)
.GroupBy(e => e.AccountId)
.Select(g => g.OrderByDescending(e => e.CreatedAt).First())
.ToListAsync();
var foundUserIds = new HashSet<Guid>();
foreach (var status in statusesFromDb)
{
var isOnline = ws.GetAccountIsConnected(status.AccountId);
status.IsOnline = !status.IsInvisible && isOnline;
results[status.AccountId] = status;
var cacheKey = $"{StatusCacheKey}{status.AccountId}";
await cache.SetAsync(cacheKey, status, TimeSpan.FromMinutes(5));
foundUserIds.Add(status.AccountId);
}
var usersWithoutStatus = cacheMissUserIds.Except(foundUserIds).ToList();
if (usersWithoutStatus.Any())
{
foreach (var userId in usersWithoutStatus)
{
var isOnline = ws.GetAccountIsConnected(userId);
var defaultStatus = new Status
{
Attitude = StatusAttitude.Neutral,
IsOnline = isOnline,
IsCustomized = false,
Label = isOnline ? "Online" : "Offline",
AccountId = userId,
};
results[userId] = defaultStatus;
}
}
}
return results;
}
public async Task<Status> CreateStatus(Account user, Status status)
{
var now = SystemClock.Instance.GetCurrentInstant();
await db.AccountStatuses
.Where(x => x.AccountId == user.Id && (x.ClearedAt == null || x.ClearedAt > now))
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ClearedAt, now));
db.AccountStatuses.Add(status);
await db.SaveChangesAsync();
return status;
}
public async Task ClearStatus(Account user, Status status)
{
status.ClearedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(status);
await db.SaveChangesAsync();
PurgeStatusCache(user.Id);
}
private const int FortuneTipCount = 7; // This will be the max index for each type (positive/negative)
private const string CaptchaCacheKey = "CheckInCaptcha_";
private const int CaptchaProbabilityPercent = 20;
public async Task<bool> CheckInDailyDoAskCaptcha(Account user)
{
var cacheKey = $"{CaptchaCacheKey}{user.Id}";
var needsCaptcha = await cache.GetAsync<bool?>(cacheKey);
if (needsCaptcha is not null)
return needsCaptcha!.Value;
var result = Random.Next(100) < CaptchaProbabilityPercent;
await cache.SetAsync(cacheKey, result, TimeSpan.FromHours(24));
return result;
}
public async Task<bool> CheckInDailyIsAvailable(Account user)
{
var now = SystemClock.Instance.GetCurrentInstant();
var lastCheckIn = await db.AccountCheckInResults
.Where(x => x.AccountId == user.Id)
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync();
if (lastCheckIn == null)
return true;
var lastDate = lastCheckIn.CreatedAt.InUtc().Date;
var currentDate = now.InUtc().Date;
return lastDate < currentDate;
}
public const string CheckInLockKey = "CheckInLock_";
public async Task<CheckInResult> CheckInDaily(Account user)
{
var lockKey = $"{CheckInLockKey}{user.Id}";
try
{
var lk = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(100));
if (lk != null)
await lk.ReleaseAsync();
}
catch
{
// Ignore errors from this pre-check
}
// Now try to acquire the lock properly
await using var lockObj = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5));
if (lockObj is null) throw new InvalidOperationException("Check-in was in progress.");
var cultureInfo = new CultureInfo(user.Language, false);
CultureInfo.CurrentCulture = cultureInfo;
CultureInfo.CurrentUICulture = cultureInfo;
// Generate 2 positive tips
var positiveIndices = Enumerable.Range(1, FortuneTipCount)
.OrderBy(_ => Random.Next())
.Take(2)
.ToList();
var tips = positiveIndices.Select(index => new FortuneTip
{
IsPositive = true, Title = localizer[$"FortuneTipPositiveTitle_{index}"].Value,
Content = localizer[$"FortuneTipPositiveContent_{index}"].Value
}).ToList();
// Generate 2 negative tips
var negativeIndices = Enumerable.Range(1, FortuneTipCount)
.Except(positiveIndices)
.OrderBy(_ => Random.Next())
.Take(2)
.ToList();
tips.AddRange(negativeIndices.Select(index => new FortuneTip
{
IsPositive = false, Title = localizer[$"FortuneTipNegativeTitle_{index}"].Value,
Content = localizer[$"FortuneTipNegativeContent_{index}"].Value
}));
var result = new CheckInResult
{
Tips = tips,
Level = (CheckInResultLevel)Random.Next(Enum.GetValues<CheckInResultLevel>().Length),
AccountId = user.Id,
RewardExperience = 100,
RewardPoints = 10,
};
var now = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
try
{
if (result.RewardPoints.HasValue)
await payment.CreateTransactionWithAccountAsync(
null,
user.Id,
WalletCurrency.SourcePoint,
result.RewardPoints.Value,
$"Check-in reward on {now:yyyy/MM/dd}"
);
}
catch
{
result.RewardPoints = null;
}
await db.AccountProfiles
.Where(p => p.AccountId == user.Id)
.ExecuteUpdateAsync(s =>
s.SetProperty(b => b.Experience, b => b.Experience + result.RewardExperience)
);
db.AccountCheckInResults.Add(result);
await db.SaveChangesAsync(); // Don't forget to save changes to the database
// The lock will be automatically released by the await using statement
return result;
}
public async Task<List<DailyEventResponse>> GetEventCalendar(Account user, int month, int year = 0,
bool replaceInvisible = false)
{
if (year == 0)
year = SystemClock.Instance.GetCurrentInstant().InUtc().Date.Year;
// Create start and end dates for the specified month
var startOfMonth = new LocalDate(year, month, 1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
var endOfMonth = startOfMonth.Plus(Duration.FromDays(DateTime.DaysInMonth(year, month)));
var statuses = await db.AccountStatuses
.AsNoTracking()
.TagWith("GetEventCalendar_Statuses")
.Where(x => x.AccountId == user.Id && x.CreatedAt >= startOfMonth && x.CreatedAt < endOfMonth)
.Select(x => new Status
{
Id = x.Id,
Attitude = x.Attitude,
IsInvisible = !replaceInvisible && x.IsInvisible,
IsNotDisturb = x.IsNotDisturb,
Label = x.Label,
ClearedAt = x.ClearedAt,
AccountId = x.AccountId,
CreatedAt = x.CreatedAt
})
.OrderBy(x => x.CreatedAt)
.ToListAsync();
var checkIn = await db.AccountCheckInResults
.AsNoTracking()
.TagWith("GetEventCalendar_CheckIn")
.Where(x => x.AccountId == user.Id && x.CreatedAt >= startOfMonth && x.CreatedAt < endOfMonth)
.ToListAsync();
var dates = Enumerable.Range(1, DateTime.DaysInMonth(year, month))
.Select(day => new LocalDate(year, month, day).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant())
.ToList();
var statusesByDate = statuses
.GroupBy(s => s.CreatedAt.InUtc().Date)
.ToDictionary(g => g.Key, g => g.ToList());
var checkInByDate = checkIn
.ToDictionary(c => c.CreatedAt.InUtc().Date);
return dates.Select(date =>
{
var utcDate = date.InUtc().Date;
return new DailyEventResponse
{
Date = date,
CheckInResult = checkInByDate.GetValueOrDefault(utcDate),
Statuses = statusesByDate.GetValueOrDefault(utcDate, new List<Status>())
};
}).ToList();
}
}

View File

@ -1,9 +1,50 @@
using System.Globalization;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Auth.OpenId;
using DysonNetwork.Sphere.Email;
using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Storage;
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using NodaTime;
using Org.BouncyCastle.Utilities;
using OtpNet;
namespace DysonNetwork.Sphere.Account;
public class AccountService(AppDatabase db)
public class AccountService(
AppDatabase db,
MagicSpellService spells,
AccountUsernameService uname,
NotificationService nty,
EmailService mailer,
IStringLocalizer<NotificationResource> localizer,
ICacheService cache,
ILogger<AccountService> logger
)
{
public static void SetCultureInfo(Account account)
{
SetCultureInfo(account.Language);
}
public static void SetCultureInfo(string? languageCode)
{
var info = new CultureInfo(languageCode ?? "en-us", false);
CultureInfo.CurrentCulture = info;
CultureInfo.CurrentUICulture = info;
}
public const string AccountCachePrefix = "account:";
public async Task PurgeAccountCache(Account account)
{
await cache.RemoveGroupAsync($"{AccountCachePrefix}{account.Id}");
}
public async Task<Account?> LookupAccount(string probe)
{
var account = await db.Accounts.Where(a => a.Name == probe).FirstOrDefaultAsync();
@ -13,8 +54,604 @@ public class AccountService(AppDatabase db)
.Where(c => c.Content == probe)
.Include(c => c.Account)
.FirstOrDefaultAsync();
if (contact is not null) return contact.Account;
return contact?.Account;
}
return null;
public async Task<Account?> LookupAccountByConnection(string identifier, string provider)
{
var connection = await db.AccountConnections
.Where(c => c.ProvidedIdentifier == identifier && c.Provider == provider)
.Include(c => c.Account)
.FirstOrDefaultAsync();
return connection?.Account;
}
public async Task<int?> GetAccountLevel(Guid accountId)
{
var profile = await db.AccountProfiles
.Where(a => a.AccountId == accountId)
.FirstOrDefaultAsync();
return profile?.Level;
}
public async Task<Account> CreateAccount(
string name,
string nick,
string email,
string? password,
string language = "en-US",
bool isEmailVerified = false,
bool isActivated = false
)
{
await using var transaction = await db.Database.BeginTransactionAsync();
try
{
var dupeNameCount = await db.Accounts.Where(a => a.Name == name).CountAsync();
if (dupeNameCount > 0)
throw new InvalidOperationException("Account name has already been taken.");
var account = new Account
{
Name = name,
Nick = nick,
Language = language,
Contacts = new List<AccountContact>
{
new()
{
Type = AccountContactType.Email,
Content = email,
VerifiedAt = isEmailVerified ? SystemClock.Instance.GetCurrentInstant() : null,
IsPrimary = true
}
},
AuthFactors = password is not null
? new List<AccountAuthFactor>
{
new AccountAuthFactor
{
Type = AccountAuthFactorType.Password,
Secret = password,
EnabledAt = SystemClock.Instance.GetCurrentInstant()
}.HashSecret()
}
: [],
Profile = new Profile()
};
if (isActivated)
{
account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
var defaultGroup = await db.PermissionGroups.FirstOrDefaultAsync(g => g.Key == "default");
if (defaultGroup is not null)
{
db.PermissionGroupMembers.Add(new PermissionGroupMember
{
Actor = $"user:{account.Id}",
Group = defaultGroup
});
}
}
else
{
var spell = await spells.CreateMagicSpell(
account,
MagicSpellType.AccountActivation,
new Dictionary<string, object>
{
{ "contact_method", account.Contacts.First().Content }
}
);
await spells.NotifyMagicSpell(spell, true);
}
db.Accounts.Add(account);
await db.SaveChangesAsync();
await transaction.CommitAsync();
return account;
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
public async Task<Account> CreateAccount(OidcUserInfo userInfo)
{
if (string.IsNullOrEmpty(userInfo.Email))
throw new ArgumentException("Email is required for account creation");
var displayName = !string.IsNullOrEmpty(userInfo.DisplayName)
? userInfo.DisplayName
: $"{userInfo.FirstName} {userInfo.LastName}".Trim();
// Generate username from email
var username = await uname.GenerateUsernameFromEmailAsync(userInfo.Email);
return await CreateAccount(
username,
displayName,
userInfo.Email,
null,
"en-US",
userInfo.EmailVerified,
userInfo.EmailVerified
);
}
public async Task RequestAccountDeletion(Account account)
{
var spell = await spells.CreateMagicSpell(
account,
MagicSpellType.AccountRemoval,
new Dictionary<string, object>(),
SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
preventRepeat: true
);
await spells.NotifyMagicSpell(spell);
}
public async Task RequestPasswordReset(Account account)
{
var spell = await spells.CreateMagicSpell(
account,
MagicSpellType.AuthPasswordReset,
new Dictionary<string, object>(),
SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
preventRepeat: true
);
await spells.NotifyMagicSpell(spell);
}
public async Task<bool> CheckAuthFactorExists(Account account, AccountAuthFactorType type)
{
var isExists = await db.AccountAuthFactors
.Where(x => x.AccountId == account.Id && x.Type == type)
.AnyAsync();
return isExists;
}
public async Task<AccountAuthFactor?> CreateAuthFactor(Account account, AccountAuthFactorType type, string? secret)
{
AccountAuthFactor? factor = null;
switch (type)
{
case AccountAuthFactorType.Password:
if (string.IsNullOrWhiteSpace(secret)) throw new ArgumentNullException(nameof(secret));
factor = new AccountAuthFactor
{
Type = AccountAuthFactorType.Password,
Trustworthy = 1,
AccountId = account.Id,
Secret = secret,
EnabledAt = SystemClock.Instance.GetCurrentInstant(),
}.HashSecret();
break;
case AccountAuthFactorType.EmailCode:
factor = new AccountAuthFactor
{
Type = AccountAuthFactorType.EmailCode,
Trustworthy = 2,
EnabledAt = SystemClock.Instance.GetCurrentInstant(),
};
break;
case AccountAuthFactorType.InAppCode:
factor = new AccountAuthFactor
{
Type = AccountAuthFactorType.InAppCode,
Trustworthy = 1,
EnabledAt = SystemClock.Instance.GetCurrentInstant()
};
break;
case AccountAuthFactorType.TimedCode:
var skOtp = KeyGeneration.GenerateRandomKey(20);
var skOtp32 = Base32Encoding.ToString(skOtp);
factor = new AccountAuthFactor
{
Secret = skOtp32,
Type = AccountAuthFactorType.TimedCode,
Trustworthy = 2,
EnabledAt = null, // It needs to be tired once to enable
CreatedResponse = new Dictionary<string, object>
{
["uri"] = new OtpUri(
OtpType.Totp,
skOtp32,
account.Id.ToString(),
"Solar Network"
).ToString(),
}
};
break;
case AccountAuthFactorType.PinCode:
if (string.IsNullOrWhiteSpace(secret)) throw new ArgumentNullException(nameof(secret));
if (!secret.All(char.IsDigit) || secret.Length != 6)
throw new ArgumentException("PIN code must be exactly 6 digits");
factor = new AccountAuthFactor
{
Type = AccountAuthFactorType.PinCode,
Trustworthy = 0, // Only for confirming, can't be used for login
Secret = secret,
EnabledAt = SystemClock.Instance.GetCurrentInstant(),
}.HashSecret();
break;
default:
throw new ArgumentOutOfRangeException(nameof(type), type, null);
}
if (factor is null) throw new InvalidOperationException("Unable to create auth factor.");
factor.AccountId = account.Id;
db.AccountAuthFactors.Add(factor);
await db.SaveChangesAsync();
return factor;
}
public async Task<AccountAuthFactor> EnableAuthFactor(AccountAuthFactor factor, string? code)
{
if (factor.EnabledAt is not null) throw new ArgumentException("The factor has been enabled.");
if (factor.Type is AccountAuthFactorType.Password or AccountAuthFactorType.TimedCode)
{
if (code is null || !factor.VerifyPassword(code))
throw new InvalidOperationException(
"Invalid code, you need to enter the correct code to enable the factor."
);
}
factor.EnabledAt = SystemClock.Instance.GetCurrentInstant();
db.Update(factor);
await db.SaveChangesAsync();
return factor;
}
public async Task<AccountAuthFactor> DisableAuthFactor(AccountAuthFactor factor)
{
if (factor.EnabledAt is null) throw new ArgumentException("The factor has been disabled.");
var count = await db.AccountAuthFactors
.Where(f => f.AccountId == factor.AccountId && f.EnabledAt != null)
.CountAsync();
if (count <= 1)
throw new InvalidOperationException(
"Disabling this auth factor will cause you have no active auth factors.");
factor.EnabledAt = null;
db.Update(factor);
await db.SaveChangesAsync();
return factor;
}
public async Task DeleteAuthFactor(AccountAuthFactor factor)
{
var count = await db.AccountAuthFactors
.Where(f => f.AccountId == factor.AccountId)
.If(factor.EnabledAt is not null, q => q.Where(f => f.EnabledAt != null))
.CountAsync();
if (count <= 1)
throw new InvalidOperationException("Deleting this auth factor will cause you have no auth factor.");
db.AccountAuthFactors.Remove(factor);
await db.SaveChangesAsync();
}
/// <summary>
/// Send the auth factor verification code to users, for factors like in-app code and email.
/// Sometimes it requires a hint, like a part of the user's email address to ensure the user is who own the account.
/// </summary>
/// <param name="account">The owner of the auth factor</param>
/// <param name="factor">The auth factor needed to send code</param>
/// <param name="hint">The part of the contact method for verification</param>
public async Task SendFactorCode(Account account, AccountAuthFactor factor, string? hint = null)
{
var code = new Random().Next(100000, 999999).ToString("000000");
switch (factor.Type)
{
case AccountAuthFactorType.InAppCode:
if (await _GetFactorCode(factor) is not null)
throw new InvalidOperationException("A factor code has been sent and in active duration.");
await nty.SendNotification(
account,
"auth.verification",
localizer["AuthCodeTitle"],
null,
localizer["AuthCodeBody", code],
save: true
);
await _SetFactorCode(factor, code, TimeSpan.FromMinutes(5));
break;
case AccountAuthFactorType.EmailCode:
if (await _GetFactorCode(factor) is not null)
throw new InvalidOperationException("A factor code has been sent and in active duration.");
ArgumentNullException.ThrowIfNull(hint);
hint = hint.Replace("@", "").Replace(".", "").Replace("+", "").Replace("%", "");
if (string.IsNullOrWhiteSpace(hint))
{
logger.LogWarning(
"Unable to send factor code to #{FactorId} with hint {Hint}, due to invalid hint...",
factor.Id,
hint
);
return;
}
var contact = await db.AccountContacts
.Where(c => c.Type == AccountContactType.Email)
.Where(c => c.VerifiedAt != null)
.Where(c => EF.Functions.ILike(c.Content, $"%{hint}%"))
.Include(c => c.Account)
.FirstOrDefaultAsync();
if (contact is null)
{
logger.LogWarning(
"Unable to send factor code to #{FactorId} with hint {Hint}, due to no contact method found according to hint...",
factor.Id,
hint
);
return;
}
await mailer.SendTemplatedEmailAsync<DysonNetwork.Sphere.Pages.Emails.VerificationEmail, VerificationEmailModel>(
account.Nick,
contact.Content,
localizer["VerificationEmail"],
new VerificationEmailModel
{
Name = account.Name,
Code = code
}
);
await _SetFactorCode(factor, code, TimeSpan.FromMinutes(30));
break;
case AccountAuthFactorType.Password:
case AccountAuthFactorType.TimedCode:
default:
// No need to send, such as password etc...
return;
}
}
public async Task<bool> VerifyFactorCode(AccountAuthFactor factor, string code)
{
switch (factor.Type)
{
case AccountAuthFactorType.EmailCode:
case AccountAuthFactorType.InAppCode:
var correctCode = await _GetFactorCode(factor);
var isCorrect = correctCode is not null &&
string.Equals(correctCode, code, StringComparison.OrdinalIgnoreCase);
await cache.RemoveAsync($"{AuthFactorCachePrefix}{factor.Id}:code");
return isCorrect;
case AccountAuthFactorType.Password:
case AccountAuthFactorType.TimedCode:
default:
return factor.VerifyPassword(code);
}
}
private const string AuthFactorCachePrefix = "authfactor:";
private async Task _SetFactorCode(AccountAuthFactor factor, string code, TimeSpan expires)
{
await cache.SetAsync(
$"{AuthFactorCachePrefix}{factor.Id}:code",
code,
expires
);
}
private async Task<string?> _GetFactorCode(AccountAuthFactor factor)
{
return await cache.GetAsync<string?>(
$"{AuthFactorCachePrefix}{factor.Id}:code"
);
}
public async Task<Session> UpdateSessionLabel(Account account, Guid sessionId, string label)
{
var session = await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.Id == sessionId && s.AccountId == account.Id)
.FirstOrDefaultAsync();
if (session is null) throw new InvalidOperationException("Session was not found.");
await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.Challenge.DeviceId == session.Challenge.DeviceId)
.ExecuteUpdateAsync(p => p.SetProperty(s => s.Label, label));
var sessions = await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId)
.ToListAsync();
foreach (var item in sessions)
await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}");
return session;
}
public async Task DeleteSession(Account account, Guid sessionId)
{
var session = await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.Id == sessionId && s.AccountId == account.Id)
.FirstOrDefaultAsync();
if (session is null) throw new InvalidOperationException("Session was not found.");
var sessions = await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId)
.ToListAsync();
if (session.Challenge.DeviceId is not null)
await nty.UnsubscribePushNotifications(session.Challenge.DeviceId);
// The current session should be included in the sessions' list
await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.Challenge.DeviceId == session.Challenge.DeviceId)
.ExecuteDeleteAsync();
foreach (var item in sessions)
await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}");
}
public async Task<AccountContact> CreateContactMethod(Account account, AccountContactType type, string content)
{
var contact = new AccountContact
{
Type = type,
Content = content,
AccountId = account.Id,
};
db.AccountContacts.Add(contact);
await db.SaveChangesAsync();
return contact;
}
public async Task VerifyContactMethod(Account account, AccountContact contact)
{
var spell = await spells.CreateMagicSpell(
account,
MagicSpellType.ContactVerification,
new Dictionary<string, object> { { "contact_method", contact.Content } },
expiredAt: SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
preventRepeat: true
);
await spells.NotifyMagicSpell(spell);
}
public async Task<AccountContact> SetContactMethodPrimary(Account account, AccountContact contact)
{
if (contact.AccountId != account.Id)
throw new InvalidOperationException("Contact method does not belong to this account.");
if (contact.VerifiedAt is null)
throw new InvalidOperationException("Cannot set unverified contact method as primary.");
await using var transaction = await db.Database.BeginTransactionAsync();
try
{
await db.AccountContacts
.Where(c => c.AccountId == account.Id && c.Type == contact.Type)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.IsPrimary, false));
contact.IsPrimary = true;
db.AccountContacts.Update(contact);
await db.SaveChangesAsync();
await transaction.CommitAsync();
return contact;
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
public async Task DeleteContactMethod(Account account, AccountContact contact)
{
if (contact.AccountId != account.Id)
throw new InvalidOperationException("Contact method does not belong to this account.");
if (contact.IsPrimary)
throw new InvalidOperationException("Cannot delete primary contact method.");
db.AccountContacts.Remove(contact);
await db.SaveChangesAsync();
}
/// <summary>
/// This method will grant a badge to the account.
/// Shouldn't be exposed to normal user and the user itself.
/// </summary>
public async Task<Badge> GrantBadge(Account account, Badge badge)
{
badge.AccountId = account.Id;
db.Badges.Add(badge);
await db.SaveChangesAsync();
return badge;
}
/// <summary>
/// This method will revoke a badge from the account.
/// Shouldn't be exposed to normal user and the user itself.
/// </summary>
public async Task RevokeBadge(Account account, Guid badgeId)
{
var badge = await db.Badges
.Where(b => b.AccountId == account.Id && b.Id == badgeId)
.OrderByDescending(b => b.CreatedAt)
.FirstOrDefaultAsync();
if (badge is null) throw new InvalidOperationException("Badge was not found.");
var profile = await db.AccountProfiles
.Where(p => p.AccountId == account.Id)
.FirstOrDefaultAsync();
if (profile?.ActiveBadge is not null && profile.ActiveBadge.Id == badge.Id)
profile.ActiveBadge = null;
db.Remove(badge);
await db.SaveChangesAsync();
}
public async Task ActiveBadge(Account account, Guid badgeId)
{
await using var transaction = await db.Database.BeginTransactionAsync();
try
{
var badge = await db.Badges
.Where(b => b.AccountId == account.Id && b.Id == badgeId)
.OrderByDescending(b => b.CreatedAt)
.FirstOrDefaultAsync();
if (badge is null) throw new InvalidOperationException("Badge was not found.");
await db.Badges
.Where(b => b.AccountId == account.Id && b.Id != badgeId)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.ActivatedAt, p => null));
badge.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(badge);
await db.SaveChangesAsync();
await db.AccountProfiles
.Where(p => p.AccountId == account.Id)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.ActiveBadge, badge.ToReference()));
await PurgeAccountCache(account);
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
/// <summary>
/// The maintenance method for server administrator.
/// To check every user has an account profile and to create them if it isn't having one.
/// </summary>
public async Task EnsureAccountProfileCreated()
{
var accountsId = await db.Accounts.Select(a => a.Id).ToListAsync();
var existingId = await db.AccountProfiles.Select(p => p.AccountId).ToListAsync();
var missingId = accountsId.Except(existingId).ToList();
if (missingId.Count != 0)
{
var newProfiles = missingId.Select(id => new Profile { Id = Guid.NewGuid(), AccountId = id }).ToList();
await db.BulkInsertAsync(newProfiles);
}
}
}

View File

@ -0,0 +1,105 @@
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Account;
/// <summary>
/// Service for handling username generation and validation
/// </summary>
public class AccountUsernameService(AppDatabase db)
{
private readonly Random _random = new();
/// <summary>
/// Generates a unique username based on the provided base name
/// </summary>
/// <param name="baseName">The preferred username</param>
/// <returns>A unique username</returns>
public async Task<string> GenerateUniqueUsernameAsync(string baseName)
{
// Sanitize the base name
var sanitized = SanitizeUsername(baseName);
// If the base name is empty after sanitization, use a default
if (string.IsNullOrEmpty(sanitized))
{
sanitized = "user";
}
// Check if the sanitized name is available
if (!await IsUsernameExistsAsync(sanitized))
{
return sanitized;
}
// Try up to 10 times with random numbers
for (int i = 0; i < 10; i++)
{
var suffix = _random.Next(1000, 9999);
var candidate = $"{sanitized}{suffix}";
if (!await IsUsernameExistsAsync(candidate))
{
return candidate;
}
}
// If all attempts fail, use a timestamp
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
return $"{sanitized}{timestamp}";
}
/// <summary>
/// Sanitizes a username by removing invalid characters and converting to lowercase
/// </summary>
public string SanitizeUsername(string username)
{
if (string.IsNullOrEmpty(username))
return string.Empty;
// Replace spaces and special characters with underscores
var sanitized = Regex.Replace(username, @"[^a-zA-Z0-9_\-]", "");
// Convert to lowercase
sanitized = sanitized.ToLowerInvariant();
// Ensure it starts with a letter
if (sanitized.Length > 0 && !char.IsLetter(sanitized[0]))
{
sanitized = "u" + sanitized;
}
// Truncate if too long
if (sanitized.Length > 30)
{
sanitized = sanitized[..30];
}
return sanitized;
}
/// <summary>
/// Checks if a username already exists
/// </summary>
public async Task<bool> IsUsernameExistsAsync(string username)
{
return await db.Accounts.AnyAsync(a => a.Name == username);
}
/// <summary>
/// Generates a username from an email address
/// </summary>
/// <param name="email">The email address to generate a username from</param>
/// <returns>A unique username derived from the email</returns>
public async Task<string> GenerateUsernameFromEmailAsync(string email)
{
if (string.IsNullOrEmpty(email))
return await GenerateUniqueUsernameAsync("user");
// Extract the local part of the email (before the @)
var localPart = email.Split('@')[0];
// Use the local part as the base for username generation
return await GenerateUniqueUsernameAsync(localPart);
}
}

View File

@ -0,0 +1,58 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Point = NetTopologySuite.Geometries.Point;
namespace DysonNetwork.Sphere.Account;
public abstract class ActionLogType
{
public const string NewLogin = "login";
public const string ChallengeAttempt = "challenges.attempt";
public const string ChallengeSuccess = "challenges.success";
public const string ChallengeFailure = "challenges.failure";
public const string PostCreate = "posts.create";
public const string PostUpdate = "posts.update";
public const string PostDelete = "posts.delete";
public const string PostReact = "posts.react";
public const string MessageCreate = "messages.create";
public const string MessageUpdate = "messages.update";
public const string MessageDelete = "messages.delete";
public const string MessageReact = "messages.react";
public const string PublisherCreate = "publishers.create";
public const string PublisherUpdate = "publishers.update";
public const string PublisherDelete = "publishers.delete";
public const string PublisherMemberInvite = "publishers.members.invite";
public const string PublisherMemberJoin = "publishers.members.join";
public const string PublisherMemberLeave = "publishers.members.leave";
public const string PublisherMemberKick = "publishers.members.kick";
public const string RealmCreate = "realms.create";
public const string RealmUpdate = "realms.update";
public const string RealmDelete = "realms.delete";
public const string RealmInvite = "realms.invite";
public const string RealmJoin = "realms.join";
public const string RealmLeave = "realms.leave";
public const string RealmKick = "realms.kick";
public const string RealmAdjustRole = "realms.role.edit";
public const string ChatroomCreate = "chatrooms.create";
public const string ChatroomUpdate = "chatrooms.update";
public const string ChatroomDelete = "chatrooms.delete";
public const string ChatroomInvite = "chatrooms.invite";
public const string ChatroomJoin = "chatrooms.join";
public const string ChatroomLeave = "chatrooms.leave";
public const string ChatroomKick = "chatrooms.kick";
public const string ChatroomAdjustRole = "chatrooms.role.edit";
}
public class ActionLog : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(4096)] public string Action { get; set; } = null!;
[Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
[MaxLength(512)] public string? UserAgent { get; set; }
[MaxLength(128)] public string? IpAddress { get; set; }
public Point? Location { get; set; }
public Guid AccountId { get; set; }
public Account Account { get; set; } = null!;
public Guid? SessionId { get; set; }
}

View File

@ -0,0 +1,46 @@
using Quartz;
using DysonNetwork.Sphere.Connection;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Storage.Handlers;
namespace DysonNetwork.Sphere.Account;
public class ActionLogService(GeoIpService geo, FlushBufferService fbs)
{
public void CreateActionLog(Guid accountId, string action, Dictionary<string, object> meta)
{
var log = new ActionLog
{
Action = action,
AccountId = accountId,
Meta = meta,
};
fbs.Enqueue(log);
}
public void CreateActionLogFromRequest(string action, Dictionary<string, object> meta, HttpRequest request,
Account? account = null)
{
var log = new ActionLog
{
Action = action,
Meta = meta,
UserAgent = request.Headers.UserAgent,
IpAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Location = geo.GetPointFromIp(request.HttpContext.Connection.RemoteIpAddress?.ToString())
};
if (request.HttpContext.Items["CurrentUser"] is Account currentUser)
log.AccountId = currentUser.Id;
else if (account != null)
log.AccountId = account.Id;
else
throw new ArgumentException("No user context was found");
if (request.HttpContext.Items["CurrentSession"] is Auth.Session currentSession)
log.SessionId = currentSession.Id;
fbs.Enqueue(log);
}
}

View File

@ -0,0 +1,47 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public class Badge : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Type { get; set; } = null!;
[MaxLength(1024)] public string? Label { get; set; }
[MaxLength(4096)] public string? Caption { get; set; }
[Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
public Instant? ActivatedAt { get; set; }
public Instant? ExpiredAt { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!;
public BadgeReferenceObject ToReference()
{
return new BadgeReferenceObject
{
Id = Id,
Type = Type,
Label = Label,
Caption = Caption,
Meta = Meta,
ActivatedAt = ActivatedAt,
ExpiredAt = ExpiredAt,
AccountId = AccountId
};
}
}
public class BadgeReferenceObject : ModelBase
{
public Guid Id { get; set; }
public string Type { get; set; } = null!;
public string? Label { get; set; }
public string? Caption { get; set; }
public Dictionary<string, object>? Meta { get; set; }
public Instant? ActivatedAt { get; set; }
public Instant? ExpiredAt { get; set; }
public Guid AccountId { get; set; }
}

View File

@ -0,0 +1,65 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public enum StatusAttitude
{
Positive,
Negative,
Neutral
}
public class Status : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public StatusAttitude Attitude { get; set; }
[NotMapped] public bool IsOnline { get; set; }
[NotMapped] public bool IsCustomized { get; set; } = true;
public bool IsInvisible { get; set; }
public bool IsNotDisturb { get; set; }
[MaxLength(1024)] public string? Label { get; set; }
public Instant? ClearedAt { get; set; }
public Guid AccountId { get; set; }
public Account Account { get; set; } = null!;
}
public enum CheckInResultLevel
{
Worst,
Worse,
Normal,
Better,
Best
}
public class CheckInResult : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public CheckInResultLevel Level { get; set; }
public decimal? RewardPoints { get; set; }
public int? RewardExperience { get; set; }
[Column(TypeName = "jsonb")] public ICollection<FortuneTip> Tips { get; set; } = new List<FortuneTip>();
public Guid AccountId { get; set; }
public Account Account { get; set; } = null!;
}
public class FortuneTip
{
public bool IsPositive { get; set; }
public string Title { get; set; } = null!;
public string Content { get; set; } = null!;
}
/// <summary>
/// This method should not be mapped. Used to generate the daily event calendar.
/// </summary>
public class DailyEventResponse
{
public Instant Date { get; set; }
public CheckInResult? CheckInResult { get; set; }
public ICollection<Status> Statuses { get; set; } = new List<Status>();
}

View File

@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public enum MagicSpellType
{
AccountActivation,
AccountDeactivation,
AccountRemoval,
AuthPasswordReset,
ContactVerification,
}
[Index(nameof(Spell), IsUnique = true)]
public class MagicSpell : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[JsonIgnore] [MaxLength(1024)] public string Spell { get; set; } = null!;
public MagicSpellType Type { get; set; }
public Instant? ExpiresAt { get; set; }
public Instant? AffectedAt { get; set; }
[Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
public Guid? AccountId { get; set; }
public Account? Account { get; set; }
}

View File

@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Account;
[ApiController]
[Route("/api/spells")]
public class MagicSpellController(AppDatabase db, MagicSpellService sp) : ControllerBase
{
[HttpPost("{spellId:guid}/resend")]
public async Task<ActionResult> ResendMagicSpell(Guid spellId)
{
var spell = db.MagicSpells.FirstOrDefault(x => x.Id == spellId);
if (spell == null)
return NotFound();
await sp.NotifyMagicSpell(spell, true);
return Ok();
}
}

View File

@ -0,0 +1,252 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json;
using DysonNetwork.Sphere.Email;
using DysonNetwork.Sphere.Pages.Emails;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Resources.Localization;
using DysonNetwork.Sphere.Resources.Pages.Emails;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public class MagicSpellService(
AppDatabase db,
EmailService email,
IConfiguration configuration,
ILogger<MagicSpellService> logger,
IStringLocalizer<Localization.EmailResource> localizer
)
{
public async Task<MagicSpell> CreateMagicSpell(
Account account,
MagicSpellType type,
Dictionary<string, object> meta,
Instant? expiredAt = null,
Instant? affectedAt = null,
bool preventRepeat = false
)
{
if (preventRepeat)
{
var now = SystemClock.Instance.GetCurrentInstant();
var existingSpell = await db.MagicSpells
.Where(s => s.AccountId == account.Id)
.Where(s => s.Type == type)
.Where(s => s.ExpiresAt == null || s.ExpiresAt > now)
.FirstOrDefaultAsync();
if (existingSpell != null)
{
throw new InvalidOperationException($"Account already has an active magic spell of type {type}");
}
}
var spellWord = _GenerateRandomString(128);
var spell = new MagicSpell
{
Spell = spellWord,
Type = type,
ExpiresAt = expiredAt,
AffectedAt = affectedAt,
AccountId = account.Id,
Meta = meta
};
db.MagicSpells.Add(spell);
await db.SaveChangesAsync();
return spell;
}
public async Task NotifyMagicSpell(MagicSpell spell, bool bypassVerify = false)
{
var contact = await db.AccountContacts
.Where(c => c.Account.Id == spell.AccountId)
.Where(c => c.Type == AccountContactType.Email)
.Where(c => c.VerifiedAt != null || bypassVerify)
.OrderByDescending(c => c.IsPrimary)
.Include(c => c.Account)
.FirstOrDefaultAsync();
if (contact is null) throw new ArgumentException("Account has no contact method that can use");
var link = $"{configuration.GetValue<string>("BaseUrl")}/spells/{Uri.EscapeDataString(spell.Spell)}";
logger.LogInformation("Sending magic spell... {Link}", link);
var accountLanguage = await db.Accounts
.Where(a => a.Id == spell.AccountId)
.Select(a => a.Language)
.FirstOrDefaultAsync();
AccountService.SetCultureInfo(accountLanguage);
try
{
switch (spell.Type)
{
case MagicSpellType.AccountActivation:
await email.SendTemplatedEmailAsync<LandingEmail, LandingEmailModel>(
contact.Account.Nick,
contact.Content,
localizer["EmailLandingTitle"],
new LandingEmailModel
{
Name = contact.Account.Name,
Link = link
}
);
break;
case MagicSpellType.AccountRemoval:
await email.SendTemplatedEmailAsync<AccountDeletionEmail, AccountDeletionEmailModel>(
contact.Account.Nick,
contact.Content,
localizer["EmailAccountDeletionTitle"],
new AccountDeletionEmailModel
{
Name = contact.Account.Name,
Link = link
}
);
break;
case MagicSpellType.AuthPasswordReset:
await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>(
contact.Account.Nick,
contact.Content,
localizer["EmailAccountDeletionTitle"],
new PasswordResetEmailModel
{
Name = contact.Account.Name,
Link = link
}
);
break;
case MagicSpellType.ContactVerification:
if (spell.Meta["contact_method"] is not string contactMethod)
throw new InvalidOperationException("Contact method is not found.");
await email.SendTemplatedEmailAsync<ContactVerificationEmail, ContactVerificationEmailModel>(
contact.Account.Nick,
contactMethod!,
localizer["EmailContactVerificationTitle"],
new ContactVerificationEmailModel
{
Name = contact.Account.Name,
Link = link
}
);
break;
default:
throw new ArgumentOutOfRangeException();
}
}
catch (Exception err)
{
logger.LogError($"Error sending magic spell (${spell.Spell})... {err}");
}
}
public async Task ApplyMagicSpell(MagicSpell spell)
{
switch (spell.Type)
{
case MagicSpellType.AuthPasswordReset:
throw new ArgumentException(
"For password reset spell, please use the ApplyPasswordReset method instead."
);
case MagicSpellType.AccountRemoval:
var account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId);
if (account is null) break;
db.Accounts.Remove(account);
break;
case MagicSpellType.AccountActivation:
var contactMethod = (spell.Meta["contact_method"] as JsonElement? ?? default).ToString();
var contact = await
db.AccountContacts.FirstOrDefaultAsync(c =>
c.Content == contactMethod
);
if (contact is not null)
{
contact.VerifiedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(contact);
}
account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId);
if (account is not null)
{
account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(account);
}
var defaultGroup = await db.PermissionGroups.FirstOrDefaultAsync(g => g.Key == "default");
if (defaultGroup is not null && account is not null)
{
db.PermissionGroupMembers.Add(new PermissionGroupMember
{
Actor = $"user:{account.Id}",
Group = defaultGroup
});
}
break;
case MagicSpellType.ContactVerification:
var verifyContactMethod = (spell.Meta["contact_method"] as JsonElement? ?? default).ToString();
var verifyContact = await db.AccountContacts
.FirstOrDefaultAsync(c => c.Content == verifyContactMethod);
if (verifyContact is not null)
{
verifyContact.VerifiedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(verifyContact);
}
break;
default:
throw new ArgumentOutOfRangeException();
}
db.Remove(spell);
await db.SaveChangesAsync();
}
public async Task ApplyPasswordReset(MagicSpell spell, string newPassword)
{
if (spell.Type != MagicSpellType.AuthPasswordReset)
throw new ArgumentException("This spell is not a password reset spell.");
var passwordFactor = await db.AccountAuthFactors
.Include(f => f.Account)
.Where(f => f.Type == AccountAuthFactorType.Password && f.Account.Id == spell.AccountId)
.FirstOrDefaultAsync();
if (passwordFactor is null)
{
var account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId);
if (account is null) throw new InvalidOperationException("Both account and auth factor was not found.");
passwordFactor = new AccountAuthFactor
{
Type = AccountAuthFactorType.Password,
Account = account,
Secret = newPassword
}.HashSecret();
db.AccountAuthFactors.Add(passwordFactor);
}
else
{
passwordFactor.Secret = newPassword;
passwordFactor.HashSecret();
db.Update(passwordFactor);
}
await db.SaveChangesAsync();
}
private static string _GenerateRandomString(int length)
{
using var rng = RandomNumberGenerator.Create();
var randomBytes = new byte[length];
rng.GetBytes(randomBytes);
var base64String = Convert.ToBase64String(randomBytes);
return base64String.Substring(0, length);
}
}

View File

@ -0,0 +1,41 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public class Notification : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Topic { get; set; } = null!;
[MaxLength(1024)] public string? Title { get; set; }
[MaxLength(2048)] public string? Subtitle { get; set; }
[MaxLength(4096)] public string? Content { get; set; }
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; }
public int Priority { get; set; } = 10;
public Instant? ViewedAt { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!;
}
public enum NotificationPushProvider
{
Apple,
Google
}
[Index(nameof(DeviceToken), nameof(DeviceId), nameof(AccountId), IsUnique = true)]
public class NotificationPushSubscription : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(4096)] public string DeviceId { get; set; } = null!;
[MaxLength(4096)] public string DeviceToken { get; set; } = null!;
public NotificationPushProvider Provider { get; set; }
public Instant? LastUsedAt { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!;
}

View File

@ -0,0 +1,166 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Permission;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
[ApiController]
[Route("/api/notifications")]
public class NotificationController(AppDatabase db, NotificationService nty) : ControllerBase
{
[HttpGet("count")]
[Authorize]
public async Task<ActionResult<int>> CountUnreadNotifications()
{
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
if (currentUserValue is not Account currentUser) return Unauthorized();
var count = await db.Notifications
.Where(s => s.AccountId == currentUser.Id && s.ViewedAt == null)
.CountAsync();
return Ok(count);
}
[HttpGet]
[Authorize]
public async Task<ActionResult<List<Notification>>> ListNotifications(
[FromQuery] int offset = 0,
// The page size set to 5 is to avoid the client pulled the notification
// but didn't render it in the screen-viewable region.
[FromQuery] int take = 5
)
{
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
if (currentUserValue is not Account currentUser) return Unauthorized();
var totalCount = await db.Notifications
.Where(s => s.AccountId == currentUser.Id)
.CountAsync();
var notifications = await db.Notifications
.Where(s => s.AccountId == currentUser.Id)
.OrderByDescending(e => e.CreatedAt)
.Skip(offset)
.Take(take)
.ToListAsync();
Response.Headers["X-Total"] = totalCount.ToString();
await nty.MarkNotificationsViewed(notifications);
return Ok(notifications);
}
public class PushNotificationSubscribeRequest
{
[MaxLength(4096)] public string DeviceToken { get; set; } = null!;
public NotificationPushProvider Provider { get; set; }
}
[HttpPut("subscription")]
[Authorize]
public async Task<ActionResult<NotificationPushSubscription>> SubscribeToPushNotification(
[FromBody] PushNotificationSubscribeRequest request
)
{
HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue);
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account;
if (currentUser == null) return Unauthorized();
var currentSession = currentSessionValue as Session;
if (currentSession == null) return Unauthorized();
var result =
await nty.SubscribePushNotification(currentUser, request.Provider, currentSession.Challenge.DeviceId!,
request.DeviceToken);
return Ok(result);
}
[HttpDelete("subscription")]
[Authorize]
public async Task<ActionResult<int>> UnsubscribeFromPushNotification()
{
HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue);
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account;
if (currentUser == null) return Unauthorized();
var currentSession = currentSessionValue as Session;
if (currentSession == null) return Unauthorized();
var affectedRows = await db.NotificationPushSubscriptions
.Where(s =>
s.AccountId == currentUser.Id &&
s.DeviceId == currentSession.Challenge.DeviceId
).ExecuteDeleteAsync();
return Ok(affectedRows);
}
public class NotificationRequest
{
[Required] [MaxLength(1024)] public string Topic { get; set; } = null!;
[Required] [MaxLength(1024)] public string Title { get; set; } = null!;
[MaxLength(2048)] public string? Subtitle { get; set; }
[Required] [MaxLength(4096)] public string Content { get; set; } = null!;
public Dictionary<string, object>? Meta { get; set; }
public int Priority { get; set; } = 10;
}
[HttpPost("broadcast")]
[Authorize]
[RequiredPermission("global", "notifications.broadcast")]
public async Task<ActionResult> BroadcastNotification(
[FromBody] NotificationRequest request,
[FromQuery] bool save = false
)
{
await nty.BroadcastNotification(
new Notification
{
CreatedAt = SystemClock.Instance.GetCurrentInstant(),
UpdatedAt = SystemClock.Instance.GetCurrentInstant(),
Topic = request.Topic,
Title = request.Title,
Subtitle = request.Subtitle,
Content = request.Content,
Meta = request.Meta,
Priority = request.Priority,
},
save
);
return Ok();
}
public class NotificationWithAimRequest : NotificationRequest
{
[Required] public List<Guid> AccountId { get; set; } = null!;
}
[HttpPost("send")]
[Authorize]
[RequiredPermission("global", "notifications.send")]
public async Task<ActionResult> SendNotification(
[FromBody] NotificationWithAimRequest request,
[FromQuery] bool save = false
)
{
var accounts = await db.Accounts.Where(a => request.AccountId.Contains(a.Id)).ToListAsync();
await nty.SendNotificationBatch(
new Notification
{
CreatedAt = SystemClock.Instance.GetCurrentInstant(),
UpdatedAt = SystemClock.Instance.GetCurrentInstant(),
Topic = request.Topic,
Title = request.Title,
Subtitle = request.Subtitle,
Content = request.Content,
Meta = request.Meta,
},
accounts,
save
);
return Ok();
}
}

View File

@ -0,0 +1,308 @@
using System.Text;
using System.Text.Json;
using DysonNetwork.Sphere.Connection;
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public class NotificationService(
AppDatabase db,
WebSocketService ws,
IHttpClientFactory httpFactory,
IConfiguration config)
{
private readonly string _notifyTopic = config["Notifications:Topic"]!;
private readonly Uri _notifyEndpoint = new(config["Notifications:Endpoint"]!);
public async Task UnsubscribePushNotifications(string deviceId)
{
await db.NotificationPushSubscriptions
.Where(s => s.DeviceId == deviceId)
.ExecuteDeleteAsync();
}
public async Task<NotificationPushSubscription> SubscribePushNotification(
Account account,
NotificationPushProvider provider,
string deviceId,
string deviceToken
)
{
var now = SystemClock.Instance.GetCurrentInstant();
// First check if a matching subscription exists
var existingSubscription = await db.NotificationPushSubscriptions
.Where(s => s.AccountId == account.Id)
.Where(s => s.DeviceId == deviceId || s.DeviceToken == deviceToken)
.FirstOrDefaultAsync();
if (existingSubscription is not null)
{
// Update the existing subscription directly in the database
await db.NotificationPushSubscriptions
.Where(s => s.Id == existingSubscription.Id)
.ExecuteUpdateAsync(setters => setters
.SetProperty(s => s.DeviceId, deviceId)
.SetProperty(s => s.DeviceToken, deviceToken)
.SetProperty(s => s.UpdatedAt, now));
// Return the updated subscription
existingSubscription.DeviceId = deviceId;
existingSubscription.DeviceToken = deviceToken;
existingSubscription.UpdatedAt = now;
return existingSubscription;
}
var subscription = new NotificationPushSubscription
{
DeviceId = deviceId,
DeviceToken = deviceToken,
Provider = provider,
AccountId = account.Id,
};
db.NotificationPushSubscriptions.Add(subscription);
await db.SaveChangesAsync();
return subscription;
}
public async Task<Notification> SendNotification(
Account account,
string topic,
string? title = null,
string? subtitle = null,
string? content = null,
Dictionary<string, object>? meta = null,
string? actionUri = null,
bool isSilent = false,
bool save = true
)
{
if (title is null && subtitle is null && content is null)
throw new ArgumentException("Unable to send notification that completely empty.");
meta ??= new Dictionary<string, object>();
if (actionUri is not null) meta["action_uri"] = actionUri;
var notification = new Notification
{
Topic = topic,
Title = title,
Subtitle = subtitle,
Content = content,
Meta = meta,
AccountId = account.Id,
};
if (save)
{
db.Add(notification);
await db.SaveChangesAsync();
}
if (!isSilent) _ = DeliveryNotification(notification);
return notification;
}
public async Task DeliveryNotification(Notification notification)
{
ws.SendPacketToAccount(notification.AccountId, new WebSocketPacket
{
Type = "notifications.new",
Data = notification
});
// Pushing the notification
var subscribers = await db.NotificationPushSubscriptions
.Where(s => s.AccountId == notification.AccountId)
.ToListAsync();
await _PushNotification(notification, subscribers);
}
public async Task MarkNotificationsViewed(ICollection<Notification> notifications)
{
var now = SystemClock.Instance.GetCurrentInstant();
var id = notifications.Where(n => n.ViewedAt == null).Select(n => n.Id).ToList();
if (id.Count == 0) return;
await db.Notifications
.Where(n => id.Contains(n.Id))
.ExecuteUpdateAsync(s => s.SetProperty(n => n.ViewedAt, now)
);
}
public async Task BroadcastNotification(Notification notification, bool save = false)
{
var accounts = await db.Accounts.ToListAsync();
if (save)
{
var notifications = accounts.Select(x =>
{
var newNotification = new Notification
{
Topic = notification.Topic,
Title = notification.Title,
Subtitle = notification.Subtitle,
Content = notification.Content,
Meta = notification.Meta,
Priority = notification.Priority,
Account = x,
AccountId = x.Id
};
return newNotification;
}).ToList();
await db.BulkInsertAsync(notifications);
}
foreach (var account in accounts)
{
notification.Account = account;
notification.AccountId = account.Id;
ws.SendPacketToAccount(account.Id, new WebSocketPacket
{
Type = "notifications.new",
Data = notification
});
}
var subscribers = await db.NotificationPushSubscriptions
.ToListAsync();
await _PushNotification(notification, subscribers);
}
public async Task SendNotificationBatch(Notification notification, List<Account> accounts, bool save = false)
{
if (save)
{
var notifications = accounts.Select(x =>
{
var newNotification = new Notification
{
Topic = notification.Topic,
Title = notification.Title,
Subtitle = notification.Subtitle,
Content = notification.Content,
Meta = notification.Meta,
Priority = notification.Priority,
Account = x,
AccountId = x.Id
};
return newNotification;
}).ToList();
await db.BulkInsertAsync(notifications);
}
foreach (var account in accounts)
{
notification.Account = account;
notification.AccountId = account.Id;
ws.SendPacketToAccount(account.Id, new WebSocketPacket
{
Type = "notifications.new",
Data = notification
});
}
var accountsId = accounts.Select(x => x.Id).ToList();
var subscribers = await db.NotificationPushSubscriptions
.Where(s => accountsId.Contains(s.AccountId))
.ToListAsync();
await _PushNotification(notification, subscribers);
}
private List<Dictionary<string, object>> _BuildNotificationPayload(Notification notification,
IEnumerable<NotificationPushSubscription> subscriptions)
{
var subDict = subscriptions
.GroupBy(x => x.Provider)
.ToDictionary(x => x.Key, x => x.ToList());
var notifications = subDict.Select(value =>
{
var platformCode = value.Key switch
{
NotificationPushProvider.Apple => 1,
NotificationPushProvider.Google => 2,
_ => throw new InvalidOperationException($"Unknown push provider: {value.Key}")
};
var tokens = value.Value.Select(x => x.DeviceToken).ToList();
return _BuildNotificationPayload(notification, platformCode, tokens);
}).ToList();
return notifications.ToList();
}
private Dictionary<string, object> _BuildNotificationPayload(Notification notification, int platformCode,
IEnumerable<string> deviceTokens)
{
var alertDict = new Dictionary<string, object>();
var dict = new Dictionary<string, object>
{
["notif_id"] = notification.Id.ToString(),
["apns_id"] = notification.Id.ToString(),
["topic"] = _notifyTopic,
["tokens"] = deviceTokens,
["data"] = new Dictionary<string, object>
{
["type"] = notification.Topic,
["meta"] = notification.Meta ?? new Dictionary<string, object>(),
},
["mutable_content"] = true,
["priority"] = notification.Priority >= 5 ? "high" : "normal",
};
if (!string.IsNullOrWhiteSpace(notification.Title))
{
dict["title"] = notification.Title;
alertDict["title"] = notification.Title;
}
if (!string.IsNullOrWhiteSpace(notification.Content))
{
dict["message"] = notification.Content;
alertDict["body"] = notification.Content;
}
if (!string.IsNullOrWhiteSpace(notification.Subtitle))
{
dict["message"] = $"{notification.Subtitle}\n{dict["message"]}";
alertDict["subtitle"] = notification.Subtitle;
}
if (notification.Priority >= 5)
dict["name"] = "default";
dict["platform"] = platformCode;
dict["alert"] = alertDict;
return dict;
}
private async Task _PushNotification(Notification notification,
IEnumerable<NotificationPushSubscription> subscriptions)
{
var subList = subscriptions.ToList();
if (subList.Count == 0) return;
var requestDict = new Dictionary<string, object>
{
["notifications"] = _BuildNotificationPayload(notification, subList)
};
var client = httpFactory.CreateClient();
client.BaseAddress = _notifyEndpoint;
var request = await client.PostAsync("/push", new StringContent(
JsonSerializer.Serialize(requestDict),
Encoding.UTF8,
"application/json"
));
request.EnsureSuccessStatusCode();
}
}

View File

@ -1,18 +1,22 @@
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public enum RelationshipType
public enum RelationshipStatus : short
{
Friend,
Blocked
Friends = 100,
Pending = 0,
Blocked = -100
}
public class Relationship : ModelBase
{
public long FromAccountId { get; set; }
public Account FromAccount { get; set; } = null!;
public Guid AccountId { get; set; }
public Account Account { get; set; } = null!;
public Guid RelatedId { get; set; }
public Account Related { get; set; } = null!;
public long ToAccountId { get; set; }
public Account ToAccount { get; set; } = null!;
public Instant? ExpiredAt { get; set; }
public RelationshipType Type { get; set; }
public RelationshipStatus Status { get; set; } = RelationshipStatus.Pending;
}

View File

@ -0,0 +1,253 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
[ApiController]
[Route("/api/relationships")]
public class RelationshipController(AppDatabase db, RelationshipService rels) : ControllerBase
{
[HttpGet]
[Authorize]
public async Task<ActionResult<List<Relationship>>> ListRelationships([FromQuery] int offset = 0,
[FromQuery] int take = 20)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var query = db.AccountRelationships.AsQueryable()
.Where(r => r.RelatedId == userId);
var totalCount = await query.CountAsync();
var relationships = await query
.Include(r => r.Related)
.Include(r => r.Related.Profile)
.Include(r => r.Account)
.Include(r => r.Account.Profile)
.Skip(offset)
.Take(take)
.ToListAsync();
var statuses = await db.AccountRelationships
.Where(r => r.AccountId == userId)
.ToDictionaryAsync(r => r.RelatedId);
foreach (var relationship in relationships)
if (statuses.TryGetValue(relationship.RelatedId, out var status))
relationship.Status = status.Status;
Response.Headers["X-Total"] = totalCount.ToString();
return relationships;
}
[HttpGet("requests")]
[Authorize]
public async Task<ActionResult<List<Relationship>>> ListSentRequests()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var relationships = await db.AccountRelationships
.Where(r => r.AccountId == currentUser.Id && r.Status == RelationshipStatus.Pending)
.Include(r => r.Related)
.Include(r => r.Related.Profile)
.Include(r => r.Account)
.Include(r => r.Account.Profile)
.ToListAsync();
return relationships;
}
public class RelationshipRequest
{
[Required] public RelationshipStatus Status { get; set; }
}
[HttpPost("{userId:guid}")]
[Authorize]
public async Task<ActionResult<Relationship>> CreateRelationship(Guid userId,
[FromBody] RelationshipRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var relatedUser = await db.Accounts.FindAsync(userId);
if (relatedUser is null) return NotFound("Account was not found.");
try
{
var relationship = await rels.CreateRelationship(
currentUser, relatedUser, request.Status
);
return relationship;
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
}
[HttpPatch("{userId:guid}")]
[Authorize]
public async Task<ActionResult<Relationship>> UpdateRelationship(Guid userId,
[FromBody] RelationshipRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
try
{
var relationship = await rels.UpdateRelationship(currentUser.Id, userId, request.Status);
return relationship;
}
catch (ArgumentException err)
{
return NotFound(err.Message);
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
}
[HttpGet("{userId:guid}")]
[Authorize]
public async Task<ActionResult<Relationship>> GetRelationship(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
var queries = db.AccountRelationships.AsQueryable()
.Where(r => r.AccountId == currentUser.Id && r.RelatedId == userId)
.Where(r => r.ExpiredAt == null || r.ExpiredAt > now);
var relationship = await queries
.Include(r => r.Related)
.Include(r => r.Related.Profile)
.FirstOrDefaultAsync();
if (relationship is null) return NotFound();
relationship.Account = currentUser;
return Ok(relationship);
}
[HttpPost("{userId:guid}/friends")]
[Authorize]
public async Task<ActionResult<Relationship>> SendFriendRequest(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var relatedUser = await db.Accounts.FindAsync(userId);
if (relatedUser is null) return NotFound("Account was not found.");
var existing = await db.AccountRelationships.FirstOrDefaultAsync(r =>
(r.AccountId == currentUser.Id && r.RelatedId == userId) ||
(r.AccountId == userId && r.RelatedId == currentUser.Id));
if (existing != null) return BadRequest("Relationship already exists.");
try
{
var relationship = await rels.SendFriendRequest(currentUser, relatedUser);
return relationship;
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
}
[HttpDelete("{userId:guid}/friends")]
[Authorize]
public async Task<ActionResult> DeleteFriendRequest(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
try
{
await rels.DeleteFriendRequest(currentUser.Id, userId);
return NoContent();
}
catch (ArgumentException err)
{
return NotFound(err.Message);
}
}
[HttpPost("{userId:guid}/friends/accept")]
[Authorize]
public async Task<ActionResult<Relationship>> AcceptFriendRequest(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var relationship = await rels.GetRelationship(userId, currentUser.Id, RelationshipStatus.Pending);
if (relationship is null) return NotFound("Friend request was not found.");
try
{
relationship = await rels.AcceptFriendRelationship(relationship);
return relationship;
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
}
[HttpPost("{userId:guid}/friends/decline")]
[Authorize]
public async Task<ActionResult<Relationship>> DeclineFriendRequest(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var relationship = await rels.GetRelationship(userId, currentUser.Id, RelationshipStatus.Pending);
if (relationship is null) return NotFound("Friend request was not found.");
try
{
relationship = await rels.AcceptFriendRelationship(relationship, status: RelationshipStatus.Blocked);
return relationship;
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
}
[HttpPost("{userId:guid}/block")]
[Authorize]
public async Task<ActionResult<Relationship>> BlockUser(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var relatedUser = await db.Accounts.FindAsync(userId);
if (relatedUser is null) return NotFound("Account was not found.");
try
{
var relationship = await rels.BlockAccount(currentUser, relatedUser);
return relationship;
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
}
[HttpDelete("{userId:guid}/block")]
[Authorize]
public async Task<ActionResult<Relationship>> UnblockUser(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var relatedUser = await db.Accounts.FindAsync(userId);
if (relatedUser is null) return NotFound("Account was not found.");
try
{
var relationship = await rels.UnblockAccount(currentUser, relatedUser);
return relationship;
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
}
}

View File

@ -0,0 +1,207 @@
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public class RelationshipService(AppDatabase db, ICacheService cache)
{
private const string UserFriendsCacheKeyPrefix = "accounts:friends:";
private const string UserBlockedCacheKeyPrefix = "accounts:blocked:";
public async Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId)
{
var count = await db.AccountRelationships
.Where(r => (r.AccountId == accountId && r.RelatedId == relatedId) ||
(r.AccountId == relatedId && r.AccountId == accountId))
.CountAsync();
return count > 0;
}
public async Task<Relationship?> GetRelationship(
Guid accountId,
Guid relatedId,
RelationshipStatus? status = null,
bool ignoreExpired = false
)
{
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
var queries = db.AccountRelationships.AsQueryable()
.Where(r => r.AccountId == accountId && r.RelatedId == relatedId);
if (!ignoreExpired) queries = queries.Where(r => r.ExpiredAt == null || r.ExpiredAt > now);
if (status is not null) queries = queries.Where(r => r.Status == status);
var relationship = await queries.FirstOrDefaultAsync();
return relationship;
}
public async Task<Relationship> CreateRelationship(Account sender, Account target, RelationshipStatus status)
{
if (status == RelationshipStatus.Pending)
throw new InvalidOperationException(
"Cannot create relationship with pending status, use SendFriendRequest instead.");
if (await HasExistingRelationship(sender.Id, target.Id))
throw new InvalidOperationException("Found existing relationship between you and target user.");
var relationship = new Relationship
{
AccountId = sender.Id,
RelatedId = target.Id,
Status = status
};
db.AccountRelationships.Add(relationship);
await db.SaveChangesAsync();
await PurgeRelationshipCache(sender.Id, target.Id);
return relationship;
}
public async Task<Relationship> BlockAccount(Account sender, Account target)
{
if (await HasExistingRelationship(sender.Id, target.Id))
return await UpdateRelationship(sender.Id, target.Id, RelationshipStatus.Blocked);
return await CreateRelationship(sender, target, RelationshipStatus.Blocked);
}
public async Task<Relationship> UnblockAccount(Account sender, Account target)
{
var relationship = await GetRelationship(sender.Id, target.Id, RelationshipStatus.Blocked);
if (relationship is null) throw new ArgumentException("There is no relationship between you and the user.");
db.Remove(relationship);
await db.SaveChangesAsync();
await PurgeRelationshipCache(sender.Id, target.Id);
return relationship;
}
public async Task<Relationship> SendFriendRequest(Account sender, Account target)
{
if (await HasExistingRelationship(sender.Id, target.Id))
throw new InvalidOperationException("Found existing relationship between you and target user.");
var relationship = new Relationship
{
AccountId = sender.Id,
RelatedId = target.Id,
Status = RelationshipStatus.Pending,
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(7))
};
db.AccountRelationships.Add(relationship);
await db.SaveChangesAsync();
return relationship;
}
public async Task DeleteFriendRequest(Guid accountId, Guid relatedId)
{
var relationship = await GetRelationship(accountId, relatedId, RelationshipStatus.Pending);
if (relationship is null) throw new ArgumentException("Friend request was not found.");
await db.AccountRelationships
.Where(r => r.AccountId == accountId && r.RelatedId == relatedId && r.Status == RelationshipStatus.Pending)
.ExecuteDeleteAsync();
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
}
public async Task<Relationship> AcceptFriendRelationship(
Relationship relationship,
RelationshipStatus status = RelationshipStatus.Friends
)
{
if (relationship.Status != RelationshipStatus.Pending)
throw new ArgumentException("Cannot accept friend request that not in pending status.");
if (status == RelationshipStatus.Pending)
throw new ArgumentException("Cannot accept friend request by setting the new status to pending.");
// Whatever the receiver decides to apply which status to the relationship,
// the sender should always see the user as a friend since the sender ask for it
relationship.Status = RelationshipStatus.Friends;
relationship.ExpiredAt = null;
db.Update(relationship);
var relationshipBackward = new Relationship
{
AccountId = relationship.RelatedId,
RelatedId = relationship.AccountId,
Status = status
};
db.AccountRelationships.Add(relationshipBackward);
await db.SaveChangesAsync();
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
return relationshipBackward;
}
public async Task<Relationship> UpdateRelationship(Guid accountId, Guid relatedId, RelationshipStatus status)
{
var relationship = await GetRelationship(accountId, relatedId);
if (relationship is null) throw new ArgumentException("There is no relationship between you and the user.");
if (relationship.Status == status) return relationship;
relationship.Status = status;
db.Update(relationship);
await db.SaveChangesAsync();
await PurgeRelationshipCache(accountId, relatedId);
return relationship;
}
public async Task<List<Guid>> ListAccountFriends(Account account)
{
var cacheKey = $"{UserFriendsCacheKeyPrefix}{account.Id}";
var friends = await cache.GetAsync<List<Guid>>(cacheKey);
if (friends == null)
{
friends = await db.AccountRelationships
.Where(r => r.RelatedId == account.Id)
.Where(r => r.Status == RelationshipStatus.Friends)
.Select(r => r.AccountId)
.ToListAsync();
await cache.SetAsync(cacheKey, friends, TimeSpan.FromHours(1));
}
return friends ?? [];
}
public async Task<List<Guid>> ListAccountBlocked(Account account)
{
var cacheKey = $"{UserBlockedCacheKeyPrefix}{account.Id}";
var blocked = await cache.GetAsync<List<Guid>>(cacheKey);
if (blocked == null)
{
blocked = await db.AccountRelationships
.Where(r => r.RelatedId == account.Id)
.Where(r => r.Status == RelationshipStatus.Blocked)
.Select(r => r.AccountId)
.ToListAsync();
await cache.SetAsync(cacheKey, blocked, TimeSpan.FromHours(1));
}
return blocked ?? [];
}
public async Task<bool> HasRelationshipWithStatus(Guid accountId, Guid relatedId,
RelationshipStatus status = RelationshipStatus.Friends)
{
var relationship = await GetRelationship(accountId, relatedId, status);
return relationship is not null;
}
private async Task PurgeRelationshipCache(Guid accountId, Guid relatedId)
{
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}");
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relatedId}");
await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{accountId}");
await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{relatedId}");
}
}

View File

@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
namespace DysonNetwork.Sphere.Account;
/// <summary>
/// The verification info of a resource
/// stands, for it is really an individual or organization or a company in the real world.
/// Besides, it can also be use for mark parody or fake.
/// </summary>
public class VerificationMark
{
public VerificationMarkType Type { get; set; }
[MaxLength(1024)] public string? Title { get; set; }
[MaxLength(8192)] public string? Description { get; set; }
[MaxLength(1024)] public string? VerifiedBy { get; set; }
}
public enum VerificationMarkType
{
Official,
Individual,
Organization,
Government,
Creator
}

View File

@ -0,0 +1,37 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using NodaTime;
namespace DysonNetwork.Sphere.Activity;
public interface IActivity
{
public Activity ToActivity();
}
[NotMapped]
public class Activity : ModelBase
{
public Guid Id { get; set; }
[MaxLength(1024)] public string Type { get; set; } = null!;
[MaxLength(4096)] public string ResourceIdentifier { get; set; } = null!;
[Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
public object? Data { get; set; }
// Outdated fields, for backward compability
public int Visibility => 0;
public static Activity Empty()
{
var now = SystemClock.Instance.GetCurrentInstant();
return new Activity
{
CreatedAt = now,
UpdatedAt = now,
Id = Guid.NewGuid(),
Type = "empty",
ResourceIdentifier = "none"
};
}
}

View File

@ -0,0 +1,52 @@
using Microsoft.AspNetCore.Mvc;
using NodaTime;
using NodaTime.Text;
namespace DysonNetwork.Sphere.Activity;
/// <summary>
/// Activity is a universal feed that contains multiple kinds of data. Personalized and generated dynamically.
/// </summary>
[ApiController]
[Route("/api/activities")]
public class ActivityController(
ActivityService acts
) : ControllerBase
{
/// <summary>
/// Listing the activities for the user, users may be logged in or not to use this API.
/// When the users are not logged in, this API will return the posts that are public.
/// When the users are logged in,
/// the API will personalize the user's experience
/// by ranking up the people they like and the posts they like.
/// Besides, when users are logged in, it will also mix the other kinds of data and who're plying to them.
/// </summary>
[HttpGet]
public async Task<ActionResult<List<Activity>>> ListActivities(
[FromQuery] string? cursor,
[FromQuery] string? filter,
[FromQuery] int take = 20,
[FromQuery] string? debugInclude = null
)
{
Instant? cursorTimestamp = null;
if (!string.IsNullOrEmpty(cursor))
{
try
{
cursorTimestamp = InstantPattern.ExtendedIso.Parse(cursor).GetValueOrThrow();
}
catch
{
return BadRequest("Invalid cursor format");
}
}
var debugIncludeSet = debugInclude?.Split(',').ToHashSet() ?? new HashSet<string>();
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
return currentUserValue is not Account.Account currentUser
? Ok(await acts.GetActivitiesForAnyone(take, cursorTimestamp, debugIncludeSet))
: Ok(await acts.GetActivities(take, cursorTimestamp, currentUser, filter, debugIncludeSet));
}
}

View File

@ -0,0 +1,285 @@
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Connection.WebReader;
using DysonNetwork.Sphere.Discovery;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Publisher;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Activity;
public class ActivityService(
AppDatabase db,
PublisherService pub,
RelationshipService rels,
PostService ps,
DiscoveryService ds)
{
private static double CalculateHotRank(Post.Post post, Instant now)
{
var score = post.Upvotes - post.Downvotes;
var postTime = post.PublishedAt ?? post.CreatedAt;
var hours = (now - postTime).TotalHours;
// Add 1 to score to prevent negative results for posts with more downvotes than upvotes
return (score + 1) / Math.Pow(hours + 2, 1.8);
}
public async Task<List<Activity>> GetActivitiesForAnyone(int take, Instant? cursor,
HashSet<string>? debugInclude = null)
{
var activities = new List<Activity>();
debugInclude ??= new HashSet<string>();
if (cursor == null && (debugInclude.Contains("realms") || Random.Shared.NextDouble() < 0.2))
{
var realms = await ds.GetPublicRealmsAsync(null, null, 5, 0, true);
if (realms.Count > 0)
{
activities.Add(new DiscoveryActivity(
realms.Select(x => new DiscoveryItem("realm", x)).ToList()
).ToActivity());
}
}
if (debugInclude.Contains("articles") || Random.Shared.NextDouble() < 0.2)
{
var recentFeedIds = await db.WebArticles
.GroupBy(a => a.FeedId)
.OrderByDescending(g => g.Max(a => a.PublishedAt))
.Take(10) // Get recent 10 distinct feeds
.Select(g => g.Key)
.ToListAsync();
// For each feed, get one random article
var recentArticles = new List<WebArticle>();
var random = new Random();
foreach (var feedId in recentFeedIds.OrderBy(_ => random.Next()))
{
var article = await db.WebArticles
.Include(a => a.Feed)
.Where(a => a.FeedId == feedId)
.OrderBy(_ => EF.Functions.Random())
.FirstOrDefaultAsync();
if (article == null) continue;
recentArticles.Add(article);
if (recentArticles.Count >= 5) break; // Limit to 5 articles
}
if (recentArticles.Count > 0)
{
activities.Add(new DiscoveryActivity(
recentArticles.Select(x => new DiscoveryItem("article", x)).ToList()
).ToActivity());
}
}
// Fetch a larger batch of recent posts to rank
var postsQuery = db.Posts
.Include(e => e.RepliedPost)
.Include(e => e.ForwardedPost)
.Include(e => e.Categories)
.Include(e => e.Tags)
.Where(e => e.RepliedPostId == null)
.Where(p => cursor == null || p.PublishedAt < cursor)
.OrderByDescending(p => p.PublishedAt)
.FilterWithVisibility(null, [], [], isListing: true)
.Take(take * 5); // Fetch more posts to have a good pool for ranking
var posts = await postsQuery.ToListAsync();
posts = await ps.LoadPostInfo(posts, null, true);
var postsId = posts.Select(e => e.Id).ToList();
var reactionMaps = await ps.GetPostReactionMapBatch(postsId);
foreach (var post in posts)
post.ReactionsCount =
reactionMaps.TryGetValue(post.Id, out var count) ? count : new Dictionary<string, int>();
// Rank and sort
var now = SystemClock.Instance.GetCurrentInstant();
var rankedPosts = posts
.Select(p => new { Post = p, Rank = CalculateHotRank(p, now) })
.OrderByDescending(x => x.Rank)
.Select(x => x.Post)
.Take(take)
.ToList();
// Formatting data
foreach (var post in rankedPosts)
activities.Add(post.ToActivity());
if (activities.Count == 0)
activities.Add(Activity.Empty());
return activities;
}
public async Task<List<Activity>> GetActivities(
int take,
Instant? cursor,
Account.Account currentUser,
string? filter = null,
HashSet<string>? debugInclude = null
)
{
var activities = new List<Activity>();
var userFriends = await rels.ListAccountFriends(currentUser);
var userPublishers = await pub.GetUserPublishers(currentUser.Id);
debugInclude ??= [];
if (string.IsNullOrEmpty(filter))
{
if (cursor == null && (debugInclude.Contains("realms") || Random.Shared.NextDouble() < 0.2))
{
var realms = await ds.GetPublicRealmsAsync(null, null, 5, 0, true);
if (realms.Count > 0)
{
activities.Add(new DiscoveryActivity(
realms.Select(x => new DiscoveryItem("realm", x)).ToList()
).ToActivity());
}
}
if (cursor == null && (debugInclude.Contains("publishers") || Random.Shared.NextDouble() < 0.2))
{
var popularPublishers = await GetPopularPublishers(5);
if (popularPublishers.Count > 0)
{
activities.Add(new DiscoveryActivity(
popularPublishers.Select(x => new DiscoveryItem("publisher", x)).ToList()
).ToActivity());
}
}
if (debugInclude.Contains("articles") || Random.Shared.NextDouble() < 0.2)
{
var recentFeedIds = await db.WebArticles
.GroupBy(a => a.FeedId)
.OrderByDescending(g => g.Max(a => a.PublishedAt))
.Take(10) // Get recent 10 distinct feeds
.Select(g => g.Key)
.ToListAsync();
// For each feed, get one random article
var recentArticles = new List<WebArticle>();
var random = new Random();
foreach (var feedId in recentFeedIds.OrderBy(_ => random.Next()))
{
var article = await db.WebArticles
.Include(a => a.Feed)
.Where(a => a.FeedId == feedId)
.OrderBy(_ => EF.Functions.Random())
.FirstOrDefaultAsync();
if (article == null) continue;
recentArticles.Add(article);
if (recentArticles.Count >= 5) break; // Limit to 5 articles
}
if (recentArticles.Count > 0)
{
activities.Add(new DiscoveryActivity(
recentArticles.Select(x => new DiscoveryItem("article", x)).ToList()
).ToActivity());
}
}
}
// Get publishers based on filter
var filteredPublishers = filter switch
{
"subscriptions" => await pub.GetSubscribedPublishers(currentUser.Id),
"friends" => (await pub.GetUserPublishersBatch(userFriends)).SelectMany(x => x.Value)
.DistinctBy(x => x.Id)
.ToList(),
_ => null
};
var filteredPublishersId = filteredPublishers?.Select(e => e.Id).ToList();
// Build the query based on the filter
var postsQuery = db.Posts
.Include(e => e.RepliedPost)
.Include(e => e.ForwardedPost)
.Include(e => e.Categories)
.Include(e => e.Tags)
.Where(p => cursor == null || p.PublishedAt < cursor)
.OrderByDescending(p => p.PublishedAt)
.AsQueryable();
if (filteredPublishersId is not null)
postsQuery = postsQuery.Where(p => filteredPublishersId.Contains(p.PublisherId));
// Complete the query with visibility filtering and execute
var posts = await postsQuery
.FilterWithVisibility(currentUser, userFriends, filter is null ? userPublishers : [], isListing: true)
.Take(take * 5) // Fetch more posts to have a good pool for ranking
.ToListAsync();
posts = await ps.LoadPostInfo(posts, currentUser, true);
var postsId = posts.Select(e => e.Id).ToList();
var reactionMaps = await ps.GetPostReactionMapBatch(postsId);
foreach (var post in posts)
{
post.ReactionsCount =
reactionMaps.TryGetValue(post.Id, out var count) ? count : new Dictionary<string, int>();
// Track view for each post in the feed
await ps.IncreaseViewCount(post.Id, currentUser.Id.ToString());
}
// Rank and sort
var now = SystemClock.Instance.GetCurrentInstant();
var rankedPosts = posts
.Select(p => new { Post = p, Rank = CalculateHotRank(p, now) })
.OrderByDescending(x => x.Rank)
.Select(x => x.Post)
.Take(take)
.ToList();
// Formatting data
foreach (var post in rankedPosts)
activities.Add(post.ToActivity());
if (activities.Count == 0)
activities.Add(Activity.Empty());
return activities;
}
private static double CalculatePopularity(List<Post.Post> posts)
{
var score = posts.Sum(p => p.Upvotes - p.Downvotes);
var postCount = posts.Count;
return score + postCount;
}
private async Task<List<Publisher.Publisher>> GetPopularPublishers(int take)
{
var now = SystemClock.Instance.GetCurrentInstant();
var recent = now.Minus(Duration.FromDays(7));
var posts = await db.Posts
.Where(p => p.PublishedAt > recent)
.ToListAsync();
var publisherIds = posts.Select(p => p.PublisherId).Distinct().ToList();
var publishers = await db.Publishers.Where(p => publisherIds.Contains(p.Id)).ToListAsync();
var rankedPublishers = publishers
.Select(p => new
{
Publisher = p,
Rank = CalculatePopularity(posts.Where(post => post.PublisherId == p.Id).ToList())
})
.OrderByDescending(x => x.Rank)
.Select(x => x.Publisher)
.Take(take)
.ToList();
return rankedPublishers;
}
}

View File

@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using NodaTime;
namespace DysonNetwork.Sphere.Activity;
public class DiscoveryActivity(List<DiscoveryItem> items) : IActivity
{
public List<DiscoveryItem> Items { get; set; } = items;
public Activity ToActivity()
{
var now = SystemClock.Instance.GetCurrentInstant();
return new Activity
{
Id = Guid.NewGuid(),
Type = "discovery",
ResourceIdentifier = "discovery",
Data = this,
CreatedAt = now,
UpdatedAt = now,
};
}
}
public record DiscoveryItem(string Type, object Data);

View File

@ -1,12 +1,30 @@
using System.Linq.Expressions;
using System.Reflection;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Publisher;
using DysonNetwork.Sphere.Realm;
using DysonNetwork.Sphere.Sticker;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Wallet;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Query;
using NodaTime;
using Npgsql;
using Quartz;
namespace DysonNetwork.Sphere;
public interface IIdentifiedResource
{
public string ResourceIdentifier { get; }
}
public abstract class ModelBase
{
public Instant CreatedAt { get; set; }
@ -19,27 +37,112 @@ public class AppDatabase(
IConfiguration configuration
) : DbContext(options)
{
public DbSet<PermissionNode> PermissionNodes { get; set; }
public DbSet<PermissionGroup> PermissionGroups { get; set; }
public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; }
public DbSet<MagicSpell> MagicSpells { get; set; }
public DbSet<Account.Account> Accounts { get; set; }
public DbSet<Account.Profile> AccountProfiles { get; set; }
public DbSet<Account.AccountContact> AccountContacts { get; set; }
public DbSet<Account.AccountAuthFactor> AccountAuthFactors { get; set; }
public DbSet<Account.Relationship> AccountRelationships { get; set; }
public DbSet<Auth.Session> AuthSessions { get; set; }
public DbSet<Auth.Challenge> AuthChallenges { get; set; }
public DbSet<Storage.CloudFile> Files { get; set; }
public DbSet<AccountConnection> AccountConnections { get; set; }
public DbSet<Profile> AccountProfiles { get; set; }
public DbSet<AccountContact> AccountContacts { get; set; }
public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; }
public DbSet<Relationship> AccountRelationships { get; set; }
public DbSet<Status> AccountStatuses { get; set; }
public DbSet<CheckInResult> AccountCheckInResults { get; set; }
public DbSet<Notification> Notifications { get; set; }
public DbSet<NotificationPushSubscription> NotificationPushSubscriptions { get; set; }
public DbSet<Badge> Badges { get; set; }
public DbSet<ActionLog> ActionLogs { get; set; }
public DbSet<AbuseReport> AbuseReports { get; set; }
public DbSet<Session> AuthSessions { get; set; }
public DbSet<Challenge> AuthChallenges { get; set; }
public DbSet<CloudFile> Files { get; set; }
public DbSet<CloudFileReference> FileReferences { get; set; }
public DbSet<Publisher.Publisher> Publishers { get; set; }
public DbSet<PublisherMember> PublisherMembers { get; set; }
public DbSet<PublisherSubscription> PublisherSubscriptions { get; set; }
public DbSet<PublisherFeature> PublisherFeatures { get; set; }
public DbSet<Post.Post> Posts { get; set; }
public DbSet<PostReaction> PostReactions { get; set; }
public DbSet<PostTag> PostTags { get; set; }
public DbSet<PostCategory> PostCategories { get; set; }
public DbSet<PostCollection> PostCollections { get; set; }
public DbSet<Realm.Realm> Realms { get; set; }
public DbSet<RealmMember> RealmMembers { get; set; }
public DbSet<Tag> Tags { get; set; }
public DbSet<RealmTag> RealmTags { get; set; }
public DbSet<ChatRoom> ChatRooms { get; set; }
public DbSet<ChatMember> ChatMembers { get; set; }
public DbSet<Message> ChatMessages { get; set; }
public DbSet<RealtimeCall> ChatRealtimeCall { get; set; }
public DbSet<MessageReaction> ChatReactions { get; set; }
public DbSet<Sticker.Sticker> Stickers { get; set; }
public DbSet<StickerPack> StickerPacks { get; set; }
public DbSet<Wallet.Wallet> Wallets { get; set; }
public DbSet<WalletPocket> WalletPockets { get; set; }
public DbSet<Order> PaymentOrders { get; set; }
public DbSet<Transaction> PaymentTransactions { get; set; }
public DbSet<CustomApp> CustomApps { get; set; }
public DbSet<CustomAppSecret> CustomAppSecrets { get; set; }
public DbSet<Subscription> WalletSubscriptions { get; set; }
public DbSet<Coupon> WalletCoupons { get; set; }
public DbSet<Connection.WebReader.WebArticle> WebArticles { get; set; }
public DbSet<Connection.WebReader.WebFeed> WebFeeds { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
var dataSourceBuilder = new NpgsqlDataSourceBuilder(configuration.GetConnectionString("App"));
dataSourceBuilder.EnableDynamicJson();
dataSourceBuilder.UseNodaTime();
var dataSource = dataSourceBuilder.Build();
optionsBuilder.UseNpgsql(
dataSource,
opt => opt.UseNodaTime()
configuration.GetConnectionString("App"),
opt => opt
.ConfigureDataSource(optSource => optSource.EnableDynamicJson())
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
.UseNetTopologySuite()
.UseNodaTime()
).UseSnakeCaseNamingConvention();
optionsBuilder.UseAsyncSeeding(async (context, _, cancellationToken) =>
{
var defaultPermissionGroup = await context.Set<PermissionGroup>()
.FirstOrDefaultAsync(g => g.Key == "default", cancellationToken);
if (defaultPermissionGroup is null)
{
context.Set<PermissionGroup>().Add(new PermissionGroup
{
Key = "default",
Nodes = new List<string>
{
"posts.create",
"posts.react",
"publishers.create",
"files.create",
"chat.create",
"chat.messages.create",
"chat.realtime.create",
"accounts.statuses.create",
"accounts.statuses.update",
"stickers.packs.create",
"stickers.create"
}.Select(permission =>
PermissionService.NewPermissionNode("group:default", "global", permission, true))
.ToList()
});
await context.SaveChangesAsync(cancellationToken);
}
});
optionsBuilder.UseSeeding((context, _) => {});
base.OnConfiguring(optionsBuilder);
}
@ -47,34 +150,165 @@ public class AppDatabase(
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Account.Account>()
.HasOne(a => a.Profile)
.WithOne(p => p.Account)
.HasForeignKey<Account.Profile>(p => p.Id);
modelBuilder.Entity<PermissionGroupMember>()
.HasKey(pg => new { pg.GroupId, pg.Actor });
modelBuilder.Entity<PermissionGroupMember>()
.HasOne(pg => pg.Group)
.WithMany(g => g.Members)
.HasForeignKey(pg => pg.GroupId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Account.Relationship>()
.HasKey(r => new { r.FromAccountId, r.ToAccountId });
modelBuilder.Entity<Account.Relationship>()
.HasOne(r => r.FromAccount)
modelBuilder.Entity<Relationship>()
.HasKey(r => new { FromAccountId = r.AccountId, ToAccountId = r.RelatedId });
modelBuilder.Entity<Relationship>()
.HasOne(r => r.Account)
.WithMany(a => a.OutgoingRelationships)
.HasForeignKey(r => r.FromAccountId);
modelBuilder.Entity<Account.Relationship>()
.HasOne(r => r.ToAccount)
.HasForeignKey(r => r.AccountId);
modelBuilder.Entity<Relationship>()
.HasOne(r => r.Related)
.WithMany(a => a.IncomingRelationships)
.HasForeignKey(r => r.ToAccountId);
.HasForeignKey(r => r.RelatedId);
modelBuilder.Entity<PublisherMember>()
.HasKey(pm => new { pm.PublisherId, pm.AccountId });
modelBuilder.Entity<PublisherMember>()
.HasOne(pm => pm.Publisher)
.WithMany(p => p.Members)
.HasForeignKey(pm => pm.PublisherId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<PublisherMember>()
.HasOne(pm => pm.Account)
.WithMany()
.HasForeignKey(pm => pm.AccountId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<PublisherSubscription>()
.HasOne(ps => ps.Publisher)
.WithMany(p => p.Subscriptions)
.HasForeignKey(ps => ps.PublisherId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<PublisherSubscription>()
.HasOne(ps => ps.Account)
.WithMany()
.HasForeignKey(ps => ps.AccountId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Post.Post>()
.HasGeneratedTsVectorColumn(p => p.SearchVector, "simple", p => new { p.Title, p.Description, p.Content })
.HasIndex(p => p.SearchVector)
.HasMethod("GIN");
modelBuilder.Entity<CustomAppSecret>()
.HasIndex(s => s.Secret)
.IsUnique();
modelBuilder.Entity<CustomApp>()
.HasMany(c => c.Secrets)
.WithOne(s => s.App)
.HasForeignKey(s => s.AppId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Post.Post>()
.HasOne(p => p.RepliedPost)
.WithMany()
.HasForeignKey(p => p.RepliedPostId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Post.Post>()
.HasOne(p => p.ForwardedPost)
.WithMany()
.HasForeignKey(p => p.ForwardedPostId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Post.Post>()
.HasMany(p => p.Tags)
.WithMany(t => t.Posts)
.UsingEntity(j => j.ToTable("post_tag_links"));
modelBuilder.Entity<Post.Post>()
.HasMany(p => p.Categories)
.WithMany(c => c.Posts)
.UsingEntity(j => j.ToTable("post_category_links"));
modelBuilder.Entity<Post.Post>()
.HasMany(p => p.Collections)
.WithMany(c => c.Posts)
.UsingEntity(j => j.ToTable("post_collection_links"));
modelBuilder.Entity<RealmMember>()
.HasKey(pm => new { pm.RealmId, pm.AccountId });
modelBuilder.Entity<RealmMember>()
.HasOne(pm => pm.Realm)
.WithMany(p => p.Members)
.HasForeignKey(pm => pm.RealmId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<RealmMember>()
.HasOne(pm => pm.Account)
.WithMany()
.HasForeignKey(pm => pm.AccountId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<RealmTag>()
.HasKey(rt => new { rt.RealmId, rt.TagId });
modelBuilder.Entity<RealmTag>()
.HasOne(rt => rt.Realm)
.WithMany(r => r.RealmTags)
.HasForeignKey(rt => rt.RealmId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<RealmTag>()
.HasOne(rt => rt.Tag)
.WithMany(t => t.RealmTags)
.HasForeignKey(rt => rt.TagId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<ChatMember>()
.HasKey(pm => new { pm.Id });
modelBuilder.Entity<ChatMember>()
.HasAlternateKey(pm => new { pm.ChatRoomId, pm.AccountId });
modelBuilder.Entity<ChatMember>()
.HasOne(pm => pm.ChatRoom)
.WithMany(p => p.Members)
.HasForeignKey(pm => pm.ChatRoomId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<ChatMember>()
.HasOne(pm => pm.Account)
.WithMany()
.HasForeignKey(pm => pm.AccountId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Message>()
.HasOne(m => m.ForwardedMessage)
.WithMany()
.HasForeignKey(m => m.ForwardedMessageId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Message>()
.HasOne(m => m.RepliedMessage)
.WithMany()
.HasForeignKey(m => m.RepliedMessageId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<RealtimeCall>()
.HasOne(m => m.Room)
.WithMany()
.HasForeignKey(m => m.RoomId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<RealtimeCall>()
.HasOne(m => m.Sender)
.WithMany()
.HasForeignKey(m => m.SenderId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Connection.WebReader.WebFeed>()
.HasIndex(f => f.Url)
.IsUnique();
modelBuilder.Entity<Connection.WebReader.WebArticle>()
.HasIndex(a => a.Url)
.IsUnique();
// Automatically apply soft-delete filter to all entities inheriting BaseModel
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (typeof(ModelBase).IsAssignableFrom(entityType.ClrType))
{
var method = typeof(AppDatabase)
.GetMethod(nameof(SetSoftDeleteFilter),
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!
.MakeGenericMethod(entityType.ClrType);
if (!typeof(ModelBase).IsAssignableFrom(entityType.ClrType)) continue;
var method = typeof(AppDatabase)
.GetMethod(nameof(SetSoftDeleteFilter),
BindingFlags.NonPublic | BindingFlags.Static)!
.MakeGenericMethod(entityType.ClrType);
method.Invoke(null, [modelBuilder]);
}
method.Invoke(null, [modelBuilder]);
}
}
@ -118,9 +352,23 @@ public class AppDatabaseRecyclingJob(AppDatabase db, ILogger<AppDatabaseRecyclin
{
public async Task Execute(IJobExecutionContext context)
{
logger.LogInformation("Deleting soft-deleted records...");
var now = SystemClock.Instance.GetCurrentInstant();
logger.LogInformation("Cleaning up expired records...");
// Expired relationships
var affectedRows = await db.AccountRelationships
.Where(x => x.ExpiredAt != null && x.ExpiredAt <= now)
.ExecuteDeleteAsync();
logger.LogDebug("Removed {Count} records of expired relationships.", affectedRows);
// Expired permission group members
affectedRows = await db.PermissionGroupMembers
.Where(x => x.ExpiredAt != null && x.ExpiredAt <= now)
.ExecuteDeleteAsync();
logger.LogDebug("Removed {Count} records of expired permission group members.", affectedRows);
logger.LogInformation("Deleting soft-deleted records...");
var threshold = now - Duration.FromDays(7);
var entityTypes = db.Model.GetEntityTypes()
@ -172,4 +420,36 @@ public class AppDatabaseFactory : IDesignTimeDbContextFactory<AppDatabase>
var optionsBuilder = new DbContextOptionsBuilder<AppDatabase>();
return new AppDatabase(optionsBuilder.Options, configuration);
}
}
public static class OptionalQueryExtensions
{
public static IQueryable<T> If<T>(
this IQueryable<T> source,
bool condition,
Func<IQueryable<T>, IQueryable<T>> transform
)
{
return condition ? transform(source) : source;
}
public static IQueryable<T> If<T, TP>(
this IIncludableQueryable<T, TP> source,
bool condition,
Func<IIncludableQueryable<T, TP>, IQueryable<T>> transform
)
where T : class
{
return condition ? transform(source) : source;
}
public static IQueryable<T> If<T, TP>(
this IIncludableQueryable<T, IEnumerable<TP>> source,
bool condition,
Func<IIncludableQueryable<T, IEnumerable<TP>>, IQueryable<T>> transform
)
where T : class
{
return condition ? transform(source) : source;
}
}

View File

@ -0,0 +1,279 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Encodings.Web;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Auth.OidcProvider.Options;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Storage.Handlers;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
using System.Text;
using DysonNetwork.Sphere.Auth.OidcProvider.Controllers;
using DysonNetwork.Sphere.Auth.OidcProvider.Services;
using SystemClock = NodaTime.SystemClock;
namespace DysonNetwork.Sphere.Auth;
public static class AuthConstants
{
public const string SchemeName = "DysonToken";
public const string TokenQueryParamName = "tk";
public const string CookieTokenName = "AuthToken";
}
public enum TokenType
{
AuthKey,
ApiKey,
OidcKey,
Unknown
}
public class TokenInfo
{
public string Token { get; set; } = string.Empty;
public TokenType Type { get; set; } = TokenType.Unknown;
}
public class DysonTokenAuthOptions : AuthenticationSchemeOptions;
public class DysonTokenAuthHandler(
IOptionsMonitor<DysonTokenAuthOptions> options,
IConfiguration configuration,
ILoggerFactory logger,
UrlEncoder encoder,
AppDatabase database,
OidcProviderService oidc,
ICacheService cache,
FlushBufferService fbs
)
: AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder)
{
public const string AuthCachePrefix = "auth:";
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var tokenInfo = _ExtractToken(Request);
if (tokenInfo == null || string.IsNullOrEmpty(tokenInfo.Token))
return AuthenticateResult.Fail("No token was provided.");
try
{
var now = SystemClock.Instance.GetCurrentInstant();
// Validate token and extract session ID
if (!ValidateToken(tokenInfo.Token, out var sessionId))
return AuthenticateResult.Fail("Invalid token.");
// Try to get session from cache first
var session = await cache.GetAsync<Session>($"{AuthCachePrefix}{sessionId}");
// If not in cache, load from database
if (session is null)
{
session = await database.AuthSessions
.Where(e => e.Id == sessionId)
.Include(e => e.Challenge)
.Include(e => e.Account)
.ThenInclude(e => e.Profile)
.FirstOrDefaultAsync();
if (session is not null)
{
// Store in cache for future requests
await cache.SetWithGroupsAsync(
$"auth:{sessionId}",
session,
[$"{AccountService.AccountCachePrefix}{session.Account.Id}"],
TimeSpan.FromHours(1)
);
}
}
// Check if the session exists
if (session == null)
return AuthenticateResult.Fail("Session not found.");
// Check if the session is expired
if (session.ExpiredAt.HasValue && session.ExpiredAt.Value < now)
return AuthenticateResult.Fail("Session expired.");
// Store user and session in the HttpContext.Items for easy access in controllers
Context.Items["CurrentUser"] = session.Account;
Context.Items["CurrentSession"] = session;
Context.Items["CurrentTokenType"] = tokenInfo.Type.ToString();
// Create claims from the session
var claims = new List<Claim>
{
new("user_id", session.Account.Id.ToString()),
new("session_id", session.Id.ToString()),
new("token_type", tokenInfo.Type.ToString())
};
// Add scopes as claims
session.Challenge.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope)));
// Add superuser claim if applicable
if (session.Account.IsSuperuser)
claims.Add(new Claim("is_superuser", "1"));
// Create the identity and principal
var identity = new ClaimsIdentity(claims, AuthConstants.SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, AuthConstants.SchemeName);
var lastInfo = new LastActiveInfo
{
Account = session.Account,
Session = session,
SeenAt = SystemClock.Instance.GetCurrentInstant(),
};
fbs.Enqueue(lastInfo);
return AuthenticateResult.Success(ticket);
}
catch (Exception ex)
{
return AuthenticateResult.Fail($"Authentication failed: {ex.Message}");
}
}
private bool ValidateToken(string token, out Guid sessionId)
{
sessionId = Guid.Empty;
try
{
var parts = token.Split('.');
switch (parts.Length)
{
// Handle JWT tokens (3 parts)
case 3:
{
var (isValid, jwtResult) = oidc.ValidateToken(token);
if (!isValid) return false;
var jti = jwtResult?.Claims.FirstOrDefault(c => c.Type == "jti")?.Value;
if (jti is null) return false;
return Guid.TryParse(jti, out sessionId);
}
// Handle compact tokens (2 parts)
case 2:
// Original compact token validation logic
try
{
// Decode the payload
var payloadBytes = Base64UrlDecode(parts[0]);
// Extract session ID
sessionId = new Guid(payloadBytes);
// Load public key for verification
var publicKeyPem = File.ReadAllText(configuration["AuthToken:PublicKeyPath"]!);
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem);
// Verify signature
var signature = Base64UrlDecode(parts[1]);
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
catch
{
return false;
}
break;
default:
return false;
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Token validation failed");
return false;
}
}
private static byte[] Base64UrlDecode(string base64Url)
{
var padded = base64Url
.Replace('-', '+')
.Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
}
return Convert.FromBase64String(padded);
}
private TokenInfo? _ExtractToken(HttpRequest request)
{
// Check for token in query parameters
if (request.Query.TryGetValue(AuthConstants.TokenQueryParamName, out var queryToken))
{
return new TokenInfo
{
Token = queryToken.ToString(),
Type = TokenType.AuthKey
};
}
// Check for token in Authorization header
var authHeader = request.Headers.Authorization.ToString();
if (!string.IsNullOrEmpty(authHeader))
{
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
var token = authHeader["Bearer ".Length..].Trim();
var parts = token.Split('.');
return new TokenInfo
{
Token = token,
Type = parts.Length == 3 ? TokenType.OidcKey : TokenType.AuthKey
};
}
else if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
{
return new TokenInfo
{
Token = authHeader["AtField ".Length..].Trim(),
Type = TokenType.AuthKey
};
}
else if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
{
return new TokenInfo
{
Token = authHeader["AkField ".Length..].Trim(),
Type = TokenType.ApiKey
};
}
}
// Check for token in cookies
if (request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out var cookieToken))
{
return new TokenInfo
{
Token = cookieToken,
Type = cookieToken.Count(c => c == '.') == 2 ? TokenType.OidcKey : TokenType.AuthKey
};
}
return null;
}
}

View File

@ -5,22 +5,26 @@ using Microsoft.AspNetCore.Mvc;
using NodaTime;
using Microsoft.EntityFrameworkCore;
using System.IdentityModel.Tokens.Jwt;
using System.Text.Json;
using DysonNetwork.Sphere.Connection;
namespace DysonNetwork.Sphere.Auth;
[ApiController]
[Route("/auth")]
[Route("/api/auth")]
public class AuthController(
AppDatabase db,
AccountService accounts,
AuthService auth,
IHttpContextAccessor httpContext
GeoIpService geo,
ActionLogService als
) : ControllerBase
{
public class ChallengeRequest
{
[Required] [MaxLength(256)] public string Account { get; set; } = string.Empty;
[MaxLength(512)] public string? DeviceId { get; set; }
[Required] public ChallengePlatform Platform { get; set; }
[Required] [MaxLength(256)] public string Account { get; set; } = null!;
[Required] [MaxLength(512)] public string DeviceId { get; set; } = null!;
public List<string> Audiences { get; set; } = new();
public List<string> Scopes { get; set; } = new();
}
@ -31,8 +35,8 @@ public class AuthController(
var account = await accounts.LookupAccount(request.Account);
if (account is null) return NotFound("Account was not found.");
var ipAddress = httpContext.HttpContext?.Connection.RemoteIpAddress?.ToString();
var userAgent = httpContext.HttpContext?.Request.Headers.UserAgent.ToString();
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
@ -48,50 +52,101 @@ public class AuthController(
var challenge = new Challenge
{
Account = account,
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)),
StepTotal = 1,
StepTotal = await auth.DetectChallengeRisk(Request, account),
Platform = request.Platform,
Audiences = request.Audiences,
Scopes = request.Scopes,
IpAddress = ipAddress,
UserAgent = userAgent,
Location = geo.GetPointFromIp(ipAddress),
DeviceId = request.DeviceId,
AccountId = account.Id
}.Normalize();
await db.AuthChallenges.AddAsync(challenge);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(ActionLogType.ChallengeAttempt,
new Dictionary<string, object> { { "challenge_id", challenge.Id } }, Request, account
);
return challenge;
}
[HttpGet("challenge/{id}/factors")]
[HttpGet("challenge/{id:guid}")]
public async Task<ActionResult<Challenge>> GetChallenge([FromRoute] Guid id)
{
var challenge = await db.AuthChallenges
.Include(e => e.Account)
.ThenInclude(e => e.Profile)
.FirstOrDefaultAsync(e => e.Id == id);
return challenge is null
? NotFound("Auth challenge was not found.")
: challenge;
}
[HttpGet("challenge/{id:guid}/factors")]
public async Task<ActionResult<List<AccountAuthFactor>>> GetChallengeFactors([FromRoute] Guid id)
{
var challenge = await db.AuthChallenges
.Include(e => e.Account)
.Include(e => e.Account.AuthFactors)
.Where(e => e.Id == id).FirstOrDefaultAsync();
.Where(e => e.Id == id)
.FirstOrDefaultAsync();
return challenge is null
? NotFound("Auth challenge was not found.")
: challenge.Account.AuthFactors.ToList();
: challenge.Account.AuthFactors.Where(e => e is { EnabledAt: not null, Trustworthy: >= 1 }).ToList();
}
[HttpPost("challenge/{id:guid}/factors/{factorId:guid}")]
public async Task<ActionResult> RequestFactorCode(
[FromRoute] Guid id,
[FromRoute] Guid factorId,
[FromBody] string? hint
)
{
var challenge = await db.AuthChallenges
.Include(e => e.Account)
.Where(e => e.Id == id).FirstOrDefaultAsync();
if (challenge is null) return NotFound("Auth challenge was not found.");
var factor = await db.AccountAuthFactors
.Where(e => e.Id == factorId)
.Where(e => e.Account == challenge.Account).FirstOrDefaultAsync();
if (factor is null) return NotFound("Auth factor was not found.");
try
{
await accounts.SendFactorCode(challenge.Account, factor, hint);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
return Ok();
}
public class PerformChallengeRequest
{
[Required] public long FactorId { get; set; }
[Required] public Guid FactorId { get; set; }
[Required] public string Password { get; set; } = string.Empty;
}
[HttpPatch("challenge/{id}")]
[HttpPatch("challenge/{id:guid}")]
public async Task<ActionResult<Challenge>> DoChallenge(
[FromRoute] Guid id,
[FromBody] PerformChallengeRequest request
)
{
var challenge = await db.AuthChallenges.FindAsync(id);
var challenge = await db.AuthChallenges.Include(e => e.Account).FirstOrDefaultAsync(e => e.Id == id);
if (challenge is null) return NotFound("Auth challenge was not found.");
var factor = await db.AccountAuthFactors.FindAsync(request.FactorId);
if (factor is null) return NotFound("Auth factor was not found.");
if (factor.EnabledAt is null) return BadRequest("Auth factor is not enabled.");
if (factor.Trustworthy <= 0) return BadRequest("Auth factor is not trustworthy.");
if (challenge.StepRemain == 0) return challenge;
if (challenge.ExpiredAt.HasValue && challenge.ExpiredAt.Value < Instant.FromDateTimeUtc(DateTime.UtcNow))
@ -99,15 +154,49 @@ public class AuthController(
try
{
if (factor.VerifyPassword(request.Password))
if (await accounts.VerifyFactorCode(factor, request.Password))
{
challenge.StepRemain--;
challenge.StepRemain -= factor.Trustworthy;
challenge.StepRemain = Math.Max(0, challenge.StepRemain);
challenge.BlacklistFactors.Add(factor.Id);
db.Update(challenge);
als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess,
new Dictionary<string, object>
{
{ "challenge_id", challenge.Id },
{ "factor_id", factor.Id }
}, Request, challenge.Account
);
}
else
{
throw new ArgumentException("Invalid password.");
}
}
catch
{
return BadRequest();
challenge.FailedAttempts++;
db.Update(challenge);
als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure,
new Dictionary<string, object>
{
{ "challenge_id", challenge.Id },
{ "factor_id", factor.Id }
}, Request, challenge.Account
);
await db.SaveChangesAsync();
return BadRequest("Invalid password.");
}
if (challenge.StepRemain == 0)
{
als.CreateActionLogFromRequest(ActionLogType.NewLogin,
new Dictionary<string, object>
{
{ "challenge_id", challenge.Id },
{ "account_id", challenge.AccountId }
}, Request, challenge.Account
);
}
await db.SaveChangesAsync();
@ -121,10 +210,14 @@ public class AuthController(
public string? Code { get; set; }
}
[HttpPost("token")]
public async Task<ActionResult<SignedTokenPair>> ExchangeToken([FromBody] TokenExchangeRequest request)
public class TokenExchangeResponse
{
public string Token { get; set; } = string.Empty;
}
[HttpPost("token")]
public async Task<ActionResult<TokenExchangeResponse>> ExchangeToken([FromBody] TokenExchangeRequest request)
{
Session? session;
switch (request.GrantType)
{
case "authorization_code":
@ -140,7 +233,7 @@ public class AuthController(
if (challenge.StepRemain != 0)
return BadRequest("Challenge not yet completed.");
session = await db.AuthSessions
var session = await db.AuthSessions
.Where(e => e.Challenge == challenge)
.FirstOrDefaultAsync();
if (session is not null)
@ -157,42 +250,20 @@ public class AuthController(
db.AuthSessions.Add(session);
await db.SaveChangesAsync();
return auth.CreateToken(session);
var tk = auth.CreateToken(session);
return Ok(new TokenExchangeResponse { Token = tk });
case "refresh_token":
var handler = new JwtSecurityTokenHandler();
var token = handler.ReadJwtToken(request.RefreshToken);
var sessionIdClaim = token.Claims.FirstOrDefault(c => c.Type == "session_id")?.Value;
if (!Guid.TryParse(sessionIdClaim, out var sessionId))
return Unauthorized("Invalid or missing session_id claim in refresh token.");
session = await db.AuthSessions
.Include(e => e.Account)
.Include(e => e.Challenge)
.FirstOrDefaultAsync(s => s.Id == sessionId);
if (session is null)
return NotFound("Session not found or expired.");
session.LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow);
await db.SaveChangesAsync();
return auth.CreateToken(session);
// Since we no longer need the refresh token
// This case is blank for now, thinking to mock it if the OIDC standard requires it
default:
return BadRequest("Unsupported grant type.");
}
}
[Authorize]
[HttpGet("test")]
public async Task<ActionResult> Test()
[HttpPost("captcha")]
public async Task<ActionResult> ValidateCaptcha([FromBody] string token)
{
var sessionIdClaim = httpContext.HttpContext?.User.FindFirst("session_id")?.Value;
if (!Guid.TryParse(sessionIdClaim, out var sessionId))
return Unauthorized();
var session = await db.AuthSessions.FirstOrDefaultAsync(s => s.Id == sessionId);
if (session is null) return NotFound();
return Ok(session);
var result = await auth.ValidateCaptcha(token);
return result ? Ok() : BadRequest();
}
}

View File

@ -1,123 +1,304 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using Casbin;
using Microsoft.AspNetCore.Authorization;
using Microsoft.IdentityModel.Tokens;
using System.Text.Json;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Auth;
public class SignedTokenPair
public class AuthService(
AppDatabase db,
IConfiguration config,
IHttpClientFactory httpClientFactory,
IHttpContextAccessor httpContextAccessor,
ICacheService cache
)
{
public string AccessToken { get; set; } = null!;
public string RefreshToken { get; set; } = null!;
public Instant ExpiredAt { get; set; }
}
private HttpContext HttpContext => httpContextAccessor.HttpContext!;
public class AuthService(AppDatabase db, IConfiguration config, IEnforcer enforcer)
{
public async Task<bool> AssignRoleToUserAsync(string user, string role, string domain = "global")
/// <summary>
/// Detect the risk of the current request to login
/// and returns the required steps to login.
/// </summary>
/// <param name="request">The request context</param>
/// <param name="account">The account to login</param>
/// <returns>The required steps to login</returns>
public async Task<int> DetectChallengeRisk(HttpRequest request, Account.Account account)
{
var added = await enforcer.AddGroupingPolicyAsync(user, role, domain);
if (added) await enforcer.SavePolicyAsync();
return added;
}
// 1) Find out how many authentication factors the account has enabled.
var maxSteps = await db.AccountAuthFactors
.Where(f => f.AccountId == account.Id)
.Where(f => f.EnabledAt != null)
.CountAsync();
public async Task<bool> AddPermissionToUserAsync(string user, string domain, string obj, string act)
{
var added = await enforcer.AddPolicyAsync(user, domain, obj, act);
if (added) await enforcer.SavePolicyAsync();
return added;
}
public async Task<bool> RemovePermissionFromUserAsync(string user, string domain, string obj, string act)
{
var removed = await enforcer.RemovePolicyAsync(user, domain, obj, act);
if (removed) await enforcer.SavePolicyAsync();
return removed;
}
// Well accumulate a “risk score” based on various factors.
// Then we can decide how many total steps are required for the challenge.
var riskScore = 0;
public async Task<bool> CreateRoleAsync(string role, string domain, IEnumerable<(string obj, string act)> permissions)
{
bool anyAdded = false;
foreach (var (obj, act) in permissions)
// 2) Get the remote IP address from the request (if any).
var ipAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString();
var lastActiveInfo = await db.AuthSessions
.OrderByDescending(s => s.LastGrantedAt)
.Include(s => s.Challenge)
.Where(s => s.AccountId == account.Id)
.FirstOrDefaultAsync();
// Example check: if IP is missing or in an unusual range, increase the risk.
// (This is just a placeholder; in reality, youd integrate with GeoIpService or a custom check.)
if (string.IsNullOrWhiteSpace(ipAddress))
riskScore += 1;
else
{
var added = await enforcer.AddPolicyAsync(role, domain, obj, act);
if (added) anyAdded = true;
if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge.IpAddress) &&
!lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase))
riskScore += 1;
}
if (anyAdded) await enforcer.SavePolicyAsync();
return anyAdded;
// 3) (Optional) Check how recent the last login was.
// If it was a long time ago, the risk might be higher.
var now = SystemClock.Instance.GetCurrentInstant();
var daysSinceLastActive = lastActiveInfo?.LastGrantedAt is not null
? (now - lastActiveInfo.LastGrantedAt.Value).TotalDays
: double.MaxValue;
if (daysSinceLastActive > 30)
riskScore += 1;
// 4) Combine base “maxSteps” (the number of enabled factors) with any accumulated risk score.
const int totalRiskScore = 3;
var totalRequiredSteps = (int)Math.Round((float)maxSteps * riskScore / totalRiskScore);
// Clamp the steps
totalRequiredSteps = Math.Max(Math.Min(totalRequiredSteps, maxSteps), 1);
return totalRequiredSteps;
}
public async Task<bool> AddPermissionsToRoleAsync(string role, string domain, IEnumerable<(string obj, string act)> permissions)
public async Task<Session> CreateSessionForOidcAsync(Account.Account account, Instant time, Guid? customAppId = null)
{
bool anyAdded = false;
foreach (var (obj, act) in permissions)
var challenge = new Challenge
{
var added = await enforcer.AddPolicyAsync(role, domain, obj, act);
if (added) anyAdded = true;
}
AccountId = account.Id,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
UserAgent = HttpContext.Request.Headers.UserAgent,
StepRemain = 1,
StepTotal = 1,
Type = customAppId is not null ? ChallengeType.OAuth : ChallengeType.Oidc
};
if (anyAdded) await enforcer.SavePolicyAsync();
return anyAdded;
}
public async Task<bool> RemovePermissionsFromRoleAsync(string role, string domain, IEnumerable<(string obj, string act)> permissions)
{
bool anyRemoved = false;
foreach (var (obj, act) in permissions)
var session = new Session
{
var removed = await enforcer.RemovePolicyAsync(role, domain, obj, act);
if (removed) anyRemoved = true;
}
AccountId = account.Id,
CreatedAt = time,
LastGrantedAt = time,
Challenge = challenge,
AppId = customAppId
};
if (anyRemoved) await enforcer.SavePolicyAsync();
return anyRemoved;
db.AuthChallenges.Add(challenge);
db.AuthSessions.Add(session);
await db.SaveChangesAsync();
return session;
}
public SignedTokenPair CreateToken(Session session)
public async Task<bool> ValidateCaptcha(string token)
{
var privateKeyPem = File.ReadAllText(config["Jwt:PrivateKeyPath"]!);
var rsa = RSA.Create();
if (string.IsNullOrWhiteSpace(token)) return false;
var provider = config.GetSection("Captcha")["Provider"]?.ToLower();
var apiSecret = config.GetSection("Captcha")["ApiSecret"];
var client = httpClientFactory.CreateClient();
var jsonOpts = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower
};
switch (provider)
{
case "cloudflare":
var content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
"application/x-www-form-urlencoded");
var response = await client.PostAsync("https://challenges.cloudflare.com/turnstile/v0/siteverify",
content);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts);
return result?.Success == true;
case "google":
content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
"application/x-www-form-urlencoded");
response = await client.PostAsync("https://www.google.com/recaptcha/siteverify", content);
response.EnsureSuccessStatusCode();
json = await response.Content.ReadAsStringAsync();
result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts);
return result?.Success == true;
case "hcaptcha":
content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
"application/x-www-form-urlencoded");
response = await client.PostAsync("https://hcaptcha.com/siteverify", content);
response.EnsureSuccessStatusCode();
json = await response.Content.ReadAsStringAsync();
result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts);
return result?.Success == true;
default:
throw new ArgumentException("The server misconfigured for the captcha.");
}
}
public string CreateToken(Session session)
{
// Load the private key for signing
var privateKeyPem = File.ReadAllText(config["AuthToken:PrivateKeyPath"]!);
using var rsa = RSA.Create();
rsa.ImportFromPem(privateKeyPem);
var key = new RsaSecurityKey(rsa);
var creds = new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
var claims = new List<Claim>
{
new("user_id", session.Account.Id.ToString()),
new("session_id", session.Id.ToString())
};
// Create and return a single token
return CreateCompactToken(session.Id, rsa);
}
var refreshTokenClaims = new JwtSecurityToken(
issuer: "solar-network",
audience: string.Join(',', session.Challenge.Audiences),
claims: claims,
expires: DateTime.Now.AddDays(30),
signingCredentials: creds
);
private string CreateCompactToken(Guid sessionId, RSA rsa)
{
// Create the payload: just the session ID
var payloadBytes = sessionId.ToByteArray();
// Base64Url encode the payload
var payloadBase64 = Base64UrlEncode(payloadBytes);
// Sign the payload with RSA-SHA256
var signature = rsa.SignData(payloadBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
// Base64Url encode the signature
var signatureBase64 = Base64UrlEncode(signature);
// Combine payload and signature with a dot
return $"{payloadBase64}.{signatureBase64}";
}
public async Task<bool> ValidateSudoMode(Session session, string? pinCode)
{
// Check if the session is already in sudo mode (cached)
var sudoModeKey = $"accounts:{session.Id}:sudo";
var (found, _) = await cache.GetAsyncWithStatus<bool>(sudoModeKey);
session.Challenge.Scopes.ForEach(c => claims.Add(new Claim("scope", c)));
if(session.Account.IsSuperuser) claims.Add(new Claim("is_superuser", "1"));
var accessTokenClaims = new JwtSecurityToken(
issuer: "solar-network",
audience: string.Join(',', session.Challenge.Audiences),
claims: claims,
expires: DateTime.Now.AddMinutes(30),
signingCredentials: creds
);
var handler = new JwtSecurityTokenHandler();
var accessToken = handler.WriteToken(accessTokenClaims);
var refreshToken = handler.WriteToken(refreshTokenClaims);
return new SignedTokenPair
if (found)
{
AccessToken = accessToken,
RefreshToken = refreshToken,
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddMinutes(30))
};
// Session is already in sudo mode
return true;
}
// Check if the user has a pin code
var hasPinCode = await db.AccountAuthFactors
.Where(f => f.AccountId == session.AccountId)
.Where(f => f.EnabledAt != null)
.Where(f => f.Type == AccountAuthFactorType.PinCode)
.AnyAsync();
if (!hasPinCode)
{
// User doesn't have a pin code, no validation needed
return true;
}
// If pin code is not provided, we can't validate
if (string.IsNullOrEmpty(pinCode))
{
return false;
}
try
{
// Validate the pin code
var isValid = await ValidatePinCode(session.AccountId, pinCode);
if (isValid)
{
// Set session in sudo mode for 5 minutes
await cache.SetAsync(sudoModeKey, true, TimeSpan.FromMinutes(5));
}
return isValid;
}
catch (InvalidOperationException)
{
// No pin code enabled for this account, so validation is successful
return true;
}
}
public async Task<bool> ValidatePinCode(Guid accountId, string pinCode)
{
var factor = await db.AccountAuthFactors
.Where(f => f.AccountId == accountId)
.Where(f => f.EnabledAt != null)
.Where(f => f.Type == AccountAuthFactorType.PinCode)
.FirstOrDefaultAsync();
if (factor is null) throw new InvalidOperationException("No pin code enabled for this account.");
return factor.VerifyPassword(pinCode);
}
public bool ValidateToken(string token, out Guid sessionId)
{
sessionId = Guid.Empty;
try
{
// Split the token
var parts = token.Split('.');
if (parts.Length != 2)
return false;
// Decode the payload
var payloadBytes = Base64UrlDecode(parts[0]);
// Extract session ID
sessionId = new Guid(payloadBytes);
// Load public key for verification
var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!);
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem);
// Verify signature
var signature = Base64UrlDecode(parts[1]);
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
catch
{
return false;
}
}
// Helper methods for Base64Url encoding/decoding
private static string Base64UrlEncode(byte[] data)
{
return Convert.ToBase64String(data)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
private static byte[] Base64UrlDecode(string base64Url)
{
string padded = base64Url
.Replace('-', '+')
.Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
}
return Convert.FromBase64String(padded);
}
}

View File

@ -1,34 +0,0 @@
using Casbin;
using Microsoft.AspNetCore.Authorization;
namespace DysonNetwork.Sphere.Auth;
public class CasbinRequirement(string domain, string obj, string act) : IAuthorizationRequirement
{
public string Domain { get; } = domain;
public string Object { get; } = obj;
public string Action { get; } = act;
}
public class CasbinAuthorizationHandler(IEnforcer enforcer)
: AuthorizationHandler<CasbinRequirement>
{
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
CasbinRequirement requirement)
{
var userId = context.User.FindFirst("user_id")?.Value;
if (userId == null) return;
var isSuperuser = context.User.FindFirst("is_superuser")?.Value == "1";
if (isSuperuser) userId = "super:" + userId;
var allowed = await enforcer.EnforceAsync(
userId,
requirement.Domain,
requirement.Object,
requirement.Action
);
if (allowed) context.Succeed(requirement);
}
}

View File

@ -0,0 +1,6 @@
namespace DysonNetwork.Sphere.Auth;
public class CaptchaVerificationResponse
{
public bool Success { get; set; }
}

View File

@ -0,0 +1,94 @@
using System.Security.Cryptography;
namespace DysonNetwork.Sphere.Auth;
public class CompactTokenService(IConfiguration config)
{
private readonly string _privateKeyPath = config["AuthToken:PrivateKeyPath"]
?? throw new InvalidOperationException("AuthToken:PrivateKeyPath configuration is missing");
public string CreateToken(Session session)
{
// Load the private key for signing
var privateKeyPem = File.ReadAllText(_privateKeyPath);
using var rsa = RSA.Create();
rsa.ImportFromPem(privateKeyPem);
// Create and return a single token
return CreateCompactToken(session.Id, rsa);
}
private string CreateCompactToken(Guid sessionId, RSA rsa)
{
// Create the payload: just the session ID
var payloadBytes = sessionId.ToByteArray();
// Base64Url encode the payload
var payloadBase64 = Base64UrlEncode(payloadBytes);
// Sign the payload with RSA-SHA256
var signature = rsa.SignData(payloadBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
// Base64Url encode the signature
var signatureBase64 = Base64UrlEncode(signature);
// Combine payload and signature with a dot
return $"{payloadBase64}.{signatureBase64}";
}
public bool ValidateToken(string token, out Guid sessionId)
{
sessionId = Guid.Empty;
try
{
// Split the token
var parts = token.Split('.');
if (parts.Length != 2)
return false;
// Decode the payload
var payloadBytes = Base64UrlDecode(parts[0]);
// Extract session ID
sessionId = new Guid(payloadBytes);
// Load public key for verification
var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!);
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem);
// Verify signature
var signature = Base64UrlDecode(parts[1]);
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
catch
{
return false;
}
}
// Helper methods for Base64Url encoding/decoding
private static string Base64UrlEncode(byte[] data)
{
return Convert.ToBase64String(data)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
private static byte[] Base64UrlDecode(string base64Url)
{
string padded = base64Url
.Replace('-', '+')
.Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
}
return Convert.FromBase64String(padded);
}
}

View File

@ -0,0 +1,242 @@
using System.Security.Cryptography;
using System.Text;
using DysonNetwork.Sphere.Auth.OidcProvider.Options;
using DysonNetwork.Sphere.Auth.OidcProvider.Responses;
using DysonNetwork.Sphere.Auth.OidcProvider.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Account;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Controllers;
[Route("/api/auth/open")]
[ApiController]
public class OidcProviderController(
AppDatabase db,
OidcProviderService oidcService,
IConfiguration configuration,
IOptions<OidcProviderOptions> options,
ILogger<OidcProviderController> logger
)
: ControllerBase
{
[HttpPost("token")]
[Consumes("application/x-www-form-urlencoded")]
public async Task<IActionResult> Token([FromForm] TokenRequest request)
{
switch (request.GrantType)
{
// Validate client credentials
case "authorization_code" when request.ClientId == null || string.IsNullOrEmpty(request.ClientSecret):
return BadRequest("Client credentials are required");
case "authorization_code" when request.Code == null:
return BadRequest("Authorization code is required");
case "authorization_code":
{
var client = await oidcService.FindClientByIdAsync(request.ClientId.Value);
if (client == null ||
!await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret))
return BadRequest(new ErrorResponse
{ Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
// Generate tokens
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
clientId: request.ClientId.Value,
authorizationCode: request.Code!,
redirectUri: request.RedirectUri,
codeVerifier: request.CodeVerifier
);
return Ok(tokenResponse);
}
case "refresh_token" when string.IsNullOrEmpty(request.RefreshToken):
return BadRequest(new ErrorResponse
{ Error = "invalid_request", ErrorDescription = "Refresh token is required" });
case "refresh_token":
{
try
{
// Decode the base64 refresh token to get the session ID
var sessionIdBytes = Convert.FromBase64String(request.RefreshToken);
var sessionId = new Guid(sessionIdBytes);
// Find the session and related data
var session = await oidcService.FindSessionByIdAsync(sessionId);
var now = SystemClock.Instance.GetCurrentInstant();
if (session?.App is null || session.ExpiredAt < now)
{
return BadRequest(new ErrorResponse
{
Error = "invalid_grant",
ErrorDescription = "Invalid or expired refresh token"
});
}
// Get the client
var client = session.App;
if (client == null)
{
return BadRequest(new ErrorResponse
{
Error = "invalid_client",
ErrorDescription = "Client not found"
});
}
// Generate new tokens
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
clientId: session.AppId!.Value,
sessionId: session.Id
);
return Ok(tokenResponse);
}
catch (FormatException)
{
return BadRequest(new ErrorResponse
{
Error = "invalid_grant",
ErrorDescription = "Invalid refresh token format"
});
}
}
default:
return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" });
}
}
[HttpGet("userinfo")]
[Authorize]
public async Task<IActionResult> GetUserInfo()
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser ||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
// Get requested scopes from the token
var scopes = currentSession.Challenge.Scopes;
var userInfo = new Dictionary<string, object>
{
["sub"] = currentUser.Id
};
// Include standard claims based on scopes
if (scopes.Contains("profile") || scopes.Contains("name"))
{
userInfo["name"] = currentUser.Name;
userInfo["preferred_username"] = currentUser.Nick;
}
var userEmail = await db.AccountContacts
.Where(c => c.Type == AccountContactType.Email && c.AccountId == currentUser.Id)
.FirstOrDefaultAsync();
if (scopes.Contains("email") && userEmail is not null)
{
userInfo["email"] = userEmail.Content;
userInfo["email_verified"] = userEmail.VerifiedAt is not null;
}
return Ok(userInfo);
}
[HttpGet("/.well-known/openid-configuration")]
public IActionResult GetConfiguration()
{
var baseUrl = configuration["BaseUrl"];
var issuer = options.Value.IssuerUri.TrimEnd('/');
return Ok(new
{
issuer = issuer,
authorization_endpoint = $"{baseUrl}/auth/authorize",
token_endpoint = $"{baseUrl}/auth/open/token",
userinfo_endpoint = $"{baseUrl}/auth/open/userinfo",
jwks_uri = $"{baseUrl}/.well-known/jwks",
scopes_supported = new[] { "openid", "profile", "email" },
response_types_supported = new[]
{ "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token" },
grant_types_supported = new[] { "authorization_code", "refresh_token" },
token_endpoint_auth_methods_supported = new[] { "client_secret_basic", "client_secret_post" },
id_token_signing_alg_values_supported = new[] { "HS256" },
subject_types_supported = new[] { "public" },
claims_supported = new[] { "sub", "name", "email", "email_verified" },
code_challenge_methods_supported = new[] { "S256" },
response_modes_supported = new[] { "query", "fragment", "form_post" },
request_parameter_supported = true,
request_uri_parameter_supported = true,
require_request_uri_registration = false
});
}
[HttpGet("/.well-known/jwks")]
public IActionResult GetJwks()
{
using var rsa = options.Value.GetRsaPublicKey();
if (rsa == null)
{
return BadRequest("Public key is not configured");
}
var parameters = rsa.ExportParameters(false);
var keyId = Convert.ToBase64String(SHA256.HashData(parameters.Modulus!)[..8])
.Replace("+", "-")
.Replace("/", "_")
.Replace("=", "");
return Ok(new
{
keys = new[]
{
new
{
kty = "RSA",
use = "sig",
kid = keyId,
n = Base64UrlEncoder.Encode(parameters.Modulus!),
e = Base64UrlEncoder.Encode(parameters.Exponent!),
alg = "RS256"
}
}
});
}
}
public class TokenRequest
{
[JsonPropertyName("grant_type")]
[FromForm(Name = "grant_type")]
public string? GrantType { get; set; }
[JsonPropertyName("code")]
[FromForm(Name = "code")]
public string? Code { get; set; }
[JsonPropertyName("redirect_uri")]
[FromForm(Name = "redirect_uri")]
public string? RedirectUri { get; set; }
[JsonPropertyName("client_id")]
[FromForm(Name = "client_id")]
public Guid? ClientId { get; set; }
[JsonPropertyName("client_secret")]
[FromForm(Name = "client_secret")]
public string? ClientSecret { get; set; }
[JsonPropertyName("refresh_token")]
[FromForm(Name = "refresh_token")]
public string? RefreshToken { get; set; }
[JsonPropertyName("scope")]
[FromForm(Name = "scope")]
public string? Scope { get; set; }
[JsonPropertyName("code_verifier")]
[FromForm(Name = "code_verifier")]
public string? CodeVerifier { get; set; }
}

View File

@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
using NodaTime;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Models;
public class AuthorizationCodeInfo
{
public Guid ClientId { get; set; }
public Guid AccountId { get; set; }
public string RedirectUri { get; set; } = string.Empty;
public List<string> Scopes { get; set; } = new();
public string? CodeChallenge { get; set; }
public string? CodeChallengeMethod { get; set; }
public string? Nonce { get; set; }
public Instant CreatedAt { get; set; }
}

View File

@ -0,0 +1,36 @@
using System.Security.Cryptography;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Options;
public class OidcProviderOptions
{
public string IssuerUri { get; set; } = "https://your-issuer-uri.com";
public string? PublicKeyPath { get; set; }
public string? PrivateKeyPath { get; set; }
public TimeSpan AccessTokenLifetime { get; set; } = TimeSpan.FromHours(1);
public TimeSpan RefreshTokenLifetime { get; set; } = TimeSpan.FromDays(30);
public TimeSpan AuthorizationCodeLifetime { get; set; } = TimeSpan.FromMinutes(5);
public bool RequireHttpsMetadata { get; set; } = true;
public RSA? GetRsaPrivateKey()
{
if (string.IsNullOrEmpty(PrivateKeyPath) || !File.Exists(PrivateKeyPath))
return null;
var privateKey = File.ReadAllText(PrivateKeyPath);
var rsa = RSA.Create();
rsa.ImportFromPem(privateKey.AsSpan());
return rsa;
}
public RSA? GetRsaPublicKey()
{
if (string.IsNullOrEmpty(PublicKeyPath) || !File.Exists(PublicKeyPath))
return null;
var publicKey = File.ReadAllText(PublicKeyPath);
var rsa = RSA.Create();
rsa.ImportFromPem(publicKey.AsSpan());
return rsa;
}
}

View File

@ -0,0 +1,23 @@
using System.Text.Json.Serialization;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses;
public class AuthorizationResponse
{
[JsonPropertyName("code")]
public string Code { get; set; } = null!;
[JsonPropertyName("state")]
public string? State { get; set; }
[JsonPropertyName("scope")]
public string? Scope { get; set; }
[JsonPropertyName("session_state")]
public string? SessionState { get; set; }
[JsonPropertyName("iss")]
public string? Issuer { get; set; }
}

View File

@ -0,0 +1,20 @@
using System.Text.Json.Serialization;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses;
public class ErrorResponse
{
[JsonPropertyName("error")]
public string Error { get; set; } = null!;
[JsonPropertyName("error_description")]
public string? ErrorDescription { get; set; }
[JsonPropertyName("error_uri")]
public string? ErrorUri { get; set; }
[JsonPropertyName("state")]
public string? State { get; set; }
}

View File

@ -0,0 +1,26 @@
using System.Text.Json.Serialization;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses;
public class TokenResponse
{
[JsonPropertyName("access_token")]
public string AccessToken { get; set; } = null!;
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; set; }
[JsonPropertyName("token_type")]
public string TokenType { get; set; } = "Bearer";
[JsonPropertyName("refresh_token")]
public string? RefreshToken { get; set; }
[JsonPropertyName("scope")]
public string? Scope { get; set; }
[JsonPropertyName("id_token")]
public string? IdToken { get; set; }
}

View File

@ -0,0 +1,395 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using DysonNetwork.Sphere.Auth.OidcProvider.Models;
using DysonNetwork.Sphere.Auth.OidcProvider.Options;
using DysonNetwork.Sphere.Auth.OidcProvider.Responses;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Services;
public class OidcProviderService(
AppDatabase db,
AuthService auth,
ICacheService cache,
IOptions<OidcProviderOptions> options,
ILogger<OidcProviderService> logger
)
{
private readonly OidcProviderOptions _options = options.Value;
public async Task<CustomApp?> FindClientByIdAsync(Guid clientId)
{
return await db.CustomApps
.Include(c => c.Secrets)
.FirstOrDefaultAsync(c => c.Id == clientId);
}
public async Task<CustomApp?> FindClientByAppIdAsync(Guid appId)
{
return await db.CustomApps
.Include(c => c.Secrets)
.FirstOrDefaultAsync(c => c.Id == appId);
}
public async Task<Session?> FindValidSessionAsync(Guid accountId, Guid clientId)
{
var now = SystemClock.Instance.GetCurrentInstant();
return await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.AccountId == accountId &&
s.AppId == clientId &&
(s.ExpiredAt == null || s.ExpiredAt > now) &&
s.Challenge.Type == ChallengeType.OAuth)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync();
}
public async Task<bool> ValidateClientCredentialsAsync(Guid clientId, string clientSecret)
{
var client = await FindClientByIdAsync(clientId);
if (client == null) return false;
var clock = SystemClock.Instance;
var secret = client.Secrets
.Where(s => s.IsOidc && (s.ExpiredAt == null || s.ExpiredAt > clock.GetCurrentInstant()))
.FirstOrDefault(s => s.Secret == clientSecret); // In production, use proper hashing
return secret != null;
}
public async Task<TokenResponse> GenerateTokenResponseAsync(
Guid clientId,
string? authorizationCode = null,
string? redirectUri = null,
string? codeVerifier = null,
Guid? sessionId = null
)
{
var client = await FindClientByIdAsync(clientId);
if (client == null)
throw new InvalidOperationException("Client not found");
Session session;
var clock = SystemClock.Instance;
var now = clock.GetCurrentInstant();
List<string>? scopes = null;
if (authorizationCode != null)
{
// Authorization code flow
var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier);
if (authCode is null) throw new InvalidOperationException("Invalid authorization code");
var account = await db.Accounts.Where(a => a.Id == authCode.AccountId).FirstOrDefaultAsync();
if (account is null) throw new InvalidOperationException("Account was not found");
session = await auth.CreateSessionForOidcAsync(account, now, client.Id);
scopes = authCode.Scopes;
}
else if (sessionId.HasValue)
{
// Refresh token flow
session = await FindSessionByIdAsync(sessionId.Value) ??
throw new InvalidOperationException("Invalid session");
// Verify the session is still valid
if (session.ExpiredAt < now)
throw new InvalidOperationException("Session has expired");
}
else
{
throw new InvalidOperationException("Either authorization code or session ID must be provided");
}
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
// Generate an access token
var accessToken = GenerateJwtToken(client, session, expiresAt, scopes);
var refreshToken = GenerateRefreshToken(session);
return new TokenResponse
{
AccessToken = accessToken,
ExpiresIn = expiresIn,
TokenType = "Bearer",
RefreshToken = refreshToken,
Scope = scopes != null ? string.Join(" ", scopes) : null
};
}
private string GenerateJwtToken(
CustomApp client,
Session session,
Instant expiresAt,
IEnumerable<string>? scopes = null
)
{
var tokenHandler = new JwtSecurityTokenHandler();
var clock = SystemClock.Instance;
var now = clock.GetCurrentInstant();
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity([
new Claim(JwtRegisteredClaimNames.Sub, session.AccountId.ToString()),
new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(),
ClaimValueTypes.Integer64),
new Claim("client_id", client.Id.ToString())
]),
Expires = expiresAt.ToDateTimeUtc(),
Issuer = _options.IssuerUri,
Audience = client.Id.ToString()
};
// Try to use RSA signing if keys are available, fall back to HMAC
var rsaPrivateKey = _options.GetRsaPrivateKey();
tokenDescriptor.SigningCredentials = new SigningCredentials(
new RsaSecurityKey(rsaPrivateKey),
SecurityAlgorithms.RsaSha256
);
// Add scopes as claims if provided
var effectiveScopes = scopes?.ToList() ?? client.OauthConfig!.AllowedScopes?.ToList() ?? [];
if (effectiveScopes.Count != 0)
{
tokenDescriptor.Subject.AddClaims(
effectiveScopes.Select(scope => new Claim("scope", scope)));
}
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
public (bool isValid, JwtSecurityToken? token) ValidateToken(string token)
{
try
{
var tokenHandler = new JwtSecurityTokenHandler();
var validationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = _options.IssuerUri,
ValidateAudience = false,
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero
};
// Try to use RSA validation if public key is available
var rsaPublicKey = _options.GetRsaPublicKey();
validationParameters.IssuerSigningKey = new RsaSecurityKey(rsaPublicKey);
validationParameters.ValidateIssuerSigningKey = true;
validationParameters.ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 };
tokenHandler.ValidateToken(token, validationParameters, out var validatedToken);
return (true, (JwtSecurityToken)validatedToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Token validation failed");
return (false, null);
}
}
public async Task<Session?> FindSessionByIdAsync(Guid sessionId)
{
return await db.AuthSessions
.Include(s => s.Account)
.Include(s => s.Challenge)
.Include(s => s.App)
.FirstOrDefaultAsync(s => s.Id == sessionId);
}
private static string GenerateRefreshToken(Session session)
{
return Convert.ToBase64String(session.Id.ToByteArray());
}
private static bool VerifyHashedSecret(string secret, string hashedSecret)
{
// In a real implementation, you'd use a proper password hashing algorithm like PBKDF2, bcrypt, or Argon2
// For now, we'll do a simple comparison, but you should replace this with proper hashing
return string.Equals(secret, hashedSecret, StringComparison.Ordinal);
}
public async Task<string> GenerateAuthorizationCodeForReuseSessionAsync(
Session session,
Guid clientId,
string redirectUri,
IEnumerable<string> scopes,
string? codeChallenge = null,
string? codeChallengeMethod = null,
string? nonce = null)
{
var clock = SystemClock.Instance;
var now = clock.GetCurrentInstant();
var code = Guid.NewGuid().ToString("N");
// Update the session's last activity time
await db.AuthSessions.Where(s => s.Id == session.Id)
.ExecuteUpdateAsync(s => s.SetProperty(s => s.LastGrantedAt, now));
// Create the authorization code info
var authCodeInfo = new AuthorizationCodeInfo
{
ClientId = clientId,
AccountId = session.AccountId,
RedirectUri = redirectUri,
Scopes = scopes.ToList(),
CodeChallenge = codeChallenge,
CodeChallengeMethod = codeChallengeMethod,
Nonce = nonce,
CreatedAt = now
};
// Store the code with its metadata in the cache
var cacheKey = $"auth:code:{code}";
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, session.AccountId);
return code;
}
public async Task<string> GenerateAuthorizationCodeAsync(
Guid clientId,
Guid userId,
string redirectUri,
IEnumerable<string> scopes,
string? codeChallenge = null,
string? codeChallengeMethod = null,
string? nonce = null
)
{
// Generate a random code
var clock = SystemClock.Instance;
var code = GenerateRandomString(32);
var now = clock.GetCurrentInstant();
// Create the authorization code info
var authCodeInfo = new AuthorizationCodeInfo
{
ClientId = clientId,
AccountId = userId,
RedirectUri = redirectUri,
Scopes = scopes.ToList(),
CodeChallenge = codeChallenge,
CodeChallengeMethod = codeChallengeMethod,
Nonce = nonce,
CreatedAt = now
};
// Store the code with its metadata in the cache
var cacheKey = $"auth:code:{code}";
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId);
return code;
}
private async Task<AuthorizationCodeInfo?> ValidateAuthorizationCodeAsync(
string code,
Guid clientId,
string? redirectUri = null,
string? codeVerifier = null
)
{
var cacheKey = $"auth:code:{code}";
var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey);
if (!found || authCode == null)
{
logger.LogWarning("Authorization code not found: {Code}", code);
return null;
}
// Verify client ID matches
if (authCode.ClientId != clientId)
{
logger.LogWarning(
"Client ID mismatch for code {Code}. Expected: {ExpectedClientId}, Actual: {ActualClientId}",
code, authCode.ClientId, clientId);
return null;
}
// Verify redirect URI if provided
if (!string.IsNullOrEmpty(redirectUri) && authCode.RedirectUri != redirectUri)
{
logger.LogWarning("Redirect URI mismatch for code {Code}", code);
return null;
}
// Verify PKCE code challenge if one was provided during authorization
if (!string.IsNullOrEmpty(authCode.CodeChallenge))
{
if (string.IsNullOrEmpty(codeVerifier))
{
logger.LogWarning("PKCE code verifier is required but not provided for code {Code}", code);
return null;
}
var isValid = authCode.CodeChallengeMethod?.ToUpperInvariant() switch
{
"S256" => VerifyCodeChallenge(codeVerifier, authCode.CodeChallenge, "S256"),
"PLAIN" => VerifyCodeChallenge(codeVerifier, authCode.CodeChallenge, "PLAIN"),
_ => false // Unsupported code challenge method
};
if (!isValid)
{
logger.LogWarning("PKCE code verifier validation failed for code {Code}", code);
return null;
}
}
// Code is valid, remove it from the cache (codes are single-use)
await cache.RemoveAsync(cacheKey);
return authCode;
}
private static string GenerateRandomString(int length)
{
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~";
var random = RandomNumberGenerator.Create();
var result = new char[length];
for (int i = 0; i < length; i++)
{
var randomNumber = new byte[4];
random.GetBytes(randomNumber);
var index = (int)(BitConverter.ToUInt32(randomNumber, 0) % chars.Length);
result[i] = chars[index];
}
return new string(result);
}
private static bool VerifyCodeChallenge(string codeVerifier, string codeChallenge, string method)
{
if (string.IsNullOrEmpty(codeVerifier)) return false;
if (method == "S256")
{
using var sha256 = SHA256.Create();
var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
var base64 = Base64UrlEncoder.Encode(hash);
return string.Equals(base64, codeChallenge, StringComparison.Ordinal);
}
if (method == "PLAIN")
{
return string.Equals(codeVerifier, codeChallenge, StringComparison.Ordinal);
}
return false;
}
}

View File

@ -0,0 +1,94 @@
using System.Net.Http.Json;
using System.Text.Json;
using DysonNetwork.Sphere.Storage;
namespace DysonNetwork.Sphere.Auth.OpenId;
public class AfdianOidcService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
AppDatabase db,
AuthService auth,
ICacheService cache,
ILogger<AfdianOidcService> logger
)
: OidcService(configuration, httpClientFactory, db, auth, cache)
{
public override string ProviderName => "Afdian";
protected override string DiscoveryEndpoint => ""; // Afdian doesn't have a standard OIDC discovery endpoint
protected override string ConfigSectionName => "Afdian";
public override string GetAuthorizationUrl(string state, string nonce)
{
var config = GetProviderConfig();
var queryParams = new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "redirect_uri", config.RedirectUri },
{ "response_type", "code" },
{ "scope", "basic" },
{ "state", state },
};
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
return $"https://afdian.com/oauth2/authorize?{queryString}";
}
protected override Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync()
{
return Task.FromResult(new OidcDiscoveryDocument
{
AuthorizationEndpoint = "https://afdian.com/oauth2/authorize",
TokenEndpoint = "https://afdian.com/oauth2/access_token",
UserinfoEndpoint = null,
JwksUri = null
})!;
}
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
{
try
{
var config = GetProviderConfig();
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "client_secret", config.ClientSecret },
{ "grant_type", "authorization_code" },
{ "code", callbackData.Code },
{ "redirect_uri", config.RedirectUri },
});
var client = HttpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/oauth2/access_token");
request.Content = content;
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
logger.LogInformation("Trying get userinfo from afdian, response: {Response}", json);
var afdianResponse = JsonDocument.Parse(json).RootElement;
var user = afdianResponse.TryGetProperty("data", out var dataElement) ? dataElement : default;
var userId = user.TryGetProperty("user_id", out var userIdElement) ? userIdElement.GetString() ?? "" : "";
var avatar = user.TryGetProperty("avatar", out var avatarElement) ? avatarElement.GetString() : null;
return new OidcUserInfo
{
UserId = userId,
DisplayName = (user.TryGetProperty("name", out var nameElement)
? nameElement.GetString()
: null) ?? "",
ProfilePictureUrl = avatar,
Provider = ProviderName
};
}
catch (Exception ex)
{
// Due to afidan's API isn't compliant with OAuth2, we want more logs from it to investigate.
logger.LogError(ex, "Failed to get user info from Afdian");
throw;
}
}
}

View File

@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace DysonNetwork.Sphere.Auth.OpenId;
public class AppleMobileConnectRequest
{
[Required]
public required string IdentityToken { get; set; }
[Required]
public required string AuthorizationCode { get; set; }
}
public class AppleMobileSignInRequest : AppleMobileConnectRequest
{
[Required]
public required string DeviceId { get; set; }
}

View File

@ -0,0 +1,279 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Storage;
using Microsoft.IdentityModel.Tokens;
namespace DysonNetwork.Sphere.Auth.OpenId;
/// <summary>
/// Implementation of OpenID Connect service for Apple Sign In
/// </summary>
public class AppleOidcService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
AppDatabase db,
AuthService auth,
ICacheService cache
)
: OidcService(configuration, httpClientFactory, db, auth, cache)
{
private readonly IConfiguration _configuration = configuration;
private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;
public override string ProviderName => "apple";
protected override string DiscoveryEndpoint => "https://appleid.apple.com/.well-known/openid-configuration";
protected override string ConfigSectionName => "Apple";
public override string GetAuthorizationUrl(string state, string nonce)
{
var config = GetProviderConfig();
var queryParams = new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "redirect_uri", config.RedirectUri },
{ "response_type", "code id_token" },
{ "scope", "name email" },
{ "response_mode", "form_post" },
{ "state", state },
{ "nonce", nonce }
};
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
return $"https://appleid.apple.com/auth/authorize?{queryString}";
}
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
{
// Verify and decode the id_token
var userInfo = await ValidateTokenAsync(callbackData.IdToken);
// If user data is provided in first login, parse it
if (!string.IsNullOrEmpty(callbackData.RawData))
{
var userData = JsonSerializer.Deserialize<AppleUserData>(callbackData.RawData);
if (userData?.Name != null)
{
userInfo.FirstName = userData.Name.FirstName ?? "";
userInfo.LastName = userData.Name.LastName ?? "";
userInfo.DisplayName = $"{userInfo.FirstName} {userInfo.LastName}".Trim();
}
}
// Exchange authorization code for access token (optional, if you need the access token)
if (string.IsNullOrEmpty(callbackData.Code)) return userInfo;
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code);
if (tokenResponse == null) return userInfo;
userInfo.AccessToken = tokenResponse.AccessToken;
userInfo.RefreshToken = tokenResponse.RefreshToken;
return userInfo;
}
private async Task<OidcUserInfo> ValidateTokenAsync(string idToken)
{
// Get Apple's public keys
var jwksJson = await GetAppleJwksAsync();
var jwks = JsonSerializer.Deserialize<AppleJwks>(jwksJson) ?? new AppleJwks { Keys = new List<AppleKey>() };
// Parse the JWT header to get the key ID
var handler = new JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(idToken);
var kid = jwtToken.Header.Kid;
// Find the matching key
var key = jwks.Keys.FirstOrDefault(k => k.Kid == kid);
if (key == null)
{
throw new SecurityTokenValidationException("Unable to find matching key in Apple's JWKS");
}
// Create the validation parameters
var validationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = "https://appleid.apple.com",
ValidateAudience = true,
ValidAudience = GetProviderConfig().ClientId,
ValidateLifetime = true,
IssuerSigningKey = key.ToSecurityKey()
};
return ValidateAndExtractIdToken(idToken, validationParameters);
}
protected override Dictionary<string, string> BuildTokenRequestParameters(
string code,
ProviderConfiguration config,
string? codeVerifier
)
{
var parameters = new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "client_secret", GenerateClientSecret() },
{ "code", code },
{ "grant_type", "authorization_code" },
{ "redirect_uri", config.RedirectUri }
};
return parameters;
}
private async Task<string> GetAppleJwksAsync()
{
var client = _httpClientFactory.CreateClient();
var response = await client.GetAsync("https://appleid.apple.com/auth/keys");
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
/// <summary>
/// Generates a client secret for Apple Sign In using JWT
/// </summary>
private string GenerateClientSecret()
{
var now = DateTime.UtcNow;
var teamId = _configuration["Oidc:Apple:TeamId"];
var clientId = _configuration["Oidc:Apple:ClientId"];
var keyId = _configuration["Oidc:Apple:KeyId"];
var privateKeyPath = _configuration["Oidc:Apple:PrivateKeyPath"];
if (string.IsNullOrEmpty(teamId) || string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(keyId) ||
string.IsNullOrEmpty(privateKeyPath))
{
throw new InvalidOperationException("Apple OIDC configuration is missing required values (TeamId, ClientId, KeyId, PrivateKeyPath).");
}
// Read the private key
var privateKey = File.ReadAllText(privateKeyPath);
// Create the JWT header
var header = new Dictionary<string, object>
{
{ "alg", "ES256" },
{ "kid", keyId }
};
// Create the JWT payload
var payload = new Dictionary<string, object>
{
{ "iss", teamId },
{ "iat", ToUnixTimeSeconds(now) },
{ "exp", ToUnixTimeSeconds(now.AddMinutes(5)) },
{ "aud", "https://appleid.apple.com" },
{ "sub", clientId }
};
// Convert header and payload to Base64Url
var headerJson = JsonSerializer.Serialize(header);
var payloadJson = JsonSerializer.Serialize(payload);
var headerBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson));
var payloadBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson));
// Create the signature
var dataToSign = $"{headerBase64}.{payloadBase64}";
var signature = SignWithECDsa(dataToSign, privateKey);
// Combine all parts
return $"{headerBase64}.{payloadBase64}.{signature}";
}
private long ToUnixTimeSeconds(DateTime dateTime)
{
return new DateTimeOffset(dateTime).ToUnixTimeSeconds();
}
private string SignWithECDsa(string dataToSign, string privateKey)
{
using var ecdsa = ECDsa.Create();
ecdsa.ImportFromPem(privateKey);
var bytes = Encoding.UTF8.GetBytes(dataToSign);
var signature = ecdsa.SignData(bytes, HashAlgorithmName.SHA256);
return Base64UrlEncode(signature);
}
private string Base64UrlEncode(byte[] data)
{
return Convert.ToBase64String(data)
.Replace('+', '-')
.Replace('/', '_')
.TrimEnd('=');
}
}
public class AppleUserData
{
[JsonPropertyName("name")] public AppleNameData? Name { get; set; }
[JsonPropertyName("email")] public string? Email { get; set; }
}
public class AppleNameData
{
[JsonPropertyName("firstName")] public string? FirstName { get; set; }
[JsonPropertyName("lastName")] public string? LastName { get; set; }
}
public class AppleJwks
{
[JsonPropertyName("keys")] public List<AppleKey> Keys { get; set; } = new List<AppleKey>();
}
public class AppleKey
{
[JsonPropertyName("kty")] public string? Kty { get; set; }
[JsonPropertyName("kid")] public string? Kid { get; set; }
[JsonPropertyName("use")] public string? Use { get; set; }
[JsonPropertyName("alg")] public string? Alg { get; set; }
[JsonPropertyName("n")] public string? N { get; set; }
[JsonPropertyName("e")] public string? E { get; set; }
public SecurityKey ToSecurityKey()
{
if (Kty != "RSA" || string.IsNullOrEmpty(N) || string.IsNullOrEmpty(E))
{
throw new InvalidOperationException("Invalid key data");
}
var parameters = new RSAParameters
{
Modulus = Base64UrlDecode(N),
Exponent = Base64UrlDecode(E)
};
var rsa = RSA.Create();
rsa.ImportParameters(parameters);
return new RsaSecurityKey(rsa);
}
private byte[] Base64UrlDecode(string input)
{
var output = input
.Replace('-', '+')
.Replace('_', '/');
switch (output.Length % 4)
{
case 0: break;
case 2: output += "=="; break;
case 3: output += "="; break;
default: throw new InvalidOperationException("Invalid base64url string");
}
return Convert.FromBase64String(output);
}
}

View File

@ -0,0 +1,409 @@
using DysonNetwork.Sphere.Account;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DysonNetwork.Sphere.Storage;
using NodaTime;
namespace DysonNetwork.Sphere.Auth.OpenId;
[ApiController]
[Route("/api/accounts/me/connections")]
[Authorize]
public class ConnectionController(
AppDatabase db,
IEnumerable<OidcService> oidcServices,
AccountService accounts,
AuthService auth,
ICacheService cache
) : ControllerBase
{
private const string StateCachePrefix = "oidc-state:";
private const string ReturnUrlCachePrefix = "oidc-returning:";
private static readonly TimeSpan StateExpiration = TimeSpan.FromMinutes(15);
[HttpGet]
public async Task<ActionResult<List<AccountConnection>>> GetConnections()
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized();
var connections = await db.AccountConnections
.Where(c => c.AccountId == currentUser.Id)
.Select(c => new
{
c.Id,
c.AccountId,
c.Provider,
c.ProvidedIdentifier,
c.Meta,
c.LastUsedAt,
c.CreatedAt,
c.UpdatedAt,
})
.ToListAsync();
return Ok(connections);
}
[HttpDelete("{id:guid}")]
public async Task<ActionResult> RemoveConnection(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized();
var connection = await db.AccountConnections
.Where(c => c.Id == id && c.AccountId == currentUser.Id)
.FirstOrDefaultAsync();
if (connection == null)
return NotFound();
db.AccountConnections.Remove(connection);
await db.SaveChangesAsync();
return Ok();
}
[HttpPost("/auth/connect/apple/mobile")]
public async Task<ActionResult> ConnectAppleMobile([FromBody] AppleMobileConnectRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized();
if (GetOidcService("apple") is not AppleOidcService appleService)
return StatusCode(503, "Apple OIDC service not available");
var callbackData = new OidcCallbackData
{
IdToken = request.IdentityToken,
Code = request.AuthorizationCode,
};
OidcUserInfo userInfo;
try
{
userInfo = await appleService.ProcessCallbackAsync(callbackData);
}
catch (Exception ex)
{
return BadRequest($"Error processing Apple token: {ex.Message}");
}
var existingConnection = await db.AccountConnections
.FirstOrDefaultAsync(c =>
c.Provider == "apple" &&
c.ProvidedIdentifier == userInfo.UserId);
if (existingConnection != null)
{
return BadRequest(
$"This Apple account is already linked to {(existingConnection.AccountId == currentUser.Id ? "your account" : "another user")}.");
}
db.AccountConnections.Add(new AccountConnection
{
AccountId = currentUser.Id,
Provider = "apple",
ProvidedIdentifier = userInfo.UserId!,
AccessToken = userInfo.AccessToken,
RefreshToken = userInfo.RefreshToken,
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
Meta = userInfo.ToMetadata(),
});
await db.SaveChangesAsync();
return Ok(new { message = "Successfully connected Apple account." });
}
private OidcService? GetOidcService(string provider)
{
return oidcServices.FirstOrDefault(s => s.ProviderName.Equals(provider, StringComparison.OrdinalIgnoreCase));
}
public class ConnectProviderRequest
{
public string Provider { get; set; } = null!;
public string? ReturnUrl { get; set; }
}
/// <summary>
/// Initiates manual connection to an OAuth provider for the current user
/// </summary>
[HttpPost("connect")]
public async Task<ActionResult<object>> InitiateConnection([FromBody] ConnectProviderRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized();
var oidcService = GetOidcService(request.Provider);
if (oidcService == null)
return BadRequest($"Provider '{request.Provider}' is not supported");
var existingConnection = await db.AccountConnections
.AnyAsync(c => c.AccountId == currentUser.Id && c.Provider == oidcService.ProviderName);
if (existingConnection)
return BadRequest($"You already have a {request.Provider} connection");
var state = Guid.NewGuid().ToString("N");
var nonce = Guid.NewGuid().ToString("N");
var stateValue = $"{currentUser.Id}|{request.Provider}|{nonce}";
var finalReturnUrl = !string.IsNullOrEmpty(request.ReturnUrl) ? request.ReturnUrl : "/settings/connections";
// Store state and return URL in cache
await cache.SetAsync($"{StateCachePrefix}{state}", stateValue, StateExpiration);
await cache.SetAsync($"{ReturnUrlCachePrefix}{state}", finalReturnUrl, StateExpiration);
var authUrl = oidcService.GetAuthorizationUrl(state, nonce);
return Ok(new
{
authUrl,
message = $"Redirect to this URL to connect your {request.Provider} account"
});
}
[AllowAnonymous]
[Route("/api/auth/callback/{provider}")]
[HttpGet, HttpPost]
public async Task<IActionResult> HandleCallback([FromRoute] string provider)
{
var oidcService = GetOidcService(provider);
if (oidcService == null)
return BadRequest($"Provider '{provider}' is not supported.");
var callbackData = await ExtractCallbackData(Request);
if (callbackData.State == null)
return BadRequest("State parameter is missing.");
// Get the state from the cache
var stateKey = $"{StateCachePrefix}{callbackData.State}";
// Try to get the state as OidcState first (new format)
var oidcState = await cache.GetAsync<OidcState>(stateKey);
// If not found, try to get as string (legacy format)
if (oidcState == null)
{
var stateValue = await cache.GetAsync<string>(stateKey);
if (string.IsNullOrEmpty(stateValue) || !OidcState.TryParse(stateValue, out oidcState) || oidcState == null)
return BadRequest("Invalid or expired state parameter");
}
// Remove the state from cache to prevent replay attacks
await cache.RemoveAsync(stateKey);
// Handle the flow based on state type
if (oidcState.FlowType == OidcFlowType.Connect && oidcState.AccountId.HasValue)
{
// Connection flow
if (oidcState.DeviceId != null)
{
callbackData.State = oidcState.DeviceId;
}
return await HandleManualConnection(provider, oidcService, callbackData, oidcState.AccountId.Value);
}
else if (oidcState.FlowType == OidcFlowType.Login)
{
// Login/Registration flow
if (!string.IsNullOrEmpty(oidcState.DeviceId))
{
callbackData.State = oidcState.DeviceId;
}
// Store return URL if provided
if (!string.IsNullOrEmpty(oidcState.ReturnUrl) && oidcState.ReturnUrl != "/")
{
var returnUrlKey = $"{ReturnUrlCachePrefix}{callbackData.State}";
await cache.SetAsync(returnUrlKey, oidcState.ReturnUrl, StateExpiration);
}
return await HandleLoginOrRegistration(provider, oidcService, callbackData);
}
return BadRequest("Unsupported flow type");
}
private async Task<IActionResult> HandleManualConnection(
string provider,
OidcService oidcService,
OidcCallbackData callbackData,
Guid accountId
)
{
provider = provider.ToLower();
OidcUserInfo userInfo;
try
{
userInfo = await oidcService.ProcessCallbackAsync(callbackData);
}
catch (Exception ex)
{
return BadRequest($"Error processing {provider} authentication: {ex.Message}");
}
if (string.IsNullOrEmpty(userInfo.UserId))
{
return BadRequest($"{provider} did not return a valid user identifier.");
}
// Extract device ID from the callback state if available
var deviceId = !string.IsNullOrEmpty(callbackData.State) ? callbackData.State : string.Empty;
// Check if this provider account is already connected to any user
var existingConnection = await db.AccountConnections
.FirstOrDefaultAsync(c =>
c.Provider == provider &&
c.ProvidedIdentifier == userInfo.UserId);
// If it's connected to a different user, return error
if (existingConnection != null && existingConnection.AccountId != accountId)
{
return BadRequest($"This {provider} account is already linked to another user.");
}
// Check if the current user already has this provider connected
var userHasProvider = await db.AccountConnections
.AnyAsync(c =>
c.AccountId == accountId &&
c.Provider == provider);
if (userHasProvider)
{
// Update existing connection with new tokens
var connection = await db.AccountConnections
.FirstOrDefaultAsync(c =>
c.AccountId == accountId &&
c.Provider == provider);
if (connection != null)
{
connection.AccessToken = userInfo.AccessToken;
connection.RefreshToken = userInfo.RefreshToken;
connection.LastUsedAt = SystemClock.Instance.GetCurrentInstant();
connection.Meta = userInfo.ToMetadata();
}
}
else
{
// Create new connection
db.AccountConnections.Add(new AccountConnection
{
AccountId = accountId,
Provider = provider,
ProvidedIdentifier = userInfo.UserId!,
AccessToken = userInfo.AccessToken,
RefreshToken = userInfo.RefreshToken,
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
Meta = userInfo.ToMetadata(),
});
}
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException)
{
return StatusCode(500, $"Failed to save {provider} connection. Please try again.");
}
// Clean up and redirect
var returnUrlKey = $"{ReturnUrlCachePrefix}{callbackData.State}";
var returnUrl = await cache.GetAsync<string>(returnUrlKey);
await cache.RemoveAsync(returnUrlKey);
return Redirect(string.IsNullOrEmpty(returnUrl) ? "/auth/callback" : returnUrl);
}
private async Task<IActionResult> HandleLoginOrRegistration(
string provider,
OidcService oidcService,
OidcCallbackData callbackData
)
{
OidcUserInfo userInfo;
try
{
userInfo = await oidcService.ProcessCallbackAsync(callbackData);
}
catch (Exception ex)
{
return BadRequest($"Error processing callback: {ex.Message}");
}
if (string.IsNullOrEmpty(userInfo.Email) || string.IsNullOrEmpty(userInfo.UserId))
{
return BadRequest($"Email or user ID is missing from {provider}'s response");
}
var connection = await db.AccountConnections
.Include(c => c.Account)
.FirstOrDefaultAsync(c => c.Provider == provider && c.ProvidedIdentifier == userInfo.UserId);
var clock = SystemClock.Instance;
if (connection != null)
{
// Login existing user
var deviceId = !string.IsNullOrEmpty(callbackData.State) ?
callbackData.State.Split('|').FirstOrDefault() :
string.Empty;
var challenge = await oidcService.CreateChallengeForUserAsync(
userInfo,
connection.Account,
HttpContext,
deviceId ?? string.Empty);
return Redirect($"/auth/callback?challenge={challenge.Id}");
}
// Register new user
var account = await accounts.LookupAccount(userInfo.Email) ?? await accounts.CreateAccount(userInfo);
// Create connection for new or existing user
var newConnection = new AccountConnection
{
Account = account,
Provider = provider,
ProvidedIdentifier = userInfo.UserId!,
AccessToken = userInfo.AccessToken,
RefreshToken = userInfo.RefreshToken,
LastUsedAt = clock.GetCurrentInstant(),
Meta = userInfo.ToMetadata()
};
db.AccountConnections.Add(newConnection);
await db.SaveChangesAsync();
var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant());
var loginToken = auth.CreateToken(loginSession);
return Redirect($"/auth/token?token={loginToken}");
}
private static async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request)
{
var data = new OidcCallbackData();
switch (request.Method)
{
case "GET":
data.Code = Uri.UnescapeDataString(request.Query["code"].FirstOrDefault() ?? "");
data.IdToken = Uri.UnescapeDataString(request.Query["id_token"].FirstOrDefault() ?? "");
data.State = Uri.UnescapeDataString(request.Query["state"].FirstOrDefault() ?? "");
break;
case "POST" when request.HasFormContentType:
{
var form = await request.ReadFormAsync();
data.Code = Uri.UnescapeDataString(form["code"].FirstOrDefault() ?? "");
data.IdToken = Uri.UnescapeDataString(form["id_token"].FirstOrDefault() ?? "");
data.State = Uri.UnescapeDataString(form["state"].FirstOrDefault() ?? "");
if (form.ContainsKey("user"))
data.RawData = Uri.UnescapeDataString(form["user"].FirstOrDefault() ?? "");
break;
}
}
return data;
}
}

View File

@ -0,0 +1,115 @@
using System.Net.Http.Json;
using System.Text.Json;
using DysonNetwork.Sphere.Storage;
namespace DysonNetwork.Sphere.Auth.OpenId;
public class DiscordOidcService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
AppDatabase db,
AuthService auth,
ICacheService cache
)
: OidcService(configuration, httpClientFactory, db, auth, cache)
{
public override string ProviderName => "Discord";
protected override string DiscoveryEndpoint => ""; // Discord doesn't have a standard OIDC discovery endpoint
protected override string ConfigSectionName => "Discord";
public override string GetAuthorizationUrl(string state, string nonce)
{
var config = GetProviderConfig();
var queryParams = new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "redirect_uri", config.RedirectUri },
{ "response_type", "code" },
{ "scope", "identify email" },
{ "state", state },
};
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
return $"https://discord.com/oauth2/authorize?{queryString}";
}
protected override Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync()
{
return Task.FromResult(new OidcDiscoveryDocument
{
AuthorizationEndpoint = "https://discord.com/oauth2/authorize",
TokenEndpoint = "https://discord.com/oauth2/token",
UserinfoEndpoint = "https://discord.com/users/@me",
JwksUri = null
})!;
}
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
{
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code);
if (tokenResponse?.AccessToken == null)
{
throw new InvalidOperationException("Failed to obtain access token from Discord");
}
var userInfo = await GetUserInfoAsync(tokenResponse.AccessToken);
userInfo.AccessToken = tokenResponse.AccessToken;
userInfo.RefreshToken = tokenResponse.RefreshToken;
return userInfo;
}
protected override async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code,
string? codeVerifier = null)
{
var config = GetProviderConfig();
var client = HttpClientFactory.CreateClient();
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "client_secret", config.ClientSecret },
{ "grant_type", "authorization_code" },
{ "code", code },
{ "redirect_uri", config.RedirectUri },
});
var response = await client.PostAsync("https://discord.com/oauth2/token", content);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
}
private async Task<OidcUserInfo> GetUserInfoAsync(string accessToken)
{
var client = HttpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, "https://discord.com/users/@me");
request.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var discordUser = JsonDocument.Parse(json).RootElement;
var userId = discordUser.GetProperty("id").GetString() ?? "";
var avatar = discordUser.TryGetProperty("avatar", out var avatarElement) ? avatarElement.GetString() : null;
return new OidcUserInfo
{
UserId = userId,
Email = (discordUser.TryGetProperty("email", out var emailElement) ? emailElement.GetString() : null) ?? "",
EmailVerified = discordUser.TryGetProperty("verified", out var verifiedElement) &&
verifiedElement.GetBoolean(),
DisplayName = (discordUser.TryGetProperty("global_name", out var globalNameElement)
? globalNameElement.GetString()
: null) ?? "",
PreferredUsername = discordUser.GetProperty("username").GetString() ?? "",
ProfilePictureUrl = !string.IsNullOrEmpty(avatar)
? $"https://cdn.discordapp.com/avatars/{userId}/{avatar}.png"
: "",
Provider = ProviderName
};
}
}

View File

@ -0,0 +1,127 @@
using System.Net.Http.Json;
using System.Text.Json;
using DysonNetwork.Sphere.Storage;
namespace DysonNetwork.Sphere.Auth.OpenId;
public class GitHubOidcService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
AppDatabase db,
AuthService auth,
ICacheService cache
)
: OidcService(configuration, httpClientFactory, db, auth, cache)
{
public override string ProviderName => "GitHub";
protected override string DiscoveryEndpoint => ""; // GitHub doesn't have a standard OIDC discovery endpoint
protected override string ConfigSectionName => "GitHub";
public override string GetAuthorizationUrl(string state, string nonce)
{
var config = GetProviderConfig();
var queryParams = new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "redirect_uri", config.RedirectUri },
{ "scope", "user:email" },
{ "state", state },
};
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
return $"https://github.com/login/oauth/authorize?{queryString}";
}
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
{
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code);
if (tokenResponse?.AccessToken == null)
{
throw new InvalidOperationException("Failed to obtain access token from GitHub");
}
var userInfo = await GetUserInfoAsync(tokenResponse.AccessToken);
userInfo.AccessToken = tokenResponse.AccessToken;
userInfo.RefreshToken = tokenResponse.RefreshToken;
return userInfo;
}
protected override async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code,
string? codeVerifier = null)
{
var config = GetProviderConfig();
var client = HttpClientFactory.CreateClient();
var tokenRequest = new HttpRequestMessage(HttpMethod.Post, "https://github.com/login/oauth/access_token")
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "client_secret", config.ClientSecret },
{ "code", code },
{ "redirect_uri", config.RedirectUri },
})
};
tokenRequest.Headers.Add("Accept", "application/json");
var response = await client.SendAsync(tokenRequest);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
}
private async Task<OidcUserInfo> GetUserInfoAsync(string accessToken)
{
var client = HttpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/user");
request.Headers.Add("Authorization", $"Bearer {accessToken}");
request.Headers.Add("User-Agent", "DysonNetwork.Sphere");
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var githubUser = JsonDocument.Parse(json).RootElement;
var email = githubUser.TryGetProperty("email", out var emailElement) ? emailElement.GetString() : null;
if (string.IsNullOrEmpty(email))
{
email = await GetPrimaryEmailAsync(accessToken);
}
return new OidcUserInfo
{
UserId = githubUser.GetProperty("id").GetInt64().ToString(),
Email = email,
DisplayName = githubUser.TryGetProperty("name", out var nameElement) ? nameElement.GetString() ?? "" : "",
PreferredUsername = githubUser.GetProperty("login").GetString() ?? "",
ProfilePictureUrl = githubUser.TryGetProperty("avatar_url", out var avatarElement)
? avatarElement.GetString() ?? ""
: "",
Provider = ProviderName
};
}
private async Task<string?> GetPrimaryEmailAsync(string accessToken)
{
var client = HttpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/user/emails");
request.Headers.Add("Authorization", $"Bearer {accessToken}");
request.Headers.Add("User-Agent", "DysonNetwork.Sphere");
var response = await client.SendAsync(request);
if (!response.IsSuccessStatusCode) return null;
var emails = await response.Content.ReadFromJsonAsync<List<GitHubEmail>>();
return emails?.FirstOrDefault(e => e.Primary)?.Email;
}
private class GitHubEmail
{
public string Email { get; set; } = "";
public bool Primary { get; set; }
public bool Verified { get; set; }
}
}

View File

@ -0,0 +1,136 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using DysonNetwork.Sphere.Storage;
using Microsoft.IdentityModel.Tokens;
namespace DysonNetwork.Sphere.Auth.OpenId;
public class GoogleOidcService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
AppDatabase db,
AuthService auth,
ICacheService cache
)
: OidcService(configuration, httpClientFactory, db, auth, cache)
{
private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;
public override string ProviderName => "google";
protected override string DiscoveryEndpoint => "https://accounts.google.com/.well-known/openid-configuration";
protected override string ConfigSectionName => "Google";
public override string GetAuthorizationUrl(string state, string nonce)
{
var config = GetProviderConfig();
var discoveryDocument = GetDiscoveryDocumentAsync().GetAwaiter().GetResult();
if (discoveryDocument?.AuthorizationEndpoint == null)
{
throw new InvalidOperationException("Authorization endpoint not found in discovery document");
}
var queryParams = new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "redirect_uri", config.RedirectUri },
{ "response_type", "code" },
{ "scope", "openid email profile" },
{ "state", state }, // No '|codeVerifier' appended anymore
{ "nonce", nonce }
};
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
return $"{discoveryDocument.AuthorizationEndpoint}?{queryString}";
}
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
{
// No need to split or parse code verifier from state
var state = callbackData.State ?? "";
callbackData.State = state; // Keep the original state if needed
// Exchange the code for tokens
// Pass null or omit the parameter for codeVerifier as PKCE is removed
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code, null);
if (tokenResponse?.IdToken == null)
{
throw new InvalidOperationException("Failed to obtain ID token from Google");
}
// Validate the ID token
var userInfo = await ValidateTokenAsync(tokenResponse.IdToken);
// Set tokens on the user info
userInfo.AccessToken = tokenResponse.AccessToken;
userInfo.RefreshToken = tokenResponse.RefreshToken;
// Try to fetch additional profile data if userinfo endpoint is available
try
{
var discoveryDocument = await GetDiscoveryDocumentAsync();
if (discoveryDocument?.UserinfoEndpoint != null && !string.IsNullOrEmpty(tokenResponse.AccessToken))
{
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokenResponse.AccessToken);
var userInfoResponse =
await client.GetFromJsonAsync<Dictionary<string, object>>(discoveryDocument.UserinfoEndpoint);
if (userInfoResponse != null)
{
if (userInfoResponse.TryGetValue("picture", out var picture) && picture != null)
{
userInfo.ProfilePictureUrl = picture.ToString();
}
}
}
}
catch
{
// Ignore errors when fetching additional profile data
}
return userInfo;
}
private async Task<OidcUserInfo> ValidateTokenAsync(string idToken)
{
var discoveryDocument = await GetDiscoveryDocumentAsync();
if (discoveryDocument?.JwksUri == null)
{
throw new InvalidOperationException("JWKS URI not found in discovery document");
}
var client = _httpClientFactory.CreateClient();
var jwksResponse = await client.GetFromJsonAsync<JsonWebKeySet>(discoveryDocument.JwksUri);
if (jwksResponse == null)
{
throw new InvalidOperationException("Failed to retrieve JWKS from Google");
}
var handler = new JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(idToken);
var kid = jwtToken.Header.Kid;
var signingKey = jwksResponse.Keys.FirstOrDefault(k => k.Kid == kid);
if (signingKey == null)
{
throw new SecurityTokenValidationException("Unable to find matching key in Google's JWKS");
}
var validationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = "https://accounts.google.com",
ValidateAudience = true,
ValidAudience = GetProviderConfig().ClientId,
ValidateLifetime = true,
IssuerSigningKey = signingKey
};
return ValidateAndExtractIdToken(idToken, validationParameters);
}
}

View File

@ -0,0 +1,124 @@
using System.Net.Http.Json;
using System.Text.Json;
using DysonNetwork.Sphere.Storage;
namespace DysonNetwork.Sphere.Auth.OpenId;
public class MicrosoftOidcService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
AppDatabase db,
AuthService auth,
ICacheService cache
)
: OidcService(configuration, httpClientFactory, db, auth, cache)
{
public override string ProviderName => "Microsoft";
protected override string DiscoveryEndpoint => Configuration[$"Oidc:{ConfigSectionName}:DiscoveryEndpoint"] ??
throw new InvalidOperationException(
"Microsoft OIDC discovery endpoint is not configured.");
protected override string ConfigSectionName => "Microsoft";
public override string GetAuthorizationUrl(string state, string nonce)
{
var config = GetProviderConfig();
var discoveryDocument = GetDiscoveryDocumentAsync().GetAwaiter().GetResult();
if (discoveryDocument?.AuthorizationEndpoint == null)
throw new InvalidOperationException("Authorization endpoint not found in discovery document.");
var queryParams = new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "response_type", "code" },
{ "redirect_uri", config.RedirectUri },
{ "response_mode", "query" },
{ "scope", "openid profile email" },
{ "state", state },
{ "nonce", nonce },
};
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
return $"{discoveryDocument.AuthorizationEndpoint}?{queryString}";
}
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
{
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code);
if (tokenResponse?.AccessToken == null)
{
throw new InvalidOperationException("Failed to obtain access token from Microsoft");
}
var userInfo = await GetUserInfoAsync(tokenResponse.AccessToken);
userInfo.AccessToken = tokenResponse.AccessToken;
userInfo.RefreshToken = tokenResponse.RefreshToken;
return userInfo;
}
protected override async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code,
string? codeVerifier = null)
{
var config = GetProviderConfig();
var discoveryDocument = await GetDiscoveryDocumentAsync();
if (discoveryDocument?.TokenEndpoint == null)
{
throw new InvalidOperationException("Token endpoint not found in discovery document.");
}
var client = HttpClientFactory.CreateClient();
var tokenRequest = new HttpRequestMessage(HttpMethod.Post, discoveryDocument.TokenEndpoint)
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "scope", "openid profile email" },
{ "code", code },
{ "redirect_uri", config.RedirectUri },
{ "grant_type", "authorization_code" },
{ "client_secret", config.ClientSecret },
})
};
var response = await client.SendAsync(tokenRequest);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
}
private async Task<OidcUserInfo> GetUserInfoAsync(string accessToken)
{
var discoveryDocument = await GetDiscoveryDocumentAsync();
if (discoveryDocument?.UserinfoEndpoint == null)
throw new InvalidOperationException("Userinfo endpoint not found in discovery document.");
var client = HttpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, discoveryDocument.UserinfoEndpoint);
request.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var microsoftUser = JsonDocument.Parse(json).RootElement;
return new OidcUserInfo
{
UserId = microsoftUser.GetProperty("sub").GetString() ?? "",
Email = microsoftUser.TryGetProperty("email", out var emailElement) ? emailElement.GetString() : null,
DisplayName =
microsoftUser.TryGetProperty("name", out var nameElement) ? nameElement.GetString() ?? "" : "",
PreferredUsername = microsoftUser.TryGetProperty("preferred_username", out var preferredUsernameElement)
? preferredUsernameElement.GetString() ?? ""
: "",
ProfilePictureUrl = microsoftUser.TryGetProperty("picture", out var pictureElement)
? pictureElement.GetString() ?? ""
: "",
Provider = ProviderName
};
}
}

View File

@ -0,0 +1,194 @@
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
namespace DysonNetwork.Sphere.Auth.OpenId;
[ApiController]
[Route("/api/auth/login")]
public class OidcController(
IServiceProvider serviceProvider,
AppDatabase db,
AccountService accounts,
ICacheService cache
)
: ControllerBase
{
private const string StateCachePrefix = "oidc-state:";
private static readonly TimeSpan StateExpiration = TimeSpan.FromMinutes(15);
[HttpGet("{provider}")]
public async Task<ActionResult> OidcLogin(
[FromRoute] string provider,
[FromQuery] string? returnUrl = "/",
[FromHeader(Name = "X-Device-Id")] string? deviceId = null
)
{
try
{
var oidcService = GetOidcService(provider);
// If the user is already authenticated, treat as an account connection request
if (HttpContext.Items["CurrentUser"] is Account.Account currentUser)
{
var state = Guid.NewGuid().ToString();
var nonce = Guid.NewGuid().ToString();
// Create and store connection state
var oidcState = OidcState.ForConnection(currentUser.Id, provider, nonce, deviceId);
await cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration);
// The state parameter sent to the provider is the GUID key for the cache.
var authUrl = oidcService.GetAuthorizationUrl(state, nonce);
return Redirect(authUrl);
}
else // Otherwise, proceed with the login / registration flow
{
var nonce = Guid.NewGuid().ToString();
var state = Guid.NewGuid().ToString();
// Create login state with return URL and device ID
var oidcState = OidcState.ForLogin(returnUrl ?? "/", deviceId);
await cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration);
var authUrl = oidcService.GetAuthorizationUrl(state, nonce);
return Redirect(authUrl);
}
}
catch (Exception ex)
{
return BadRequest($"Error initiating OpenID Connect flow: {ex.Message}");
}
}
/// <summary>
/// Mobile Apple Sign In endpoint
/// Handles Apple authentication directly from mobile apps
/// </summary>
[HttpPost("apple/mobile")]
public async Task<ActionResult<Challenge>> AppleMobileLogin(
[FromBody] AppleMobileSignInRequest request)
{
try
{
// Get Apple OIDC service
if (GetOidcService("apple") is not AppleOidcService appleService)
return StatusCode(503, "Apple OIDC service not available");
// Prepare callback data for processing
var callbackData = new OidcCallbackData
{
IdToken = request.IdentityToken,
Code = request.AuthorizationCode,
};
// Process the authentication
var userInfo = await appleService.ProcessCallbackAsync(callbackData);
// Find or create user account using existing logic
var account = await FindOrCreateAccount(userInfo, "apple");
// Create session using the OIDC service
var challenge = await appleService.CreateChallengeForUserAsync(
userInfo,
account,
HttpContext,
request.DeviceId
);
return Ok(challenge);
}
catch (SecurityTokenValidationException ex)
{
return Unauthorized($"Invalid identity token: {ex.Message}");
}
catch (Exception ex)
{
// Log the error
return StatusCode(500, $"Authentication failed: {ex.Message}");
}
}
private OidcService GetOidcService(string provider)
{
return provider.ToLower() switch
{
"apple" => serviceProvider.GetRequiredService<AppleOidcService>(),
"google" => serviceProvider.GetRequiredService<GoogleOidcService>(),
"microsoft" => serviceProvider.GetRequiredService<MicrosoftOidcService>(),
"discord" => serviceProvider.GetRequiredService<DiscordOidcService>(),
"github" => serviceProvider.GetRequiredService<GitHubOidcService>(),
"afdian" => serviceProvider.GetRequiredService<AfdianOidcService>(),
_ => throw new ArgumentException($"Unsupported provider: {provider}")
};
}
private async Task<Account.Account> FindOrCreateAccount(OidcUserInfo userInfo, string provider)
{
if (string.IsNullOrEmpty(userInfo.Email))
throw new ArgumentException("Email is required for account creation");
// Check if an account exists by email
var existingAccount = await accounts.LookupAccount(userInfo.Email);
if (existingAccount != null)
{
// Check if this provider connection already exists
var existingConnection = await db.AccountConnections
.FirstOrDefaultAsync(c => c.AccountId == existingAccount.Id &&
c.Provider == provider &&
c.ProvidedIdentifier == userInfo.UserId);
// If no connection exists, create one
if (existingConnection != null)
{
await db.AccountConnections
.Where(c => c.AccountId == existingAccount.Id &&
c.Provider == provider &&
c.ProvidedIdentifier == userInfo.UserId)
.ExecuteUpdateAsync(s => s
.SetProperty(c => c.LastUsedAt, SystemClock.Instance.GetCurrentInstant())
.SetProperty(c => c.Meta, userInfo.ToMetadata()));
return existingAccount;
}
var connection = new AccountConnection
{
AccountId = existingAccount.Id,
Provider = provider,
ProvidedIdentifier = userInfo.UserId!,
AccessToken = userInfo.AccessToken,
RefreshToken = userInfo.RefreshToken,
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
Meta = userInfo.ToMetadata()
};
await db.AccountConnections.AddAsync(connection);
await db.SaveChangesAsync();
return existingAccount;
}
// Create new account using the AccountService
var newAccount = await accounts.CreateAccount(userInfo);
// Create the provider connection
var newConnection = new AccountConnection
{
AccountId = newAccount.Id,
Provider = provider,
ProvidedIdentifier = userInfo.UserId!,
AccessToken = userInfo.AccessToken,
RefreshToken = userInfo.RefreshToken,
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
Meta = userInfo.ToMetadata()
};
db.AccountConnections.Add(newConnection);
await db.SaveChangesAsync();
return newAccount;
}
}

View File

@ -0,0 +1,295 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
namespace DysonNetwork.Sphere.Auth.OpenId;
/// <summary>
/// Base service for OpenID Connect authentication providers
/// </summary>
public abstract class OidcService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
AppDatabase db,
AuthService auth,
ICacheService cache
)
{
protected readonly IConfiguration Configuration = configuration;
protected readonly IHttpClientFactory HttpClientFactory = httpClientFactory;
protected readonly AppDatabase Db = db;
/// <summary>
/// Gets the unique identifier for this provider
/// </summary>
public abstract string ProviderName { get; }
/// <summary>
/// Gets the OIDC discovery document endpoint
/// </summary>
protected abstract string DiscoveryEndpoint { get; }
/// <summary>
/// Gets configuration section name for this provider
/// </summary>
protected abstract string ConfigSectionName { get; }
/// <summary>
/// Gets the authorization URL for initiating the authentication flow
/// </summary>
public abstract string GetAuthorizationUrl(string state, string nonce);
/// <summary>
/// Process the callback from the OIDC provider
/// </summary>
public abstract Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData);
/// <summary>
/// Gets the provider configuration
/// </summary>
protected ProviderConfiguration GetProviderConfig()
{
return new ProviderConfiguration
{
ClientId = Configuration[$"Oidc:{ConfigSectionName}:ClientId"] ?? "",
ClientSecret = Configuration[$"Oidc:{ConfigSectionName}:ClientSecret"] ?? "",
RedirectUri = Configuration["BaseUrl"] + "/auth/callback/" + ProviderName.ToLower()
};
}
/// <summary>
/// Retrieves the OpenID Connect discovery document
/// </summary>
protected virtual async Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync()
{
// Construct a cache key unique to the current provider:
var cacheKey = $"oidc-discovery:{ProviderName}";
// Try getting the discovery document from cache first:
var (found, cachedDoc) = await cache.GetAsyncWithStatus<OidcDiscoveryDocument>(cacheKey);
if (found && cachedDoc != null)
{
return cachedDoc;
}
// If it's not cached, fetch from the actual discovery endpoint:
var client = HttpClientFactory.CreateClient();
var response = await client.GetAsync(DiscoveryEndpoint);
response.EnsureSuccessStatusCode();
var doc = await response.Content.ReadFromJsonAsync<OidcDiscoveryDocument>();
// Store the discovery document in the cache for a while (e.g., 15 minutes):
if (doc is not null)
await cache.SetAsync(cacheKey, doc, TimeSpan.FromMinutes(15));
return doc;
}
/// <summary>
/// Exchange the authorization code for tokens
/// </summary>
protected virtual async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code,
string? codeVerifier = null)
{
var config = GetProviderConfig();
var discoveryDocument = await GetDiscoveryDocumentAsync();
if (discoveryDocument?.TokenEndpoint == null)
{
throw new InvalidOperationException("Token endpoint not found in discovery document");
}
var client = HttpClientFactory.CreateClient();
var content = new FormUrlEncodedContent(BuildTokenRequestParameters(code, config, codeVerifier));
var response = await client.PostAsync(discoveryDocument.TokenEndpoint, content);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
}
/// <summary>
/// Build the token request parameters
/// </summary>
protected virtual Dictionary<string, string> BuildTokenRequestParameters(string code, ProviderConfiguration config,
string? codeVerifier)
{
var parameters = new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "code", code },
{ "grant_type", "authorization_code" },
{ "redirect_uri", config.RedirectUri }
};
if (!string.IsNullOrEmpty(config.ClientSecret))
{
parameters.Add("client_secret", config.ClientSecret);
}
if (!string.IsNullOrEmpty(codeVerifier))
{
parameters.Add("code_verifier", codeVerifier);
}
return parameters;
}
/// <summary>
/// Validates and extracts information from an ID token
/// </summary>
protected virtual OidcUserInfo ValidateAndExtractIdToken(string idToken,
TokenValidationParameters validationParameters)
{
var handler = new JwtSecurityTokenHandler();
handler.ValidateToken(idToken, validationParameters, out _);
var jwtToken = handler.ReadJwtToken(idToken);
// Extract standard claims
var userId = jwtToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
var email = jwtToken.Claims.FirstOrDefault(c => c.Type == "email")?.Value;
var emailVerified = jwtToken.Claims.FirstOrDefault(c => c.Type == "email_verified")?.Value == "true";
var name = jwtToken.Claims.FirstOrDefault(c => c.Type == "name")?.Value;
var givenName = jwtToken.Claims.FirstOrDefault(c => c.Type == "given_name")?.Value;
var familyName = jwtToken.Claims.FirstOrDefault(c => c.Type == "family_name")?.Value;
var preferredUsername = jwtToken.Claims.FirstOrDefault(c => c.Type == "preferred_username")?.Value;
var picture = jwtToken.Claims.FirstOrDefault(c => c.Type == "picture")?.Value;
// Determine preferred username - try different options
var username = preferredUsername;
if (string.IsNullOrEmpty(username))
{
// Fall back to email local part if no preferred username
username = !string.IsNullOrEmpty(email) ? email.Split('@')[0] : null;
}
return new OidcUserInfo
{
UserId = userId,
Email = email,
EmailVerified = emailVerified,
FirstName = givenName ?? "",
LastName = familyName ?? "",
DisplayName = name ?? $"{givenName} {familyName}".Trim(),
PreferredUsername = username ?? "",
ProfilePictureUrl = picture,
Provider = ProviderName
};
}
/// <summary>
/// Creates a challenge and session for an authenticated user
/// Also creates or updates the account connection
/// </summary>
public async Task<Challenge> CreateChallengeForUserAsync(
OidcUserInfo userInfo,
Account.Account account,
HttpContext request,
string deviceId
)
{
// Create or update the account connection
var connection = await Db.AccountConnections
.FirstOrDefaultAsync(c => c.Provider == ProviderName &&
c.ProvidedIdentifier == userInfo.UserId &&
c.AccountId == account.Id
);
if (connection is null)
{
connection = new AccountConnection
{
Provider = ProviderName,
ProvidedIdentifier = userInfo.UserId ?? "",
AccessToken = userInfo.AccessToken,
RefreshToken = userInfo.RefreshToken,
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
AccountId = account.Id
};
await Db.AccountConnections.AddAsync(connection);
}
// Create a challenge that's already completed
var now = SystemClock.Instance.GetCurrentInstant();
var challenge = new Challenge
{
ExpiredAt = now.Plus(Duration.FromHours(1)),
StepTotal = await auth.DetectChallengeRisk(request.Request, account),
Type = ChallengeType.Oidc,
Platform = ChallengePlatform.Unidentified,
Audiences = [ProviderName],
Scopes = ["*"],
AccountId = account.Id,
DeviceId = deviceId,
IpAddress = request.Connection.RemoteIpAddress?.ToString() ?? null,
UserAgent = request.Request.Headers.UserAgent,
};
challenge.StepRemain--;
if (challenge.StepRemain < 0) challenge.StepRemain = 0;
await Db.AuthChallenges.AddAsync(challenge);
await Db.SaveChangesAsync();
return challenge;
}
}
/// <summary>
/// Provider configuration from app settings
/// </summary>
public class ProviderConfiguration
{
public string ClientId { get; set; } = "";
public string ClientSecret { get; set; } = "";
public string RedirectUri { get; set; } = "";
}
/// <summary>
/// OIDC Discovery Document
/// </summary>
public class OidcDiscoveryDocument
{
[JsonPropertyName("authorization_endpoint")]
public string? AuthorizationEndpoint { get; set; }
[JsonPropertyName("token_endpoint")] public string? TokenEndpoint { get; set; }
[JsonPropertyName("userinfo_endpoint")]
public string? UserinfoEndpoint { get; set; }
[JsonPropertyName("jwks_uri")] public string? JwksUri { get; set; }
}
/// <summary>
/// Response from the token endpoint
/// </summary>
public class OidcTokenResponse
{
[JsonPropertyName("access_token")] public string? AccessToken { get; set; }
[JsonPropertyName("token_type")] public string? TokenType { get; set; }
[JsonPropertyName("expires_in")] public int ExpiresIn { get; set; }
[JsonPropertyName("refresh_token")] public string? RefreshToken { get; set; }
[JsonPropertyName("id_token")] public string? IdToken { get; set; }
}
/// <summary>
/// Data received in the callback from an OIDC provider
/// </summary>
public class OidcCallbackData
{
public string Code { get; set; } = "";
public string IdToken { get; set; } = "";
public string? State { get; set; }
public string? RawData { get; set; }
}

View File

@ -0,0 +1,189 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DysonNetwork.Sphere.Auth.OpenId;
/// <summary>
/// Represents the state parameter used in OpenID Connect flows.
/// Handles serialization and deserialization of the state parameter.
/// </summary>
public class OidcState
{
/// <summary>
/// The type of OIDC flow (login or connect).
/// </summary>
public OidcFlowType FlowType { get; set; }
/// <summary>
/// The account ID (for connect flow).
/// </summary>
public Guid? AccountId { get; set; }
/// <summary>
/// The OIDC provider name.
/// </summary>
public string? Provider { get; set; }
/// <summary>
/// The nonce for CSRF protection.
/// </summary>
public string? Nonce { get; set; }
/// <summary>
/// The device ID for the authentication request.
/// </summary>
public string? DeviceId { get; set; }
/// <summary>
/// The return URL after authentication (for login flow).
/// </summary>
public string? ReturnUrl { get; set; }
/// <summary>
/// Creates a new OidcState for a connection flow.
/// </summary>
public static OidcState ForConnection(Guid accountId, string provider, string nonce, string? deviceId = null)
{
return new OidcState
{
FlowType = OidcFlowType.Connect,
AccountId = accountId,
Provider = provider,
Nonce = nonce,
DeviceId = deviceId
};
}
/// <summary>
/// Creates a new OidcState for a login flow.
/// </summary>
public static OidcState ForLogin(string returnUrl = "/", string? deviceId = null)
{
return new OidcState
{
FlowType = OidcFlowType.Login,
ReturnUrl = returnUrl,
DeviceId = deviceId
};
}
/// <summary>
/// The version of the state format.
/// </summary>
public int Version { get; set; } = 1;
/// <summary>
/// Serializes the state to a JSON string for use in OIDC flows.
/// </summary>
public string Serialize()
{
return JsonSerializer.Serialize(this, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
}
/// <summary>
/// Attempts to parse a state string into an OidcState object.
/// </summary>
public static bool TryParse(string? stateString, out OidcState? state)
{
state = null;
if (string.IsNullOrEmpty(stateString))
return false;
try
{
// First try to parse as JSON
try
{
state = JsonSerializer.Deserialize<OidcState>(stateString);
return state != null;
}
catch (JsonException)
{
// Not a JSON string, try legacy format for backward compatibility
return TryParseLegacyFormat(stateString, out state);
}
}
catch
{
return false;
}
}
private static bool TryParseLegacyFormat(string stateString, out OidcState? state)
{
state = null;
var parts = stateString.Split('|');
// Check for connection flow format: {accountId}|{provider}|{nonce}|{deviceId}|connect
if (parts.Length >= 5 &&
Guid.TryParse(parts[0], out var accountId) &&
string.Equals(parts[^1], "connect", StringComparison.OrdinalIgnoreCase))
{
state = new OidcState
{
FlowType = OidcFlowType.Connect,
AccountId = accountId,
Provider = parts[1],
Nonce = parts[2],
DeviceId = parts.Length >= 4 && !string.IsNullOrEmpty(parts[3]) ? parts[3] : null
};
return true;
}
// Check for login flow format: {returnUrl}|{deviceId}|login
if (parts.Length >= 2 &&
parts.Length <= 3 &&
(parts.Length < 3 || string.Equals(parts[^1], "login", StringComparison.OrdinalIgnoreCase)))
{
state = new OidcState
{
FlowType = OidcFlowType.Login,
ReturnUrl = parts[0],
DeviceId = parts.Length >= 2 && !string.IsNullOrEmpty(parts[1]) ? parts[1] : null
};
return true;
}
// Legacy format support (for backward compatibility)
if (parts.Length == 1)
{
state = new OidcState
{
FlowType = OidcFlowType.Login,
ReturnUrl = parts[0],
DeviceId = null
};
return true;
}
return false;
}
}
/// <summary>
/// Represents the type of OIDC flow.
/// </summary>
public enum OidcFlowType
{
/// <summary>
/// Login or registration flow.
/// </summary>
Login,
/// <summary>
/// Account connection flow.
/// </summary>
Connect
}

View File

@ -0,0 +1,49 @@
namespace DysonNetwork.Sphere.Auth.OpenId;
/// <summary>
/// Represents the user information from an OIDC provider
/// </summary>
public class OidcUserInfo
{
public string? UserId { get; set; }
public string? Email { get; set; }
public bool EmailVerified { get; set; }
public string FirstName { get; set; } = "";
public string LastName { get; set; } = "";
public string DisplayName { get; set; } = "";
public string PreferredUsername { get; set; } = "";
public string? ProfilePictureUrl { get; set; }
public string Provider { get; set; } = "";
public string? RefreshToken { get; set; }
public string? AccessToken { get; set; }
public Dictionary<string, object> ToMetadata()
{
var metadata = new Dictionary<string, object>();
if (!string.IsNullOrWhiteSpace(UserId))
metadata["user_id"] = UserId;
if (!string.IsNullOrWhiteSpace(Email))
metadata["email"] = Email;
metadata["email_verified"] = EmailVerified;
if (!string.IsNullOrWhiteSpace(FirstName))
metadata["first_name"] = FirstName;
if (!string.IsNullOrWhiteSpace(LastName))
metadata["last_name"] = LastName;
if (!string.IsNullOrWhiteSpace(DisplayName))
metadata["display_name"] = DisplayName;
if (!string.IsNullOrWhiteSpace(PreferredUsername))
metadata["preferred_username"] = PreferredUsername;
if (!string.IsNullOrWhiteSpace(ProfilePictureUrl))
metadata["profile_picture_url"] = ProfilePictureUrl;
return metadata;
}
}

View File

@ -1,18 +1,43 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Developer;
using NodaTime;
using Point = NetTopologySuite.Geometries.Point;
namespace DysonNetwork.Sphere.Auth;
public class Session : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string? Label { get; set; }
public Instant? LastGrantedAt { get; set; }
public Instant? ExpiredAt { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!;
[JsonIgnore] public Challenge Challenge { get; set; } = null!;
public Guid ChallengeId { get; set; }
public Challenge Challenge { get; set; } = null!;
public Guid? AppId { get; set; }
public CustomApp? App { get; set; }
}
public enum ChallengeType
{
Login,
OAuth, // Trying to authorize other platforms
Oidc // Trying to connect other platforms
}
public enum ChallengePlatform
{
Unidentified,
Web,
Ios,
Android,
MacOs,
Windows,
Linux
}
public class Challenge : ModelBase
@ -21,14 +46,19 @@ public class Challenge : ModelBase
public Instant? ExpiredAt { get; set; }
public int StepRemain { get; set; }
public int StepTotal { get; set; }
[Column(TypeName = "jsonb")] public List<long> BlacklistFactors { get; set; } = new();
public int FailedAttempts { get; set; }
public ChallengePlatform Platform { get; set; } = ChallengePlatform.Unidentified;
public ChallengeType Type { get; set; } = ChallengeType.Login;
[Column(TypeName = "jsonb")] public List<Guid> BlacklistFactors { get; set; } = new();
[Column(TypeName = "jsonb")] public List<string> Audiences { get; set; } = new();
[Column(TypeName = "jsonb")] public List<string> Scopes { get; set; } = new();
[MaxLength(128)] public string? IpAddress { get; set; }
[MaxLength(512)] public string? UserAgent { get; set; }
[MaxLength(256)] public string? DeviceId { get; set; }
[MaxLength(1024)] public string? Nonce { get; set; }
public Point? Location { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!;
public Challenge Normalize()

View File

@ -1,14 +0,0 @@
[request_definition]
r = sub, dom, obj, act
[policy_definition]
p = sub, dom, obj, act
[role_definition]
g = _, _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = r.sub.StartsWith("super:") || (g(r.sub, p.sub, r.dom) && r.dom == p.dom && r.obj == p.obj && r.act == p.act)

View File

@ -0,0 +1,309 @@
using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Chat;
[ApiController]
[Route("/api/chat")]
public partial class ChatController(AppDatabase db, ChatService cs, ChatRoomService crs) : ControllerBase
{
public class MarkMessageReadRequest
{
public Guid ChatRoomId { get; set; }
}
public class ChatRoomWsUniversalRequest
{
public Guid ChatRoomId { get; set; }
}
public class ChatSummaryResponse
{
public int UnreadCount { get; set; }
public Message? LastMessage { get; set; }
}
[HttpGet("summary")]
[Authorize]
public async Task<ActionResult<Dictionary<Guid, ChatSummaryResponse>>> GetChatSummary()
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var unreadMessages = await cs.CountUnreadMessageForUser(currentUser.Id);
var lastMessages = await cs.ListLastMessageForUser(currentUser.Id);
var result = unreadMessages.Keys
.Union(lastMessages.Keys)
.ToDictionary(
roomId => roomId,
roomId => new ChatSummaryResponse
{
UnreadCount = unreadMessages.GetValueOrDefault(roomId),
LastMessage = lastMessages.GetValueOrDefault(roomId)
}
);
return Ok(result);
}
public class SendMessageRequest
{
[MaxLength(4096)] public string? Content { get; set; }
[MaxLength(36)] public string? Nonce { get; set; }
public List<string>? AttachmentsId { get; set; }
public Dictionary<string, object>? Meta { get; set; }
public Guid? RepliedMessageId { get; set; }
public Guid? ForwardedMessageId { get; set; }
}
[HttpGet("{roomId:guid}/messages")]
public async Task<ActionResult<List<Message>>> ListMessages(Guid roomId, [FromQuery] int offset,
[FromQuery] int take = 20)
{
var currentUser = HttpContext.Items["CurrentUser"] as Account.Account;
var room = await db.ChatRooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (!room.IsPublic)
{
if (currentUser is null) return Unauthorized();
var member = await db.ChatMembers
.Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId)
.FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Member)
return StatusCode(403, "You are not a member of this chat room.");
}
var totalCount = await db.ChatMessages
.Where(m => m.ChatRoomId == roomId)
.CountAsync();
var messages = await db.ChatMessages
.Where(m => m.ChatRoomId == roomId)
.OrderByDescending(m => m.CreatedAt)
.Include(m => m.Sender)
.Include(m => m.Sender.Account)
.Include(m => m.Sender.Account.Profile)
.Skip(offset)
.Take(take)
.ToListAsync();
Response.Headers["X-Total"] = totalCount.ToString();
return Ok(messages);
}
[HttpGet("{roomId:guid}/messages/{messageId:guid}")]
public async Task<ActionResult<Message>> GetMessage(Guid roomId, Guid messageId)
{
var currentUser = HttpContext.Items["CurrentUser"] as Account.Account;
var room = await db.ChatRooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (!room.IsPublic)
{
if (currentUser is null) return Unauthorized();
var member = await db.ChatMembers
.Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId)
.FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Member)
return StatusCode(403, "You are not a member of this chat room.");
}
var message = await db.ChatMessages
.Where(m => m.Id == messageId && m.ChatRoomId == roomId)
.Include(m => m.Sender)
.Include(m => m.Sender.Account)
.Include(m => m.Sender.Account.Profile)
.FirstOrDefaultAsync();
if (message is null) return NotFound();
return Ok(message);
}
[GeneratedRegex("@([A-Za-z0-9_-]+)")]
private static partial Regex MentionRegex();
[HttpPost("{roomId:guid}/messages")]
[Authorize]
[RequiredPermission("global", "chat.messages.create")]
public async Task<ActionResult> SendMessage([FromBody] SendMessageRequest request, Guid roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
request.Content = TextSanitizer.Sanitize(request.Content);
if (string.IsNullOrWhiteSpace(request.Content) &&
(request.AttachmentsId == null || request.AttachmentsId.Count == 0))
return BadRequest("You cannot send an empty message.");
var member = await crs.GetRoomMember(currentUser.Id, roomId);
if (member == null || member.Role < ChatMemberRole.Member)
return StatusCode(403, "You need to be a normal member to send messages here.");
var message = new Message
{
Type = "text",
SenderId = member.Id,
ChatRoomId = roomId,
Nonce = request.Nonce ?? Guid.NewGuid().ToString(),
Meta = request.Meta ?? new Dictionary<string, object>(),
};
if (request.Content is not null)
message.Content = request.Content;
if (request.AttachmentsId is not null)
{
var attachments = await db.Files
.Where(f => request.AttachmentsId.Contains(f.Id))
.ToListAsync();
message.Attachments = attachments
.OrderBy(f => request.AttachmentsId.IndexOf(f.Id))
.Select(f => f.ToReferenceObject())
.ToList();
}
if (request.RepliedMessageId.HasValue)
{
var repliedMessage = await db.ChatMessages
.FirstOrDefaultAsync(m => m.Id == request.RepliedMessageId.Value && m.ChatRoomId == roomId);
if (repliedMessage == null)
return BadRequest("The message you're replying to does not exist.");
message.RepliedMessageId = repliedMessage.Id;
}
if (request.ForwardedMessageId.HasValue)
{
var forwardedMessage = await db.ChatMessages
.FirstOrDefaultAsync(m => m.Id == request.ForwardedMessageId.Value);
if (forwardedMessage == null)
return BadRequest("The message you're forwarding does not exist.");
message.ForwardedMessageId = forwardedMessage.Id;
}
if (request.Content is not null)
{
var mentioned = MentionRegex()
.Matches(request.Content)
.Select(m => m.Groups[1].Value)
.ToList();
if (mentioned.Count > 0)
{
var mentionedMembers = await db.ChatMembers
.Where(m => mentioned.Contains(m.Account.Name))
.Select(m => m.Id)
.ToListAsync();
message.MembersMentioned = mentionedMembers;
}
}
var result = await cs.SendMessageAsync(message, member, member.ChatRoom);
return Ok(result);
}
[HttpPatch("{roomId:guid}/messages/{messageId:guid}")]
[Authorize]
public async Task<ActionResult> UpdateMessage([FromBody] SendMessageRequest request, Guid roomId, Guid messageId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
request.Content = TextSanitizer.Sanitize(request.Content);
var message = await db.ChatMessages
.Include(m => m.Sender)
.Include(m => m.Sender.Account)
.Include(m => m.Sender.Account.Profile)
.Include(message => message.ChatRoom)
.FirstOrDefaultAsync(m => m.Id == messageId && m.ChatRoomId == roomId);
if (message == null) return NotFound();
if (message.Sender.AccountId != currentUser.Id)
return StatusCode(403, "You can only edit your own messages.");
if (string.IsNullOrWhiteSpace(request.Content) &&
(request.AttachmentsId == null || request.AttachmentsId.Count == 0))
return BadRequest("You cannot send an empty message.");
if (request.RepliedMessageId.HasValue)
{
var repliedMessage = await db.ChatMessages
.FirstOrDefaultAsync(m => m.Id == request.RepliedMessageId.Value && m.ChatRoomId == roomId);
if (repliedMessage == null)
return BadRequest("The message you're replying to does not exist.");
}
if (request.ForwardedMessageId.HasValue)
{
var forwardedMessage = await db.ChatMessages
.FirstOrDefaultAsync(m => m.Id == request.ForwardedMessageId.Value);
if (forwardedMessage == null)
return BadRequest("The message you're forwarding does not exist.");
}
// Call service method to update the message
await cs.UpdateMessageAsync(
message,
request.Meta,
request.Content,
request.RepliedMessageId,
request.ForwardedMessageId,
request.AttachmentsId
);
return Ok(message);
}
[HttpDelete("{roomId:guid}/messages/{messageId:guid}")]
[Authorize]
public async Task<ActionResult> DeleteMessage(Guid roomId, Guid messageId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var message = await db.ChatMessages
.Include(m => m.Sender)
.Include(m => m.ChatRoom)
.FirstOrDefaultAsync(m => m.Id == messageId && m.ChatRoomId == roomId);
if (message == null) return NotFound();
if (message.Sender.AccountId != currentUser.Id)
return StatusCode(403, "You can only delete your own messages.");
// Call service method to delete the message
await cs.DeleteMessageAsync(message);
return Ok();
}
public class SyncRequest
{
[Required] public long LastSyncTimestamp { get; set; }
}
[HttpPost("{roomId:guid}/sync")]
public async Task<ActionResult<SyncResponse>> GetSyncData([FromBody] SyncRequest request, Guid roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized();
var isMember = await db.ChatMembers
.AnyAsync(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId);
if (!isMember)
return StatusCode(403, "You are not a member of this chat room.");
var response = await cs.GetSyncDataAsync(roomId, request.LastSyncTimestamp);
return Ok(response);
}
}

View File

@ -0,0 +1,144 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Storage;
using NodaTime;
namespace DysonNetwork.Sphere.Chat;
public enum ChatRoomType
{
Group,
DirectMessage
}
public class ChatRoom : ModelBase, IIdentifiedResource
{
public Guid Id { get; set; }
[MaxLength(1024)] public string? Name { get; set; }
[MaxLength(4096)] public string? Description { get; set; }
public ChatRoomType Type { get; set; }
public bool IsCommunity { get; set; }
public bool IsPublic { get; set; }
// Outdated fields, for backward compability
[MaxLength(32)] public string? PictureId { get; set; }
[MaxLength(32)] public string? BackgroundId { get; set; }
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
[JsonIgnore] public ICollection<ChatMember> Members { get; set; } = new List<ChatMember>();
public Guid? RealmId { get; set; }
public Realm.Realm? Realm { get; set; }
[NotMapped]
[JsonPropertyName("members")]
public ICollection<ChatMemberTransmissionObject> DirectMembers { get; set; } =
new List<ChatMemberTransmissionObject>();
public string ResourceIdentifier => $"chatroom/{Id}";
}
public abstract class ChatMemberRole
{
public const int Owner = 100;
public const int Moderator = 50;
public const int Member = 0;
}
public enum ChatMemberNotify
{
All,
Mentions,
None
}
public enum ChatTimeoutCauseType
{
ByModerator = 0,
BySlowMode = 1,
}
public class ChatTimeoutCause
{
public ChatTimeoutCauseType Type { get; set; }
public Guid? SenderId { get; set; }
}
public class ChatMember : ModelBase
{
public Guid Id { get; set; }
public Guid ChatRoomId { get; set; }
public ChatRoom ChatRoom { get; set; } = null!;
public Guid AccountId { get; set; }
public Account.Account Account { get; set; } = null!;
[MaxLength(1024)] public string? Nick { get; set; }
public int Role { get; set; } = ChatMemberRole.Member;
public ChatMemberNotify Notify { get; set; } = ChatMemberNotify.All;
public Instant? LastReadAt { get; set; }
public Instant? JoinedAt { get; set; }
public Instant? LeaveAt { get; set; }
public bool IsBot { get; set; } = false;
/// <summary>
/// The break time is the user doesn't receive any message from this member for a while.
/// Expect mentioned him or her.
/// </summary>
public Instant? BreakUntil { get; set; }
/// <summary>
/// The timeout is the user can't send any message.
/// Set by the moderator of the chat room.
/// </summary>
public Instant? TimeoutUntil { get; set; }
/// <summary>
/// The timeout cause is the reason why the user is timeout.
/// </summary>
[Column(TypeName = "jsonb")] public ChatTimeoutCause? TimeoutCause { get; set; }
}
public class ChatMemberTransmissionObject : ModelBase
{
public Guid Id { get; set; }
public Guid ChatRoomId { get; set; }
public Guid AccountId { get; set; }
public Account.Account Account { get; set; } = null!;
[MaxLength(1024)] public string? Nick { get; set; }
public int Role { get; set; } = ChatMemberRole.Member;
public ChatMemberNotify Notify { get; set; } = ChatMemberNotify.All;
public Instant? JoinedAt { get; set; }
public Instant? LeaveAt { get; set; }
public bool IsBot { get; set; } = false;
public Instant? BreakUntil { get; set; }
public Instant? TimeoutUntil { get; set; }
public ChatTimeoutCause? TimeoutCause { get; set; }
public static ChatMemberTransmissionObject FromEntity(ChatMember member)
{
return new ChatMemberTransmissionObject
{
Id = member.Id,
ChatRoomId = member.ChatRoomId,
AccountId = member.AccountId,
Account = member.Account,
Nick = member.Nick,
Role = member.Role,
Notify = member.Notify,
JoinedAt = member.JoinedAt,
LeaveAt = member.LeaveAt,
IsBot = member.IsBot,
BreakUntil = member.BreakUntil,
TimeoutUntil = member.TimeoutUntil,
TimeoutCause = member.TimeoutCause,
CreatedAt = member.CreatedAt,
UpdatedAt = member.UpdatedAt,
DeletedAt = member.DeletedAt
};
}
}

View File

@ -0,0 +1,821 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Realm;
using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Localization;
using NodaTime;
namespace DysonNetwork.Sphere.Chat;
[ApiController]
[Route("/api/chat")]
public class ChatRoomController(
AppDatabase db,
FileReferenceService fileRefService,
ChatRoomService crs,
RealmService rs,
ActionLogService als,
NotificationService nty,
RelationshipService rels,
IStringLocalizer<NotificationResource> localizer,
AccountEventService aes
) : ControllerBase
{
[HttpGet("{id:guid}")]
public async Task<ActionResult<ChatRoom>> GetChatRoom(Guid id)
{
var chatRoom = await db.ChatRooms
.Where(c => c.Id == id)
.Include(e => e.Realm)
.FirstOrDefaultAsync();
if (chatRoom is null) return NotFound();
if (chatRoom.Type != ChatRoomType.DirectMessage) return Ok(chatRoom);
if (HttpContext.Items["CurrentUser"] is Account.Account currentUser)
chatRoom = await crs.LoadDirectMessageMembers(chatRoom, currentUser.Id);
return Ok(chatRoom);
}
[HttpGet]
[Authorize]
public async Task<ActionResult<List<ChatRoom>>> ListJoinedChatRooms()
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized();
var userId = currentUser.Id;
var chatRooms = await db.ChatMembers
.Where(m => m.AccountId == userId)
.Where(m => m.JoinedAt != null)
.Where(m => m.LeaveAt == null)
.Include(m => m.ChatRoom)
.Select(m => m.ChatRoom)
.ToListAsync();
chatRooms = await crs.LoadDirectMessageMembers(chatRooms, userId);
chatRooms = await crs.SortChatRoomByLastMessage(chatRooms);
return Ok(chatRooms);
}
public class DirectMessageRequest
{
[Required] public Guid RelatedUserId { get; set; }
}
[HttpPost("direct")]
[Authorize]
public async Task<ActionResult<ChatRoom>> CreateDirectMessage([FromBody] DirectMessageRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized();
var relatedUser = await db.Accounts.FindAsync(request.RelatedUserId);
if (relatedUser is null)
return BadRequest("Related user was not found");
if (await rels.HasRelationshipWithStatus(currentUser.Id, relatedUser.Id, RelationshipStatus.Blocked))
return StatusCode(403, "You cannot create direct message with a user that blocked you.");
// Check if DM already exists between these users
var existingDm = await db.ChatRooms
.Include(c => c.Members)
.Where(c => c.Type == ChatRoomType.DirectMessage && c.Members.Count == 2)
.Where(c => c.Members.Any(m => m.AccountId == currentUser.Id))
.Where(c => c.Members.Any(m => m.AccountId == request.RelatedUserId))
.FirstOrDefaultAsync();
if (existingDm != null)
return BadRequest("You already have a DM with this user.");
// Create new DM chat room
var dmRoom = new ChatRoom
{
Type = ChatRoomType.DirectMessage,
IsPublic = false,
Members = new List<ChatMember>
{
new()
{
AccountId = currentUser.Id,
Role = ChatMemberRole.Owner,
JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow)
},
new()
{
AccountId = request.RelatedUserId,
Role = ChatMemberRole.Member,
JoinedAt = null, // Pending status
}
}
};
db.ChatRooms.Add(dmRoom);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.ChatroomCreate,
new Dictionary<string, object> { { "chatroom_id", dmRoom.Id } }, Request
);
var invitedMember = dmRoom.Members.First(m => m.AccountId == request.RelatedUserId);
invitedMember.ChatRoom = dmRoom;
await _SendInviteNotify(invitedMember, currentUser);
return Ok(dmRoom);
}
[HttpGet("direct/{userId:guid}")]
[Authorize]
public async Task<ActionResult<ChatRoom>> GetDirectChatRoom(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized();
var room = await db.ChatRooms
.Include(c => c.Members)
.Where(c => c.Type == ChatRoomType.DirectMessage && c.Members.Count == 2)
.Where(c => c.Members.Any(m => m.AccountId == currentUser.Id))
.Where(c => c.Members.Any(m => m.AccountId == userId))
.FirstOrDefaultAsync();
if (room is null) return NotFound();
return Ok(room);
}
public class ChatRoomRequest
{
[Required] [MaxLength(1024)] public string? Name { get; set; }
[MaxLength(4096)] public string? Description { get; set; }
[MaxLength(32)] public string? PictureId { get; set; }
[MaxLength(32)] public string? BackgroundId { get; set; }
public Guid? RealmId { get; set; }
public bool? IsCommunity { get; set; }
public bool? IsPublic { get; set; }
}
[HttpPost]
[Authorize]
[RequiredPermission("global", "chat.create")]
public async Task<ActionResult<ChatRoom>> CreateChatRoom(ChatRoomRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
if (request.Name is null) return BadRequest("You cannot create a chat room without a name.");
var chatRoom = new ChatRoom
{
Name = request.Name,
Description = request.Description ?? string.Empty,
IsCommunity = request.IsCommunity ?? false,
IsPublic = request.IsPublic ?? false,
Type = ChatRoomType.Group,
Members = new List<ChatMember>
{
new()
{
Role = ChatMemberRole.Owner,
AccountId = currentUser.Id,
JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow)
}
}
};
if (request.RealmId is not null)
{
if (!await rs.IsMemberWithRole(request.RealmId.Value, currentUser.Id, RealmMemberRole.Moderator))
return StatusCode(403, "You need at least be a moderator to create chat linked to the realm.");
chatRoom.RealmId = request.RealmId;
}
if (request.PictureId is not null)
{
chatRoom.Picture = (await db.Files.FindAsync(request.PictureId))?.ToReferenceObject();
if (chatRoom.Picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud.");
}
if (request.BackgroundId is not null)
{
chatRoom.Background = (await db.Files.FindAsync(request.BackgroundId))?.ToReferenceObject();
if (chatRoom.Background is null)
return BadRequest("Invalid background id, unable to find the file on cloud.");
}
db.ChatRooms.Add(chatRoom);
await db.SaveChangesAsync();
var chatRoomResourceId = $"chatroom:{chatRoom.Id}";
if (chatRoom.Picture is not null)
await fileRefService.CreateReferenceAsync(
chatRoom.Picture.Id,
"chat.room.picture",
chatRoomResourceId
);
if (chatRoom.Background is not null)
await fileRefService.CreateReferenceAsync(
chatRoom.Background.Id,
"chat.room.background",
chatRoomResourceId
);
als.CreateActionLogFromRequest(
ActionLogType.ChatroomCreate,
new Dictionary<string, object> { { "chatroom_id", chatRoom.Id } }, Request
);
return Ok(chatRoom);
}
[HttpPatch("{id:guid}")]
public async Task<ActionResult<ChatRoom>> UpdateChatRoom(Guid id, [FromBody] ChatRoomRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var chatRoom = await db.ChatRooms
.Where(e => e.Id == id)
.FirstOrDefaultAsync();
if (chatRoom is null) return NotFound();
if (chatRoom.RealmId is not null)
{
if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, currentUser.Id, RealmMemberRole.Moderator))
return StatusCode(403, "You need at least be a realm moderator to update the chat.");
}
else if (!await crs.IsMemberWithRole(chatRoom.Id, currentUser.Id, ChatMemberRole.Moderator))
return StatusCode(403, "You need at least be a moderator to update the chat.");
if (request.RealmId is not null)
{
var member = await db.RealmMembers
.Where(m => m.AccountId == currentUser.Id)
.Where(m => m.RealmId == request.RealmId)
.FirstOrDefaultAsync();
if (member is null || member.Role < RealmMemberRole.Moderator)
return StatusCode(403, "You need at least be a moderator to transfer the chat linked to the realm.");
chatRoom.RealmId = member.RealmId;
}
if (request.PictureId is not null)
{
var picture = await db.Files.FindAsync(request.PictureId);
if (picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud.");
// Remove old references for pictures
await fileRefService.DeleteResourceReferencesAsync(chatRoom.ResourceIdentifier, "chat.room.picture");
// Add a new reference
await fileRefService.CreateReferenceAsync(
picture.Id,
"chat.room.picture",
chatRoom.ResourceIdentifier
);
chatRoom.Picture = picture.ToReferenceObject();
}
if (request.BackgroundId is not null)
{
var background = await db.Files.FindAsync(request.BackgroundId);
if (background is null) return BadRequest("Invalid background id, unable to find the file on cloud.");
// Remove old references for backgrounds
await fileRefService.DeleteResourceReferencesAsync(chatRoom.ResourceIdentifier, "chat.room.background");
// Add a new reference
await fileRefService.CreateReferenceAsync(
background.Id,
"chat.room.background",
chatRoom.ResourceIdentifier
);
chatRoom.Background = background.ToReferenceObject();
}
if (request.Name is not null)
chatRoom.Name = request.Name;
if (request.Description is not null)
chatRoom.Description = request.Description;
if (request.IsCommunity is not null)
chatRoom.IsCommunity = request.IsCommunity.Value;
if (request.IsPublic is not null)
chatRoom.IsPublic = request.IsPublic.Value;
db.ChatRooms.Update(chatRoom);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.ChatroomUpdate,
new Dictionary<string, object> { { "chatroom_id", chatRoom.Id } }, Request
);
return Ok(chatRoom);
}
[HttpDelete("{id:guid}")]
public async Task<ActionResult> DeleteChatRoom(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var chatRoom = await db.ChatRooms
.Where(e => e.Id == id)
.FirstOrDefaultAsync();
if (chatRoom is null) return NotFound();
if (chatRoom.RealmId is not null)
{
if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, currentUser.Id, RealmMemberRole.Moderator))
return StatusCode(403, "You need at least be a realm moderator to delete the chat.");
}
else if (!await crs.IsMemberWithRole(chatRoom.Id, currentUser.Id, ChatMemberRole.Owner))
return StatusCode(403, "You need at least be the owner to delete the chat.");
var chatRoomResourceId = $"chatroom:{chatRoom.Id}";
// Delete all file references for this chat room
await fileRefService.DeleteResourceReferencesAsync(chatRoomResourceId);
db.ChatRooms.Remove(chatRoom);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(
ActionLogType.ChatroomDelete,
new Dictionary<string, object> { { "chatroom_id", chatRoom.Id } }, Request
);
return NoContent();
}
[HttpGet("{roomId:guid}/members/me")]
[Authorize]
public async Task<ActionResult<ChatMember>> GetRoomIdentity(Guid roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized();
var member = await db.ChatMembers
.Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId)
.Include(m => m.Account)
.Include(m => m.Account.Profile)
.FirstOrDefaultAsync();
if (member == null)
return NotFound();
return Ok(member);
}
[HttpGet("{roomId:guid}/members")]
public async Task<ActionResult<List<ChatMember>>> ListMembers(Guid roomId, [FromQuery] int take = 20,
[FromQuery] int skip = 0, [FromQuery] bool withStatus = false, [FromQuery] string? status = null)
{
var currentUser = HttpContext.Items["CurrentUser"] as Account.Account;
var room = await db.ChatRooms
.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (!room.IsPublic)
{
if (currentUser is null) return Unauthorized();
var member = await db.ChatMembers
.FirstOrDefaultAsync(m => m.ChatRoomId == roomId && m.AccountId == currentUser.Id);
if (member is null) return StatusCode(403, "You need to be a member to see members of private chat room.");
}
IQueryable<ChatMember> query = db.ChatMembers
.Where(m => m.ChatRoomId == roomId)
.Where(m => m.LeaveAt == null) // Add this condition to exclude left members
.Include(m => m.Account)
.Include(m => m.Account.Profile);
if (withStatus)
{
var members = await query
.OrderBy(m => m.JoinedAt)
.ToListAsync();
var memberStatuses = await aes.GetStatuses(members.Select(m => m.AccountId).ToList());
if (!string.IsNullOrEmpty(status))
{
members = members.Where(m =>
memberStatuses.TryGetValue(m.AccountId, out var s) && s.Label != null &&
s.Label.Equals(status, StringComparison.OrdinalIgnoreCase)).ToList();
}
members = members.OrderByDescending(m => memberStatuses.TryGetValue(m.AccountId, out var s) && s.IsOnline)
.ToList();
var total = members.Count;
Response.Headers.Append("X-Total", total.ToString());
var result = members.Skip(skip).Take(take).ToList();
return Ok(result);
}
else
{
var total = await query.CountAsync();
Response.Headers.Append("X-Total", total.ToString());
var members = await query
.OrderBy(m => m.JoinedAt)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(members);
}
}
public class ChatMemberRequest
{
[Required] public Guid RelatedUserId { get; set; }
[Required] public int Role { get; set; }
}
[HttpPost("invites/{roomId:guid}")]
[Authorize]
public async Task<ActionResult<ChatMember>> InviteMember(Guid roomId,
[FromBody] ChatMemberRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var relatedUser = await db.Accounts.FindAsync(request.RelatedUserId);
if (relatedUser is null) return BadRequest("Related user was not found");
if (await rels.HasRelationshipWithStatus(currentUser.Id, relatedUser.Id, RelationshipStatus.Blocked))
return StatusCode(403, "You cannot invite a user that blocked you.");
var chatRoom = await db.ChatRooms
.Where(p => p.Id == roomId)
.FirstOrDefaultAsync();
if (chatRoom is null) return NotFound();
// Handle realm-owned chat rooms
if (chatRoom.RealmId is not null)
{
var realmMember = await db.RealmMembers
.Where(m => m.AccountId == userId)
.Where(m => m.RealmId == chatRoom.RealmId)
.FirstOrDefaultAsync();
if (realmMember is null || realmMember.Role < RealmMemberRole.Moderator)
return StatusCode(403, "You need at least be a realm moderator to invite members to this chat.");
}
else
{
var chatMember = await db.ChatMembers
.Where(m => m.AccountId == userId)
.Where(m => m.ChatRoomId == roomId)
.FirstOrDefaultAsync();
if (chatMember is null) return StatusCode(403, "You are not even a member of the targeted chat room.");
if (chatMember.Role < ChatMemberRole.Moderator)
return StatusCode(403,
"You need at least be a moderator to invite other members to this chat room.");
if (chatMember.Role < request.Role)
return StatusCode(403, "You cannot invite member with higher permission than yours.");
}
var hasExistingMember = await db.ChatMembers
.Where(m => m.AccountId == request.RelatedUserId)
.Where(m => m.ChatRoomId == roomId)
.Where(m => m.LeaveAt == null)
.AnyAsync();
if (hasExistingMember)
return BadRequest("This user has been joined the chat cannot be invited again.");
var newMember = new ChatMember
{
AccountId = relatedUser.Id,
ChatRoomId = roomId,
Role = request.Role,
};
db.ChatMembers.Add(newMember);
await db.SaveChangesAsync();
newMember.ChatRoom = chatRoom;
await _SendInviteNotify(newMember, currentUser);
als.CreateActionLogFromRequest(
ActionLogType.ChatroomInvite,
new Dictionary<string, object> { { "chatroom_id", chatRoom.Id }, { "account_id", relatedUser.Id } }, Request
);
return Ok(newMember);
}
[HttpGet("invites")]
[Authorize]
public async Task<ActionResult<List<ChatMember>>> ListChatInvites()
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var members = await db.ChatMembers
.Where(m => m.AccountId == userId)
.Where(m => m.JoinedAt == null)
.Include(e => e.ChatRoom)
.Include(e => e.Account)
.Include(e => e.Account.Profile)
.ToListAsync();
var chatRooms = members.Select(m => m.ChatRoom).ToList();
var directMembers =
(await crs.LoadDirectMessageMembers(chatRooms, userId)).ToDictionary(c => c.Id, c => c.Members);
foreach (var member in members.Where(member => member.ChatRoom.Type == ChatRoomType.DirectMessage))
member.ChatRoom.Members = directMembers[member.ChatRoom.Id];
return members.ToList();
}
[HttpPost("invites/{roomId:guid}/accept")]
[Authorize]
public async Task<ActionResult<ChatRoom>> AcceptChatInvite(Guid roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var member = await db.ChatMembers
.Where(m => m.AccountId == userId)
.Where(m => m.ChatRoomId == roomId)
.Where(m => m.JoinedAt == null)
.FirstOrDefaultAsync();
if (member is null) return NotFound();
member.JoinedAt = Instant.FromDateTimeUtc(DateTime.UtcNow);
db.Update(member);
await db.SaveChangesAsync();
_ = crs.PurgeRoomMembersCache(roomId);
als.CreateActionLogFromRequest(
ActionLogType.ChatroomJoin,
new Dictionary<string, object> { { "chatroom_id", roomId } }, Request
);
return Ok(member);
}
[HttpPost("invites/{roomId:guid}/decline")]
[Authorize]
public async Task<ActionResult> DeclineChatInvite(Guid roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var member = await db.ChatMembers
.Where(m => m.AccountId == userId)
.Where(m => m.ChatRoomId == roomId)
.Where(m => m.JoinedAt == null)
.FirstOrDefaultAsync();
if (member is null) return NotFound();
member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
return NoContent();
}
public class ChatMemberNotifyRequest
{
public ChatMemberNotify? NotifyLevel { get; set; }
public Instant? BreakUntil { get; set; }
}
[HttpPatch("{roomId:guid}/members/me/notify")]
[Authorize]
public async Task<ActionResult<ChatMember>> UpdateChatMemberNotify(
Guid roomId,
[FromBody] ChatMemberNotifyRequest request
)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var chatRoom = await db.ChatRooms
.Where(r => r.Id == roomId)
.FirstOrDefaultAsync();
if (chatRoom is null) return NotFound();
var targetMember = await db.ChatMembers
.Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId)
.FirstOrDefaultAsync();
if (targetMember is null) return BadRequest("You have not joined this chat room.");
if (request.NotifyLevel is not null)
targetMember.Notify = request.NotifyLevel.Value;
if (request.BreakUntil is not null)
targetMember.BreakUntil = request.BreakUntil.Value;
db.ChatMembers.Update(targetMember);
await db.SaveChangesAsync();
await crs.PurgeRoomMembersCache(roomId);
return Ok(targetMember);
}
[HttpPatch("{roomId:guid}/members/{memberId:guid}/role")]
[Authorize]
public async Task<ActionResult<ChatMember>> UpdateChatMemberRole(Guid roomId, Guid memberId, [FromBody] int newRole)
{
if (newRole >= ChatMemberRole.Owner) return BadRequest("Unable to set chat member to owner or greater role.");
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var chatRoom = await db.ChatRooms
.Where(r => r.Id == roomId)
.FirstOrDefaultAsync();
if (chatRoom is null) return NotFound();
// Check if the chat room is owned by a realm
if (chatRoom.RealmId is not null)
{
var realmMember = await db.RealmMembers
.Where(m => m.AccountId == currentUser.Id)
.Where(m => m.RealmId == chatRoom.RealmId)
.FirstOrDefaultAsync();
if (realmMember is null || realmMember.Role < RealmMemberRole.Moderator)
return StatusCode(403, "You need at least be a realm moderator to change member roles.");
}
else
{
var targetMember = await db.ChatMembers
.Where(m => m.AccountId == memberId && m.ChatRoomId == roomId)
.FirstOrDefaultAsync();
if (targetMember is null) return NotFound();
// Check if the current user has permission to change roles
if (
!await crs.IsMemberWithRole(
chatRoom.Id,
currentUser.Id,
ChatMemberRole.Moderator,
targetMember.Role,
newRole
)
)
return StatusCode(403, "You don't have enough permission to edit the roles of members.");
targetMember.Role = newRole;
db.ChatMembers.Update(targetMember);
await db.SaveChangesAsync();
await crs.PurgeRoomMembersCache(roomId);
als.CreateActionLogFromRequest(
ActionLogType.RealmAdjustRole,
new Dictionary<string, object>
{ { "chatroom_id", roomId }, { "account_id", memberId }, { "new_role", newRole } },
Request
);
return Ok(targetMember);
}
return BadRequest();
}
[HttpDelete("{roomId:guid}/members/{memberId:guid}")]
[Authorize]
public async Task<ActionResult> RemoveChatMember(Guid roomId, Guid memberId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var chatRoom = await db.ChatRooms
.Where(r => r.Id == roomId)
.FirstOrDefaultAsync();
if (chatRoom is null) return NotFound();
// Check if the chat room is owned by a realm
if (chatRoom.RealmId is not null)
{
if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, currentUser.Id, RealmMemberRole.Moderator))
return StatusCode(403, "You need at least be a realm moderator to remove members.");
}
else
{
if (!await crs.IsMemberWithRole(chatRoom.Id, currentUser.Id, ChatMemberRole.Moderator))
return StatusCode(403, "You need at least be a moderator to remove members.");
// Find the target member
var member = await db.ChatMembers
.Where(m => m.AccountId == memberId && m.ChatRoomId == roomId)
.FirstOrDefaultAsync();
if (member is null) return NotFound();
// Check if the current user has sufficient permissions
if (!await crs.IsMemberWithRole(chatRoom.Id, memberId, member.Role))
return StatusCode(403, "You cannot remove members with equal or higher roles.");
member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
_ = crs.PurgeRoomMembersCache(roomId);
als.CreateActionLogFromRequest(
ActionLogType.ChatroomKick,
new Dictionary<string, object> { { "chatroom_id", roomId }, { "account_id", memberId } }, Request
);
return NoContent();
}
return BadRequest();
}
[HttpPost("{roomId:guid}/members/me")]
[Authorize]
public async Task<ActionResult<ChatRoom>> JoinChatRoom(Guid roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var chatRoom = await db.ChatRooms
.Where(r => r.Id == roomId)
.FirstOrDefaultAsync();
if (chatRoom is null) return NotFound();
if (!chatRoom.IsCommunity)
return StatusCode(403, "This chat room isn't a community. You need an invitation to join.");
var existingMember = await db.ChatMembers
.FirstOrDefaultAsync(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId);
if (existingMember != null)
return BadRequest("You are already a member of this chat room.");
var newMember = new ChatMember
{
AccountId = currentUser.Id,
ChatRoomId = roomId,
Role = ChatMemberRole.Member,
JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow)
};
db.ChatMembers.Add(newMember);
await db.SaveChangesAsync();
_ = crs.PurgeRoomMembersCache(roomId);
als.CreateActionLogFromRequest(
ActionLogType.ChatroomJoin,
new Dictionary<string, object> { { "chatroom_id", roomId } }, Request
);
return Ok(chatRoom);
}
[HttpDelete("{roomId:guid}/members/me")]
[Authorize]
public async Task<ActionResult> LeaveChat(Guid roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var member = await db.ChatMembers
.Where(m => m.AccountId == currentUser.Id)
.Where(m => m.ChatRoomId == roomId)
.FirstOrDefaultAsync();
if (member is null) return NotFound();
if (member.Role == ChatMemberRole.Owner)
{
// Check if this is the only owner
var otherOwners = await db.ChatMembers
.Where(m => m.ChatRoomId == roomId)
.Where(m => m.Role == ChatMemberRole.Owner)
.Where(m => m.AccountId != currentUser.Id)
.AnyAsync();
if (!otherOwners)
return BadRequest("The last owner cannot leave the chat. Transfer ownership first or delete the chat.");
}
member.LeaveAt = Instant.FromDateTimeUtc(DateTime.UtcNow);
await db.SaveChangesAsync();
await crs.PurgeRoomMembersCache(roomId);
als.CreateActionLogFromRequest(
ActionLogType.ChatroomLeave,
new Dictionary<string, object> { { "chatroom_id", roomId } }, Request
);
return NoContent();
}
private async Task _SendInviteNotify(ChatMember member, Account.Account sender)
{
string title = localizer["ChatInviteTitle"];
string body = member.ChatRoom.Type == ChatRoomType.DirectMessage
? localizer["ChatInviteDirectBody", sender.Nick]
: localizer["ChatInviteBody", member.ChatRoom.Name ?? "Unnamed"];
AccountService.SetCultureInfo(member.Account);
await nty.SendNotification(member.Account, "invites.chats", title, null, body, actionUri: "/chat");
}
}

View File

@ -0,0 +1,134 @@
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Chat;
public class ChatRoomService(AppDatabase db, ICacheService cache)
{
public const string ChatRoomGroupPrefix = "chatroom:";
private const string RoomMembersCacheKeyPrefix = "chatroom:members:";
private const string ChatMemberCacheKey = "chatroom:{0}:member:{1}";
public async Task<List<ChatMember>> ListRoomMembers(Guid roomId)
{
var cacheKey = RoomMembersCacheKeyPrefix + roomId;
var cachedMembers = await cache.GetAsync<List<ChatMember>>(cacheKey);
if (cachedMembers != null)
return cachedMembers;
var members = await db.ChatMembers
.Include(m => m.Account)
.ThenInclude(m => m.Profile)
.Where(m => m.ChatRoomId == roomId)
.Where(m => m.JoinedAt != null)
.Where(m => m.LeaveAt == null)
.ToListAsync();
var chatRoomGroup = ChatRoomGroupPrefix + roomId;
await cache.SetWithGroupsAsync(cacheKey, members,
[chatRoomGroup],
TimeSpan.FromMinutes(5));
return members;
}
public async Task<ChatMember?> GetRoomMember(Guid accountId, Guid chatRoomId)
{
var cacheKey = string.Format(ChatMemberCacheKey, accountId, chatRoomId);
var member = await cache.GetAsync<ChatMember?>(cacheKey);
if (member is not null) return member;
member = await db.ChatMembers
.Include(m => m.Account)
.ThenInclude(m => m.Profile)
.Include(m => m.ChatRoom)
.ThenInclude(m => m.Realm)
.Where(m => m.AccountId == accountId && m.ChatRoomId == chatRoomId)
.FirstOrDefaultAsync();
if (member == null) return member;
var chatRoomGroup = ChatRoomGroupPrefix + chatRoomId;
await cache.SetWithGroupsAsync(cacheKey, member,
[chatRoomGroup],
TimeSpan.FromMinutes(5));
return member;
}
public async Task PurgeRoomMembersCache(Guid roomId)
{
var chatRoomGroup = ChatRoomGroupPrefix + roomId;
await cache.RemoveGroupAsync(chatRoomGroup);
}
public async Task<List<ChatRoom>> SortChatRoomByLastMessage(List<ChatRoom> rooms)
{
var roomIds = rooms.Select(r => r.Id).ToList();
var lastMessages = await db.ChatMessages
.Where(m => roomIds.Contains(m.ChatRoomId))
.GroupBy(m => m.ChatRoomId)
.Select(g => new { RoomId = g.Key, CreatedAt = g.Max(m => m.CreatedAt) })
.ToDictionaryAsync(g => g.RoomId, m => m.CreatedAt);
var now = SystemClock.Instance.GetCurrentInstant();
var sortedRooms = rooms
.OrderByDescending(r => lastMessages.TryGetValue(r.Id, out var time) ? time : now)
.ToList();
return sortedRooms;
}
public async Task<List<ChatRoom>> LoadDirectMessageMembers(List<ChatRoom> rooms, Guid userId)
{
var directRoomsId = rooms
.Where(r => r.Type == ChatRoomType.DirectMessage)
.Select(r => r.Id)
.ToList();
if (directRoomsId.Count == 0) return rooms;
var directMembers = directRoomsId.Count != 0
? await db.ChatMembers
.Where(m => directRoomsId.Contains(m.ChatRoomId))
.Where(m => m.AccountId != userId)
.Where(m => m.LeaveAt == null)
.Include(m => m.Account)
.Include(m => m.Account.Profile)
.GroupBy(m => m.ChatRoomId)
.ToDictionaryAsync(g => g.Key, g => g.ToList())
: new Dictionary<Guid, List<ChatMember>>();
return rooms.Select(r =>
{
if (r.Type == ChatRoomType.DirectMessage && directMembers.TryGetValue(r.Id, out var otherMembers))
r.DirectMembers = otherMembers.Select(ChatMemberTransmissionObject.FromEntity).ToList();
return r;
}).ToList();
}
public async Task<ChatRoom> LoadDirectMessageMembers(ChatRoom room, Guid userId)
{
if (room.Type != ChatRoomType.DirectMessage) return room;
var members = await db.ChatMembers
.Where(m => m.ChatRoomId == room.Id && m.AccountId != userId)
.Where(m => m.LeaveAt == null)
.Include(m => m.Account)
.Include(m => m.Account.Profile)
.ToListAsync();
if (members.Count > 0)
room.DirectMembers = members.Select(ChatMemberTransmissionObject.FromEntity).ToList();
return room;
}
public async Task<bool> IsMemberWithRole(Guid roomId, Guid accountId, params int[] requiredRoles)
{
if (requiredRoles.Length == 0)
return false;
var maxRequiredRole = requiredRoles.Max();
var member = await db.ChatMembers
.FirstOrDefaultAsync(m => m.ChatRoomId == roomId && m.AccountId == accountId);
return member?.Role >= maxRequiredRole;
}
}

View File

@ -0,0 +1,578 @@
using System.Text.RegularExpressions;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Chat.Realtime;
using DysonNetwork.Sphere.Connection;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Chat;
public partial class ChatService(
AppDatabase db,
FileReferenceService fileRefService,
IServiceScopeFactory scopeFactory,
IRealtimeService realtime,
ILogger<ChatService> logger
)
{
private const string ChatFileUsageIdentifier = "chat";
[GeneratedRegex(@"https?://[-A-Za-z0-9+&@#/%?=~_|!:,.;]*[-A-Za-z0-9+&@#/%=~_|]")]
private static partial Regex GetLinkRegex();
/// <summary>
/// Process link previews for a message in the background
/// This method is designed to be called from a background task
/// </summary>
/// <param name="message">The message to process link previews for</param>
private async Task ProcessMessageLinkPreviewAsync(Message message)
{
try
{
// Create a new scope for database operations
using var scope = scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var webReader = scope.ServiceProvider.GetRequiredService<Connection.WebReader.WebReaderService>();
var newChat = scope.ServiceProvider.GetRequiredService<ChatService>();
// Preview the links in the message
var updatedMessage = await PreviewMessageLinkAsync(message, webReader);
// If embeds were added, update the message in the database
if (updatedMessage.Meta != null &&
updatedMessage.Meta.TryGetValue("embeds", out var embeds) &&
embeds is List<Dictionary<string, object>> { Count: > 0 } embedsList)
{
// Get a fresh copy of the message from the database
var dbMessage = await dbContext.ChatMessages
.Where(m => m.Id == message.Id)
.Include(m => m.Sender)
.Include(m => m.ChatRoom)
.FirstOrDefaultAsync();
if (dbMessage != null)
{
// Update the meta field with the new embeds
dbMessage.Meta ??= new Dictionary<string, object>();
dbMessage.Meta["embeds"] = embedsList;
// Save changes to the database
dbContext.Update(dbMessage);
await dbContext.SaveChangesAsync();
logger.LogDebug($"Updated message {message.Id} with {embedsList.Count} link previews");
// Notify clients of the updated message
await newChat.DeliverMessageAsync(
dbMessage,
dbMessage.Sender,
dbMessage.ChatRoom,
WebSocketPacketType.MessageUpdate
);
}
}
}
catch (Exception ex)
{
// Log errors but don't rethrow - this is a background task
logger.LogError($"Error processing link previews for message {message.Id}: {ex.Message} {ex.StackTrace}");
}
}
/// <summary>
/// Processes a message to find and preview links in its content
/// </summary>
/// <param name="message">The message to process</param>
/// <param name="webReader">The web reader service</param>
/// <returns>The message with link previews added to its meta data</returns>
public async Task<Message> PreviewMessageLinkAsync(Message message,
Connection.WebReader.WebReaderService? webReader = null)
{
if (string.IsNullOrEmpty(message.Content))
return message;
// Find all URLs in the content
var matches = GetLinkRegex().Matches(message.Content);
if (matches.Count == 0)
return message;
// Initialize meta dictionary if null
message.Meta ??= new Dictionary<string, object>();
// Initialize the embeds' array if it doesn't exist
if (!message.Meta.TryGetValue("embeds", out var existingEmbeds) ||
existingEmbeds is not List<Dictionary<string, object>>)
{
message.Meta["embeds"] = new List<Dictionary<string, object>>();
}
var embeds = (List<Dictionary<string, object>>)message.Meta["embeds"];
webReader ??= scopeFactory.CreateScope().ServiceProvider
.GetRequiredService<Connection.WebReader.WebReaderService>();
// Process up to 3 links to avoid excessive processing
var processedLinks = 0;
foreach (Match match in matches)
{
if (processedLinks >= 3)
break;
var url = match.Value;
try
{
// Check if this URL is already in the embed list
var urlAlreadyEmbedded = embeds.Any(e =>
e.TryGetValue("Url", out var originalUrl) && (string)originalUrl == url);
if (urlAlreadyEmbedded)
continue;
// Preview the link
var linkEmbed = await webReader.GetLinkPreviewAsync(url);
embeds.Add(linkEmbed.ToDictionary());
processedLinks++;
}
catch
{
// ignored
}
}
message.Meta["embeds"] = embeds;
return message;
}
public async Task<Message> SendMessageAsync(Message message, ChatMember sender, ChatRoom room)
{
if (string.IsNullOrWhiteSpace(message.Nonce)) message.Nonce = Guid.NewGuid().ToString();
message.CreatedAt = SystemClock.Instance.GetCurrentInstant();
message.UpdatedAt = message.CreatedAt;
// First complete the save operation
db.ChatMessages.Add(message);
await db.SaveChangesAsync();
var files = message.Attachments.Distinct().ToList();
if (files.Count != 0)
{
var messageResourceId = $"message:{message.Id}";
foreach (var file in files)
{
await fileRefService.CreateReferenceAsync(
file.Id,
ChatFileUsageIdentifier,
messageResourceId,
duration: Duration.FromDays(30)
);
}
}
// Then start the delivery process
_ = Task.Run(async () =>
{
try
{
await DeliverMessageAsync(message, sender, room);
}
catch (Exception ex)
{
// Log the exception properly
// Consider using ILogger or your logging framework
logger.LogError($"Error when delivering message: {ex.Message} {ex.StackTrace}");
}
});
// Process link preview in the background to avoid delaying message sending
_ = Task.Run(async () => await ProcessMessageLinkPreviewAsync(message));
message.Sender = sender;
message.ChatRoom = room;
return message;
}
private async Task DeliverMessageAsync(
Message message,
ChatMember sender,
ChatRoom room,
string type = WebSocketPacketType.MessageNew
)
{
message.Sender = sender;
message.ChatRoom = room;
using var scope = scopeFactory.CreateScope();
var scopedWs = scope.ServiceProvider.GetRequiredService<WebSocketService>();
var scopedNty = scope.ServiceProvider.GetRequiredService<NotificationService>();
var scopedCrs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
var roomSubject = room is { Type: ChatRoomType.DirectMessage, Name: null } ? "DM" :
room.Realm is not null ? $"{room.Name}, {room.Realm.Name}" : room.Name;
var members = await scopedCrs.ListRoomMembers(room.Id);
var metaDict =
new Dictionary<string, object>
{
["sender_name"] = sender.Nick ?? sender.Account.Nick,
["user_id"] = sender.AccountId,
["sender_id"] = sender.Id,
["message_id"] = message.Id,
["room_id"] = room.Id,
["images"] = message.Attachments
.Where(a => a.MimeType != null && a.MimeType.StartsWith("image"))
.Select(a => a.Id).ToList(),
["action_uri"] = $"/chat/{room.Id}"
};
if (sender.Account.Profile is not { Picture: null })
metaDict["pfp"] = sender.Account.Profile.Picture.Id;
if (!string.IsNullOrEmpty(room.Name))
metaDict["room_name"] = room.Name;
var notification = new Notification
{
Topic = "messages.new",
Title = $"{sender.Nick ?? sender.Account.Nick} ({roomSubject})",
Content = !string.IsNullOrEmpty(message.Content)
? message.Content[..Math.Min(message.Content.Length, 100)]
: "<no content>",
Meta = metaDict,
Priority = 10,
};
List<Account.Account> accountsToNotify = [];
foreach (var member in members)
{
scopedWs.SendPacketToAccount(member.AccountId, new WebSocketPacket
{
Type = type,
Data = message
});
if (member.Account.Id == sender.AccountId) continue;
if (member.Notify == ChatMemberNotify.None) continue;
// if (scopedWs.IsUserSubscribedToChatRoom(member.AccountId, room.Id.ToString())) continue;
if (message.MembersMentioned is null || !message.MembersMentioned.Contains(member.Account.Id))
{
var now = SystemClock.Instance.GetCurrentInstant();
if (member.BreakUntil is not null && member.BreakUntil > now) continue;
}
else if (member.Notify == ChatMemberNotify.Mentions) continue;
accountsToNotify.Add(member.Account);
}
logger.LogInformation($"Trying to deliver message to {accountsToNotify.Count} accounts...");
// Only send notifications if there are accounts to notify
if (accountsToNotify.Count > 0)
await scopedNty.SendNotificationBatch(notification, accountsToNotify, save: false);
logger.LogInformation($"Delivered message to {accountsToNotify.Count} accounts.");
}
/// <summary>
/// This method will instant update the LastReadAt field for chat member,
/// for better performance, using the flush buffer one instead
/// </summary>
/// <param name="roomId">The user chat room</param>
/// <param name="userId">The user id</param>
/// <exception cref="ArgumentException"></exception>
public async Task ReadChatRoomAsync(Guid roomId, Guid userId)
{
var sender = await db.ChatMembers
.Where(m => m.AccountId == userId && m.ChatRoomId == roomId)
.FirstOrDefaultAsync();
if (sender is null) throw new ArgumentException("User is not a member of the chat room.");
sender.LastReadAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
}
public async Task<int> CountUnreadMessage(Guid userId, Guid chatRoomId)
{
var sender = await db.ChatMembers
.Where(m => m.AccountId == userId && m.ChatRoomId == chatRoomId)
.Select(m => new { m.LastReadAt })
.FirstOrDefaultAsync();
if (sender?.LastReadAt is null) return 0;
return await db.ChatMessages
.Where(m => m.ChatRoomId == chatRoomId)
.Where(m => m.CreatedAt > sender.LastReadAt)
.CountAsync();
}
public async Task<Dictionary<Guid, int>> CountUnreadMessageForUser(Guid userId)
{
var members = await db.ChatMembers
.Where(m => m.AccountId == userId)
.Select(m => new { m.ChatRoomId, m.LastReadAt })
.ToListAsync();
var lastReadAt = members.ToDictionary(m => m.ChatRoomId, m => m.LastReadAt);
var roomsId = lastReadAt.Keys.ToList();
return await db.ChatMessages
.Where(m => roomsId.Contains(m.ChatRoomId))
.GroupBy(m => m.ChatRoomId)
.ToDictionaryAsync(
g => g.Key,
g => g.Count(m => lastReadAt[g.Key] == null || m.CreatedAt > lastReadAt[g.Key])
);
}
public async Task<Dictionary<Guid, Message?>> ListLastMessageForUser(Guid userId)
{
var userRooms = await db.ChatMembers
.Where(m => m.AccountId == userId)
.Select(m => m.ChatRoomId)
.ToListAsync();
var messages = await db.ChatMessages
.IgnoreQueryFilters()
.Include(m => m.Sender)
.Include(m => m.Sender.Account)
.Include(m => m.Sender.Account.Profile)
.Where(m => userRooms.Contains(m.ChatRoomId))
.GroupBy(m => m.ChatRoomId)
.Select(g => g.OrderByDescending(m => m.CreatedAt).FirstOrDefault())
.ToDictionaryAsync(
m => m!.ChatRoomId,
m => m
);
return messages;
}
public async Task<RealtimeCall> CreateCallAsync(ChatRoom room, ChatMember sender)
{
var call = new RealtimeCall
{
RoomId = room.Id,
SenderId = sender.Id,
ProviderName = realtime.ProviderName
};
try
{
var sessionConfig = await realtime.CreateSessionAsync(room.Id, new Dictionary<string, object>
{
{ "room_id", room.Id },
{ "user_id", sender.AccountId },
});
// Store session details
call.SessionId = sessionConfig.SessionId;
call.UpstreamConfig = sessionConfig.Parameters;
}
catch (Exception ex)
{
// Log the exception but continue with call creation
throw new InvalidOperationException($"Failed to create {realtime.ProviderName} session: {ex.Message}");
}
db.ChatRealtimeCall.Add(call);
await db.SaveChangesAsync();
await SendMessageAsync(new Message
{
Type = "call.start",
ChatRoomId = room.Id,
SenderId = sender.Id,
Meta = new Dictionary<string, object>
{
{ "call_id", call.Id },
}
}, sender, room);
return call;
}
public async Task EndCallAsync(Guid roomId, ChatMember sender)
{
var call = await GetCallOngoingAsync(roomId);
if (call is null) throw new InvalidOperationException("No ongoing call was not found.");
if (sender.Role < ChatMemberRole.Moderator && call.SenderId != sender.Id)
throw new InvalidOperationException("You are not the call initiator either the chat room moderator.");
// End the realtime session if it exists
if (!string.IsNullOrEmpty(call.SessionId) && !string.IsNullOrEmpty(call.ProviderName))
{
try
{
var config = new RealtimeSessionConfig
{
SessionId = call.SessionId,
Parameters = call.UpstreamConfig
};
await realtime.EndSessionAsync(call.SessionId, config);
}
catch (Exception ex)
{
// Log the exception but continue with call ending
throw new InvalidOperationException($"Failed to end {call.ProviderName} session: {ex.Message}");
}
}
call.EndedAt = SystemClock.Instance.GetCurrentInstant();
db.ChatRealtimeCall.Update(call);
await db.SaveChangesAsync();
await SendMessageAsync(new Message
{
Type = "call.ended",
ChatRoomId = call.RoomId,
SenderId = call.SenderId,
Meta = new Dictionary<string, object>
{
{ "call_id", call.Id },
{ "duration", (call.EndedAt!.Value - call.CreatedAt).TotalSeconds }
}
}, call.Sender, call.Room);
}
public async Task<RealtimeCall?> GetCallOngoingAsync(Guid roomId)
{
return await db.ChatRealtimeCall
.Where(c => c.RoomId == roomId)
.Where(c => c.EndedAt == null)
.Include(c => c.Room)
.Include(c => c.Sender)
.FirstOrDefaultAsync();
}
public async Task<SyncResponse> GetSyncDataAsync(Guid roomId, long lastSyncTimestamp)
{
var timestamp = Instant.FromUnixTimeMilliseconds(lastSyncTimestamp);
var changes = await db.ChatMessages
.IgnoreQueryFilters()
.Include(e => e.Sender)
.Include(e => e.Sender.Account)
.Include(e => e.Sender.Account.Profile)
.Where(m => m.ChatRoomId == roomId)
.Where(m => m.UpdatedAt > timestamp || m.DeletedAt > timestamp)
.Select(m => new MessageChange
{
MessageId = m.Id,
Action = m.DeletedAt != null ? "delete" : (m.UpdatedAt == m.CreatedAt ? "create" : "update"),
Message = m.DeletedAt != null ? null : m,
Timestamp = m.DeletedAt ?? m.UpdatedAt
})
.ToListAsync();
return new SyncResponse
{
Changes = changes,
CurrentTimestamp = SystemClock.Instance.GetCurrentInstant()
};
}
public async Task<Message> UpdateMessageAsync(
Message message,
Dictionary<string, object>? meta = null,
string? content = null,
Guid? repliedMessageId = null,
Guid? forwardedMessageId = null,
List<string>? attachmentsId = null
)
{
if (content is not null)
message.Content = content;
if (meta is not null)
message.Meta = meta;
if (repliedMessageId.HasValue)
message.RepliedMessageId = repliedMessageId;
if (forwardedMessageId.HasValue)
message.ForwardedMessageId = forwardedMessageId;
if (attachmentsId is not null)
{
var messageResourceId = $"message:{message.Id}";
// Delete existing references for this message
await fileRefService.DeleteResourceReferencesAsync(messageResourceId);
// Create new references for each attachment
foreach (var fileId in attachmentsId)
{
await fileRefService.CreateReferenceAsync(
fileId,
ChatFileUsageIdentifier,
messageResourceId,
duration: Duration.FromDays(30)
);
}
// Update message attachments by getting files from database
var files = await db.Files
.Where(f => attachmentsId.Contains(f.Id))
.ToListAsync();
message.Attachments = files.Select(x => x.ToReferenceObject()).ToList();
}
message.EditedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(message);
await db.SaveChangesAsync();
// Process link preview in the background if content was updated
if (content is not null)
_ = Task.Run(async () => await ProcessMessageLinkPreviewAsync(message));
_ = DeliverMessageAsync(
message,
message.Sender,
message.ChatRoom,
WebSocketPacketType.MessageUpdate
);
return message;
}
/// <summary>
/// Deletes a message and notifies other chat members
/// </summary>
/// <param name="message">The message to delete</param>
public async Task DeleteMessageAsync(Message message)
{
// Remove all file references for this message
var messageResourceId = $"message:{message.Id}";
await fileRefService.DeleteResourceReferencesAsync(messageResourceId);
db.ChatMessages.Remove(message);
await db.SaveChangesAsync();
_ = DeliverMessageAsync(
message,
message.Sender,
message.ChatRoom,
WebSocketPacketType.MessageDelete
);
}
}
public class MessageChangeAction
{
public const string Create = "create";
public const string Update = "update";
public const string Delete = "delete";
}
public class MessageChange
{
public Guid MessageId { get; set; }
public string Action { get; set; } = null!;
public Message? Message { get; set; }
public Instant Timestamp { get; set; }
}
public class SyncResponse
{
public List<MessageChange> Changes { get; set; } = [];
public Instant CurrentTimestamp { get; set; }
}

View File

@ -0,0 +1,67 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Chat;
public class Message : ModelBase, IIdentifiedResource
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Type { get; set; } = null!;
[MaxLength(4096)] public string? Content { get; set; }
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; }
[Column(TypeName = "jsonb")] public List<Guid>? MembersMentioned { get; set; }
[MaxLength(36)] public string Nonce { get; set; } = null!;
public Instant? EditedAt { get; set; }
[Column(TypeName = "jsonb")] public List<CloudFileReferenceObject> Attachments { get; set; } = [];
// Outdated fields, keep for backward compability
public ICollection<CloudFile> OutdatedAttachments { get; set; } = new List<CloudFile>();
public ICollection<MessageReaction> Reactions { get; set; } = new List<MessageReaction>();
public Guid? RepliedMessageId { get; set; }
public Message? RepliedMessage { get; set; }
public Guid? ForwardedMessageId { get; set; }
public Message? ForwardedMessage { get; set; }
public Guid SenderId { get; set; }
public ChatMember Sender { get; set; } = null!;
public Guid ChatRoomId { get; set; }
[JsonIgnore] public ChatRoom ChatRoom { get; set; } = null!;
public string ResourceIdentifier => $"message/{Id}";
}
public enum MessageReactionAttitude
{
Positive,
Neutral,
Negative,
}
public class MessageReaction : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid MessageId { get; set; }
[JsonIgnore] public Message Message { get; set; } = null!;
public Guid SenderId { get; set; }
public ChatMember Sender { get; set; } = null!;
[MaxLength(256)] public string Symbol { get; set; } = null!;
public MessageReactionAttitude Attitude { get; set; }
}
/// <summary>
/// The data model for updating the last read at field for chat member,
/// after the refactor of the unread system, this no longer stored in the database.
/// Not only used for the data transmission object
/// </summary>
[NotMapped]
public class MessageReadReceipt
{
public Guid SenderId { get; set; }
}

View File

@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace DysonNetwork.Sphere.Chat.Realtime;
/// <summary>
/// Interface for real-time communication services (like Cloudflare, Agora, Twilio, etc.)
/// </summary>
public interface IRealtimeService
{
/// <summary>
/// Service provider name
/// </summary>
string ProviderName { get; }
/// <summary>
/// Creates a new real-time session
/// </summary>
/// <param name="roomId">The room identifier</param>
/// <param name="metadata">Additional metadata to associate with the session</param>
/// <returns>Session configuration data</returns>
Task<RealtimeSessionConfig> CreateSessionAsync(Guid roomId, Dictionary<string, object> metadata);
/// <summary>
/// Ends an existing real-time session
/// </summary>
/// <param name="sessionId">The session identifier</param>
/// <param name="config">The session configuration</param>
Task EndSessionAsync(string sessionId, RealtimeSessionConfig config);
/// <summary>
/// Gets a token for user to join the session
/// </summary>
/// <param name="account">The user identifier</param>
/// <param name="sessionId">The session identifier</param>
/// <param name="isAdmin">The user is the admin of session</param>
/// <returns>User-specific token for the session</returns>
string GetUserToken(Account.Account account, string sessionId, bool isAdmin = false);
/// <summary>
/// Processes incoming webhook requests from the realtime service provider
/// </summary>
/// <param name="body">The webhook request body content</param>
/// <param name="authHeader">The authentication header value</param>
/// <returns>Task representing the asynchronous operation</returns>
Task ReceiveWebhook(string body, string authHeader);
}
/// <summary>
/// Common configuration object for real-time sessions
/// </summary>
public class RealtimeSessionConfig
{
/// <summary>
/// Service-specific session identifier
/// </summary>
public string SessionId { get; set; } = null!;
/// <summary>
/// Additional provider-specific configuration parameters
/// </summary>
public Dictionary<string, object> Parameters { get; set; } = new();
}

View File

@ -0,0 +1,391 @@
using DysonNetwork.Sphere.Connection;
using DysonNetwork.Sphere.Storage;
using Livekit.Server.Sdk.Dotnet;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using System.Text.Json;
namespace DysonNetwork.Sphere.Chat.Realtime;
/// <summary>
/// LiveKit implementation of the real-time communication service
/// </summary>
public class LivekitRealtimeService : IRealtimeService
{
private readonly AppDatabase _db;
private readonly ICacheService _cache;
private readonly WebSocketService _ws;
private readonly ILogger<LivekitRealtimeService> _logger;
private readonly RoomServiceClient _roomService;
private readonly AccessToken _accessToken;
private readonly WebhookReceiver _webhookReceiver;
public LivekitRealtimeService(
IConfiguration configuration,
ILogger<LivekitRealtimeService> logger,
AppDatabase db,
ICacheService cache,
WebSocketService ws
)
{
_logger = logger;
// Get LiveKit configuration from appsettings
var host = configuration["RealtimeChat:Endpoint"] ??
throw new ArgumentNullException("Endpoint configuration is required");
var apiKey = configuration["RealtimeChat:ApiKey"] ??
throw new ArgumentNullException("ApiKey configuration is required");
var apiSecret = configuration["RealtimeChat:ApiSecret"] ??
throw new ArgumentNullException("ApiSecret configuration is required");
_roomService = new RoomServiceClient(host, apiKey, apiSecret);
_accessToken = new AccessToken(apiKey, apiSecret);
_webhookReceiver = new WebhookReceiver(apiKey, apiSecret);
_db = db;
_cache = cache;
_ws = ws;
}
/// <inheritdoc />
public string ProviderName => "LiveKit";
/// <inheritdoc />
public async Task<RealtimeSessionConfig> CreateSessionAsync(Guid roomId, Dictionary<string, object> metadata)
{
try
{
var roomName = $"Call_{roomId.ToString().Replace("-", "")}";
// Convert metadata to a string dictionary for LiveKit
var roomMetadata = new Dictionary<string, string>();
foreach (var item in metadata)
{
roomMetadata[item.Key] = item.Value?.ToString() ?? string.Empty;
}
// Create room in LiveKit
var room = await _roomService.CreateRoom(new CreateRoomRequest
{
Name = roomName,
EmptyTimeout = 300, // 5 minutes
Metadata = JsonSerializer.Serialize(roomMetadata)
});
// Return session config
return new RealtimeSessionConfig
{
SessionId = room.Name,
Parameters = new Dictionary<string, object>
{
{ "sid", room.Sid },
{ "emptyTimeout", room.EmptyTimeout },
{ "creationTime", room.CreationTime }
}
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create LiveKit room for roomId: {RoomId}", roomId);
throw;
}
}
/// <inheritdoc />
public async Task EndSessionAsync(string sessionId, RealtimeSessionConfig config)
{
try
{
// Delete the room in LiveKit
await _roomService.DeleteRoom(new DeleteRoomRequest
{
Room = sessionId
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to end LiveKit session: {SessionId}", sessionId);
throw;
}
}
/// <inheritdoc />
public string GetUserToken(Account.Account account, string sessionId, bool isAdmin = false)
{
var token = _accessToken.WithIdentity(account.Name)
.WithName(account.Nick)
.WithGrants(new VideoGrants
{
RoomJoin = true,
CanPublish = true,
CanPublishData = true,
CanSubscribe = true,
CanSubscribeMetrics = true,
RoomAdmin = isAdmin,
Room = sessionId
})
.WithMetadata(JsonSerializer.Serialize(new Dictionary<string, string>
{ { "account_id", account.Id.ToString() } }))
.WithTtl(TimeSpan.FromHours(1));
return token.ToJwt();
}
public async Task ReceiveWebhook(string body, string authHeader)
{
var evt = _webhookReceiver.Receive(body, authHeader);
if (evt is null) return;
switch (evt.Event)
{
case "room_finished":
var now = SystemClock.Instance.GetCurrentInstant();
await _db.ChatRealtimeCall
.Where(c => c.SessionId == evt.Room.Name)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.EndedAt, now)
);
// Also clean up participants list when the room is finished
await _cache.RemoveAsync(_GetParticipantsKey(evt.Room.Name));
break;
case "participant_joined":
if (evt.Participant != null)
{
// Add the participant to cache
await _AddParticipantToCache(evt.Room.Name, evt.Participant);
_logger.LogInformation(
"Participant joined room: {RoomName}, Participant: {ParticipantIdentity}",
evt.Room.Name, evt.Participant.Identity);
// Broadcast participant list update to all participants
await _BroadcastParticipantUpdate(evt.Room.Name);
}
break;
case "participant_left":
if (evt.Participant != null)
{
// Remove the participant from cache
await _RemoveParticipantFromCache(evt.Room.Name, evt.Participant);
_logger.LogInformation(
"Participant left room: {RoomName}, Participant: {ParticipantIdentity}",
evt.Room.Name, evt.Participant.Identity);
// Broadcast participant list update to all participants
await _BroadcastParticipantUpdate(evt.Room.Name);
}
break;
}
}
private static string _GetParticipantsKey(string roomName)
=> $"RoomParticipants_{roomName}";
private async Task _AddParticipantToCache(string roomName, ParticipantInfo participant)
{
var participantsKey = _GetParticipantsKey(roomName);
// Try to acquire a lock to prevent race conditions when updating the participants list
await using var lockObj = await _cache.AcquireLockAsync(
$"{participantsKey}_lock",
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(5));
if (lockObj == null)
{
_logger.LogWarning("Failed to acquire lock for updating participants list in room: {RoomName}", roomName);
return;
}
// Get the current participants list
var participants = await _cache.GetAsync<List<ParticipantCacheItem>>(participantsKey) ??
[];
// Check if the participant already exists
var existingIndex = participants.FindIndex(p => p.Identity == participant.Identity);
if (existingIndex >= 0)
{
// Update existing participant
participants[existingIndex] = CreateParticipantCacheItem(participant);
}
else
{
// Add new participant
participants.Add(CreateParticipantCacheItem(participant));
}
// Update cache with new list
await _cache.SetAsync(participantsKey, participants, TimeSpan.FromHours(6));
// Also add to a room group in cache for easy cleanup
await _cache.AddToGroupAsync(participantsKey, $"Room_{roomName}");
}
private async Task _RemoveParticipantFromCache(string roomName, ParticipantInfo participant)
{
var participantsKey = _GetParticipantsKey(roomName);
// Try to acquire a lock to prevent race conditions when updating the participants list
await using var lockObj = await _cache.AcquireLockAsync(
$"{participantsKey}_lock",
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(5));
if (lockObj == null)
{
_logger.LogWarning("Failed to acquire lock for updating participants list in room: {RoomName}", roomName);
return;
}
// Get current participants list
var participants = await _cache.GetAsync<List<ParticipantCacheItem>>(participantsKey);
if (participants == null || !participants.Any())
return;
// Remove participant
participants.RemoveAll(p => p.Identity == participant.Identity);
// Update cache with new list
await _cache.SetAsync(participantsKey, participants, TimeSpan.FromHours(6));
}
// Helper method to get participants in a room
public async Task<List<ParticipantCacheItem>> GetRoomParticipantsAsync(string roomName)
{
var participantsKey = _GetParticipantsKey(roomName);
return await _cache.GetAsync<List<ParticipantCacheItem>>(participantsKey) ?? [];
}
// Class to represent a participant in the cache
public class ParticipantCacheItem
{
public string Identity { get; set; } = null!;
public string Name { get; set; } = null!;
public Guid? AccountId { get; set; }
public ParticipantInfo.Types.State State { get; set; }
public Dictionary<string, string> Metadata { get; set; } = new();
public DateTime JoinedAt { get; set; }
}
private ParticipantCacheItem CreateParticipantCacheItem(ParticipantInfo participant)
{
// Try to parse account ID from metadata
Guid? accountId = null;
var metadata = new Dictionary<string, string>();
if (string.IsNullOrEmpty(participant.Metadata))
return new ParticipantCacheItem
{
Identity = participant.Identity,
Name = participant.Name,
AccountId = accountId,
State = participant.State,
Metadata = metadata,
JoinedAt = DateTime.UtcNow
};
try
{
metadata = JsonSerializer.Deserialize<Dictionary<string, string>>(participant.Metadata) ??
new Dictionary<string, string>();
if (metadata.TryGetValue("account_id", out var accountIdStr))
if (Guid.TryParse(accountIdStr, out var parsedId))
accountId = parsedId;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse participant metadata");
}
return new ParticipantCacheItem
{
Identity = participant.Identity,
Name = participant.Name,
AccountId = accountId,
State = participant.State,
Metadata = metadata,
JoinedAt = DateTime.UtcNow
};
}
// Broadcast participant update to all participants in a room
private async Task _BroadcastParticipantUpdate(string roomName)
{
try
{
// Get the room ID from the session name
var roomInfo = await _db.ChatRealtimeCall
.Where(c => c.SessionId == roomName && c.EndedAt == null)
.Select(c => new { c.RoomId, c.Id })
.FirstOrDefaultAsync();
if (roomInfo == null)
{
_logger.LogWarning("Could not find room info for session: {SessionName}", roomName);
return;
}
// Get current participants
var livekitParticipants = await GetRoomParticipantsAsync(roomName);
// Get all room members who should receive this update
var roomMembers = await _db.ChatMembers
.Where(m => m.ChatRoomId == roomInfo.RoomId && m.LeaveAt == null)
.Select(m => m.AccountId)
.ToListAsync();
// Get member profiles for participants who have account IDs
var accountIds = livekitParticipants
.Where(p => p.AccountId.HasValue)
.Select(p => p.AccountId!.Value)
.ToList();
var memberProfiles = new Dictionary<Guid, ChatMember>();
if (accountIds.Count != 0)
{
memberProfiles = await _db.ChatMembers
.Where(m => m.ChatRoomId == roomInfo.RoomId && accountIds.Contains(m.AccountId))
.Include(m => m.Account)
.ThenInclude(m => m.Profile)
.ToDictionaryAsync(m => m.AccountId, m => m);
}
// Convert to CallParticipant objects
var participants = livekitParticipants.Select(p => new CallParticipant
{
Identity = p.Identity,
Name = p.Name,
AccountId = p.AccountId,
JoinedAt = p.JoinedAt,
Profile = p.AccountId.HasValue && memberProfiles.TryGetValue(p.AccountId.Value, out var profile)
? profile
: null
}).ToList();
// Create the update packet with CallParticipant objects
var updatePacket = new WebSocketPacket
{
Type = WebSocketPacketType.CallParticipantsUpdate,
Data = new Dictionary<string, object>
{
{ "room_id", roomInfo.RoomId },
{ "call_id", roomInfo.Id },
{ "participants", participants }
}
};
// Send the update to all members
foreach (var accountId in roomMembers)
{
_ws.SendPacketToAccount(accountId, updatePacket);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error broadcasting participant update for room {RoomName}", roomName);
}
}
}

View File

@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Chat.Realtime;
using NodaTime;
namespace DysonNetwork.Sphere.Chat;
public class RealtimeCall : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public Instant? EndedAt { get; set; }
public Guid SenderId { get; set; }
public ChatMember Sender { get; set; } = null!;
public Guid RoomId { get; set; }
public ChatRoom Room { get; set; } = null!;
/// <summary>
/// Provider name (e.g., "cloudflare", "agora", "twilio")
/// </summary>
public string? ProviderName { get; set; }
/// <summary>
/// Service provider's session identifier
/// </summary>
public string? SessionId { get; set; }
/// <summary>
/// JSONB column containing provider-specific configuration
/// </summary>
[Column(name: "upstream", TypeName = "jsonb")]
public string? UpstreamConfigJson { get; set; }
/// <summary>
/// Deserialized upstream configuration
/// </summary>
[NotMapped]
public Dictionary<string, object> UpstreamConfig
{
get => string.IsNullOrEmpty(UpstreamConfigJson)
? new Dictionary<string, object>()
: JsonSerializer.Deserialize<Dictionary<string, object>>(UpstreamConfigJson) ?? new Dictionary<string, object>();
set => UpstreamConfigJson = value.Count > 0
? JsonSerializer.Serialize(value)
: null;
}
}

View File

@ -0,0 +1,254 @@
using DysonNetwork.Sphere.Chat.Realtime;
using Livekit.Server.Sdk.Dotnet;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Swashbuckle.AspNetCore.Annotations;
namespace DysonNetwork.Sphere.Chat;
public class RealtimeChatConfiguration
{
public string Endpoint { get; set; } = null!;
}
[ApiController]
[Route("/api/chat/realtime")]
public class RealtimeCallController(
IConfiguration configuration,
AppDatabase db,
ChatService cs,
IRealtimeService realtime
) : ControllerBase
{
private readonly RealtimeChatConfiguration _config =
configuration.GetSection("RealtimeChat").Get<RealtimeChatConfiguration>()!;
/// <summary>
/// This endpoint is especially designed for livekit webhooks,
/// for update the call participates and more.
/// Learn more at: https://docs.livekit.io/home/server/webhooks/
/// </summary>
[HttpPost("webhook")]
[SwaggerIgnore]
public async Task<IActionResult> WebhookReceiver()
{
using var reader = new StreamReader(Request.Body);
var postData = await reader.ReadToEndAsync();
var authHeader = Request.Headers.Authorization.ToString();
await realtime.ReceiveWebhook(postData, authHeader);
return Ok();
}
[HttpGet("{roomId:guid}")]
[Authorize]
public async Task<ActionResult<RealtimeCall>> GetOngoingCall(Guid roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var member = await db.ChatMembers
.Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId)
.FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Member)
return StatusCode(403, "You need to be a member to view call status.");
var ongoingCall = await db.ChatRealtimeCall
.Where(c => c.RoomId == roomId)
.Where(c => c.EndedAt == null)
.Include(c => c.Room)
.Include(c => c.Sender)
.ThenInclude(c => c.Account)
.ThenInclude(c => c.Profile)
.FirstOrDefaultAsync();
if (ongoingCall is null) return NotFound();
return Ok(ongoingCall);
}
[HttpGet("{roomId:guid}/join")]
[Authorize]
public async Task<ActionResult<JoinCallResponse>> JoinCall(Guid roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
// Check if the user is a member of the chat room
var member = await db.ChatMembers
.Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId)
.FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Member)
return StatusCode(403, "You need to be a member to join a call.");
// Get ongoing call
var ongoingCall = await cs.GetCallOngoingAsync(roomId);
if (ongoingCall is null)
return NotFound("There is no ongoing call in this room.");
// Check if session ID exists
if (string.IsNullOrEmpty(ongoingCall.SessionId))
return BadRequest("Call session is not properly configured.");
var isAdmin = member.Role >= ChatMemberRole.Moderator;
var userToken = realtime.GetUserToken(currentUser, ongoingCall.SessionId, isAdmin);
// Get LiveKit endpoint from configuration
var endpoint = _config.Endpoint ??
throw new InvalidOperationException("LiveKit endpoint configuration is missing");
// Inject the ChatRoomService
var chatRoomService = HttpContext.RequestServices.GetRequiredService<ChatRoomService>();
// Get current participants from the LiveKit service
var participants = new List<CallParticipant>();
if (realtime is LivekitRealtimeService livekitService)
{
var roomParticipants = await livekitService.GetRoomParticipantsAsync(ongoingCall.SessionId);
participants = [];
foreach (var p in roomParticipants)
{
var participant = new CallParticipant
{
Identity = p.Identity,
Name = p.Name,
AccountId = p.AccountId,
JoinedAt = p.JoinedAt
};
// Fetch the ChatMember profile if we have an account ID
if (p.AccountId.HasValue)
participant.Profile = await chatRoomService.GetRoomMember(p.AccountId.Value, roomId);
participants.Add(participant);
}
}
// Create the response model
var response = new JoinCallResponse
{
Provider = realtime.ProviderName,
Endpoint = endpoint,
Token = userToken,
CallId = ongoingCall.Id,
RoomName = ongoingCall.SessionId,
IsAdmin = isAdmin,
Participants = participants
};
return Ok(response);
}
[HttpPost("{roomId:guid}")]
[Authorize]
public async Task<ActionResult<RealtimeCall>> StartCall(Guid roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var member = await db.ChatMembers
.Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId)
.Include(m => m.ChatRoom)
.FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Member)
return StatusCode(403, "You need to be a normal member to start a call.");
var ongoingCall = await cs.GetCallOngoingAsync(roomId);
if (ongoingCall is not null) return StatusCode(423, "There is already an ongoing call inside the chatroom.");
var call = await cs.CreateCallAsync(member.ChatRoom, member);
return Ok(call);
}
[HttpDelete("{roomId:guid}")]
[Authorize]
public async Task<ActionResult<RealtimeCall>> EndCall(Guid roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var member = await db.ChatMembers
.Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId)
.FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Member)
return StatusCode(403, "You need to be a normal member to end a call.");
try
{
await cs.EndCallAsync(roomId, member);
return NoContent();
}
catch (Exception exception)
{
return BadRequest(exception.Message);
}
}
}
// Response model for joining a call
public class JoinCallResponse
{
/// <summary>
/// The service provider name (e.g., "LiveKit")
/// </summary>
public string Provider { get; set; } = null!;
/// <summary>
/// The LiveKit server endpoint
/// </summary>
public string Endpoint { get; set; } = null!;
/// <summary>
/// Authentication token for the user
/// </summary>
public string Token { get; set; } = null!;
/// <summary>
/// The call identifier
/// </summary>
public Guid CallId { get; set; }
/// <summary>
/// The room name in LiveKit
/// </summary>
public string RoomName { get; set; } = null!;
/// <summary>
/// Whether the user is the admin of the call
/// </summary>
public bool IsAdmin { get; set; }
/// <summary>
/// Current participants in the call
/// </summary>
public List<CallParticipant> Participants { get; set; } = new();
}
/// <summary>
/// Represents a participant in a real-time call
/// </summary>
public class CallParticipant
{
/// <summary>
/// The participant's identity (username)
/// </summary>
public string Identity { get; set; } = null!;
/// <summary>
/// The participant's display name
/// </summary>
public string Name { get; set; } = null!;
/// <summary>
/// The participant's account ID if available
/// </summary>
public Guid? AccountId { get; set; }
/// <summary>
/// The participant's profile in the chat
/// </summary>
public ChatMember? Profile { get; set; }
/// <summary>
/// When the participant joined the call
/// </summary>
public DateTime JoinedAt { get; set; }
}

View File

@ -0,0 +1,92 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Connection;
[ApiController]
[Route("completion")]
public class AutoCompletionController(AppDatabase db)
: ControllerBase
{
[HttpPost]
public async Task<ActionResult<AutoCompletionResponse>> GetCompletions([FromBody] AutoCompletionRequest request)
{
if (string.IsNullOrWhiteSpace(request?.Content))
{
return BadRequest("Content is required");
}
var result = new AutoCompletionResponse();
var lastWord = request.Content.Trim().Split(' ').LastOrDefault() ?? string.Empty;
if (lastWord.StartsWith("@"))
{
var searchTerm = lastWord[1..]; // Remove the @
result.Items = await GetAccountCompletions(searchTerm);
result.Type = "account";
}
else if (lastWord.StartsWith(":"))
{
var searchTerm = lastWord[1..]; // Remove the :
result.Items = await GetStickerCompletions(searchTerm);
result.Type = "sticker";
}
return Ok(result);
}
private async Task<List<CompletionItem>> GetAccountCompletions(string searchTerm)
{
return await db.Accounts
.Where(a => EF.Functions.ILike(a.Name, $"%{searchTerm}%"))
.OrderBy(a => a.Name)
.Take(10)
.Select(a => new CompletionItem
{
Id = a.Id.ToString(),
DisplayName = a.Name,
SecondaryText = a.Nick,
Type = "account",
Data = a
})
.ToListAsync();
}
private async Task<List<CompletionItem>> GetStickerCompletions(string searchTerm)
{
return await db.Stickers
.Include(s => s.Pack)
.Where(s => EF.Functions.ILike(s.Pack.Prefix + s.Slug, $"%{searchTerm}%"))
.OrderBy(s => s.Slug)
.Take(10)
.Select(s => new CompletionItem
{
Id = s.Id.ToString(),
DisplayName = s.Slug,
Type = "sticker",
Data = s
})
.ToListAsync();
}
}
public class AutoCompletionRequest
{
[Required] public string Content { get; set; } = string.Empty;
}
public class AutoCompletionResponse
{
public string Type { get; set; } = string.Empty;
public List<CompletionItem> Items { get; set; } = new();
}
public class CompletionItem
{
public string Id { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string? SecondaryText { get; set; }
public string Type { get; set; } = string.Empty;
public object? Data { get; set; }
}

View File

@ -0,0 +1,42 @@
namespace DysonNetwork.Sphere.Connection;
public class ClientTypeMiddleware(RequestDelegate next)
{
public async Task Invoke(HttpContext context)
{
var headers = context.Request.Headers;
bool isWebPage;
// Priority 1: Check for custom header
if (headers.TryGetValue("X-Client", out var clientType))
{
isWebPage = clientType.ToString().Length == 0;
}
else
{
var userAgent = headers.UserAgent.ToString();
var accept = headers.Accept.ToString();
// Priority 2: Check known app User-Agent (backward compatibility)
if (!string.IsNullOrEmpty(userAgent) && userAgent.Contains("Solian"))
isWebPage = false;
// Priority 3: Accept header can help infer intent
else if (!string.IsNullOrEmpty(accept) && accept.Contains("text/html"))
isWebPage = true;
else if (!string.IsNullOrEmpty(accept) && accept.Contains("application/json"))
isWebPage = false;
else
isWebPage = true;
}
context.Items["IsWebPage"] = isWebPage;
if (!isWebPage && context.Request.Path != "/ws" && !context.Request.Path.StartsWithSegments("/api"))
context.Response.Redirect(
$"/api{context.Request.Path.Value}{context.Request.QueryString.Value}",
permanent: false
);
else
await next(context);
}
}

View File

@ -0,0 +1,56 @@
using MaxMind.GeoIP2;
using NetTopologySuite.Geometries;
using Microsoft.Extensions.Options;
using Point = NetTopologySuite.Geometries.Point;
namespace DysonNetwork.Sphere.Connection;
public class GeoIpOptions
{
public string DatabasePath { get; set; } = null!;
}
public class GeoIpService(IOptions<GeoIpOptions> options)
{
private readonly string _databasePath = options.Value.DatabasePath;
private readonly GeometryFactory _geometryFactory = new(new PrecisionModel(), 4326); // 4326 is the SRID for WGS84
public Point? GetPointFromIp(string? ipAddress)
{
if (string.IsNullOrEmpty(ipAddress))
return null;
try
{
using var reader = new DatabaseReader(_databasePath);
var city = reader.City(ipAddress);
if (city?.Location == null || !city.Location.HasCoordinates)
return null;
return _geometryFactory.CreatePoint(new Coordinate(
city.Location.Longitude ?? 0,
city.Location.Latitude ?? 0));
}
catch (Exception)
{
return null;
}
}
public MaxMind.GeoIP2.Responses.CityResponse? GetFromIp(string? ipAddress)
{
if (string.IsNullOrEmpty(ipAddress))
return null;
try
{
using var reader = new DatabaseReader(_databasePath);
return reader.City(ipAddress);
}
catch (Exception)
{
return null;
}
}
}

View File

@ -0,0 +1,68 @@
using System.Net.WebSockets;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Internal;
using SystemClock = NodaTime.SystemClock;
namespace DysonNetwork.Sphere.Connection.Handlers;
public class MessageReadHandler(
ChatRoomService crs,
FlushBufferService buffer
)
: IWebSocketPacketHandler
{
public string PacketType => "messages.read";
public const string ChatMemberCacheKey = "ChatMember_{0}_{1}";
public async Task HandleAsync(
Account.Account currentUser,
string deviceId,
WebSocketPacket packet,
WebSocket socket,
WebSocketService srv
)
{
var request = packet.GetData<ChatController.MarkMessageReadRequest>();
if (request is null)
{
await socket.SendAsync(
new ArraySegment<byte>(new WebSocketPacket
{
Type = WebSocketPacketType.Error,
ErrorMessage = "Mark message as read requires you provide the ChatRoomId and MessageId"
}.ToBytes()),
WebSocketMessageType.Binary,
true,
CancellationToken.None
);
return;
}
var sender = await crs.GetRoomMember(currentUser.Id, request.ChatRoomId);
if (sender is null)
{
await socket.SendAsync(
new ArraySegment<byte>(new WebSocketPacket
{
Type = WebSocketPacketType.Error,
ErrorMessage = "User is not a member of the chat room."
}.ToBytes()),
WebSocketMessageType.Binary,
true,
CancellationToken.None
);
return;
}
var readReceipt = new MessageReadReceipt
{
SenderId = sender.Id,
};
buffer.Enqueue(readReceipt);
}
}

View File

@ -0,0 +1,68 @@
using System.Net.WebSockets;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Connection.Handlers;
public class MessageTypingHandler(ChatRoomService crs) : IWebSocketPacketHandler
{
public string PacketType => "messages.typing";
public async Task HandleAsync(
Account.Account currentUser,
string deviceId,
WebSocketPacket packet,
WebSocket socket,
WebSocketService srv
)
{
var request = packet.GetData<ChatController.ChatRoomWsUniversalRequest>();
if (request is null)
{
await socket.SendAsync(
new ArraySegment<byte>(new WebSocketPacket
{
Type = WebSocketPacketType.Error,
ErrorMessage = "Mark message as read requires you provide the ChatRoomId"
}.ToBytes()),
WebSocketMessageType.Binary,
true,
CancellationToken.None
);
return;
}
var sender = await crs.GetRoomMember(currentUser.Id, request.ChatRoomId);
if (sender is null)
{
await socket.SendAsync(
new ArraySegment<byte>(new WebSocketPacket
{
Type = WebSocketPacketType.Error,
ErrorMessage = "User is not a member of the chat room."
}.ToBytes()),
WebSocketMessageType.Binary,
true,
CancellationToken.None
);
return;
}
var responsePacket = new WebSocketPacket
{
Type = "messages.typing",
Data = new Dictionary<string, object>()
{
["room_id"] = sender.ChatRoomId,
["sender_id"] = sender.Id,
["sender"] = sender
}
};
// Broadcast read statuses
var otherMembers = (await crs.ListRoomMembers(request.ChatRoomId)).Select(m => m.AccountId).ToList();
foreach (var member in otherMembers)
srv.SendPacketToAccount(member, responsePacket);
}
}

View File

@ -0,0 +1,53 @@
using System.Net.WebSockets;
using DysonNetwork.Sphere.Chat;
namespace DysonNetwork.Sphere.Connection.Handlers;
public class MessagesSubscribeHandler(ChatRoomService crs) : IWebSocketPacketHandler
{
public string PacketType => "messages.subscribe";
public async Task HandleAsync(
Account.Account currentUser,
string deviceId,
WebSocketPacket packet,
WebSocket socket,
WebSocketService srv
)
{
var request = packet.GetData<ChatController.ChatRoomWsUniversalRequest>();
if (request is null)
{
await socket.SendAsync(
new ArraySegment<byte>(new WebSocketPacket
{
Type = WebSocketPacketType.Error,
ErrorMessage = "messages.subscribe requires you provide the ChatRoomId"
}.ToBytes()),
WebSocketMessageType.Binary,
true,
CancellationToken.None
);
return;
}
var sender = await crs.GetRoomMember(currentUser.Id, request.ChatRoomId);
if (sender is null)
{
await socket.SendAsync(
new ArraySegment<byte>(new WebSocketPacket
{
Type = WebSocketPacketType.Error,
ErrorMessage = "User is not a member of the chat room."
}.ToBytes()),
WebSocketMessageType.Binary,
true,
CancellationToken.None
);
return;
}
srv.SubscribeToChatRoom(sender.ChatRoomId.ToString(), deviceId);
}
}

View File

@ -0,0 +1,21 @@
using System.Net.WebSockets;
using DysonNetwork.Sphere.Chat;
namespace DysonNetwork.Sphere.Connection.Handlers;
public class MessagesUnsubscribeHandler() : IWebSocketPacketHandler
{
public string PacketType => "messages.unsubscribe";
public Task HandleAsync(
Account.Account currentUser,
string deviceId,
WebSocketPacket packet,
WebSocket socket,
WebSocketService srv
)
{
srv.UnsubscribeFromChatRoom(deviceId);
return Task.CompletedTask;
}
}

View File

@ -0,0 +1,9 @@
using System.Net.WebSockets;
namespace DysonNetwork.Sphere.Connection;
public interface IWebSocketPacketHandler
{
string PacketType { get; }
Task HandleAsync(Account.Account currentUser, string deviceId, WebSocketPacket packet, WebSocket socket, WebSocketService srv);
}

View File

@ -0,0 +1,44 @@
using System.Reflection;
using System.Text.Json.Serialization;
namespace DysonNetwork.Sphere.Connection.WebReader;
/// <summary>
/// The embeddable can be used in the post or messages' meta's embeds fields
/// To render a richer type of content.
///
/// A simple example of using link preview embed:
/// <code>
/// {
/// // ... post content
/// "meta": {
/// "embeds": [
/// {
/// "type": "link",
/// "title: "...",
/// /// ...
/// }
/// ]
/// }
/// }
/// </code>
/// </summary>
public abstract class EmbeddableBase
{
public abstract string Type { get; }
public Dictionary<string, object> ToDictionary()
{
var dict = new Dictionary<string, object>();
foreach (var prop in GetType().GetProperties())
{
if (prop.GetCustomAttribute<JsonIgnoreAttribute>() is not null)
continue;
var value = prop.GetValue(this);
if (value is null) continue;
dict[prop.Name] = value;
}
return dict;
}
}

View File

@ -0,0 +1,55 @@
namespace DysonNetwork.Sphere.Connection.WebReader;
/// <summary>
/// The link embed is a part of the embeddable implementations
/// It can be used in the post or messages' meta's embeds fields
/// </summary>
public class LinkEmbed : EmbeddableBase
{
public override string Type => "link";
/// <summary>
/// The original URL that was processed
/// </summary>
public required string Url { get; set; }
/// <summary>
/// Title of the linked content (from OpenGraph og:title, meta title, or page title)
/// </summary>
public string? Title { get; set; }
/// <summary>
/// Description of the linked content (from OpenGraph og:description or meta description)
/// </summary>
public string? Description { get; set; }
/// <summary>
/// URL to the thumbnail image (from OpenGraph og:image or other meta tags)
/// </summary>
public string? ImageUrl { get; set; }
/// <summary>
/// The favicon URL of the site
/// </summary>
public string? FaviconUrl { get; set; }
/// <summary>
/// The site name (from OpenGraph og:site_name)
/// </summary>
public string? SiteName { get; set; }
/// <summary>
/// Type of the content (from OpenGraph og:type)
/// </summary>
public string? ContentType { get; set; }
/// <summary>
/// Author of the content if available
/// </summary>
public string? Author { get; set; }
/// <summary>
/// Published date of the content if available
/// </summary>
public DateTime? PublishedDate { get; set; }
}

View File

@ -0,0 +1,7 @@
namespace DysonNetwork.Sphere.Connection.WebReader;
public class ScrapedArticle
{
public LinkEmbed LinkEmbed { get; set; } = null!;
public string? Content { get; set; }
}

View File

@ -0,0 +1,46 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
namespace DysonNetwork.Sphere.Connection.WebReader;
public class WebArticle : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(4096)] public string Title { get; set; } = null!;
[MaxLength(8192)] public string Url { get; set; } = null!;
[MaxLength(4096)] public string? Author { get; set; }
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; }
[Column(TypeName = "jsonb")] public LinkEmbed? Preview { get; set; }
// ReSharper disable once EntityFramework.ModelValidation.UnlimitedStringLength
public string? Content { get; set; }
public DateTime? PublishedAt { get; set; }
public Guid FeedId { get; set; }
public WebFeed Feed { get; set; } = null!;
}
public class WebFeedConfig
{
public bool ScrapPage { get; set; }
}
public class WebFeed : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(8192)] public string Url { get; set; } = null!;
[MaxLength(4096)] public string Title { get; set; } = null!;
[MaxLength(8192)] public string? Description { get; set; }
[Column(TypeName = "jsonb")] public LinkEmbed? Preview { get; set; }
[Column(TypeName = "jsonb")] public WebFeedConfig Config { get; set; } = new();
public Guid PublisherId { get; set; }
public Publisher.Publisher Publisher { get; set; } = null!;
[JsonIgnore] public ICollection<WebArticle> Articles { get; set; } = new List<WebArticle>();
}

View File

@ -0,0 +1,82 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Connection.WebReader;
[ApiController]
[Route("/api/feeds/articles")]
public class WebArticleController(AppDatabase db) : ControllerBase
{
/// <summary>
/// Get a list of recent web articles
/// </summary>
/// <param name="limit">Maximum number of articles to return</param>
/// <param name="offset">Number of articles to skip</param>
/// <param name="feedId">Optional feed ID to filter by</param>
/// <param name="publisherId">Optional publisher ID to filter by</param>
/// <returns>List of web articles</returns>
[HttpGet]
public async Task<IActionResult> GetArticles(
[FromQuery] int limit = 20,
[FromQuery] int offset = 0,
[FromQuery] Guid? feedId = null,
[FromQuery] Guid? publisherId = null
)
{
var query = db.WebArticles
.OrderByDescending(a => a.PublishedAt)
.Include(a => a.Feed)
.AsQueryable();
if (feedId.HasValue)
query = query.Where(a => a.FeedId == feedId.Value);
if (publisherId.HasValue)
query = query.Where(a => a.Feed.PublisherId == publisherId.Value);
var totalCount = await query.CountAsync();
var articles = await query
.Skip(offset)
.Take(limit)
.ToListAsync();
Response.Headers["X-Total"] = totalCount.ToString();
return Ok(articles);
}
/// <summary>
/// Get a specific web article by ID
/// </summary>
/// <param name="id">The article ID</param>
/// <returns>The web article</returns>
[HttpGet("{id:guid}")]
[ProducesResponseType(404)]
public async Task<IActionResult> GetArticle(Guid id)
{
var article = await db.WebArticles
.Include(a => a.Feed)
.FirstOrDefaultAsync(a => a.Id == id);
if (article == null)
return NotFound();
return Ok(article);
}
/// <summary>
/// Get random web articles
/// </summary>
/// <param name="limit">Maximum number of articles to return</param>
/// <returns>List of random web articles</returns>
[HttpGet("random")]
public async Task<IActionResult> GetRandomArticles([FromQuery] int limit = 5)
{
var articles = await db.WebArticles
.OrderBy(_ => EF.Functions.Random())
.Include(a => a.Feed)
.Take(limit)
.ToListAsync();
return Ok(articles);
}
}

View File

@ -0,0 +1,124 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Publisher;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Connection.WebReader;
[Authorize]
[ApiController]
[Route("/api/publishers/{pubName}/feeds")]
public class WebFeedController(WebFeedService webFeed, PublisherService ps) : ControllerBase
{
public record WebFeedRequest(
[MaxLength(8192)] string? Url,
[MaxLength(4096)] string? Title,
[MaxLength(8192)] string? Description,
WebFeedConfig? Config
);
[HttpGet]
public async Task<IActionResult> ListFeeds([FromRoute] string pubName)
{
var publisher = await ps.GetPublisherByName(pubName);
if (publisher is null) return NotFound();
var feeds = await webFeed.GetFeedsByPublisherAsync(publisher.Id);
return Ok(feeds);
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetFeed([FromRoute] string pubName, Guid id)
{
var publisher = await ps.GetPublisherByName(pubName);
if (publisher is null) return NotFound();
var feed = await webFeed.GetFeedAsync(id, publisherId: publisher.Id);
if (feed == null)
return NotFound();
return Ok(feed);
}
[HttpPost]
[Authorize]
public async Task<IActionResult> CreateWebFeed([FromRoute] string pubName, [FromBody] WebFeedRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.Url) || string.IsNullOrWhiteSpace(request.Title))
return BadRequest("Url and title are required");
var publisher = await ps.GetPublisherByName(pubName);
if (publisher is null) return NotFound();
if (!await ps.IsMemberWithRole(publisher.Id, currentUser.Id, PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the publisher to create a web feed");
var feed = await webFeed.CreateWebFeedAsync(publisher, request);
return Ok(feed);
}
[HttpPatch("{id:guid}")]
[Authorize]
public async Task<IActionResult> UpdateFeed([FromRoute] string pubName, Guid id, [FromBody] WebFeedRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var publisher = await ps.GetPublisherByName(pubName);
if (publisher is null) return NotFound();
if (!await ps.IsMemberWithRole(publisher.Id, currentUser.Id, PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the publisher to update a web feed");
var feed = await webFeed.GetFeedAsync(id, publisherId: publisher.Id);
if (feed == null)
return NotFound();
feed = await webFeed.UpdateFeedAsync(feed, request);
return Ok(feed);
}
[HttpDelete("{id:guid}")]
[Authorize]
public async Task<IActionResult> DeleteFeed([FromRoute] string pubName, Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var publisher = await ps.GetPublisherByName(pubName);
if (publisher is null) return NotFound();
if (!await ps.IsMemberWithRole(publisher.Id, currentUser.Id, PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the publisher to delete a web feed");
var feed = await webFeed.GetFeedAsync(id, publisherId: publisher.Id);
if (feed == null)
return NotFound();
var result = await webFeed.DeleteFeedAsync(id);
if (!result)
return NotFound();
return NoContent();
}
[HttpPost("{id:guid}/scrap")]
[Authorize]
public async Task<ActionResult> Scrap([FromRoute] string pubName, Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var publisher = await ps.GetPublisherByName(pubName);
if (publisher is null) return NotFound();
if (!await ps.IsMemberWithRole(publisher.Id, currentUser.Id, PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the publisher to scrape a web feed");
var feed = await webFeed.GetFeedAsync(id, publisherId: publisher.Id);
if (feed == null)
{
return NotFound();
}
await webFeed.ScrapeFeedAsync(feed);
return Ok();
}
}

View File

@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Quartz;
namespace DysonNetwork.Sphere.Connection.WebReader;
[DisallowConcurrentExecution]
public class WebFeedScraperJob(
AppDatabase database,
WebFeedService webFeedService,
ILogger<WebFeedScraperJob> logger
)
: IJob
{
public async Task Execute(IJobExecutionContext context)
{
logger.LogInformation("Starting web feed scraper job.");
var feeds = await database.Set<WebFeed>().ToListAsync(context.CancellationToken);
foreach (var feed in feeds)
{
try
{
await webFeedService.ScrapeFeedAsync(feed, context.CancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to scrape web feed {FeedId}", feed.Id);
}
}
logger.LogInformation("Web feed scraper job finished.");
}
}

View File

@ -0,0 +1,135 @@
using System.ServiceModel.Syndication;
using System.Xml;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Connection.WebReader;
public class WebFeedService(
AppDatabase database,
IHttpClientFactory httpClientFactory,
ILogger<WebFeedService> logger,
WebReaderService webReaderService
)
{
public async Task<WebFeed> CreateWebFeedAsync(Publisher.Publisher publisher,
WebFeedController.WebFeedRequest request)
{
var feed = new WebFeed
{
Url = request.Url!,
Title = request.Title!,
Description = request.Description,
Config = request.Config ?? new WebFeedConfig(),
PublisherId = publisher.Id,
};
database.Set<WebFeed>().Add(feed);
await database.SaveChangesAsync();
return feed;
}
public async Task<WebFeed?> 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);
return await query.FirstOrDefaultAsync();
}
public async Task<List<WebFeed>> GetFeedsByPublisherAsync(Guid publisherId)
{
return await database.WebFeeds.Where(a => a.PublisherId == publisherId).ToListAsync();
}
public async Task<WebFeed> UpdateFeedAsync(WebFeed 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();
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(WebFeed 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<WebArticle>()
.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 webReaderService.ScrapeArticleAsync(itemUrl, cancellationToken);
preview = scrapedArticle.LinkEmbed;
if (scrapedArticle.Content is not null)
content = scrapedArticle.Content;
}
else
{
preview = await webReaderService.GetLinkPreviewAsync(itemUrl, cancellationToken);
}
var newArticle = new WebArticle
{
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);
}
}

View File

@ -0,0 +1,110 @@
using DysonNetwork.Sphere.Permission;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace DysonNetwork.Sphere.Connection.WebReader;
/// <summary>
/// Controller for web scraping and link preview services
/// </summary>
[ApiController]
[Route("/api/scrap")]
[EnableRateLimiting("fixed")]
public class WebReaderController(WebReaderService reader, ILogger<WebReaderController> logger)
: ControllerBase
{
/// <summary>
/// Retrieves a preview for the provided URL
/// </summary>
/// <param name="url">URL-encoded link to generate preview for</param>
/// <returns>Link preview data including title, description, and image</returns>
[HttpGet("link")]
public async Task<ActionResult<LinkEmbed>> ScrapLink([FromQuery] string url)
{
if (string.IsNullOrEmpty(url))
{
return BadRequest(new { error = "URL parameter is required" });
}
try
{
// Ensure URL is properly decoded
var decodedUrl = UrlDecoder.Decode(url);
// Validate URL format
if (!Uri.TryCreate(decodedUrl, UriKind.Absolute, out _))
{
return BadRequest(new { error = "Invalid URL format" });
}
var linkEmbed = await reader.GetLinkPreviewAsync(decodedUrl);
return Ok(linkEmbed);
}
catch (WebReaderException ex)
{
logger.LogWarning(ex, "Error scraping link: {Url}", url);
return BadRequest(new { error = ex.Message });
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected error scraping link: {Url}", url);
return StatusCode(StatusCodes.Status500InternalServerError,
new { error = "An unexpected error occurred while processing the link" });
}
}
/// <summary>
/// Force invalidates the cache for a specific URL
/// </summary>
[HttpDelete("link/cache")]
[Authorize]
[RequiredPermission("maintenance", "cache.scrap")]
public async Task<IActionResult> InvalidateCache([FromQuery] string url)
{
if (string.IsNullOrEmpty(url))
{
return BadRequest(new { error = "URL parameter is required" });
}
await reader.InvalidateCacheForUrlAsync(url);
return Ok(new { message = "Cache invalidated for URL" });
}
/// <summary>
/// Force invalidates all cached link previews
/// </summary>
[HttpDelete("cache/all")]
[Authorize]
[RequiredPermission("maintenance", "cache.scrap")]
public async Task<IActionResult> InvalidateAllCache()
{
await reader.InvalidateAllCachedPreviewsAsync();
return Ok(new { message = "All link preview caches invalidated" });
}
}
/// <summary>
/// Helper class for URL decoding
/// </summary>
public static class UrlDecoder
{
public static string Decode(string url)
{
// First check if URL is already decoded
if (!url.Contains('%') && !url.Contains('+'))
{
return url;
}
try
{
return System.Net.WebUtility.UrlDecode(url);
}
catch
{
// If decoding fails, return the original string
return url;
}
}
}

View File

@ -0,0 +1,17 @@
using System;
namespace DysonNetwork.Sphere.Connection.WebReader;
/// <summary>
/// Exception thrown when an error occurs during web reading operations
/// </summary>
public class WebReaderException : Exception
{
public WebReaderException(string message) : base(message)
{
}
public WebReaderException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@ -0,0 +1,365 @@
using System.Globalization;
using AngleSharp;
using AngleSharp.Dom;
using DysonNetwork.Sphere.Storage;
using HtmlAgilityPack;
namespace DysonNetwork.Sphere.Connection.WebReader;
/// <summary>
/// The service is amin to providing scrapping service to the Solar Network.
/// Such as news feed, external articles and link preview.
/// </summary>
public class WebReaderService(
IHttpClientFactory httpClientFactory,
ILogger<WebReaderService> logger,
ICacheService cache)
{
private const string LinkPreviewCachePrefix = "scrap:preview:";
private const string LinkPreviewCacheGroup = "scrap:preview";
public async Task<ScrapedArticle> ScrapeArticleAsync(string url, CancellationToken cancellationToken = default)
{
var linkEmbed = await GetLinkPreviewAsync(url, cancellationToken);
var content = await GetArticleContentAsync(url, cancellationToken);
return new ScrapedArticle
{
LinkEmbed = linkEmbed,
Content = content
};
}
private async Task<string?> GetArticleContentAsync(string url, CancellationToken cancellationToken)
{
var httpClient = httpClientFactory.CreateClient("WebReader");
var response = await httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
logger.LogWarning("Failed to scrap article content for URL: {Url}", url);
return null;
}
var html = await response.Content.ReadAsStringAsync(cancellationToken);
var doc = new HtmlDocument();
doc.LoadHtml(html);
var articleNode = doc.DocumentNode.SelectSingleNode("//article");
return articleNode?.InnerHtml;
}
/// <summary>
/// Generate a link preview embed from a URL
/// </summary>
/// <param name="url">The URL to generate the preview for</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <param name="bypassCache">If true, bypass cache and fetch fresh data</param>
/// <param name="cacheExpiry">Custom cache expiration time</param>
/// <returns>A LinkEmbed object containing the preview data</returns>
public async Task<LinkEmbed> GetLinkPreviewAsync(
string url,
CancellationToken cancellationToken = default,
TimeSpan? cacheExpiry = null,
bool bypassCache = false
)
{
// Ensure URL is valid
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
throw new ArgumentException(@"Invalid URL format", nameof(url));
}
// Try to get from cache if not bypassing
if (!bypassCache)
{
var cachedPreview = await GetCachedLinkPreview(url);
if (cachedPreview is not null)
return cachedPreview;
}
// Cache miss or bypass, fetch fresh data
logger.LogDebug("Fetching fresh link preview for URL: {Url}", url);
var httpClient = httpClientFactory.CreateClient("WebReader");
httpClient.MaxResponseContentBufferSize =
10 * 1024 * 1024; // 10MB, prevent scrap some directly accessible files
httpClient.Timeout = TimeSpan.FromSeconds(3);
// Setting UA to facebook's bot to get the opengraph.
httpClient.DefaultRequestHeaders.Add("User-Agent", "facebookexternalhit/1.1");
try
{
var response = await httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
var contentType = response.Content.Headers.ContentType?.MediaType;
if (contentType == null || !contentType.StartsWith("text/html"))
{
logger.LogWarning("URL is not an HTML page: {Url}, ContentType: {ContentType}", url, contentType);
var nonHtmlEmbed = new LinkEmbed
{
Url = url,
Title = uri.Host,
ContentType = contentType
};
// Cache non-HTML responses too
await CacheLinkPreview(nonHtmlEmbed, url, cacheExpiry);
return nonHtmlEmbed;
}
var html = await response.Content.ReadAsStringAsync(cancellationToken);
var linkEmbed = await ExtractLinkData(url, html, uri);
// Cache the result
await CacheLinkPreview(linkEmbed, url, cacheExpiry);
return linkEmbed;
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "Failed to fetch URL: {Url}", url);
throw new WebReaderException($"Failed to fetch URL: {url}", ex);
}
}
private async Task<LinkEmbed> ExtractLinkData(string url, string html, Uri uri)
{
var embed = new LinkEmbed
{
Url = url
};
// Configure AngleSharp context
var config = Configuration.Default;
var context = BrowsingContext.New(config);
var document = await context.OpenAsync(req => req.Content(html));
// Extract OpenGraph tags
var ogTitle = GetMetaTagContent(document, "og:title");
var ogDescription = GetMetaTagContent(document, "og:description");
var ogImage = GetMetaTagContent(document, "og:image");
var ogSiteName = GetMetaTagContent(document, "og:site_name");
var ogType = GetMetaTagContent(document, "og:type");
// Extract Twitter card tags as fallback
var twitterTitle = GetMetaTagContent(document, "twitter:title");
var twitterDescription = GetMetaTagContent(document, "twitter:description");
var twitterImage = GetMetaTagContent(document, "twitter:image");
// Extract standard meta tags as final fallback
var metaTitle = GetMetaTagContent(document, "title") ??
GetMetaContent(document, "title");
var metaDescription = GetMetaTagContent(document, "description");
// Extract page title
var pageTitle = document.Title?.Trim();
// Extract publish date
var publishedTime = GetMetaTagContent(document, "article:published_time") ??
GetMetaTagContent(document, "datePublished") ??
GetMetaTagContent(document, "pubdate");
// Extract author
var author = GetMetaTagContent(document, "author") ??
GetMetaTagContent(document, "article:author");
// Extract favicon
var faviconUrl = GetFaviconUrl(document, uri);
// Populate the embed with the data, prioritizing OpenGraph
embed.Title = ogTitle ?? twitterTitle ?? metaTitle ?? pageTitle ?? uri.Host;
embed.Description = ogDescription ?? twitterDescription ?? metaDescription;
embed.ImageUrl = ResolveRelativeUrl(ogImage ?? twitterImage, uri);
embed.SiteName = ogSiteName ?? uri.Host;
embed.ContentType = ogType;
embed.FaviconUrl = faviconUrl;
embed.Author = author;
// Parse and set published date
if (!string.IsNullOrEmpty(publishedTime) &&
DateTime.TryParse(publishedTime, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal,
out DateTime parsedDate))
{
embed.PublishedDate = parsedDate;
}
return embed;
}
private static string? GetMetaTagContent(IDocument doc, string property)
{
// Check for OpenGraph/Twitter style meta tags
var node = doc.QuerySelector($"meta[property='{property}'][content]")
?? doc.QuerySelector($"meta[name='{property}'][content]");
return node?.GetAttribute("content")?.Trim();
}
private static string? GetMetaContent(IDocument doc, string name)
{
var node = doc.QuerySelector($"meta[name='{name}'][content]");
return node?.GetAttribute("content")?.Trim();
}
private static string? GetFaviconUrl(IDocument doc, Uri baseUri)
{
// Look for apple-touch-icon first as it's typically higher quality
var appleIconNode = doc.QuerySelector("link[rel='apple-touch-icon'][href]");
if (appleIconNode != null)
{
return ResolveRelativeUrl(appleIconNode.GetAttribute("href"), baseUri);
}
// Then check for standard favicon
var faviconNode = doc.QuerySelector("link[rel='icon'][href]") ??
doc.QuerySelector("link[rel='shortcut icon'][href]");
return faviconNode != null
? ResolveRelativeUrl(faviconNode.GetAttribute("href"), baseUri)
: new Uri(baseUri, "/favicon.ico").ToString();
}
private static string? ResolveRelativeUrl(string? url, Uri baseUri)
{
if (string.IsNullOrEmpty(url))
{
return null;
}
if (Uri.TryCreate(url, UriKind.Absolute, out _))
{
return url; // Already absolute
}
return Uri.TryCreate(baseUri, url, out var absoluteUri) ? absoluteUri.ToString() : null;
}
/// <summary>
/// Generate a hash-based cache key for a URL
/// </summary>
private string GenerateUrlCacheKey(string url)
{
// Normalize the URL first
var normalizedUrl = NormalizeUrl(url);
// Create SHA256 hash of the normalized URL
using var sha256 = System.Security.Cryptography.SHA256.Create();
var urlBytes = System.Text.Encoding.UTF8.GetBytes(normalizedUrl);
var hashBytes = sha256.ComputeHash(urlBytes);
// Convert to hex string
var hashString = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
// Return prefixed key
return $"{LinkPreviewCachePrefix}{hashString}";
}
/// <summary>
/// Normalize URL by trimming trailing slashes but preserving query parameters
/// </summary>
private string NormalizeUrl(string url)
{
if (string.IsNullOrEmpty(url))
return string.Empty;
// First ensure we have a valid URI
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
return url.TrimEnd('/');
// Rebuild the URL without trailing slashes but with query parameters
var scheme = uri.Scheme;
var host = uri.Host;
var port = uri.IsDefaultPort ? string.Empty : $":{uri.Port}";
var path = uri.AbsolutePath.TrimEnd('/');
var query = uri.Query;
return $"{scheme}://{host}{port}{path}{query}".ToLowerInvariant();
}
/// <summary>
/// Cache a link preview
/// </summary>
private async Task CacheLinkPreview(LinkEmbed? linkEmbed, string url, TimeSpan? expiry = null)
{
if (linkEmbed == null || string.IsNullOrEmpty(url))
return;
try
{
var cacheKey = GenerateUrlCacheKey(url);
var expiryTime = expiry ?? TimeSpan.FromHours(24);
await cache.SetWithGroupsAsync(
cacheKey,
linkEmbed,
[LinkPreviewCacheGroup],
expiryTime);
logger.LogDebug("Cached link preview for URL: {Url} with key: {CacheKey}", url, cacheKey);
}
catch (Exception ex)
{
// Log but don't throw - caching failures shouldn't break the main functionality
logger.LogWarning(ex, "Failed to cache link preview for URL: {Url}", url);
}
}
/// <summary>
/// Try to get a cached link preview
/// </summary>
private async Task<LinkEmbed?> GetCachedLinkPreview(string url)
{
if (string.IsNullOrEmpty(url))
return null;
try
{
var cacheKey = GenerateUrlCacheKey(url);
var cachedPreview = await cache.GetAsync<LinkEmbed>(cacheKey);
if (cachedPreview is not null)
logger.LogDebug("Retrieved cached link preview for URL: {Url}", url);
return cachedPreview;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to retrieve cached link preview for URL: {Url}", url);
return null;
}
}
/// <summary>
/// Invalidate cache for a specific URL
/// </summary>
public async Task InvalidateCacheForUrlAsync(string url)
{
if (string.IsNullOrEmpty(url))
return;
try
{
var cacheKey = GenerateUrlCacheKey(url);
await cache.RemoveAsync(cacheKey);
logger.LogDebug("Invalidated cache for URL: {Url} with key: {CacheKey}", url, cacheKey);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to invalidate cache for URL: {Url}", url);
}
}
/// <summary>
/// Invalidate all cached link previews
/// </summary>
public async Task InvalidateAllCachedPreviewsAsync()
{
try
{
await cache.RemoveGroupAsync(LinkPreviewCacheGroup);
logger.LogInformation("Invalidated all cached link previews");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to invalidate all cached link previews");
}
}
}

View File

@ -0,0 +1,108 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore.Metadata.Internal;
using Swashbuckle.AspNetCore.Annotations;
namespace DysonNetwork.Sphere.Connection;
[ApiController]
[Route("/ws")]
public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext> logger) : ControllerBase
{
[Route("/ws")]
[Authorize]
[SwaggerIgnore]
public async Task TheGateway()
{
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue);
if (currentUserValue is not Account.Account currentUser ||
currentSessionValue is not Auth.Session currentSession)
{
HttpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
var accountId = currentUser.Id;
var deviceId = currentSession.Challenge.DeviceId;
if (string.IsNullOrEmpty(deviceId))
{
HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
using var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
var cts = new CancellationTokenSource();
var connectionKey = (accountId, deviceId);
if (!ws.TryAdd(connectionKey, webSocket, cts))
{
await webSocket.CloseAsync(
WebSocketCloseStatus.InternalServerError,
"Failed to establish connection.",
CancellationToken.None
);
return;
}
logger.LogInformation(
$"Connection established with user @{currentUser.Name}#{currentUser.Id} and device #{deviceId}");
try
{
await _ConnectionEventLoop(deviceId, currentUser, webSocket, cts.Token);
}
catch (Exception ex)
{
Console.WriteLine($"WebSocket Error: {ex.Message}");
}
finally
{
ws.Disconnect(connectionKey);
logger.LogInformation(
$"Connection disconnected with user @{currentUser.Name}#{currentUser.Id} and device #{deviceId}");
}
}
private async Task _ConnectionEventLoop(
string deviceId,
Account.Account currentUser,
WebSocket webSocket,
CancellationToken cancellationToken
)
{
var connectionKey = (AccountId: currentUser.Id, DeviceId: deviceId);
var buffer = new byte[1024 * 4];
try
{
var receiveResult = await webSocket.ReceiveAsync(
new ArraySegment<byte>(buffer),
cancellationToken
);
while (!receiveResult.CloseStatus.HasValue)
{
receiveResult = await webSocket.ReceiveAsync(
new ArraySegment<byte>(buffer),
cancellationToken
);
var packet = WebSocketPacket.FromBytes(buffer[..receiveResult.Count]);
_ = ws.HandlePacket(currentUser, connectionKey.DeviceId, packet, webSocket);
}
}
catch (OperationCanceledException)
{
if (
webSocket.State != WebSocketState.Closed
&& webSocket.State != WebSocketState.Aborted
)
{
ws.Disconnect(connectionKey);
}
}
}
}

View File

@ -0,0 +1,72 @@
using System.Text.Json;
using NodaTime;
using NodaTime.Serialization.SystemTextJson;
public class WebSocketPacketType
{
public const string Error = "error";
public const string MessageNew = "messages.new";
public const string MessageUpdate = "messages.update";
public const string MessageDelete = "messages.delete";
public const string CallParticipantsUpdate = "call.participants.update";
}
public class WebSocketPacket
{
public string Type { get; set; } = null!;
public object Data { get; set; } = null!;
public string? ErrorMessage { get; set; }
/// <summary>
/// Creates a WebSocketPacket from raw WebSocket message bytes
/// </summary>
/// <param name="bytes">Raw WebSocket message bytes</param>
/// <returns>Deserialized WebSocketPacket</returns>
public static WebSocketPacket FromBytes(byte[] bytes)
{
var json = System.Text.Encoding.UTF8.GetString(bytes);
var jsonOpts = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower,
};
return JsonSerializer.Deserialize<WebSocketPacket>(json, jsonOpts) ??
throw new JsonException("Failed to deserialize WebSocketPacket");
}
/// <summary>
/// Deserializes the Data property to the specified type T
/// </summary>
/// <typeparam name="T">Target type to deserialize to</typeparam>
/// <returns>Deserialized data of type T</returns>
public T? GetData<T>()
{
if (Data is T typedData)
return typedData;
var jsonOpts = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower,
};
return JsonSerializer.Deserialize<T>(
JsonSerializer.Serialize(Data, jsonOpts),
jsonOpts
);
}
/// <summary>
/// Serializes this WebSocketPacket to a byte array for sending over WebSocket
/// </summary>
/// <returns>Byte array representation of the packet</returns>
public byte[] ToBytes()
{
var jsonOpts = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower,
}.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
var json = JsonSerializer.Serialize(this, jsonOpts);
return System.Text.Encoding.UTF8.GetBytes(json);
}
}

View File

@ -0,0 +1,129 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
namespace DysonNetwork.Sphere.Connection;
public class WebSocketService
{
private readonly IDictionary<string, IWebSocketPacketHandler> _handlerMap;
public WebSocketService(IEnumerable<IWebSocketPacketHandler> handlers)
{
_handlerMap = handlers.ToDictionary(h => h.PacketType);
}
private static readonly ConcurrentDictionary<
(Guid AccountId, string DeviceId),
(WebSocket Socket, CancellationTokenSource Cts)
> ActiveConnections = new();
private static readonly ConcurrentDictionary<string, string> ActiveSubscriptions = new(); // deviceId -> chatRoomId
public void SubscribeToChatRoom(string chatRoomId, string deviceId)
{
ActiveSubscriptions[deviceId] = chatRoomId;
}
public void UnsubscribeFromChatRoom(string deviceId)
{
ActiveSubscriptions.TryRemove(deviceId, out _);
}
public bool IsUserSubscribedToChatRoom(Guid accountId, string chatRoomId)
{
var userDeviceIds = ActiveConnections.Keys.Where(k => k.AccountId == accountId).Select(k => k.DeviceId);
foreach (var deviceId in userDeviceIds)
{
if (ActiveSubscriptions.TryGetValue(deviceId, out var subscribedChatRoomId) && subscribedChatRoomId == chatRoomId)
{
return true;
}
}
return false;
}
public bool TryAdd(
(Guid AccountId, string DeviceId) key,
WebSocket socket,
CancellationTokenSource cts
)
{
if (ActiveConnections.TryGetValue(key, out _))
Disconnect(key,
"Just connected somewhere else with the same identifier."); // Disconnect the previous one using the same identifier
return ActiveConnections.TryAdd(key, (socket, cts));
}
public void Disconnect((Guid AccountId, string DeviceId) key, string? reason = null)
{
if (!ActiveConnections.TryGetValue(key, out var data)) return;
data.Socket.CloseAsync(
WebSocketCloseStatus.NormalClosure,
reason ?? "Server just decided to disconnect.",
CancellationToken.None
);
data.Cts.Cancel();
ActiveConnections.TryRemove(key, out _);
UnsubscribeFromChatRoom(key.DeviceId);
}
public bool GetAccountIsConnected(Guid accountId)
{
return ActiveConnections.Any(c => c.Key.AccountId == accountId);
}
public void SendPacketToAccount(Guid userId, WebSocketPacket packet)
{
var connections = ActiveConnections.Where(c => c.Key.AccountId == userId);
var packetBytes = packet.ToBytes();
var segment = new ArraySegment<byte>(packetBytes);
foreach (var connection in connections)
{
connection.Value.Socket.SendAsync(
segment,
WebSocketMessageType.Binary,
true,
CancellationToken.None
);
}
}
public void SendPacketToDevice(string deviceId, WebSocketPacket packet)
{
var connections = ActiveConnections.Where(c => c.Key.DeviceId == deviceId);
var packetBytes = packet.ToBytes();
var segment = new ArraySegment<byte>(packetBytes);
foreach (var connection in connections)
{
connection.Value.Socket.SendAsync(
segment,
WebSocketMessageType.Binary,
true,
CancellationToken.None
);
}
}
public async Task HandlePacket(Account.Account currentUser, string deviceId, WebSocketPacket packet,
WebSocket socket)
{
if (_handlerMap.TryGetValue(packet.Type, out var handler))
{
await handler.HandleAsync(currentUser, deviceId, packet, socket, this);
return;
}
await socket.SendAsync(
new ArraySegment<byte>(new WebSocketPacket
{
Type = WebSocketPacketType.Error,
ErrorMessage = $"Unprocessable packet: {packet.Type}"
}.ToBytes()),
WebSocketMessageType.Binary,
true,
CancellationToken.None
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,139 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Sphere.Data.Migrations
{
/// <inheritdoc />
public partial class AddOidcProviderSupport : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "remarks",
table: "custom_app_secrets",
newName: "description");
migrationBuilder.AddColumn<bool>(
name: "allow_offline_access",
table: "custom_apps",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "allowed_grant_types",
table: "custom_apps",
type: "character varying(256)",
maxLength: 256,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "allowed_scopes",
table: "custom_apps",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "client_uri",
table: "custom_apps",
type: "character varying(1024)",
maxLength: 1024,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "logo_uri",
table: "custom_apps",
type: "character varying(4096)",
maxLength: 4096,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "post_logout_redirect_uris",
table: "custom_apps",
type: "character varying(4096)",
maxLength: 4096,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "redirect_uris",
table: "custom_apps",
type: "character varying(4096)",
maxLength: 4096,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<bool>(
name: "require_pkce",
table: "custom_apps",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "is_oidc",
table: "custom_app_secrets",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.CreateIndex(
name: "ix_custom_app_secrets_secret",
table: "custom_app_secrets",
column: "secret",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_custom_app_secrets_secret",
table: "custom_app_secrets");
migrationBuilder.DropColumn(
name: "allow_offline_access",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "allowed_grant_types",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "allowed_scopes",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "client_uri",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "logo_uri",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "post_logout_redirect_uris",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "redirect_uris",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "require_pkce",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "is_oidc",
table: "custom_app_secrets");
migrationBuilder.RenameColumn(
name: "description",
table: "custom_app_secrets",
newName: "remarks");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Sphere.Data.Migrations
{
/// <inheritdoc />
public partial class AuthSessionWithApp : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "app_id",
table: "auth_sessions",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "ix_auth_sessions_app_id",
table: "auth_sessions",
column: "app_id");
migrationBuilder.AddForeignKey(
name: "fk_auth_sessions_custom_apps_app_id",
table: "auth_sessions",
column: "app_id",
principalTable: "custom_apps",
principalColumn: "id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_auth_sessions_custom_apps_app_id",
table: "auth_sessions");
migrationBuilder.DropIndex(
name: "ix_auth_sessions_app_id",
table: "auth_sessions");
migrationBuilder.DropColumn(
name: "app_id",
table: "auth_sessions");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,182 @@
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Sphere.Data.Migrations
{
/// <inheritdoc />
public partial class CustomAppsRefine : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "allow_offline_access",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "allowed_grant_types",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "allowed_scopes",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "client_uri",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "logo_uri",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "post_logout_redirect_uris",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "redirect_uris",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "require_pkce",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "verified_at",
table: "custom_apps");
migrationBuilder.RenameColumn(
name: "verified_as",
table: "custom_apps",
newName: "description");
migrationBuilder.AddColumn<CloudFileReferenceObject>(
name: "background",
table: "custom_apps",
type: "jsonb",
nullable: true);
migrationBuilder.AddColumn<CustomAppLinks>(
name: "links",
table: "custom_apps",
type: "jsonb",
nullable: true);
migrationBuilder.AddColumn<CustomAppOauthConfig>(
name: "oauth_config",
table: "custom_apps",
type: "jsonb",
nullable: true);
migrationBuilder.AddColumn<CloudFileReferenceObject>(
name: "picture",
table: "custom_apps",
type: "jsonb",
nullable: true);
migrationBuilder.AddColumn<VerificationMark>(
name: "verification",
table: "custom_apps",
type: "jsonb",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "background",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "links",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "oauth_config",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "picture",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "verification",
table: "custom_apps");
migrationBuilder.RenameColumn(
name: "description",
table: "custom_apps",
newName: "verified_as");
migrationBuilder.AddColumn<bool>(
name: "allow_offline_access",
table: "custom_apps",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "allowed_grant_types",
table: "custom_apps",
type: "character varying(256)",
maxLength: 256,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "allowed_scopes",
table: "custom_apps",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "client_uri",
table: "custom_apps",
type: "character varying(1024)",
maxLength: 1024,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "logo_uri",
table: "custom_apps",
type: "character varying(4096)",
maxLength: 4096,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "post_logout_redirect_uris",
table: "custom_apps",
type: "character varying(4096)",
maxLength: 4096,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "redirect_uris",
table: "custom_apps",
type: "character varying(4096)",
maxLength: 4096,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<bool>(
name: "require_pkce",
table: "custom_apps",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<Instant>(
name: "verified_at",
table: "custom_apps",
type: "timestamp with time zone",
nullable: true);
}
}
}

View File

@ -0,0 +1,69 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using NodaTime;
namespace DysonNetwork.Sphere.Developer;
public enum CustomAppStatus
{
Developing,
Staging,
Production,
Suspended
}
public class CustomApp : ModelBase, IIdentifiedResource
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Slug { get; set; } = null!;
[MaxLength(1024)] public string Name { get; set; } = null!;
[MaxLength(4096)] public string? Description { get; set; }
public CustomAppStatus Status { get; set; } = CustomAppStatus.Developing;
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
[Column(TypeName = "jsonb")] public CustomAppOauthConfig? OauthConfig { get; set; }
[Column(TypeName = "jsonb")] public CustomAppLinks? Links { get; set; }
[JsonIgnore] public ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>();
public Guid PublisherId { get; set; }
public Publisher.Publisher Developer { get; set; } = null!;
[NotMapped] public string ResourceIdentifier => "custom-app/" + Id;
}
public class CustomAppLinks
{
[MaxLength(8192)] public string? HomePage { get; set; }
[MaxLength(8192)] public string? PrivacyPolicy { get; set; }
[MaxLength(8192)] public string? TermsOfService { get; set; }
}
public class CustomAppOauthConfig
{
[MaxLength(1024)] public string? ClientUri { get; set; }
[MaxLength(4096)] public string[] RedirectUris { get; set; } = [];
[MaxLength(4096)] public string[]? PostLogoutRedirectUris { get; set; }
[MaxLength(256)] public string[]? AllowedScopes { get; set; } = ["openid", "profile", "email"];
[MaxLength(256)] public string[] AllowedGrantTypes { get; set; } = ["authorization_code", "refresh_token"];
public bool RequirePkce { get; set; } = true;
public bool AllowOfflineAccess { get; set; } = false;
}
public class CustomAppSecret : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Secret { get; set; } = null!;
[MaxLength(4096)] public string? Description { get; set; } = null!;
public Instant? ExpiredAt { get; set; }
public bool IsOidc { get; set; } = false; // Indicates if this secret is for OIDC/OAuth
public Guid AppId { get; set; }
public CustomApp App { get; set; } = null!;
}

View File

@ -0,0 +1,129 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Publisher;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Developer;
[ApiController]
[Route("/api/developers/{pubName}/apps")]
public class CustomAppController(CustomAppService customApps, PublisherService ps) : ControllerBase
{
public record CustomAppRequest(
[MaxLength(1024)] string? Slug,
[MaxLength(1024)] string? Name,
[MaxLength(4096)] string? Description,
string? PictureId,
string? BackgroundId,
CustomAppStatus? Status,
CustomAppLinks? Links,
CustomAppOauthConfig? OauthConfig
);
[HttpGet]
public async Task<IActionResult> ListApps([FromRoute] string pubName)
{
var publisher = await ps.GetPublisherByName(pubName);
if (publisher is null) return NotFound();
var apps = await customApps.GetAppsByPublisherAsync(publisher.Id);
return Ok(apps);
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetApp([FromRoute] string pubName, Guid id)
{
var publisher = await ps.GetPublisherByName(pubName);
if (publisher is null) return NotFound();
var app = await customApps.GetAppAsync(id, publisherId: publisher.Id);
if (app == null)
return NotFound();
return Ok(app);
}
[HttpPost]
[Authorize]
public async Task<IActionResult> CreateApp([FromRoute] string pubName, [FromBody] CustomAppRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Slug))
return BadRequest("Name and slug are required");
var publisher = await ps.GetPublisherByName(pubName);
if (publisher is null) return NotFound();
if (!await ps.IsMemberWithRole(publisher.Id, currentUser.Id, PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the publisher to create a custom app");
if (!await ps.HasFeature(publisher.Id, PublisherFeatureFlag.Develop))
return StatusCode(403, "Publisher must be a developer to create a custom app");
try
{
var app = await customApps.CreateAppAsync(publisher, request);
return Ok(app);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[HttpPatch("{id:guid}")]
[Authorize]
public async Task<IActionResult> UpdateApp(
[FromRoute] string pubName,
[FromRoute] Guid id,
[FromBody] CustomAppRequest request
)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var publisher = await ps.GetPublisherByName(pubName);
if (publisher is null) return NotFound();
if (!await ps.IsMemberWithRole(publisher.Id, currentUser.Id, PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the publisher to update a custom app");
var app = await customApps.GetAppAsync(id, publisherId: publisher.Id);
if (app == null)
return NotFound();
try
{
app = await customApps.UpdateAppAsync(app, request);
return Ok(app);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[HttpDelete("{id:guid}")]
[Authorize]
public async Task<IActionResult> DeleteApp(
[FromRoute] string pubName,
[FromRoute] Guid id
)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var publisher = await ps.GetPublisherByName(pubName);
if (publisher is null) return NotFound();
if (!await ps.IsMemberWithRole(publisher.Id, currentUser.Id, PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the publisher to delete a custom app");
var app = await customApps.GetAppAsync(id, publisherId: publisher.Id);
if (app == null)
return NotFound();
var result = await customApps.DeleteAppAsync(id);
if (!result)
return NotFound();
return NoContent();
}
}

View File

@ -0,0 +1,150 @@
using DysonNetwork.Sphere.Publisher;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Developer;
public class CustomAppService(AppDatabase db, FileReferenceService fileRefService)
{
public async Task<CustomApp?> CreateAppAsync(
Publisher.Publisher pub,
CustomAppController.CustomAppRequest request
)
{
var app = new CustomApp
{
Slug = request.Slug!,
Name = request.Name!,
Description = request.Description,
Status = request.Status ?? CustomAppStatus.Developing,
Links = request.Links,
OauthConfig = request.OauthConfig,
PublisherId = pub.Id
};
if (request.PictureId is not null)
{
var picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync();
if (picture is null)
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
app.Picture = picture.ToReferenceObject();
// Create a new reference
await fileRefService.CreateReferenceAsync(
picture.Id,
"custom-apps.picture",
app.ResourceIdentifier
);
}
if (request.BackgroundId is not null)
{
var background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync();
if (background is null)
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
app.Background = background.ToReferenceObject();
// Create a new reference
await fileRefService.CreateReferenceAsync(
background.Id,
"custom-apps.background",
app.ResourceIdentifier
);
}
db.CustomApps.Add(app);
await db.SaveChangesAsync();
return app;
}
public async Task<CustomApp?> GetAppAsync(Guid id, Guid? publisherId = null)
{
var query = db.CustomApps.Where(a => a.Id == id).AsQueryable();
if (publisherId.HasValue)
query = query.Where(a => a.PublisherId == publisherId.Value);
return await query.FirstOrDefaultAsync();
}
public async Task<List<CustomApp>> GetAppsByPublisherAsync(Guid publisherId)
{
return await db.CustomApps.Where(a => a.PublisherId == publisherId).ToListAsync();
}
public async Task<CustomApp?> UpdateAppAsync(CustomApp app, CustomAppController.CustomAppRequest request)
{
if (request.Slug is not null)
app.Slug = request.Slug;
if (request.Name is not null)
app.Name = request.Name;
if (request.Description is not null)
app.Description = request.Description;
if (request.Status is not null)
app.Status = request.Status.Value;
if (request.Links is not null)
app.Links = request.Links;
if (request.OauthConfig is not null)
app.OauthConfig = request.OauthConfig;
if (request.PictureId is not null)
{
var picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync();
if (picture is null)
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
if (app.Picture is not null)
await fileRefService.DeleteResourceReferencesAsync(app.ResourceIdentifier, "custom-apps.picture");
app.Picture = picture.ToReferenceObject();
// Create a new reference
await fileRefService.CreateReferenceAsync(
picture.Id,
"custom-apps.picture",
app.ResourceIdentifier
);
}
if (request.BackgroundId is not null)
{
var background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync();
if (background is null)
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
if (app.Background is not null)
await fileRefService.DeleteResourceReferencesAsync(app.ResourceIdentifier, "custom-apps.background");
app.Background = background.ToReferenceObject();
// Create a new reference
await fileRefService.CreateReferenceAsync(
background.Id,
"custom-apps.background",
app.ResourceIdentifier
);
}
db.Update(app);
await db.SaveChangesAsync();
return app;
}
public async Task<bool> DeleteAppAsync(Guid id)
{
var app = await db.CustomApps.FindAsync(id);
if (app == null)
{
return false;
}
db.CustomApps.Remove(app);
await db.SaveChangesAsync();
await fileRefService.DeleteResourceReferencesAsync(app.ResourceIdentifier);
return true;
}
}

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