Compare commits

34 Commits

Author SHA1 Message Date
17330fc104 🐛 Fixes for websocket 2025-07-10 15:57:00 +08:00
7c0ad46deb 🐛 Fix api redirect 2025-07-10 15:30:30 +08:00
b8fcd0d94f Post web view 2025-07-10 15:15:20 +08:00
fc6edd7378 💥 Add /api prefix for json endpoints with redirect 2025-07-10 14:18:02 +08:00
1f2cdb146d 🐛 Serval bug fixes 2025-07-10 12:53:45 +08:00
be236a27c6 🐛 Serval bug fixes and improvement to web page 2025-07-10 03:08:39 +08:00
99c36ae548 💄 Optimized the authorized page style 2025-07-10 02:28:27 +08:00
ed2961a5d5 💄 Restyled web pages 2025-07-10 01:53:44 +08:00
08b5ffa02f 🐛 Fix afdian got wrong URL to request 2025-07-09 22:51:14 +08:00
837a123c3b 🐛 Trying to fix payment handler 2025-07-09 22:00:06 +08:00
ad1166190f 🐛 Bug fixes 2025-07-09 21:43:39 +08:00
8e8c938132 🐛 Fix restore purchase in afdian 2025-07-09 21:39:38 +08:00
8e5b6ace45 Skip subscribed check in message 2025-07-07 13:08:31 +08:00
5757526ea5 Get user blocked users infra 2025-07-03 21:57:16 +08:00
6a9cd0905d Unblock user 2025-07-03 21:47:17 +08:00
082a096470 🐛 Fix wrong pagination query param name 2025-07-03 13:14:19 +08:00
3a72347432 🐛 Fix inconsistent between authorized feed and unauthorized feed 2025-07-03 00:43:02 +08:00
19b1e957dd 🐛 Trying to fix push notification 2025-07-03 00:05:00 +08:00
6449926334 Subscription required level, optimized cancellation logic 2025-07-02 21:58:44 +08:00
fb885e138d 💄 Optimized subscriptions 2025-07-02 21:30:35 +08:00
5bdc21ebc5 💄 Optimize activity service 2025-07-02 21:09:35 +08:00
f177377fe3 Return the complete data while auto completion 2025-07-02 01:16:59 +08:00
0df4864888 Auto completion handler for account and stickers 2025-07-01 23:51:26 +08:00
29b0ad184e 🐛 Fix search didn't contains description in post 2025-07-01 23:42:45 +08:00
ad730832db Searching in posts 2025-07-01 23:39:13 +08:00
71fcc26534 🐛 Bug fixes on web article 2025-07-01 22:35:46 +08:00
fb8fc69920 🐛 Fix web article loop 2025-07-01 00:57:05 +08:00
05bf2cd055 Web articles 2025-07-01 00:44:48 +08:00
ccb8a4e3f4 Bug fixes on web feed & scraping 2025-06-30 23:26:05 +08:00
ca5be5a01c ♻️ Refined web feed APIs 2025-06-29 22:02:26 +08:00
c4ea15097e ♻️ Refined custom apps 2025-06-29 20:32:08 +08:00
cdeed3c318 🐛 Fixes custom app 2025-06-29 19:38:29 +08:00
a53fcb10dd Developer programs APIs 2025-06-29 18:37:23 +08:00
c0879d30d4 Oidc auto approval and session reuse 2025-06-29 17:46:17 +08:00
74 changed files with 8517 additions and 1661 deletions

View File

@ -11,7 +11,7 @@ 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,
AuthService auth, AuthService auth,

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

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

@ -30,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)
@ -37,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;
} }

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

@ -7,6 +7,7 @@ namespace DysonNetwork.Sphere.Account;
public class RelationshipService(AppDatabase db, ICacheService cache) public class RelationshipService(AppDatabase db, ICacheService cache)
{ {
private const string UserFriendsCacheKeyPrefix = "accounts:friends:"; 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

View File

@ -1,4 +1,5 @@
using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Connection.WebReader;
using DysonNetwork.Sphere.Discovery; using DysonNetwork.Sphere.Discovery;
using DysonNetwork.Sphere.Post; using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Publisher; using DysonNetwork.Sphere.Publisher;
@ -23,7 +24,8 @@ public class ActivityService(
return (score + 1) / Math.Pow(hours + 2, 1.8); return (score + 1) / Math.Pow(hours + 2, 1.8);
} }
public async Task<List<Activity>> GetActivitiesForAnyone(int take, Instant? cursor, HashSet<string>? debugInclude = null) 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>(); debugInclude ??= new HashSet<string>();
@ -39,6 +41,40 @@ public class ActivityService(
} }
} }
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 // Fetch a larger batch of recent posts to rank
var postsQuery = db.Posts var postsQuery = db.Posts
.Include(e => e.RepliedPost) .Include(e => e.RepliedPost)
@ -90,7 +126,7 @@ public class ActivityService(
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 ??= new HashSet<string>(); debugInclude ??= [];
if (string.IsNullOrEmpty(filter)) if (string.IsNullOrEmpty(filter))
{ {
@ -115,6 +151,40 @@ public class ActivityService(
).ToActivity()); ).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 // Get publishers based on filter
@ -212,4 +282,4 @@ public class ActivityService(
return rankedPublishers; return rankedPublishers;
} }
} }

View File

