Compare commits

108 Commits

Author SHA1 Message Date
7c0ad46deb 🐛 Fix api redirect 2025-07-10 15:30:30 +08:00
b8fcd0d94f Post web view 2025-07-10 15:15:20 +08:00
fc6edd7378 💥 Add /api prefix for json endpoints with redirect 2025-07-10 14:18:02 +08:00
1f2cdb146d 🐛 Serval bug fixes 2025-07-10 12:53:45 +08:00
be236a27c6 🐛 Serval bug fixes and improvement to web page 2025-07-10 03:08:39 +08:00
99c36ae548 💄 Optimized the authorized page style 2025-07-10 02:28:27 +08:00
ed2961a5d5 💄 Restyled web pages 2025-07-10 01:53:44 +08:00
08b5ffa02f 🐛 Fix afdian got wrong URL to request 2025-07-09 22:51:14 +08:00
837a123c3b 🐛 Trying to fix payment handler 2025-07-09 22:00:06 +08:00
ad1166190f 🐛 Bug fixes 2025-07-09 21:43:39 +08:00
8e8c938132 🐛 Fix restore purchase in afdian 2025-07-09 21:39:38 +08:00
8e5b6ace45 Skip subscribed check in message 2025-07-07 13:08:31 +08:00
5757526ea5 Get user blocked users infra 2025-07-03 21:57:16 +08:00
6a9cd0905d Unblock user 2025-07-03 21:47:17 +08:00
082a096470 🐛 Fix wrong pagination query param name 2025-07-03 13:14:19 +08:00
3a72347432 🐛 Fix inconsistent between authorized feed and unauthorized feed 2025-07-03 00:43:02 +08:00
19b1e957dd 🐛 Trying to fix push notification 2025-07-03 00:05:00 +08:00
6449926334 Subscription required level, optimized cancellation logic 2025-07-02 21:58:44 +08:00
fb885e138d 💄 Optimized subscriptions 2025-07-02 21:30:35 +08:00
5bdc21ebc5 💄 Optimize activity service 2025-07-02 21:09:35 +08:00
f177377fe3 Return the complete data while auto completion 2025-07-02 01:16:59 +08:00
0df4864888 Auto completion handler for account and stickers 2025-07-01 23:51:26 +08:00
29b0ad184e 🐛 Fix search didn't contains description in post 2025-07-01 23:42:45 +08:00
ad730832db Searching in posts 2025-07-01 23:39:13 +08:00
71fcc26534 🐛 Bug fixes on web article 2025-07-01 22:35:46 +08:00
fb8fc69920 🐛 Fix web article loop 2025-07-01 00:57:05 +08:00
05bf2cd055 Web articles 2025-07-01 00:44:48 +08:00
ccb8a4e3f4 Bug fixes on web feed & scraping 2025-06-30 23:26:05 +08:00
ca5be5a01c ♻️ Refined web feed APIs 2025-06-29 22:02:26 +08:00
c4ea15097e ♻️ Refined custom apps 2025-06-29 20:32:08 +08:00
cdeed3c318 🐛 Fixes custom app 2025-06-29 19:38:29 +08:00
a53fcb10dd Developer programs APIs 2025-06-29 18:37:23 +08:00
c0879d30d4 Oidc auto approval and session reuse 2025-06-29 17:46:17 +08:00
0226bf8fa3 Complete oauth / oidc 2025-06-29 17:29:24 +08:00
217b434cc4 🐛 Dozen of bugs fixes 2025-06-29 16:35:01 +08:00
f8295c6a18 ♻️ Refactored oidc 2025-06-29 11:53:44 +08:00
d4fa08d320 Support OIDC 2025-06-29 03:47:58 +08:00
8bd0ea0fa1 💄 Optimized profile page 2025-06-29 00:58:08 +08:00
9ab31d79ce 💄 Optimized web version styling 2025-06-29 00:44:59 +08:00
ee5d6ef821 Support factor hint in web login 2025-06-29 00:38:00 +08:00
d7b443e678 Web version login support send factor code 2025-06-29 00:32:55 +08:00
98b2eeb13d Web version login 2025-06-28 22:53:07 +08:00
ec3961d546 🐛 Fix subscription notification send twice 2025-06-28 22:08:53 +08:00
a5dae37525 Publisher features flags APIs 2025-06-28 20:56:53 +08:00
933d762f24 Custom apps developer APIs 2025-06-28 19:20:36 +08:00
8251a9ec7d Publisher myself endpoint 2025-06-28 18:53:04 +08:00
38243f9eba 🐛 Fix publisher member has no account in response 2025-06-28 18:40:20 +08:00
b0b7afd6b3 Publisher members APIs 2025-06-28 18:13:32 +08:00
6237fd6140 🐛 Fix file analyze didn't generate video ratio 2025-06-28 03:00:53 +08:00
2e8d6a3667 🐛 Bug fixes 2025-06-28 01:28:20 +08:00
ac496777ed 🐛 Fix update realm failed caused by picture 2025-06-27 23:41:51 +08:00
19ddc1b363 👔 Discovery only shows community realms 2025-06-27 23:30:37 +08:00
661b612537 Chat and realm members with status 2025-06-27 23:24:27 +08:00
8432436fcf 🐛 Fix circular dependency in service injection 2025-06-27 23:16:28 +08:00
2a28948418 Chat room subscribe 2025-06-27 22:55:00 +08:00
5dd138949e 🐛 Fix update chat room failed 2025-06-27 22:54:50 +08:00
f540544a47 Activity debug include 2025-06-27 22:35:23 +08:00
9f8eec792b 🐛 Fix missing pagination query in discovery controller 2025-06-27 18:02:12 +08:00
0bdd429d87 🐛 Fix realm chat controllers path typo 2025-06-27 17:37:13 +08:00
b2203fb464 Publisher's recommendation (discovery) 2025-06-27 16:14:25 +08:00
c5bbd58f5c 👔 Reduce the chance to display realm discovery 2025-06-27 15:54:56 +08:00
35a9dcffbc 🐛 Fix post modeling data infiniting loop 2025-06-27 01:11:19 +08:00
1d50f225c1 🐛 Fix analyzing images failed due to duplicate meta keys 2025-06-27 00:57:00 +08:00
b7263b9804 💥 Update activity data format 2025-06-26 22:28:18 +08:00
c63d6e0fbc 💄 Optimize realm discovery 2025-06-26 19:59:09 +08:00
cebd1bd65a 💄 Optimized file analyze 2025-06-26 19:21:26 +08:00
da58e10d88 Ranked posts 2025-06-26 19:17:28 +08:00
d492c9ce1f Realm tags and discovery 2025-06-26 19:00:55 +08:00
f170793928 💄 Optimized web articles 2025-06-26 18:34:51 +08:00
1a137fbb6a Web articles and feed 2025-06-26 17:36:45 +08:00
21cf212d8f Abuse report 2025-06-25 23:49:18 +08:00
c6cb2a0dc3 🗃️ Remove the fk of session in action logs in order to fix logout 2025-06-25 00:03:50 +08:00
d9747daab9 👔 Optimize the file service handling for image 2025-06-24 23:58:16 +08:00
d91b705b9a 🐛 Fix the localization file 2025-06-23 03:06:52 +08:00
5ce3598cc9 🐛 Bug fixes in restore purchase with afdian 2025-06-23 02:59:41 +08:00
1b45f07419 🐛 Fixes notification 2025-06-23 02:46:49 +08:00
6bec0a672e 🐛 Fix create subscription from order will make the identifier null 2025-06-23 02:40:24 +08:00
c338512c16 🐛 Fix afdian webhook 2025-06-23 02:31:22 +08:00
9444913b72 🐛 Bug fixes for afdian webhook 2025-06-23 02:22:45 +08:00
50bfec59ee 🐛 Fix json library inconsistent cause the field name different 2025-06-23 02:08:03 +08:00
a97bf15362 🐛 Trying to fix afdian webhook 2025-06-23 01:58:12 +08:00
feb612afcd Payment and subscription notification 2025-06-23 01:34:53 +08:00
049a5c9b6f 🐛 Trying to fix bugs on afdian oidc 2025-06-23 01:11:21 +08:00
694bc77921 🔊 Add more logs on afdian oidc 2025-06-23 00:57:10 +08:00
be0b48cfd9 Afdian as payment handler 2025-06-23 00:29:37 +08:00
a23338c263 🐛 Fix afdian oauth 2025-06-22 22:02:14 +08:00
c5ef9b065b 🐛 Bug fixes 2025-06-22 21:42:28 +08:00
5990b17b4c Subscription status validation on profile 2025-06-22 20:09:52 +08:00
de7a2cea09 Complete subscriptions 2025-06-22 19:56:42 +08:00
698442ad13 Subscription and stellar program 2025-06-22 17:57:19 +08:00
9fd6016308 Subscription service 2025-06-22 03:15:16 +08:00
516090a5f8 🐛 Fixes afdian service 2025-06-22 02:35:12 +08:00
6b0e5f919d Add afdian as OIDC provider 2025-06-22 02:22:19 +08:00
c6450757be Add pin code 2025-06-22 00:18:50 +08:00
38abe16ba6 🐛 Fix bugs of subscription 2025-06-22 00:08:27 +08:00
bf40b51c41 🐛 Fix web reader can't get opengraph data 2025-06-22 00:06:19 +08:00
f50894a3d1 🐛 Fix publisher subscription cache and status validation 2025-06-21 23:46:59 +08:00
d1fb0b9b55 Filter on activities 2025-06-21 22:21:20 +08:00
f1a47fd079 Chat message also preview links 2025-06-21 15:19:49 +08:00
546b65f4c6 🐛 Fixed post service 2025-06-21 14:47:21 +08:00
1baa3109bc 🎨 Removed unused dependency injected services arguments in constructor 2025-06-21 14:32:59 +08:00
eadf25f389 🐛 Bug fixes in post embed links 2025-06-21 14:30:19 +08:00
d385abbf57 🐛 Trying to fix post link auto preview 2025-06-21 14:17:55 +08:00
a431fbbd51 🐛 Fix the logger service broke the post service 2025-06-21 14:09:56 +08:00
d83c69620f Adding the link preview automatically to post 2025-06-21 14:02:32 +08:00
cb8e720af1 🎨 Unified the cache key styling 2025-06-21 13:48:20 +08:00
5f30b56ef8 Link scrapping for preview 2025-06-21 13:46:30 +08:00
95010e4188 🐛 Fix set cache with group broken the cache 2025-06-21 13:46:21 +08:00
164 changed files with 42037 additions and 855 deletions

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

