Compare commits

...

170 Commits

Author SHA1 Message Date
b6d416a3a8 🔀 Merge pull request '更新 DysonNetwork.Sphere/Resources/Localization/AccountEventResource.zh-hans.resx' (#3) from a123lsw-patch-1 into master
Reviewed-on: #3
Reviewed-by: LittleSheep <littlesheep@noreply.localhost>
2025-07-12 13:57:17 +00:00
2a8cbbfa24 更新 DysonNetwork.Sphere/Resources/Localization/AccountEventResource.zh-hans.resx
先提一下。
但是VS好像把我格式改了,删了空格(应该是没仔细看),应该没有影响吧
2025-07-12 11:37:25 +00:00
29d752bdd9 🔀 Merge pull request 'Add Cloudflare (Dyte) as a provider of call' (#2) from refactor/cloudflare-call into master
Reviewed-on: #2
2025-07-11 18:28:06 +00:00
LittleSheep
b12e3315fe 🐛 Fixes of bugs 2025-07-12 01:54:00 +08:00
ce3958d397 🔀 Merge pull request '更新 DysonNetwork.Sphere/Resources/Localization/AccountEventResource.zh-hans.resx' (#1) from a123lsw-patch-1 into master
Reviewed-on: #1
Reviewed-by: LittleSheep <littlesheep@noreply.localhost>
2025-07-11 17:26:23 +00:00
26ea2503a4 更新 DysonNetwork.Sphere/Resources/Localization/AccountEventResource.zh-hans.resx
2025-07-11 16:11:15 +00:00
d6ce068490 更新 DysonNetwork.Sphere/Resources/Localization/AccountEventResource.zh-hans.resx 2025-07-11 16:02:15 +00:00
LittleSheep
da4ee81c95 🐛 Bug fixes on swagger ui 2025-07-11 23:41:39 +08:00
LittleSheep
bec294365f ♻️ Refactored webhook receiver in realtime call 2025-07-11 23:07:32 +08:00
51a8b684fd 更新 DysonNetwork.Sphere/Resources/Localization/AccountEventResource.zh-hans.resx 2025-07-11 14:36:31 +00:00
LittleSheep
7b026eeae1 🧱 Add cloudflare realtime service 2025-07-11 21:07:38 +08:00
4dd4542c37 更新 DysonNetwork.Sphere/Resources/Localization/AccountEventResource.zh-hans.resx
突然想起来忘上传了
2025-07-10 17:23:58 +00:00
LittleSheep
2a3918134f 🐛 More file endpoint override options 2025-07-10 21:16:03 +08:00
LittleSheep
734e5ca4a0 File opening response type overriding 2025-07-10 19:49:55 +08:00
LittleSheep
ff0789904d Improved open file endpoint 2025-07-10 19:28:15 +08:00
LittleSheep
17330fc104 🐛 Fixes for websocket 2025-07-10 15:57:00 +08:00
LittleSheep
7c0ad46deb 🐛 Fix api redirect 2025-07-10 15:30:30 +08:00
LittleSheep
b8fcd0d94f Post web view 2025-07-10 15:15:20 +08:00
LittleSheep
fc6edd7378 💥 Add /api prefix for json endpoints with redirect 2025-07-10 14:18:02 +08:00
LittleSheep
1f2cdb146d 🐛 Serval bug fixes 2025-07-10 12:53:45 +08:00
LittleSheep
be236a27c6 🐛 Serval bug fixes and improvement to web page 2025-07-10 03:08:39 +08:00
LittleSheep
99c36ae548 💄 Optimized the authorized page style 2025-07-10 02:28:27 +08:00
LittleSheep
ed2961a5d5 💄 Restyled web pages 2025-07-10 01:53:44 +08:00
LittleSheep
08b5ffa02f 🐛 Fix afdian got wrong URL to request 2025-07-09 22:51:14 +08:00
LittleSheep
837a123c3b 🐛 Trying to fix payment handler 2025-07-09 22:00:06 +08:00
LittleSheep
ad1166190f 🐛 Bug fixes 2025-07-09 21:43:39 +08:00
LittleSheep
8e8c938132 🐛 Fix restore purchase in afdian 2025-07-09 21:39:38 +08:00
LittleSheep
8e5b6ace45 Skip subscribed check in message 2025-07-07 13:08:31 +08:00
LittleSheep
5757526ea5 Get user blocked users infra 2025-07-03 21:57:16 +08:00
LittleSheep
6a9cd0905d Unblock user 2025-07-03 21:47:17 +08:00
LittleSheep
082a096470 🐛 Fix wrong pagination query param name 2025-07-03 13:14:19 +08:00
LittleSheep
3a72347432 🐛 Fix inconsistent between authorized feed and unauthorized feed 2025-07-03 00:43:02 +08:00
LittleSheep
19b1e957dd 🐛 Trying to fix push notification 2025-07-03 00:05:00 +08:00
LittleSheep
6449926334 Subscription required level, optimized cancellation logic 2025-07-02 21:58:44 +08:00
LittleSheep
fb885e138d 💄 Optimized subscriptions 2025-07-02 21:30:35 +08:00
LittleSheep
5bdc21ebc5 💄 Optimize activity service 2025-07-02 21:09:35 +08:00
LittleSheep
f177377fe3 Return the complete data while auto completion 2025-07-02 01:16:59 +08:00
LittleSheep
0df4864888 Auto completion handler for account and stickers 2025-07-01 23:51:26 +08:00
LittleSheep
29b0ad184e 🐛 Fix search didn't contains description in post 2025-07-01 23:42:45 +08:00
LittleSheep
ad730832db Searching in posts 2025-07-01 23:39:13 +08:00
LittleSheep
71fcc26534 🐛 Bug fixes on web article 2025-07-01 22:35:46 +08:00
LittleSheep
fb8fc69920 🐛 Fix web article loop 2025-07-01 00:57:05 +08:00
LittleSheep
05bf2cd055 Web articles 2025-07-01 00:44:48 +08:00
LittleSheep
ccb8a4e3f4 Bug fixes on web feed & scraping 2025-06-30 23:26:05 +08:00
LittleSheep
ca5be5a01c ♻️ Refined web feed APIs 2025-06-29 22:02:26 +08:00
LittleSheep
c4ea15097e ♻️ Refined custom apps 2025-06-29 20:32:08 +08:00
LittleSheep
cdeed3c318 🐛 Fixes custom app 2025-06-29 19:38:29 +08:00
LittleSheep
a53fcb10dd Developer programs APIs 2025-06-29 18:37:23 +08:00
LittleSheep
c0879d30d4 Oidc auto approval and session reuse 2025-06-29 17:46:17 +08:00
LittleSheep
0226bf8fa3 Complete oauth / oidc 2025-06-29 17:29:24 +08:00
LittleSheep
217b434cc4 🐛 Dozen of bugs fixes 2025-06-29 16:35:01 +08:00
LittleSheep
f8295c6a18 ♻️ Refactored oidc 2025-06-29 11:53:44 +08:00
LittleSheep
d4fa08d320 Support OIDC 2025-06-29 03:47:58 +08:00
LittleSheep
8bd0ea0fa1 💄 Optimized profile page 2025-06-29 00:58:08 +08:00
LittleSheep
9ab31d79ce 💄 Optimized web version styling 2025-06-29 00:44:59 +08:00
LittleSheep
ee5d6ef821 Support factor hint in web login 2025-06-29 00:38:00 +08:00
LittleSheep
d7b443e678 Web version login support send factor code 2025-06-29 00:32:55 +08:00
LittleSheep
98b2eeb13d Web version login 2025-06-28 22:53:07 +08:00
LittleSheep
ec3961d546 🐛 Fix subscription notification send twice 2025-06-28 22:08:53 +08:00
LittleSheep
a5dae37525 Publisher features flags APIs 2025-06-28 20:56:53 +08:00
LittleSheep
933d762f24 Custom apps developer APIs 2025-06-28 19:20:36 +08:00
LittleSheep
8251a9ec7d Publisher myself endpoint 2025-06-28 18:53:04 +08:00
LittleSheep
38243f9eba 🐛 Fix publisher member has no account in response 2025-06-28 18:40:20 +08:00
LittleSheep
b0b7afd6b3 Publisher members APIs 2025-06-28 18:13:32 +08:00
LittleSheep
6237fd6140 🐛 Fix file analyze didn't generate video ratio 2025-06-28 03:00:53 +08:00
LittleSheep
2e8d6a3667 🐛 Bug fixes 2025-06-28 01:28:20 +08:00
LittleSheep
ac496777ed 🐛 Fix update realm failed caused by picture 2025-06-27 23:41:51 +08:00
LittleSheep
19ddc1b363 👔 Discovery only shows community realms 2025-06-27 23:30:37 +08:00
LittleSheep
661b612537 Chat and realm members with status 2025-06-27 23:24:27 +08:00
LittleSheep
8432436fcf 🐛 Fix circular dependency in service injection 2025-06-27 23:16:28 +08:00
LittleSheep
2a28948418 Chat room subscribe 2025-06-27 22:55:00 +08:00
LittleSheep
5dd138949e 🐛 Fix update chat room failed 2025-06-27 22:54:50 +08:00
LittleSheep
f540544a47 Activity debug include 2025-06-27 22:35:23 +08:00
LittleSheep
9f8eec792b 🐛 Fix missing pagination query in discovery controller 2025-06-27 18:02:12 +08:00
LittleSheep
0bdd429d87 🐛 Fix realm chat controllers path typo 2025-06-27 17:37:13 +08:00
LittleSheep
b2203fb464 Publisher's recommendation (discovery) 2025-06-27 16:14:25 +08:00
LittleSheep
c5bbd58f5c 👔 Reduce the chance to display realm discovery 2025-06-27 15:54:56 +08:00
LittleSheep
35a9dcffbc 🐛 Fix post modeling data infiniting loop 2025-06-27 01:11:19 +08:00
LittleSheep
1d50f225c1 🐛 Fix analyzing images failed due to duplicate meta keys 2025-06-27 00:57:00 +08:00
LittleSheep
b7263b9804 💥 Update activity data format 2025-06-26 22:28:18 +08:00
LittleSheep
c63d6e0fbc 💄 Optimize realm discovery 2025-06-26 19:59:09 +08:00
LittleSheep
cebd1bd65a 💄 Optimized file analyze 2025-06-26 19:21:26 +08:00
LittleSheep
da58e10d88 Ranked posts 2025-06-26 19:17:28 +08:00
LittleSheep
d492c9ce1f Realm tags and discovery 2025-06-26 19:00:55 +08:00
LittleSheep
f170793928 💄 Optimized web articles 2025-06-26 18:34:51 +08:00
LittleSheep
1a137fbb6a Web articles and feed 2025-06-26 17:36:45 +08:00
LittleSheep
21cf212d8f Abuse report 2025-06-25 23:49:18 +08:00
LittleSheep
c6cb2a0dc3 🗃️ Remove the fk of session in action logs in order to fix logout 2025-06-25 00:03:50 +08:00
LittleSheep
d9747daab9 👔 Optimize the file service handling for image 2025-06-24 23:58:16 +08:00
LittleSheep
d91b705b9a 🐛 Fix the localization file 2025-06-23 03:06:52 +08:00
LittleSheep
5ce3598cc9 🐛 Bug fixes in restore purchase with afdian 2025-06-23 02:59:41 +08:00
LittleSheep
1b45f07419 🐛 Fixes notification 2025-06-23 02:46:49 +08:00
LittleSheep
6bec0a672e 🐛 Fix create subscription from order will make the identifier null 2025-06-23 02:40:24 +08:00
LittleSheep
c338512c16 🐛 Fix afdian webhook 2025-06-23 02:31:22 +08:00
LittleSheep
9444913b72 🐛 Bug fixes for afdian webhook 2025-06-23 02:22:45 +08:00
LittleSheep
50bfec59ee 🐛 Fix json library inconsistent cause the field name different 2025-06-23 02:08:03 +08:00
LittleSheep
a97bf15362 🐛 Trying to fix afdian webhook 2025-06-23 01:58:12 +08:00
LittleSheep
feb612afcd Payment and subscription notification 2025-06-23 01:34:53 +08:00
LittleSheep
049a5c9b6f 🐛 Trying to fix bugs on afdian oidc 2025-06-23 01:11:21 +08:00
LittleSheep
694bc77921 🔊 Add more logs on afdian oidc 2025-06-23 00:57:10 +08:00
LittleSheep
be0b48cfd9 Afdian as payment handler 2025-06-23 00:29:37 +08:00
LittleSheep
a23338c263 🐛 Fix afdian oauth 2025-06-22 22:02:14 +08:00
LittleSheep
c5ef9b065b 🐛 Bug fixes 2025-06-22 21:42:28 +08:00
LittleSheep
5990b17b4c Subscription status validation on profile 2025-06-22 20:09:52 +08:00
LittleSheep
de7a2cea09 Complete subscriptions 2025-06-22 19:56:42 +08:00
LittleSheep
698442ad13 Subscription and stellar program 2025-06-22 17:57:19 +08:00
LittleSheep
9fd6016308 Subscription service 2025-06-22 03:15:16 +08:00
LittleSheep
516090a5f8 🐛 Fixes afdian service 2025-06-22 02:35:12 +08:00
LittleSheep
6b0e5f919d Add afdian as OIDC provider 2025-06-22 02:22:19 +08:00
LittleSheep
c6450757be Add pin code 2025-06-22 00:18:50 +08:00
LittleSheep
38abe16ba6 🐛 Fix bugs of subscription 2025-06-22 00:08:27 +08:00
LittleSheep
bf40b51c41 🐛 Fix web reader can't get opengraph data 2025-06-22 00:06:19 +08:00
LittleSheep
f50894a3d1 🐛 Fix publisher subscription cache and status validation 2025-06-21 23:46:59 +08:00
LittleSheep
d1fb0b9b55 Filter on activities 2025-06-21 22:21:20 +08:00
LittleSheep
f1a47fd079 Chat message also preview links 2025-06-21 15:19:49 +08:00
LittleSheep
546b65f4c6 🐛 Fixed post service 2025-06-21 14:47:21 +08:00
LittleSheep
1baa3109bc 🎨 Removed unused dependency injected services arguments in constructor 2025-06-21 14:32:59 +08:00
LittleSheep
eadf25f389 🐛 Bug fixes in post embed links 2025-06-21 14:30:19 +08:00
LittleSheep
d385abbf57 🐛 Trying to fix post link auto preview 2025-06-21 14:17:55 +08:00
LittleSheep
a431fbbd51 🐛 Fix the logger service broke the post service 2025-06-21 14:09:56 +08:00
LittleSheep
d83c69620f Adding the link preview automatically to post 2025-06-21 14:02:32 +08:00
LittleSheep
cb8e720af1 🎨 Unified the cache key styling 2025-06-21 13:48:20 +08:00
LittleSheep
5f30b56ef8 Link scrapping for preview 2025-06-21 13:46:30 +08:00
LittleSheep
95010e4188 🐛 Fix set cache with group broken the cache 2025-06-21 13:46:21 +08:00
LittleSheep
3824fba8e5 👔 Auto removal for expired relationships 2025-06-21 12:00:18 +08:00
LittleSheep
aefc38c5a3 🐛 Fixing the post truncate 2025-06-21 11:46:08 +08:00
LittleSheep
bcd107ae2c 🐛 Fix the included post did not truncated 2025-06-21 11:05:12 +08:00
LittleSheep
700c818df8 🐛 Fix bugs on relationship api 2025-06-20 01:26:30 +08:00
LittleSheep
27276c66c5 Post reactions and replies counting
🎨 Improve the styles of post service
2025-06-20 00:31:16 +08:00
LittleSheep
abc89dc782 🎨 Splitting up startup steps in Program 2025-06-20 00:00:40 +08:00
LittleSheep
15edd74a9f 👔 The read packet of message no longer forwarded 2025-06-19 23:54:55 +08:00
LittleSheep
cfa63c7c93 Marking views 2025-06-19 23:54:25 +08:00
LittleSheep
eb1c283971 ♻️ Refactor the last active flush handler 2025-06-19 23:47:50 +08:00
LittleSheep
ea599fb15b 👔 Continue to remove GPS EXIF in photos 2025-06-19 23:44:11 +08:00
LittleSheep
e40514e440 🐛 Fix discord oidc since it has no discovery endpoint 2025-06-19 01:56:16 +08:00
LittleSheep
ca2d37eb39 Challenge retrieve api 2025-06-18 01:26:34 +08:00
LittleSheep
2a5926a94a ♻️ Refactor the state and auth system 2025-06-18 01:23:06 +08:00
LittleSheep
aba0f6b5e2 🐛 Fixes the callback page 2025-06-17 23:27:49 +08:00
LittleSheep
3b9db74a34 💄 Update the default callback uri 2025-06-17 23:16:51 +08:00
LittleSheep
5c02c63f70 🐛 Remove PKCE on Google OIDC 2025-06-17 23:08:58 +08:00
LittleSheep
ada84f85e9 🐛 Fix url didn't decoded 2025-06-17 21:44:47 +08:00
LittleSheep
3aad515ab8 🐛 Fix PCKE state broke the callback 2025-06-17 21:30:58 +08:00
LittleSheep
5e455599fd 🐛 Fix remain use session 2025-06-17 21:18:06 +08:00
LittleSheep
b634d587a7 🗑️ Remove unused session system 2025-06-17 21:11:25 +08:00
LittleSheep
50d8f74a98 🐛 Trying to fix connection issues 2025-06-17 20:01:20 +08:00
LittleSheep
1a5d0bbfc0 🐛 Fix bugs 2025-06-17 00:40:05 +08:00
LittleSheep
b868d0c153 🐛 Fix oidc services didn't registered 2025-06-17 00:06:28 +08:00
LittleSheep
60a8338c9a Able to connect with the auth endpoint for oidc 2025-06-16 23:42:55 +08:00
LittleSheep
fe04b12561 Add cache to oidc discovery 2025-06-16 23:15:45 +08:00
LittleSheep
47caff569d Add microsoft OIDC 2025-06-16 23:10:54 +08:00
LittleSheep
b27b6b8c1b 🐛 Fixes bugs 2025-06-16 01:43:54 +08:00
LittleSheep
c806c5d139 Self-contained oidc receiver page 2025-06-16 00:12:19 +08:00
LittleSheep
70aeb5e0cb 🔨 Fix the dockerfile missing install ffprobe (ffmpeg) 2025-06-15 23:53:59 +08:00
LittleSheep
90eca43284 Enrich the user connections 2025-06-15 23:49:26 +08:00
LittleSheep
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
LittleSheep
16ff5588b9 Login with Apple 2025-06-15 17:29:30 +08:00
LittleSheep
bf013a108b 🧱 OAuth login infra 2025-06-15 13:11:45 +08:00
LittleSheep
d00917fb39 🐛 Fix message notification missing action uri 2025-06-14 17:06:30 +08:00
LittleSheep
c8b1c1ba55 Notification action URIs 2025-06-14 16:57:23 +08:00
LittleSheep
e7942fc687 🐛 Fix notification via Send didn't push via ws 2025-06-14 16:47:03 +08:00
LittleSheep
1f2e9b1de8 ♻️ Refactored publishers 2025-06-14 16:19:45 +08:00
LittleSheep
2821beb1b7 🐛 Bug fixes on relationship API 2025-06-14 12:24:49 +08:00
LittleSheep
fcab12f175 Get another user with current user direct message API 2025-06-14 11:52:55 +08:00
LittleSheep
f5b04fa745 Get user's relationship API one to one 2025-06-14 11:49:20 +08:00
LittleSheep
8af2dddb45 🐛 Fix get the posts repeatly 2025-06-13 00:36:12 +08:00
LittleSheep
34902d0486 🗃️ Migrate previous changes 2025-06-12 01:01:52 +08:00
LittleSheep
ffb3f83b96 Add the cloud file recycling job back to online with data safety. 2025-06-12 00:58:16 +08:00
LittleSheep
2e09e63022 🗃️ Subscriptions modeling 2025-06-12 00:48:38 +08:00
LittleSheep
ebac6698ff Enrich user profile (skip ci)
💥 Un-migrated database changes, DO NOT PUSH TO PRODUCTION
2025-06-11 23:35:04 +08:00
LittleSheep
45f8cab555 📝 Add license and README 2025-06-11 23:23:45 +08:00
194 changed files with 58592 additions and 1675 deletions

View File

@@ -0,0 +1,82 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Team;
public enum TeamType
{
Individual,
Organizational
}
[Index(nameof(Name), IsUnique = true)]
public class Team : ModelBase, IIdentifiedResource
{
public Guid Id { get; set; }
public TeamType Type { get; set; }
[MaxLength(256)] public string Name { get; set; } = string.Empty;
[MaxLength(256)] public string Nick { get; set; } = string.Empty;
[MaxLength(4096)] public string? Bio { 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; }
[Column(TypeName = "jsonb")] public Account.VerificationMark? Verification { get; set; }
[JsonIgnore] public ICollection<TeamMember> Members { get; set; } = new List<TeamMember>();
[JsonIgnore] public ICollection<TeamFeature> Features { get; set; } = new List<TeamFeature>();
public Guid? AccountId { get; set; }
public Account.Account? Account { get; set; }
public string ResourceIdentifier => $"publisher/{Id}";
}
public enum TeamMemberRole
{
Owner = 100,
Manager = 75,
Editor = 50,
Viewer = 25
}
public class TeamMember : ModelBase
{
public Guid TeamId { get; set; }
[JsonIgnore] public Team Team { get; set; } = null!;
public Guid AccountId { get; set; }
public Account.Account Account { get; set; } = null!;
public TeamMemberRole Role { get; set; } = TeamMemberRole.Viewer;
public Instant? JoinedAt { get; set; }
}
public enum TeamSubscriptionStatus
{
Active,
Expired,
Cancelled
}
public class TeamFeature : ModelBase
{
public Guid Id { get; set; }
[MaxLength(1024)] public string Flag { get; set; } = null!;
public Instant? ExpiredAt { get; set; }
public Guid TeamId { get; set; }
public Team Team { get; set; } = null!;
}
public abstract class TeamFeatureFlag
{
public static List<string> AllFlags => [Develop];
public static string Develop = "develop";
}

View File

@@ -1,5 +1,6 @@
Keys
Uploads
DataProtection-Keys
node_modules
bun.lock

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

@@ -3,6 +3,7 @@ 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;
@@ -24,11 +25,14 @@ public class Account : ModelBase
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
@@ -62,11 +66,14 @@ public class Profile : ModelBase
[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; }
[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;
@@ -112,12 +119,15 @@ public class AccountAuthFactor : ModelBase
public Guid Id { get; set; }
public AccountAuthFactorType Type { get; set; }
[JsonIgnore] [MaxLength(8196)] public string? Secret { get; set; }
[JsonIgnore] [Column(TypeName = "jsonb")] public Dictionary<string, object>? Config { get; set; } = new();
[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.
/// Besides, users may need to use some high-trustworthy level auth factors when confirming some dangerous operations.
/// </summary>
public int Trustworthy { get; set; } = 1;
@@ -141,6 +151,7 @@ public class AccountAuthFactor : ModelBase
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));
@@ -165,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,7 +1,6 @@
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;
@@ -12,14 +11,12 @@ using System.Collections.Generic;
namespace DysonNetwork.Sphere.Account;
[ApiController]
[Route("/accounts")]
[Route("/api/accounts")]
public class AccountController(
AppDatabase db,
FileService fs,
AuthService auth,
AccountService accounts,
AccountEventService events,
MagicSpellService spells
AccountEventService events
) : ControllerBase
{
[HttpGet("{name}")]
@@ -82,51 +79,21 @@ public class AccountController(
{
if (!await auth.ValidateCaptcha(request.CaptchaToken)) return BadRequest("Invalid captcha token.");
var dupeNameCount = await db.Accounts.Where(a => a.Name == request.Name).CountAsync();
if (dupeNameCount > 0)
return BadRequest("The name is already taken.");
var account = new Account
try
{
Name = request.Name,
Nick = request.Nick,
Language = request.Language,
Contacts = new List<AccountContact>
{
new()
{
Type = AccountContactType.Email,
Content = request.Email,
IsPrimary = true
}
},
AuthFactors = new List<AccountAuthFactor>
{
new AccountAuthFactor
{
Type = AccountAuthFactorType.Password,
Secret = request.Password,
EnabledAt = SystemClock.Instance.GetCurrentInstant()
}.HashSecret()
},
Profile = new Profile()
};
await db.Accounts.AddAsync(account);
await db.SaveChangesAsync();
var spell = await spells.CreateMagicSpell(
account,
MagicSpellType.AccountActivation,
new Dictionary<string, object>
{
{ "contact_method", account.Contacts.First().Content }
},
expiredAt: SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromDays(7))
);
await spells.NotifyMagicSpell(spell, true);
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);
}
}
public class RecoveryPasswordRequest
@@ -207,13 +174,4 @@ public class AccountController(
.Take(take)
.ToListAsync();
}
[HttpPost("/maintenance/ensureProfileCreated")]
[Authorize]
[RequiredPermission("maintenance", "accounts.profiles")]
public async Task<ActionResult> EnsureProfileCreated()
{
await accounts.EnsureAccountProfileCreated();
return Ok();
}
}

View File

@@ -12,7 +12,7 @@ namespace DysonNetwork.Sphere.Account;
[Authorize]
[ApiController]
[Route("/accounts/me")]
[Route("/api/accounts/me")]
public class AccountCurrentController(
AppDatabase db,
AccountService accounts,
@@ -63,7 +63,12 @@ public class AccountCurrentController(
[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; }
@@ -84,6 +89,11 @@ public class AccountCurrentController(
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)
{
@@ -667,7 +677,7 @@ public class AccountCurrentController(
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();

View File

@@ -65,7 +65,7 @@ public class AccountEventService(
};
}
return new Status
return new Status
{
Attitude = StatusAttitude.Neutral,
IsOnline = false,
@@ -75,6 +75,70 @@ public class AccountEventService(
};
}
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();

View File

@@ -1,8 +1,10 @@
using System.Globalization;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Auth.OpenId;
using DysonNetwork.Sphere.Email;
using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Pages.Emails;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Storage;
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
@@ -16,8 +18,9 @@ namespace DysonNetwork.Sphere.Account;
public class AccountService(
AppDatabase db,
MagicSpellService spells,
AccountUsernameService uname,
NotificationService nty,
EmailService email,
EmailService mailer,
IStringLocalizer<NotificationResource> localizer,
ICacheService cache,
ILogger<AccountService> logger
@@ -54,6 +57,15 @@ public class AccountService(
return contact?.Account;
}
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
@@ -62,6 +74,114 @@ public class AccountService(
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(
@@ -146,6 +266,18 @@ public class AccountService(
}
};
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);
}
@@ -265,7 +397,7 @@ public class AccountService(
return;
}
await email.SendTemplatedEmailAsync<VerificationEmail, VerificationEmailModel>(
await mailer.SendTemplatedEmailAsync<DysonNetwork.Sphere.Pages.Emails.VerificationEmail, VerificationEmailModel>(
account.Nick,
contact.Content,
localizer["VerificationEmail"],

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

@@ -4,7 +4,7 @@ using Point = NetTopologySuite.Geometries.Point;
namespace DysonNetwork.Sphere.Account;
public class ActionLogType
public abstract class ActionLogType
{
public const string NewLogin = "login";
public const string ChallengeAttempt = "challenges.attempt";
@@ -55,5 +55,4 @@ public class ActionLog : ModelBase
public Guid AccountId { get; set; }
public Account Account { get; set; } = null!;
public Guid? SessionId { get; set; }
public Auth.Session? Session { get; set; } = null!;
}

View File

@@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Account;
[ApiController]
[Route("/spells")]
[Route("/api/spells")]
public class MagicSpellController(AppDatabase db, MagicSpellService sp) : ControllerBase
{
[HttpPost("{spellId:guid}/resend")]

View File

@@ -9,7 +9,7 @@ using NodaTime;
namespace DysonNetwork.Sphere.Account;
[ApiController]
[Route("/notifications")]
[Route("/api/notifications")]
public class NotificationController(AppDatabase db, NotificationService nty) : ControllerBase
{
[HttpGet("count")]

View File

@@ -10,7 +10,6 @@ namespace DysonNetwork.Sphere.Account;
public class NotificationService(
AppDatabase db,
WebSocketService ws,
ILogger<NotificationService> logger,
IHttpClientFactory httpFactory,
IConfiguration config)
{
@@ -31,6 +30,9 @@ public class NotificationService(
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)
@@ -38,11 +40,18 @@ public class NotificationService(
if (existingSubscription is not null)
{
// Reset these audit fields to renew the lifecycle of this device token
// 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;
db.Update(existingSubscription);
await db.SaveChangesAsync();
existingSubscription.UpdatedAt = now;
return existingSubscription;
}
@@ -67,6 +76,7 @@ public class NotificationService(
string? subtitle = null,
string? content = null,
Dictionary<string, object>? meta = null,
string? actionUri = null,
bool isSilent = false,
bool save = true
)
@@ -74,6 +84,9 @@ public class NotificationService(
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,
@@ -125,9 +138,10 @@ public class NotificationService(
public async Task BroadcastNotification(Notification notification, bool save = false)
{
var accounts = await db.Accounts.ToListAsync();
if (save)
{
var accounts = await db.Accounts.ToListAsync();
var notifications = accounts.Select(x =>
{
var newNotification = new Notification
@@ -146,6 +160,17 @@ public class NotificationService(
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);
@@ -173,6 +198,17 @@ public class NotificationService(
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))
@@ -189,7 +225,7 @@ public class NotificationService(
var notifications = subDict.Select(value =>
{
int platformCode = value.Key switch
var platformCode = value.Key switch
{
NotificationPushProvider.Apple => 1,
NotificationPushProvider.Google => 2,
@@ -262,7 +298,7 @@ public class NotificationService(
var client = httpFactory.CreateClient();
client.BaseAddress = _notifyEndpoint;
var request = await client.PostAsync("/api/push", new StringContent(
var request = await client.PostAsync("/push", new StringContent(
JsonSerializer.Serialize(requestDict),
Encoding.UTF8,
"application/json"

View File

@@ -2,11 +2,12 @@ using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
[ApiController]
[Route("/relationships")]
[Route("/api/relationships")]
public class RelationshipController(AppDatabase db, RelationshipService rels) : ControllerBase
{
[HttpGet]
@@ -31,7 +32,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
var statuses = await db.AccountRelationships
.Where(r => r.AccountId == userId)
.ToDictionaryAsync(r => r.AccountId);
.ToDictionaryAsync(r => r.RelatedId);
foreach (var relationship in relationships)
if (statuses.TryGetValue(relationship.RelatedId, out var status))
relationship.Status = status.Status;
@@ -40,13 +41,13 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
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)
@@ -54,7 +55,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
.Include(r => r.Account)
.Include(r => r.Account.Profile)
.ToListAsync();
return relationships;
}
@@ -65,7 +66,8 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[HttpPost("{userId:guid}")]
[Authorize]
public async Task<ActionResult<Relationship>> CreateRelationship(Guid userId, [FromBody] RelationshipRequest request)
public async Task<ActionResult<Relationship>> CreateRelationship(Guid userId,
[FromBody] RelationshipRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
@@ -84,13 +86,14 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
return BadRequest(err.Message);
}
}
[HttpPatch("{userId:guid}")]
[Authorize]
public async Task<ActionResult<Relationship>> UpdateRelationship(Guid userId, [FromBody] RelationshipRequest request)
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);
@@ -105,7 +108,27 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
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)
@@ -114,9 +137,9 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
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) ||
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.");
@@ -130,13 +153,13 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
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);
@@ -156,7 +179,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
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);
@@ -176,7 +199,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
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);
@@ -187,16 +210,16 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
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);
@@ -207,4 +230,24 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
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

@@ -6,7 +6,8 @@ namespace DysonNetwork.Sphere.Account;
public class RelationshipService(AppDatabase db, ICacheService cache)
{
private const string UserFriendsCacheKeyPrefix = "UserFriends_";
private const string UserFriendsCacheKeyPrefix = "accounts:friends:";
private const string UserBlockedCacheKeyPrefix = "accounts:blocked:";
public async Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId)
{
@@ -50,9 +51,8 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
db.AccountRelationships.Add(relationship);
await db.SaveChangesAsync();
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relationship.AccountId}");
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relationship.RelatedId}");
await PurgeRelationshipCache(sender.Id, target.Id);
return relationship;
}
@@ -63,6 +63,18 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
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)
{
@@ -92,8 +104,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
.Where(r => r.AccountId == accountId && r.RelatedId == relatedId && r.Status == RelationshipStatus.Pending)
.ExecuteDeleteAsync();
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}");
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relatedId}");
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
}
public async Task<Relationship> AcceptFriendRelationship(
@@ -122,8 +133,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
await db.SaveChangesAsync();
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relationship.AccountId}");
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relationship.RelatedId}");
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
return relationshipBackward;
}
@@ -137,15 +147,14 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
db.Update(relationship);
await db.SaveChangesAsync();
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}");
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relatedId}");
await PurgeRelationshipCache(accountId, relatedId);
return relationship;
}
public async Task<List<Guid>> ListAccountFriends(Account account)
{
string cacheKey = $"{UserFriendsCacheKeyPrefix}{account.Id}";
var cacheKey = $"{UserFriendsCacheKeyPrefix}{account.Id}";
var friends = await cache.GetAsync<List<Guid>>(cacheKey);
if (friends == null)
@@ -161,6 +170,25 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
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)
@@ -168,4 +196,12 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
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

@@ -1,6 +1,4 @@
using DysonNetwork.Sphere.Account;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using NodaTime.Text;
@@ -10,7 +8,7 @@ namespace DysonNetwork.Sphere.Activity;
/// Activity is a universal feed that contains multiple kinds of data. Personalized and generated dynamically.
/// </summary>
[ApiController]
[Route("/activities")]
[Route("/api/activities")]
public class ActivityController(
ActivityService acts
) : ControllerBase
@@ -24,7 +22,12 @@ public class ActivityController(
/// 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] int take = 20)
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))
@@ -39,11 +42,11 @@ public class ActivityController(
}
}
var debugIncludeSet = debugInclude?.Split(',').ToHashSet() ?? new HashSet<string>();
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
if (currentUserValue is not Account.Account currentUser)
return Ok(await acts.GetActivitiesForAnyone(take, cursorTimestamp));
return Ok(await acts.GetActivities(take, cursorTimestamp, currentUser));
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

@@ -1,80 +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, RelationshipService rels, PostService ps)
public class ActivityService(
AppDatabase db,
PublisherService pub,
RelationshipService rels,
PostService ps,
DiscoveryService ds)
{
public async Task<List<Activity>> GetActivitiesForAnyone(int take, Instant? cursor)
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>();
// Crunching up data
var posts = await db.Posts
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 || cursor > p.CreatedAt)
.Where(p => cursor == null || p.PublishedAt < cursor)
.OrderByDescending(p => p.PublishedAt)
.FilterWithVisibility(null, [], isListing: true)
.Take(take)
.ToListAsync();
posts = PostService.TruncatePostContent(posts);
posts = await ps.LoadPublishers(posts);
.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 posts)
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)
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);
// Crunching data
var posts = await db.Posts
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(e => e.RepliedPostId == null)
.Where(p => cursor == null || p.CreatedAt > cursor)
.Where(p => cursor == null || p.PublishedAt < cursor)
.OrderByDescending(p => p.PublishedAt)
.FilterWithVisibility(currentUser, userFriends, isListing: true)
.Take(take)
.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 = PostService.TruncatePostContent(posts);
posts = await ps.LoadPublishers(posts);
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 posts)
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,6 +1,16 @@
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;
@@ -31,24 +41,26 @@ public class AppDatabase(
public DbSet<PermissionGroup> PermissionGroups { get; set; }
public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; }
public DbSet<Account.MagicSpell> MagicSpells { 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<Account.Status> AccountStatuses { get; set; }
public DbSet<Account.CheckInResult> AccountCheckInResults { get; set; }
public DbSet<Account.Notification> Notifications { get; set; }
public DbSet<Account.NotificationPushSubscription> NotificationPushSubscriptions { get; set; }
public DbSet<Account.Badge> Badges { get; set; }
public DbSet<Account.ActionLog> ActionLogs { 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<Auth.Session> AuthSessions { get; set; }
public DbSet<Auth.Challenge> AuthChallenges { get; set; }
public DbSet<Session> AuthSessions { get; set; }
public DbSet<Challenge> AuthChallenges { get; set; }
public DbSet<Storage.CloudFile> Files { get; set; }
public DbSet<Storage.CloudFileReference> FileReferences { 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; }
@@ -56,30 +68,37 @@ public class AppDatabase(
public DbSet<PublisherFeature> PublisherFeatures { get; set; }
public DbSet<Post.Post> Posts { get; set; }
public DbSet<Post.PostReaction> PostReactions { get; set; }
public DbSet<Post.PostTag> PostTags { get; set; }
public DbSet<Post.PostCategory> PostCategories { get; set; }
public DbSet<Post.PostCollection> PostCollections { 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<Realm.RealmMember> RealmMembers { get; set; }
public DbSet<RealmMember> RealmMembers { get; set; }
public DbSet<Tag> Tags { get; set; }
public DbSet<RealmTag> RealmTags { get; set; }
public DbSet<Chat.ChatRoom> ChatRooms { get; set; }
public DbSet<Chat.ChatMember> ChatMembers { get; set; }
public DbSet<Chat.Message> ChatMessages { get; set; }
public DbSet<Chat.RealtimeCall> ChatRealtimeCall { get; set; }
public DbSet<Chat.MessageReaction> ChatReactions { 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<Sticker.StickerPack> StickerPacks { get; set; }
public DbSet<StickerPack> StickerPacks { get; set; }
public DbSet<Wallet.Wallet> Wallets { get; set; }
public DbSet<Wallet.WalletPocket> WalletPockets { get; set; }
public DbSet<Wallet.Order> PaymentOrders { get; set; }
public DbSet<Wallet.Transaction> PaymentTransactions { get; set; }
public DbSet<WalletPocket> WalletPockets { get; set; }
public DbSet<Order> PaymentOrders { get; set; }
public DbSet<Transaction> PaymentTransactions { get; set; }
public DbSet<Developer.CustomApp> CustomApps { get; set; }
public DbSet<Developer.CustomAppSecret> CustomAppSecrets { 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)
{
@@ -122,6 +141,8 @@ public class AppDatabase(
}
});
optionsBuilder.UseSeeding((context, _) => {});
base.OnConfiguring(optionsBuilder);
}
@@ -137,13 +158,13 @@ public class AppDatabase(
.HasForeignKey(pg => pg.GroupId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Account.Relationship>()
modelBuilder.Entity<Relationship>()
.HasKey(r => new { FromAccountId = r.AccountId, ToAccountId = r.RelatedId });
modelBuilder.Entity<Account.Relationship>()
modelBuilder.Entity<Relationship>()
.HasOne(r => r.Account)
.WithMany(a => a.OutgoingRelationships)
.HasForeignKey(r => r.AccountId);
modelBuilder.Entity<Account.Relationship>()
modelBuilder.Entity<Relationship>()
.HasOne(r => r.Related)
.WithMany(a => a.IncomingRelationships)
.HasForeignKey(r => r.RelatedId);
@@ -175,6 +196,17 @@ public class AppDatabase(
.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()
@@ -198,61 +230,82 @@ public class AppDatabase(
.WithMany(c => c.Posts)
.UsingEntity(j => j.ToTable("post_collection_links"));
modelBuilder.Entity<Realm.RealmMember>()
modelBuilder.Entity<RealmMember>()
.HasKey(pm => new { pm.RealmId, pm.AccountId });
modelBuilder.Entity<Realm.RealmMember>()
modelBuilder.Entity<RealmMember>()
.HasOne(pm => pm.Realm)
.WithMany(p => p.Members)
.HasForeignKey(pm => pm.RealmId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Realm.RealmMember>()
modelBuilder.Entity<RealmMember>()
.HasOne(pm => pm.Account)
.WithMany()
.HasForeignKey(pm => pm.AccountId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Chat.ChatMember>()
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<Chat.ChatMember>()
modelBuilder.Entity<ChatMember>()
.HasAlternateKey(pm => new { pm.ChatRoomId, pm.AccountId });
modelBuilder.Entity<Chat.ChatMember>()
modelBuilder.Entity<ChatMember>()
.HasOne(pm => pm.ChatRoom)
.WithMany(p => p.Members)
.HasForeignKey(pm => pm.ChatRoomId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Chat.ChatMember>()
modelBuilder.Entity<ChatMember>()
.HasOne(pm => pm.Account)
.WithMany()
.HasForeignKey(pm => pm.AccountId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Chat.Message>()
modelBuilder.Entity<Message>()
.HasOne(m => m.ForwardedMessage)
.WithMany()
.HasForeignKey(m => m.ForwardedMessageId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Chat.Message>()
modelBuilder.Entity<Message>()
.HasOne(m => m.RepliedMessage)
.WithMany()
.HasForeignKey(m => m.RepliedMessageId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Chat.RealtimeCall>()
modelBuilder.Entity<RealtimeCall>()
.HasOne(m => m.Room)
.WithMany()
.HasForeignKey(m => m.RoomId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Chat.RealtimeCall>()
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)) continue;
var method = typeof(AppDatabase)
.GetMethod(nameof(SetSoftDeleteFilter),
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!
BindingFlags.NonPublic | BindingFlags.Static)!
.MakeGenericMethod(entityType.ClrType);
method.Invoke(null, [modelBuilder]);
@@ -299,9 +352,23 @@ public class AppDatabaseRecyclingJob(AppDatabase db, ILogger<AppDatabaseRecyclin
{
public async Task Execute(IJobExecutionContext context)
{
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 now = SystemClock.Instance.GetCurrentInstant();
var threshold = now - Duration.FromDays(7);
var entityTypes = db.Model.GetEntityTypes()

View File

@@ -1,12 +1,19 @@
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;
@@ -15,12 +22,14 @@ 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
}
@@ -38,13 +47,14 @@ public class DysonTokenAuthHandler(
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);
@@ -77,7 +87,7 @@ public class DysonTokenAuthHandler(
{
// Store in cache for future requests
await cache.SetWithGroupsAsync(
$"Auth_{sessionId}",
$"auth:{sessionId}",
session,
[$"{AccountService.AccountCachePrefix}{session.Account.Id}"],
TimeSpan.FromHours(1)
@@ -126,7 +136,7 @@ public class DysonTokenAuthHandler(
SeenAt = SystemClock.Instance.GetCurrentInstant(),
};
fbs.Enqueue(lastInfo);
return AuthenticateResult.Success(ticket);
}
catch (Exception ex)
@@ -141,35 +151,60 @@ public class DysonTokenAuthHandler(
try
{
// Split the token
var parts = token.Split('.');
if (parts.Length != 2)
return false;
// Decode the payload
var payloadBytes = Base64UrlDecode(parts[0]);
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;
// Extract session ID
sessionId = new Guid(payloadBytes);
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]);
// Load public key for verification
var publicKeyPem = File.ReadAllText(configuration["Jwt:PublicKeyPath"]!);
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem);
// Extract session ID
sessionId = new Guid(payloadBytes);
// Verify signature
var signature = Base64UrlDecode(parts[1]);
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
// 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
catch (Exception ex)
{
Logger.LogWarning(ex, "Token validation failed");
return false;
}
}
private static byte[] Base64UrlDecode(string base64Url)
{
string padded = base64Url
var padded = base64Url
.Replace('-', '+')
.Replace('_', '/');
@@ -182,7 +217,7 @@ public class DysonTokenAuthHandler(
return Convert.FromBase64String(padded);
}
private static TokenInfo? _ExtractToken(HttpRequest request)
private TokenInfo? _ExtractToken(HttpRequest request)
{
// Check for token in query parameters
if (request.Query.TryGetValue(AuthConstants.TokenQueryParamName, out var queryToken))
@@ -194,20 +229,23 @@ public class DysonTokenAuthHandler(
};
}
// 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 = authHeader["Bearer ".Length..].Trim(),
Type = TokenType.AuthKey
Token = token,
Type = parts.Length == 3 ? TokenType.OidcKey : TokenType.AuthKey
};
}
if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
else if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
{
return new TokenInfo
{
@@ -215,8 +253,7 @@ public class DysonTokenAuthHandler(
Type = TokenType.AuthKey
};
}
if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
else if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
{
return new TokenInfo
{
@@ -227,15 +264,16 @@ public class DysonTokenAuthHandler(
}
// Check for token in cookies
if (request.Cookies.TryGetValue(AuthConstants.TokenQueryParamName, out var cookieToken))
if (request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out var cookieToken))
{
return new TokenInfo
{
Token = cookieToken,
Type = TokenType.AuthKey
Type = cookieToken.Count(c => c == '.') == 2 ? TokenType.OidcKey : TokenType.AuthKey
};
}
return null;
}
}

View File

@@ -11,7 +11,7 @@ using DysonNetwork.Sphere.Connection;
namespace DysonNetwork.Sphere.Auth;
[ApiController]
[Route("/auth")]
[Route("/api/auth")]
public class AuthController(
AppDatabase db,
AccountService accounts,
@@ -74,6 +74,19 @@ public class AuthController(
return challenge;
}
[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)
{
@@ -84,7 +97,7 @@ public class AuthController(
.FirstOrDefaultAsync();
return challenge is null
? NotFound("Auth challenge was not found.")
: challenge.Account.AuthFactors.Where(e => e.EnabledAt != null).ToList();
: challenge.Account.AuthFactors.Where(e => e is { EnabledAt: not null, Trustworthy: >= 1 }).ToList();
}
[HttpPost("challenge/{id:guid}/factors/{factorId:guid}")]

View File

@@ -1,12 +1,22 @@
using System.Security.Cryptography;
using System.Text.Json;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Auth;
public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFactory httpClientFactory)
public class AuthService(
AppDatabase db,
IConfiguration config,
IHttpClientFactory httpClientFactory,
IHttpContextAccessor httpContextAccessor,
ICacheService cache
)
{
private HttpContext HttpContext => httpContextAccessor.HttpContext!;
/// <summary>
/// Detect the risk of the current request to login
/// and returns the required steps to login.
@@ -56,13 +66,41 @@ public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFacto
// 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 / 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<Session> CreateSessionForOidcAsync(Account.Account account, Instant time, Guid? customAppId = null)
{
var challenge = new Challenge
{
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
};
var session = new Session
{
AccountId = account.Id,
CreatedAt = time,
LastGrantedAt = time,
Challenge = challenge,
AppId = customAppId
};
db.AuthChallenges.Add(challenge);
db.AuthSessions.Add(session);
await db.SaveChangesAsync();
return session;
}
public async Task<bool> ValidateCaptcha(string token)
{
if (string.IsNullOrWhiteSpace(token)) return false;
@@ -94,7 +132,7 @@ public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFacto
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/api/siteverify", content);
response = await client.PostAsync("https://www.google.com/recaptcha/siteverify", content);
response.EnsureSuccessStatusCode();
json = await response.Content.ReadAsStringAsync();
@@ -119,7 +157,7 @@ public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFacto
public string CreateToken(Session session)
{
// Load the private key for signing
var privateKeyPem = File.ReadAllText(config["Jwt:PrivateKeyPath"]!);
var privateKeyPem = File.ReadAllText(config["AuthToken:PrivateKeyPath"]!);
using var rsa = RSA.Create();
rsa.ImportFromPem(privateKeyPem);
@@ -145,6 +183,69 @@ public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFacto
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);
if (found)
{
// 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;
@@ -163,7 +264,7 @@ public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFacto
sessionId = new Guid(payloadBytes);
// Load public key for verification
var publicKeyPem = File.ReadAllText(config["Jwt:PublicKeyPath"]!);
var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!);
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem);

View File

@@ -4,8 +4,8 @@ namespace DysonNetwork.Sphere.Auth;
public class CompactTokenService(IConfiguration config)
{
private readonly string _privateKeyPath = config["Jwt:PrivateKeyPath"]
?? throw new InvalidOperationException("Jwt:PrivateKeyPath configuration is missing");
private readonly string _privateKeyPath = config["AuthToken:PrivateKeyPath"]
?? throw new InvalidOperationException("AuthToken:PrivateKeyPath configuration is missing");
public string CreateToken(Session session)
{
@@ -54,7 +54,7 @@ public class CompactTokenService(IConfiguration config)
sessionId = new Guid(payloadBytes);
// Load public key for verification
var publicKeyPem = File.ReadAllText(config["Jwt:PublicKeyPath"]!);
var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!);
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem);

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,6 +1,7 @@
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;
@@ -17,12 +18,15 @@ public class Session : ModelBase
[JsonIgnore] public Account.Account Account { 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
OAuth, // Trying to authorize other platforms
Oidc // Trying to connect other platforms
}
public enum ChallengePlatform

View File

@@ -1,19 +1,15 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using SystemClock = NodaTime.SystemClock;
namespace DysonNetwork.Sphere.Chat;
[ApiController]
[Route("/chat")]
[Route("/api/chat")]
public partial class ChatController(AppDatabase db, ChatService cs, ChatRoomService crs) : ControllerBase
{
public class MarkMessageReadRequest
@@ -21,7 +17,7 @@ public partial class ChatController(AppDatabase db, ChatService cs, ChatRoomServ
public Guid ChatRoomId { get; set; }
}
public class TypingMessageRequest
public class ChatRoomWsUniversalRequest
{
public Guid ChatRoomId { get; set; }
}
@@ -310,4 +306,4 @@ public partial class ChatController(AppDatabase db, ChatService cs, ChatRoomServ
var response = await cs.GetSyncDataAsync(roomId, request.LastSyncTimestamp);
return Ok(response);
}
}
}

View File

@@ -13,17 +13,17 @@ using NodaTime;
namespace DysonNetwork.Sphere.Chat;
[ApiController]
[Route("/chat")]
[Route("/api/chat")]
public class ChatRoomController(
AppDatabase db,
FileService fs,
FileReferenceService fileRefService,
ChatRoomService crs,
RealmService rs,
ActionLogService als,
NotificationService nty,
RelationshipService rels,
IStringLocalizer<NotificationResource> localizer
IStringLocalizer<NotificationResource> localizer,
AccountEventService aes
) : ControllerBase
{
[HttpGet("{id:guid}")]
@@ -130,6 +130,23 @@ public class ChatRoomController(
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
{
@@ -223,7 +240,6 @@ public class ChatRoomController(
var chatRoom = await db.ChatRooms
.Where(e => e.Id == id)
.Include(c => c.Background)
.FirstOrDefaultAsync();
if (chatRoom is null) return NotFound();
@@ -246,26 +262,19 @@ public class ChatRoomController(
chatRoom.RealmId = member.RealmId;
}
var chatRoomResourceId = $"chatroom:{chatRoom.Id}";
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
var oldPictureRefs =
await fileRefService.GetResourceReferencesAsync(chatRoomResourceId, "chat.room.picture");
foreach (var oldRef in oldPictureRefs)
{
await fileRefService.DeleteReferenceAsync(oldRef.Id);
}
await fileRefService.DeleteResourceReferencesAsync(chatRoom.ResourceIdentifier, "chat.room.picture");
// Add a new reference
await fileRefService.CreateReferenceAsync(
picture.Id,
"chat.room.picture",
chatRoomResourceId
chatRoom.ResourceIdentifier
);
chatRoom.Picture = picture.ToReferenceObject();
@@ -277,18 +286,13 @@ public class ChatRoomController(
if (background is null) return BadRequest("Invalid background id, unable to find the file on cloud.");
// Remove old references for backgrounds
var oldBackgroundRefs =
await fileRefService.GetResourceReferencesAsync(chatRoomResourceId, "chat.room.background");
foreach (var oldRef in oldBackgroundRefs)
{
await fileRefService.DeleteReferenceAsync(oldRef.Id);
}
await fileRefService.DeleteResourceReferencesAsync(chatRoom.ResourceIdentifier, "chat.room.background");
// Add a new reference
await fileRefService.CreateReferenceAsync(
background.Id,
"chat.room.background",
chatRoomResourceId
chatRoom.ResourceIdentifier
);
chatRoom.Background = background.ToReferenceObject();
@@ -369,7 +373,7 @@ public class ChatRoomController(
[HttpGet("{roomId:guid}/members")]
public async Task<ActionResult<List<ChatMember>>> ListMembers(Guid roomId, [FromQuery] int take = 20,
[FromQuery] int skip = 0)
[FromQuery] int skip = 0, [FromQuery] bool withStatus = false, [FromQuery] string? status = null)
{
var currentUser = HttpContext.Items["CurrentUser"] as Account.Account;
@@ -385,24 +389,54 @@ public class ChatRoomController(
if (member is null) return StatusCode(403, "You need to be a member to see members of private chat room.");
}
var query = db.ChatMembers
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);
var total = await query.CountAsync();
Response.Headers.Append("X-Total", total.ToString());
if (withStatus)
{
var members = await query
.OrderBy(m => m.JoinedAt)
.ToListAsync();
var members = await query
.OrderBy(m => m.JoinedAt)
.Skip(skip)
.Take(take)
.ToListAsync();
var memberStatuses = await aes.GetStatuses(members.Select(m => m.AccountId).ToList());
return Ok(members);
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; }
@@ -782,6 +816,6 @@ public class ChatRoomController(
: localizer["ChatInviteBody", member.ChatRoom.Name ?? "Unnamed"];
AccountService.SetCultureInfo(member.Account);
await nty.SendNotification(member.Account, "invites.chats", title, null, body);
await nty.SendNotification(member.Account, "invites.chats", title, null, body, actionUri: "/chat");
}
}

View File

@@ -6,9 +6,9 @@ namespace DysonNetwork.Sphere.Chat;
public class ChatRoomService(AppDatabase db, ICacheService cache)
{
public const string ChatRoomGroupPrefix = "ChatRoom_";
private const string RoomMembersCacheKeyPrefix = "ChatRoomMembers_";
private const string ChatMemberCacheKey = "ChatMember_{0}_{1}";
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)
{

View File

@@ -1,3 +1,4 @@
using System.Text.RegularExpressions;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Chat.Realtime;
using DysonNetwork.Sphere.Connection;
@@ -7,9 +8,8 @@ using NodaTime;
namespace DysonNetwork.Sphere.Chat;
public class ChatService(
public partial class ChatService(
AppDatabase db,
FileService fs,
FileReferenceService fileRefService,
IServiceScopeFactory scopeFactory,
IRealtimeService realtime,
@@ -18,6 +18,132 @@ public class ChatService(
{
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();
@@ -58,6 +184,9 @@ public class ChatService(
}
});
// 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;
@@ -93,7 +222,8 @@ public class ChatService(
["room_id"] = room.Id,
["images"] = message.Attachments
.Where(a => a.MimeType != null && a.MimeType.StartsWith("image"))
.Select(a => a.Id).ToList()
.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;
@@ -122,6 +252,7 @@ public class ChatService(
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();
@@ -293,7 +424,7 @@ public class ChatService(
{
Type = "call.ended",
ChatRoomId = call.RoomId,
SenderId = sender.Id,
SenderId = call.SenderId,
Meta = new Dictionary<string, object>
{
{ "call_id", call.Id },
@@ -389,6 +520,10 @@ public class ChatService(
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,
@@ -440,4 +575,4 @@ public class SyncResponse
{
public List<MessageChange> Changes { get; set; } = [];
public Instant CurrentTimestamp { get; set; }
}
}

View File

@@ -0,0 +1,308 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.IdentityModel.Tokens;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Chat.Realtime;
public class CloudflareRealtimeService : IRealtimeService
{
private readonly AppDatabase _db;
private readonly HttpClient _httpClient;
private readonly IConfiguration _configuration;
private RSA? _publicKey;
public CloudflareRealtimeService(
AppDatabase db,
HttpClient httpClient,
IConfiguration configuration
)
{
_db = db;
_httpClient = httpClient;
_configuration = configuration;
var apiKey = _configuration["Realtime:Cloudflare:ApiKey"];
var apiSecret = _configuration["Realtime:Cloudflare:ApiSecret"]!;
var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{apiKey}:{apiSecret}"));
_httpClient.BaseAddress = new Uri("https://rtk.realtime.cloudflare.com/v2/");
_httpClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", credentials);
}
public string ProviderName => "Cloudflare";
public async Task<RealtimeSessionConfig> CreateSessionAsync(Guid roomId, Dictionary<string, object> metadata)
{
var roomName = $"Room Call #{roomId.ToString().Replace("-", "")}";
var requestBody = new
{
title = roomName,
preferred_region = _configuration["Realtime:Cloudflare:PreferredRegion"]
};
var content = new StringContent(JsonSerializer.Serialize(requestBody), Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync("meetings", content);
response.EnsureSuccessStatusCode();
var responseContent = await response.Content.ReadAsStringAsync();
var meetingResponse = JsonSerializer.Deserialize<CfMeetingResponse>(responseContent);
if (meetingResponse is null) throw new Exception("Failed to create meeting with cloudflare");
return new RealtimeSessionConfig
{
SessionId = meetingResponse.Data.Id,
Parameters = new Dictionary<string, object>
{
{ "meetingId", meetingResponse.Data.Id }
}
};
}
public async Task EndSessionAsync(string sessionId, RealtimeSessionConfig config)
{
var requestBody = new
{
status = "INACTIVE"
};
var content = new StringContent(JsonSerializer.Serialize(requestBody), Encoding.UTF8, "application/json");
var response = await _httpClient.PatchAsync($"sessions/{sessionId}", content);
response.EnsureSuccessStatusCode();
}
public string GetUserToken(Account.Account account, string sessionId, bool isAdmin = false)
{
return GetUserTokenAsync(account, sessionId, isAdmin).GetAwaiter().GetResult();
}
public async Task<string> GetUserTokenAsync(Account.Account account, string sessionId, bool isAdmin = false)
{
try
{
// First try to get the participant by their custom ID
var participantCheckResponse = await _httpClient
.GetAsync($"meetings/{sessionId}/participants/{account.Id}");
if (participantCheckResponse.IsSuccessStatusCode)
{
// Participant exists, get a new token
var tokenResponse = await _httpClient
.PostAsync($"meetings/{sessionId}/participants/{account.Id}/token", null);
tokenResponse.EnsureSuccessStatusCode();
var tokenContent = await tokenResponse.Content.ReadAsStringAsync();
var tokenData = JsonSerializer.Deserialize<CfResponse<CfTokenResponse>>(tokenContent);
if (tokenData == null || !tokenData.Success)
{
throw new Exception("Failed to get participant token");
}
return tokenData.Data?.Token ?? throw new Exception("Token is null");
}
// Participant doesn't exist, create a new one
var baseUrl = _configuration["BaseUrl"];
var requestBody = new
{
name = "@" + account.Name,
picture = account.Profile.Picture is not null
? $"{baseUrl}/api/files/{account.Profile.Picture.Id}"
: null,
preset_name = isAdmin ? "group_call_host" : "group_call_participant",
custom_participant_id = account.Id.ToString()
};
var content = new StringContent(
JsonSerializer.Serialize(requestBody),
Encoding.UTF8,
"application/json"
);
var createResponse = await _httpClient.PostAsync($"meetings/{sessionId}/participants", content);
createResponse.EnsureSuccessStatusCode();
var responseContent = await createResponse.Content.ReadAsStringAsync();
var participantData = JsonSerializer.Deserialize<CfResponse<CfParticipantResponse>>(responseContent);
if (participantData == null || !participantData.Success)
{
throw new Exception("Failed to create participant");
}
return participantData.Data?.Token ?? throw new Exception("Token is null");
}
catch (Exception ex)
{
// Log the error or handle it appropriately
throw new Exception($"Failed to get or create participant: {ex.Message}", ex);
}
}
public async Task ReceiveWebhook(string body, string authHeader)
{
if (string.IsNullOrEmpty(authHeader))
{
throw new ArgumentException("Auth header is missing");
}
if (_publicKey == null)
{
await GetPublicKeyAsync();
}
var signature = authHeader.Replace("Signature ", "");
var bodyBytes = Encoding.UTF8.GetBytes(body);
var signatureBytes = Convert.FromBase64String(signature);
if (!(_publicKey?.VerifyData(bodyBytes, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1) ??
false))
{
throw new SecurityTokenException("Webhook signature validation failed");
}
// Process the webhook event
var evt = JsonSerializer.Deserialize<CfWebhookEvent>(body);
if (evt is null) return;
switch (evt.Type)
{
case "meeting.ended":
var now = SystemClock.Instance.GetCurrentInstant();
await _db.ChatRealtimeCall
.Where(c => c.SessionId == evt.Event.Meeting.Id)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.EndedAt, now)
);
break;
}
}
private class WebhooksConfig
{
[JsonPropertyName("keys")] public List<WebhookKey> Keys { get; set; } = new List<WebhookKey>();
}
private class WebhookKey
{
[JsonPropertyName("publicKeyPem")] public string PublicKeyPem { get; set; } = string.Empty;
}
private async Task GetPublicKeyAsync()
{
var response = await _httpClient.GetAsync("https://rtk.realtime.cloudflare.com/.well-known/webhooks.json");
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var webhooksConfig = JsonSerializer.Deserialize<WebhooksConfig>(content);
var publicKeyPem = webhooksConfig?.Keys.FirstOrDefault()?.PublicKeyPem;
if (string.IsNullOrEmpty(publicKeyPem))
{
throw new InvalidOperationException("Public key not found in webhooks configuration.");
}
_publicKey = RSA.Create();
_publicKey.ImportFromPem(publicKeyPem);
}
private class CfMeetingResponse
{
[JsonPropertyName("data")] public CfMeetingData Data { get; set; } = new();
}
private class CfMeetingData
{
[JsonPropertyName("id")] public string Id { get; set; } = string.Empty;
[JsonPropertyName("roomName")] public string RoomName { get; set; } = string.Empty;
[JsonPropertyName("title")] public string Title { get; set; } = string.Empty;
[JsonPropertyName("status")] public string Status { get; set; } = string.Empty;
[JsonPropertyName("createdAt")] public DateTime CreatedAt { get; set; }
[JsonPropertyName("updatedAt")] public DateTime UpdatedAt { get; set; }
}
private class CfParticipant
{
public string Id { get; set; } = string.Empty;
public string Token { get; set; } = string.Empty;
public string CustomParticipantId { get; set; } = string.Empty;
}
public class CfResponse<T>
{
[JsonPropertyName("success")] public bool Success { get; set; }
[JsonPropertyName("data")] public T? Data { get; set; }
}
public class CfTokenResponse
{
[JsonPropertyName("token")] public string Token { get; set; } = string.Empty;
}
public class CfParticipantResponse
{
[JsonPropertyName("id")] public string Id { get; set; } = string.Empty;
[JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
[JsonPropertyName("customUserId")] public string CustomUserId { get; set; } = string.Empty;
[JsonPropertyName("presetName")] public string PresetName { get; set; } = string.Empty;
[JsonPropertyName("isActive")] public bool IsActive { get; set; }
[JsonPropertyName("token")] public string Token { get; set; } = string.Empty;
}
public class CfWebhookEvent
{
[JsonPropertyName("id")] public string Id { get; set; }
[JsonPropertyName("type")] public string Type { get; set; }
[JsonPropertyName("webhookId")] public string WebhookId { get; set; }
[JsonPropertyName("timestamp")] public DateTime Timestamp { get; set; }
[JsonPropertyName("event")] public EventData Event { get; set; }
}
public class EventData
{
[JsonPropertyName("meeting")] public MeetingData Meeting { get; set; }
[JsonPropertyName("participant")] public ParticipantData Participant { get; set; }
}
public class MeetingData
{
[JsonPropertyName("id")] public string Id { get; set; }
[JsonPropertyName("roomName")] public string RoomName { get; set; }
[JsonPropertyName("title")] public string Title { get; set; }
[JsonPropertyName("status")] public string Status { get; set; }
[JsonPropertyName("createdAt")] public DateTime CreatedAt { get; set; }
[JsonPropertyName("updatedAt")] public DateTime UpdatedAt { get; set; }
}
public class ParticipantData
{
[JsonPropertyName("id")] public string Id { get; set; }
[JsonPropertyName("userId")] public string UserId { get; set; }
[JsonPropertyName("customParticipantId")]
public string CustomParticipantId { get; set; }
[JsonPropertyName("presetName")] public string PresetName { get; set; }
[JsonPropertyName("joinedAt")] public DateTime JoinedAt { get; set; }
[JsonPropertyName("leftAt")] public DateTime? LeftAt { get; set; }
}
}

View File

@@ -30,7 +30,7 @@ public interface IRealtimeService
Task EndSessionAsync(string sessionId, RealtimeSessionConfig config);
/// <summary>
/// Gets a token for user to join the session
/// Gets a token for user to join the session (synchronous version for backward compatibility)
/// </summary>
/// <param name="account">The user identifier</param>
/// <param name="sessionId">The session identifier</param>
@@ -38,6 +38,15 @@ public interface IRealtimeService
/// <returns>User-specific token for the session</returns>
string GetUserToken(Account.Account account, string sessionId, bool isAdmin = false);
/// <summary>
/// Gets a token for user to join the session asynchronously
/// </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>Task that resolves to the user-specific token for the session</returns>
Task<string> GetUserTokenAsync(Account.Account account, string sessionId, bool isAdmin = false);
/// <summary>
/// Processes incoming webhook requests from the realtime service provider
/// </summary>

View File

@@ -10,33 +10,33 @@ namespace DysonNetwork.Sphere.Chat.Realtime;
/// <summary>
/// LiveKit implementation of the real-time communication service
/// </summary>
public class LivekitRealtimeService : IRealtimeService
public class LiveKitRealtimeService : IRealtimeService
{
private readonly AppDatabase _db;
private readonly ICacheService _cache;
private readonly WebSocketService _ws;
private readonly RealtimeStatusService _callStatus;
private readonly ILogger<LivekitRealtimeService> _logger;
private readonly ILogger<LiveKitRealtimeService> _logger;
private readonly RoomServiceClient _roomService;
private readonly AccessToken _accessToken;
private readonly WebhookReceiver _webhookReceiver;
public LivekitRealtimeService(
public LiveKitRealtimeService(
IConfiguration configuration,
ILogger<LivekitRealtimeService> logger,
ILogger<LiveKitRealtimeService> logger,
AppDatabase db,
ICacheService cache,
WebSocketService ws
RealtimeStatusService callStatus
)
{
_logger = logger;
// Get LiveKit configuration from appsettings
var host = configuration["RealtimeChat:Endpoint"] ??
var host = configuration["Realtime:LiveKit:Endpoint"] ??
throw new ArgumentNullException("Endpoint configuration is required");
var apiKey = configuration["RealtimeChat:ApiKey"] ??
var apiKey = configuration["Realtime:LiveKit:ApiKey"] ??
throw new ArgumentNullException("ApiKey configuration is required");
var apiSecret = configuration["RealtimeChat:ApiSecret"] ??
var apiSecret = configuration["Realtime:LiveKit:ApiSecret"] ??
throw new ArgumentNullException("ApiSecret configuration is required");
_roomService = new RoomServiceClient(host, apiKey, apiSecret);
@@ -45,7 +45,7 @@ public class LivekitRealtimeService : IRealtimeService
_db = db;
_cache = cache;
_ws = ws;
_callStatus = callStatus;
}
/// <inheritdoc />
@@ -112,6 +112,11 @@ public class LivekitRealtimeService : IRealtimeService
/// <inheritdoc />
public string GetUserToken(Account.Account account, string sessionId, bool isAdmin = false)
{
return GetUserTokenAsync(account, sessionId, isAdmin).GetAwaiter().GetResult();
}
public Task<string> GetUserTokenAsync(Account.Account account, string sessionId, bool isAdmin = false)
{
var token = _accessToken.WithIdentity(account.Name)
.WithName(account.Nick)
@@ -128,7 +133,7 @@ public class LivekitRealtimeService : IRealtimeService
.WithMetadata(JsonSerializer.Serialize(new Dictionary<string, string>
{ { "account_id", account.Id.ToString() } }))
.WithTtl(TimeSpan.FromHours(1));
return token.ToJwt();
return Task.FromResult(token.ToJwt());
}
public async Task ReceiveWebhook(string body, string authHeader)
@@ -159,7 +164,8 @@ public class LivekitRealtimeService : IRealtimeService
evt.Room.Name, evt.Participant.Identity);
// Broadcast participant list update to all participants
await _BroadcastParticipantUpdate(evt.Room.Name);
var info = await GetRoomParticipantsAsync(evt.Room.Name);
await _callStatus.BroadcastParticipantUpdate(evt.Room.Name, info);
}
break;
@@ -174,14 +180,15 @@ public class LivekitRealtimeService : IRealtimeService
evt.Room.Name, evt.Participant.Identity);
// Broadcast participant list update to all participants
await _BroadcastParticipantUpdate(evt.Room.Name);
var info = await GetRoomParticipantsAsync(evt.Room.Name);
await _callStatus.BroadcastParticipantUpdate(evt.Room.Name, info);
}
break;
}
}
private static string _GetParticipantsKey(string roomName)
private static string _GetParticipantsKey(string roomName)
=> $"RoomParticipants_{roomName}";
private async Task _AddParticipantToCache(string roomName, ParticipantInfo participant)
@@ -201,7 +208,7 @@ public class LivekitRealtimeService : IRealtimeService
}
// Get the current participants list
var participants = await _cache.GetAsync<List<ParticipantCacheItem>>(participantsKey) ??
var participants = await _cache.GetAsync<List<ParticipantInfoItem>>(participantsKey) ??
[];
// Check if the participant already exists
@@ -241,7 +248,7 @@ public class LivekitRealtimeService : IRealtimeService
}
// Get current participants list
var participants = await _cache.GetAsync<List<ParticipantCacheItem>>(participantsKey);
var participants = await _cache.GetAsync<List<ParticipantInfoItem>>(participantsKey);
if (participants == null || !participants.Any())
return;
@@ -253,36 +260,24 @@ public class LivekitRealtimeService : IRealtimeService
}
// Helper method to get participants in a room
public async Task<List<ParticipantCacheItem>> GetRoomParticipantsAsync(string roomName)
public async Task<List<ParticipantInfoItem>> GetRoomParticipantsAsync(string roomName)
{
var participantsKey = _GetParticipantsKey(roomName);
return await _cache.GetAsync<List<ParticipantCacheItem>>(participantsKey) ?? [];
return await _cache.GetAsync<List<ParticipantInfoItem>>(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)
private ParticipantInfoItem 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
return new ParticipantInfoItem
{
Identity = participant.Identity,
Name = participant.Name,
AccountId = accountId,
State = participant.State,
Metadata = metadata,
JoinedAt = DateTime.UtcNow
};
@@ -300,92 +295,13 @@ public class LivekitRealtimeService : IRealtimeService
_logger.LogError(ex, "Failed to parse participant metadata");
}
return new ParticipantCacheItem
return new ParticipantInfoItem
{
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,91 @@
using DysonNetwork.Sphere.Connection;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Chat.Realtime;
public class ParticipantInfoItem
{
public string Identity { get; set; } = null!;
public string Name { get; set; } = null!;
public Guid? AccountId { get; set; }
public Dictionary<string, string> Metadata { get; set; } = new();
public DateTime JoinedAt { get; set; }
}
public class RealtimeStatusService(AppDatabase db, WebSocketService ws, ILogger<RealtimeStatusService> logger)
{
// Broadcast participant update to all participants in a room
public async Task BroadcastParticipantUpdate(string roomName, List<ParticipantInfoItem> participantsInfo)
{
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 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 = participantsInfo
.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 = participantsInfo.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

@@ -1,5 +1,4 @@
using DysonNetwork.Sphere.Chat.Realtime;
using Livekit.Server.Sdk.Dotnet;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -7,13 +6,8 @@ using Swashbuckle.AspNetCore.Annotations;
namespace DysonNetwork.Sphere.Chat;
public class RealtimeChatConfiguration
{
public string Endpoint { get; set; } = null!;
}
[ApiController]
[Route("/chat/realtime")]
[Route("/api/chat/realtime")]
public class RealtimeCallController(
IConfiguration configuration,
AppDatabase db,
@@ -21,9 +15,6 @@ public class RealtimeCallController(
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.
@@ -36,9 +27,9 @@ public class RealtimeCallController(
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();
}
@@ -91,39 +82,17 @@ public class RealtimeCallController(
return BadRequest("Call session is not properly configured.");
var isAdmin = member.Role >= ChatMemberRole.Moderator;
var userToken = realtime.GetUserToken(currentUser, ongoingCall.SessionId, isAdmin);
var userToken = await realtime.GetUserTokenAsync(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 endpoint = configuration[$"Realtime:{realtime.ProviderName}:Endpoint"] ?? realtime.ProviderName switch
{
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);
}
}
// Unusable for sure, just for placeholder
"LiveKit" => "https://livekit.cloud",
"Cloudflare" => "https://rtk.realtime.cloudflare.com/v2",
// Unusable for sure, just for placeholder
_ => "https://example.com"
};
// Create the response model
var response = new JoinCallResponse
@@ -133,8 +102,7 @@ public class RealtimeCallController(
Token = userToken,
CallId = ongoingCall.Id,
RoomName = ongoingCall.SessionId,
IsAdmin = isAdmin,
Participants = participants
IsAdmin = isAdmin
};
return Ok(response);
@@ -192,7 +160,7 @@ public class JoinCallResponse
public string Provider { get; set; } = null!;
/// <summary>
/// The LiveKit server endpoint
/// The provider server endpoint
/// </summary>
public string Endpoint { get; set; } = null!;
@@ -215,11 +183,6 @@ public class JoinCallResponse
/// 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>
@@ -231,22 +194,22 @@ public class CallParticipant
/// 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>

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,45 @@
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;
var redirectWhiteList = new[] { "/ws", "/.well-known", "/swagger" };
if(redirectWhiteList.Any(w => context.Request.Path.StartsWithSegments(w)))
await next(context);
else if (!isWebPage && !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

@@ -64,9 +64,5 @@ public class MessageReadHandler(
};
buffer.Enqueue(readReceipt);
var otherMembers = (await crs.ListRoomMembers(request.ChatRoomId)).Select(m => m.AccountId).ToList();
foreach (var member in otherMembers)
srv.SendPacketToAccount(member, packet);
}
}

View File

@@ -17,7 +17,7 @@ public class MessageTypingHandler(ChatRoomService crs) : IWebSocketPacketHandler
WebSocketService srv
)
{
var request = packet.GetData<ChatController.TypingMessageRequest>();
var request = packet.GetData<ChatController.ChatRoomWsUniversalRequest>();
if (request is null)
{
await socket.SendAsync(

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

@@ -17,6 +17,31 @@ public class WebSocketService
(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,
@@ -39,6 +64,7 @@ public class WebSocketService
);
data.Cts.Cancel();
ActiveConnections.TryRemove(key, out _);
UnsubscribeFromChatRoom(key.DeviceId);
}
public bool GetAccountIsConnected(Guid accountId)

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

@@ -1,5 +1,8 @@
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;
@@ -12,28 +15,55 @@ public enum CustomAppStatus
Suspended
}
public class CustomApp : ModelBase
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;
public Instant? VerifiedAt { get; set; }
[MaxLength(4096)] public string? VerifiedAs { get; set; }
[JsonIgnore] private ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>();
[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? Remarks { 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

@@ -1,11 +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("/developers/apps")]
public class CustomAppController(PublisherService ps) : ControllerBase
[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;
}
}

View File

@@ -0,0 +1,142 @@
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Publisher;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Developer;
[ApiController]
[Route("/api/developers")]
public class DeveloperController(
AppDatabase db,
PublisherService ps,
ActionLogService als
)
: ControllerBase
{
[HttpGet("{name}")]
public async Task<ActionResult<Publisher.Publisher>> GetDeveloper(string name)
{
var publisher = await db.Publishers
.Where(e => e.Name == name)
.FirstOrDefaultAsync();
if (publisher is null) return NotFound();
return Ok(publisher);
}
[HttpGet("{name}/stats")]
public async Task<ActionResult<DeveloperStats>> GetDeveloperStats(string name)
{
var publisher = await db.Publishers
.Where(p => p.Name == name)
.FirstOrDefaultAsync();
if (publisher is null) return NotFound();
// Check if publisher has developer feature
var now = SystemClock.Instance.GetCurrentInstant();
var hasDeveloperFeature = await db.PublisherFeatures
.Where(f => f.PublisherId == publisher.Id)
.Where(f => f.Flag == PublisherFeatureFlag.Develop)
.Where(f => f.ExpiredAt == null || f.ExpiredAt > now)
.AnyAsync();
if (!hasDeveloperFeature) return NotFound("Not a developer account");
// Get custom apps count
var customAppsCount = await db.CustomApps
.Where(a => a.PublisherId == publisher.Id)
.CountAsync();
var stats = new DeveloperStats
{
TotalCustomApps = customAppsCount
};
return Ok(stats);
}
[HttpGet]
[Authorize]
public async Task<ActionResult<List<Publisher.Publisher>>> ListJoinedDevelopers()
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var members = await db.PublisherMembers
.Where(m => m.AccountId == userId)
.Where(m => m.JoinedAt != null)
.Include(e => e.Publisher)
.ToListAsync();
// Filter to only include publishers with the developer feature flag
var now = SystemClock.Instance.GetCurrentInstant();
var publisherIds = members.Select(m => m.Publisher.Id).ToList();
var developerPublisherIds = await db.PublisherFeatures
.Where(f => publisherIds.Contains(f.PublisherId))
.Where(f => f.Flag == PublisherFeatureFlag.Develop)
.Where(f => f.ExpiredAt == null || f.ExpiredAt > now)
.Select(f => f.PublisherId)
.ToListAsync();
return members
.Where(m => developerPublisherIds.Contains(m.Publisher.Id))
.Select(m => m.Publisher)
.ToList();
}
[HttpPost("{name}/enroll")]
[Authorize]
[RequiredPermission("global", "developers.create")]
public async Task<ActionResult<Publisher.Publisher>> EnrollDeveloperProgram(string name)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var publisher = await db.Publishers
.Where(p => p.Name == name)
.FirstOrDefaultAsync();
if (publisher is null) return NotFound();
// Check if the user is an owner of the publisher
var isOwner = await db.PublisherMembers
.AnyAsync(m =>
m.PublisherId == publisher.Id &&
m.AccountId == userId &&
m.Role == PublisherMemberRole.Owner &&
m.JoinedAt != null);
if (!isOwner) return StatusCode(403, "You must be the owner of the publisher to join the developer program");
// Check if already has a developer feature
var now = SystemClock.Instance.GetCurrentInstant();
var hasDeveloperFeature = await db.PublisherFeatures
.AnyAsync(f =>
f.PublisherId == publisher.Id &&
f.Flag == PublisherFeatureFlag.Develop &&
(f.ExpiredAt == null || f.ExpiredAt > now));
if (hasDeveloperFeature) return BadRequest("Publisher is already in the developer program");
// Add developer feature flag
var feature = new PublisherFeature
{
PublisherId = publisher.Id,
Flag = PublisherFeatureFlag.Develop,
ExpiredAt = null
};
db.PublisherFeatures.Add(feature);
await db.SaveChangesAsync();
return Ok(publisher);
}
public class DeveloperStats
{
public int TotalCustomApps { get; set; }
}
}

View File

@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Discovery;
[ApiController]
[Route("/api/discovery")]
public class DiscoveryController(DiscoveryService discoveryService) : ControllerBase
{
[HttpGet("realms")]
public Task<List<Realm.Realm>> GetPublicRealms(
[FromQuery] string? query,
[FromQuery] List<string>? tags,
[FromQuery] int take = 10,
[FromQuery] int offset = 0
)
{
return discoveryService.GetPublicRealmsAsync(query, tags, take, offset);
}
}

View File

@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Discovery;
public class DiscoveryService(AppDatabase appDatabase)
{
public Task<List<Realm.Realm>> GetPublicRealmsAsync(string? query,
List<string>? tags,
int take = 10,
int offset = 0,
bool randomizer = false
)
{
var realmsQuery = appDatabase.Realms
.Take(take)
.Skip(offset)
.Where(r => r.IsCommunity);
if (!string.IsNullOrEmpty(query))
{
realmsQuery = realmsQuery.Where(r => r.Name.Contains(query) || r.Description.Contains(query));
}
if (tags is { Count: > 0 })
realmsQuery = realmsQuery.Where(r => r.RealmTags.Any(rt => tags.Contains(rt.Tag.Name)));
if (randomizer)
realmsQuery = realmsQuery.OrderBy(r => EF.Functions.Random());
else
realmsQuery = realmsQuery.OrderByDescending(r => r.CreatedAt);
return realmsQuery.Skip(offset).Take(take).ToListAsync();
}
}

View File

@@ -9,6 +9,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libharfbuzz0b \
libgif7 \
libvips \
ffmpeg \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
@@ -45,4 +46,4 @@ FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "DysonNetwork.Sphere.dll"]
ENTRYPOINT ["dotnet", "DysonNetwork.Sphere.dll"]

View File

@@ -16,38 +16,41 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3"/>
<PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4"/>
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.1"/>
<PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1"/>
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0"/>
<PackageReference Include="FFMpegCore" Version="5.2.0"/>
<PackageReference Include="AngleSharp" Version="1.3.0" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" />
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.1" />
<PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="FFMpegCore" Version="5.2.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.1" />
<PackageReference Include="Livekit.Server.Sdk.Dotnet" Version="1.0.8" />
<PackageReference Include="MailKit" Version="4.11.0"/>
<PackageReference Include="MaxMind.GeoIP2" Version="5.3.0"/>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4"/>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2"/>
<PackageReference Include="MailKit" Version="4.11.0" />
<PackageReference Include="Markdig" Version="0.41.3" />
<PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4"/>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
<PackageReference Include="MimeTypes" Version="2.5.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Minio" Version="6.0.4"/>
<PackageReference Include="NetVips" Version="3.0.1"/>
<PackageReference Include="NetVips.Native.linux-x64" Version="8.16.1"/>
<PackageReference Include="NetVips.Native.osx-arm64" Version="8.16.1"/>
<PackageReference Include="Minio" Version="6.0.4" />
<PackageReference Include="NetVips" Version="3.0.1" />
<PackageReference Include="NetVips.Native.linux-x64" Version="8.16.1" />
<PackageReference Include="NetVips.Native.osx-arm64" Version="8.16.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NodaTime" Version="3.2.2"/>
<PackageReference Include="NodaTime" Version="3.2.2" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="9.0.4"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4"/>
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
@@ -59,18 +62,19 @@
<PackageReference Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
<PackageReference Include="prometheus-net.EntityFramework" Version="0.9.5" />
<PackageReference Include="prometheus-net.SystemMetrics" Version="3.1.0" />
<PackageReference Include="Quartz" Version="3.14.0"/>
<PackageReference Include="Quartz.AspNetCore" Version="3.14.0"/>
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0"/>
<PackageReference Include="Quartz" Version="3.14.0" />
<PackageReference Include="Quartz.AspNetCore" Version="3.14.0" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
<PackageReference Include="SkiaSharp" Version="2.88.9" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.9" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.9" />
<PackageReference Include="SkiaSharp.NativeAssets.macOS" Version="2.88.9" />
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
<PackageReference Include="StackExchange.Redis.Extensions.AspNetCore" Version="11.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.0"/>
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="8.1.0"/>
<PackageReference Include="tusdotnet" Version="2.8.1"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.0" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="8.1.0" />
<PackageReference Include="System.ServiceModel.Syndication" Version="9.0.6" />
<PackageReference Include="tusdotnet" Version="2.8.1" />
</ItemGroup>
<ItemGroup>
@@ -80,7 +84,8 @@
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\"/>
<Folder Include="Migrations\" />
<Folder Include="Discovery\" />
</ItemGroup>
<ItemGroup>
@@ -147,16 +152,17 @@
<AutoGen>True</AutoGen>
<DependentUpon>NotificationResource.resx</DependentUpon>
</Compile>
<Compile Remove="Auth\AppleAuthController.cs" />
</ItemGroup>
<ItemGroup>
<_ContentIncludedByDefault Remove="app\publish\appsettings.json"/>
<_ContentIncludedByDefault Remove="app\publish\DysonNetwork.Sphere.deps.json"/>
<_ContentIncludedByDefault Remove="app\publish\DysonNetwork.Sphere.runtimeconfig.json"/>
<_ContentIncludedByDefault Remove="app\publish\DysonNetwork.Sphere.staticwebassets.endpoints.json"/>
<_ContentIncludedByDefault Remove="app\publish\Keys\Solian.json"/>
<_ContentIncludedByDefault Remove="app\publish\package-lock.json"/>
<_ContentIncludedByDefault Remove="app\publish\package.json"/>
<_ContentIncludedByDefault Remove="app\publish\appsettings.json" />
<_ContentIncludedByDefault Remove="app\publish\DysonNetwork.Sphere.deps.json" />
<_ContentIncludedByDefault Remove="app\publish\DysonNetwork.Sphere.runtimeconfig.json" />
<_ContentIncludedByDefault Remove="app\publish\DysonNetwork.Sphere.staticwebassets.endpoints.json" />
<_ContentIncludedByDefault Remove="app\publish\Keys\Solian.json" />
<_ContentIncludedByDefault Remove="app\publish\package-lock.json" />
<_ContentIncludedByDefault Remove="app\publish\package.json" />
</ItemGroup>
</Project>

View File

@@ -2269,7 +2269,7 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid")
.HasColumnName("post_id");
b.Property<List<CloudFileSensitiveMark>>("SensitiveMarks")
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
.HasColumnType("jsonb")
.HasColumnName("sensitive_marks");

View File

@@ -852,7 +852,7 @@ namespace DysonNetwork.Sphere.Migrations
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
file_meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true),
user_meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true),
sensitive_marks = table.Column<List<CloudFileSensitiveMark>>(type: "jsonb", nullable: true),
sensitive_marks = table.Column<List<ContentSensitiveMark>>(type: "jsonb", nullable: true),
mime_type = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
hash = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
size = table.Column<long>(type: "bigint", nullable: false),

View File

@@ -2287,7 +2287,7 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid")
.HasColumnName("post_id");
b.Property<List<CloudFileSensitiveMark>>("SensitiveMarks")
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
.HasColumnType("jsonb")
.HasColumnName("sensitive_marks");

View File

@@ -2292,7 +2292,7 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid")
.HasColumnName("post_id");
b.Property<List<CloudFileSensitiveMark>>("SensitiveMarks")
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
.HasColumnType("jsonb")
.HasColumnName("sensitive_marks");

View File

@@ -2262,7 +2262,7 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid")
.HasColumnName("post_id");
b.Property<List<CloudFileSensitiveMark>>("SensitiveMarks")
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
.HasColumnType("jsonb")
.HasColumnName("sensitive_marks");

View File

@@ -2266,7 +2266,7 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid")
.HasColumnName("post_id");
b.Property<List<CloudFileSensitiveMark>>("SensitiveMarks")
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
.HasColumnType("jsonb")
.HasColumnName("sensitive_marks");

View File

@@ -2266,7 +2266,7 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid")
.HasColumnName("post_id");
b.Property<List<CloudFileSensitiveMark>>("SensitiveMarks")
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
.HasColumnType("jsonb")
.HasColumnName("sensitive_marks");

View File

@@ -2268,7 +2268,7 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid")
.HasColumnName("post_id");
b.Property<List<CloudFileSensitiveMark>>("SensitiveMarks")
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
.HasColumnType("jsonb")
.HasColumnName("sensitive_marks");

View File

@@ -2268,7 +2268,7 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid")
.HasColumnName("post_id");
b.Property<List<CloudFileSensitiveMark>>("SensitiveMarks")
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
.HasColumnType("jsonb")
.HasColumnName("sensitive_marks");

View File

@@ -2274,7 +2274,7 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid")
.HasColumnName("post_id");
b.Property<List<CloudFileSensitiveMark>>("SensitiveMarks")
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
.HasColumnType("jsonb")
.HasColumnName("sensitive_marks");

View File

@@ -2274,7 +2274,7 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid")
.HasColumnName("post_id");
b.Property<List<CloudFileSensitiveMark>>("SensitiveMarks")
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
.HasColumnType("jsonb")
.HasColumnName("sensitive_marks");

View File

@@ -2290,7 +2290,7 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid")
.HasColumnName("post_id");
b.Property<List<CloudFileSensitiveMark>>("SensitiveMarks")
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
.HasColumnType("jsonb")
.HasColumnName("sensitive_marks");

View File

@@ -2294,7 +2294,7 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid")
.HasColumnName("post_id");
b.Property<List<CloudFileSensitiveMark>>("SensitiveMarks")
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
.HasColumnType("jsonb")
.HasColumnName("sensitive_marks");

View File

@@ -2236,7 +2236,7 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid")
.HasColumnName("post_id");
b.Property<List<CloudFileSensitiveMark>>("SensitiveMarks")
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
.HasColumnType("jsonb")
.HasColumnName("sensitive_marks");

View File

@@ -2249,7 +2249,7 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid")
.HasColumnName("post_id");
b.Property<List<CloudFileSensitiveMark>>("SensitiveMarks")
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
.HasColumnType("jsonb")
.HasColumnName("sensitive_marks");

View File

@@ -2260,7 +2260,7 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid")
.HasColumnName("post_id");
b.Property<List<CloudFileSensitiveMark>>("SensitiveMarks")
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
.HasColumnType("jsonb")
.HasColumnName("sensitive_marks");

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