@ -197,30 +197,6 @@ public class AppDatabase(
.HasIndex(p => p.SearchVector) .HasIndex(p => p.SearchVector)
.HasMethod("GIN"); .HasMethod("GIN");
modelBuilder.Entity<CustomApp>()
.Property(c => c.RedirectUris)
.HasConversion(
v => string.Join(",", v),
v => v.Split(",", StringSplitOptions.RemoveEmptyEntries).ToArray());
modelBuilder.Entity<CustomApp>()
.Property(c => c.PostLogoutRedirectUris)
.HasConversion(
v => v != null ? string.Join(",", v) : "",
v => !string.IsNullOrEmpty(v) ? v.Split(",", StringSplitOptions.RemoveEmptyEntries) : Array.Empty<string>());
modelBuilder.Entity<CustomApp>()
.Property(c => c.AllowedScopes)
.HasConversion(
v => v != null ? string.Join(" ", v) : "",
v => !string.IsNullOrEmpty(v) ? v.Split(" ", StringSplitOptions.RemoveEmptyEntries) : new[] { "openid", "profile", "email" });
modelBuilder.Entity<CustomApp>()
.Property(c => c.AllowedGrantTypes)
.HasConversion(
v => v != null ? string.Join(" ", v) : "",
v => !string.IsNullOrEmpty(v) ? v.Split(" ", StringSplitOptions.RemoveEmptyEntries) : new[] { "authorization_code", "refresh_token" });
modelBuilder.Entity<CustomAppSecret>() modelBuilder.Entity<CustomAppSecret>()
.HasIndex(s => s.Secret) .HasIndex(s => s.Secret)
.IsUnique(); .IsUnique();

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,

View File

@ -14,7 +14,7 @@ using NodaTime;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Controllers; namespace DysonNetwork.Sphere.Auth.OidcProvider.Controllers;
[Route("/auth/open")] [Route("/api/auth/open")]
[ApiController] [ApiController]
public class OidcProviderController( public class OidcProviderController(
AppDatabase db, AppDatabase db,

View File

@ -38,6 +38,20 @@ public class OidcProviderService(
.FirstOrDefaultAsync(c => c.Id == appId); .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) public async Task<bool> ValidateClientCredentialsAsync(Guid clientId, string clientSecret)
{ {
var client = await FindClientByIdAsync(clientId); var client = await FindClientByIdAsync(clientId);
@ -76,15 +90,15 @@ public class OidcProviderService(
var account = await db.Accounts.Where(a => a.Id == authCode.AccountId).FirstOrDefaultAsync(); var account = await db.Accounts.Where(a => a.Id == authCode.AccountId).FirstOrDefaultAsync();
if (account is null) throw new InvalidOperationException("Account was not found"); if (account is null) throw new InvalidOperationException("Account was not found");
session = await auth.CreateSessionForOidcAsync(account, now); session = await auth.CreateSessionForOidcAsync(account, now, client.Id);
scopes = authCode.Scopes; scopes = authCode.Scopes;
} }
else if (sessionId.HasValue) else if (sessionId.HasValue)
{ {
// Refresh token flow // Refresh token flow
session = await FindSessionByIdAsync(sessionId.Value) ?? session = await FindSessionByIdAsync(sessionId.Value) ??
throw new InvalidOperationException("Invalid session"); throw new InvalidOperationException("Invalid session");
// Verify the session is still valid // Verify the session is still valid
if (session.ExpiredAt < now) if (session.ExpiredAt < now)
throw new InvalidOperationException("Session has expired"); throw new InvalidOperationException("Session has expired");
@ -112,10 +126,10 @@ public class OidcProviderService(
} }
private string GenerateJwtToken( private string GenerateJwtToken(
CustomApp client, CustomApp client,
Session session, Session session,
Instant expiresAt, Instant expiresAt,
IEnumerable<string>? scopes = null IEnumerable<string>? scopes = null
) )
{ {
var tokenHandler = new JwtSecurityTokenHandler(); var tokenHandler = new JwtSecurityTokenHandler();
@ -126,10 +140,10 @@ public class OidcProviderService(
{ {
Subject = new ClaimsIdentity([ Subject = new ClaimsIdentity([
new Claim(JwtRegisteredClaimNames.Sub, session.AccountId.ToString()), new Claim(JwtRegisteredClaimNames.Sub, session.AccountId.ToString()),
new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()), new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(), new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(),
ClaimValueTypes.Integer64), ClaimValueTypes.Integer64),
new Claim("client_id", client.Id.ToString()) new Claim("client_id", client.Id.ToString())
]), ]),
Expires = expiresAt.ToDateTimeUtc(), Expires = expiresAt.ToDateTimeUtc(),
Issuer = _options.IssuerUri, Issuer = _options.IssuerUri,
@ -144,8 +158,8 @@ public class OidcProviderService(
); );
// Add scopes as claims if provided // Add scopes as claims if provided
var effectiveScopes = scopes?.ToList() ?? client.AllowedScopes?.ToList() ?? new List<string>(); var effectiveScopes = scopes?.ToList() ?? client.OauthConfig!.AllowedScopes?.ToList() ?? [];
if (effectiveScopes.Any()) if (effectiveScopes.Count != 0)
{ {
tokenDescriptor.Subject.AddClaims( tokenDescriptor.Subject.AddClaims(
effectiveScopes.Select(scope => new Claim("scope", scope))); effectiveScopes.Select(scope => new Claim("scope", scope)));
@ -207,6 +221,44 @@ public class OidcProviderService(
return string.Equals(secret, hashedSecret, StringComparison.Ordinal); 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( public async Task<string> GenerateAuthorizationCodeAsync(
Guid clientId, Guid clientId,
Guid userId, Guid userId,
@ -214,7 +266,8 @@ public class OidcProviderService(
IEnumerable<string> scopes, IEnumerable<string> scopes,
string? codeChallenge = null, string? codeChallenge = null,
string? codeChallengeMethod = null, string? codeChallengeMethod = null,
string? nonce = null) string? nonce = null
)
{ {
// Generate a random code // Generate a random code
var clock = SystemClock.Instance; var clock = SystemClock.Instance;

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)
{ {

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,

View File

@ -9,7 +9,7 @@ using Microsoft.EntityFrameworkCore;
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

View File

@ -13,7 +13,7 @@ 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,
FileReferenceService fileRefService, FileReferenceService fileRefService,

View File

@ -252,7 +252,7 @@ public partial 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 (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();
@ -575,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 != "/ws" && !context.Request.Path.StartsWithSegments("/api"))
context.Response.Redirect(
$"/api{context.Request.Path.Value}{context.Request.QueryString.Value}",
permanent: false
);
else
await next(context);
}
}

View File

@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
namespace DysonNetwork.Sphere.Connection.WebReader; namespace DysonNetwork.Sphere.Connection.WebReader;
@ -7,8 +8,8 @@ public class WebArticle : ModelBase
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(4096)] public string Title { get; set; } [MaxLength(4096)] public string Title { get; set; } = null!;
[MaxLength(8192)] public string Url { get; set; } [MaxLength(8192)] public string Url { get; set; } = null!;
[MaxLength(4096)] public string? Author { get; set; } [MaxLength(4096)] public string? Author { get; set; }
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; } [Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; }
@ -31,8 +32,8 @@ public class WebFeedConfig
public class WebFeed : ModelBase public class WebFeed : ModelBase
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(8192)] public string Url { get; set; } [MaxLength(8192)] public string Url { get; set; } = null!;
[MaxLength(4096)] public string Title { get; set; } [MaxLength(4096)] public string Title { get; set; } = null!;
[MaxLength(8192)] public string? Description { get; set; } [MaxLength(8192)] public string? Description { get; set; }
[Column(TypeName = "jsonb")] public LinkEmbed? Preview { get; set; } [Column(TypeName = "jsonb")] public LinkEmbed? Preview { get; set; }
@ -41,5 +42,5 @@ public class WebFeed : ModelBase
public Guid PublisherId { get; set; } public Guid PublisherId { get; set; }
public Publisher.Publisher Publisher { get; set; } = null!; public Publisher.Publisher Publisher { get; set; } = null!;
public ICollection<WebArticle> Articles { get; set; } = new List<WebArticle>(); [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

@ -1,62 +1,124 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Publisher;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Connection.WebReader; namespace DysonNetwork.Sphere.Connection.WebReader;
[Authorize] [Authorize]
[ApiController] [ApiController]
[Route("/feeds")] [Route("/api/publishers/{pubName}/feeds")]
public class WebFeedController(WebFeedService webFeedService, AppDatabase database) : ControllerBase public class WebFeedController(WebFeedService webFeed, PublisherService ps) : ControllerBase
{ {
public class CreateWebFeedRequest 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)
{ {
[Required] var publisher = await ps.GetPublisherByName(pubName);
[MaxLength(8192)] if (publisher is null) return NotFound();
public required string Url { get; set; } var feeds = await webFeed.GetFeedsByPublisherAsync(publisher.Id);
return Ok(feeds);
[Required]
[MaxLength(4096)]
public required string Title { get; set; }
[MaxLength(8192)]
public string? Description { get; set; }
} }
[HttpGet("{id:guid}")]
[HttpPost] public async Task<IActionResult> GetFeed([FromRoute] string pubName, Guid id)
public async Task<IActionResult> CreateWebFeed([FromBody] CreateWebFeedRequest request)
{ {
var feed = await webFeedService.CreateWebFeedAsync(request, User); 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); return Ok(feed);
} }
[HttpPost("scrape/{feedId}")] [HttpPost]
[RequiredPermission("maintenance", "web-feeds")] [Authorize]
public async Task<ActionResult> ScrapeFeed(Guid feedId) public async Task<IActionResult> CreateWebFeed([FromRoute] string pubName, [FromBody] WebFeedRequest request)
{ {
var feed = await database.Set<WebFeed>().FindAsync(feedId); 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) if (feed == null)
{ {
return NotFound(); return NotFound();
} }
await webFeedService.ScrapeFeedAsync(feed); await webFeed.ScrapeFeedAsync(feed);
return Ok();
}
[HttpPost("scrape-all")]
[RequiredPermission("maintenance", "web-feeds")]
public async Task<ActionResult> ScrapeAllFeeds()
{
var feeds = await database.Set<WebFeed>().ToListAsync();
foreach (var feed in feeds)
{
await webFeedService.ScrapeFeedAsync(feed);
}
return Ok(); return Ok();
} }
} }

View File

@ -1,9 +1,6 @@
using System.Security.Claims;
using System.ServiceModel.Syndication; using System.ServiceModel.Syndication;
using System.Xml; using System.Xml;
using DysonNetwork.Sphere.Account;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace DysonNetwork.Sphere.Connection.WebReader; namespace DysonNetwork.Sphere.Connection.WebReader;
@ -11,30 +8,19 @@ public class WebFeedService(
AppDatabase database, AppDatabase database,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
ILogger<WebFeedService> logger, ILogger<WebFeedService> logger,
AccountService accountService,
WebReaderService webReaderService WebReaderService webReaderService
) )
{ {
public async Task<WebFeed> CreateWebFeedAsync(WebFeedController.CreateWebFeedRequest request, public async Task<WebFeed> CreateWebFeedAsync(Publisher.Publisher publisher,
ClaimsPrincipal claims) WebFeedController.WebFeedRequest request)
{ {
if (claims.Identity?.Name == null)
{
throw new UnauthorizedAccessException();
}
var account = await accountService.LookupAccount(claims.Identity.Name);
if (account == null)
{
throw new UnauthorizedAccessException();
}
var feed = new WebFeed var feed = new WebFeed
{ {
Url = request.Url, Url = request.Url!,
Title = request.Title, Title = request.Title!,
Description = request.Description, Description = request.Description,
PublisherId = account.Id, Config = request.Config ?? new WebFeedConfig(),
PublisherId = publisher.Id,
}; };
database.Set<WebFeed>().Add(feed); database.Set<WebFeed>().Add(feed);
@ -43,6 +29,50 @@ public class WebFeedService(
return feed; 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) public async Task ScrapeFeedAsync(WebFeed feed, CancellationToken cancellationToken = default)
{ {
var httpClient = httpClientFactory.CreateClient(); var httpClient = httpClientFactory.CreateClient();
@ -63,17 +93,13 @@ public class WebFeedService(
{ {
var itemUrl = item.Links.FirstOrDefault()?.Uri.ToString(); var itemUrl = item.Links.FirstOrDefault()?.Uri.ToString();
if (string.IsNullOrEmpty(itemUrl)) if (string.IsNullOrEmpty(itemUrl))
{
continue; continue;
}
var articleExists = await database.Set<WebArticle>() var articleExists = await database.Set<WebArticle>()
.AnyAsync(a => a.FeedId == feed.Id && a.Url == itemUrl, cancellationToken); .AnyAsync(a => a.FeedId == feed.Id && a.Url == itemUrl, cancellationToken);
if (articleExists) if (articleExists)
{
continue; continue;
}
var content = (item.Content as TextSyndicationContent)?.Text ?? item.Summary.Text; var content = (item.Content as TextSyndicationContent)?.Text ?? item.Summary.Text;
LinkEmbed preview; LinkEmbed preview;
@ -82,7 +108,8 @@ public class WebFeedService(
{ {
var scrapedArticle = await webReaderService.ScrapeArticleAsync(itemUrl, cancellationToken); var scrapedArticle = await webReaderService.ScrapeArticleAsync(itemUrl, cancellationToken);
preview = scrapedArticle.LinkEmbed; preview = scrapedArticle.LinkEmbed;
content = scrapedArticle.Content; if (scrapedArticle.Content is not null)
content = scrapedArticle.Content;
} }
else else
{ {
@ -96,11 +123,11 @@ public class WebFeedService(
Url = itemUrl, Url = itemUrl,
Author = item.Authors.FirstOrDefault()?.Name, Author = item.Authors.FirstOrDefault()?.Name,
Content = content, Content = content,
PublishedAt = item.PublishDate.UtcDateTime, PublishedAt = item.LastUpdatedTime.UtcDateTime,
Preview = preview, Preview = preview,
}; };
database.Set<WebArticle>().Add(newArticle); database.WebArticles.Add(newArticle);
} }
await database.SaveChangesAsync(cancellationToken); await database.SaveChangesAsync(cancellationToken);

View File

@ -9,7 +9,7 @@ namespace DysonNetwork.Sphere.Connection.WebReader;
/// Controller for web scraping and link preview services /// Controller for web scraping and link preview services
/// </summary> /// </summary>
[ApiController] [ApiController]
[Route("/scrap")] [Route("/api/scrap")]
[EnableRateLimiting("fixed")] [EnableRateLimiting("fixed")]
public class WebReaderController(WebReaderService reader, ILogger<WebReaderController> logger) public class WebReaderController(WebReaderService reader, ILogger<WebReaderController> logger)
: ControllerBase : ControllerBase

View File

@ -33,7 +33,11 @@ public class WebReaderService(
{ {
var httpClient = httpClientFactory.CreateClient("WebReader"); var httpClient = httpClientFactory.CreateClient("WebReader");
var response = await httpClient.GetAsync(url, cancellationToken); var response = await httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode(); if (!response.IsSuccessStatusCode)
{
logger.LogWarning("Failed to scrap article content for URL: {Url}", url);
return null;
}
var html = await response.Content.ReadAsStringAsync(cancellationToken); var html = await response.Content.ReadAsStringAsync(cancellationToken);
var doc = new HtmlDocument(); var doc = new HtmlDocument();
doc.LoadHtml(html); doc.LoadHtml(html);
@ -74,7 +78,8 @@ public class WebReaderService(
// Cache miss or bypass, fetch fresh data // Cache miss or bypass, fetch fresh data
logger.LogDebug("Fetching fresh link preview for URL: {Url}", url); logger.LogDebug("Fetching fresh link preview for URL: {Url}", url);
var httpClient = httpClientFactory.CreateClient("WebReader"); var httpClient = httpClientFactory.CreateClient("WebReader");
httpClient.MaxResponseContentBufferSize = 10 * 1024 * 1024; // 10MB, prevent scrap some directly accessible files httpClient.MaxResponseContentBufferSize =
10 * 1024 * 1024; // 10MB, prevent scrap some directly accessible files
httpClient.Timeout = TimeSpan.FromSeconds(3); httpClient.Timeout = TimeSpan.FromSeconds(3);
// Setting UA to facebook's bot to get the opengraph. // Setting UA to facebook's bot to get the opengraph.
httpClient.DefaultRequestHeaders.Add("User-Agent", "facebookexternalhit/1.1"); httpClient.DefaultRequestHeaders.Add("User-Agent", "facebookexternalhit/1.1");

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,17 +15,38 @@ 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; }
// OIDC/OAuth specific properties
[MaxLength(4096)] public string? LogoUri { get; set; } [Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
[Column(TypeName = "jsonb")] public CustomAppOauthConfig? OauthConfig { get; set; }
[Column(TypeName = "jsonb")] public CustomAppLinks? Links { get; set; }
[JsonIgnore] public ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>();
public Guid PublisherId { get; set; }
public Publisher.Publisher Developer { get; set; } = null!;
[NotMapped] public string ResourceIdentifier => "custom-app/" + Id;
}
public class CustomAppLinks
{
[MaxLength(8192)] public string? HomePage { get; set; }
[MaxLength(8192)] public string? PrivacyPolicy { get; set; }
[MaxLength(8192)] public string? TermsOfService { get; set; }
}
public class CustomAppOauthConfig
{
[MaxLength(1024)] public string? ClientUri { get; set; } [MaxLength(1024)] public string? ClientUri { get; set; }
[MaxLength(4096)] public string[] RedirectUris { get; set; } = []; [MaxLength(4096)] public string[] RedirectUris { get; set; } = [];
[MaxLength(4096)] public string[]? PostLogoutRedirectUris { get; set; } [MaxLength(4096)] public string[]? PostLogoutRedirectUris { get; set; }
@ -30,11 +54,6 @@ public class CustomApp : ModelBase
[MaxLength(256)] public string[] AllowedGrantTypes { get; set; } = ["authorization_code", "refresh_token"]; [MaxLength(256)] public string[] AllowedGrantTypes { get; set; } = ["authorization_code", "refresh_token"];
public bool RequirePkce { get; set; } = true; public bool RequirePkce { get; set; } = true;
public bool AllowOfflineAccess { get; set; } = false; public bool AllowOfflineAccess { get; set; } = false;
[JsonIgnore] public ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>();
public Guid PublisherId { get; set; }
public Publisher.Publisher Developer { get; set; } = null!;
} }
public class CustomAppSecret : ModelBase public class CustomAppSecret : ModelBase
@ -44,7 +63,7 @@ public class CustomAppSecret : ModelBase
[MaxLength(4096)] public string? Description { 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 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,63 +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(CustomAppService customAppService, PublisherService ps) : ControllerBase public class CustomAppController(CustomAppService customApps, PublisherService ps) : ControllerBase
{ {
[HttpGet("")] public record CustomAppRequest(
public async Task<IActionResult> GetApps([FromQuery] Guid publisherId) [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 apps = await customAppService.GetAppsByPublisherAsync(publisherId); var publisher = await ps.GetPublisherByName(pubName);
if (publisher is null) return NotFound();
var apps = await customApps.GetAppsByPublisherAsync(publisher.Id);
return Ok(apps); return Ok(apps);
} }
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
public async Task<IActionResult> GetApp(Guid id) public async Task<IActionResult> GetApp([FromRoute] string pubName, Guid id)
{ {
var app = await customAppService.GetAppAsync(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) if (app == null)
{
return NotFound(); return NotFound();
}
return Ok(app); return Ok(app);
} }
[HttpPost("")] [HttpPost]
public async Task<IActionResult> CreateApp([FromBody] CreateAppDto dto) [Authorize]
public async Task<IActionResult> CreateApp([FromRoute] string pubName, [FromBody] CustomAppRequest request)
{ {
var app = await customAppService.CreateAppAsync(dto.PublisherId, dto.Name, dto.Slug); if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
if (app == null)
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
{ {
return BadRequest("Invalid publisher ID or missing developer feature flag"); var app = await customApps.CreateAppAsync(publisher, request);
return Ok(app);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
} }
return CreatedAtAction(nameof(GetApp), new { id = app.Id }, app);
} }
[HttpPut("{id:guid}")] [HttpPatch("{id:guid}")]
public async Task<IActionResult> UpdateApp(Guid id, [FromBody] UpdateAppDto dto) [Authorize]
public async Task<IActionResult> UpdateApp(
[FromRoute] string pubName,
[FromRoute] Guid id,
[FromBody] CustomAppRequest request
)
{ {
var app = await customAppService.UpdateAppAsync(id, dto.Name, dto.Slug); 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) if (app == null)
{
return NotFound(); return NotFound();
try
{
app = await customApps.UpdateAppAsync(app, request);
return Ok(app);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
} }
return Ok(app);
} }
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
public async Task<IActionResult> DeleteApp(Guid id) [Authorize]
public async Task<IActionResult> DeleteApp(
[FromRoute] string pubName,
[FromRoute] Guid id
)
{ {
var result = await customAppService.DeleteAppAsync(id); if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
if (!result)
{ 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 NotFound();
}
return NoContent(); return NoContent();
} }
} }
public record CreateAppDto(Guid PublisherId, string Name, string Slug);
public record UpdateAppDto(string Name, string Slug);

View File

@ -1,29 +1,58 @@
using DysonNetwork.Sphere.Publisher; using DysonNetwork.Sphere.Publisher;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Developer; namespace DysonNetwork.Sphere.Developer;
public class CustomAppService(AppDatabase db, PublisherService ps) public class CustomAppService(AppDatabase db, FileReferenceService fileRefService)
{ {
public async Task<CustomApp?> CreateAppAsync(Guid publisherId, string name, string slug) public async Task<CustomApp?> CreateAppAsync(
Publisher.Publisher pub,
CustomAppController.CustomAppRequest request
)
{ {
var publisher = await db.Publishers.FirstOrDefaultAsync(p => p.Id == publisherId);
if (publisher == null)
{
return null;
}
if (!await ps.HasFeature(publisherId, "developer"))
{
return null;
}
var app = new CustomApp var app = new CustomApp
{ {
Name = name, Slug = request.Slug!,
Slug = slug, Name = request.Name!,
PublisherId = publisher.Id 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); db.CustomApps.Add(app);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@ -31,9 +60,12 @@ public class CustomAppService(AppDatabase db, PublisherService ps)
return app; return app;
} }
public async Task<CustomApp?> GetAppAsync(Guid id) public async Task<CustomApp?> GetAppAsync(Guid id, Guid? publisherId = null)
{ {
return await db.CustomApps.FindAsync(id); 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) public async Task<List<CustomApp>> GetAppsByPublisherAsync(Guid publisherId)
@ -41,17 +73,60 @@ public class CustomAppService(AppDatabase db, PublisherService ps)
return await db.CustomApps.Where(a => a.PublisherId == publisherId).ToListAsync(); return await db.CustomApps.Where(a => a.PublisherId == publisherId).ToListAsync();
} }
public async Task<CustomApp?> UpdateAppAsync(Guid id, string name, string slug) public async Task<CustomApp?> UpdateAppAsync(CustomApp app, CustomAppController.CustomAppRequest request)
{ {
var app = await db.CustomApps.FindAsync(id); if (request.Slug is not null)
if (app == 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)
{ {
return 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
);
} }
app.Name = name; db.Update(app);
app.Slug = slug;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return app; return app;
@ -67,6 +142,8 @@ public class CustomAppService(AppDatabase db, PublisherService ps)
db.CustomApps.Remove(app); db.CustomApps.Remove(app);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await fileRefService.DeleteResourceReferencesAsync(app.ResourceIdentifier);
return true; 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

@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Discovery; namespace DysonNetwork.Sphere.Discovery;
[ApiController] [ApiController]
[Route("/discovery")] [Route("/api/discovery")]
public class DiscoveryController(DiscoveryService discoveryService) : ControllerBase public class DiscoveryController(DiscoveryService discoveryService) : ControllerBase
{ {
[HttpGet("realms")] [HttpGet("realms")]

View File

@ -26,6 +26,7 @@
<PackageReference Include="HtmlAgilityPack" Version="1.12.1" /> <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" />

View File

@ -6,6 +6,7 @@ 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.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;
@ -1507,25 +1508,9 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("id"); .HasColumnName("id");
b.Property<bool>("AllowOfflineAccess") b.Property<CloudFileReferenceObject>("Background")
.HasColumnType("boolean") .HasColumnType("jsonb")
.HasColumnName("allow_offline_access"); .HasColumnName("background");
b.Property<string>("AllowedGrantTypes")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("allowed_grant_types");
b.Property<string>("AllowedScopes")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("allowed_scopes");
b.Property<string>("ClientUri")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("client_uri");
b.Property<Instant>("CreatedAt") b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
@ -1535,10 +1520,14 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at"); .HasColumnName("deleted_at");
b.Property<string>("LogoUri") b.Property<string>("Description")
.HasMaxLength(4096) .HasMaxLength(4096)
.HasColumnType("character varying(4096)") .HasColumnType("character varying(4096)")
.HasColumnName("logo_uri"); .HasColumnName("description");
b.Property<CustomAppLinks>("Links")
.HasColumnType("jsonb")
.HasColumnName("links");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
@ -1546,25 +1535,18 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("character varying(1024)") .HasColumnType("character varying(1024)")
.HasColumnName("name"); .HasColumnName("name");
b.Property<string>("PostLogoutRedirectUris") b.Property<CustomAppOauthConfig>("OauthConfig")
.HasMaxLength(4096) .HasColumnType("jsonb")
.HasColumnType("character varying(4096)") .HasColumnName("oauth_config");
.HasColumnName("post_logout_redirect_uris");
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");
b.Property<string>("RedirectUris")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("redirect_uris");
b.Property<bool>("RequirePkce")
.HasColumnType("boolean")
.HasColumnName("require_pkce");
b.Property<string>("Slug") b.Property<string>("Slug")
.IsRequired() .IsRequired()
.HasMaxLength(1024) .HasMaxLength(1024)
@ -1579,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");
@ -3618,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()
@ -3980,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");

View File

@ -1,4 +1,4 @@
@page "/web/account/profile" @page "//account/profile"
@model DysonNetwork.Sphere.Pages.Account.ProfileModel @model DysonNetwork.Sphere.Pages.Account.ProfileModel
@{ @{
ViewData["Title"] = "Profile"; ViewData["Title"] = "Profile";
@ -6,53 +6,44 @@
@if (Model.Account != null) @if (Model.Account != null)
{ {
<div class="h-full bg-gray-100 dark:bg-gray-900 py-8 px-4"> <div class="p-4 sm:p-8 bg-base-200">
<div class="max-w-6xl mx-auto"> <div class="max-w-6xl mx-auto">
<!-- Header --> <!-- Header -->
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Profile Settings</h1> <h1 class="text-3xl font-bold">Profile Settings</h1>
<p class="text-gray-600 dark:text-gray-400 mt-2">Manage your account information and preferences</p> <p class="text-base-content/70 mt-2">Manage your account information and preferences</p>
</div> </div>
<!-- Two Column Layout --> <!-- Two Column Layout -->
<div class="flex flex-col md:flex-row gap-8"> <div class="flex flex-col md:flex-row gap-6">
<!-- Left Pane - Profile Card --> <!-- Left Pane - Profile Card -->
<div class="w-full md:w-1/3 lg:w-1/4"> <div class="w-full md:w-1/3 lg:w-1/4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 sticky top-8"> <div class="card bg-base-100 shadow-xl sticky top-8">
<div class="flex flex-col items-center text-center"> <div class="card-body items-center text-center">
<!-- Avatar --> <!-- Avatar -->
<div <div class="avatar avatar-placeholder mb-4">
class="w-32 h-32 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center mb-4 overflow-hidden"> <div class="bg-neutral text-neutral-content rounded-full w-32">
<span class="text-4xl text-gray-500 dark:text-gray-400"> <span class="text-4xl">@Model.Account.Name?[..1].ToUpper()</span>
@Model.Account.Name?.Substring(0, 1).ToUpper() </div>
</span>
</div> </div>
<!-- Basic Info --> <!-- Basic Info -->
<h2 class="text-xl font-semibold text-gray-900 dark:text-white"> <h2 class="card-title">@Model.Account.Nick</h2>
@Model.Account.Nick <p class="font-mono text-sm">@@@Model.Account.Name</p>
</h2>
<p class="text-gray-600 dark:text-gray-400">@Model.Account.Name</p>
<!-- Stats --> <!-- Stats -->
<div <div class="stats stats-vertical shadow mt-4">
class="mt-4 flex justify-around w-full border-t border-gray-200 dark:border-gray-700 pt-4"> <div class="stat">
<div class="text-center"> <div class="stat-title">Level</div>
<div <div class="stat-value">@Model.Account.Profile.Level</div>
class="text-lg font-semibold text-gray-900 dark:text-white">@Model.Account.Profile.Level</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Level</div>
</div> </div>
<div class="text-center"> <div class="stat">
<div <div class="stat-title">XP</div>
class="text-lg font-semibold text-gray-900 dark:text-white">@Model.Account.Profile.Experience</div> <div class="stat-value">@Model.Account.Profile.Experience</div>
<div class="text-sm text-gray-500 dark:text-gray-400">XP</div>
</div> </div>
<div class="text-center"> <div class="stat">
<div <div class="stat-title">Member since</div>
class="text-lg font-semibold text-gray-900 dark:text-white"> <div class="stat-value">@Model.Account.CreatedAt.ToDateTimeUtc().ToString("yyyy/MM")</div>
@Model.Account.CreatedAt.ToDateTimeUtc().ToString("yyyy/MM/dd")
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Member since</div>
</div> </div>
</div> </div>
</div> </div>
@ -61,181 +52,107 @@
<!-- Right Pane - Tabbed Content --> <!-- Right Pane - Tabbed Content -->
<div class="flex-1"> <div class="flex-1">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden"> <div role="tablist" class="tabs tabs-lift w-full">
<!-- Tabs --> <input type="radio" name="profile-tabs" role="tab" class="tab" aria-label="Profile" checked />
<div class="border-b border-gray-200 dark:border-gray-700"> <div role="tabpanel" class="tab-content bg-base-100 border-base-300 p-6">
<nav class="flex -mb-px"> <h2 class="text-xl font-semibold mb-6">Profile Information</h2>
<button type="button"
class="tab-button active py-4 px-6 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200" <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
data-tab="profile"> <div>
Profile <h3 class="text-lg font-medium mb-4">Basic Information</h3>
</button> <dl class="space-y-4">
<button type="button" <div>
class="tab-button py-4 px-6 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200" <dt class="text-sm font-medium text-base-content/70">Full Name</dt>
data-tab="security"> <dd class="mt-1 text-sm">@($"{Model.Account.Profile.FirstName} {Model.Account.Profile.MiddleName} {Model.Account.Profile.LastName}".Trim())</dd>
Security </div>
</button> <div>
<button type="button" <dt class="text-sm font-medium text-base-content/70">Username</dt>
class="tab-button py-4 px-6 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200" <dd class="mt-1 text-sm">@Model.Account.Name</dd>
data-tab="sessions"> </div>
Sessions <div>
</button> <dt class="text-sm font-medium text-base-content/70">Nickname</dt>
</nav> <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> </div>
<!-- Tab Content --> <input type="radio" name="profile-tabs" role="tab" class="tab" aria-label="Security" />
<div class="p-6"> <div role="tabpanel" class="tab-content bg-base-100 border-base-300 p-6">
<!-- Profile Tab --> <h2 class="text-xl font-semibold mb-2">Security Settings</h2>
<div id="profile-tab" class="tab-content">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Profile
Information</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="space-y-6">
<div> <div class="card bg-base-300 shadow-xl">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Basic <div class="card-body">
Information</h3> <h3 class="card-title">Access Token</h3>
<dl class="space-y-4"> <p>Use this token to authenticate with the API</p>
<div> <div class="form-control">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Full <div class="join">
Name <input type="password" id="accessToken" value="@Model.AccessToken" readonly class="input input-bordered join-item flex-grow" />
</dt> <button onclick="copyAccessToken()" class="btn join-item">Copy</button>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
@($"{Model.Account.Profile.FirstName} {Model.Account.Profile.MiddleName} {Model.Account.Profile.LastName}".Trim())
</dd>
</div> </div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
Username
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">@Model.Account.Name</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
Nickname
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">@Model.Account.Nick</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
Gender
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">@Model.Account.Profile.Gender</dd>
</div>
</dl>
</div>
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Additional
Details</h3>
<dl class="space-y-4">
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
Location
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">@Model.Account.Profile.Location</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
Birthday
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
@Model.Account.Profile.Birthday?.ToString("MMMM d, yyyy", System.Globalization.CultureInfo.InvariantCulture)
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Bio
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
@(string.IsNullOrEmpty(Model.Account.Profile.Bio) ? "No bio provided" : Model.Account.Profile.Bio)
</dd>
</div>
</dl>
</div>
</div>
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<button type="button"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Edit Profile
</button>
</div>
</div>
<!-- Security Tab -->
<div id="security-tab" class="tab-content hidden">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Security
Settings</h2>
<div class="space-y-6">
<div class="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg">
<div class="px-4 py-5 sm:px-6">
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white">
Access Token</h3>
<p class="mt-1 max-w-2xl text-sm text-gray-500 dark:text-gray-400">Use this
token to authenticate with the API</p>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 px-4 py-5 sm:px-6">
<div class="flex items-center">
<input type="password" id="accessToken" value="@Model.AccessToken"
readonly
class="form-input flex-grow rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-white py-2 px-4"/>
<button onclick="copyAccessToken()"
class="ml-4 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50">
Copy
</button>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Keep this token secure and do not share it with anyone.
</p>
</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>
</div> </div>
</div>
<!-- Sessions Tab --> <input type="radio" name="profile-tabs" role="tab" class="tab" aria-label="Sessions" />
<div id="sessions-tab" class="tab-content hidden"> <div role="tabpanel" class="tab-content bg-base-100 border-base-300 p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Active <h2 class="text-xl font-semibold">Active Sessions</h2>
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>
<p class="text-gray-600 dark:text-gray-400 mb-6">This is a list of devices that have
logged into your account. Revoke any sessions that you do not recognize.</p>
<div class="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg"> <div class="card bg-base-300 shadow-xl">
<ul class="divide-y divide-gray-200 dark:divide-gray-700"> <div class="card-body">
<li class="px-4 py-4 sm:px-6"> <div class="overflow-x-auto">
<div class="flex items-center justify-between"> <table class="table">
<div class="flex items-center"> <tbody>
<div <tr>
class="flex-shrink-0 h-10 w-10 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center"> <td>
<svg class="h-6 w-6 text-blue-600 dark:text-blue-400" <div class="flex items-center gap-3">
fill="currentColor" viewBox="0 0 20 20"> <div class="avatar">
<path fill-rule="evenodd" <div class="mask mask-squircle w-12 h-12">
d="M4 4a2 2 0 012-2h8a2 2 0 012 2v12a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 0v12h8V4H6z" <svg class="h-full w-full text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
clip-rule="evenodd"/> <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> </svg>
</div> </div>
<div class="ml-4">
<div class="text-sm font-medium text-gray-900 dark:text-white">
Current Session
</div> </div>
<div class="text-sm text-gray-500 dark:text-gray-400"> <div>
@($"{Request.Headers["User-Agent"]} • {DateTime.Now:MMMM d, yyyy 'at' h:mm tt}") <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>
</div> </div>
</div> </td>
<div class="ml-4 flex-shrink-0"> </tr>
<span </tbody>
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"> </table>
Active now </div>
</span> <div class="card-actions justify-end mt-4">
</div> <button type="button" class="btn btn-error">Sign out all other sessions</button>
</div>
</li>
</ul>
<div class="bg-gray-50 dark:bg-gray-800 px-4 py-4 sm:px-6 text-right">
<button type="button"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
Sign out all other sessions
</button>
</div> </div>
</div> </div>
</div> </div>
@ -245,10 +162,7 @@
<!-- Logout Button --> <!-- Logout Button -->
<div class="mt-6 flex justify-end"> <div class="mt-6 flex justify-end">
<form method="post" asp-page-handler="Logout"> <form method="post" asp-page-handler="Logout">
<button type="submit" <button type="submit" class="btn btn-error">Sign out</button>
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
Sign out
</button>
</form> </form>
</div> </div>
</div> </div>
@ -258,54 +172,22 @@
} }
else else
{ {
<div class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900"> <div class="hero min-h-screen bg-base-200">
<div class="max-w-md w-full p-8 bg-white dark:bg-gray-800 rounded-lg shadow-md text-center"> <div class="hero-content text-center">
<div class="text-red-500 text-5xl mb-4"> <div class="max-w-md">
<i class="fas fa-exclamation-circle"></i> <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>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Profile Not Found</h2>
<p class="text-gray-600 dark:text-gray-400 mb-6">User profile not found. Please log in to continue.</p>
<a href="/auth/login"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Go to Login
</a>
</div> </div>
</div> </div>
} }
@section Scripts { @section Scripts {
<script> <script>
// Tab functionality
document.addEventListener('DOMContentLoaded', function () {
// Get all tab buttons and content
const tabButtons = document.querySelectorAll('.tab-button');
const tabContents = document.querySelectorAll('.tab-content');
// Add click event listeners to tab buttons
tabButtons.forEach(button => {
button.addEventListener('click', function () {
const tabId = this.getAttribute('data-tab');
// Update active tab button
tabButtons.forEach(btn => btn.classList.remove('border-blue-500', 'text-blue-600', 'dark:text-blue-400'));
this.classList.add('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
// Show corresponding tab content
tabContents.forEach(content => {
content.classList.add('hidden');
if (content.id === `${tabId}-tab`) {
content.classList.remove('hidden');
}
});
});
});
// Show first tab by default
if (tabButtons.length > 0) {
tabButtons[0].click();
}
});
// Copy access token to clipboard // Copy access token to clipboard
function copyAccessToken() { function copyAccessToken() {
const copyText = document.getElementById("accessToken"); const copyText = document.getElementById("accessToken");
@ -340,4 +222,4 @@ else
} }
} }
</script> </script>
} }

View File

@ -4,10 +4,10 @@
ViewData["Title"] = "Authorize Application"; ViewData["Title"] = "Authorize Application";
} }
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 transition-colors duration-200 py-12 px-4 sm:px-6 lg:px-8"> <div class="h-full flex items-center justify-center bg-base-200 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 bg-white dark:bg-gray-800 p-8 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 transition-colors duration-200"> <div class="card w-full max-w-md bg-base-100 shadow-xl">
<div class="text-center"> <div class="card-body px-8 py-7">
<h2 class="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white"> <h2 class="card-title justify-center text-2xl font-bold">
Authorize Application Authorize Application
</h2> </h2>
@if (!string.IsNullOrEmpty(Model.AppName)) @if (!string.IsNullOrEmpty(Model.AppName))
@ -16,21 +16,25 @@
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
@if (!string.IsNullOrEmpty(Model.AppLogo)) @if (!string.IsNullOrEmpty(Model.AppLogo))
{ {
<div class="relative h-16 w-16 flex-shrink-0"> <div class="avatar">
<img class="h-16 w-16 rounded-lg object-cover border border-gray-200 dark:border-gray-700" <div class="w-12 rounded">
src="@Model.AppLogo" <img src="@Model.AppLogo" alt="@Model.AppName logo" />
alt="@Model.AppName logo" </div>
onerror="this.onerror=null; this.src='data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iY3VycmVudENvbG9yIiBjbGFzczz0idy02IGgtNiB0ZXh0LWdyYXktNDAwIj48cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xOS42MDMgMy4wMDRjMS4xNTMgMCAyLjI2LjE4IDMuMzI5LjUxM2EuNzUuNzUgMCAwMS0uMzI4IDEuNDQ1IDE2LjkzIDE2LjkzIDAgMDAtNi4wMS0xuMTAzLjc1Ljc1IDAgMDEtLjY2OC0uNzQ4VjMuNzVBLjc1Ljc1IDAgMDExNi41IDNoMy4xMDN6TTMxLjUgMjEuMDA4Yy0uU0UuNzUgNzUuNzUgMCAwMTEuOTQ4LjA1MWMuMDgxLjkxOC4wNTIgMS44MzgtLjA4NiAyLjczNGEuNzUuNzUgMCAwMS0xLjQ5LS4xNTkgMjEuMzg2IDIxLjM4NiAwIDAwLjA2LTIuNTc1Ljc1Ljc1IDAgMDExLjU3OC0uMDQzem0tMy4wMi0xOC4wMmMuMDYgMS4wOTIuMDQgMi4xODctLjA1NiAzLjI3MmEuNzUuNzUgMCAwMS0xLjQ5Mi0uMTY0Yy4wOTItLjg3NC4xMDctMS43NTYuMDUxLTIuNjA3YS43NS43NSAwIDAxMS40OTctLjEwMXpNNS42MzcgNS42MzJjLS4zMzQuMS0uNjc2LjE4NS0xLjAyNi4yNTdhLjc1Ljc1IDAgMDEuMTQ5LTEuNDljLjQyNS0uMDg1Ljg1Mi0uMTg5IDEuMjY4LS4zMDRhLjc1Ljc1IDAgMDEuMzYgMS40Mzd6TTMuMzMgMTkuNjUzYy0uMTY1LS4zNS0uMzA4LS43MDctNDIuNjUzLjc1Ljc1IDAgMS4zODgtLjU0MiAxLjQ5LTEuMjg1LjA0Ni0uMzI2LjEwNi0uNjUyLjE4LS45NzZhLjc1Ljc1IDAgMTExLjQ2LS41M2MuMTA2LjQzNy4xODkuODgxLjI0NSAxLjM0OGEuNzUuNzUgMCAwMS0xLjQ5LjIzM3pNTEuMzUzIDIuNzY3YS43NS43NSAwIDAxLjc1LS4wMTdsLjAwMS4wMDFoLjAwMWwuMDAyLjAwMWwuMDA3LjAwM2wuMDI0LjAxM2MuMDIuMDEuMDQ1LjAyNS4wNzkuMDQ2LjA2Ny4wNDIuMTYxLjEwMi4yNzUuMTc4LjIzMi4xNTEuNTUuMzgzLjg1Ni42NjdsLjAyNy4wMjRjLjYxNi41NTggMS4yMTIgMS4xNzYgMS43MzMgMS44NDNhLjc1Ljc1IDAgMDEtMS4yNC44N2MtLjQ3LS42NzEtMS4wMzEtMS4yOTItMS42LFsxLjYxNi0xLjYxNmEzLjc1IDMuNzUgMCAwMC01LjMwNS01LjMwNGwtMS4wNi0xuMDZBNy4yNDMgNy4yNDMgMCAwMTUxLjM1MyAyLjc2N3pNNDQuMzc5IDkuNjRsLTEuNTYgMS41NmE2Ljk5IDYuOTkgMCAwMTIuMjMgNC4zMzcgNi45OSA2Ljk5IDAgMDEtMi4yMyA1LjE3NmwtMS41Ni0xLjU2QTguNDkgOC40OSAwIDAwNDUuNSAxNS41YzAtMi4yOTYtLjg3NC00LjQzLTIuMTIxLTYuMDF6bS0zLjUzLTMuNTNsLTEuMDYxLTEuMDZhNy4yNDMgNy4yNDMgMCAwMTkuMTkyIDkuE2x0LTEuMDYgMS4wNjFhNS43NDkgNS43NDkgMCAwMC04LjEzLTguMTN6TTM0LjUgMTUuNWE4Ljk5IDguOTkgMCAwMC0yLjYzMSA2LjM2OS43NS43NSAwIDExLTEuNDk0LS43MzlDNzIuMzkgMjAuMDYgMzMuNSAxNy41NzUgMzMuNSAxNS41YzAtMi4zNzYgMS4wOTktNC40MzggMi44MTEtNS44MTJsLS4zOTYtLjM5NmEuNzUuNzUgMCAwMTEuMDYtMS4wNkwzNy41IDkuNDRWMmgtL4wMDJhLjc1Ljc1IDAgMDEtLjc0OC0uNzVWMS41YS43NS43NSAwIDAxLjc1LS43NWg2YS43NS43NSAwIDAxLjc1Ljc1di4yNWEwIC43NS0uNzUuNzVoLS4wMDF2Ny40NGwzLjUzNy0zLjUzN2EuNzUuNzUgMCAwMTEuMDYgMS4wNmwtLjM5Ni4zOTZDMzUuNDAxIDExLjA2MiAzNC41IDEzLjEyNCAzNC41IDE1LjV6TTM5IDIuMjV2Ni4wMy0uMDAyYTEuNSAxLjUgMCAwMS0uNDQ0IDEuMDZsLTEuMDYxIDEuMDZBOC40OSA4LjQ5IDAgMDAzOSAxNS41YzAgMi4yOTYtLjg3NCA0LjQzLTIuMTIxIDYuMDFsMS41NiAxLjU2QTYuOTkgNi45OSAwIDAwNDIgMTUuNWE2Ljk5IDYuOTkgMCAwMC0yLjIzLTUuMTc2bC0xLjU2LTEuNTZhMS41IDEuNSAwIDAxLS40NC0xLjA2VjIuMjVoLTF6TTI0IDkuNzVhLjc1Ljc1IDAgMDEtLjc1Ljc1di0uNWMwLS40MTQuMzM2LS43NS43NS0uNzVoLjVjLjQxNCAwIC43NS4zMzYuNzUuNzV2LjVhLjc1Ljc1IDAgMDEtLjc1Ljc1aC0uNXpNMTkuNSAxM2EuNzUuNzUgMCAwMS0uNzUtLjc1di0uNWMwLS40MTQuMzM2LS43NS43NS0uNzVoLjVjLjQxNCAwIC43NS4zMzYuNzUuNzV2LjVhLjc1Ljc1IDAgMDEtLjc1Ljc1aC0uNXpNMTUgMTYuMjVhLjc1Ljc1IDAgMDEuNzUtLjc1aC41Yy40MTQgMCAuNzUuMzM2Ljc1Ljc1di41YS43NS43NSAwIDAxLS43NS43NWgtLjVhLjc1Ljc1IDAgMDEtLjc1LS43NXYtLjV6IiBjbGlwLXJ1bGU9ImV2ZW5vZGQiLz48L3N2Zz4=';"> </div>
<div class="absolute inset-0 flex items-center justify-center bg-gray-100 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600"> }
<span class="text-xs font-medium text-gray-500 dark:text-gray-300">@Model.AppName?[0]</span> else
{
<div class="avatar avatar-placeholder">
<div class="bg-neutral text-neutral-content rounded-full w-12">
<span class="text-xl">@Model.AppName?[..1].ToUpper()</span>
</div> </div>
</div> </div>
} }
<div class="ml-4 text-left"> <div class="ml-4 text-left">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">@Model.AppName</h3> <h3 class="text-lg font-medium">@Model.AppName</h3>
@if (!string.IsNullOrEmpty(Model.AppUri)) @if (!string.IsNullOrEmpty(Model.AppUri))
{ {
<a href="@Model.AppUri" class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300 transition-colors duration-200" target="_blank" rel="noopener noreferrer"> <a href="@Model.AppUri" class="text-sm link link-primary" target="_blank" rel="noopener noreferrer">
@Model.AppUri @Model.AppUri
</a> </a>
} }
@ -38,77 +42,59 @@
</div> </div>
</div> </div>
} }
<p class="mt-6 text-sm text-gray-600 dark:text-gray-300"> <p class="mt-6 text-sm text-center">
wants to access your account with the following permissions: When you authorize this application, you consent to the following permissions:
</p> </p>
</div>
<div class="mt-6"> <div class="mt-4">
<ul class="border border-gray-200 dark:border-gray-700 rounded-lg divide-y divide-gray-200 dark:divide-gray-700 overflow-hidden"> <ul class="menu bg-base-200 rounded-box w-full">
@if (Model.Scope != null) @if (Model.Scope != null)
{
var scopeDescriptions = new Dictionary<string, (string Name, string Description)>
{ {
["openid"] = ("OpenID", "Read your basic profile information"), var scopeDescriptions = new Dictionary<string, (string Name, string Description)>
["profile"] = ("Profile", "View your basic profile information"), {
["email"] = ("Email", "View your email address"), ["openid"] = ("OpenID", "Read your basic profile information"),
["offline_access"] = ("Offline Access", "Access your data while you're not using the application") ["profile"] = ("Profile", "View your basic profile information"),
}; ["email"] = ("Email", "View your email address"),
["offline_access"] = ("Offline Access", "Access your data while you're not using the application")
};
foreach (var scope in Model.Scope.Split(' ').Where(s => !string.IsNullOrWhiteSpace(s))) foreach (var scope in Model.Scope.Split(' ').Where(s => !string.IsNullOrWhiteSpace(s)))
{ {
var scopeInfo = scopeDescriptions.GetValueOrDefault(scope, (scope, scope.Replace('_', ' '))); var scopeInfo = scopeDescriptions.GetValueOrDefault(scope, (scope, scope.Replace('_', ' ')));
<li class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150"> <li>
<div class="flex items-start"> <a>
<div class="flex-shrink-0 pt-0.5"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-success" viewBox="0 0 20 20" fill="currentColor">
<svg class="h-5 w-5 text-green-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg> </svg>
</div> <div>
<div class="ml-3"> <p class="font-medium">@scopeInfo.Item1</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">@scopeInfo.Item1</p> <p class="text-xs text-base-content/70">@scopeInfo.Item2</p>
<p class="text-xs text-gray-500 dark:text-gray-400">@scopeInfo.Item2</p> </div>
</div> </a>
</div> </li>
</li> }
} }
} </ul>
</ul>
<div class="mt-4 text-xs text-gray-500 dark:text-gray-400">
<p>By authorizing, you allow this application to access your information on your behalf.</p>
</div> </div>
</div>
<form method="post" class="mt-8 space-y-4"> <form method="post" class="mt-8 space-y-4">
<input type="hidden" asp-for="ClientIdString" /> <input type="hidden" asp-for="ClientIdString" />
<input type="hidden" asp-for="ResponseType" name="response_type" /> <input type="hidden" asp-for="ResponseType" name="response_type" />
<input type="hidden" asp-for="RedirectUri" name="redirect_uri" /> <input type="hidden" asp-for="RedirectUri" name="redirect_uri" />
<input type="hidden" asp-for="Scope" name="scope" /> <input type="hidden" asp-for="Scope" name="scope" />
<input type="hidden" asp-for="State" name="state" /> <input type="hidden" asp-for="State" name="state" />
<input type="hidden" asp-for="Nonce" name="nonce" /> <input type="hidden" asp-for="Nonce" name="nonce" />
<input type="hidden" asp-for="ReturnUrl" name="returnUrl" /> <input type="hidden" asp-for="ReturnUrl" name="returnUrl" />
<input type="hidden" name="code_challenge" value="@HttpContext.Request.Query["code_challenge"]" /> <input type="hidden" asp-for="CodeChallenge" value="@HttpContext.Request.Query["code_challenge"]" />
<input type="hidden" name="code_challenge_method" value="@HttpContext.Request.Query["code_challenge_method"]" /> <input type="hidden" asp-for="CodeChallengeMethod" value="@HttpContext.Request.Query["code_challenge_method"]" />
<input type="hidden" name="response_mode" value="@HttpContext.Request.Query["response_mode"]" /> <input type="hidden" asp-for="ResponseMode" value="@HttpContext.Request.Query["response_mode"]" />
<div class="flex flex-col space-y-3"> <div class="card-actions justify-center flex gap-4">
<button type="submit" name="allow" value="true" <button type="submit" name="allow" value="true" class="btn btn-primary flex-1">Allow</button>
class="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-800 transition-colors duration-200"> <button type="submit" name="allow" value="false" class="btn btn-ghost flex-1">Deny</button>
Allow </div>
</button> </form>
<button type="submit" name="allow" value="false" </div>
class="w-full flex justify-center py-2.5 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-800 transition-colors duration-200">
Deny
</button>
</div>
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<p class="text-xs text-center text-gray-500 dark:text-gray-400">
You can change these permissions later in your account settings.
</p>
</div>
</form>
</div> </div>
</div> </div>
@ -124,4 +110,4 @@
_ => scope _ => scope
}; };
} }
} }

View File

@ -1,15 +1,14 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using DysonNetwork.Sphere.Auth.OidcProvider.Services; using DysonNetwork.Sphere.Auth.OidcProvider.Services;
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Auth.OidcProvider.Responses; using DysonNetwork.Sphere.Auth.OidcProvider.Responses;
using DysonNetwork.Sphere.Developer; using DysonNetwork.Sphere.Developer;
namespace DysonNetwork.Sphere.Pages.Auth; namespace DysonNetwork.Sphere.Pages.Auth;
public class AuthorizeModel(OidcProviderService oidcService) : PageModel public class AuthorizeModel(OidcProviderService oidcService, IConfiguration configuration) : PageModel
{ {
[BindProperty(SupportsGet = true)] public string? ReturnUrl { get; set; } [BindProperty(SupportsGet = true)] public string? ReturnUrl { get; set; }
@ -48,12 +47,14 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel
public async Task<IActionResult> OnGetAsync() public async Task<IActionResult> OnGetAsync()
{ {
if (HttpContext.Items["CurrentUser"] is not Sphere.Account.Account) // First check if user is authenticated
if (HttpContext.Items["CurrentUser"] is not Sphere.Account.Account currentUser)
{ {
var returnUrl = Uri.EscapeDataString($"{Request.Path}{Request.QueryString}"); var returnUrl = Uri.EscapeDataString($"{Request.Path}{Request.QueryString}");
return RedirectToPage("/Auth/Login", new { returnUrl }); return RedirectToPage("/Auth/Login", new { returnUrl });
} }
// Validate client_id
if (string.IsNullOrEmpty(ClientIdString) || !Guid.TryParse(ClientIdString, out var clientId)) if (string.IsNullOrEmpty(ClientIdString) || !Guid.TryParse(ClientIdString, out var clientId))
{ {
ModelState.AddModelError("client_id", "Invalid client_id format"); ModelState.AddModelError("client_id", "Invalid client_id format");
@ -62,29 +63,102 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel
ClientId = clientId; ClientId = clientId;
// Get client info
var client = await oidcService.FindClientByIdAsync(ClientId); var client = await oidcService.FindClientByIdAsync(ClientId);
if (client == null) if (client == null)
{ {
ModelState.AddModelError("client_id", "Client not found"); ModelState.AddModelError("client_id", "Client not found");
return NotFound("Client not found"); return NotFound("Client not found");
} }
if (client.Status != CustomAppStatus.Developing) var config = client.OauthConfig;
if (config is null)
{ {
// Validate redirect URI for non-Developing apps ModelState.AddModelError("client_id", "Client was not available for use OAuth / OIDC");
if (!string.IsNullOrEmpty(RedirectUri) && !(client.RedirectUris?.Contains(RedirectUri) ?? false)) return BadRequest("Client was not enabled for OAuth / OIDC");
return BadRequest(
new ErrorResponse { Error = "invalid_request", ErrorDescription = "Invalid redirect_uri" });
} }
// Validate redirect URI for non-Developing apps
if (client.Status != CustomAppStatus.Developing)
{
if (!string.IsNullOrEmpty(RedirectUri) && !(config.RedirectUris?.Contains(RedirectUri) ?? false))
{
return BadRequest(new ErrorResponse
{
Error = "invalid_request",
ErrorDescription = "Invalid redirect_uri"
});
}
}
// Check for an existing valid session
var existingSession = await oidcService.FindValidSessionAsync(currentUser.Id, clientId);
if (existingSession != null)
{
// Auto-approve since valid session exists
return await HandleApproval(currentUser, client, existingSession);
}
// Show authorization page
var baseUrl = configuration["BaseUrl"];
AppName = client.Name; AppName = client.Name;
AppLogo = client.LogoUri; AppLogo = client.Picture is not null ? $"{baseUrl}/files/{client.Picture.Id}" : null;
AppUri = client.ClientUri; AppUri = config.ClientUri;
RequestedScopes = (Scope ?? "openid profile").Split(' ').Distinct().ToArray(); RequestedScopes = (Scope ?? "openid profile").Split(' ').Distinct().ToArray();
return Page(); return Page();
} }
private async Task<IActionResult> HandleApproval(Sphere.Account.Account currentUser, CustomApp client, Session? existingSession = null)
{
if (string.IsNullOrEmpty(RedirectUri))
{
ModelState.AddModelError("redirect_uri", "No redirect_uri provided");
return BadRequest("No redirect_uri provided");
}
string authCode;
if (existingSession != null)
{
// Reuse existing session
authCode = await oidcService.GenerateAuthorizationCodeForReuseSessionAsync(
session: existingSession,
clientId: ClientId,
redirectUri: RedirectUri,
scopes: Scope?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? [],
codeChallenge: CodeChallenge,
codeChallengeMethod: CodeChallengeMethod,
nonce: Nonce
);
}
else
{
// Create a new session (existing flow)
authCode = await oidcService.GenerateAuthorizationCodeAsync(
clientId: ClientId,
userId: currentUser.Id,
redirectUri: RedirectUri,
scopes: Scope?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? [],
codeChallenge: CodeChallenge,
codeChallengeMethod: CodeChallengeMethod,
nonce: Nonce
);
}
// Build the redirect URI with the authorization code
var redirectUriBuilder = new UriBuilder(RedirectUri);
var query = System.Web.HttpUtility.ParseQueryString(redirectUriBuilder.Query);
query["code"] = authCode;
if (!string.IsNullOrEmpty(State))
query["state"] = State;
if (!string.IsNullOrEmpty(Scope))
query["scope"] = Scope;
redirectUriBuilder.Query = query.ToString();
return Redirect(redirectUriBuilder.ToString());
}
public async Task<IActionResult> OnPostAsync(bool allow) public async Task<IActionResult> OnPostAsync(bool allow)
{ {
if (HttpContext.Items["CurrentUser"] is not Sphere.Account.Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Sphere.Account.Account currentUser) return Unauthorized();

View File

@ -5,10 +5,12 @@
Layout = "_Layout"; Layout = "_Layout";
} }
<div class="h-full flex items-center justify-center"> <div class="hero min-h-full bg-base-200">
<div class="max-w-lg w-full mx-auto p-6 text-center"> <div class="hero-content text-center">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Authentication Successful</h1> <div class="max-w-md">
<p class="mb-6 text-gray-900 dark:text-white">You can now close this window and return to the application.</p> <h1 class="text-5xl font-bold">Authentication Successful</h1>
<p class="py-6">You can now close this window and return to the application.</p>
</div>
</div> </div>
</div> </div>
@ -44,4 +46,4 @@
} }
})(); })();
</script> </script>
} }

View File

@ -1,13 +1,16 @@
@page "/web/auth/challenge/{id:guid}" @page "//auth/challenge/{id:guid}"
@model DysonNetwork.Sphere.Pages.Auth.ChallengeModel @model DysonNetwork.Sphere.Pages.Auth.ChallengeModel
@{ @{
// This page is kept for backward compatibility // This page is kept for backward compatibility
// It will automatically redirect to the new SelectFactor page // It will automatically redirect to the new SelectFactor page
Response.Redirect($"/web/auth/challenge/{Model.Id}/select-factor"); Response.Redirect($"//auth/challenge/{Model.Id}/select-factor");
} }
<div class="h-full flex items-center justify-center bg-gray-100 dark:bg-gray-900"> <div class="hero min-h-full bg-base-200">
<div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md w-full max-w-md text-center"> <div class="hero-content text-center">
<p>Redirecting to authentication page...</p> <div class="max-w-md">
<span class="loading loading-spinner loading-lg"></span>
<p class="py-6">Redirecting to authentication page...</p>
</div>
</div> </div>
</div> </div>

View File

@ -1,36 +1,36 @@
@page "/web/auth/login" @page "//auth/login"
@model DysonNetwork.Sphere.Pages.Auth.LoginModel @model DysonNetwork.Sphere.Pages.Auth.LoginModel
@{ @{
ViewData["Title"] = "Login"; ViewData["Title"] = "Login | Solar Network";
var returnUrl = Model.ReturnUrl ?? ""; var returnUrl = Model.ReturnUrl ?? "";
} }
<div class="h-full flex items-center justify-center bg-gray-100 dark:bg-gray-900"> <div class="hero min-h-full bg-base-200">
<div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md w-full max-w-md"> <div class="hero-content w-full max-w-md">
<h1 class="text-2xl font-bold text-center text-gray-900 dark:text-white mb-6">Login</h1> <div class="card w-full bg-base-100 shadow-xl">
<div class="card-body px-8 py-7">
<form method="post"> <h1 class="card-title justify-center text-2xl font-bold">Welcome back!</h1>
<input type="hidden" asp-for="ReturnUrl" value="@returnUrl" /> <p class="text-center">Login to your Solar Network account to continue.</p>
<div class="mb-4"> <form method="post" class="mt-4">
<label asp-for="Username" <input type="hidden" asp-for="ReturnUrl" value="@returnUrl"/>
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"></label> <div class="form-control">
<input asp-for="Username" <label class="label" asp-for="Username">
class="form-input mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-white px-4 py-2"/> <span class="label-text">Username</span>
<span asp-validation-for="Username" class="text-red-500 text-sm mt-1"></span> </label>
<input asp-for="Username" class="input input-bordered w-full"/>
<span asp-validation-for="Username" class="text-error text-sm mt-1"></span>
</div>
<div class="form-control mt-6">
<button type="submit" class="btn btn-primary w-full">Next</button>
</div>
<div class="text-sm text-center mt-4">
<span class="text-base-content/70">Have no account?</span> <br/>
<a href="https://solian.app/#/auth/create-account" class="link link-primary">
Create a new account →
</a>
</div>
</form>
</div> </div>
<button type="submit"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50">
Next
</button>
</form>
<div class="mt-8 flex flex-col text-sm text-center">
<span class="text-gray-900 dark:text-white opacity-80">Have no account?</span>
<a href="https://solian.app/#/auth/create-account"
class="text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300">
Create a new account →
</a>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,58 +1,104 @@
@page "/web/auth/challenge/{id:guid}/select-factor" @page "//auth/challenge/{id:guid}/select-factor"
@using DysonNetwork.Sphere.Account @using DysonNetwork.Sphere.Account
@model DysonNetwork.Sphere.Pages.Auth.SelectFactorModel @model DysonNetwork.Sphere.Pages.Auth.SelectFactorModel
@{ @{
ViewData["Title"] = "Select Authentication Method"; ViewData["Title"] = "Select Authentication Method | Solar Network";
} }
<div class="h-full flex items-center justify-center bg-gray-100 dark:bg-gray-900"> <div class="hero min-h-full bg-base-200">
<div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md w-full max-w-md"> <div class="hero-content w-full max-w-md">
<h1 class="text-2xl font-bold text-center text-gray-900 dark:text-white mb-6">Select Authentication Method</h1> <div class="card w-full bg-base-100 shadow-xl">
<div class="card-body">
<h1 class="card-title justify-center text-2xl font-bold">Select Authentication Method</h1>
@if (Model.AuthChallenge == null) @if (Model.AuthChallenge != null && Model.AuthChallenge.StepRemain > 0)
{
<p class="text-red-500 text-center">Challenge not found or expired.</p>
}
else if (Model.AuthChallenge.StepRemain == 0)
{
<p class="text-green-600 dark:text-green-400 text-center">Challenge completed. Redirecting...</p>
}
else
{
<p class="text-gray-700 dark:text-gray-300 mb-4">Please select an authentication method:</p>
<div class="space-y-4">
@foreach (var factor in Model.AuthFactors)
{ {
<div class="mb-4"> <div class="text-center mt-4">
<form method="post" asp-page-handler="SelectFactor" class="w-full" id="factor-@factor.Id"> <p class="text-sm text-info mb-2">Progress: @(Model.AuthChallenge.StepTotal - Model.AuthChallenge.StepRemain) of @Model.AuthChallenge.StepTotal steps completed</p>
<input type="hidden" name="SelectedFactorId" value="@factor.Id"/> <progress class="progress progress-info w-full" value="@(Model.AuthChallenge.StepTotal - Model.AuthChallenge.StepRemain)" max="@Model.AuthChallenge.StepTotal"></progress>
</div>
@if (factor.Type == AccountAuthFactorType.EmailCode) }
{
<div class="mb-3"> @if (Model.AuthChallenge == null)
<label for="hint-@factor.Id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> {
Email to send code to <div class="alert alert-error">
</label> <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
<input type="email" viewBox="0 0 24 24">
id="hint-@factor.Id" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
name="hint" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white" </svg>
placeholder="Enter your email" <span>Challenge not found or expired.</span>
required> </div>
</div> }
} else if (Model.AuthChallenge.StepRemain == 0)
{
<button type="submit" <div class="alert alert-success">
class="w-full text-left p-4 bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg transition-colors"> <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
<div class="font-medium text-gray-900 dark:text-white">@GetFactorDisplayName(factor.Type)</div> viewBox="0 0 24 24">
<div class="text-sm text-gray-500 dark:text-gray-400">@GetFactorDescription(factor.Type)</div> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
</button> d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</form> </svg>
<span>Challenge completed. Redirecting...</span>
</div>
}
else
{
<p class="text-center">Please select an authentication method:</p>
<div class="space-y-4">
@foreach (var factor in Model.AuthFactors)
{
<form method="post" asp-page-handler="SelectFactor" class="w-full" id="factor-@factor.Id">
<input type="hidden" name="SelectedFactorId" value="@factor.Id"/>
@if (factor.Type == AccountAuthFactorType.EmailCode)
{
<div class="card w-full bg-base-200 card-sm shadow-sm rounded-md">
<div class="py-4 px-5 align-items-center">
<div>
<h2 class="card-title">@GetFactorDisplayName(factor.Type)</h2>
<p>@GetFactorDescription(factor.Type)</p>
</div>
<div class="join w-full mt-2">
<div class="flex-1">
<label class="input join-item input-sm">
<input id="hint-@factor.Id" type="email"
placeholder="mail@site.com" required/>
</label>
</div>
<button class="btn btn-primary join-item btn-sm">
<span class="material-symbols-outlined">
arrow_right_alt
</span>
</button>
</div>
</div>
</div>
}
else
{
<div class="card w-full bg-base-200 card-sm shadow-sm rounded-md">
<div class="flex py-4 px-5 align-items-center">
<div class="flex-1">
<h2 class="card-title">@GetFactorDisplayName(factor.Type)</h2>
<p>@GetFactorDescription(factor.Type)</p>
</div>
<div class="justify-end card-actions">
<button type="submit" class="btn btn-primary btn-sm">
<span class="material-symbols-outlined">
arrow_right_alt
</span>
</button>
</div>
</div>
</div>
}
</form>
}
</div> </div>
} }
</div> </div>
} </div>
</div> </div>
</div> </div>
@ -78,4 +124,4 @@
_ => string.Empty _ => string.Empty
}; };
} }

View File

@ -1,77 +1,99 @@
@page "/web/auth/challenge/{id:guid}/verify/{factorId:guid}" @page "//auth/challenge/{id:guid}/verify/{factorId:guid}"
@using DysonNetwork.Sphere.Account @using DysonNetwork.Sphere.Account
@model DysonNetwork.Sphere.Pages.Auth.VerifyFactorModel @model DysonNetwork.Sphere.Pages.Auth.VerifyFactorModel
@{ @{
ViewData["Title"] = "Verify Your Identity"; ViewData["Title"] = "Verify Your Identity | Solar Network";
} }
<div class="h-full flex items-center justify-center bg-gray-100 dark:bg-gray-900"> <div class="hero min-h-full bg-base-200">
<div class="bg-white dark:bg-gray-800 px-8 pt-8 pb-4 rounded-lg shadow-md w-full max-w-md"> <div class="hero-content w-full max-w-md">
<h1 class="text-2xl font-bold text-center text-gray-900 dark:text-white mb-2">Verify Your Identity</h1> <div class="card w-full bg-base-100 shadow-xl">
<p class="text-center text-gray-600 dark:text-gray-300 mb-6"> <div class="card-body px-8 py-7">
@switch (Model.FactorType) <h1 class="card-title justify-center text-2xl font-bold">Verify Your Identity</h1>
{ <p class="text-center">
case AccountAuthFactorType.EmailCode: @switch (Model.FactorType)
<span>We've sent a verification code to your email.</span> {
break; case AccountAuthFactorType.EmailCode:
case AccountAuthFactorType.InAppCode: <span>We've sent a verification code to your email.</span>
<span>Enter the code from your authenticator app.</span> break;
break; case AccountAuthFactorType.InAppCode:
case AccountAuthFactorType.TimedCode: <span>Enter the code from your authenticator app.</span>
<span>Enter your time-based verification code.</span> break;
break; case AccountAuthFactorType.TimedCode:
case AccountAuthFactorType.PinCode: <span>Enter your time-based verification code.</span>
<span>Enter your PIN code.</span> break;
break; case AccountAuthFactorType.PinCode:
case AccountAuthFactorType.Password: <span>Enter your PIN code.</span>
<span>Enter your password.</span> break;
break; case AccountAuthFactorType.Password:
default: <span>Enter your password.</span>
<span>Please verify your identity.</span> break;
break; default:
} <span>Please verify your identity.</span>
</p> break;
}
</p>
@if (Model.AuthChallenge == null) @if (Model.AuthChallenge != null && Model.AuthChallenge.StepRemain > 0)
{ {
<p class="text-red-500 text-center">Challenge not found or expired.</p> <div class="text-center mt-4">
} <p class="text-sm text-info mb-2">Progress: @(Model.AuthChallenge.StepTotal - Model.AuthChallenge.StepRemain) of @Model.AuthChallenge.StepTotal steps completed</p>
else if (Model.AuthChallenge.StepRemain == 0) <progress class="progress progress-info w-full" value="@(Model.AuthChallenge.StepTotal - Model.AuthChallenge.StepRemain)" max="@Model.AuthChallenge.StepTotal"></progress>
{ </div>
<p class="text-green-600 dark:text-green-400 text-center">Verification successful. Redirecting...</p> }
}
else
{
<form method="post" class="space-y-4">
<div asp-validation-summary="ModelOnly" class="text-red-500 text-sm"></div>
<div class="mb-4">
<label asp-for="Code" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
@(Model.FactorType == AccountAuthFactorType.Password ? "Use your password" : "Verification Code")
</label>
<input asp-for="Code"
class="form-input mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-white px-4 py-2"
autocomplete="one-time-code"
type="password"
autofocus />
<span asp-validation-for="Code" class="text-red-500 text-sm mt-1"></span>
</div>
<button type="submit" @if (Model.AuthChallenge == null)
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"> {
Verify <div class="alert alert-error">
</button> <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>Challenge not found or expired.</span>
</div>
}
else if (Model.AuthChallenge.StepRemain == 0)
{
<div class="alert alert-success">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>Verification successful. Redirecting...</span>
</div>
}
else
{
<form method="post" class="space-y-4">
@if (!ViewData.ModelState.IsValid && ViewData.ModelState.Any(m => m.Value.Errors.Any()))
{
<div role="alert" class="alert alert-error mb-4">
<span>@Html.ValidationSummary(true)</span>
</div>
}
<div class="form-control">
<label asp-for="Code" class="label">
<span class="label-text">@(Model.FactorType == AccountAuthFactorType.Password ? "Use your password" : "Verification Code")</span>
</label>
<input asp-for="Code"
class="input input-bordered w-full"
autocomplete="one-time-code"
type="password"
autofocus />
<span asp-validation-for="Code" class="text-error text-sm mt-1"></span>
</div>
<div class="text-center mt-4"> <div class="form-control mt-6">
<a asp-page="SelectFactor" asp-route-id="@Model.Id" class="text-sm text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"> <button type="submit" class="btn btn-primary w-full">Verify</button>
← Back to authentication methods </div>
</a>
</div> <div class="text-center mt-4">
</form> <a asp-page="SelectFactor" asp-route-id="@Model.Id" class="link link-primary text-sm">
} ← Back to authentication methods
</a>
</div>
</form>
}
</div>
</div>
</div> </div>
</div> </div>
@section Scripts { @section Scripts {
@{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); } @{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); }
} }