@ -23,7 +23,7 @@ public class Account : ModelBase
public Profile Profile { get; set; } = null!; public Profile Profile { get; set; } = null!;
public ICollection<AccountContact> Contacts { get; set; } = new List<AccountContact>(); public ICollection<AccountContact> Contacts { get; set; } = new List<AccountContact>();
public ICollection<Badge> Badges { get; set; } = new List<Badge>(); public ICollection<Badge> Badges { get; set; } = new List<Badge>();
[JsonIgnore] public ICollection<AccountAuthFactor> AuthFactors { get; set; } = new List<AccountAuthFactor>(); [JsonIgnore] public ICollection<AccountAuthFactor> AuthFactors { get; set; } = new List<AccountAuthFactor>();
[JsonIgnore] public ICollection<AccountConnection> Connections { get; set; } = new List<AccountConnection>(); [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.Session> Sessions { get; set; } = new List<Auth.Session>();
@ -31,7 +31,7 @@ public class Account : ModelBase
[JsonIgnore] public ICollection<Relationship> OutgoingRelationships { get; set; } = new List<Relationship>(); [JsonIgnore] public ICollection<Relationship> OutgoingRelationships { get; set; } = new List<Relationship>();
[JsonIgnore] public ICollection<Relationship> IncomingRelationships { 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>(); [JsonIgnore] public ICollection<Subscription> Subscriptions { get; set; } = new List<Subscription>();
} }
@ -119,12 +119,15 @@ public class AccountAuthFactor : ModelBase
public Guid Id { get; set; } public Guid Id { get; set; }
public AccountAuthFactorType Type { get; set; } public AccountAuthFactorType Type { get; set; }
[JsonIgnore] [MaxLength(8196)] public string? Secret { 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> /// <summary>
/// The trustworthy stands for how safe is this auth factor. /// The trustworthy stands for how safe is this auth factor.
/// Basically, it affects how many steps it can complete in authentication. /// 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> /// </summary>
public int Trustworthy { get; set; } = 1; public int Trustworthy { get; set; } = 1;
@ -148,6 +151,7 @@ public class AccountAuthFactor : ModelBase
switch (Type) switch (Type)
{ {
case AccountAuthFactorType.Password: case AccountAuthFactorType.Password:
case AccountAuthFactorType.PinCode:
return BCrypt.Net.BCrypt.Verify(password, Secret); return BCrypt.Net.BCrypt.Verify(password, Secret);
case AccountAuthFactorType.TimedCode: case AccountAuthFactorType.TimedCode:
var otp = new Totp(Base32Encoding.ToBytes(Secret)); var otp = new Totp(Base32Encoding.ToBytes(Secret));
@ -172,7 +176,8 @@ public enum AccountAuthFactorType
Password, Password,
EmailCode, EmailCode,
InAppCode, InAppCode,
TimedCode TimedCode,
PinCode,
} }
public class AccountConnection : ModelBase public class AccountConnection : ModelBase
@ -181,11 +186,11 @@ public class AccountConnection : ModelBase
[MaxLength(4096)] public string Provider { get; set; } = null!; [MaxLength(4096)] public string Provider { get; set; } = null!;
[MaxLength(8192)] public string ProvidedIdentifier { get; set; } = null!; [MaxLength(8192)] public string ProvidedIdentifier { get; set; } = null!;
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; } = new(); [Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; } = new();
[JsonIgnore] [MaxLength(4096)] public string? AccessToken { get; set; } [JsonIgnore] [MaxLength(4096)] public string? AccessToken { get; set; }
[JsonIgnore] [MaxLength(4096)] public string? RefreshToken { get; set; } [JsonIgnore] [MaxLength(4096)] public string? RefreshToken { get; set; }
public Instant? LastUsedAt { get; set; } public Instant? LastUsedAt { get; set; }
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
public Account Account { get; set; } = null!; public Account Account { get; set; } = null!;
} }

View File

@ -1,7 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Auth; using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -12,14 +11,12 @@ using System.Collections.Generic;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Sphere.Account;
[ApiController] [ApiController]
[Route("/accounts")] [Route("/api/accounts")]
public class AccountController( public class AccountController(
AppDatabase db, AppDatabase db,
FileService fs,
AuthService auth, AuthService auth,
AccountService accounts, AccountService accounts,
AccountEventService events, AccountEventService events
MagicSpellService spells
) : ControllerBase ) : ControllerBase
{ {
[HttpGet("{name}")] [HttpGet("{name}")]
@ -177,13 +174,4 @@ public class AccountController(
.Take(take) .Take(take)
.ToListAsync(); .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] [Authorize]
[ApiController] [ApiController]
[Route("/accounts/me")] [Route("/api/accounts/me")]
public class AccountCurrentController( public class AccountCurrentController(
AppDatabase db, AppDatabase db,
AccountService accounts, AccountService accounts,

View File

@ -65,7 +65,7 @@ public class AccountEventService(
}; };
} }
return new Status return new Status
{ {
Attitude = StatusAttitude.Neutral, Attitude = StatusAttitude.Neutral,
IsOnline = false, 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) public async Task<Status> CreateStatus(Account user, Status status)
{ {
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();

View File

@ -57,6 +57,15 @@ public class AccountService(
return contact?.Account; 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) public async Task<int?> GetAccountLevel(Guid accountId)
{ {
var profile = await db.AccountProfiles var profile = await db.AccountProfiles
@ -257,6 +266,18 @@ public class AccountService(
} }
}; };
break; 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: default:
throw new ArgumentOutOfRangeException(nameof(type), type, null); throw new ArgumentOutOfRangeException(nameof(type), type, null);
} }

View File

@ -4,7 +4,7 @@ using Point = NetTopologySuite.Geometries.Point;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Sphere.Account;
public class ActionLogType public abstract class ActionLogType
{ {
public const string NewLogin = "login"; public const string NewLogin = "login";
public const string ChallengeAttempt = "challenges.attempt"; public const string ChallengeAttempt = "challenges.attempt";
@ -55,5 +55,4 @@ public class ActionLog : ModelBase
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
public Account Account { get; set; } = null!; public Account Account { get; set; } = null!;
public Guid? SessionId { get; set; } 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; namespace DysonNetwork.Sphere.Account;
[ApiController] [ApiController]
[Route("/spells")] [Route("/api/spells")]
public class MagicSpellController(AppDatabase db, MagicSpellService sp) : ControllerBase public class MagicSpellController(AppDatabase db, MagicSpellService sp) : ControllerBase
{ {
[HttpPost("{spellId:guid}/resend")] [HttpPost("{spellId:guid}/resend")]

View File

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

View File

@ -10,7 +10,6 @@ namespace DysonNetwork.Sphere.Account;
public class NotificationService( public class NotificationService(
AppDatabase db, AppDatabase db,
WebSocketService ws, WebSocketService ws,
ILogger<NotificationService> logger,
IHttpClientFactory httpFactory, IHttpClientFactory httpFactory,
IConfiguration config) IConfiguration config)
{ {
@ -31,6 +30,9 @@ public class NotificationService(
string deviceToken string deviceToken
) )
{ {
var now = SystemClock.Instance.GetCurrentInstant();
// First check if a matching subscription exists
var existingSubscription = await db.NotificationPushSubscriptions var existingSubscription = await db.NotificationPushSubscriptions
.Where(s => s.AccountId == account.Id) .Where(s => s.AccountId == account.Id)
.Where(s => s.DeviceId == deviceId || s.DeviceToken == deviceToken) .Where(s => s.DeviceId == deviceId || s.DeviceToken == deviceToken)
@ -38,11 +40,18 @@ public class NotificationService(
if (existingSubscription is not null) 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.DeviceId = deviceId;
existingSubscription.DeviceToken = deviceToken; existingSubscription.DeviceToken = deviceToken;
db.Update(existingSubscription); existingSubscription.UpdatedAt = now;
await db.SaveChangesAsync();
return existingSubscription; return existingSubscription;
} }
@ -216,7 +225,7 @@ public class NotificationService(
var notifications = subDict.Select(value => var notifications = subDict.Select(value =>
{ {
int platformCode = value.Key switch var platformCode = value.Key switch
{ {
NotificationPushProvider.Apple => 1, NotificationPushProvider.Apple => 1,
NotificationPushProvider.Google => 2, NotificationPushProvider.Google => 2,
@ -289,7 +298,7 @@ public class NotificationService(
var client = httpFactory.CreateClient(); var client = httpFactory.CreateClient();
client.BaseAddress = _notifyEndpoint; client.BaseAddress = _notifyEndpoint;
var request = await client.PostAsync("/api/push", new StringContent( var request = await client.PostAsync("/push", new StringContent(
JsonSerializer.Serialize(requestDict), JsonSerializer.Serialize(requestDict),
Encoding.UTF8, Encoding.UTF8,
"application/json" "application/json"

View File

@ -7,7 +7,7 @@ using NodaTime;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Sphere.Account;
[ApiController] [ApiController]
[Route("/relationships")] [Route("/api/relationships")]
public class RelationshipController(AppDatabase db, RelationshipService rels) : ControllerBase public class RelationshipController(AppDatabase db, RelationshipService rels) : ControllerBase
{ {
[HttpGet] [HttpGet]
@ -230,4 +230,24 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
return BadRequest(err.Message); 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) 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) public async Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId)
{ {
@ -50,9 +51,8 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
db.AccountRelationships.Add(relationship); db.AccountRelationships.Add(relationship);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relationship.AccountId}"); await PurgeRelationshipCache(sender.Id, target.Id);
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relationship.RelatedId}");
return relationship; return relationship;
} }
@ -63,6 +63,18 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
return await UpdateRelationship(sender.Id, target.Id, RelationshipStatus.Blocked); return await UpdateRelationship(sender.Id, target.Id, RelationshipStatus.Blocked);
return await CreateRelationship(sender, target, 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) 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) .Where(r => r.AccountId == accountId && r.RelatedId == relatedId && r.Status == RelationshipStatus.Pending)
.ExecuteDeleteAsync(); .ExecuteDeleteAsync();
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}"); await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relatedId}");
} }
public async Task<Relationship> AcceptFriendRelationship( public async Task<Relationship> AcceptFriendRelationship(
@ -122,8 +133,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relationship.AccountId}"); await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relationship.RelatedId}");
return relationshipBackward; return relationshipBackward;
} }
@ -137,15 +147,14 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
db.Update(relationship); db.Update(relationship);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}"); await PurgeRelationshipCache(accountId, relatedId);
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relatedId}");
return relationship; return relationship;
} }
public async Task<List<Guid>> ListAccountFriends(Account account) 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); var friends = await cache.GetAsync<List<Guid>>(cacheKey);
if (friends == null) if (friends == null)
@ -161,6 +170,25 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
return friends ?? []; 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, public async Task<bool> HasRelationshipWithStatus(Guid accountId, Guid relatedId,
RelationshipStatus status = RelationshipStatus.Friends) RelationshipStatus status = RelationshipStatus.Friends)
@ -168,4 +196,12 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
var relationship = await GetRelationship(accountId, relatedId, status); var relationship = await GetRelationship(accountId, relatedId, status);
return relationship is not null; 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

@ -8,7 +8,7 @@ namespace DysonNetwork.Sphere.Activity;
/// Activity is a universal feed that contains multiple kinds of data. Personalized and generated dynamically. /// Activity is a universal feed that contains multiple kinds of data. Personalized and generated dynamically.
/// </summary> /// </summary>
[ApiController] [ApiController]
[Route("/activities")] [Route("/api/activities")]
public class ActivityController( public class ActivityController(
ActivityService acts ActivityService acts
) : ControllerBase ) : ControllerBase
@ -22,8 +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. /// Besides, when users are logged in, it will also mix the other kinds of data and who're plying to them.
/// </summary> /// </summary>
[HttpGet] [HttpGet]
public async Task<ActionResult<List<Activity>>> ListActivities([FromQuery] string? cursor, public async Task<ActionResult<List<Activity>>> ListActivities(
[FromQuery] int take = 20) [FromQuery] string? cursor,
[FromQuery] string? filter,
[FromQuery] int take = 20,
[FromQuery] string? debugInclude = null
)
{ {
Instant? cursorTimestamp = null; Instant? cursorTimestamp = null;
if (!string.IsNullOrEmpty(cursor)) if (!string.IsNullOrEmpty(cursor))
@ -38,10 +42,11 @@ public class ActivityController(
} }
} }
var debugIncludeSet = debugInclude?.Split(',').ToHashSet() ?? new HashSet<string>();
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
return currentUserValue is not Account.Account currentUser return currentUserValue is not Account.Account currentUser
? Ok(await acts.GetActivitiesForAnyone(take, cursorTimestamp)) ? Ok(await acts.GetActivitiesForAnyone(take, cursorTimestamp, debugIncludeSet))
: Ok(await acts.GetActivities(take, cursorTimestamp, currentUser)); : Ok(await acts.GetActivities(take, cursorTimestamp, currentUser, filter, debugIncludeSet));
} }
} }