View File

@ -32,16 +32,24 @@ namespace DysonNetwork.Sphere.Pages.Auth
public async Task<IActionResult> OnGetAsync() public async Task<IActionResult> OnGetAsync()
{ {
if (!string.IsNullOrEmpty(ReturnUrl))
{
TempData["ReturnUrl"] = ReturnUrl;
}
await LoadChallengeAndFactor(); await LoadChallengeAndFactor();
if (AuthChallenge == null) return NotFound("Challenge not found or expired."); if (AuthChallenge == null) return NotFound("Challenge not found or expired.");
if (Factor == null) return NotFound("Authentication method not found."); if (Factor == null) return NotFound("Authentication method not found.");
if (AuthChallenge.StepRemain == 0) return await ExchangeTokenAndRedirect(); if (AuthChallenge.StepRemain == 0) return await ExchangeTokenAndRedirect(AuthChallenge);
return Page(); return Page();
} }
public async Task<IActionResult> OnPostAsync() public async Task<IActionResult> OnPostAsync()
{ {
if (!string.IsNullOrEmpty(ReturnUrl))
{
TempData["ReturnUrl"] = ReturnUrl;
}
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
await LoadChallengeAndFactor(); await LoadChallengeAndFactor();
@ -52,6 +60,12 @@ namespace DysonNetwork.Sphere.Pages.Auth
if (AuthChallenge == null) return NotFound("Challenge not found or expired."); if (AuthChallenge == null) return NotFound("Challenge not found or expired.");
if (Factor == null) return NotFound("Authentication method not found."); if (Factor == null) return NotFound("Authentication method not found.");
if (AuthChallenge.BlacklistFactors.Contains(Factor.Id))
{
ModelState.AddModelError(string.Empty, "This authentication method has already been used for this challenge.");
return Page();
}
try try
{ {
if (await accounts.VerifyFactorCode(Factor, Code)) if (await accounts.VerifyFactorCode(Factor, Code))
@ -79,7 +93,7 @@ namespace DysonNetwork.Sphere.Pages.Auth
{ "account_id", AuthChallenge.AccountId } { "account_id", AuthChallenge.AccountId }
}, Request, AuthChallenge.Account); }, Request, AuthChallenge.Account);
return await ExchangeTokenAndRedirect(); return await ExchangeTokenAndRedirect(AuthChallenge);
} }
else else
@ -131,14 +145,10 @@ namespace DysonNetwork.Sphere.Pages.Auth
} }
} }
private async Task<IActionResult> ExchangeTokenAndRedirect() private async Task<IActionResult> ExchangeTokenAndRedirect(Challenge challenge)
{ {
var challenge = await db.AuthChallenges await db.Entry(challenge).ReloadAsync();
.Include(e => e.Account) if (challenge.StepRemain != 0) return BadRequest($"Challenge not yet completed. Remaining steps: {challenge.StepRemain}");
.FirstOrDefaultAsync(e => e.Id == Id);
if (challenge == null) return BadRequest("Authorization code not found or expired.");
if (challenge.StepRemain != 0) return BadRequest("Challenge not yet completed.");
var session = await db.AuthSessions var session = await db.AuthSessions
.FirstOrDefaultAsync(e => e.ChallengeId == challenge.Id); .FirstOrDefaultAsync(e => e.ChallengeId == challenge.Id);
@ -160,7 +170,7 @@ namespace DysonNetwork.Sphere.Pages.Auth
Response.Cookies.Append(AuthConstants.CookieTokenName, token, new CookieOptions Response.Cookies.Append(AuthConstants.CookieTokenName, token, new CookieOptions
{ {
HttpOnly = true, HttpOnly = true,
Secure = !configuration.GetValue<bool>("Debug"), Secure = Request.IsHttps,
SameSite = SameSiteMode.Strict, SameSite = SameSiteMode.Strict,
Path = "/" Path = "/"
}); });

View File

@ -38,73 +38,73 @@
</script> </script>
} }
<div class="h-full flex items-center justify-center"> <div class="hero min-h-full bg-base-200">
<div class="max-w-lg w-full mx-auto p-6"> <div class="hero-content text-center">
<div class="text-center"> <div class="max-w-md">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Security Check</h1> <div class="card bg-base-100 shadow-xl">
<p class="mb-6 text-gray-900 dark:text-white">Please complete the contest below to confirm you're not a robot</p> <div class="card-body">
<h1 class="card-title">Security Check</h1>
<p>Please complete the contest below to confirm you're not a robot</p>
<div class="flex justify-center mb-8"> <div class="flex justify-center my-8">
@switch (provider) @switch (provider)
{ {
case "cloudflare": case "cloudflare":
<div class="cf-turnstile" <div class="cf-turnstile"
data-sitekey="@apiKey" data-sitekey="@apiKey"
data-callback="onSuccess"> data-callback="onSuccess">
</div>
break;
case "recaptcha":
<div class="g-recaptcha"
data-sitekey="@apiKey"
data-callback="onSuccess">
</div>
break;
case "hcaptcha":
<div class="h-captcha"
data-sitekey="@apiKey"
data-callback="onSuccess">
</div>
break;
default:
<div class="alert alert-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>Captcha provider not configured correctly.</span>
</div>
break;
}
</div>
<div class="text-center text-sm">
<div class="font-semibold mb-1">Solar Network Anti-Robot</div>
<div class="text-base-content/70">
Powered by
@switch (provider)
{
case "cloudflare":
<a href="https://www.cloudflare.com/turnstile/" class="link link-hover">
Cloudflare Turnstile
</a>
break;
case "recaptcha":
<a href="https://www.google.com/recaptcha/" class="link link-hover">
Google reCaptcha
</a>
break;
default:
<span>Nothing</span>
break;
}
<br/>
Hosted by
<a href="https://github.com/Solsynth/DysonNetwork" class="link link-hover">
DysonNetwork.Sphere
</a>
</div> </div>
break; </div>
case "recaptcha": </div>
<div class="g-recaptcha"
data-sitekey="@apiKey"
data-callback="onSuccess">
</div>
break;
case "hcaptcha":
<div class="h-captcha"
data-sitekey="@apiKey"
data-callback="onSuccess">
</div>
break;
default:
<div class="p-4 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
<p class="text-yellow-800 dark:text-yellow-200">
Captcha provider not configured correctly.
</p>
</div>
break;
}
</div>
</div>
<div class="mt-8 text-center text-sm">
<div class="font-semibold text-gray-700 dark:text-gray-300 mb-1">Solar Network Anti-Robot</div>
<div class="text-gray-600 dark:text-gray-400">
Powered by
@switch (provider)
{
case "cloudflare":
<a href="https://www.cloudflare.com/turnstile/"
class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
Cloudflare Turnstile
</a>
break;
case "recaptcha":
<a href="https://www.google.com/recaptcha/"
class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
Google reCaptcha
</a>
break;
default:
<span>Nothing</span>
break;
}
<br/>
Hosted by
<a href="https://github.com/Solsynth/DysonNetwork"
class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
DysonNetwork.Sphere
</a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,29 +1,23 @@
@page @page
@model IndexModel @model IndexModel
@{ @{
ViewData["Title"] = "The Solar Network"; ViewData["Title"] = "The Solar Network | Solar Network";
} }
<div class="container-default h-full text-center flex flex-col justify-center items-center"> <div class="hero min-h-full bg-base-200">
<div class="mx-auto max-w-2xl"> <div class="hero-content text-center">
<h1 class="text-4xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-6xl"> <div class="max-w-md">
Solar Network <h1 class="text-5xl font-bold">Solar Network</h1>
</h1> <p class="py-6">This Solar Network instance is up and running.</p>
<p class="mt-6 text-lg leading-8 text-gray-600 dark:text-gray-300"> <a href="https://sn.solsynth.dev" target="_blank" class="btn btn-primary">Get started</a>
This Solar Network instance is up and running. <div class="flex items-center justify-center gap-x-6 mt-6">
</p> <a href="/swagger" target="_blank" class="btn btn-ghost">
<div class="mt-10 flex items-center justify-center gap-x-6"> <span aria-hidden="true">λ&nbsp;</span> API Docs
<a href="https://sn.solsynth.dev" target="_blank" class="btn-primary"> </a>
Get started <a href="https://kb.solsynth.dev" target="_blank" class="btn btn-ghost">
</a> Learn more <span aria-hidden="true">→</span>
</div> </a>
<div class="flex items-center justify-center gap-x-6 mt-6"> </div>
<a href="/swagger" target="_blank" class="btn-text">
<span aria-hidden="true">λ&nbsp;</span> API Docs
</a>
<a href="https://kb.solsynth.dev" target="_blank" class="btn-text">
Learn more <span aria-hidden="true">→</span>
</a>
</div>
</div> </div>
</div>
</div> </div>

View File

@ -6,6 +6,5 @@ public class IndexModel : PageModel
{ {
public void OnGet() public void OnGet()
{ {
// Add any page initialization logic here
} }
} }

View File

@ -0,0 +1,67 @@
@page "/posts/{PostId:guid}"
@model DysonNetwork.Sphere.Pages.Posts.PostDetailModel
@using Markdig
@{
ViewData["Title"] = Model.Post?.Title + " | Solar Network";
var imageUrl = Model.Post?.Attachments?.FirstOrDefault(a => a.MimeType.StartsWith("image/"))?.Id;
}
@section Head {
<meta property="og:title" content="@Model.Post?.Title" />
<meta property="og:type" content="article" />
@if (imageUrl != null)
{
<meta property="og:image" content="/api/files/@imageUrl" />
}
<meta property="og:url" content="@Request.Scheme://@Request.Host@Request.Path" />
<meta property="og:description" content="@Model.Post?.Description" />
}
<div class="container mx-auto p-4">
@if (Model.Post != null)
{
<h1 class="text-3xl font-bold mb-4">@Model.Post.Title</h1>
<p class="text-gray-600 mb-2">
Created at: @Model.Post.CreatedAt
@if (Model.Post.Publisher?.Account != null)
{
<span>by <a href="#" class="text-blue-500">@@@Model.Post.Publisher.Name</a></span>
}
</p>
<div class="prose lg:prose-xl mb-4">
@Html.Raw(Markdown.ToHtml(Model.Post.Content ?? string.Empty))
</div>
@if (Model.Post.Attachments != null && Model.Post.Attachments.Any())
{
<h2 class="text-2xl font-bold mb-2">Attachments</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@foreach (var attachment in Model.Post.Attachments)
{
<div class="border p-2 rounded-md">
@if (attachment.MimeType != null && attachment.MimeType.StartsWith("image/"))
{
<img src="/api/files/@attachment.Id" alt="@attachment.Name" class="w-full h-auto object-cover mb-2" />
}
else if (attachment.MimeType != null && attachment.MimeType.StartsWith("video/"))
{
<video controls class="w-full h-auto object-cover mb-2">
<source src="/api/files/@attachment.Id" type="@attachment.MimeType">
Your browser does not support the video tag.
</video>
}
<a href="/api/files/@attachment.Id" target="_blank" class="text-blue-500 hover:underline">
@attachment.Name
</a>
</div>
}
</div>
}
}
else
{
<div class="alert alert-error">
<span>Post not found.</span>
</div>
}
</div>