View File

@ -1,4 +1,6 @@
using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Connection.WebReader;
using DysonNetwork.Sphere.Discovery;
using DysonNetwork.Sphere.Post; using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Publisher; using DysonNetwork.Sphere.Publisher;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -6,14 +8,75 @@ using NodaTime;
namespace DysonNetwork.Sphere.Activity; namespace DysonNetwork.Sphere.Activity;
public class ActivityService(AppDatabase db, PublisherService pub, 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>(); var activities = new List<Activity>();
debugInclude ??= new HashSet<string>();
// Crunching up data if (cursor == null && (debugInclude.Contains("realms") || Random.Shared.NextDouble() < 0.2))
var posts = await db.Posts {
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.RepliedPost)
.Include(e => e.ForwardedPost) .Include(e => e.ForwardedPost)
.Include(e => e.Categories) .Include(e => e.Categories)
@ -22,8 +85,9 @@ public class ActivityService(AppDatabase db, PublisherService pub, RelationshipS
.Where(p => cursor == null || p.PublishedAt < cursor) .Where(p => cursor == null || p.PublishedAt < cursor)
.OrderByDescending(p => p.PublishedAt) .OrderByDescending(p => p.PublishedAt)
.FilterWithVisibility(null, [], [], isListing: true) .FilterWithVisibility(null, [], [], isListing: true)
.Take(take) .Take(take * 5); // Fetch more posts to have a good pool for ranking
.ToListAsync();
var posts = await postsQuery.ToListAsync();
posts = await ps.LoadPostInfo(posts, null, true); posts = await ps.LoadPostInfo(posts, null, true);
var postsId = posts.Select(e => e.Id).ToList(); var postsId = posts.Select(e => e.Id).ToList();
@ -32,8 +96,17 @@ public class ActivityService(AppDatabase db, PublisherService pub, RelationshipS
post.ReactionsCount = post.ReactionsCount =
reactionMaps.TryGetValue(post.Id, out var count) ? count : new Dictionary<string, int>(); 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 // Formatting data
foreach (var post in posts) foreach (var post in rankedPosts)
activities.Add(post.ToActivity()); activities.Add(post.ToActivity());
if (activities.Count == 0) if (activities.Count == 0)
@ -42,26 +115,109 @@ public class ActivityService(AppDatabase db, PublisherService pub, RelationshipS
return activities; 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 activities = new List<Activity>();
var userFriends = await rels.ListAccountFriends(currentUser); var userFriends = await rels.ListAccountFriends(currentUser);
var userPublishers = await pub.GetUserPublishers(currentUser.Id); var userPublishers = await pub.GetUserPublishers(currentUser.Id);
debugInclude ??= [];
var publishersId = userPublishers.Select(e => e.Id).ToList();
if (string.IsNullOrEmpty(filter))
// Crunching data {
var posts = await db.Posts 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.RepliedPost)
.Include(e => e.ForwardedPost) .Include(e => e.ForwardedPost)
.Include(e => e.Categories) .Include(e => e.Categories)
.Include(e => e.Tags) .Include(e => e.Tags)
.Where(e => e.RepliedPostId == null || publishersId.Contains(e.RepliedPost!.PublisherId))
.Where(p => cursor == null || p.PublishedAt < cursor) .Where(p => cursor == null || p.PublishedAt < cursor)
.OrderByDescending(p => p.PublishedAt) .OrderByDescending(p => p.PublishedAt)
.FilterWithVisibility(currentUser, userFriends, userPublishers, isListing: true) .AsQueryable();
.Take(take)
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(); .ToListAsync();
posts = await ps.LoadPostInfo(posts, currentUser, true); posts = await ps.LoadPostInfo(posts, currentUser, true);
var postsId = posts.Select(e => e.Id).ToList(); var postsId = posts.Select(e => e.Id).ToList();
@ -75,8 +231,17 @@ public class ActivityService(AppDatabase db, PublisherService pub, RelationshipS
await ps.IncreaseViewCount(post.Id, currentUser.Id.ToString()); 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 // Formatting data
foreach (var post in posts) foreach (var post in rankedPosts)
activities.Add(post.ToActivity()); activities.Add(post.ToActivity());
if (activities.Count == 0) if (activities.Count == 0)
@ -84,4 +249,37 @@ public class ActivityService(AppDatabase db, PublisherService pub, RelationshipS
return activities; 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

@ -54,6 +54,7 @@ public class AppDatabase(
public DbSet<NotificationPushSubscription> NotificationPushSubscriptions { get; set; } public DbSet<NotificationPushSubscription> NotificationPushSubscriptions { get; set; }
public DbSet<Badge> Badges { get; set; } public DbSet<Badge> Badges { get; set; }
public DbSet<ActionLog> ActionLogs { get; set; } public DbSet<ActionLog> ActionLogs { get; set; }
public DbSet<AbuseReport> AbuseReports { get; set; }
public DbSet<Session> AuthSessions { get; set; } public DbSet<Session> AuthSessions { get; set; }
public DbSet<Challenge> AuthChallenges { get; set; } public DbSet<Challenge> AuthChallenges { get; set; }
@ -74,6 +75,8 @@ public class AppDatabase(
public DbSet<Realm.Realm> Realms { get; set; } public DbSet<Realm.Realm> Realms { get; set; }
public DbSet<RealmMember> RealmMembers { get; set; } public DbSet<RealmMember> RealmMembers { get; set; }
public DbSet<Tag> Tags { get; set; }
public DbSet<RealmTag> RealmTags { get; set; }
public DbSet<ChatRoom> ChatRooms { get; set; } public DbSet<ChatRoom> ChatRooms { get; set; }
public DbSet<ChatMember> ChatMembers { get; set; } public DbSet<ChatMember> ChatMembers { get; set; }
@ -94,6 +97,8 @@ public class AppDatabase(
public DbSet<Subscription> WalletSubscriptions { get; set; } public DbSet<Subscription> WalletSubscriptions { get; set; }
public DbSet<Coupon> WalletCoupons { 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) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
@ -136,6 +141,8 @@ public class AppDatabase(
} }
}); });
optionsBuilder.UseSeeding((context, _) => {});
base.OnConfiguring(optionsBuilder); base.OnConfiguring(optionsBuilder);
} }
@ -189,6 +196,17 @@ public class AppDatabase(
.HasGeneratedTsVectorColumn(p => p.SearchVector, "simple", p => new { p.Title, p.Description, p.Content }) .HasGeneratedTsVectorColumn(p => p.SearchVector, "simple", p => new { p.Title, p.Description, p.Content })
.HasIndex(p => p.SearchVector) .HasIndex(p => p.SearchVector)
.HasMethod("GIN"); .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>() modelBuilder.Entity<Post.Post>()
.HasOne(p => p.RepliedPost) .HasOne(p => p.RepliedPost)
.WithMany() .WithMany()
@ -225,6 +243,19 @@ public class AppDatabase(
.HasForeignKey(pm => pm.AccountId) .HasForeignKey(pm => pm.AccountId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<RealmTag>()
.HasKey(rt => new { rt.RealmId, rt.TagId });
modelBuilder.Entity<RealmTag>()
.HasOne(rt => rt.Realm)
.WithMany(r => r.RealmTags)
.HasForeignKey(rt => rt.RealmId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<RealmTag>()
.HasOne(rt => rt.Tag)
.WithMany(t => t.RealmTags)
.HasForeignKey(rt => rt.TagId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<ChatMember>() modelBuilder.Entity<ChatMember>()
.HasKey(pm => new { pm.Id }); .HasKey(pm => new { pm.Id });
modelBuilder.Entity<ChatMember>() modelBuilder.Entity<ChatMember>()
@ -260,6 +291,14 @@ public class AppDatabase(
.HasForeignKey(m => m.SenderId) .HasForeignKey(m => m.SenderId)
.OnDelete(DeleteBehavior.Cascade); .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 // Automatically apply soft-delete filter to all entities inheriting BaseModel
foreach (var entityType in modelBuilder.Model.GetEntityTypes()) foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{ {

View File

@ -1,12 +1,19 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Auth.OidcProvider.Options;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Storage.Handlers; using DysonNetwork.Sphere.Storage.Handlers;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; 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; using SystemClock = NodaTime.SystemClock;
namespace DysonNetwork.Sphere.Auth; namespace DysonNetwork.Sphere.Auth;
@ -15,12 +22,14 @@ public static class AuthConstants
{ {
public const string SchemeName = "DysonToken"; public const string SchemeName = "DysonToken";
public const string TokenQueryParamName = "tk"; public const string TokenQueryParamName = "tk";
public const string CookieTokenName = "AuthToken";
} }
public enum TokenType public enum TokenType
{ {
AuthKey, AuthKey,
ApiKey, ApiKey,
OidcKey,
Unknown Unknown
} }
@ -38,13 +47,14 @@ public class DysonTokenAuthHandler(
ILoggerFactory logger, ILoggerFactory logger,
UrlEncoder encoder, UrlEncoder encoder,
AppDatabase database, AppDatabase database,
OidcProviderService oidc,
ICacheService cache, ICacheService cache,
FlushBufferService fbs FlushBufferService fbs
) )
: AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder) : AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder)
{ {
public const string AuthCachePrefix = "auth:"; public const string AuthCachePrefix = "auth:";
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{ {
var tokenInfo = _ExtractToken(Request); var tokenInfo = _ExtractToken(Request);
@ -77,7 +87,7 @@ public class DysonTokenAuthHandler(
{ {
// Store in cache for future requests // Store in cache for future requests
await cache.SetWithGroupsAsync( await cache.SetWithGroupsAsync(
$"Auth_{sessionId}", $"auth:{sessionId}",
session, session,
[$"{AccountService.AccountCachePrefix}{session.Account.Id}"], [$"{AccountService.AccountCachePrefix}{session.Account.Id}"],
TimeSpan.FromHours(1) TimeSpan.FromHours(1)
@ -126,7 +136,7 @@ public class DysonTokenAuthHandler(
SeenAt = SystemClock.Instance.GetCurrentInstant(), SeenAt = SystemClock.Instance.GetCurrentInstant(),
}; };
fbs.Enqueue(lastInfo); fbs.Enqueue(lastInfo);
return AuthenticateResult.Success(ticket); return AuthenticateResult.Success(ticket);
} }
catch (Exception ex) catch (Exception ex)
@ -141,35 +151,60 @@ public class DysonTokenAuthHandler(
try try
{ {
// Split the token
var parts = token.Split('.'); var parts = token.Split('.');
if (parts.Length != 2)
return false;
// Decode the payload switch (parts.Length)
var payloadBytes = Base64UrlDecode(parts[0]); {
// 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 return Guid.TryParse(jti, out sessionId);
sessionId = new Guid(payloadBytes); }
// 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 // Extract session ID
var publicKeyPem = File.ReadAllText(configuration["Jwt:PublicKeyPath"]!); sessionId = new Guid(payloadBytes);
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem);
// Verify signature // Load public key for verification
var signature = Base64UrlDecode(parts[1]); var publicKeyPem = File.ReadAllText(configuration["AuthToken:PublicKeyPath"]!);
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); 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; return false;
} }
} }
private static byte[] Base64UrlDecode(string base64Url) private static byte[] Base64UrlDecode(string base64Url)
{ {
string padded = base64Url var padded = base64Url
.Replace('-', '+') .Replace('-', '+')
.Replace('_', '/'); .Replace('_', '/');
@ -182,7 +217,7 @@ public class DysonTokenAuthHandler(
return Convert.FromBase64String(padded); return Convert.FromBase64String(padded);
} }
private static TokenInfo? _ExtractToken(HttpRequest request) private TokenInfo? _ExtractToken(HttpRequest request)
{ {
// Check for token in query parameters // Check for token in query parameters
if (request.Query.TryGetValue(AuthConstants.TokenQueryParamName, out var queryToken)) if (request.Query.TryGetValue(AuthConstants.TokenQueryParamName, out var queryToken))
@ -194,20 +229,23 @@ public class DysonTokenAuthHandler(
}; };
} }
// Check for token in Authorization header // Check for token in Authorization header
var authHeader = request.Headers.Authorization.ToString(); var authHeader = request.Headers.Authorization.ToString();
if (!string.IsNullOrEmpty(authHeader)) if (!string.IsNullOrEmpty(authHeader))
{ {
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{ {
var token = authHeader["Bearer ".Length..].Trim();
var parts = token.Split('.');
return new TokenInfo return new TokenInfo
{ {
Token = authHeader["Bearer ".Length..].Trim(), Token = token,
Type = TokenType.AuthKey Type = parts.Length == 3 ? TokenType.OidcKey : TokenType.AuthKey
}; };
} }
else if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
{ {
return new TokenInfo return new TokenInfo
{ {
@ -215,8 +253,7 @@ public class DysonTokenAuthHandler(
Type = TokenType.AuthKey Type = TokenType.AuthKey
}; };
} }
else if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
{ {
return new TokenInfo return new TokenInfo
{ {
@ -227,15 +264,16 @@ public class DysonTokenAuthHandler(
} }
// Check for token in cookies // 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 return new TokenInfo
{ {
Token = cookieToken, Token = cookieToken,
Type = TokenType.AuthKey Type = cookieToken.Count(c => c == '.') == 2 ? TokenType.OidcKey : TokenType.AuthKey
}; };
} }
return null; return null;
} }
} }

View File

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

View File

@ -1,11 +1,19 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Auth; namespace DysonNetwork.Sphere.Auth;
public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFactory httpClientFactory, IHttpContextAccessor httpContextAccessor) public class AuthService(
AppDatabase db,
IConfiguration config,
IHttpClientFactory httpClientFactory,
IHttpContextAccessor httpContextAccessor,
ICacheService cache
)
{ {
private HttpContext HttpContext => httpContextAccessor.HttpContext!; private HttpContext HttpContext => httpContextAccessor.HttpContext!;
@ -58,14 +66,14 @@ public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFacto
// 4) Combine base “maxSteps” (the number of enabled factors) with any accumulated risk score. // 4) Combine base “maxSteps” (the number of enabled factors) with any accumulated risk score.
const int totalRiskScore = 3; 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 // Clamp the steps
totalRequiredSteps = Math.Max(Math.Min(totalRequiredSteps, maxSteps), 1); totalRequiredSteps = Math.Max(Math.Min(totalRequiredSteps, maxSteps), 1);
return totalRequiredSteps; return totalRequiredSteps;
} }
public async Task<Session> CreateSessionAsync(Account.Account account, Instant time) public async Task<Session> CreateSessionForOidcAsync(Account.Account account, Instant time, Guid? customAppId = null)
{ {
var challenge = new Challenge var challenge = new Challenge
{ {
@ -74,7 +82,7 @@ public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFacto
UserAgent = HttpContext.Request.Headers.UserAgent, UserAgent = HttpContext.Request.Headers.UserAgent,
StepRemain = 1, StepRemain = 1,
StepTotal = 1, StepTotal = 1,
Type = ChallengeType.Oidc Type = customAppId is not null ? ChallengeType.OAuth : ChallengeType.Oidc
}; };
var session = new Session var session = new Session
@ -82,7 +90,8 @@ public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFacto
AccountId = account.Id, AccountId = account.Id,
CreatedAt = time, CreatedAt = time,
LastGrantedAt = time, LastGrantedAt = time,
Challenge = challenge Challenge = challenge,
AppId = customAppId
}; };
db.AuthChallenges.Add(challenge); db.AuthChallenges.Add(challenge);
@ -123,7 +132,7 @@ public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFacto
case "google": case "google":
content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8, content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
"application/x-www-form-urlencoded"); "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(); response.EnsureSuccessStatusCode();
json = await response.Content.ReadAsStringAsync(); json = await response.Content.ReadAsStringAsync();
@ -148,7 +157,7 @@ public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFacto
public string CreateToken(Session session) public string CreateToken(Session session)
{ {
// Load the private key for signing // 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(); using var rsa = RSA.Create();
rsa.ImportFromPem(privateKeyPem); rsa.ImportFromPem(privateKeyPem);
@ -174,6 +183,69 @@ public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFacto
return $"{payloadBase64}.{signatureBase64}"; 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) public bool ValidateToken(string token, out Guid sessionId)
{ {
sessionId = Guid.Empty; sessionId = Guid.Empty;
@ -192,7 +264,7 @@ public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFacto
sessionId = new Guid(payloadBytes); sessionId = new Guid(payloadBytes);
// Load public key for verification // Load public key for verification
var publicKeyPem = File.ReadAllText(config["Jwt:PublicKeyPath"]!); var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!);
using var rsa = RSA.Create(); using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem); rsa.ImportFromPem(publicKeyPem);

View File

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

@ -8,7 +8,7 @@ using NodaTime;
namespace DysonNetwork.Sphere.Auth.OpenId; namespace DysonNetwork.Sphere.Auth.OpenId;
[ApiController] [ApiController]
[Route("/accounts/me/connections")] [Route("/api/accounts/me/connections")]
[Authorize] [Authorize]
public class ConnectionController( public class ConnectionController(
AppDatabase db, AppDatabase db,
@ -164,7 +164,7 @@ public class ConnectionController(
} }
[AllowAnonymous] [AllowAnonymous]
[Route("/auth/callback/{provider}")] [Route("/api/auth/callback/{provider}")]
[HttpGet, HttpPost] [HttpGet, HttpPost]
public async Task<IActionResult> HandleCallback([FromRoute] string provider) public async Task<IActionResult> HandleCallback([FromRoute] string provider)
{ {
@ -304,7 +304,7 @@ public class ConnectionController(
{ {
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
catch (DbUpdateException ex) catch (DbUpdateException)
{ {
return StatusCode(500, $"Failed to save {provider} connection. Please try again."); return StatusCode(500, $"Failed to save {provider} connection. Please try again.");
} }
@ -376,7 +376,7 @@ public class ConnectionController(
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var loginSession = await auth.CreateSessionAsync(account, clock.GetCurrentInstant()); var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant());
var loginToken = auth.CreateToken(loginSession); var loginToken = auth.CreateToken(loginSession);
return Redirect($"/auth/token?token={loginToken}"); return Redirect($"/auth/token?token={loginToken}");
} }

View File

@ -30,18 +30,18 @@ public class DiscordOidcService(
}; };
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}")); var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
return $"https://discord.com/api/oauth2/authorize?{queryString}"; return $"https://discord.com/oauth2/authorize?{queryString}";
} }
protected override async Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync() protected override Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync()
{ {
return new OidcDiscoveryDocument return Task.FromResult(new OidcDiscoveryDocument
{ {
AuthorizationEndpoint = "https://discord.com/oauth2/authorize", AuthorizationEndpoint = "https://discord.com/oauth2/authorize",
TokenEndpoint = "https://discord.com/api/oauth2/token", TokenEndpoint = "https://discord.com/oauth2/token",
UserinfoEndpoint = "https://discord.com/api/users/@me", UserinfoEndpoint = "https://discord.com/users/@me",
JwksUri = null JwksUri = null
}; })!;
} }
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData) public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
@ -75,7 +75,7 @@ public class DiscordOidcService(
{ "redirect_uri", config.RedirectUri }, { "redirect_uri", config.RedirectUri },
}); });
var response = await client.PostAsync("https://discord.com/api/oauth2/token", content); var response = await client.PostAsync("https://discord.com/oauth2/token", content);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<OidcTokenResponse>(); return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
@ -84,7 +84,7 @@ public class DiscordOidcService(
private async Task<OidcUserInfo> GetUserInfoAsync(string accessToken) private async Task<OidcUserInfo> GetUserInfoAsync(string accessToken)
{ {
var client = HttpClientFactory.CreateClient(); var client = HttpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, "https://discord.com/api/users/@me"); var request = new HttpRequestMessage(HttpMethod.Get, "https://discord.com/users/@me");
request.Headers.Add("Authorization", $"Bearer {accessToken}"); request.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(request); var response = await client.SendAsync(request);

View File

@ -8,7 +8,7 @@ using NodaTime;
namespace DysonNetwork.Sphere.Auth.OpenId; namespace DysonNetwork.Sphere.Auth.OpenId;
[ApiController] [ApiController]
[Route("/auth/login")] [Route("/api/auth/login")]
public class OidcController( public class OidcController(
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
AppDatabase db, AppDatabase db,
@ -120,6 +120,7 @@ public class OidcController(
"microsoft" => serviceProvider.GetRequiredService<MicrosoftOidcService>(), "microsoft" => serviceProvider.GetRequiredService<MicrosoftOidcService>(),
"discord" => serviceProvider.GetRequiredService<DiscordOidcService>(), "discord" => serviceProvider.GetRequiredService<DiscordOidcService>(),
"github" => serviceProvider.GetRequiredService<GitHubOidcService>(), "github" => serviceProvider.GetRequiredService<GitHubOidcService>(),
"afdian" => serviceProvider.GetRequiredService<AfdianOidcService>(),
_ => throw new ArgumentException($"Unsupported provider: {provider}") _ => throw new ArgumentException($"Unsupported provider: {provider}")
}; };
} }

View File

@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Developer;
using NodaTime; using NodaTime;
using Point = NetTopologySuite.Geometries.Point; using Point = NetTopologySuite.Geometries.Point;
@ -17,6 +18,8 @@ public class Session : ModelBase
[JsonIgnore] public Account.Account Account { get; set; } = null!; [JsonIgnore] public Account.Account Account { get; set; } = null!;
public Guid ChallengeId { get; set; } public Guid ChallengeId { get; set; }
public Challenge Challenge { get; set; } = null!; public Challenge Challenge { get; set; } = null!;
public Guid? AppId { get; set; }
public CustomApp? App { get; set; }
} }
public enum ChallengeType public enum ChallengeType

View File

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

View File

@ -13,17 +13,17 @@ using NodaTime;
namespace DysonNetwork.Sphere.Chat; namespace DysonNetwork.Sphere.Chat;
[ApiController] [ApiController]
[Route("/chat")] [Route("/api/chat")]
public class ChatRoomController( public class ChatRoomController(
AppDatabase db, AppDatabase db,
FileService fs,
FileReferenceService fileRefService, FileReferenceService fileRefService,
ChatRoomService crs, ChatRoomService crs,
RealmService rs, RealmService rs,
ActionLogService als, ActionLogService als,
NotificationService nty, NotificationService nty,
RelationshipService rels, RelationshipService rels,
IStringLocalizer<NotificationResource> localizer IStringLocalizer<NotificationResource> localizer,
AccountEventService aes
) : ControllerBase ) : ControllerBase
{ {
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
@ -240,7 +240,6 @@ public class ChatRoomController(
var chatRoom = await db.ChatRooms var chatRoom = await db.ChatRooms
.Where(e => e.Id == id) .Where(e => e.Id == id)
.Include(c => c.Background)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (chatRoom is null) return NotFound(); if (chatRoom is null) return NotFound();
@ -263,26 +262,19 @@ public class ChatRoomController(
chatRoom.RealmId = member.RealmId; chatRoom.RealmId = member.RealmId;
} }
var chatRoomResourceId = $"chatroom:{chatRoom.Id}";
if (request.PictureId is not null) if (request.PictureId is not null)
{ {
var picture = await db.Files.FindAsync(request.PictureId); var picture = await db.Files.FindAsync(request.PictureId);
if (picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud."); if (picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud.");
// Remove old references for pictures // Remove old references for pictures
var oldPictureRefs = await fileRefService.DeleteResourceReferencesAsync(chatRoom.ResourceIdentifier, "chat.room.picture");
await fileRefService.GetResourceReferencesAsync(chatRoomResourceId, "chat.room.picture");
foreach (var oldRef in oldPictureRefs)
{
await fileRefService.DeleteReferenceAsync(oldRef.Id);
}
// Add a new reference // Add a new reference
await fileRefService.CreateReferenceAsync( await fileRefService.CreateReferenceAsync(
picture.Id, picture.Id,
"chat.room.picture", "chat.room.picture",
chatRoomResourceId chatRoom.ResourceIdentifier
); );
chatRoom.Picture = picture.ToReferenceObject(); chatRoom.Picture = picture.ToReferenceObject();
@ -294,18 +286,13 @@ public class ChatRoomController(
if (background is null) return BadRequest("Invalid background id, unable to find the file on cloud."); if (background is null) return BadRequest("Invalid background id, unable to find the file on cloud.");
// Remove old references for backgrounds // Remove old references for backgrounds
var oldBackgroundRefs = await fileRefService.DeleteResourceReferencesAsync(chatRoom.ResourceIdentifier, "chat.room.background");
await fileRefService.GetResourceReferencesAsync(chatRoomResourceId, "chat.room.background");
foreach (var oldRef in oldBackgroundRefs)
{
await fileRefService.DeleteReferenceAsync(oldRef.Id);
}
// Add a new reference // Add a new reference
await fileRefService.CreateReferenceAsync( await fileRefService.CreateReferenceAsync(
background.Id, background.Id,
"chat.room.background", "chat.room.background",
chatRoomResourceId chatRoom.ResourceIdentifier
); );
chatRoom.Background = background.ToReferenceObject(); chatRoom.Background = background.ToReferenceObject();
@ -386,7 +373,7 @@ public class ChatRoomController(
[HttpGet("{roomId:guid}/members")] [HttpGet("{roomId:guid}/members")]
public async Task<ActionResult<List<ChatMember>>> ListMembers(Guid roomId, [FromQuery] int take = 20, 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; var currentUser = HttpContext.Items["CurrentUser"] as Account.Account;
@ -402,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."); 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.ChatRoomId == roomId)
.Where(m => m.LeaveAt == null) // Add this condition to exclude left members .Where(m => m.LeaveAt == null) // Add this condition to exclude left members
.Include(m => m.Account) .Include(m => m.Account)
.Include(m => m.Account.Profile); .Include(m => m.Account.Profile);
var total = await query.CountAsync(); if (withStatus)
Response.Headers.Append("X-Total", total.ToString()); {
var members = await query
.OrderBy(m => m.JoinedAt)
.ToListAsync();
var members = await query var memberStatuses = await aes.GetStatuses(members.Select(m => m.AccountId).ToList());
.OrderBy(m => m.JoinedAt)
.Skip(skip)
.Take(take)
.ToListAsync();
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 public class ChatMemberRequest
{ {
[Required] public Guid RelatedUserId { get; set; } [Required] public Guid RelatedUserId { get; set; }

View File

@ -6,9 +6,9 @@ namespace DysonNetwork.Sphere.Chat;
public class ChatRoomService(AppDatabase db, ICacheService cache) public class ChatRoomService(AppDatabase db, ICacheService cache)
{ {
public const string ChatRoomGroupPrefix = "ChatRoom_"; public const string ChatRoomGroupPrefix = "chatroom:";
private const string RoomMembersCacheKeyPrefix = "ChatRoomMembers_"; private const string RoomMembersCacheKeyPrefix = "chatroom:members:";
private const string ChatMemberCacheKey = "ChatMember_{0}_{1}"; private const string ChatMemberCacheKey = "chatroom:{0}:member:{1}";
public async Task<List<ChatMember>> ListRoomMembers(Guid roomId) 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.Account;
using DysonNetwork.Sphere.Chat.Realtime; using DysonNetwork.Sphere.Chat.Realtime;
using DysonNetwork.Sphere.Connection; using DysonNetwork.Sphere.Connection;
@ -7,9 +8,8 @@ using NodaTime;
namespace DysonNetwork.Sphere.Chat; namespace DysonNetwork.Sphere.Chat;
public class ChatService( public partial class ChatService(
AppDatabase db, AppDatabase db,
FileService fs,
FileReferenceService fileRefService, FileReferenceService fileRefService,
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
IRealtimeService realtime, IRealtimeService realtime,
@ -18,6 +18,132 @@ public class ChatService(
{ {
private const string ChatFileUsageIdentifier = "chat"; 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) public async Task<Message> SendMessageAsync(Message message, ChatMember sender, ChatRoom room)
{ {
if (string.IsNullOrWhiteSpace(message.Nonce)) message.Nonce = Guid.NewGuid().ToString(); 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.Sender = sender;
message.ChatRoom = room; message.ChatRoom = room;
return message; return message;
@ -123,6 +252,7 @@ public class ChatService(
if (member.Account.Id == sender.AccountId) continue; if (member.Account.Id == sender.AccountId) continue;
if (member.Notify == ChatMemberNotify.None) 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)) if (message.MembersMentioned is null || !message.MembersMentioned.Contains(member.Account.Id))
{ {
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
@ -294,7 +424,7 @@ public class ChatService(
{ {
Type = "call.ended", Type = "call.ended",
ChatRoomId = call.RoomId, ChatRoomId = call.RoomId,
SenderId = sender.Id, SenderId = call.SenderId,
Meta = new Dictionary<string, object> Meta = new Dictionary<string, object>
{ {
{ "call_id", call.Id }, { "call_id", call.Id },
@ -390,6 +520,10 @@ public class ChatService(
db.Update(message); db.Update(message);
await db.SaveChangesAsync(); 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( _ = DeliverMessageAsync(
message, message,
message.Sender, message.Sender,
@ -441,4 +575,4 @@ public class SyncResponse
{ {
public List<MessageChange> Changes { get; set; } = []; public List<MessageChange> Changes { get; set; } = [];
public Instant CurrentTimestamp { get; set; } public Instant CurrentTimestamp { get; set; }
} }

View File

@ -13,7 +13,7 @@ public class RealtimeChatConfiguration
} }
[ApiController] [ApiController]
[Route("/chat/realtime")] [Route("/api/chat/realtime")]
public class RealtimeCallController( public class RealtimeCallController(
IConfiguration configuration, IConfiguration configuration,
AppDatabase db, AppDatabase db,

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,44 @@
using Azure.Core;
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["User-Agent"].ToString();
var accept = headers["Accept"].ToString();
// Priority 2: Check known app User-Agent (backward compatibility)
if (!string.IsNullOrEmpty(userAgent) && userAgent.Contains("Solian"))
isWebPage = false;
// Priority 3: Accept header can help infer intent
else if (!string.IsNullOrEmpty(accept) && accept.Contains("text/html"))
isWebPage = true;
else if (!string.IsNullOrEmpty(accept) && accept.Contains("application/json"))
isWebPage = false;
else
isWebPage = true;
}
context.Items["IsWebPage"] = isWebPage;
if (!isWebPage && !context.Request.Path.StartsWithSegments("/api"))
context.Response.Redirect(
$"/api{context.Request.Path.Value}{context.Request.QueryString.Value}",
permanent: false
);
else
await next(context);
}
}

View File

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

@ -8,10 +8,10 @@ using Swashbuckle.AspNetCore.Annotations;
namespace DysonNetwork.Sphere.Connection; namespace DysonNetwork.Sphere.Connection;
[ApiController] [ApiController]
[Route("/ws")] [Route("/api/ws")]
public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext> logger) : ControllerBase public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext> logger) : ControllerBase
{ {
[Route("/ws")] [Route("/api/ws")]
[Authorize] [Authorize]
[SwaggerIgnore] [SwaggerIgnore]
public async Task TheGateway() public async Task TheGateway()

View File

@ -17,6 +17,31 @@ public class WebSocketService
(WebSocket Socket, CancellationTokenSource Cts) (WebSocket Socket, CancellationTokenSource Cts)
> ActiveConnections = new(); > 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( public bool TryAdd(
(Guid AccountId, string DeviceId) key, (Guid AccountId, string DeviceId) key,
WebSocket socket, WebSocket socket,
@ -39,6 +64,7 @@ public class WebSocketService
); );
data.Cts.Cancel(); data.Cts.Cancel();
ActiveConnections.TryRemove(key, out _); ActiveConnections.TryRemove(key, out _);
UnsubscribeFromChatRoom(key.DeviceId);
} }
public bool GetAccountIsConnected(Guid accountId) 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;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Developer; namespace DysonNetwork.Sphere.Developer;
@ -12,28 +15,55 @@ public enum CustomAppStatus
Suspended Suspended
} }
public class CustomApp : ModelBase public class CustomApp : ModelBase, IIdentifiedResource
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Slug { get; set; } = null!; [MaxLength(1024)] public string Slug { get; set; } = null!;
[MaxLength(1024)] public string Name { 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 CustomAppStatus Status { get; set; } = CustomAppStatus.Developing;
public Instant? VerifiedAt { get; set; }
[MaxLength(4096)] public string? VerifiedAs { get; set; } [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
[JsonIgnore] private ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>();
[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 Guid PublisherId { get; set; }
public Publisher.Publisher Developer { get; set; } = null!; 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 class CustomAppSecret : ModelBase
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Secret { get; set; } = null!; [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 Instant? ExpiredAt { get; set; }
public bool IsOidc { get; set; } = false; // Indicates if this secret is for OIDC/OAuth
public Guid AppId { get; set; } public Guid AppId { get; set; }
public CustomApp App { get; set; } = null!; 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 DysonNetwork.Sphere.Publisher;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Developer; namespace DysonNetwork.Sphere.Developer;
[ApiController] [ApiController]
[Route("/developers/apps")] [Route("/api/developers/{pubName}/apps")]
public class CustomAppController(PublisherService ps) : ControllerBase 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

@ -16,14 +16,17 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AngleSharp" Version="1.3.0" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" /> <PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" />
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.1" /> <PackageReference Include="EFCore.BulkExtensions" Version="9.0.1" />
<PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1" /> <PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="FFMpegCore" Version="5.2.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="Livekit.Server.Sdk.Dotnet" Version="1.0.8" />
<PackageReference Include="MailKit" Version="4.11.0" /> <PackageReference Include="MailKit" Version="4.11.0" />
<PackageReference Include="Markdig" Version="0.41.3" />
<PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" /> <PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2" />
@ -70,6 +73,7 @@
<PackageReference Include="StackExchange.Redis.Extensions.AspNetCore" Version="11.0.0" /> <PackageReference Include="StackExchange.Redis.Extensions.AspNetCore" Version="11.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.0" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.0" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" 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" /> <PackageReference Include="tusdotnet" Version="2.8.1" />
</ItemGroup> </ItemGroup>
@ -81,6 +85,7 @@
<ItemGroup> <ItemGroup>
<Folder Include="Migrations\" /> <Folder Include="Migrations\" />
<Folder Include="Discovery\" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -2269,7 +2269,7 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("post_id"); .HasColumnName("post_id");
b.Property<List<CloudFileSensitiveMark>>("SensitiveMarks") b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("sensitive_marks"); .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), description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
file_meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true), file_meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true),
user_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), 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), hash = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
size = table.Column<long>(type: "bigint", nullable: false), size = table.Column<long>(type: "bigint", nullable: false),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class WalletOrderAppDX : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_payment_orders_wallets_payee_wallet_id",
table: "payment_orders");
migrationBuilder.AlterColumn<Guid>(
name: "payee_wallet_id",
table: "payment_orders",
type: "uuid",
nullable: true,
oldClrType: typeof(Guid),
oldType: "uuid");
migrationBuilder.AddColumn<string>(
name: "app_identifier",
table: "payment_orders",
type: "character varying(4096)",
maxLength: 4096,
nullable: true);
migrationBuilder.AddColumn<Dictionary<string, object>>(
name: "meta",
table: "payment_orders",
type: "jsonb",
nullable: true);
migrationBuilder.AddForeignKey(
name: "fk_payment_orders_wallets_payee_wallet_id",
table: "payment_orders",
column: "payee_wallet_id",
principalTable: "wallets",
principalColumn: "id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_payment_orders_wallets_payee_wallet_id",
table: "payment_orders");
migrationBuilder.DropColumn(
name: "app_identifier",
table: "payment_orders");
migrationBuilder.DropColumn(
name: "meta",
table: "payment_orders");
migrationBuilder.AlterColumn<Guid>(
name: "payee_wallet_id",
table: "payment_orders",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
oldClrType: typeof(Guid),
oldType: "uuid",
oldNullable: true);
migrationBuilder.AddForeignKey(
name: "fk_payment_orders_wallets_payee_wallet_id",
table: "payment_orders",
column: "payee_wallet_id",
principalTable: "wallets",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class DropActionLogSessionFk : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_action_logs_auth_sessions_session_id",
table: "action_logs");
migrationBuilder.DropIndex(
name: "ix_action_logs_session_id",
table: "action_logs");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "ix_action_logs_session_id",
table: "action_logs",
column: "session_id");
migrationBuilder.AddForeignKey(
name: "fk_action_logs_auth_sessions_session_id",
table: "action_logs",
column: "session_id",
principalTable: "auth_sessions",
principalColumn: "id");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,66 @@
using System;
using System.Collections.Generic;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class SafetyAbuseReport : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<List<ContentSensitiveMark>>(
name: "sensitive_marks",
table: "posts",
type: "jsonb",
nullable: true);
migrationBuilder.CreateTable(
name: "abuse_reports",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
resource_identifier = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
type = table.Column<int>(type: "integer", nullable: false),
reason = table.Column<string>(type: "character varying(8192)", maxLength: 8192, nullable: false),
resolved_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
resolution = table.Column<string>(type: "character varying(8192)", maxLength: 8192, nullable: true),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_abuse_reports", x => x.id);
table.ForeignKey(
name: "fk_abuse_reports_accounts_account_id",
column: x => x.account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_abuse_reports_account_id",
table: "abuse_reports",
column: "account_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "abuse_reports");
migrationBuilder.DropColumn(
name: "sensitive_marks",
table: "posts");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using DysonNetwork.Sphere.Connection.WebReader;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class AddWebArticles : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "web_feeds",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
url = table.Column<string>(type: "character varying(8192)", maxLength: 8192, nullable: false),
title = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
description = table.Column<string>(type: "character varying(8192)", maxLength: 8192, nullable: true),
preview = table.Column<LinkEmbed>(type: "jsonb", nullable: true),
publisher_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_web_feeds", x => x.id);
table.ForeignKey(
name: "fk_web_feeds_publishers_publisher_id",
column: x => x.publisher_id,
principalTable: "publishers",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "web_articles",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
title = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
url = table.Column<string>(type: "character varying(8192)", maxLength: 8192, nullable: false),
author = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true),
preview = table.Column<LinkEmbed>(type: "jsonb", nullable: true),
content = table.Column<string>(type: "text", nullable: true),
published_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
feed_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_web_articles", x => x.id);
table.ForeignKey(
name: "fk_web_articles_web_feeds_feed_id",
column: x => x.feed_id,
principalTable: "web_feeds",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_web_articles_feed_id",
table: "web_articles",
column: "feed_id");
migrationBuilder.CreateIndex(
name: "ix_web_articles_url",
table: "web_articles",
column: "url",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_web_feeds_publisher_id",
table: "web_feeds",
column: "publisher_id");
migrationBuilder.CreateIndex(
name: "ix_web_feeds_url",
table: "web_feeds",
column: "url",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "web_articles");
migrationBuilder.DropTable(
name: "web_feeds");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,84 @@
using System;
using DysonNetwork.Sphere.Connection.WebReader;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class AddRealmTags : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<WebFeedConfig>(
name: "config",
table: "web_feeds",
type: "jsonb",
nullable: false);
migrationBuilder.CreateTable(
name: "tags",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
name = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_tags", x => x.id);
});
migrationBuilder.CreateTable(
name: "realm_tags",
columns: table => new
{
realm_id = table.Column<Guid>(type: "uuid", nullable: false),
tag_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_realm_tags", x => new { x.realm_id, x.tag_id });
table.ForeignKey(
name: "fk_realm_tags_realms_realm_id",
column: x => x.realm_id,
principalTable: "realms",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_realm_tags_tags_tag_id",
column: x => x.tag_id,
principalTable: "tags",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_realm_tags_tag_id",
table: "realm_tags",
column: "tag_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "realm_tags");
migrationBuilder.DropTable(
name: "tags");
migrationBuilder.DropColumn(
name: "config",
table: "web_feeds");
}
}
}

View File

@ -5,6 +5,8 @@ using System.Text.Json;
using DysonNetwork.Sphere; using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Chat; using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Connection.WebReader;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Wallet; using DysonNetwork.Sphere.Wallet;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -32,6 +34,63 @@ namespace DysonNetwork.Sphere.Migrations
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Sphere.Account.AbuseReport", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("reason");
b.Property<string>("Resolution")
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("resolution");
b.Property<Instant?>("ResolvedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resolved_at");
b.Property<string>("ResourceIdentifier")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("resource_identifier");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_abuse_reports");
b.HasIndex("AccountId")
.HasDatabaseName("ix_abuse_reports_account_id");
b.ToTable("abuse_reports", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Account.Account", b => modelBuilder.Entity("DysonNetwork.Sphere.Account.Account", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -314,9 +373,6 @@ namespace DysonNetwork.Sphere.Migrations
b.HasIndex("AccountId") b.HasIndex("AccountId")
.HasDatabaseName("ix_action_logs_account_id"); .HasDatabaseName("ix_action_logs_account_id");
b.HasIndex("SessionId")
.HasDatabaseName("ix_action_logs_session_id");
b.ToTable("action_logs", (string)null); b.ToTable("action_logs", (string)null);
}); });
@ -923,6 +979,10 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("account_id"); .HasColumnName("account_id");
b.Property<Guid?>("AppId")
.HasColumnType("uuid")
.HasColumnName("app_id");
b.Property<Guid>("ChallengeId") b.Property<Guid>("ChallengeId")
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("challenge_id"); .HasColumnName("challenge_id");
@ -958,6 +1018,9 @@ namespace DysonNetwork.Sphere.Migrations
b.HasIndex("AccountId") b.HasIndex("AccountId")
.HasDatabaseName("ix_auth_sessions_account_id"); .HasDatabaseName("ix_auth_sessions_account_id");
b.HasIndex("AppId")
.HasDatabaseName("ix_auth_sessions_app_id");
b.HasIndex("ChallengeId") b.HasIndex("ChallengeId")
.HasDatabaseName("ix_auth_sessions_challenge_id"); .HasDatabaseName("ix_auth_sessions_challenge_id");
@ -1307,13 +1370,22 @@ namespace DysonNetwork.Sphere.Migrations
b.ToTable("chat_realtime_call", (string)null); b.ToTable("chat_realtime_call", (string)null);
}); });
modelBuilder.Entity("DysonNetwork.Sphere.Developer.CustomApp", b => modelBuilder.Entity("DysonNetwork.Sphere.Connection.WebReader.WebArticle", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("id"); .HasColumnName("id");
b.Property<string>("Author")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("author");
b.Property<string>("Content")
.HasColumnType("text")
.HasColumnName("content");
b.Property<Instant>("CreatedAt") b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("created_at"); .HasColumnName("created_at");
@ -1322,12 +1394,155 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at"); .HasColumnName("deleted_at");
b.Property<Guid>("FeedId")
.HasColumnType("uuid")
.HasColumnName("feed_id");
b.Property<Dictionary<string, object>>("Meta")
.HasColumnType("jsonb")
.HasColumnName("meta");
b.Property<LinkEmbed>("Preview")
.HasColumnType("jsonb")
.HasColumnName("preview");
b.Property<DateTime?>("PublishedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("published_at");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("title");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("url");
b.HasKey("Id")
.HasName("pk_web_articles");
b.HasIndex("FeedId")
.HasDatabaseName("ix_web_articles_feed_id");
b.HasIndex("Url")
.IsUnique()
.HasDatabaseName("ix_web_articles_url");
b.ToTable("web_articles", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Connection.WebReader.WebFeed", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<WebFeedConfig>("Config")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("config");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("description");
b.Property<LinkEmbed>("Preview")
.HasColumnType("jsonb")
.HasColumnName("preview");
b.Property<Guid>("PublisherId")
.HasColumnType("uuid")
.HasColumnName("publisher_id");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("title");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("url");
b.HasKey("Id")
.HasName("pk_web_feeds");
b.HasIndex("PublisherId")
.HasDatabaseName("ix_web_feeds_publisher_id");
b.HasIndex("Url")
.IsUnique()
.HasDatabaseName("ix_web_feeds_url");
b.ToTable("web_feeds", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Developer.CustomApp", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<CloudFileReferenceObject>("Background")
.HasColumnType("jsonb")
.HasColumnName("background");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("description");
b.Property<CustomAppLinks>("Links")
.HasColumnType("jsonb")
.HasColumnName("links");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(1024) .HasMaxLength(1024)
.HasColumnType("character varying(1024)") .HasColumnType("character varying(1024)")
.HasColumnName("name"); .HasColumnName("name");
b.Property<CustomAppOauthConfig>("OauthConfig")
.HasColumnType("jsonb")
.HasColumnName("oauth_config");
b.Property<CloudFileReferenceObject>("Picture")
.HasColumnType("jsonb")
.HasColumnName("picture");
b.Property<Guid>("PublisherId") b.Property<Guid>("PublisherId")
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("publisher_id"); .HasColumnName("publisher_id");
@ -1346,14 +1561,9 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("updated_at"); .HasColumnName("updated_at");
b.Property<string>("VerifiedAs") b.Property<VerificationMark>("Verification")
.HasMaxLength(4096) .HasColumnType("jsonb")
.HasColumnType("character varying(4096)") .HasColumnName("verification");
.HasColumnName("verified_as");
b.Property<Instant?>("VerifiedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("verified_at");
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_custom_apps"); .HasName("pk_custom_apps");
@ -1383,14 +1593,18 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at"); .HasColumnName("deleted_at");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("description");
b.Property<Instant?>("ExpiredAt") b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("expired_at"); .HasColumnName("expired_at");
b.Property<string>("Remarks") b.Property<bool>("IsOidc")
.HasMaxLength(4096) .HasColumnType("boolean")
.HasColumnType("character varying(4096)") .HasColumnName("is_oidc");
.HasColumnName("remarks");
b.Property<string>("Secret") b.Property<string>("Secret")
.IsRequired() .IsRequired()
@ -1408,6 +1622,10 @@ namespace DysonNetwork.Sphere.Migrations
b.HasIndex("AppId") b.HasIndex("AppId")
.HasDatabaseName("ix_custom_app_secrets_app_id"); .HasDatabaseName("ix_custom_app_secrets_app_id");
b.HasIndex("Secret")
.IsUnique()
.HasDatabaseName("ix_custom_app_secrets_secret");
b.ToTable("custom_app_secrets", (string)null); b.ToTable("custom_app_secrets", (string)null);
}); });
@ -1615,6 +1833,10 @@ namespace DysonNetwork.Sphere.Migrations
.HasAnnotation("Npgsql:TsVectorConfig", "simple") .HasAnnotation("Npgsql:TsVectorConfig", "simple")
.HasAnnotation("Npgsql:TsVectorProperties", new[] { "Title", "Description", "Content" }); .HasAnnotation("Npgsql:TsVectorProperties", new[] { "Title", "Description", "Content" });
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
.HasColumnType("jsonb")
.HasColumnName("sensitive_marks");
b.Property<string>("Title") b.Property<string>("Title")
.HasMaxLength(1024) .HasMaxLength(1024)
.HasColumnType("character varying(1024)") .HasColumnType("character varying(1024)")
@ -2174,6 +2396,68 @@ namespace DysonNetwork.Sphere.Migrations
b.ToTable("realm_members", (string)null); b.ToTable("realm_members", (string)null);
}); });
modelBuilder.Entity("DysonNetwork.Sphere.Realm.RealmTag", b =>
{
b.Property<Guid>("RealmId")
.HasColumnType("uuid")
.HasColumnName("realm_id");
b.Property<Guid>("TagId")
.HasColumnType("uuid")
.HasColumnName("tag_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("RealmId", "TagId")
.HasName("pk_realm_tags");
b.HasIndex("TagId")
.HasDatabaseName("ix_realm_tags_tag_id");
b.ToTable("realm_tags", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Realm.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("name");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_tags");
b.ToTable("tags", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Sticker.Sticker", b => modelBuilder.Entity("DysonNetwork.Sphere.Sticker.Sticker", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -2338,7 +2622,7 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("post_id"); .HasColumnName("post_id");
b.Property<List<CloudFileSensitiveMark>>("SensitiveMarks") b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("sensitive_marks"); .HasColumnName("sensitive_marks");
@ -2504,6 +2788,11 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("numeric") .HasColumnType("numeric")
.HasColumnName("amount"); .HasColumnName("amount");
b.Property<string>("AppIdentifier")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("app_identifier");
b.Property<Instant>("CreatedAt") b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("created_at"); .HasColumnName("created_at");
@ -2526,7 +2815,11 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("issuer_app_id"); .HasColumnName("issuer_app_id");
b.Property<Guid>("PayeeWalletId") b.Property<Dictionary<string, object>>("Meta")
.HasColumnType("jsonb")
.HasColumnName("meta");
b.Property<Guid?>("PayeeWalletId")
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("payee_wallet_id"); .HasColumnName("payee_wallet_id");
@ -2838,6 +3131,18 @@ namespace DysonNetwork.Sphere.Migrations
b.ToTable("post_tag_links", (string)null); b.ToTable("post_tag_links", (string)null);
}); });
modelBuilder.Entity("DysonNetwork.Sphere.Account.AbuseReport", b =>
{
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_abuse_reports_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountAuthFactor", b => modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountAuthFactor", b =>
{ {
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
@ -2883,14 +3188,7 @@ namespace DysonNetwork.Sphere.Migrations
.IsRequired() .IsRequired()
.HasConstraintName("fk_action_logs_accounts_account_id"); .HasConstraintName("fk_action_logs_accounts_account_id");
b.HasOne("DysonNetwork.Sphere.Auth.Session", "Session")
.WithMany()
.HasForeignKey("SessionId")
.HasConstraintName("fk_action_logs_auth_sessions_session_id");
b.Navigation("Account"); b.Navigation("Account");
b.Navigation("Session");
}); });
modelBuilder.Entity("DysonNetwork.Sphere.Account.Badge", b => modelBuilder.Entity("DysonNetwork.Sphere.Account.Badge", b =>
@ -3017,6 +3315,11 @@ namespace DysonNetwork.Sphere.Migrations
.IsRequired() .IsRequired()
.HasConstraintName("fk_auth_sessions_accounts_account_id"); .HasConstraintName("fk_auth_sessions_accounts_account_id");
b.HasOne("DysonNetwork.Sphere.Developer.CustomApp", "App")
.WithMany()
.HasForeignKey("AppId")
.HasConstraintName("fk_auth_sessions_custom_apps_app_id");
b.HasOne("DysonNetwork.Sphere.Auth.Challenge", "Challenge") b.HasOne("DysonNetwork.Sphere.Auth.Challenge", "Challenge")
.WithMany() .WithMany()
.HasForeignKey("ChallengeId") .HasForeignKey("ChallengeId")
@ -3026,6 +3329,8 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("Account"); b.Navigation("Account");
b.Navigation("App");
b.Navigation("Challenge"); b.Navigation("Challenge");
}); });
@ -3139,6 +3444,30 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("Sender"); b.Navigation("Sender");
}); });
modelBuilder.Entity("DysonNetwork.Sphere.Connection.WebReader.WebArticle", b =>
{
b.HasOne("DysonNetwork.Sphere.Connection.WebReader.WebFeed", "Feed")
.WithMany("Articles")
.HasForeignKey("FeedId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_web_articles_web_feeds_feed_id");
b.Navigation("Feed");
});
modelBuilder.Entity("DysonNetwork.Sphere.Connection.WebReader.WebFeed", b =>
{
b.HasOne("DysonNetwork.Sphere.Publisher.Publisher", "Publisher")
.WithMany()
.HasForeignKey("PublisherId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_web_feeds_publishers_publisher_id");
b.Navigation("Publisher");
});
modelBuilder.Entity("DysonNetwork.Sphere.Developer.CustomApp", b => modelBuilder.Entity("DysonNetwork.Sphere.Developer.CustomApp", b =>
{ {
b.HasOne("DysonNetwork.Sphere.Publisher.Publisher", "Developer") b.HasOne("DysonNetwork.Sphere.Publisher.Publisher", "Developer")
@ -3154,7 +3483,7 @@ namespace DysonNetwork.Sphere.Migrations
modelBuilder.Entity("DysonNetwork.Sphere.Developer.CustomAppSecret", b => modelBuilder.Entity("DysonNetwork.Sphere.Developer.CustomAppSecret", b =>
{ {
b.HasOne("DysonNetwork.Sphere.Developer.CustomApp", "App") b.HasOne("DysonNetwork.Sphere.Developer.CustomApp", "App")
.WithMany() .WithMany("Secrets")
.HasForeignKey("AppId") .HasForeignKey("AppId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired() .IsRequired()
@ -3266,7 +3595,7 @@ namespace DysonNetwork.Sphere.Migrations
modelBuilder.Entity("DysonNetwork.Sphere.Publisher.PublisherFeature", b => modelBuilder.Entity("DysonNetwork.Sphere.Publisher.PublisherFeature", b =>
{ {
b.HasOne("DysonNetwork.Sphere.Publisher.Publisher", "Publisher") b.HasOne("DysonNetwork.Sphere.Publisher.Publisher", "Publisher")
.WithMany() .WithMany("Features")
.HasForeignKey("PublisherId") .HasForeignKey("PublisherId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired() .IsRequired()
@ -3350,6 +3679,27 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("Realm"); b.Navigation("Realm");
}); });
modelBuilder.Entity("DysonNetwork.Sphere.Realm.RealmTag", b =>
{
b.HasOne("DysonNetwork.Sphere.Realm.Realm", "Realm")
.WithMany("RealmTags")
.HasForeignKey("RealmId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_realm_tags_realms_realm_id");
b.HasOne("DysonNetwork.Sphere.Realm.Tag", "Tag")
.WithMany("RealmTags")
.HasForeignKey("TagId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_realm_tags_tags_tag_id");
b.Navigation("Realm");
b.Navigation("Tag");
});
modelBuilder.Entity("DysonNetwork.Sphere.Sticker.Sticker", b => modelBuilder.Entity("DysonNetwork.Sphere.Sticker.Sticker", b =>
{ {
b.HasOne("DysonNetwork.Sphere.Sticker.StickerPack", "Pack") b.HasOne("DysonNetwork.Sphere.Sticker.StickerPack", "Pack")
@ -3418,8 +3768,6 @@ namespace DysonNetwork.Sphere.Migrations
b.HasOne("DysonNetwork.Sphere.Wallet.Wallet", "PayeeWallet") b.HasOne("DysonNetwork.Sphere.Wallet.Wallet", "PayeeWallet")
.WithMany() .WithMany()
.HasForeignKey("PayeeWalletId") .HasForeignKey("PayeeWalletId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_payment_orders_wallets_payee_wallet_id"); .HasConstraintName("fk_payment_orders_wallets_payee_wallet_id");
b.HasOne("DysonNetwork.Sphere.Wallet.Transaction", "Transaction") b.HasOne("DysonNetwork.Sphere.Wallet.Transaction", "Transaction")
@ -3581,6 +3929,16 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("Reactions"); b.Navigation("Reactions");
}); });
modelBuilder.Entity("DysonNetwork.Sphere.Connection.WebReader.WebFeed", b =>
{
b.Navigation("Articles");
});
modelBuilder.Entity("DysonNetwork.Sphere.Developer.CustomApp", b =>
{
b.Navigation("Secrets");
});
modelBuilder.Entity("DysonNetwork.Sphere.Permission.PermissionGroup", b => modelBuilder.Entity("DysonNetwork.Sphere.Permission.PermissionGroup", b =>
{ {
b.Navigation("Members"); b.Navigation("Members");
@ -3599,6 +3957,8 @@ namespace DysonNetwork.Sphere.Migrations
{ {
b.Navigation("Collections"); b.Navigation("Collections");
b.Navigation("Features");
b.Navigation("Members"); b.Navigation("Members");
b.Navigation("Posts"); b.Navigation("Posts");
@ -3611,6 +3971,13 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("ChatRooms"); b.Navigation("ChatRooms");
b.Navigation("Members"); b.Navigation("Members");
b.Navigation("RealmTags");
});
modelBuilder.Entity("DysonNetwork.Sphere.Realm.Tag", b =>
{
b.Navigation("RealmTags");
}); });
modelBuilder.Entity("DysonNetwork.Sphere.Wallet.Wallet", b => modelBuilder.Entity("DysonNetwork.Sphere.Wallet.Wallet", b =>

View File

@ -0,0 +1,225 @@
@page "//account/profile"
@model DysonNetwork.Sphere.Pages.Account.ProfileModel
@{
ViewData["Title"] = "Profile";
}
@if (Model.Account != null)
{
<div class="p-4 sm:p-8 bg-base-200">
<div class="max-w-6xl mx-auto">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold">Profile Settings</h1>
<p class="text-base-content/70 mt-2">Manage your account information and preferences</p>
</div>
<!-- Two Column Layout -->
<div class="flex flex-col md:flex-row gap-6">
<!-- Left Pane - Profile Card -->
<div class="w-full md:w-1/3 lg:w-1/4">
<div class="card bg-base-100 shadow-xl sticky top-8">
<div class="card-body items-center text-center">
<!-- Avatar -->
<div class="avatar avatar-placeholder mb-4">
<div class="bg-neutral text-neutral-content rounded-full w-32">
<span class="text-4xl">@Model.Account.Name?[..1].ToUpper()</span>
</div>
</div>
<!-- Basic Info -->
<h2 class="card-title">@Model.Account.Nick</h2>
<p class="font-mono text-sm">@@@Model.Account.Name</p>
<!-- Stats -->
<div class="stats stats-vertical shadow mt-4">
<div class="stat">
<div class="stat-title">Level</div>
<div class="stat-value">@Model.Account.Profile.Level</div>
</div>
<div class="stat">
<div class="stat-title">XP</div>
<div class="stat-value">@Model.Account.Profile.Experience</div>
</div>
<div class="stat">
<div class="stat-title">Member since</div>
<div class="stat-value">@Model.Account.CreatedAt.ToDateTimeUtc().ToString("yyyy/MM")</div>
</div>
</div>
</div>
</div>
</div>
<!-- Right Pane - Tabbed Content -->
<div class="flex-1">
<div role="tablist" class="tabs tabs-lift w-full">
<input type="radio" name="profile-tabs" role="tab" class="tab" aria-label="Profile" checked />
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 p-6">
<h2 class="text-xl font-semibold mb-6">Profile Information</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 class="text-lg font-medium mb-4">Basic Information</h3>
<dl class="space-y-4">
<div>
<dt class="text-sm font-medium text-base-content/70">Full Name</dt>
<dd class="mt-1 text-sm">@($"{Model.Account.Profile.FirstName} {Model.Account.Profile.MiddleName} {Model.Account.Profile.LastName}".Trim())</dd>
</div>
<div>
<dt class="text-sm font-medium text-base-content/70">Username</dt>
<dd class="mt-1 text-sm">@Model.Account.Name</dd>
</div>
<div>
<dt class="text-sm font-medium text-base-content/70">Nickname</dt>
<dd class="mt-1 text-sm">@Model.Account.Nick</dd>
</div>
<div>
<dt class="text-sm font-medium text-base-content/70">Gender</dt>
<dd class="mt-1 text-sm">@Model.Account.Profile.Gender</dd>
</div>
</dl>
</div>
<div>
<h3 class="text-lg font-medium mb-4">Additional Details</h3>
<dl class="space-y-4">
<div>
<dt class="text-sm font-medium text-base-content/70">Location</dt>
<dd class="mt-1 text-sm">@Model.Account.Profile.Location</dd>
</div>
<div>
<dt class="text-sm font-medium text-base-content/70">Birthday</dt>
<dd class="mt-1 text-sm">@Model.Account.Profile.Birthday?.ToString("MMMM d, yyyy", System.Globalization.CultureInfo.InvariantCulture)</dd>
</div>
<div>
<dt class="text-sm font-medium text-base-content/70">Bio</dt>
<dd class="mt-1 text-sm">@(string.IsNullOrEmpty(Model.Account.Profile.Bio) ? "No bio provided" : Model.Account.Profile.Bio)</dd>
</div>
</dl>
</div>
</div>
</div>
<input type="radio" name="profile-tabs" role="tab" class="tab" aria-label="Security" />
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 p-6">
<h2 class="text-xl font-semibold mb-2">Security Settings</h2>
<div class="space-y-6">
<div class="card bg-base-300 shadow-xl">
<div class="card-body">
<h3 class="card-title">Access Token</h3>
<p>Use this token to authenticate with the API</p>
<div class="form-control">
<div class="join">
<input type="password" id="accessToken" value="@Model.AccessToken" readonly class="input input-bordered join-item flex-grow" />
<button onclick="copyAccessToken()" class="btn join-item">Copy</button>
</div>
</div>
<p class="text-sm text-base-content/70 mt-2">Keep this token secure and do not share it with anyone.</p>
</div>
</div>
</div>
</div>
<input type="radio" name="profile-tabs" role="tab" class="tab" aria-label="Sessions" />
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 p-6">
<h2 class="text-xl font-semibold">Active Sessions</h2>
<p class="text-base-content/70 mb-3">This is a list of devices that have logged into your account. Revoke any sessions that you do not recognize.</p>
<div class="card bg-base-300 shadow-xl">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table">
<tbody>
<tr>
<td>
<div class="flex items-center gap-3">
<div class="avatar">
<div class="mask mask-squircle w-12 h-12">
<svg class="h-full w-full text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h8a2 2 0 012 2v12a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 0v12h8V4H6z" clip-rule="evenodd" />
</svg>
</div>
</div>
<div>
<div class="font-bold">Current Session</div>
<div class="text-sm opacity-50">@($"{Request.Headers["User-Agent"]} • {DateTime.Now:MMMM d, yyyy 'at' h:mm tt}")</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="card-actions justify-end mt-4">
<button type="button" class="btn btn-error">Sign out all other sessions</button>
</div>
</div>
</div>
</div>
</div>
<!-- Logout Button -->
<div class="mt-6 flex justify-end">
<form method="post" asp-page-handler="Logout">
<button type="submit" class="btn btn-error">Sign out</button>
</form>
</div>
</div>
</div>
</div>
</div>
}
else
{
<div class="hero min-h-screen bg-base-200">
<div class="hero-content text-center">
<div class="max-w-md">
<div class="text-error text-5xl mb-4">
<i class="fas fa-exclamation-circle"></i>
</div>
<h1 class="text-5xl font-bold">Profile Not Found</h1>
<p class="py-6">User profile not found. Please log in to continue.</p>
<a href="/auth/login" class="btn btn-primary">Go to Login</a>
</div>
</div>
</div>
}
@section Scripts {
<script>
// Copy access token to clipboard
function copyAccessToken() {
const copyText = document.getElementById("accessToken");
copyText.select();
copyText.setSelectionRange(0, 99999);
document.execCommand("copy");
// Show tooltip or notification
const originalText = event.target.innerHTML;
event.target.innerHTML = '<i class="fas fa-check mr-1"></i> Copied!';
event.target.disabled = true;
setTimeout(() => {
event.target.innerHTML = originalText;
event.target.disabled = false;
}, 2000);
}
// Toggle password visibility
function togglePasswordVisibility(inputId) {
const input = document.getElementById(inputId);
const icon = document.querySelector(`[onclick="togglePasswordVisibility('${inputId}')"] i`);
if (input.type === 'password') {
input.type = 'text';
icon.classList.remove('fa-eye');
icon.classList.add('fa-eye-slash');
} else {
input.type = 'password';
icon.classList.remove('fa-eye-slash');
icon.classList.add('fa-eye');
}
}
</script>
}

View File

@ -0,0 +1,28 @@
using DysonNetwork.Sphere.Auth;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace DysonNetwork.Sphere.Pages.Account;
public class ProfileModel : PageModel
{
public DysonNetwork.Sphere.Account.Account? Account { get; set; }
public string? AccessToken { get; set; }
public Task<IActionResult> OnGetAsync()
{
if (HttpContext.Items["CurrentUser"] is not Sphere.Account.Account currentUser)
return Task.FromResult<IActionResult>(RedirectToPage("/Auth/Login"));
Account = currentUser;
AccessToken = Request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out var value) ? value : null;
return Task.FromResult<IActionResult>(Page());
}
public IActionResult OnPostLogout()
{
HttpContext.Response.Cookies.Delete(AuthConstants.CookieTokenName);
return RedirectToPage("/Auth/Login");
}
}

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