View File

@ -0,0 +1,45 @@
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Publisher;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Pages.Posts;
public class PostDetailModel(
AppDatabase db,
PublisherService pub,
RelationshipService rels
) : PageModel
{
[BindProperty(SupportsGet = true)]
public Guid PostId { get; set; }
public Post.Post? Post { get; set; }
public async Task<IActionResult> OnGetAsync()
{
if (PostId == Guid.Empty)
return NotFound();
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Sphere.Account.Account;
var userFriends = currentUser is null ? [] : await rels.ListAccountFriends(currentUser);
var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(currentUser.Id);
Post = await db.Posts
.Where(e => e.Id == PostId)
.Include(e => e.Publisher)
.ThenInclude(p => p.Account)
.Include(e => e.Tags)
.Include(e => e.Categories)
.FilterWithVisibility(currentUser, userFriends, userPublishers)
.FirstOrDefaultAsync();
if (Post == null)
return NotFound();
return Page();
}
}

View File

@ -5,34 +5,54 @@
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>@ViewData["Title"]</title> <title>@ViewData["Title"]</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="~/css/styles.css" asp-append-version="true"/> <link rel="stylesheet" href="~/css/styles.css" asp-append-version="true"/>
<link
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200"
rel="stylesheet"
/>
@await RenderSectionAsync("Head", required: false)
</head> </head>
<body class="h-[calc(100dvh-118px)] mt-[64px] bg-white dark:bg-gray-900"> <body class="h-full bg-base-200">
<header class="bg-white dark:bg-gray-800 shadow-sm fixed left-0 right-0 top-0 z-50"> <header class="navbar bg-base-100/35 backdrop-blur-md shadow-xl fixed left-0 right-0 top-0 z-50 px-5">
<nav class="container-default"> <div class="flex-1">
<div class="flex justify-between h-16 items-center"> <a class="btn btn-ghost text-xl">Solar Network</a>
<div class="flex"> </div>
<a href="/" class="text-xl font-bold text-gray-900 dark:text-white">Solar Network</a> <div class="flex-none">
</div> <ul class="menu menu-horizontal menu-sm px-1">
<div class="flex items-center ml-auto"> @if (Context.Request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out _))
@if (Context.Request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out _)) {
{ <li class="tooltip tooltip-bottom" data-tip="Profile">
<a href="/web/account/profile" class="text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300 px-3 py-2 rounded-md text-sm font-medium">Profile</a> <a href="//account/profile">
<form method="post" asp-page="/Account/Profile" asp-page-handler="Logout" class="inline"> <span class="material-symbols-outlined">account_circle</span>
<button type="submit" class="text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300 px-3 py-2 rounded-md text-sm font-medium">Logout</button> </a>
</li>
<li class="tooltip tooltip-bottom" data-tip="Logout">
<form method="post" asp-page="/Account/Profile" asp-page-handler="Logout">
<button type="submit">
<span class="material-symbols-outlined">
logout
</span>
</button>
</form> </form>
} </li>
else }
{ else
<a href="/web/auth/login" class="text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300 px-3 py-2 rounded-md text-sm font-medium">Login</a> {
} <li class="tooltip tooltip-bottom" data-tip="Login">
</div> <a href="//auth/login"><span class="material-symbols-outlined">login</span></a>
</div> </li>
</nav> }
</ul>
</div>
</header> </header>
@* The header 64px *@ <main class="h-full pt-16">
<main class="h-full">
@RenderBody() @RenderBody()
</main> </main>

View File

@ -6,96 +6,86 @@
ViewData["Title"] = "Magic Spell"; ViewData["Title"] = "Magic Spell";
} }
<div class="h-full flex items-center justify-center"> <div class="hero min-h-full bg-base-200">
<div class="max-w-lg w-full mx-auto p-6"> <div class="hero-content text-center">
<div class="text-center"> <div class="max-w-md">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Magic Spell</h1> <h1 class="text-5xl font-bold mb-4">Magic Spell</h1>
@if (Model.IsSuccess) @if (Model.IsSuccess)
{ {
<div class="p-4 bg-green-100 dark:bg-green-900 rounded-lg mb-6"> <div class="alert alert-success">
<p class="text-green-800 dark:text-green-200">The spell was applied successfully!</p> <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<p class="text-green-800 dark:text-green-200 opacity-80">Now you can close this page.</p> <span>The spell was applied successfully!</span>
<p>Now you can close this page.</p>
</div> </div>
} }
else if (Model.CurrentSpell == null) else if (Model.CurrentSpell == null)
{ {
<div class="p-4 bg-yellow-100 dark:bg-yellow-900 rounded-lg"> <div class="alert alert-warning">
<p class="text-yellow-800 dark:text-yellow-200">The spell was expired or does not exist.</p> <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>The spell was expired or does not exist.</span>
</div> </div>
} }
else else
{ {
<div <div class="card bg-base-100 shadow-xl">
class="px-4 py-12 bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-lg rounded-lg mb-6"> <div class="card-body">
<div class="mb-2"> <h2 class="card-title">
<p> @System.Text.RegularExpressions.Regex.Replace(Model.CurrentSpell!.Type.ToString(), "([a-z])([A-Z])", "$1 $2")
<span class="font-medium">The spell is for </span> </h2>
<span <p>for @@ @Model.CurrentSpell.Account?.Name</p>
class="font-bold">@System.Text.RegularExpressions.Regex.Replace(Model.CurrentSpell!.Type.ToString(), "([a-z])([A-Z])", "$1 $2")</span> <div class="text-sm opacity-80">
</p> @if (Model.CurrentSpell.ExpiresAt.HasValue)
<p><span class="font-medium">for @@</span>@Model.CurrentSpell.Account?.Name</p> {
</div> <p>Available until @Model.CurrentSpell.ExpiresAt.Value.ToDateTimeUtc().ToString("g")</p>
<div class="text-sm opacity-80"> }
@if (Model.CurrentSpell.ExpiresAt.HasValue) @if (Model.CurrentSpell.AffectedAt.HasValue)
{ {
<p>Available until @Model.CurrentSpell.ExpiresAt.Value.ToDateTimeUtc().ToString("g")</p> <p>Available after @Model.CurrentSpell.AffectedAt.Value.ToDateTimeUtc().ToString("g")</p>
} }
@if (Model.CurrentSpell.AffectedAt.HasValue)
{
<p>Available after @Model.CurrentSpell.AffectedAt.Value.ToDateTimeUtc().ToString("g")</p>
}
</div>
<p class="text-sm opacity-80">Would you like to apply this spell?</p>
</div>
<form method="post" class="mt-4">
<input type="hidden" asp-for="CurrentSpell!.Id"/>
@if (Model.CurrentSpell?.Type == MagicSpellType.AuthPasswordReset)
{
<div class="mb-4">
<label
asp-for="NewPassword"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
New Password
</label>
<input type="password"
asp-for="NewPassword"
required
minlength="8"
style="padding: 0.5rem 1rem"
placeholder="Your new password"
class="w-full border-2 border-gray-300 dark:border-gray-600 rounded-lg
focus:ring-2 focus:ring-blue-400
dark:text-white bg-gray-100 dark:bg-gray-800"/>
</div> </div>
} <p class="text-sm opacity-80">Would you like to apply this spell?</p>
<form method="post" class="mt-4">
<input type="hidden" asp-for="CurrentSpell!.Id"/>
<button type="submit" @if (Model.CurrentSpell?.Type == MagicSpellType.AuthPasswordReset)
class="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors {
transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-400"> <div class="form-control w-full max-w-xs">
Apply <label class="label" asp-for="NewPassword">
</button> <span class="label-text">New Password</span>
</form> </label>
<input type="password"
asp-for="NewPassword"
required
minlength="8"
placeholder="Your new password"
class="input input-bordered w-full max-w-xs"/>
</div>
}
<div class="card-actions justify-end mt-4">
<button type="submit" class="btn btn-primary">Apply</button>
</div>
</form>
</div>
</div>
} }
</div>
<div class="mt-8 text-center text-sm">
<div class="mt-8 text-center text-sm"> <div class="font-semibold mb-1">Solar Network</div>
<div class="font-semibold text-gray-700 dark:text-gray-300 mb-1">Solar Network</div> <div class="text-base-content/70">
<div class="text-gray-600 dark:text-gray-400"> <a href="https://solsynth.dev" class="link link-hover">
<a href="https://solsynth.dev" class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors"> Solsynth LLC
Solsynth LLC </a>
</a> &copy; @DateTime.Now.Year
&copy; @DateTime.Now.Year <br/>
<br/> Powered by
Powered by <a href="https://github.com/Solsynth/DysonNetwork" class="link link-hover">
<a href="https://github.com/Solsynth/DysonNetwork" DysonNetwork.Sphere
class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors"> </a>
DysonNetwork.Sphere </div>
</a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Pages.Posts;
using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Publisher; using DysonNetwork.Sphere.Publisher;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage;
@ -8,24 +9,27 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using NpgsqlTypes;
namespace DysonNetwork.Sphere.Post; namespace DysonNetwork.Sphere.Post;
[ApiController] [ApiController]
[Route("/posts")] [Route("/api/posts")]
public class PostController( public class PostController(
AppDatabase db, AppDatabase db,
PostService ps, PostService ps,
PublisherService pub, PublisherService pub,
RelationshipService rels, RelationshipService rels,
IServiceScopeFactory factory,
ActionLogService als ActionLogService als
) )
: ControllerBase : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<ActionResult<List<Post>>> ListPosts([FromQuery] int offset = 0, [FromQuery] int take = 20, public async Task<ActionResult<List<Post>>> ListPosts(
[FromQuery(Name = "pub")] string? pubName = null) [FromQuery] int offset = 0,
[FromQuery] int take = 20,
[FromQuery(Name = "pub")] string? pubName = null
)
{ {
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account.Account; var currentUser = currentUserValue as Account.Account;
@ -62,6 +66,9 @@ public class PostController(
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
public async Task<ActionResult<Post>> GetPost(Guid id) public async Task<ActionResult<Post>> GetPost(Guid id)
{ {
if (HttpContext.Items["IsWebPage"] as bool? ?? true)
return RedirectToPage("/Posts/PostDetail", new { PostId = id });
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account.Account; var currentUser = currentUserValue as Account.Account;
var userFriends = currentUser is null ? [] : await rels.ListAccountFriends(currentUser); var userFriends = currentUser is null ? [] : await rels.ListAccountFriends(currentUser);
@ -83,6 +90,50 @@ public class PostController(
return Ok(post); return Ok(post);
} }
[HttpGet("search")]
public async Task<ActionResult<List<Post>>> SearchPosts(
[FromQuery] string query,
[FromQuery] int offset = 0,
[FromQuery] int take = 20,
[FromQuery] bool useVector = true
)
{
if (string.IsNullOrWhiteSpace(query))
return BadRequest("Search query cannot be empty");
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account.Account;
var userFriends = currentUser is null ? [] : await rels.ListAccountFriends(currentUser);
var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(currentUser.Id);
var queryable = db.Posts
.FilterWithVisibility(currentUser, userFriends, userPublishers, isListing: true)
.AsQueryable();
if (useVector)
queryable = queryable.Where(p => p.SearchVector.Matches(EF.Functions.ToTsQuery(query)));
else
queryable = queryable.Where(p => EF.Functions.ILike(p.Title, $"%{query}%") ||
EF.Functions.ILike(p.Description, $"%{query}%") ||
EF.Functions.ILike(p.Content, $"%{query}%")
);
var totalCount = await queryable.CountAsync();
var posts = await queryable
.Include(e => e.RepliedPost)
.Include(e => e.ForwardedPost)
.Include(e => e.Categories)
.Include(e => e.Tags)
.OrderByDescending(e => e.PublishedAt ?? e.CreatedAt)
.Skip(offset)
.Take(take)
.ToListAsync();
posts = await ps.LoadPostInfo(posts, currentUser, true);
Response.Headers["X-Total"] = totalCount.ToString();
return Ok(posts);
}
[HttpGet("{id:guid}/replies")] [HttpGet("{id:guid}/replies")]
public async Task<ActionResult<List<Post>>> ListReplies(Guid id, [FromQuery] int offset = 0, public async Task<ActionResult<List<Post>>> ListReplies(Guid id, [FromQuery] int offset = 0,
[FromQuery] int take = 20) [FromQuery] int take = 20)

View File

@ -35,6 +35,7 @@ public class Publisher : ModelBase, IIdentifiedResource
[JsonIgnore] public ICollection<Post.Post> Posts { get; set; } = new List<Post.Post>(); [JsonIgnore] public ICollection<Post.Post> Posts { get; set; } = new List<Post.Post>();
[JsonIgnore] public ICollection<PostCollection> Collections { get; set; } = new List<PostCollection>(); [JsonIgnore] public ICollection<PostCollection> Collections { get; set; } = new List<PostCollection>();
[JsonIgnore] public ICollection<PublisherMember> Members { get; set; } = new List<PublisherMember>(); [JsonIgnore] public ICollection<PublisherMember> Members { get; set; } = new List<PublisherMember>();
[JsonIgnore] public ICollection<PublisherFeature> Features { get; set; } = new List<PublisherFeature>();
[JsonIgnore] [JsonIgnore]
public ICollection<PublisherSubscription> Subscriptions { get; set; } = new List<PublisherSubscription>(); public ICollection<PublisherSubscription> Subscriptions { get; set; } = new List<PublisherSubscription>();
@ -98,6 +99,6 @@ public class PublisherFeature : ModelBase
public abstract class PublisherFeatureFlag public abstract class PublisherFeatureFlag
{ {
public static List<string> AllFlags => [Developer]; public static List<string> AllFlags => [Develop];
public static string Developer = "develop"; public static string Develop = "develop";
} }

View File

@ -11,7 +11,7 @@ using NodaTime;
namespace DysonNetwork.Sphere.Publisher; namespace DysonNetwork.Sphere.Publisher;
[ApiController] [ApiController]
[Route("/publishers")] [Route("/api/publishers")]
public class PublisherController( public class PublisherController(
AppDatabase db, AppDatabase db,
PublisherService ps, PublisherService ps,

View File

@ -8,6 +8,13 @@ namespace DysonNetwork.Sphere.Publisher;
public class PublisherService(AppDatabase db, FileReferenceService fileRefService, ICacheService cache) public class PublisherService(AppDatabase db, FileReferenceService fileRefService, ICacheService cache)
{ {
public async Task<Publisher?> GetPublisherByName(string name)
{
return await db.Publishers
.Where(e => e.Name == name)
.FirstOrDefaultAsync();
}
private const string UserPublishersCacheKey = "accounts:{0}:publishers"; private const string UserPublishersCacheKey = "accounts:{0}:publishers";
public async Task<List<Publisher>> GetUserPublishers(Guid userId) public async Task<List<Publisher>> GetUserPublishers(Guid userId)
@ -336,7 +343,7 @@ public class PublisherService(AppDatabase db, FileReferenceService fileRefServic
f.PublisherId == publisherId && f.Flag == flag && f.PublisherId == publisherId && f.Flag == flag &&
(f.ExpiredAt == null || f.ExpiredAt > now) (f.ExpiredAt == null || f.ExpiredAt > now)
); );
if (featureFlag is not null) isEnabled = true; isEnabled = featureFlag is not null;
await cache.SetAsync(cacheKey, isEnabled!.Value, TimeSpan.FromMinutes(5)); await cache.SetAsync(cacheKey, isEnabled!.Value, TimeSpan.FromMinutes(5));
return isEnabled.Value; return isEnabled.Value;

View File

@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Publisher; namespace DysonNetwork.Sphere.Publisher;
[ApiController] [ApiController]
[Route("/publishers")] [Route("/api/publishers")]
public class PublisherSubscriptionController( public class PublisherSubscriptionController(
PublisherSubscriptionService subs, PublisherSubscriptionService subs,
AppDatabase db, AppDatabase db,

View File

@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Realm; namespace DysonNetwork.Sphere.Realm;
[ApiController] [ApiController]
[Route("/realms/{slug}")] [Route("/api/realms/{slug}")]
public class RealmChatController(AppDatabase db, RealmService rs) : ControllerBase public class RealmChatController(AppDatabase db, RealmService rs) : ControllerBase
{ {
[HttpGet("chat")] [HttpGet("chat")]

View File

@ -9,7 +9,7 @@ using NodaTime;
namespace DysonNetwork.Sphere.Realm; namespace DysonNetwork.Sphere.Realm;
[ApiController] [ApiController]
[Route("/realms")] [Route("/api/realms")]
public class RealmController( public class RealmController(
AppDatabase db, AppDatabase db,
RealmService rs, RealmService rs,

View File

@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Safety; namespace DysonNetwork.Sphere.Safety;
[ApiController] [ApiController]
[Route("/safety/reports")] [Route("/api/safety/reports")]
public class AbuseReportController( public class AbuseReportController(
SafetyService safety SafetyService safety
) : ControllerBase ) : ControllerBase
@ -54,13 +54,13 @@ public class AbuseReportController(
[RequiredPermission("safety", "reports.view")] [RequiredPermission("safety", "reports.view")]
[ProducesResponseType<List<AbuseReport>>(StatusCodes.Status200OK)] [ProducesResponseType<List<AbuseReport>>(StatusCodes.Status200OK)]
public async Task<ActionResult<List<AbuseReport>>> GetReports( public async Task<ActionResult<List<AbuseReport>>> GetReports(
[FromQuery] int skip = 0, [FromQuery] int offset = 0,
[FromQuery] int take = 20, [FromQuery] int take = 20,
[FromQuery] bool includeResolved = false [FromQuery] bool includeResolved = false
) )
{ {
var totalCount = await safety.CountReports(includeResolved); var totalCount = await safety.CountReports(includeResolved);
var reports = await safety.GetReports(skip, take, includeResolved); var reports = await safety.GetReports(offset, take, includeResolved);
Response.Headers["X-Total"] = totalCount.ToString(); Response.Headers["X-Total"] = totalCount.ToString();
return Ok(reports); return Ok(reports);
} }
@ -70,14 +70,15 @@ public class AbuseReportController(
[ProducesResponseType<List<AbuseReport>>(StatusCodes.Status200OK)] [ProducesResponseType<List<AbuseReport>>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<List<AbuseReport>>> GetMyReports( public async Task<ActionResult<List<AbuseReport>>> GetMyReports(
[FromQuery] int skip = 0, [FromQuery] int offset = 0,
[FromQuery] int take = 20, [FromQuery] int take = 20,
[FromQuery] bool includeResolved = false) [FromQuery] bool includeResolved = false
)
{ {
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var totalCount = await safety.CountUserReports(currentUser.Id, includeResolved); var totalCount = await safety.CountUserReports(currentUser.Id, includeResolved);
var reports = await safety.GetUserReports(currentUser.Id, skip, take, includeResolved); var reports = await safety.GetUserReports(currentUser.Id, offset, take, includeResolved);
Response.Headers["X-Total"] = totalCount.ToString(); Response.Headers["X-Total"] = totalCount.ToString();
return Ok(reports); return Ok(reports);
} }

View File

@ -1,4 +1,5 @@
using System.Net; using System.Net;
using DysonNetwork.Sphere.Connection;
using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
@ -14,6 +15,7 @@ public static class ApplicationConfiguration
{ {
app.MapMetrics(); app.MapMetrics();
app.MapOpenApi(); app.MapOpenApi();
app.UseMiddleware<ClientTypeMiddleware>();
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI();
@ -34,6 +36,7 @@ public static class ApplicationConfiguration
app.UseWebSockets(); app.UseWebSockets();
app.UseRateLimiter(); app.UseRateLimiter();
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseMiddleware<PermissionMiddleware>(); app.UseMiddleware<PermissionMiddleware>();

View File

@ -9,7 +9,7 @@ using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Sticker; namespace DysonNetwork.Sphere.Sticker;
[ApiController] [ApiController]
[Route("/stickers")] [Route("/api/stickers")]
public class StickerController(AppDatabase db, StickerService st) : ControllerBase public class StickerController(AppDatabase db, StickerService st) : ControllerBase
{ {
private async Task<IActionResult> _CheckStickerPackPermissions(Guid packId, Account.Account currentUser, private async Task<IActionResult> _CheckStickerPackPermissions(Guid packId, Account.Account currentUser,

View File

@ -7,7 +7,7 @@ using Minio.DataModel.Args;
namespace DysonNetwork.Sphere.Storage; namespace DysonNetwork.Sphere.Storage;
[ApiController] [ApiController]
[Route("/files")] [Route("/api/files")]
public class FileController( public class FileController(
AppDatabase db, AppDatabase db,
FileService fs, FileService fs,

View File

@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Wallet; namespace DysonNetwork.Sphere.Wallet;
[ApiController] [ApiController]
[Route("/orders")] [Route("/api/orders")]
public class OrderController(PaymentService payment, AuthService auth, AppDatabase db) : ControllerBase public class OrderController(PaymentService payment, AuthService auth, AppDatabase db) : ControllerBase
{ {
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]

View File

@ -2,6 +2,7 @@ using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Wallet.PaymentHandlers; namespace DysonNetwork.Sphere.Wallet.PaymentHandlers;
@ -51,7 +52,7 @@ public class AfdianPaymentHandler(
var sign = CalculateSign(token, userId, paramsJson, ts); var sign = CalculateSign(token, userId, paramsJson, ts);
var client = _httpClientFactory.CreateClient(); var client = _httpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/open/query-order") var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/open/query-order")
{ {
Content = new StringContent(JsonSerializer.Serialize(new Content = new StringContent(JsonSerializer.Serialize(new
{ {
@ -107,7 +108,7 @@ public class AfdianPaymentHandler(
var sign = CalculateSign(token, userId, paramsJson, ts); var sign = CalculateSign(token, userId, paramsJson, ts);
var client = _httpClientFactory.CreateClient(); var client = _httpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/open/query-order") var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/open/query-order")
{ {
Content = new StringContent(JsonSerializer.Serialize(new Content = new StringContent(JsonSerializer.Serialize(new
{ {
@ -176,7 +177,7 @@ public class AfdianPaymentHandler(
var sign = CalculateSign(token, userId, paramsJson, ts); var sign = CalculateSign(token, userId, paramsJson, ts);
var client = _httpClientFactory.CreateClient(); var client = _httpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/open/query-order") var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/open/query-order")
{ {
Content = new StringContent(JsonSerializer.Serialize(new Content = new StringContent(JsonSerializer.Serialize(new
{ {
@ -442,4 +443,4 @@ public class SkuDetailItem
[JsonPropertyName("album_id")] public string AlbumId { get; set; } = null!; [JsonPropertyName("album_id")] public string AlbumId { get; set; } = null!;
[JsonPropertyName("pic")] public string Picture { get; set; } = null!; [JsonPropertyName("pic")] public string Picture { get; set; } = null!;
} }

View File

@ -7,16 +7,43 @@ namespace DysonNetwork.Sphere.Wallet;
public record class SubscriptionTypeData( public record class SubscriptionTypeData(
string Identifier, string Identifier,
decimal BasePrice string? GroupIdentifier,
string Currency,
decimal BasePrice,
int? RequiredLevel = null
) )
{ {
public static readonly Dictionary<string, SubscriptionTypeData> SubscriptionDict = public static readonly Dictionary<string, SubscriptionTypeData> SubscriptionDict =
new() new()
{ {
[SubscriptionType.Twinkle] = new SubscriptionTypeData(SubscriptionType.Twinkle, 0), [SubscriptionType.Twinkle] = new SubscriptionTypeData(
[SubscriptionType.Stellar] = new SubscriptionTypeData(SubscriptionType.Stellar, 10), SubscriptionType.Twinkle,
[SubscriptionType.Nova] = new SubscriptionTypeData(SubscriptionType.Nova, 20), SubscriptionType.StellarProgram,
[SubscriptionType.Supernova] = new SubscriptionTypeData(SubscriptionType.Supernova, 30) WalletCurrency.SourcePoint,
0,
1
),
[SubscriptionType.Stellar] = new SubscriptionTypeData(
SubscriptionType.Stellar,
SubscriptionType.StellarProgram,
WalletCurrency.SourcePoint,
1200,
3
),
[SubscriptionType.Nova] = new SubscriptionTypeData(
SubscriptionType.Nova,
SubscriptionType.StellarProgram,
WalletCurrency.SourcePoint,
2400,
6
),
[SubscriptionType.Supernova] = new SubscriptionTypeData(
SubscriptionType.Supernova,
SubscriptionType.StellarProgram,
WalletCurrency.SourcePoint,
3600,
9
)
}; };
public static readonly Dictionary<string, string> SubscriptionHumanReadable = public static readonly Dictionary<string, string> SubscriptionHumanReadable =
@ -65,7 +92,7 @@ public abstract class SubscriptionPaymentMethod
public enum SubscriptionStatus public enum SubscriptionStatus
{ {
Unpaid, Unpaid,
Paid, Active,
Expired, Expired,
Cancelled Cancelled
} }
@ -125,7 +152,7 @@ public class Subscription : ModelBase
if (BegunAt > now) return false; if (BegunAt > now) return false;
if (EndedAt.HasValue && now > EndedAt.Value) return false; if (EndedAt.HasValue && now > EndedAt.Value) return false;
if (RenewalAt.HasValue && now > RenewalAt.Value) return false; if (RenewalAt.HasValue && now > RenewalAt.Value) return false;
if (Status != SubscriptionStatus.Paid) return false; if (Status != SubscriptionStatus.Active) return false;
return true; return true;
} }

View File

@ -8,7 +8,7 @@ using DysonNetwork.Sphere.Wallet.PaymentHandlers;
namespace DysonNetwork.Sphere.Wallet; namespace DysonNetwork.Sphere.Wallet;
[ApiController] [ApiController]
[Route("/subscriptions")] [Route("/api/subscriptions")]
public class SubscriptionController(SubscriptionService subscriptions, AfdianPaymentHandler afdian, AppDatabase db) : ControllerBase public class SubscriptionController(SubscriptionService subscriptions, AfdianPaymentHandler afdian, AppDatabase db) : ControllerBase
{ {
[HttpGet] [HttpGet]
@ -180,11 +180,12 @@ public class SubscriptionController(SubscriptionService subscriptions, AfdianPay
} }
[HttpPost("order/restore/afdian")] [HttpPost("order/restore/afdian")]
[Authorize]
public async Task<IActionResult> RestorePurchaseFromAfdian([FromBody] RestorePurchaseRequest request) public async Task<IActionResult> RestorePurchaseFromAfdian([FromBody] RestorePurchaseRequest request)
{ {
var order = await afdian.GetOrderAsync(request.OrderId); var order = await afdian.GetOrderAsync(request.OrderId);
if (order is null) return NotFound($"Order with ID {request.OrderId} was not found."); if (order is null) return NotFound($"Order with ID {request.OrderId} was not found.");
var subscription = await subscriptions.CreateSubscriptionFromOrder(order); var subscription = await subscriptions.CreateSubscriptionFromOrder(order);
return Ok(subscription); return Ok(subscription);
} }
@ -200,4 +201,4 @@ public class SubscriptionController(SubscriptionService subscriptions, AfdianPay
return Ok(response); return Ok(response);
} }
} }

View File

@ -29,7 +29,7 @@ public class SubscriptionRenewalJob(
// Find subscriptions that need renewal (due for renewal and are still active) // Find subscriptions that need renewal (due for renewal and are still active)
var subscriptionsToRenew = await db.WalletSubscriptions var subscriptionsToRenew = await db.WalletSubscriptions
.Where(s => s.RenewalAt.HasValue && s.RenewalAt.Value <= now) // Due for renewal .Where(s => s.RenewalAt.HasValue && s.RenewalAt.Value <= now) // Due for renewal
.Where(s => s.Status == SubscriptionStatus.Paid) // Only paid subscriptions .Where(s => s.Status == SubscriptionStatus.Active) // Only paid subscriptions
.Where(s => s.IsActive) // Only active subscriptions .Where(s => s.IsActive) // Only active subscriptions
.Where(s => !s.IsFreeTrial) // Exclude free trials .Where(s => !s.IsFreeTrial) // Exclude free trials
.OrderBy(s => s.RenewalAt) // Process oldest first .OrderBy(s => s.RenewalAt) // Process oldest first
@ -49,6 +49,17 @@ public class SubscriptionRenewalJob(
"Processing renewal for subscription {SubscriptionId} (Identifier: {Identifier}) for account {AccountId}", "Processing renewal for subscription {SubscriptionId} (Identifier: {Identifier}) for account {AccountId}",
subscription.Id, subscription.Identifier, subscription.AccountId); subscription.Id, subscription.Identifier, subscription.AccountId);
if (subscription.RenewalAt is null)
{
logger.LogWarning(
"Subscription {SubscriptionId} (Identifier: {Identifier}) has no renewal date or has been cancelled.",
subscription.Id, subscription.Identifier);
subscription.Status = SubscriptionStatus.Cancelled;
db.WalletSubscriptions.Update(subscription);
await db.SaveChangesAsync();
continue;
}
// Calculate next cycle duration based on current cycle // Calculate next cycle duration based on current cycle
var currentCycle = subscription.EndedAt!.Value - subscription.BegunAt; var currentCycle = subscription.EndedAt!.Value - subscription.BegunAt;

View File

@ -32,21 +32,39 @@ public class SubscriptionService(
bool noop = false bool noop = false
) )
{ {
var subscriptionTemplate = SubscriptionTypeData var subscriptionInfo = SubscriptionTypeData
.SubscriptionDict.TryGetValue(identifier, out var template) .SubscriptionDict.TryGetValue(identifier, out var template)
? template ? template
: null; : null;
if (subscriptionTemplate is null) if (subscriptionInfo is null)
throw new ArgumentOutOfRangeException(nameof(identifier), $@"Subscription {identifier} was not found."); throw new ArgumentOutOfRangeException(nameof(identifier), $@"Subscription {identifier} was not found.");
var subscriptionsInGroup = subscriptionInfo.GroupIdentifier is not null
? SubscriptionTypeData.SubscriptionDict
.Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier)
.Select(s => s.Value.Identifier)
.ToArray()
: [identifier];
cycleDuration ??= Duration.FromDays(30); cycleDuration ??= Duration.FromDays(30);
var existingSubscription = await GetSubscriptionAsync(account.Id, identifier); var existingSubscription = await GetSubscriptionAsync(account.Id, subscriptionsInGroup);
if (existingSubscription is not null && !noop) if (existingSubscription is not null && !noop)
throw new InvalidOperationException($"Active subscription with identifier {identifier} already exists."); throw new InvalidOperationException($"Active subscription with identifier {identifier} already exists.");
if (existingSubscription is not null) if (existingSubscription is not null)
return existingSubscription; return existingSubscription;
if (subscriptionInfo.RequiredLevel > 0)
{
var profile = await db.AccountProfiles
.Where(p => p.AccountId == account.Id)
.FirstOrDefaultAsync();
if (profile is null) throw new InvalidOperationException("Account profile was not found.");
if (profile.Level < subscriptionInfo.RequiredLevel)
throw new InvalidOperationException(
$"Account level must be at least {subscriptionInfo.RequiredLevel} to subscribe to {identifier}."
);
}
if (isFreeTrial) if (isFreeTrial)
{ {
var prevFreeTrial = await db.WalletSubscriptions var prevFreeTrial = await db.WalletSubscriptions
@ -77,7 +95,7 @@ public class SubscriptionService(
Status = SubscriptionStatus.Unpaid, Status = SubscriptionStatus.Unpaid,
PaymentMethod = paymentMethod, PaymentMethod = paymentMethod,
PaymentDetails = paymentDetails, PaymentDetails = paymentDetails,
BasePrice = subscriptionTemplate.BasePrice, BasePrice = subscriptionInfo.BasePrice,
CouponId = couponData?.Id, CouponId = couponData?.Id,
Coupon = couponData, Coupon = couponData,
RenewalAt = (isFreeTrial || !isAutoRenewal) ? null : now.Plus(cycleDuration.Value), RenewalAt = (isFreeTrial || !isAutoRenewal) ? null : now.Plus(cycleDuration.Value),
@ -140,7 +158,7 @@ public class SubscriptionService(
existingSubscription.PaymentDetails.OrderId = order.Id; existingSubscription.PaymentDetails.OrderId = order.Id;
existingSubscription.EndedAt = order.BegunAt.Plus(cycleDuration); existingSubscription.EndedAt = order.BegunAt.Plus(cycleDuration);
existingSubscription.RenewalAt = order.BegunAt.Plus(cycleDuration); existingSubscription.RenewalAt = order.BegunAt.Plus(cycleDuration);
existingSubscription.Status = SubscriptionStatus.Paid; existingSubscription.Status = SubscriptionStatus.Active;
db.Update(existingSubscription); db.Update(existingSubscription);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@ -153,7 +171,7 @@ public class SubscriptionService(
BegunAt = order.BegunAt, BegunAt = order.BegunAt,
EndedAt = order.BegunAt.Plus(cycleDuration), EndedAt = order.BegunAt.Plus(cycleDuration),
IsActive = true, IsActive = true,
Status = SubscriptionStatus.Paid, Status = SubscriptionStatus.Active,
Identifier = subscriptionIdentifier, Identifier = subscriptionIdentifier,
PaymentMethod = provider, PaymentMethod = provider,
PaymentDetails = new PaymentDetails PaymentDetails = new PaymentDetails
@ -186,10 +204,15 @@ public class SubscriptionService(
var subscription = await GetSubscriptionAsync(accountId, identifier); var subscription = await GetSubscriptionAsync(accountId, identifier);
if (subscription is null) if (subscription is null)
throw new InvalidOperationException($"Subscription with identifier {identifier} was not found."); throw new InvalidOperationException($"Subscription with identifier {identifier} was not found.");
if (subscription.Status == SubscriptionStatus.Cancelled) if (subscription.Status != SubscriptionStatus.Active)
throw new InvalidOperationException("Subscription is already cancelled."); throw new InvalidOperationException("Subscription is already cancelled.");
if (subscription.RenewalAt is null)
throw new InvalidOperationException("Subscription is no need to be cancelled.");
if (subscription.PaymentMethod != SubscriptionPaymentMethod.InAppWallet)
throw new InvalidOperationException(
"Only in-app wallet subscription can be cancelled. For other payment methods, please head to the payment provider."
);
subscription.Status = SubscriptionStatus.Cancelled;
subscription.RenewalAt = null; subscription.RenewalAt = null;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@ -221,9 +244,15 @@ public class SubscriptionService(
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (subscription is null) throw new InvalidOperationException("No matching subscription found."); if (subscription is null) throw new InvalidOperationException("No matching subscription found.");
var subscriptionInfo = SubscriptionTypeData.SubscriptionDict
.TryGetValue(subscription.Identifier, out var template)
? template
: null;
if (subscriptionInfo is null) throw new InvalidOperationException("No matching subscription found.");
return await payment.CreateOrderAsync( return await payment.CreateOrderAsync(
null, null,
WalletCurrency.GoldenPoint, subscriptionInfo.Currency,
subscription.FinalPrice, subscription.FinalPrice,
appIdentifier: SubscriptionOrderIdentifier, appIdentifier: SubscriptionOrderIdentifier,
meta: new Dictionary<string, object>() meta: new Dictionary<string, object>()
@ -264,7 +293,7 @@ public class SubscriptionService(
subscription.EndedAt = nextEndedAt; subscription.EndedAt = nextEndedAt;
} }
subscription.Status = SubscriptionStatus.Paid; subscription.Status = SubscriptionStatus.Active;
db.Update(subscription); db.Update(subscription);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@ -294,7 +323,7 @@ public class SubscriptionService(
// Find active subscriptions that have passed their end date // Find active subscriptions that have passed their end date
var expiredSubscriptions = await db.WalletSubscriptions var expiredSubscriptions = await db.WalletSubscriptions
.Where(s => s.IsActive) .Where(s => s.IsActive)
.Where(s => s.Status == SubscriptionStatus.Paid) .Where(s => s.Status == SubscriptionStatus.Active)
.Where(s => s.EndedAt.HasValue && s.EndedAt.Value < now) .Where(s => s.EndedAt.HasValue && s.EndedAt.Value < now)
.Take(batchSize) .Take(batchSize)
.ToListAsync(); .ToListAsync();
@ -319,7 +348,7 @@ public class SubscriptionService(
{ {
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == subscription.AccountId); var account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == subscription.AccountId);
if (account is null) return; if (account is null) return;
AccountService.SetCultureInfo(account); AccountService.SetCultureInfo(account);
var humanReadableName = var humanReadableName =
@ -345,10 +374,10 @@ public class SubscriptionService(
private const string SubscriptionCacheKeyPrefix = "subscription:"; private const string SubscriptionCacheKeyPrefix = "subscription:";
public async Task<Subscription?> GetSubscriptionAsync(Guid accountId, string identifier) public async Task<Subscription?> GetSubscriptionAsync(Guid accountId, params string[] identifiers)
{ {
// Create a unique cache key for this subscription // Create a unique cache key for this subscription
var cacheKey = $"{SubscriptionCacheKeyPrefix}{accountId}:{identifier}"; var cacheKey = $"{SubscriptionCacheKeyPrefix}{accountId}:{string.Join(",", identifiers)}";
// Try to get the subscription from cache first // Try to get the subscription from cache first
var (found, cachedSubscription) = await cache.GetAsyncWithStatus<Subscription>(cacheKey); var (found, cachedSubscription) = await cache.GetAsyncWithStatus<Subscription>(cacheKey);
@ -359,15 +388,13 @@ public class SubscriptionService(
// If not in cache, get from database // If not in cache, get from database
var subscription = await db.WalletSubscriptions var subscription = await db.WalletSubscriptions
.Where(s => s.AccountId == accountId && s.Identifier == identifier) .Where(s => s.AccountId == accountId && identifiers.Contains(s.Identifier))
.OrderByDescending(s => s.BegunAt) .OrderByDescending(s => s.BegunAt)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
// Cache the result if found (with 30 minutes expiry) // Cache the result if found (with 30 minutes expiry)
if (subscription != null) if (subscription != null)
{
await cache.SetAsync(cacheKey, subscription, TimeSpan.FromMinutes(30)); await cache.SetAsync(cacheKey, subscription, TimeSpan.FromMinutes(30));
}
return subscription; return subscription;
} }

View File

@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Wallet; namespace DysonNetwork.Sphere.Wallet;
[ApiController] [ApiController]
[Route("/wallets")] [Route("/api/wallets")]
public class WalletController(AppDatabase db, WalletService ws, PaymentService payment) : ControllerBase public class WalletController(AppDatabase db, WalletService ws, PaymentService payment) : ControllerBase
{ {
[HttpPost] [HttpPost]

View File

@ -6,7 +6,8 @@
"css:build": "npx @tailwindcss/cli -i ./wwwroot/css/site.css -o ./wwwroot/css/styles.css" "css:build": "npx @tailwindcss/cli -i ./wwwroot/css/site.css -o ./wwwroot/css/styles.css"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/cli": "^4.1.7",
"@tailwindcss/postcss": "^4.1.7", "@tailwindcss/postcss": "^4.1.7",
"@tailwindcss/cli": "^4.1.7" "daisyui": "^5.0.46"
} }
} }

View File

@ -1,10 +1,88 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "daisyui";
@layer theme, base, components, utilities; @layer theme, base, components, utilities;
@import "tailwindcss/theme.css" layer(theme); @import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/preflight.css" layer(base); @import "tailwindcss/preflight.css" layer(base);
@import "tailwindcss/utilities.css" layer(utilities); @import "tailwindcss/utilities.css" layer(utilities);
@theme {
--font-sans: "Nunito", sans-serif;
--font-mono: "Noto Sans Mono", monospace;
}
@plugin "daisyui/theme" {
name: "light";
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(100% 0 0);
--color-base-200: oklch(98% 0 0);
--color-base-300: oklch(95% 0 0);
--color-base-content: oklch(21% 0.006 285.885);
--color-primary: oklch(62% 0.0873 281deg);
--color-primary-content: oklch(93% 0.034 272.788);
--color-secondary: oklch(62% 0.214 259.815);
--color-secondary-content: oklch(94% 0.028 342.258);
--color-accent: oklch(77% 0.152 181.912);
--color-accent-content: oklch(38% 0.063 188.416);
--color-neutral: oklch(14% 0.005 285.823);
--color-neutral-content: oklch(92% 0.004 286.32);
--color-info: oklch(82% 0.111 230.318);
--color-info-content: oklch(29% 0.066 243.157);
--color-success: oklch(79% 0.209 151.711);
--color-success-content: oklch(37% 0.077 168.94);
--color-warning: oklch(82% 0.189 84.429);
--color-warning-content: oklch(41% 0.112 45.904);
--color-error: oklch(71% 0.194 13.428);
--color-error-content: oklch(27% 0.105 12.094);
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 1rem;
--size-selector: 0.28125rem;
--size-field: 0.28125rem;
--border: 1px;
--depth: 1;
--noise: 1;
}
@plugin "daisyui/theme" {
name: "dark";
default: false;
prefersdark: true;
color-scheme: "dark";
--color-base-100: oklch(0% 0 0);
--color-base-200: oklch(20% 0.016 285.938);
--color-base-300: oklch(25% 0.013 285.805);
--color-base-content: oklch(97.807% 0.029 256.847);
--color-primary: oklch(50% 0.0873 281deg);
--color-primary-content: oklch(96% 0.018 272.314);
--color-secondary: oklch(62% 0.214 259.815);
--color-secondary-content: oklch(94% 0.028 342.258);
--color-accent: oklch(77% 0.152 181.912);
--color-accent-content: oklch(38% 0.063 188.416);
--color-neutral: oklch(21% 0.006 285.885);
--color-neutral-content: oklch(92% 0.004 286.32);
--color-info: oklch(82% 0.111 230.318);
--color-info-content: oklch(29% 0.066 243.157);
--color-success: oklch(79% 0.209 151.711);
--color-success-content: oklch(37% 0.077 168.94);
--color-warning: oklch(82% 0.189 84.429);
--color-warning-content: oklch(41% 0.112 45.904);
--color-error: oklch(64% 0.246 16.439);
--color-error-content: oklch(27% 0.105 12.094);
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 1rem;
--size-selector: 0.28125rem;
--size-field: 0.28125rem;
--border: 1px;
--depth: 1;
--noise: 1;
}
@layer base { @layer base {
html, body { html, body {
padding: 0; padding: 0;
@ -12,6 +90,10 @@
box-sizing: border-box; box-sizing: border-box;
} }
.material-symbols-outlined {
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
}
/* For Firefox. */ /* For Firefox. */
* { * {
scrollbar-width: none; scrollbar-width: none;
@ -23,14 +105,6 @@
} }
} }
.btn-primary {
@apply px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors dark:bg-blue-600 dark:hover:bg-blue-700;
}
.btn-text {
@apply text-sm font-semibold leading-6 text-gray-900 dark:text-gray-100 hover:text-gray-700 dark:hover:text-gray-300;
}
.container-default { .container-default {
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8; @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
} }

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AClaim_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fa7fdc52b6e574ae7b9822133be91162a15800_003Ff7_003Feebffd8d_003FClaim_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AClaim_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fa7fdc52b6e574ae7b9822133be91162a15800_003Ff7_003Feebffd8d_003FClaim_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConnectionMultiplexer_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F2ed0e2f073b1d77b98dadb822da09ee8a9dfb91bf29bf2bbaecb8750d7e74cc9_003FConnectionMultiplexer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConnectionMultiplexer_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F2ed0e2f073b1d77b98dadb822da09ee8a9dfb91bf29bf2bbaecb8750d7e74cc9_003FConnectionMultiplexer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AControllerBase_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003Ff6_003Fdf150bb3_003FControllerBase_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AControllerBase_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003Ff6_003Fdf150bb3_003FControllerBase_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AController_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb320290c1b964c3e88434ff5505d9086c9a00_003Fdf_003F95b535f9_003FController_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACookieOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F663f33943e4c4e889dc7050c1e97e703e000_003F89_003Fb06980d7_003FCookieOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACookieOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F663f33943e4c4e889dc7050c1e97e703e000_003F89_003Fb06980d7_003FCookieOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACorsPolicyBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F051ad509d0504b7ca10dedd9c2cabb9914200_003F8e_003Fb28257cb_003FCorsPolicyBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACorsPolicyBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F051ad509d0504b7ca10dedd9c2cabb9914200_003F8e_003Fb28257cb_003FCorsPolicyBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADailyTimeIntervalScheduleBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F929ef51651404d13aacd3eb8198d2961e4800_003F2b_003Ff86eadcb_003FDailyTimeIntervalScheduleBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADailyTimeIntervalScheduleBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F929ef51651404d13aacd3eb8198d2961e4800_003F2b_003Ff86eadcb_003FDailyTimeIntervalScheduleBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
@ -70,6 +71,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APutObjectArgs_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F6efe388c7585d5dd5587416a55298550b030c2a107edf45f988791297c3ffa_003FPutObjectArgs_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APutObjectArgs_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F6efe388c7585d5dd5587416a55298550b030c2a107edf45f988791297c3ffa_003FPutObjectArgs_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AQueryable_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F42d8f09d6a294d00a6f49efc989927492fe00_003F4e_003F26d1ee34_003FQueryable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AQueryable_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F42d8f09d6a294d00a6f49efc989927492fe00_003F4e_003F26d1ee34_003FQueryable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AQueryable_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fcbafb95b4df34952928f87356db00c8f2fe00_003F9b_003F8ba036bb_003FQueryable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AQueryable_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fcbafb95b4df34952928f87356db00c8f2fe00_003F9b_003F8ba036bb_003FQueryable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARazorPage_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F81d2924a2bbd4b0c864a1d23cbf5f0893d200_003F5f_003Fc110be1c_003FRazorPage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AResizeOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fef3339e864a448e2b1ec6fa7bbf4c6661fee00_003F48_003F0209e410_003FResizeOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AResizeOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fef3339e864a448e2b1ec6fa7bbf4c6661fee00_003F48_003F0209e410_003FResizeOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AResourceManagerStringLocalizerFactory_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb62f365d06c44ad695ff75960cdf97a2a800_003Fe4_003Ff6ba93b7_003FResourceManagerStringLocalizerFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AResourceManagerStringLocalizerFactory_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb62f365d06c44ad695ff75960cdf97a2a800_003Fe4_003Ff6ba93b7_003FResourceManagerStringLocalizerFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARSA_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fee4f989f6b8042b59b2654fdc188e287243600_003F8b_003F44e5f855_003FRSA_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARSA_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fee4f989f6b8042b59b2654fdc188e287243600_003F8b_003F44e5f855_003FRSA_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
@ -83,6 +85,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003Fdf_003F3fcdc4d2_003FStackFrameIterator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003Fdf_003F3fcdc4d2_003FStackFrameIterator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStatusCodeResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003F7c_003F8b7572ae_003FStatusCodeResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStatusCodeResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003F7c_003F8b7572ae_003FStatusCodeResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASyndicationFeed_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F5b43b9cf654743f8b9a2eee23c625dd21dd30_003Fad_003Fd26b4d73_003FSyndicationFeed_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASyndicationFeed_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F5b43b9cf654743f8b9a2eee23c625dd21dd30_003Fad_003Fd26b4d73_003FSyndicationFeed_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASyndicationItem_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F5b43b9cf654743f8b9a2eee23c625dd21dd30_003Fe1_003Fb136d7be_003FSyndicationItem_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATagging_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F36f4c2e6baa65ba603de42eedad12ea36845aa35a910a6a82d82baf688e3e1_003FTagging_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATagging_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F36f4c2e6baa65ba603de42eedad12ea36845aa35a910a6a82d82baf688e3e1_003FTagging_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003F12_003Fe0a28ad6_003FThrowHelper_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003F12_003Fe0a28ad6_003FThrowHelper_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATotp_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F48c9d2a1b3c84b32b36ebc6f20a927ea4600_003F7b_003Ff98e5727_003FTotp_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATotp_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F48c9d2a1b3c84b32b36ebc6f20a927ea4600_003F7b_003Ff98e5727_003FTotp_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>