Compare commits
43 Commits
ee5d6ef821
...
master
Author | SHA1 | Date | |
---|---|---|---|
2a3918134f | |||
734e5ca4a0 | |||
ff0789904d | |||
17330fc104 | |||
7c0ad46deb | |||
b8fcd0d94f | |||
fc6edd7378 | |||
1f2cdb146d | |||
be236a27c6 | |||
99c36ae548 | |||
ed2961a5d5 | |||
08b5ffa02f | |||
837a123c3b | |||
ad1166190f | |||
8e8c938132 | |||
8e5b6ace45 | |||
5757526ea5 | |||
6a9cd0905d | |||
082a096470 | |||
3a72347432 | |||
19b1e957dd | |||
6449926334 | |||
fb885e138d | |||
5bdc21ebc5 | |||
f177377fe3 | |||
0df4864888 | |||
29b0ad184e | |||
ad730832db | |||
71fcc26534 | |||
fb8fc69920 | |||
05bf2cd055 | |||
ccb8a4e3f4 | |||
ca5be5a01c | |||
c4ea15097e | |||
cdeed3c318 | |||
a53fcb10dd | |||
c0879d30d4 | |||
0226bf8fa3 | |||
217b434cc4 | |||
f8295c6a18 | |||
d4fa08d320 | |||
8bd0ea0fa1 | |||
9ab31d79ce |
@ -11,7 +11,7 @@ using System.Collections.Generic;
|
||||
namespace DysonNetwork.Sphere.Account;
|
||||
|
||||
[ApiController]
|
||||
[Route("/accounts")]
|
||||
[Route("/api/accounts")]
|
||||
public class AccountController(
|
||||
AppDatabase db,
|
||||
AuthService auth,
|
||||
|
@ -12,7 +12,7 @@ namespace DysonNetwork.Sphere.Account;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("/accounts/me")]
|
||||
[Route("/api/accounts/me")]
|
||||
public class AccountCurrentController(
|
||||
AppDatabase db,
|
||||
AccountService accounts,
|
||||
|
@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace DysonNetwork.Sphere.Account;
|
||||
|
||||
[ApiController]
|
||||
[Route("/spells")]
|
||||
[Route("/api/spells")]
|
||||
public class MagicSpellController(AppDatabase db, MagicSpellService sp) : ControllerBase
|
||||
{
|
||||
[HttpPost("{spellId:guid}/resend")]
|
||||
|
@ -9,7 +9,7 @@ using NodaTime;
|
||||
namespace DysonNetwork.Sphere.Account;
|
||||
|
||||
[ApiController]
|
||||
[Route("/notifications")]
|
||||
[Route("/api/notifications")]
|
||||
public class NotificationController(AppDatabase db, NotificationService nty) : ControllerBase
|
||||
{
|
||||
[HttpGet("count")]
|
||||
|
@ -30,6 +30,9 @@ public class NotificationService(
|
||||
string deviceToken
|
||||
)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
// First check if a matching subscription exists
|
||||
var existingSubscription = await db.NotificationPushSubscriptions
|
||||
.Where(s => s.AccountId == account.Id)
|
||||
.Where(s => s.DeviceId == deviceId || s.DeviceToken == deviceToken)
|
||||
@ -37,11 +40,18 @@ public class NotificationService(
|
||||
|
||||
if (existingSubscription is not null)
|
||||
{
|
||||
// Reset these audit fields to renew the lifecycle of this device token
|
||||
// Update the existing subscription directly in the database
|
||||
await db.NotificationPushSubscriptions
|
||||
.Where(s => s.Id == existingSubscription.Id)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(s => s.DeviceId, deviceId)
|
||||
.SetProperty(s => s.DeviceToken, deviceToken)
|
||||
.SetProperty(s => s.UpdatedAt, now));
|
||||
|
||||
// Return the updated subscription
|
||||
existingSubscription.DeviceId = deviceId;
|
||||
existingSubscription.DeviceToken = deviceToken;
|
||||
db.Update(existingSubscription);
|
||||
await db.SaveChangesAsync();
|
||||
existingSubscription.UpdatedAt = now;
|
||||
return existingSubscription;
|
||||
}
|
||||
|
||||
|
@ -7,7 +7,7 @@ using NodaTime;
|
||||
namespace DysonNetwork.Sphere.Account;
|
||||
|
||||
[ApiController]
|
||||
[Route("/relationships")]
|
||||
[Route("/api/relationships")]
|
||||
public class RelationshipController(AppDatabase db, RelationshipService rels) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
@ -230,4 +230,24 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
|
||||
return BadRequest(err.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{userId:guid}/block")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Relationship>> UnblockUser(Guid userId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
var relatedUser = await db.Accounts.FindAsync(userId);
|
||||
if (relatedUser is null) return NotFound("Account was not found.");
|
||||
|
||||
try
|
||||
{
|
||||
var relationship = await rels.UnblockAccount(currentUser, relatedUser);
|
||||
return relationship;
|
||||
}
|
||||
catch (InvalidOperationException err)
|
||||
{
|
||||
return BadRequest(err.Message);
|
||||
}
|
||||
}
|
||||
}
|
@ -7,6 +7,7 @@ namespace DysonNetwork.Sphere.Account;
|
||||
public class RelationshipService(AppDatabase db, ICacheService cache)
|
||||
{
|
||||
private const string UserFriendsCacheKeyPrefix = "accounts:friends:";
|
||||
private const string UserBlockedCacheKeyPrefix = "accounts:blocked:";
|
||||
|
||||
public async Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId)
|
||||
{
|
||||
@ -50,9 +51,8 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
||||
|
||||
db.AccountRelationships.Add(relationship);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relationship.AccountId}");
|
||||
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relationship.RelatedId}");
|
||||
|
||||
await PurgeRelationshipCache(sender.Id, target.Id);
|
||||
|
||||
return relationship;
|
||||
}
|
||||
@ -63,6 +63,18 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
||||
return await UpdateRelationship(sender.Id, target.Id, RelationshipStatus.Blocked);
|
||||
return await CreateRelationship(sender, target, RelationshipStatus.Blocked);
|
||||
}
|
||||
|
||||
public async Task<Relationship> UnblockAccount(Account sender, Account target)
|
||||
{
|
||||
var relationship = await GetRelationship(sender.Id, target.Id, RelationshipStatus.Blocked);
|
||||
if (relationship is null) throw new ArgumentException("There is no relationship between you and the user.");
|
||||
db.Remove(relationship);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await PurgeRelationshipCache(sender.Id, target.Id);
|
||||
|
||||
return relationship;
|
||||
}
|
||||
|
||||
public async Task<Relationship> SendFriendRequest(Account sender, Account target)
|
||||
{
|
||||
@ -92,8 +104,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
||||
.Where(r => r.AccountId == accountId && r.RelatedId == relatedId && r.Status == RelationshipStatus.Pending)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}");
|
||||
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relatedId}");
|
||||
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
|
||||
}
|
||||
|
||||
public async Task<Relationship> AcceptFriendRelationship(
|
||||
@ -122,8 +133,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relationship.AccountId}");
|
||||
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relationship.RelatedId}");
|
||||
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
|
||||
|
||||
return relationshipBackward;
|
||||
}
|
||||
@ -137,15 +147,14 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
||||
db.Update(relationship);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}");
|
||||
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relatedId}");
|
||||
await PurgeRelationshipCache(accountId, relatedId);
|
||||
|
||||
return relationship;
|
||||
}
|
||||
|
||||
public async Task<List<Guid>> ListAccountFriends(Account account)
|
||||
{
|
||||
string cacheKey = $"{UserFriendsCacheKeyPrefix}{account.Id}";
|
||||
var cacheKey = $"{UserFriendsCacheKeyPrefix}{account.Id}";
|
||||
var friends = await cache.GetAsync<List<Guid>>(cacheKey);
|
||||
|
||||
if (friends == null)
|
||||
@ -161,6 +170,25 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
||||
|
||||
return friends ?? [];
|
||||
}
|
||||
|
||||
public async Task<List<Guid>> ListAccountBlocked(Account account)
|
||||
{
|
||||
var cacheKey = $"{UserBlockedCacheKeyPrefix}{account.Id}";
|
||||
var blocked = await cache.GetAsync<List<Guid>>(cacheKey);
|
||||
|
||||
if (blocked == null)
|
||||
{
|
||||
blocked = await db.AccountRelationships
|
||||
.Where(r => r.RelatedId == account.Id)
|
||||
.Where(r => r.Status == RelationshipStatus.Blocked)
|
||||
.Select(r => r.AccountId)
|
||||
.ToListAsync();
|
||||
|
||||
await cache.SetAsync(cacheKey, blocked, TimeSpan.FromHours(1));
|
||||
}
|
||||
|
||||
return blocked ?? [];
|
||||
}
|
||||
|
||||
public async Task<bool> HasRelationshipWithStatus(Guid accountId, Guid relatedId,
|
||||
RelationshipStatus status = RelationshipStatus.Friends)
|
||||
@ -168,4 +196,12 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
||||
var relationship = await GetRelationship(accountId, relatedId, status);
|
||||
return relationship is not null;
|
||||
}
|
||||
|
||||
private async Task PurgeRelationshipCache(Guid accountId, Guid relatedId)
|
||||
{
|
||||
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}");
|
||||
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relatedId}");
|
||||
await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{accountId}");
|
||||
await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{relatedId}");
|
||||
}
|
||||
}
|
@ -8,7 +8,7 @@ namespace DysonNetwork.Sphere.Activity;
|
||||
/// Activity is a universal feed that contains multiple kinds of data. Personalized and generated dynamically.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("/activities")]
|
||||
[Route("/api/activities")]
|
||||
public class ActivityController(
|
||||
ActivityService acts
|
||||
) : ControllerBase
|
||||
|
@ -1,4 +1,5 @@
|
||||
using DysonNetwork.Sphere.Account;
|
||||
using DysonNetwork.Sphere.Connection.WebReader;
|
||||
using DysonNetwork.Sphere.Discovery;
|
||||
using DysonNetwork.Sphere.Post;
|
||||
using DysonNetwork.Sphere.Publisher;
|
||||
@ -23,7 +24,8 @@ public class ActivityService(
|
||||
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>();
|
||||
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
|
||||
var postsQuery = db.Posts
|
||||
.Include(e => e.RepliedPost)
|
||||
@ -90,7 +126,7 @@ public class ActivityService(
|
||||
var activities = new List<Activity>();
|
||||
var userFriends = await rels.ListAccountFriends(currentUser);
|
||||
var userPublishers = await pub.GetUserPublishers(currentUser.Id);
|
||||
debugInclude ??= new HashSet<string>();
|
||||
debugInclude ??= [];
|
||||
|
||||
if (string.IsNullOrEmpty(filter))
|
||||
{
|
||||
@ -115,6 +151,40 @@ public class ActivityService(
|
||||
).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
|
||||
@ -212,4 +282,4 @@ public class ActivityService(
|
||||
|
||||
return rankedPublishers;
|
||||
}
|
||||
}
|
||||
}
|
@ -196,6 +196,17 @@ public class AppDatabase(
|
||||
.HasGeneratedTsVectorColumn(p => p.SearchVector, "simple", p => new { p.Title, p.Description, p.Content })
|
||||
.HasIndex(p => p.SearchVector)
|
||||
.HasMethod("GIN");
|
||||
|
||||
modelBuilder.Entity<CustomAppSecret>()
|
||||
.HasIndex(s => s.Secret)
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<CustomApp>()
|
||||
.HasMany(c => c.Secrets)
|
||||
.WithOne(s => s.App)
|
||||
.HasForeignKey(s => s.AppId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<Post.Post>()
|
||||
.HasOne(p => p.RepliedPost)
|
||||
.WithMany()
|
||||
|
@ -1,12 +1,19 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Encodings.Web;
|
||||
using DysonNetwork.Sphere.Account;
|
||||
using DysonNetwork.Sphere.Auth.OidcProvider.Options;
|
||||
using DysonNetwork.Sphere.Storage;
|
||||
using DysonNetwork.Sphere.Storage.Handlers;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using NodaTime;
|
||||
using System.Text;
|
||||
using DysonNetwork.Sphere.Auth.OidcProvider.Controllers;
|
||||
using DysonNetwork.Sphere.Auth.OidcProvider.Services;
|
||||
using SystemClock = NodaTime.SystemClock;
|
||||
|
||||
namespace DysonNetwork.Sphere.Auth;
|
||||
@ -22,6 +29,7 @@ public enum TokenType
|
||||
{
|
||||
AuthKey,
|
||||
ApiKey,
|
||||
OidcKey,
|
||||
Unknown
|
||||
}
|
||||
|
||||
@ -39,6 +47,7 @@ public class DysonTokenAuthHandler(
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
AppDatabase database,
|
||||
OidcProviderService oidc,
|
||||
ICacheService cache,
|
||||
FlushBufferService fbs
|
||||
)
|
||||
@ -142,35 +151,60 @@ public class DysonTokenAuthHandler(
|
||||
|
||||
try
|
||||
{
|
||||
// Split the token
|
||||
var parts = token.Split('.');
|
||||
if (parts.Length != 2)
|
||||
return false;
|
||||
|
||||
// Decode the payload
|
||||
var payloadBytes = Base64UrlDecode(parts[0]);
|
||||
switch (parts.Length)
|
||||
{
|
||||
// Handle JWT tokens (3 parts)
|
||||
case 3:
|
||||
{
|
||||
var (isValid, jwtResult) = oidc.ValidateToken(token);
|
||||
if (!isValid) return false;
|
||||
var jti = jwtResult?.Claims.FirstOrDefault(c => c.Type == "jti")?.Value;
|
||||
if (jti is null) return false;
|
||||
|
||||
// Extract session ID
|
||||
sessionId = new Guid(payloadBytes);
|
||||
return Guid.TryParse(jti, out sessionId);
|
||||
}
|
||||
// Handle compact tokens (2 parts)
|
||||
case 2:
|
||||
// Original compact token validation logic
|
||||
try
|
||||
{
|
||||
// Decode the payload
|
||||
var payloadBytes = Base64UrlDecode(parts[0]);
|
||||
|
||||
// Load public key for verification
|
||||
var publicKeyPem = File.ReadAllText(configuration["Jwt:PublicKeyPath"]!);
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(publicKeyPem);
|
||||
// Extract session ID
|
||||
sessionId = new Guid(payloadBytes);
|
||||
|
||||
// Verify signature
|
||||
var signature = Base64UrlDecode(parts[1]);
|
||||
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
// Load public key for verification
|
||||
var publicKeyPem = File.ReadAllText(configuration["AuthToken:PublicKeyPath"]!);
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(publicKeyPem);
|
||||
|
||||
// Verify signature
|
||||
var signature = Base64UrlDecode(parts[1]);
|
||||
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Token validation failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Base64UrlDecode(string base64Url)
|
||||
{
|
||||
string padded = base64Url
|
||||
var padded = base64Url
|
||||
.Replace('-', '+')
|
||||
.Replace('_', '/');
|
||||
|
||||
@ -195,20 +229,23 @@ public class DysonTokenAuthHandler(
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Check for token in Authorization header
|
||||
var authHeader = request.Headers.Authorization.ToString();
|
||||
if (!string.IsNullOrEmpty(authHeader))
|
||||
{
|
||||
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var token = authHeader["Bearer ".Length..].Trim();
|
||||
var parts = token.Split('.');
|
||||
|
||||
return new TokenInfo
|
||||
{
|
||||
Token = authHeader["Bearer ".Length..].Trim(),
|
||||
Type = TokenType.AuthKey
|
||||
Token = token,
|
||||
Type = parts.Length == 3 ? TokenType.OidcKey : TokenType.AuthKey
|
||||
};
|
||||
}
|
||||
|
||||
if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
|
||||
else if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new TokenInfo
|
||||
{
|
||||
@ -216,8 +253,7 @@ public class DysonTokenAuthHandler(
|
||||
Type = TokenType.AuthKey
|
||||
};
|
||||
}
|
||||
|
||||
if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
|
||||
else if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new TokenInfo
|
||||
{
|
||||
@ -233,10 +269,11 @@ public class DysonTokenAuthHandler(
|
||||
return new TokenInfo
|
||||
{
|
||||
Token = cookieToken,
|
||||
Type = TokenType.AuthKey
|
||||
Type = cookieToken.Count(c => c == '.') == 2 ? TokenType.OidcKey : TokenType.AuthKey
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
@ -11,7 +11,7 @@ using DysonNetwork.Sphere.Connection;
|
||||
namespace DysonNetwork.Sphere.Auth;
|
||||
|
||||
[ApiController]
|
||||
[Route("/auth")]
|
||||
[Route("/api/auth")]
|
||||
public class AuthController(
|
||||
AppDatabase db,
|
||||
AccountService accounts,
|
||||
|
@ -73,7 +73,7 @@ public class AuthService(
|
||||
return totalRequiredSteps;
|
||||
}
|
||||
|
||||
public async Task<Session> CreateSessionAsync(Account.Account account, Instant time)
|
||||
public async Task<Session> CreateSessionForOidcAsync(Account.Account account, Instant time, Guid? customAppId = null)
|
||||
{
|
||||
var challenge = new Challenge
|
||||
{
|
||||
@ -82,7 +82,7 @@ public class AuthService(
|
||||
UserAgent = HttpContext.Request.Headers.UserAgent,
|
||||
StepRemain = 1,
|
||||
StepTotal = 1,
|
||||
Type = ChallengeType.Oidc
|
||||
Type = customAppId is not null ? ChallengeType.OAuth : ChallengeType.Oidc
|
||||
};
|
||||
|
||||
var session = new Session
|
||||
@ -90,7 +90,8 @@ public class AuthService(
|
||||
AccountId = account.Id,
|
||||
CreatedAt = time,
|
||||
LastGrantedAt = time,
|
||||
Challenge = challenge
|
||||
Challenge = challenge,
|
||||
AppId = customAppId
|
||||
};
|
||||
|
||||
db.AuthChallenges.Add(challenge);
|
||||
@ -156,7 +157,7 @@ public class AuthService(
|
||||
public string CreateToken(Session session)
|
||||
{
|
||||
// Load the private key for signing
|
||||
var privateKeyPem = File.ReadAllText(config["Jwt:PrivateKeyPath"]!);
|
||||
var privateKeyPem = File.ReadAllText(config["AuthToken:PrivateKeyPath"]!);
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(privateKeyPem);
|
||||
|
||||
@ -263,7 +264,7 @@ public class AuthService(
|
||||
sessionId = new Guid(payloadBytes);
|
||||
|
||||
// Load public key for verification
|
||||
var publicKeyPem = File.ReadAllText(config["Jwt:PublicKeyPath"]!);
|
||||
var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!);
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(publicKeyPem);
|
||||
|
||||
|
@ -4,8 +4,8 @@ namespace DysonNetwork.Sphere.Auth;
|
||||
|
||||
public class CompactTokenService(IConfiguration config)
|
||||
{
|
||||
private readonly string _privateKeyPath = config["Jwt:PrivateKeyPath"]
|
||||
?? throw new InvalidOperationException("Jwt:PrivateKeyPath configuration is missing");
|
||||
private readonly string _privateKeyPath = config["AuthToken:PrivateKeyPath"]
|
||||
?? throw new InvalidOperationException("AuthToken:PrivateKeyPath configuration is missing");
|
||||
|
||||
public string CreateToken(Session session)
|
||||
{
|
||||
@ -54,7 +54,7 @@ public class CompactTokenService(IConfiguration config)
|
||||
sessionId = new Guid(payloadBytes);
|
||||
|
||||
// Load public key for verification
|
||||
var publicKeyPem = File.ReadAllText(config["Jwt:PublicKeyPath"]!);
|
||||
var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!);
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(publicKeyPem);
|
||||
|
||||
|
@ -0,0 +1,242 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using DysonNetwork.Sphere.Auth.OidcProvider.Options;
|
||||
using DysonNetwork.Sphere.Auth.OidcProvider.Responses;
|
||||
using DysonNetwork.Sphere.Auth.OidcProvider.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Sphere.Account;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Sphere.Auth.OidcProvider.Controllers;
|
||||
|
||||
[Route("/api/auth/open")]
|
||||
[ApiController]
|
||||
public class OidcProviderController(
|
||||
AppDatabase db,
|
||||
OidcProviderService oidcService,
|
||||
IConfiguration configuration,
|
||||
IOptions<OidcProviderOptions> options,
|
||||
ILogger<OidcProviderController> logger
|
||||
)
|
||||
: ControllerBase
|
||||
{
|
||||
[HttpPost("token")]
|
||||
[Consumes("application/x-www-form-urlencoded")]
|
||||
public async Task<IActionResult> Token([FromForm] TokenRequest request)
|
||||
{
|
||||
switch (request.GrantType)
|
||||
{
|
||||
// Validate client credentials
|
||||
case "authorization_code" when request.ClientId == null || string.IsNullOrEmpty(request.ClientSecret):
|
||||
return BadRequest("Client credentials are required");
|
||||
case "authorization_code" when request.Code == null:
|
||||
return BadRequest("Authorization code is required");
|
||||
case "authorization_code":
|
||||
{
|
||||
var client = await oidcService.FindClientByIdAsync(request.ClientId.Value);
|
||||
if (client == null ||
|
||||
!await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret))
|
||||
return BadRequest(new ErrorResponse
|
||||
{ Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
|
||||
|
||||
// Generate tokens
|
||||
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
||||
clientId: request.ClientId.Value,
|
||||
authorizationCode: request.Code!,
|
||||
redirectUri: request.RedirectUri,
|
||||
codeVerifier: request.CodeVerifier
|
||||
);
|
||||
|
||||
return Ok(tokenResponse);
|
||||
}
|
||||
case "refresh_token" when string.IsNullOrEmpty(request.RefreshToken):
|
||||
return BadRequest(new ErrorResponse
|
||||
{ Error = "invalid_request", ErrorDescription = "Refresh token is required" });
|
||||
case "refresh_token":
|
||||
{
|
||||
try
|
||||
{
|
||||
// Decode the base64 refresh token to get the session ID
|
||||
var sessionIdBytes = Convert.FromBase64String(request.RefreshToken);
|
||||
var sessionId = new Guid(sessionIdBytes);
|
||||
|
||||
// Find the session and related data
|
||||
var session = await oidcService.FindSessionByIdAsync(sessionId);
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
if (session?.App is null || session.ExpiredAt < now)
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "invalid_grant",
|
||||
ErrorDescription = "Invalid or expired refresh token"
|
||||
});
|
||||
}
|
||||
|
||||
// Get the client
|
||||
var client = session.App;
|
||||
if (client == null)
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "invalid_client",
|
||||
ErrorDescription = "Client not found"
|
||||
});
|
||||
}
|
||||
|
||||
// Generate new tokens
|
||||
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
||||
clientId: session.AppId!.Value,
|
||||
sessionId: session.Id
|
||||
);
|
||||
|
||||
return Ok(tokenResponse);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "invalid_grant",
|
||||
ErrorDescription = "Invalid refresh token format"
|
||||
});
|
||||
}
|
||||
}
|
||||
default:
|
||||
return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("userinfo")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GetUserInfo()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser ||
|
||||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
|
||||
|
||||
// Get requested scopes from the token
|
||||
var scopes = currentSession.Challenge.Scopes;
|
||||
|
||||
var userInfo = new Dictionary<string, object>
|
||||
{
|
||||
["sub"] = currentUser.Id
|
||||
};
|
||||
|
||||
// Include standard claims based on scopes
|
||||
if (scopes.Contains("profile") || scopes.Contains("name"))
|
||||
{
|
||||
userInfo["name"] = currentUser.Name;
|
||||
userInfo["preferred_username"] = currentUser.Nick;
|
||||
}
|
||||
|
||||
var userEmail = await db.AccountContacts
|
||||
.Where(c => c.Type == AccountContactType.Email && c.AccountId == currentUser.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (scopes.Contains("email") && userEmail is not null)
|
||||
{
|
||||
userInfo["email"] = userEmail.Content;
|
||||
userInfo["email_verified"] = userEmail.VerifiedAt is not null;
|
||||
}
|
||||
|
||||
return Ok(userInfo);
|
||||
}
|
||||
|
||||
[HttpGet("/.well-known/openid-configuration")]
|
||||
public IActionResult GetConfiguration()
|
||||
{
|
||||
var baseUrl = configuration["BaseUrl"];
|
||||
var issuer = options.Value.IssuerUri.TrimEnd('/');
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
issuer = issuer,
|
||||
authorization_endpoint = $"{baseUrl}/auth/authorize",
|
||||
token_endpoint = $"{baseUrl}/auth/open/token",
|
||||
userinfo_endpoint = $"{baseUrl}/auth/open/userinfo",
|
||||
jwks_uri = $"{baseUrl}/.well-known/jwks",
|
||||
scopes_supported = new[] { "openid", "profile", "email" },
|
||||
response_types_supported = new[]
|
||||
{ "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token" },
|
||||
grant_types_supported = new[] { "authorization_code", "refresh_token" },
|
||||
token_endpoint_auth_methods_supported = new[] { "client_secret_basic", "client_secret_post" },
|
||||
id_token_signing_alg_values_supported = new[] { "HS256" },
|
||||
subject_types_supported = new[] { "public" },
|
||||
claims_supported = new[] { "sub", "name", "email", "email_verified" },
|
||||
code_challenge_methods_supported = new[] { "S256" },
|
||||
response_modes_supported = new[] { "query", "fragment", "form_post" },
|
||||
request_parameter_supported = true,
|
||||
request_uri_parameter_supported = true,
|
||||
require_request_uri_registration = false
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/.well-known/jwks")]
|
||||
public IActionResult GetJwks()
|
||||
{
|
||||
using var rsa = options.Value.GetRsaPublicKey();
|
||||
if (rsa == null)
|
||||
{
|
||||
return BadRequest("Public key is not configured");
|
||||
}
|
||||
|
||||
var parameters = rsa.ExportParameters(false);
|
||||
var keyId = Convert.ToBase64String(SHA256.HashData(parameters.Modulus!)[..8])
|
||||
.Replace("+", "-")
|
||||
.Replace("/", "_")
|
||||
.Replace("=", "");
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
keys = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
kty = "RSA",
|
||||
use = "sig",
|
||||
kid = keyId,
|
||||
n = Base64UrlEncoder.Encode(parameters.Modulus!),
|
||||
e = Base64UrlEncoder.Encode(parameters.Exponent!),
|
||||
alg = "RS256"
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public class TokenRequest
|
||||
{
|
||||
[JsonPropertyName("grant_type")]
|
||||
[FromForm(Name = "grant_type")]
|
||||
public string? GrantType { get; set; }
|
||||
|
||||
[JsonPropertyName("code")]
|
||||
[FromForm(Name = "code")]
|
||||
public string? Code { get; set; }
|
||||
|
||||
[JsonPropertyName("redirect_uri")]
|
||||
[FromForm(Name = "redirect_uri")]
|
||||
public string? RedirectUri { get; set; }
|
||||
|
||||
[JsonPropertyName("client_id")]
|
||||
[FromForm(Name = "client_id")]
|
||||
public Guid? ClientId { get; set; }
|
||||
|
||||
[JsonPropertyName("client_secret")]
|
||||
[FromForm(Name = "client_secret")]
|
||||
public string? ClientSecret { get; set; }
|
||||
|
||||
[JsonPropertyName("refresh_token")]
|
||||
[FromForm(Name = "refresh_token")]
|
||||
public string? RefreshToken { get; set; }
|
||||
|
||||
[JsonPropertyName("scope")]
|
||||
[FromForm(Name = "scope")]
|
||||
public string? Scope { get; set; }
|
||||
|
||||
[JsonPropertyName("code_verifier")]
|
||||
[FromForm(Name = "code_verifier")]
|
||||
public string? CodeVerifier { get; set; }
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Sphere.Auth.OidcProvider.Models;
|
||||
|
||||
public class AuthorizationCodeInfo
|
||||
{
|
||||
public Guid ClientId { get; set; }
|
||||
public Guid AccountId { get; set; }
|
||||
public string RedirectUri { get; set; } = string.Empty;
|
||||
public List<string> Scopes { get; set; } = new();
|
||||
public string? CodeChallenge { get; set; }
|
||||
public string? CodeChallengeMethod { get; set; }
|
||||
public string? Nonce { get; set; }
|
||||
public Instant CreatedAt { get; set; }
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace DysonNetwork.Sphere.Auth.OidcProvider.Options;
|
||||
|
||||
public class OidcProviderOptions
|
||||
{
|
||||
public string IssuerUri { get; set; } = "https://your-issuer-uri.com";
|
||||
public string? PublicKeyPath { get; set; }
|
||||
public string? PrivateKeyPath { get; set; }
|
||||
public TimeSpan AccessTokenLifetime { get; set; } = TimeSpan.FromHours(1);
|
||||
public TimeSpan RefreshTokenLifetime { get; set; } = TimeSpan.FromDays(30);
|
||||
public TimeSpan AuthorizationCodeLifetime { get; set; } = TimeSpan.FromMinutes(5);
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
|
||||
public RSA? GetRsaPrivateKey()
|
||||
{
|
||||
if (string.IsNullOrEmpty(PrivateKeyPath) || !File.Exists(PrivateKeyPath))
|
||||
return null;
|
||||
|
||||
var privateKey = File.ReadAllText(PrivateKeyPath);
|
||||
var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(privateKey.AsSpan());
|
||||
return rsa;
|
||||
}
|
||||
|
||||
public RSA? GetRsaPublicKey()
|
||||
{
|
||||
if (string.IsNullOrEmpty(PublicKeyPath) || !File.Exists(PublicKeyPath))
|
||||
return null;
|
||||
|
||||
var publicKey = File.ReadAllText(PublicKeyPath);
|
||||
var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(publicKey.AsSpan());
|
||||
return rsa;
|
||||
}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses;
|
||||
|
||||
public class AuthorizationResponse
|
||||
{
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("state")]
|
||||
public string? State { get; set; }
|
||||
|
||||
[JsonPropertyName("scope")]
|
||||
public string? Scope { get; set; }
|
||||
|
||||
|
||||
[JsonPropertyName("session_state")]
|
||||
public string? SessionState { get; set; }
|
||||
|
||||
|
||||
[JsonPropertyName("iss")]
|
||||
public string? Issuer { get; set; }
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses;
|
||||
|
||||
public class ErrorResponse
|
||||
{
|
||||
[JsonPropertyName("error")]
|
||||
public string Error { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("error_description")]
|
||||
public string? ErrorDescription { get; set; }
|
||||
|
||||
|
||||
[JsonPropertyName("error_uri")]
|
||||
public string? ErrorUri { get; set; }
|
||||
|
||||
|
||||
[JsonPropertyName("state")]
|
||||
public string? State { get; set; }
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses;
|
||||
|
||||
public class TokenResponse
|
||||
{
|
||||
[JsonPropertyName("access_token")]
|
||||
public string AccessToken { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("expires_in")]
|
||||
public int ExpiresIn { get; set; }
|
||||
|
||||
[JsonPropertyName("token_type")]
|
||||
public string TokenType { get; set; } = "Bearer";
|
||||
|
||||
[JsonPropertyName("refresh_token")]
|
||||
public string? RefreshToken { get; set; }
|
||||
|
||||
|
||||
[JsonPropertyName("scope")]
|
||||
public string? Scope { get; set; }
|
||||
|
||||
|
||||
[JsonPropertyName("id_token")]
|
||||
public string? IdToken { get; set; }
|
||||
}
|
@ -0,0 +1,395 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using DysonNetwork.Sphere.Auth.OidcProvider.Models;
|
||||
using DysonNetwork.Sphere.Auth.OidcProvider.Options;
|
||||
using DysonNetwork.Sphere.Auth.OidcProvider.Responses;
|
||||
using DysonNetwork.Sphere.Developer;
|
||||
using DysonNetwork.Sphere.Storage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Sphere.Auth.OidcProvider.Services;
|
||||
|
||||
public class OidcProviderService(
|
||||
AppDatabase db,
|
||||
AuthService auth,
|
||||
ICacheService cache,
|
||||
IOptions<OidcProviderOptions> options,
|
||||
ILogger<OidcProviderService> logger
|
||||
)
|
||||
{
|
||||
private readonly OidcProviderOptions _options = options.Value;
|
||||
|
||||
public async Task<CustomApp?> FindClientByIdAsync(Guid clientId)
|
||||
{
|
||||
return await db.CustomApps
|
||||
.Include(c => c.Secrets)
|
||||
.FirstOrDefaultAsync(c => c.Id == clientId);
|
||||
}
|
||||
|
||||
public async Task<CustomApp?> FindClientByAppIdAsync(Guid appId)
|
||||
{
|
||||
return await db.CustomApps
|
||||
.Include(c => c.Secrets)
|
||||
.FirstOrDefaultAsync(c => c.Id == appId);
|
||||
}
|
||||
|
||||
public async Task<Session?> FindValidSessionAsync(Guid accountId, Guid clientId)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
return await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.Where(s => s.AccountId == accountId &&
|
||||
s.AppId == clientId &&
|
||||
(s.ExpiredAt == null || s.ExpiredAt > now) &&
|
||||
s.Challenge.Type == ChallengeType.OAuth)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> ValidateClientCredentialsAsync(Guid clientId, string clientSecret)
|
||||
{
|
||||
var client = await FindClientByIdAsync(clientId);
|
||||
if (client == null) return false;
|
||||
|
||||
var clock = SystemClock.Instance;
|
||||
var secret = client.Secrets
|
||||
.Where(s => s.IsOidc && (s.ExpiredAt == null || s.ExpiredAt > clock.GetCurrentInstant()))
|
||||
.FirstOrDefault(s => s.Secret == clientSecret); // In production, use proper hashing
|
||||
|
||||
return secret != null;
|
||||
}
|
||||
|
||||
public async Task<TokenResponse> GenerateTokenResponseAsync(
|
||||
Guid clientId,
|
||||
string? authorizationCode = null,
|
||||
string? redirectUri = null,
|
||||
string? codeVerifier = null,
|
||||
Guid? sessionId = null
|
||||
)
|
||||
{
|
||||
var client = await FindClientByIdAsync(clientId);
|
||||
if (client == null)
|
||||
throw new InvalidOperationException("Client not found");
|
||||
|
||||
Session session;
|
||||
var clock = SystemClock.Instance;
|
||||
var now = clock.GetCurrentInstant();
|
||||
|
||||
List<string>? scopes = null;
|
||||
if (authorizationCode != null)
|
||||
{
|
||||
// Authorization code flow
|
||||
var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier);
|
||||
if (authCode is null) throw new InvalidOperationException("Invalid authorization code");
|
||||
var account = await db.Accounts.Where(a => a.Id == authCode.AccountId).FirstOrDefaultAsync();
|
||||
if (account is null) throw new InvalidOperationException("Account was not found");
|
||||
|
||||
session = await auth.CreateSessionForOidcAsync(account, now, client.Id);
|
||||
scopes = authCode.Scopes;
|
||||
}
|
||||
else if (sessionId.HasValue)
|
||||
{
|
||||
// Refresh token flow
|
||||
session = await FindSessionByIdAsync(sessionId.Value) ??
|
||||
throw new InvalidOperationException("Invalid session");
|
||||
|
||||
// Verify the session is still valid
|
||||
if (session.ExpiredAt < now)
|
||||
throw new InvalidOperationException("Session has expired");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException("Either authorization code or session ID must be provided");
|
||||
}
|
||||
|
||||
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
|
||||
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
|
||||
|
||||
// Generate an access token
|
||||
var accessToken = GenerateJwtToken(client, session, expiresAt, scopes);
|
||||
var refreshToken = GenerateRefreshToken(session);
|
||||
|
||||
return new TokenResponse
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
ExpiresIn = expiresIn,
|
||||
TokenType = "Bearer",
|
||||
RefreshToken = refreshToken,
|
||||
Scope = scopes != null ? string.Join(" ", scopes) : null
|
||||
};
|
||||
}
|
||||
|
||||
private string GenerateJwtToken(
|
||||
CustomApp client,
|
||||
Session session,
|
||||
Instant expiresAt,
|
||||
IEnumerable<string>? scopes = null
|
||||
)
|
||||
{
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var clock = SystemClock.Instance;
|
||||
var now = clock.GetCurrentInstant();
|
||||
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity([
|
||||
new Claim(JwtRegisteredClaimNames.Sub, session.AccountId.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(),
|
||||
ClaimValueTypes.Integer64),
|
||||
new Claim("client_id", client.Id.ToString())
|
||||
]),
|
||||
Expires = expiresAt.ToDateTimeUtc(),
|
||||
Issuer = _options.IssuerUri,
|
||||
Audience = client.Id.ToString()
|
||||
};
|
||||
|
||||
// Try to use RSA signing if keys are available, fall back to HMAC
|
||||
var rsaPrivateKey = _options.GetRsaPrivateKey();
|
||||
tokenDescriptor.SigningCredentials = new SigningCredentials(
|
||||
new RsaSecurityKey(rsaPrivateKey),
|
||||
SecurityAlgorithms.RsaSha256
|
||||
);
|
||||
|
||||
// Add scopes as claims if provided
|
||||
var effectiveScopes = scopes?.ToList() ?? client.OauthConfig!.AllowedScopes?.ToList() ?? [];
|
||||
if (effectiveScopes.Count != 0)
|
||||
{
|
||||
tokenDescriptor.Subject.AddClaims(
|
||||
effectiveScopes.Select(scope => new Claim("scope", scope)));
|
||||
}
|
||||
|
||||
var token = tokenHandler.CreateToken(tokenDescriptor);
|
||||
return tokenHandler.WriteToken(token);
|
||||
}
|
||||
|
||||
public (bool isValid, JwtSecurityToken? token) ValidateToken(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var validationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = _options.IssuerUri,
|
||||
ValidateAudience = false,
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.Zero
|
||||
};
|
||||
|
||||
// Try to use RSA validation if public key is available
|
||||
var rsaPublicKey = _options.GetRsaPublicKey();
|
||||
validationParameters.IssuerSigningKey = new RsaSecurityKey(rsaPublicKey);
|
||||
validationParameters.ValidateIssuerSigningKey = true;
|
||||
validationParameters.ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 };
|
||||
|
||||
|
||||
tokenHandler.ValidateToken(token, validationParameters, out var validatedToken);
|
||||
return (true, (JwtSecurityToken)validatedToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Token validation failed");
|
||||
return (false, null);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Session?> FindSessionByIdAsync(Guid sessionId)
|
||||
{
|
||||
return await db.AuthSessions
|
||||
.Include(s => s.Account)
|
||||
.Include(s => s.Challenge)
|
||||
.Include(s => s.App)
|
||||
.FirstOrDefaultAsync(s => s.Id == sessionId);
|
||||
}
|
||||
|
||||
private static string GenerateRefreshToken(Session session)
|
||||
{
|
||||
return Convert.ToBase64String(session.Id.ToByteArray());
|
||||
}
|
||||
|
||||
private static bool VerifyHashedSecret(string secret, string hashedSecret)
|
||||
{
|
||||
// In a real implementation, you'd use a proper password hashing algorithm like PBKDF2, bcrypt, or Argon2
|
||||
// For now, we'll do a simple comparison, but you should replace this with proper hashing
|
||||
return string.Equals(secret, hashedSecret, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public async Task<string> GenerateAuthorizationCodeForReuseSessionAsync(
|
||||
Session session,
|
||||
Guid clientId,
|
||||
string redirectUri,
|
||||
IEnumerable<string> scopes,
|
||||
string? codeChallenge = null,
|
||||
string? codeChallengeMethod = null,
|
||||
string? nonce = null)
|
||||
{
|
||||
var clock = SystemClock.Instance;
|
||||
var now = clock.GetCurrentInstant();
|
||||
var code = Guid.NewGuid().ToString("N");
|
||||
|
||||
// Update the session's last activity time
|
||||
await db.AuthSessions.Where(s => s.Id == session.Id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(s => s.LastGrantedAt, now));
|
||||
|
||||
// Create the authorization code info
|
||||
var authCodeInfo = new AuthorizationCodeInfo
|
||||
{
|
||||
ClientId = clientId,
|
||||
AccountId = session.AccountId,
|
||||
RedirectUri = redirectUri,
|
||||
Scopes = scopes.ToList(),
|
||||
CodeChallenge = codeChallenge,
|
||||
CodeChallengeMethod = codeChallengeMethod,
|
||||
Nonce = nonce,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
// Store the code with its metadata in the cache
|
||||
var cacheKey = $"auth:code:{code}";
|
||||
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
|
||||
|
||||
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, session.AccountId);
|
||||
return code;
|
||||
}
|
||||
|
||||
public async Task<string> GenerateAuthorizationCodeAsync(
|
||||
Guid clientId,
|
||||
Guid userId,
|
||||
string redirectUri,
|
||||
IEnumerable<string> scopes,
|
||||
string? codeChallenge = null,
|
||||
string? codeChallengeMethod = null,
|
||||
string? nonce = null
|
||||
)
|
||||
{
|
||||
// Generate a random code
|
||||
var clock = SystemClock.Instance;
|
||||
var code = GenerateRandomString(32);
|
||||
var now = clock.GetCurrentInstant();
|
||||
|
||||
// Create the authorization code info
|
||||
var authCodeInfo = new AuthorizationCodeInfo
|
||||
{
|
||||
ClientId = clientId,
|
||||
AccountId = userId,
|
||||
RedirectUri = redirectUri,
|
||||
Scopes = scopes.ToList(),
|
||||
CodeChallenge = codeChallenge,
|
||||
CodeChallengeMethod = codeChallengeMethod,
|
||||
Nonce = nonce,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
// Store the code with its metadata in the cache
|
||||
var cacheKey = $"auth:code:{code}";
|
||||
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
|
||||
|
||||
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId);
|
||||
return code;
|
||||
}
|
||||
|
||||
private async Task<AuthorizationCodeInfo?> ValidateAuthorizationCodeAsync(
|
||||
string code,
|
||||
Guid clientId,
|
||||
string? redirectUri = null,
|
||||
string? codeVerifier = null
|
||||
)
|
||||
{
|
||||
var cacheKey = $"auth:code:{code}";
|
||||
var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey);
|
||||
|
||||
if (!found || authCode == null)
|
||||
{
|
||||
logger.LogWarning("Authorization code not found: {Code}", code);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify client ID matches
|
||||
if (authCode.ClientId != clientId)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Client ID mismatch for code {Code}. Expected: {ExpectedClientId}, Actual: {ActualClientId}",
|
||||
code, authCode.ClientId, clientId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify redirect URI if provided
|
||||
if (!string.IsNullOrEmpty(redirectUri) && authCode.RedirectUri != redirectUri)
|
||||
{
|
||||
logger.LogWarning("Redirect URI mismatch for code {Code}", code);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify PKCE code challenge if one was provided during authorization
|
||||
if (!string.IsNullOrEmpty(authCode.CodeChallenge))
|
||||
{
|
||||
if (string.IsNullOrEmpty(codeVerifier))
|
||||
{
|
||||
logger.LogWarning("PKCE code verifier is required but not provided for code {Code}", code);
|
||||
return null;
|
||||
}
|
||||
|
||||
var isValid = authCode.CodeChallengeMethod?.ToUpperInvariant() switch
|
||||
{
|
||||
"S256" => VerifyCodeChallenge(codeVerifier, authCode.CodeChallenge, "S256"),
|
||||
"PLAIN" => VerifyCodeChallenge(codeVerifier, authCode.CodeChallenge, "PLAIN"),
|
||||
_ => false // Unsupported code challenge method
|
||||
};
|
||||
|
||||
if (!isValid)
|
||||
{
|
||||
logger.LogWarning("PKCE code verifier validation failed for code {Code}", code);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Code is valid, remove it from the cache (codes are single-use)
|
||||
await cache.RemoveAsync(cacheKey);
|
||||
|
||||
return authCode;
|
||||
}
|
||||
|
||||
private static string GenerateRandomString(int length)
|
||||
{
|
||||
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~";
|
||||
var random = RandomNumberGenerator.Create();
|
||||
var result = new char[length];
|
||||
|
||||
for (int i = 0; i < length; i++)
|
||||
{
|
||||
var randomNumber = new byte[4];
|
||||
random.GetBytes(randomNumber);
|
||||
var index = (int)(BitConverter.ToUInt32(randomNumber, 0) % chars.Length);
|
||||
result[i] = chars[index];
|
||||
}
|
||||
|
||||
return new string(result);
|
||||
}
|
||||
|
||||
private static bool VerifyCodeChallenge(string codeVerifier, string codeChallenge, string method)
|
||||
{
|
||||
if (string.IsNullOrEmpty(codeVerifier)) return false;
|
||||
|
||||
if (method == "S256")
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
|
||||
var base64 = Base64UrlEncoder.Encode(hash);
|
||||
return string.Equals(base64, codeChallenge, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
if (method == "PLAIN")
|
||||
{
|
||||
return string.Equals(codeVerifier, codeChallenge, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
@ -8,7 +8,7 @@ using NodaTime;
|
||||
namespace DysonNetwork.Sphere.Auth.OpenId;
|
||||
|
||||
[ApiController]
|
||||
[Route("/accounts/me/connections")]
|
||||
[Route("/api/accounts/me/connections")]
|
||||
[Authorize]
|
||||
public class ConnectionController(
|
||||
AppDatabase db,
|
||||
@ -164,7 +164,7 @@ public class ConnectionController(
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[Route("/auth/callback/{provider}")]
|
||||
[Route("/api/auth/callback/{provider}")]
|
||||
[HttpGet, HttpPost]
|
||||
public async Task<IActionResult> HandleCallback([FromRoute] string provider)
|
||||
{
|
||||
@ -376,7 +376,7 @@ public class ConnectionController(
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var loginSession = await auth.CreateSessionAsync(account, clock.GetCurrentInstant());
|
||||
var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant());
|
||||
var loginToken = auth.CreateToken(loginSession);
|
||||
return Redirect($"/auth/token?token={loginToken}");
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ using NodaTime;
|
||||
namespace DysonNetwork.Sphere.Auth.OpenId;
|
||||
|
||||
[ApiController]
|
||||
[Route("/auth/login")]
|
||||
[Route("/api/auth/login")]
|
||||
public class OidcController(
|
||||
IServiceProvider serviceProvider,
|
||||
AppDatabase db,
|
||||
|
@ -1,6 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Sphere.Developer;
|
||||
using NodaTime;
|
||||
using Point = NetTopologySuite.Geometries.Point;
|
||||
|
||||
@ -17,6 +18,8 @@ public class Session : ModelBase
|
||||
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
||||
public Guid ChallengeId { get; set; }
|
||||
public Challenge Challenge { get; set; } = null!;
|
||||
public Guid? AppId { get; set; }
|
||||
public CustomApp? App { get; set; }
|
||||
}
|
||||
|
||||
public enum ChallengeType
|
||||
|
@ -9,7 +9,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
namespace DysonNetwork.Sphere.Chat;
|
||||
|
||||
[ApiController]
|
||||
[Route("/chat")]
|
||||
[Route("/api/chat")]
|
||||
public partial class ChatController(AppDatabase db, ChatService cs, ChatRoomService crs) : ControllerBase
|
||||
{
|
||||
public class MarkMessageReadRequest
|
||||
|
@ -13,7 +13,7 @@ using NodaTime;
|
||||
namespace DysonNetwork.Sphere.Chat;
|
||||
|
||||
[ApiController]
|
||||
[Route("/chat")]
|
||||
[Route("/api/chat")]
|
||||
public class ChatRoomController(
|
||||
AppDatabase db,
|
||||
FileReferenceService fileRefService,
|
||||
|
@ -252,7 +252,7 @@ public partial class ChatService(
|
||||
|
||||
if (member.Account.Id == sender.AccountId) continue;
|
||||
if (member.Notify == ChatMemberNotify.None) continue;
|
||||
if (scopedWs.IsUserSubscribedToChatRoom(member.AccountId, room.Id.ToString())) continue;
|
||||
// if (scopedWs.IsUserSubscribedToChatRoom(member.AccountId, room.Id.ToString())) continue;
|
||||
if (message.MembersMentioned is null || !message.MembersMentioned.Contains(member.Account.Id))
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
@ -575,4 +575,4 @@ public class SyncResponse
|
||||
{
|
||||
public List<MessageChange> Changes { get; set; } = [];
|
||||
public Instant CurrentTimestamp { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ public class RealtimeChatConfiguration
|
||||
}
|
||||
|
||||
[ApiController]
|
||||
[Route("/chat/realtime")]
|
||||
[Route("/api/chat/realtime")]
|
||||
public class RealtimeCallController(
|
||||
IConfiguration configuration,
|
||||
AppDatabase db,
|
||||
|
92
DysonNetwork.Sphere/Connection/AutoCompletionController.cs
Normal file
92
DysonNetwork.Sphere/Connection/AutoCompletionController.cs
Normal 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; }
|
||||
}
|
42
DysonNetwork.Sphere/Connection/ClientTypeMiddleware.cs
Normal file
42
DysonNetwork.Sphere/Connection/ClientTypeMiddleware.cs
Normal file
@ -0,0 +1,42 @@
|
||||
namespace DysonNetwork.Sphere.Connection;
|
||||
|
||||
public class ClientTypeMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task Invoke(HttpContext context)
|
||||
{
|
||||
var headers = context.Request.Headers;
|
||||
bool isWebPage;
|
||||
|
||||
// Priority 1: Check for custom header
|
||||
if (headers.TryGetValue("X-Client", out var clientType))
|
||||
{
|
||||
isWebPage = clientType.ToString().Length == 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
var userAgent = headers.UserAgent.ToString();
|
||||
var accept = headers.Accept.ToString();
|
||||
|
||||
// Priority 2: Check known app User-Agent (backward compatibility)
|
||||
if (!string.IsNullOrEmpty(userAgent) && userAgent.Contains("Solian"))
|
||||
isWebPage = false;
|
||||
// Priority 3: Accept header can help infer intent
|
||||
else if (!string.IsNullOrEmpty(accept) && accept.Contains("text/html"))
|
||||
isWebPage = true;
|
||||
else if (!string.IsNullOrEmpty(accept) && accept.Contains("application/json"))
|
||||
isWebPage = false;
|
||||
else
|
||||
isWebPage = true;
|
||||
}
|
||||
|
||||
context.Items["IsWebPage"] = isWebPage;
|
||||
|
||||
if (!isWebPage && context.Request.Path != "/ws" && !context.Request.Path.StartsWithSegments("/api"))
|
||||
context.Response.Redirect(
|
||||
$"/api{context.Request.Path.Value}{context.Request.QueryString.Value}",
|
||||
permanent: false
|
||||
);
|
||||
else
|
||||
await next(context);
|
||||
}
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DysonNetwork.Sphere.Connection.WebReader;
|
||||
|
||||
@ -7,8 +8,8 @@ public class WebArticle : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
[MaxLength(4096)] public string Title { get; set; }
|
||||
[MaxLength(8192)] public string Url { get; set; }
|
||||
[MaxLength(4096)] public string Title { get; set; } = null!;
|
||||
[MaxLength(8192)] public string Url { get; set; } = null!;
|
||||
[MaxLength(4096)] public string? Author { get; set; }
|
||||
|
||||
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; }
|
||||
@ -31,8 +32,8 @@ public class WebFeedConfig
|
||||
public class WebFeed : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(8192)] public string Url { get; set; }
|
||||
[MaxLength(4096)] public string Title { get; set; }
|
||||
[MaxLength(8192)] public string Url { get; set; } = null!;
|
||||
[MaxLength(4096)] public string Title { get; set; } = null!;
|
||||
[MaxLength(8192)] public string? Description { get; set; }
|
||||
|
||||
[Column(TypeName = "jsonb")] public LinkEmbed? Preview { get; set; }
|
||||
@ -41,5 +42,5 @@ public class WebFeed : ModelBase
|
||||
public Guid PublisherId { get; set; }
|
||||
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>();
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -1,62 +1,124 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Sphere.Permission;
|
||||
using DysonNetwork.Sphere.Publisher;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Sphere.Connection.WebReader;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("/feeds")]
|
||||
public class WebFeedController(WebFeedService webFeedService, AppDatabase database) : ControllerBase
|
||||
[Route("/api/publishers/{pubName}/feeds")]
|
||||
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]
|
||||
[MaxLength(8192)]
|
||||
public required string Url { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(4096)]
|
||||
public required string Title { get; set; }
|
||||
|
||||
[MaxLength(8192)]
|
||||
public string? Description { get; set; }
|
||||
var publisher = await ps.GetPublisherByName(pubName);
|
||||
if (publisher is null) return NotFound();
|
||||
var feeds = await webFeed.GetFeedsByPublisherAsync(publisher.Id);
|
||||
return Ok(feeds);
|
||||
}
|
||||
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CreateWebFeed([FromBody] CreateWebFeedRequest request)
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IActionResult> GetFeed([FromRoute] string pubName, Guid id)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
[HttpPost("scrape/{feedId}")]
|
||||
[RequiredPermission("maintenance", "web-feeds")]
|
||||
public async Task<ActionResult> ScrapeFeed(Guid feedId)
|
||||
|
||||
[HttpPost]
|
||||
[Authorize]
|
||||
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)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
await webFeedService.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);
|
||||
}
|
||||
|
||||
await webFeed.ScrapeFeedAsync(feed);
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,6 @@
|
||||
using System.Security.Claims;
|
||||
using System.ServiceModel.Syndication;
|
||||
using System.Xml;
|
||||
using DysonNetwork.Sphere.Account;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DysonNetwork.Sphere.Connection.WebReader;
|
||||
|
||||
@ -11,30 +8,19 @@ public class WebFeedService(
|
||||
AppDatabase database,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<WebFeedService> logger,
|
||||
AccountService accountService,
|
||||
WebReaderService webReaderService
|
||||
)
|
||||
{
|
||||
public async Task<WebFeed> CreateWebFeedAsync(WebFeedController.CreateWebFeedRequest request,
|
||||
ClaimsPrincipal claims)
|
||||
public async Task<WebFeed> CreateWebFeedAsync(Publisher.Publisher publisher,
|
||||
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
|
||||
{
|
||||
Url = request.Url,
|
||||
Title = request.Title,
|
||||
Url = request.Url!,
|
||||
Title = request.Title!,
|
||||
Description = request.Description,
|
||||
PublisherId = account.Id,
|
||||
Config = request.Config ?? new WebFeedConfig(),
|
||||
PublisherId = publisher.Id,
|
||||
};
|
||||
|
||||
database.Set<WebFeed>().Add(feed);
|
||||
@ -43,6 +29,50 @@ public class WebFeedService(
|
||||
return feed;
|
||||
}
|
||||
|
||||
public async Task<WebFeed?> GetFeedAsync(Guid id, Guid? publisherId = null)
|
||||
{
|
||||
var query = database.WebFeeds.Where(a => a.Id == id).AsQueryable();
|
||||
if (publisherId.HasValue)
|
||||
query = query.Where(a => a.PublisherId == publisherId.Value);
|
||||
return await query.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<List<WebFeed>> GetFeedsByPublisherAsync(Guid publisherId)
|
||||
{
|
||||
return await database.WebFeeds.Where(a => a.PublisherId == publisherId).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<WebFeed> UpdateFeedAsync(WebFeed feed, WebFeedController.WebFeedRequest request)
|
||||
{
|
||||
if (request.Url is not null)
|
||||
feed.Url = request.Url;
|
||||
if (request.Title is not null)
|
||||
feed.Title = request.Title;
|
||||
if (request.Description is not null)
|
||||
feed.Description = request.Description;
|
||||
if (request.Config is not null)
|
||||
feed.Config = request.Config;
|
||||
|
||||
database.Update(feed);
|
||||
await database.SaveChangesAsync();
|
||||
|
||||
return feed;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteFeedAsync(Guid id)
|
||||
{
|
||||
var feed = await database.WebFeeds.FindAsync(id);
|
||||
if (feed == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
database.WebFeeds.Remove(feed);
|
||||
await database.SaveChangesAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task ScrapeFeedAsync(WebFeed feed, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var httpClient = httpClientFactory.CreateClient();
|
||||
@ -63,17 +93,13 @@ public class WebFeedService(
|
||||
{
|
||||
var itemUrl = item.Links.FirstOrDefault()?.Uri.ToString();
|
||||
if (string.IsNullOrEmpty(itemUrl))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var articleExists = await database.Set<WebArticle>()
|
||||
.AnyAsync(a => a.FeedId == feed.Id && a.Url == itemUrl, cancellationToken);
|
||||
|
||||
if (articleExists)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var content = (item.Content as TextSyndicationContent)?.Text ?? item.Summary.Text;
|
||||
LinkEmbed preview;
|
||||
@ -82,7 +108,8 @@ public class WebFeedService(
|
||||
{
|
||||
var scrapedArticle = await webReaderService.ScrapeArticleAsync(itemUrl, cancellationToken);
|
||||
preview = scrapedArticle.LinkEmbed;
|
||||
content = scrapedArticle.Content;
|
||||
if (scrapedArticle.Content is not null)
|
||||
content = scrapedArticle.Content;
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -96,11 +123,11 @@ public class WebFeedService(
|
||||
Url = itemUrl,
|
||||
Author = item.Authors.FirstOrDefault()?.Name,
|
||||
Content = content,
|
||||
PublishedAt = item.PublishDate.UtcDateTime,
|
||||
PublishedAt = item.LastUpdatedTime.UtcDateTime,
|
||||
Preview = preview,
|
||||
};
|
||||
|
||||
database.Set<WebArticle>().Add(newArticle);
|
||||
database.WebArticles.Add(newArticle);
|
||||
}
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
@ -9,7 +9,7 @@ namespace DysonNetwork.Sphere.Connection.WebReader;
|
||||
/// Controller for web scraping and link preview services
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("/scrap")]
|
||||
[Route("/api/scrap")]
|
||||
[EnableRateLimiting("fixed")]
|
||||
public class WebReaderController(WebReaderService reader, ILogger<WebReaderController> logger)
|
||||
: ControllerBase
|
||||
|
@ -33,7 +33,11 @@ public class WebReaderService(
|
||||
{
|
||||
var httpClient = httpClientFactory.CreateClient("WebReader");
|
||||
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 doc = new HtmlDocument();
|
||||
doc.LoadHtml(html);
|
||||
@ -74,7 +78,8 @@ public class WebReaderService(
|
||||
// Cache miss or bypass, fetch fresh data
|
||||
logger.LogDebug("Fetching fresh link preview for URL: {Url}", url);
|
||||
var httpClient = httpClientFactory.CreateClient("WebReader");
|
||||
httpClient.MaxResponseContentBufferSize = 10 * 1024 * 1024; // 10MB, prevent scrap some directly accessible files
|
||||
httpClient.MaxResponseContentBufferSize =
|
||||
10 * 1024 * 1024; // 10MB, prevent scrap some directly accessible files
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(3);
|
||||
// Setting UA to facebook's bot to get the opengraph.
|
||||
httpClient.DefaultRequestHeaders.Add("User-Agent", "facebookexternalhit/1.1");
|
||||
|
4000
DysonNetwork.Sphere/Data/Migrations/20250628172328_AddOidcProviderSupport.Designer.cs
generated
Normal file
4000
DysonNetwork.Sphere/Data/Migrations/20250628172328_AddOidcProviderSupport.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,139 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Sphere.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddOidcProviderSupport : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "remarks",
|
||||
table: "custom_app_secrets",
|
||||
newName: "description");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "allow_offline_access",
|
||||
table: "custom_apps",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "allowed_grant_types",
|
||||
table: "custom_apps",
|
||||
type: "character varying(256)",
|
||||
maxLength: 256,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "allowed_scopes",
|
||||
table: "custom_apps",
|
||||
type: "character varying(256)",
|
||||
maxLength: 256,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "client_uri",
|
||||
table: "custom_apps",
|
||||
type: "character varying(1024)",
|
||||
maxLength: 1024,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "logo_uri",
|
||||
table: "custom_apps",
|
||||
type: "character varying(4096)",
|
||||
maxLength: 4096,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "post_logout_redirect_uris",
|
||||
table: "custom_apps",
|
||||
type: "character varying(4096)",
|
||||
maxLength: 4096,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "redirect_uris",
|
||||
table: "custom_apps",
|
||||
type: "character varying(4096)",
|
||||
maxLength: 4096,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "require_pkce",
|
||||
table: "custom_apps",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "is_oidc",
|
||||
table: "custom_app_secrets",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_custom_app_secrets_secret",
|
||||
table: "custom_app_secrets",
|
||||
column: "secret",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_custom_app_secrets_secret",
|
||||
table: "custom_app_secrets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "allow_offline_access",
|
||||
table: "custom_apps");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "allowed_grant_types",
|
||||
table: "custom_apps");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "allowed_scopes",
|
||||
table: "custom_apps");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "client_uri",
|
||||
table: "custom_apps");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "logo_uri",
|
||||
table: "custom_apps");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "post_logout_redirect_uris",
|
||||
table: "custom_apps");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "redirect_uris",
|
||||
table: "custom_apps");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "require_pkce",
|
||||
table: "custom_apps");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "is_oidc",
|
||||
table: "custom_app_secrets");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "description",
|
||||
table: "custom_app_secrets",
|
||||
newName: "remarks");
|
||||
}
|
||||
}
|
||||
}
|
4014
DysonNetwork.Sphere/Data/Migrations/20250629084150_AuthSessionWithApp.Designer.cs
generated
Normal file
4014
DysonNetwork.Sphere/Data/Migrations/20250629084150_AuthSessionWithApp.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Sphere.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AuthSessionWithApp : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "app_id",
|
||||
table: "auth_sessions",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_auth_sessions_app_id",
|
||||
table: "auth_sessions",
|
||||
column: "app_id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_auth_sessions_custom_apps_app_id",
|
||||
table: "auth_sessions",
|
||||
column: "app_id",
|
||||
principalTable: "custom_apps",
|
||||
principalColumn: "id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_auth_sessions_custom_apps_app_id",
|
||||
table: "auth_sessions");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_auth_sessions_app_id",
|
||||
table: "auth_sessions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "app_id",
|
||||
table: "auth_sessions");
|
||||
}
|
||||
}
|
||||
}
|
3993
DysonNetwork.Sphere/Data/Migrations/20250629123136_CustomAppsRefine.Designer.cs
generated
Normal file
3993
DysonNetwork.Sphere/Data/Migrations/20250629123136_CustomAppsRefine.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +1,8 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Sphere.Account;
|
||||
using DysonNetwork.Sphere.Storage;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Sphere.Developer;
|
||||
@ -12,28 +15,55 @@ public enum CustomAppStatus
|
||||
Suspended
|
||||
}
|
||||
|
||||
public class CustomApp : ModelBase
|
||||
public class CustomApp : ModelBase, IIdentifiedResource
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(1024)] public string Slug { get; set; } = null!;
|
||||
[MaxLength(1024)] public string Name { get; set; } = null!;
|
||||
[MaxLength(4096)] public string? Description { get; set; }
|
||||
public CustomAppStatus Status { get; set; } = CustomAppStatus.Developing;
|
||||
public Instant? VerifiedAt { get; set; }
|
||||
[MaxLength(4096)] public string? VerifiedAs { get; set; }
|
||||
|
||||
[JsonIgnore] private ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>();
|
||||
|
||||
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
|
||||
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
|
||||
|
||||
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
|
||||
[Column(TypeName = "jsonb")] public CustomAppOauthConfig? OauthConfig { get; set; }
|
||||
[Column(TypeName = "jsonb")] public CustomAppLinks? Links { get; set; }
|
||||
|
||||
[JsonIgnore] public ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>();
|
||||
|
||||
public Guid PublisherId { get; set; }
|
||||
public Publisher.Publisher Developer { get; set; } = null!;
|
||||
|
||||
[NotMapped] public string ResourceIdentifier => "custom-app/" + Id;
|
||||
}
|
||||
|
||||
public class CustomAppLinks
|
||||
{
|
||||
[MaxLength(8192)] public string? HomePage { get; set; }
|
||||
[MaxLength(8192)] public string? PrivacyPolicy { get; set; }
|
||||
[MaxLength(8192)] public string? TermsOfService { get; set; }
|
||||
}
|
||||
|
||||
public class CustomAppOauthConfig
|
||||
{
|
||||
[MaxLength(1024)] public string? ClientUri { get; set; }
|
||||
[MaxLength(4096)] public string[] RedirectUris { get; set; } = [];
|
||||
[MaxLength(4096)] public string[]? PostLogoutRedirectUris { get; set; }
|
||||
[MaxLength(256)] public string[]? AllowedScopes { get; set; } = ["openid", "profile", "email"];
|
||||
[MaxLength(256)] public string[] AllowedGrantTypes { get; set; } = ["authorization_code", "refresh_token"];
|
||||
public bool RequirePkce { get; set; } = true;
|
||||
public bool AllowOfflineAccess { get; set; } = false;
|
||||
}
|
||||
|
||||
public class CustomAppSecret : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(1024)] public string Secret { get; set; } = null!;
|
||||
[MaxLength(4096)] public string? Remarks { get; set; } = null!;
|
||||
[MaxLength(4096)] public string? Description { get; set; } = null!;
|
||||
public Instant? ExpiredAt { get; set; }
|
||||
|
||||
public bool IsOidc { get; set; } = false; // Indicates if this secret is for OIDC/OAuth
|
||||
|
||||
public Guid AppId { get; set; }
|
||||
public CustomApp App { get; set; } = null!;
|
||||
}
|
@ -1,63 +1,129 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Sphere.Account;
|
||||
using DysonNetwork.Sphere.Publisher;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DysonNetwork.Sphere.Developer;
|
||||
|
||||
[ApiController]
|
||||
[Route("/developers/apps")]
|
||||
public class CustomAppController(CustomAppService customAppService, PublisherService ps) : ControllerBase
|
||||
[Route("/api/developers/{pubName}/apps")]
|
||||
public class CustomAppController(CustomAppService customApps, PublisherService ps) : ControllerBase
|
||||
{
|
||||
[HttpGet("")]
|
||||
public async Task<IActionResult> GetApps([FromQuery] Guid publisherId)
|
||||
public record CustomAppRequest(
|
||||
[MaxLength(1024)] string? Slug,
|
||||
[MaxLength(1024)] string? Name,
|
||||
[MaxLength(4096)] string? Description,
|
||||
string? PictureId,
|
||||
string? BackgroundId,
|
||||
CustomAppStatus? Status,
|
||||
CustomAppLinks? Links,
|
||||
CustomAppOauthConfig? OauthConfig
|
||||
);
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ListApps([FromRoute] string pubName)
|
||||
{
|
||||
var 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);
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return Ok(app);
|
||||
}
|
||||
|
||||
[HttpPost("")]
|
||||
public async Task<IActionResult> CreateApp([FromBody] CreateAppDto dto)
|
||||
[HttpPost]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CreateApp([FromRoute] string pubName, [FromBody] CustomAppRequest request)
|
||||
{
|
||||
var app = await customAppService.CreateAppAsync(dto.PublisherId, dto.Name, dto.Slug);
|
||||
if (app == null)
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Slug))
|
||||
return BadRequest("Name and slug are required");
|
||||
|
||||
var publisher = await ps.GetPublisherByName(pubName);
|
||||
if (publisher is null) return NotFound();
|
||||
|
||||
if (!await ps.IsMemberWithRole(publisher.Id, currentUser.Id, PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the publisher to create a custom app");
|
||||
if (!await ps.HasFeature(publisher.Id, PublisherFeatureFlag.Develop))
|
||||
return StatusCode(403, "Publisher must be a developer to create a custom app");
|
||||
|
||||
try
|
||||
{
|
||||
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}")]
|
||||
public async Task<IActionResult> UpdateApp(Guid id, [FromBody] UpdateAppDto dto)
|
||||
[HttpPatch("{id:guid}")]
|
||||
[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)
|
||||
{
|
||||
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}")]
|
||||
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 (!result)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
var publisher = await ps.GetPublisherByName(pubName);
|
||||
if (publisher is null) return NotFound();
|
||||
|
||||
if (!await ps.IsMemberWithRole(publisher.Id, currentUser.Id, PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the publisher to delete a custom app");
|
||||
|
||||
var app = await customApps.GetAppAsync(id, publisherId: publisher.Id);
|
||||
if (app == null)
|
||||
return NotFound();
|
||||
|
||||
var result = await customApps.DeleteAppAsync(id);
|
||||
if (!result)
|
||||
return NotFound();
|
||||
}
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateAppDto(Guid PublisherId, string Name, string Slug);
|
||||
public record UpdateAppDto(string Name, string Slug);
|
||||
}
|
@ -1,29 +1,58 @@
|
||||
using DysonNetwork.Sphere.Publisher;
|
||||
using DysonNetwork.Sphere.Storage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
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
|
||||
{
|
||||
Name = name,
|
||||
Slug = slug,
|
||||
PublisherId = publisher.Id
|
||||
Slug = request.Slug!,
|
||||
Name = request.Name!,
|
||||
Description = request.Description,
|
||||
Status = request.Status ?? CustomAppStatus.Developing,
|
||||
Links = request.Links,
|
||||
OauthConfig = request.OauthConfig,
|
||||
PublisherId = pub.Id
|
||||
};
|
||||
|
||||
if (request.PictureId is not null)
|
||||
{
|
||||
var picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync();
|
||||
if (picture is null)
|
||||
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
|
||||
|
||||
app.Picture = picture.ToReferenceObject();
|
||||
|
||||
// Create a new reference
|
||||
await fileRefService.CreateReferenceAsync(
|
||||
picture.Id,
|
||||
"custom-apps.picture",
|
||||
app.ResourceIdentifier
|
||||
);
|
||||
}
|
||||
|
||||
if (request.BackgroundId is not null)
|
||||
{
|
||||
var background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync();
|
||||
if (background is null)
|
||||
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
|
||||
|
||||
app.Background = background.ToReferenceObject();
|
||||
|
||||
// Create a new reference
|
||||
await fileRefService.CreateReferenceAsync(
|
||||
background.Id,
|
||||
"custom-apps.background",
|
||||
app.ResourceIdentifier
|
||||
);
|
||||
}
|
||||
|
||||
db.CustomApps.Add(app);
|
||||
await db.SaveChangesAsync();
|
||||
@ -31,9 +60,12 @@ public class CustomAppService(AppDatabase db, PublisherService ps)
|
||||
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)
|
||||
@ -41,17 +73,60 @@ public class CustomAppService(AppDatabase db, PublisherService ps)
|
||||
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 (app == null)
|
||||
if (request.Slug is not null)
|
||||
app.Slug = request.Slug;
|
||||
if (request.Name is not null)
|
||||
app.Name = request.Name;
|
||||
if (request.Description is not null)
|
||||
app.Description = request.Description;
|
||||
if (request.Status is not null)
|
||||
app.Status = request.Status.Value;
|
||||
if (request.Links is not null)
|
||||
app.Links = request.Links;
|
||||
if (request.OauthConfig is not null)
|
||||
app.OauthConfig = request.OauthConfig;
|
||||
|
||||
if (request.PictureId is not null)
|
||||
{
|
||||
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;
|
||||
app.Slug = slug;
|
||||
|
||||
db.Update(app);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return app;
|
||||
@ -67,6 +142,8 @@ public class CustomAppService(AppDatabase db, PublisherService ps)
|
||||
|
||||
db.CustomApps.Remove(app);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await fileRefService.DeleteResourceReferencesAsync(app.ResourceIdentifier);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
142
DysonNetwork.Sphere/Developer/DeveloperController.cs
Normal file
142
DysonNetwork.Sphere/Developer/DeveloperController.cs
Normal 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; }
|
||||
}
|
||||
}
|
@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace DysonNetwork.Sphere.Discovery;
|
||||
|
||||
[ApiController]
|
||||
[Route("/discovery")]
|
||||
[Route("/api/discovery")]
|
||||
public class DiscoveryController(DiscoveryService discoveryService) : ControllerBase
|
||||
{
|
||||
[HttpGet("realms")]
|
||||
|
@ -26,6 +26,7 @@
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.12.1" />
|
||||
<PackageReference Include="Livekit.Server.Sdk.Dotnet" Version="1.0.8" />
|
||||
<PackageReference Include="MailKit" Version="4.11.0" />
|
||||
<PackageReference Include="Markdig" Version="0.41.3" />
|
||||
<PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2" />
|
||||
|
@ -6,6 +6,7 @@ using DysonNetwork.Sphere;
|
||||
using DysonNetwork.Sphere.Account;
|
||||
using DysonNetwork.Sphere.Chat;
|
||||
using DysonNetwork.Sphere.Connection.WebReader;
|
||||
using DysonNetwork.Sphere.Developer;
|
||||
using DysonNetwork.Sphere.Storage;
|
||||
using DysonNetwork.Sphere.Wallet;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@ -978,6 +979,10 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Guid?>("AppId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("app_id");
|
||||
|
||||
b.Property<Guid>("ChallengeId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("challenge_id");
|
||||
@ -1013,6 +1018,9 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
b.HasIndex("AccountId")
|
||||
.HasDatabaseName("ix_auth_sessions_account_id");
|
||||
|
||||
b.HasIndex("AppId")
|
||||
.HasDatabaseName("ix_auth_sessions_app_id");
|
||||
|
||||
b.HasIndex("ChallengeId")
|
||||
.HasDatabaseName("ix_auth_sessions_challenge_id");
|
||||
|
||||
@ -1500,6 +1508,10 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<CloudFileReferenceObject>("Background")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("background");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
@ -1508,12 +1520,29 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<CustomAppLinks>("Links")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("links");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<CustomAppOauthConfig>("OauthConfig")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("oauth_config");
|
||||
|
||||
b.Property<CloudFileReferenceObject>("Picture")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("picture");
|
||||
|
||||
b.Property<Guid>("PublisherId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("publisher_id");
|
||||
@ -1532,14 +1561,9 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("VerifiedAs")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("verified_as");
|
||||
|
||||
b.Property<Instant?>("VerifiedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("verified_at");
|
||||
b.Property<VerificationMark>("Verification")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("verification");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_custom_apps");
|
||||
@ -1569,14 +1593,18 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("Remarks")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("remarks");
|
||||
b.Property<bool>("IsOidc")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_oidc");
|
||||
|
||||
b.Property<string>("Secret")
|
||||
.IsRequired()
|
||||
@ -1594,6 +1622,10 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
b.HasIndex("AppId")
|
||||
.HasDatabaseName("ix_custom_app_secrets_app_id");
|
||||
|
||||
b.HasIndex("Secret")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_custom_app_secrets_secret");
|
||||
|
||||
b.ToTable("custom_app_secrets", (string)null);
|
||||
});
|
||||
|
||||
@ -3283,6 +3315,11 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_auth_sessions_accounts_account_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Sphere.Developer.CustomApp", "App")
|
||||
.WithMany()
|
||||
.HasForeignKey("AppId")
|
||||
.HasConstraintName("fk_auth_sessions_custom_apps_app_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Sphere.Auth.Challenge", "Challenge")
|
||||
.WithMany()
|
||||
.HasForeignKey("ChallengeId")
|
||||
@ -3292,6 +3329,8 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("App");
|
||||
|
||||
b.Navigation("Challenge");
|
||||
});
|
||||
|
||||
@ -3444,7 +3483,7 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Developer.CustomAppSecret", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Sphere.Developer.CustomApp", "App")
|
||||
.WithMany()
|
||||
.WithMany("Secrets")
|
||||
.HasForeignKey("AppId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
@ -3556,7 +3595,7 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Publisher.PublisherFeature", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Sphere.Publisher.Publisher", "Publisher")
|
||||
.WithMany()
|
||||
.WithMany("Features")
|
||||
.HasForeignKey("PublisherId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
@ -3895,6 +3934,11 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
b.Navigation("Articles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Developer.CustomApp", b =>
|
||||
{
|
||||
b.Navigation("Secrets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Permission.PermissionGroup", b =>
|
||||
{
|
||||
b.Navigation("Members");
|
||||
@ -3913,6 +3957,8 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
{
|
||||
b.Navigation("Collections");
|
||||
|
||||
b.Navigation("Features");
|
||||
|
||||
b.Navigation("Members");
|
||||
|
||||
b.Navigation("Posts");
|
||||
|
@ -1,78 +1,225 @@
|
||||
@page "/web/account/profile"
|
||||
@page "//account/profile"
|
||||
@model DysonNetwork.Sphere.Pages.Account.ProfileModel
|
||||
@{
|
||||
ViewData["Title"] = "Profile";
|
||||
}
|
||||
|
||||
<div class="h-full flex items-center justify-center bg-gray-100 dark:bg-gray-900 py-12">
|
||||
<div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md w-full max-w-2xl">
|
||||
<h1 class="text-3xl font-bold text-center text-gray-900 dark:text-white mb-8">User Profile</h1>
|
||||
|
||||
@if (Model.Account != null)
|
||||
{
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">Account Information</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Username:</strong> @Model.Account.Name</p>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Nickname:</strong> @Model.Account.Nick</p>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Language:</strong> @Model.Account.Language</p>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-2">
|
||||
<strong>Activated:</strong> @Model.Account.ActivatedAt?.ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture)
|
||||
</p>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Superuser:</strong> @Model.Account.IsSuperuser
|
||||
</p>
|
||||
@if (Model.Account != null)
|
||||
{
|
||||
<div class="p-4 sm:p-8 bg-base-200">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold">Profile Settings</h1>
|
||||
<p class="text-base-content/70 mt-2">Manage your account information and preferences</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">Profile Details</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-2">
|
||||
<strong>Name:</strong> @Model.Account.Profile.FirstName @Model.Account.Profile.MiddleName @Model.Account.Profile.LastName
|
||||
</p>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Bio:</strong> @Model.Account.Profile.Bio</p>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Gender:</strong> @Model.Account.Profile.Gender
|
||||
</p>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-2">
|
||||
<strong>Location:</strong> @Model.Account.Profile.Location</p>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-2">
|
||||
<strong>Birthday:</strong> @Model.Account.Profile.Birthday?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture)
|
||||
</p>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-2">
|
||||
<strong>Experience:</strong> @Model.Account.Profile.Experience</p>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Level:</strong> @Model.Account.Profile.Level
|
||||
</p>
|
||||
</div>
|
||||
<!-- Two Column Layout -->
|
||||
<div class="flex flex-col md:flex-row gap-6">
|
||||
<!-- Left Pane - Profile Card -->
|
||||
<div class="w-full md:w-1/3 lg:w-1/4">
|
||||
<div class="card bg-base-100 shadow-xl sticky top-8">
|
||||
<div class="card-body items-center text-center">
|
||||
<!-- Avatar -->
|
||||
<div class="avatar avatar-placeholder mb-4">
|
||||
<div class="bg-neutral text-neutral-content rounded-full w-32">
|
||||
<span class="text-4xl">@Model.Account.Name?[..1].ToUpper()</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">Access Token</h2>
|
||||
<div class="flex items-center">
|
||||
<input type="text" 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"/>
|
||||
<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>
|
||||
<!-- Basic Info -->
|
||||
<h2 class="card-title">@Model.Account.Nick</h2>
|
||||
<p class="font-mono text-sm">@@@Model.Account.Name</p>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats stats-vertical shadow mt-4">
|
||||
<div class="stat">
|
||||
<div class="stat-title">Level</div>
|
||||
<div class="stat-value">@Model.Account.Profile.Level</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">XP</div>
|
||||
<div class="stat-value">@Model.Account.Profile.Experience</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Member since</div>
|
||||
<div class="stat-value">@Model.Account.CreatedAt.ToDateTimeUtc().ToString("yyyy/MM")</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Pane - Tabbed Content -->
|
||||
<div class="flex-1">
|
||||
<div role="tablist" class="tabs tabs-lift w-full">
|
||||
<input type="radio" name="profile-tabs" role="tab" class="tab" aria-label="Profile" checked />
|
||||
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 p-6">
|
||||
<h2 class="text-xl font-semibold mb-6">Profile Information</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium mb-4">Basic Information</h3>
|
||||
<dl class="space-y-4">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-base-content/70">Full Name</dt>
|
||||
<dd class="mt-1 text-sm">@($"{Model.Account.Profile.FirstName} {Model.Account.Profile.MiddleName} {Model.Account.Profile.LastName}".Trim())</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-base-content/70">Username</dt>
|
||||
<dd class="mt-1 text-sm">@Model.Account.Name</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-base-content/70">Nickname</dt>
|
||||
<dd class="mt-1 text-sm">@Model.Account.Nick</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-base-content/70">Gender</dt>
|
||||
<dd class="mt-1 text-sm">@Model.Account.Profile.Gender</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-medium mb-4">Additional Details</h3>
|
||||
<dl class="space-y-4">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-base-content/70">Location</dt>
|
||||
<dd class="mt-1 text-sm">@Model.Account.Profile.Location</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-base-content/70">Birthday</dt>
|
||||
<dd class="mt-1 text-sm">@Model.Account.Profile.Birthday?.ToString("MMMM d, yyyy", System.Globalization.CultureInfo.InvariantCulture)</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-base-content/70">Bio</dt>
|
||||
<dd class="mt-1 text-sm">@(string.IsNullOrEmpty(Model.Account.Profile.Bio) ? "No bio provided" : Model.Account.Profile.Bio)</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="radio" name="profile-tabs" role="tab" class="tab" aria-label="Security" />
|
||||
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 p-6">
|
||||
<h2 class="text-xl font-semibold mb-2">Security Settings</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="card bg-base-300 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">Access Token</h3>
|
||||
<p>Use this token to authenticate with the API</p>
|
||||
<div class="form-control">
|
||||
<div class="join">
|
||||
<input type="password" id="accessToken" value="@Model.AccessToken" readonly class="input input-bordered join-item flex-grow" />
|
||||
<button onclick="copyAccessToken()" class="btn join-item">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/70 mt-2">Keep this token secure and do not share it with anyone.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="radio" name="profile-tabs" role="tab" class="tab" aria-label="Sessions" />
|
||||
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 p-6">
|
||||
<h2 class="text-xl font-semibold">Active Sessions</h2>
|
||||
<p class="text-base-content/70 mb-3">This is a list of devices that have logged into your account. Revoke any sessions that you do not recognize.</p>
|
||||
|
||||
<div class="card bg-base-300 shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="avatar">
|
||||
<div class="mask mask-squircle w-12 h-12">
|
||||
<svg class="h-full w-full text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h8a2 2 0 012 2v12a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 0v12h8V4H6z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold">Current Session</div>
|
||||
<div class="text-sm opacity-50">@($"{Request.Headers["User-Agent"]} • {DateTime.Now:MMMM d, yyyy 'at' h:mm tt}")</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<button type="button" class="btn btn-error">Sign out all other sessions</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logout Button -->
|
||||
<div class="mt-6 flex justify-end">
|
||||
<form method="post" asp-page-handler="Logout">
|
||||
<button type="submit" class="btn btn-error">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" asp-page-handler="Logout" class="text-center">
|
||||
<button type="submit"
|
||||
class="bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50">
|
||||
Logout
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-red-500 text-center">User profile not found. Please log in.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="hero min-h-screen bg-base-200">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-md">
|
||||
<div class="text-error text-5xl mb-4">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
</div>
|
||||
<h1 class="text-5xl font-bold">Profile Not Found</h1>
|
||||
<p class="py-6">User profile not found. Please log in to continue.</p>
|
||||
<a href="/auth/login" class="btn btn-primary">Go to Login</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<script>
|
||||
function copyAccessToken() {
|
||||
var copyText = document.getElementById("accessToken");
|
||||
copyText.select();
|
||||
copyText.setSelectionRange(0, 99999); /* For mobile devices */
|
||||
document.execCommand("copy");
|
||||
alert("Access Token copied to clipboard!");
|
||||
}
|
||||
</script>
|
||||
@section Scripts {
|
||||
<script>
|
||||
// Copy access token to clipboard
|
||||
function copyAccessToken() {
|
||||
const copyText = document.getElementById("accessToken");
|
||||
copyText.select();
|
||||
copyText.setSelectionRange(0, 99999);
|
||||
document.execCommand("copy");
|
||||
|
||||
// Show tooltip or notification
|
||||
const originalText = event.target.innerHTML;
|
||||
event.target.innerHTML = '<i class="fas fa-check mr-1"></i> Copied!';
|
||||
event.target.disabled = true;
|
||||
|
||||
setTimeout(() => {
|
||||
event.target.innerHTML = originalText;
|
||||
event.target.disabled = false;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Toggle password visibility
|
||||
function togglePasswordVisibility(inputId) {
|
||||
const input = document.getElementById(inputId);
|
||||
const icon = document.querySelector(`[onclick="togglePasswordVisibility('${inputId}')"] i`);
|
||||
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.classList.remove('fa-eye');
|
||||
icon.classList.add('fa-eye-slash');
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.classList.remove('fa-eye-slash');
|
||||
icon.classList.add('fa-eye');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
}
|
||||
|
113
DysonNetwork.Sphere/Pages/Auth/Authorize.cshtml
Normal file
113
DysonNetwork.Sphere/Pages/Auth/Authorize.cshtml
Normal file
@ -0,0 +1,113 @@
|
||||
@page "/auth/authorize"
|
||||
@model DysonNetwork.Sphere.Pages.Auth.AuthorizeModel
|
||||
@{
|
||||
ViewData["Title"] = "Authorize Application";
|
||||
}
|
||||
|
||||
<div class="h-full flex items-center justify-center bg-base-200 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="card w-full max-w-md bg-base-100 shadow-xl">
|
||||
<div class="card-body px-8 py-7">
|
||||
<h2 class="card-title justify-center text-2xl font-bold">
|
||||
Authorize Application
|
||||
</h2>
|
||||
@if (!string.IsNullOrEmpty(Model.AppName))
|
||||
{
|
||||
<div class="mt-6">
|
||||
<div class="flex items-center justify-center">
|
||||
@if (!string.IsNullOrEmpty(Model.AppLogo))
|
||||
{
|
||||
<div class="avatar">
|
||||
<div class="w-12 rounded">
|
||||
<img src="@Model.AppLogo" alt="@Model.AppName logo" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
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 class="ml-4 text-left">
|
||||
<h3 class="text-lg font-medium">@Model.AppName</h3>
|
||||
@if (!string.IsNullOrEmpty(Model.AppUri))
|
||||
{
|
||||
<a href="@Model.AppUri" class="text-sm link link-primary" target="_blank" rel="noopener noreferrer">
|
||||
@Model.AppUri
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<p class="mt-6 text-sm text-center">
|
||||
When you authorize this application, you consent to the following permissions:
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<ul class="menu bg-base-200 rounded-box w-full">
|
||||
@if (Model.Scope != null)
|
||||
{
|
||||
var scopeDescriptions = new Dictionary<string, (string Name, string Description)>
|
||||
{
|
||||
["openid"] = ("OpenID", "Read your basic profile information"),
|
||||
["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)))
|
||||
{
|
||||
var scopeInfo = scopeDescriptions.GetValueOrDefault(scope, (scope, scope.Replace('_', ' ')));
|
||||
<li>
|
||||
<a>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-success" viewBox="0 0 20 20" fill="currentColor">
|
||||
<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>
|
||||
<div>
|
||||
<p class="font-medium">@scopeInfo.Item1</p>
|
||||
<p class="text-xs text-base-content/70">@scopeInfo.Item2</p>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<form method="post" class="mt-8 space-y-4">
|
||||
<input type="hidden" asp-for="ClientIdString" />
|
||||
<input type="hidden" asp-for="ResponseType" name="response_type" />
|
||||
<input type="hidden" asp-for="RedirectUri" name="redirect_uri" />
|
||||
<input type="hidden" asp-for="Scope" name="scope" />
|
||||
<input type="hidden" asp-for="State" name="state" />
|
||||
<input type="hidden" asp-for="Nonce" name="nonce" />
|
||||
<input type="hidden" asp-for="ReturnUrl" name="returnUrl" />
|
||||
<input type="hidden" asp-for="CodeChallenge" value="@HttpContext.Request.Query["code_challenge"]" />
|
||||
<input type="hidden" asp-for="CodeChallengeMethod" value="@HttpContext.Request.Query["code_challenge_method"]" />
|
||||
<input type="hidden" asp-for="ResponseMode" value="@HttpContext.Request.Query["response_mode"]" />
|
||||
|
||||
<div class="card-actions justify-center flex gap-4">
|
||||
<button type="submit" name="allow" value="true" class="btn btn-primary flex-1">Allow</button>
|
||||
<button type="submit" name="allow" value="false" class="btn btn-ghost flex-1">Deny</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@functions {
|
||||
private string GetScopeDisplayName(string scope)
|
||||
{
|
||||
return scope switch
|
||||
{
|
||||
"openid" => "View your basic profile information",
|
||||
"profile" => "View your profile information (name, picture, etc.)",
|
||||
"email" => "View your email address",
|
||||
"offline_access" => "Access your information while you're not using the app",
|
||||
_ => scope
|
||||
};
|
||||
}
|
||||
}
|
233
DysonNetwork.Sphere/Pages/Auth/Authorize.cshtml.cs
Normal file
233
DysonNetwork.Sphere/Pages/Auth/Authorize.cshtml.cs
Normal file
@ -0,0 +1,233 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using DysonNetwork.Sphere.Auth.OidcProvider.Services;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Sphere.Auth;
|
||||
using DysonNetwork.Sphere.Auth.OidcProvider.Responses;
|
||||
using DysonNetwork.Sphere.Developer;
|
||||
|
||||
namespace DysonNetwork.Sphere.Pages.Auth;
|
||||
|
||||
public class AuthorizeModel(OidcProviderService oidcService, IConfiguration configuration) : PageModel
|
||||
{
|
||||
[BindProperty(SupportsGet = true)] public string? ReturnUrl { get; set; }
|
||||
|
||||
[BindProperty(SupportsGet = true, Name = "client_id")]
|
||||
[Required(ErrorMessage = "The client_id parameter is required")]
|
||||
public string? ClientIdString { get; set; }
|
||||
|
||||
public Guid ClientId { get; set; }
|
||||
|
||||
[BindProperty(SupportsGet = true, Name = "response_type")]
|
||||
public string ResponseType { get; set; } = "code";
|
||||
|
||||
[BindProperty(SupportsGet = true, Name = "redirect_uri")]
|
||||
public string? RedirectUri { get; set; }
|
||||
|
||||
[BindProperty(SupportsGet = true)] public string? Scope { get; set; }
|
||||
|
||||
[BindProperty(SupportsGet = true)] public string? State { get; set; }
|
||||
|
||||
[BindProperty(SupportsGet = true)] public string? Nonce { get; set; }
|
||||
|
||||
|
||||
[BindProperty(SupportsGet = true, Name = "code_challenge")]
|
||||
public string? CodeChallenge { get; set; }
|
||||
|
||||
[BindProperty(SupportsGet = true, Name = "code_challenge_method")]
|
||||
public string? CodeChallengeMethod { get; set; }
|
||||
|
||||
[BindProperty(SupportsGet = true, Name = "response_mode")]
|
||||
public string? ResponseMode { get; set; }
|
||||
|
||||
public string? AppName { get; set; }
|
||||
public string? AppLogo { get; set; }
|
||||
public string? AppUri { get; set; }
|
||||
public string[]? RequestedScopes { get; set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync()
|
||||
{
|
||||
// First check if user is authenticated
|
||||
if (HttpContext.Items["CurrentUser"] is not Sphere.Account.Account currentUser)
|
||||
{
|
||||
var returnUrl = Uri.EscapeDataString($"{Request.Path}{Request.QueryString}");
|
||||
return RedirectToPage("/Auth/Login", new { returnUrl });
|
||||
}
|
||||
|
||||
// Validate client_id
|
||||
if (string.IsNullOrEmpty(ClientIdString) || !Guid.TryParse(ClientIdString, out var clientId))
|
||||
{
|
||||
ModelState.AddModelError("client_id", "Invalid client_id format");
|
||||
return BadRequest("Invalid client_id format");
|
||||
}
|
||||
|
||||
ClientId = clientId;
|
||||
|
||||
// Get client info
|
||||
var client = await oidcService.FindClientByIdAsync(ClientId);
|
||||
if (client == null)
|
||||
{
|
||||
ModelState.AddModelError("client_id", "Client not found");
|
||||
return NotFound("Client not found");
|
||||
}
|
||||
|
||||
var config = client.OauthConfig;
|
||||
if (config is null)
|
||||
{
|
||||
ModelState.AddModelError("client_id", "Client was not available for use OAuth / OIDC");
|
||||
return BadRequest("Client was not enabled for OAuth / OIDC");
|
||||
}
|
||||
|
||||
// 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;
|
||||
AppLogo = client.Picture is not null ? $"{baseUrl}/files/{client.Picture.Id}" : null;
|
||||
AppUri = config.ClientUri;
|
||||
RequestedScopes = (Scope ?? "openid profile").Split(' ').Distinct().ToArray();
|
||||
|
||||
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)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Sphere.Account.Account currentUser) return Unauthorized();
|
||||
|
||||
// First, validate the client ID
|
||||
if (string.IsNullOrEmpty(ClientIdString) || !Guid.TryParse(ClientIdString, out var clientId))
|
||||
{
|
||||
ModelState.AddModelError("client_id", "Invalid client_id format");
|
||||
return BadRequest("Invalid client_id format");
|
||||
}
|
||||
|
||||
ClientId = clientId;
|
||||
|
||||
// Check if a client exists
|
||||
var client = await oidcService.FindClientByIdAsync(ClientId);
|
||||
if (client == null)
|
||||
{
|
||||
ModelState.AddModelError("client_id", "Client not found");
|
||||
return NotFound("Client not found");
|
||||
}
|
||||
|
||||
if (!allow)
|
||||
{
|
||||
// User denied the authorization request
|
||||
if (string.IsNullOrEmpty(RedirectUri))
|
||||
return BadRequest("No redirect_uri provided");
|
||||
|
||||
var deniedUriBuilder = new UriBuilder(RedirectUri);
|
||||
var deniedQuery = System.Web.HttpUtility.ParseQueryString(deniedUriBuilder.Query);
|
||||
deniedQuery["error"] = "access_denied";
|
||||
deniedQuery["error_description"] = "The user denied the authorization request";
|
||||
if (!string.IsNullOrEmpty(State)) deniedQuery["state"] = State;
|
||||
deniedUriBuilder.Query = deniedQuery.ToString();
|
||||
|
||||
return Redirect(deniedUriBuilder.ToString());
|
||||
}
|
||||
|
||||
// User approved the request
|
||||
if (string.IsNullOrEmpty(RedirectUri))
|
||||
{
|
||||
ModelState.AddModelError("redirect_uri", "No redirect_uri provided");
|
||||
return BadRequest("No redirect_uri provided");
|
||||
}
|
||||
|
||||
// Generate authorization code
|
||||
var authCode = await oidcService.GenerateAuthorizationCodeAsync(
|
||||
clientId: ClientId,
|
||||
userId: currentUser.Id,
|
||||
redirectUri: RedirectUri,
|
||||
scopes: Scope?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>(),
|
||||
codeChallenge: CodeChallenge,
|
||||
codeChallengeMethod: CodeChallengeMethod,
|
||||
nonce: Nonce);
|
||||
|
||||
// Build the redirect URI with the authorization code
|
||||
var redirectUri = new UriBuilder(RedirectUri);
|
||||
var query = System.Web.HttpUtility.ParseQueryString(redirectUri.Query);
|
||||
|
||||
// Add the authorization code
|
||||
query["code"] = authCode;
|
||||
|
||||
// Add state if provided (for CSRF protection)
|
||||
if (!string.IsNullOrEmpty(State))
|
||||
query["state"] = State;
|
||||
|
||||
// Set the query string
|
||||
redirectUri.Query = query.ToString();
|
||||
|
||||
// Redirect back to the client with the authorization code
|
||||
return Redirect(redirectUri.ToString());
|
||||
}
|
||||
}
|
@ -5,10 +5,12 @@
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div class="max-w-lg w-full mx-auto p-6 text-center">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Authentication Successful</h1>
|
||||
<p class="mb-6 text-gray-900 dark:text-white">You can now close this window and return to the application.</p>
|
||||
<div class="hero min-h-full bg-base-200">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-md">
|
||||
<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>
|
||||
|
||||
@ -44,4 +46,4 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
}
|
@ -1,13 +1,16 @@
|
||||
@page "/web/auth/challenge/{id:guid}"
|
||||
@page "//auth/challenge/{id:guid}"
|
||||
@model DysonNetwork.Sphere.Pages.Auth.ChallengeModel
|
||||
@{
|
||||
// This page is kept for backward compatibility
|
||||
// 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="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md w-full max-w-md text-center">
|
||||
<p>Redirecting to authentication page...</p>
|
||||
<div class="hero min-h-full bg-base-200">
|
||||
<div class="hero-content text-center">
|
||||
<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>
|
||||
|
@ -7,10 +7,13 @@ namespace DysonNetwork.Sphere.Pages.Auth
|
||||
{
|
||||
[BindProperty(SupportsGet = true)]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[BindProperty(SupportsGet = true)]
|
||||
public string? ReturnUrl { get; set; }
|
||||
|
||||
public IActionResult OnGet()
|
||||
{
|
||||
return RedirectToPage("SelectFactor", new { id = Id });
|
||||
return RedirectToPage("SelectFactor", new { id = Id, returnUrl = ReturnUrl });
|
||||
}
|
||||
}
|
||||
}
|
@ -1,26 +1,37 @@
|
||||
@page "/web/auth/login"
|
||||
@page "//auth/login"
|
||||
@model DysonNetwork.Sphere.Pages.Auth.LoginModel
|
||||
@{
|
||||
ViewData["Title"] = "Login";
|
||||
ViewData["Title"] = "Login | Solar Network";
|
||||
var returnUrl = Model.ReturnUrl ?? "";
|
||||
}
|
||||
|
||||
<div class="h-full flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||
<div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md w-full max-w-md">
|
||||
<h1 class="text-2xl font-bold text-center text-gray-900 dark:text-white mb-6">Login</h1>
|
||||
|
||||
<form method="post">
|
||||
<div class="mb-4">
|
||||
<label asp-for="Username"
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"></label>
|
||||
<input 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 asp-validation-for="Username" class="text-red-500 text-sm mt-1"></span>
|
||||
<div class="hero min-h-full bg-base-200">
|
||||
<div class="hero-content w-full max-w-md">
|
||||
<div class="card w-full bg-base-100 shadow-xl">
|
||||
<div class="card-body px-8 py-7">
|
||||
<h1 class="card-title justify-center text-2xl font-bold">Welcome back!</h1>
|
||||
<p class="text-center">Login to your Solar Network account to continue.</p>
|
||||
<form method="post" class="mt-4">
|
||||
<input type="hidden" asp-for="ReturnUrl" value="@returnUrl"/>
|
||||
<div class="form-control">
|
||||
<label class="label" asp-for="Username">
|
||||
<span class="label-text">Username</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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -18,6 +18,10 @@ namespace DysonNetwork.Sphere.Pages.Auth
|
||||
) : PageModel
|
||||
{
|
||||
[BindProperty] [Required] public string Username { get; set; } = string.Empty;
|
||||
|
||||
[BindProperty]
|
||||
[FromQuery]
|
||||
public string? ReturnUrl { get; set; }
|
||||
|
||||
public void OnGet()
|
||||
{
|
||||
@ -36,6 +40,12 @@ namespace DysonNetwork.Sphere.Pages.Auth
|
||||
ModelState.AddModelError(string.Empty, "Account was not found.");
|
||||
return Page();
|
||||
}
|
||||
|
||||
// Store the return URL in TempData to preserve it during the login flow
|
||||
if (!string.IsNullOrEmpty(ReturnUrl) && Url.IsLocalUrl(ReturnUrl))
|
||||
{
|
||||
TempData["ReturnUrl"] = ReturnUrl;
|
||||
}
|
||||
|
||||
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
|
||||
@ -71,11 +81,13 @@ namespace DysonNetwork.Sphere.Pages.Auth
|
||||
await db.AuthChallenges.AddAsync(challenge);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
als.CreateActionLogFromRequest(ActionLogType.ChallengeAttempt,
|
||||
new Dictionary<string, object> { { "challenge_id", challenge.Id } }, Request, account
|
||||
);
|
||||
|
||||
return RedirectToPage("Challenge", new { id = challenge.Id });
|
||||
// If we have a return URL, pass it to the verify page
|
||||
if (TempData.TryGetValue("ReturnUrl", out var returnUrl) && returnUrl is string url)
|
||||
{
|
||||
return RedirectToPage("SelectFactor", new { id = challenge.Id, returnUrl = url });
|
||||
}
|
||||
|
||||
return RedirectToPage("SelectFactor", new { id = challenge.Id });
|
||||
}
|
||||
}
|
||||
}
|
@ -1,58 +1,104 @@
|
||||
@page "/web/auth/challenge/{id:guid}/select-factor"
|
||||
@page "//auth/challenge/{id:guid}/select-factor"
|
||||
@using DysonNetwork.Sphere.Account
|
||||
@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="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md 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="hero min-h-full bg-base-200">
|
||||
<div class="hero-content w-full max-w-md">
|
||||
<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)
|
||||
{
|
||||
<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)
|
||||
@if (Model.AuthChallenge != null && Model.AuthChallenge.StepRemain > 0)
|
||||
{
|
||||
<div class="mb-4">
|
||||
<form method="post" asp-page-handler="SelectFactor" class="w-full" id="factor-@factor.Id">
|
||||
<input type="hidden" name="factorId" value="@factor.Id"/>
|
||||
|
||||
@if (factor.Type == AccountAuthFactorType.EmailCode)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<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
|
||||
</label>
|
||||
<input type="email"
|
||||
id="hint-@factor.Id"
|
||||
name="hint"
|
||||
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"
|
||||
placeholder="Enter your email"
|
||||
required>
|
||||
</div>
|
||||
}
|
||||
|
||||
<button type="submit"
|
||||
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">
|
||||
<div class="font-medium text-gray-900 dark:text-white">@GetFactorDisplayName(factor.Type)</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">@GetFactorDescription(factor.Type)</div>
|
||||
</button>
|
||||
</form>
|
||||
<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>
|
||||
<progress class="progress progress-info w-full" value="@(Model.AuthChallenge.StepTotal - Model.AuthChallenge.StepRemain)" max="@Model.AuthChallenge.StepTotal"></progress>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.AuthChallenge == null)
|
||||
{
|
||||
<div class="alert alert-error">
|
||||
<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>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>
|
||||
|
||||
@ -78,4 +124,4 @@
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
}
|
||||
}
|
@ -13,6 +13,9 @@ public class SelectFactorModel(
|
||||
: PageModel
|
||||
{
|
||||
[BindProperty(SupportsGet = true)] public Guid Id { get; set; }
|
||||
[BindProperty(SupportsGet = true)] public string? ReturnUrl { get; set; }
|
||||
[BindProperty] public Guid SelectedFactorId { get; set; }
|
||||
[BindProperty] public string? Hint { get; set; }
|
||||
|
||||
public Challenge? AuthChallenge { get; set; }
|
||||
public List<AccountAuthFactor> AuthFactors { get; set; } = [];
|
||||
@ -25,7 +28,7 @@ public class SelectFactorModel(
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostSelectFactorAsync(Guid factorId, string? hint = null)
|
||||
public async Task<IActionResult> OnPostSelectFactorAsync()
|
||||
{
|
||||
var challenge = await db.AuthChallenges
|
||||
.Include(e => e.Account)
|
||||
@ -33,47 +36,60 @@ public class SelectFactorModel(
|
||||
|
||||
if (challenge == null) return NotFound();
|
||||
|
||||
var factor = await db.AccountAuthFactors.FindAsync(factorId);
|
||||
var factor = await db.AccountAuthFactors.FindAsync(SelectedFactorId);
|
||||
if (factor?.EnabledAt == null || factor.Trustworthy <= 0)
|
||||
return BadRequest("Invalid authentication method.");
|
||||
|
||||
// Store return URL in TempData to pass to the next step
|
||||
if (!string.IsNullOrEmpty(ReturnUrl))
|
||||
{
|
||||
TempData["ReturnUrl"] = ReturnUrl;
|
||||
}
|
||||
|
||||
// For OTP factors that require code delivery
|
||||
try
|
||||
{
|
||||
// Validate hint for factors that require it
|
||||
if (factor.Type == AccountAuthFactorType.EmailCode
|
||||
&& string.IsNullOrWhiteSpace(hint))
|
||||
// For OTP factors that require code delivery
|
||||
if (
|
||||
factor.Type == AccountAuthFactorType.EmailCode
|
||||
&& string.IsNullOrWhiteSpace(Hint)
|
||||
)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, $"Please provide a {factor.Type.ToString().ToLower().Replace("code", "")} to send the code to.");
|
||||
ModelState.AddModelError(string.Empty,
|
||||
$"Please provide a {factor.Type.ToString().ToLower().Replace("code", "")} to send the code to."
|
||||
);
|
||||
await LoadChallengeAndFactors();
|
||||
return Page();
|
||||
}
|
||||
|
||||
await accounts.SendFactorCode(challenge.Account, factor, hint);
|
||||
await accounts.SendFactorCode(challenge.Account, factor, Hint);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, $"An error occurred while sending the verification code: {ex.Message}");
|
||||
ModelState.AddModelError(string.Empty,
|
||||
$"An error occurred while sending the verification code: {ex.Message}");
|
||||
await LoadChallengeAndFactors();
|
||||
return Page();
|
||||
}
|
||||
|
||||
// Redirect to verify the page with the selected factor
|
||||
return RedirectToPage("VerifyFactor", new { id = Id, factorId });
|
||||
// Redirect to verify page with return URL if available
|
||||
return !string.IsNullOrEmpty(ReturnUrl)
|
||||
? RedirectToPage("VerifyFactor", new { id = Id, factorId = factor.Id, returnUrl = ReturnUrl })
|
||||
: RedirectToPage("VerifyFactor", new { id = Id, factorId = factor.Id });
|
||||
}
|
||||
|
||||
private async Task LoadChallengeAndFactors()
|
||||
{
|
||||
AuthChallenge = await db.AuthChallenges
|
||||
.Include(e => e.Account)
|
||||
.ThenInclude(e => e.AuthFactors)
|
||||
.FirstOrDefaultAsync(e => e.Id == Id);
|
||||
|
||||
if (AuthChallenge != null)
|
||||
{
|
||||
AuthFactors = AuthChallenge.Account.AuthFactors
|
||||
.Where(e => e is { EnabledAt: not null, Trustworthy: >= 1 })
|
||||
.ToList();
|
||||
AuthFactors = await db.AccountAuthFactors
|
||||
.Where(e => e.AccountId == AuthChallenge.Account.Id)
|
||||
.Where(e => e.EnabledAt != null && e.Trustworthy >= 1)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,76 +1,99 @@
|
||||
@page "/web/auth/challenge/{id:guid}/verify/{factorId:guid}"
|
||||
@page "//auth/challenge/{id:guid}/verify/{factorId:guid}"
|
||||
@using DysonNetwork.Sphere.Account
|
||||
@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="bg-white dark:bg-gray-800 px-8 pt-8 pb-4 rounded-lg shadow-md 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>
|
||||
<p class="text-center text-gray-600 dark:text-gray-300 mb-6">
|
||||
@switch (Model.FactorType)
|
||||
{
|
||||
case AccountAuthFactorType.EmailCode:
|
||||
<span>We've sent a verification code to your email.</span>
|
||||
break;
|
||||
case AccountAuthFactorType.InAppCode:
|
||||
<span>Enter the code from your authenticator app.</span>
|
||||
break;
|
||||
case AccountAuthFactorType.TimedCode:
|
||||
<span>Enter your time-based verification code.</span>
|
||||
break;
|
||||
case AccountAuthFactorType.PinCode:
|
||||
<span>Enter your PIN code.</span>
|
||||
break;
|
||||
case AccountAuthFactorType.Password:
|
||||
<span>Enter your password.</span>
|
||||
break;
|
||||
default:
|
||||
<span>Please verify your identity.</span>
|
||||
break;
|
||||
}
|
||||
</p>
|
||||
<div class="hero min-h-full bg-base-200">
|
||||
<div class="hero-content w-full max-w-md">
|
||||
<div class="card w-full bg-base-100 shadow-xl">
|
||||
<div class="card-body px-8 py-7">
|
||||
<h1 class="card-title justify-center text-2xl font-bold">Verify Your Identity</h1>
|
||||
<p class="text-center">
|
||||
@switch (Model.FactorType)
|
||||
{
|
||||
case AccountAuthFactorType.EmailCode:
|
||||
<span>We've sent a verification code to your email.</span>
|
||||
break;
|
||||
case AccountAuthFactorType.InAppCode:
|
||||
<span>Enter the code from your authenticator app.</span>
|
||||
break;
|
||||
case AccountAuthFactorType.TimedCode:
|
||||
<span>Enter your time-based verification code.</span>
|
||||
break;
|
||||
case AccountAuthFactorType.PinCode:
|
||||
<span>Enter your PIN code.</span>
|
||||
break;
|
||||
case AccountAuthFactorType.Password:
|
||||
<span>Enter your password.</span>
|
||||
break;
|
||||
default:
|
||||
<span>Please verify your identity.</span>
|
||||
break;
|
||||
}
|
||||
</p>
|
||||
|
||||
@if (Model.AuthChallenge == null)
|
||||
{
|
||||
<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">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"
|
||||
autofocus />
|
||||
<span asp-validation-for="Code" class="text-red-500 text-sm mt-1"></span>
|
||||
</div>
|
||||
@if (Model.AuthChallenge != null && Model.AuthChallenge.StepRemain > 0)
|
||||
{
|
||||
<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>
|
||||
<progress class="progress progress-info w-full" value="@(Model.AuthChallenge.StepTotal - Model.AuthChallenge.StepRemain)" max="@Model.AuthChallenge.StepTotal"></progress>
|
||||
</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">
|
||||
Verify
|
||||
</button>
|
||||
@if (Model.AuthChallenge == null)
|
||||
{
|
||||
<div class="alert alert-error">
|
||||
<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">
|
||||
<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">
|
||||
← Back to authentication methods
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
<div class="form-control mt-6">
|
||||
<button type="submit" class="btn btn-primary w-full">Verify</button>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<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>
|
||||
|
||||
@section Scripts {
|
||||
@{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); }
|
||||
}
|
||||
}
|
@ -8,56 +8,48 @@ using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Sphere.Pages.Auth
|
||||
{
|
||||
public class VerifyFactorModel : PageModel
|
||||
public class VerifyFactorModel(
|
||||
AppDatabase db,
|
||||
AccountService accounts,
|
||||
AuthService auth,
|
||||
ActionLogService als,
|
||||
IConfiguration configuration,
|
||||
IHttpClientFactory httpClientFactory
|
||||
)
|
||||
: PageModel
|
||||
{
|
||||
private readonly AppDatabase _db;
|
||||
private readonly AccountService _accounts;
|
||||
private readonly AuthService _auth;
|
||||
private readonly ActionLogService _als;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
[BindProperty(SupportsGet = true)] public Guid Id { get; set; }
|
||||
|
||||
[BindProperty(SupportsGet = true)]
|
||||
public Guid Id { get; set; }
|
||||
[BindProperty(SupportsGet = true)] public Guid FactorId { get; set; }
|
||||
|
||||
[BindProperty(SupportsGet = true)]
|
||||
public Guid FactorId { get; set; }
|
||||
[BindProperty(SupportsGet = true)] public string? ReturnUrl { get; set; }
|
||||
|
||||
[BindProperty, Required]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
[BindProperty, Required] public string Code { get; set; } = string.Empty;
|
||||
|
||||
public Challenge? AuthChallenge { get; set; }
|
||||
public AccountAuthFactor? Factor { get; set; }
|
||||
public AccountAuthFactorType FactorType => Factor?.Type ?? AccountAuthFactorType.EmailCode;
|
||||
|
||||
public VerifyFactorModel(
|
||||
AppDatabase db,
|
||||
AccountService accounts,
|
||||
AuthService auth,
|
||||
ActionLogService als,
|
||||
IConfiguration configuration,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_db = db;
|
||||
_accounts = accounts;
|
||||
_auth = auth;
|
||||
_als = als;
|
||||
_configuration = configuration;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnGetAsync()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(ReturnUrl))
|
||||
{
|
||||
TempData["ReturnUrl"] = ReturnUrl;
|
||||
}
|
||||
await LoadChallengeAndFactor();
|
||||
if (AuthChallenge == null) return NotFound("Challenge not found or expired.");
|
||||
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();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostAsync()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(ReturnUrl))
|
||||
{
|
||||
TempData["ReturnUrl"] = ReturnUrl;
|
||||
}
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await LoadChallengeAndFactor();
|
||||
@ -68,40 +60,46 @@ namespace DysonNetwork.Sphere.Pages.Auth
|
||||
if (AuthChallenge == null) return NotFound("Challenge not found or expired.");
|
||||
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
|
||||
{
|
||||
if (await _accounts.VerifyFactorCode(Factor, Code))
|
||||
if (await accounts.VerifyFactorCode(Factor, Code))
|
||||
{
|
||||
AuthChallenge.StepRemain -= Factor.Trustworthy;
|
||||
AuthChallenge.StepRemain = Math.Max(0, AuthChallenge.StepRemain);
|
||||
AuthChallenge.BlacklistFactors.Add(Factor.Id);
|
||||
_db.Update(AuthChallenge);
|
||||
db.Update(AuthChallenge);
|
||||
|
||||
_als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess,
|
||||
als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess,
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ "challenge_id", AuthChallenge.Id },
|
||||
{ "factor_id", Factor?.Id.ToString() ?? string.Empty }
|
||||
}, Request, AuthChallenge.Account);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (AuthChallenge.StepRemain == 0)
|
||||
{
|
||||
_als.CreateActionLogFromRequest(ActionLogType.NewLogin,
|
||||
als.CreateActionLogFromRequest(ActionLogType.NewLogin,
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ "challenge_id", AuthChallenge.Id },
|
||||
{ "account_id", AuthChallenge.AccountId }
|
||||
}, Request, AuthChallenge.Account);
|
||||
|
||||
return await ExchangeTokenAndRedirect();
|
||||
return await ExchangeTokenAndRedirect(AuthChallenge);
|
||||
}
|
||||
|
||||
else
|
||||
{
|
||||
// If more steps are needed, redirect back to select factor
|
||||
return RedirectToPage("SelectFactor", new { id = Id });
|
||||
return RedirectToPage("SelectFactor", new { id = Id, returnUrl = ReturnUrl });
|
||||
}
|
||||
}
|
||||
else
|
||||
@ -114,10 +112,10 @@ namespace DysonNetwork.Sphere.Pages.Auth
|
||||
if (AuthChallenge != null)
|
||||
{
|
||||
AuthChallenge.FailedAttempts++;
|
||||
_db.Update(AuthChallenge);
|
||||
await _db.SaveChangesAsync();
|
||||
db.Update(AuthChallenge);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
_als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure,
|
||||
als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure,
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ "challenge_id", AuthChallenge.Id },
|
||||
@ -133,55 +131,64 @@ namespace DysonNetwork.Sphere.Pages.Auth
|
||||
|
||||
private async Task LoadChallengeAndFactor()
|
||||
{
|
||||
AuthChallenge = await _db.AuthChallenges
|
||||
AuthChallenge = await db.AuthChallenges
|
||||
.Include(e => e.Account)
|
||||
.FirstOrDefaultAsync(e => e.Id == Id);
|
||||
|
||||
if (AuthChallenge?.Account != null)
|
||||
{
|
||||
Factor = await _db.AccountAuthFactors
|
||||
.FirstOrDefaultAsync(e => e.Id == FactorId &&
|
||||
e.AccountId == AuthChallenge.Account.Id &&
|
||||
e.EnabledAt != null &&
|
||||
e.Trustworthy > 0);
|
||||
Factor = await db.AccountAuthFactors
|
||||
.FirstOrDefaultAsync(e => e.Id == FactorId &&
|
||||
e.AccountId == AuthChallenge.Account.Id &&
|
||||
e.EnabledAt != null &&
|
||||
e.Trustworthy > 0);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> ExchangeTokenAndRedirect()
|
||||
private async Task<IActionResult> ExchangeTokenAndRedirect(Challenge challenge)
|
||||
{
|
||||
var challenge = await _db.AuthChallenges
|
||||
.Include(e => e.Account)
|
||||
.FirstOrDefaultAsync(e => e.Id == Id);
|
||||
await db.Entry(challenge).ReloadAsync();
|
||||
if (challenge.StepRemain != 0) return BadRequest($"Challenge not yet completed. Remaining steps: {challenge.StepRemain}");
|
||||
|
||||
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);
|
||||
|
||||
if (session != null) return BadRequest("Session already exists for this challenge.");
|
||||
|
||||
session = new Session
|
||||
if (session == null)
|
||||
{
|
||||
LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow),
|
||||
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)),
|
||||
Account = challenge.Account,
|
||||
Challenge = challenge,
|
||||
};
|
||||
session = new Session
|
||||
{
|
||||
LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow),
|
||||
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)),
|
||||
Account = challenge.Account,
|
||||
Challenge = challenge,
|
||||
};
|
||||
db.AuthSessions.Add(session);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
_db.AuthSessions.Add(session);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var token = _auth.CreateToken(session);
|
||||
Response.Cookies.Append(AuthConstants.CookieTokenName, token, new()
|
||||
var token = auth.CreateToken(session);
|
||||
Response.Cookies.Append(AuthConstants.CookieTokenName, token, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = !_configuration.GetValue<bool>("Debug"),
|
||||
Secure = Request.IsHttps,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Path = "/"
|
||||
});
|
||||
|
||||
return RedirectToPage("/Account/Profile");
|
||||
// Redirect to the return URL if provided and valid, otherwise to the home page
|
||||
if (!string.IsNullOrEmpty(ReturnUrl) && Url.IsLocalUrl(ReturnUrl))
|
||||
{
|
||||
return Redirect(ReturnUrl);
|
||||
}
|
||||
|
||||
// Check TempData for return URL (in case it was passed through multiple steps)
|
||||
if (TempData.TryGetValue("ReturnUrl", out var tempReturnUrl) && tempReturnUrl is string returnUrl &&
|
||||
!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl))
|
||||
{
|
||||
return Redirect(returnUrl);
|
||||
}
|
||||
|
||||
return RedirectToPage("/Index");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -38,73 +38,73 @@
|
||||
</script>
|
||||
}
|
||||
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div class="max-w-lg w-full mx-auto p-6">
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Security Check</h1>
|
||||
<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="hero min-h-full bg-base-200">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-md">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<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">
|
||||
@switch (provider)
|
||||
{
|
||||
case "cloudflare":
|
||||
<div class="cf-turnstile"
|
||||
data-sitekey="@apiKey"
|
||||
data-callback="onSuccess">
|
||||
<div class="flex justify-center my-8">
|
||||
@switch (provider)
|
||||
{
|
||||
case "cloudflare":
|
||||
<div class="cf-turnstile"
|
||||
data-sitekey="@apiKey"
|
||||
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>
|
||||
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="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>
|
||||
|
@ -1,29 +1,23 @@
|
||||
@page
|
||||
@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="mx-auto max-w-2xl">
|
||||
<h1 class="text-4xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-6xl">
|
||||
Solar Network
|
||||
</h1>
|
||||
<p class="mt-6 text-lg leading-8 text-gray-600 dark:text-gray-300">
|
||||
This Solar Network instance is up and running.
|
||||
</p>
|
||||
<div class="mt-10 flex items-center justify-center gap-x-6">
|
||||
<a href="https://sn.solsynth.dev" target="_blank" class="btn-primary">
|
||||
Get started
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-x-6 mt-6">
|
||||
<a href="/swagger" target="_blank" class="btn-text">
|
||||
<span aria-hidden="true">λ </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 class="hero min-h-full bg-base-200">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-md">
|
||||
<h1 class="text-5xl font-bold">Solar Network</h1>
|
||||
<p class="py-6">This Solar Network instance is up and running.</p>
|
||||
<a href="https://sn.solsynth.dev" target="_blank" class="btn btn-primary">Get started</a>
|
||||
<div class="flex items-center justify-center gap-x-6 mt-6">
|
||||
<a href="/swagger" target="_blank" class="btn btn-ghost">
|
||||
<span aria-hidden="true">λ </span> API Docs
|
||||
</a>
|
||||
<a href="https://kb.solsynth.dev" target="_blank" class="btn btn-ghost">
|
||||
Learn more <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -6,6 +6,5 @@ public class IndexModel : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
{
|
||||
// Add any page initialization logic here
|
||||
}
|
||||
}
|
67
DysonNetwork.Sphere/Pages/Posts/PostDetail.cshtml
Normal file
67
DysonNetwork.Sphere/Pages/Posts/PostDetail.cshtml
Normal 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>
|
45
DysonNetwork.Sphere/Pages/Posts/PostDetail.cshtml.cs
Normal file
45
DysonNetwork.Sphere/Pages/Posts/PostDetail.cshtml.cs
Normal 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();
|
||||
}
|
||||
}
|
@ -5,46 +5,57 @@
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<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
|
||||
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>
|
||||
<body class="h-[calc(100dvh-118px)] mt-[64px] bg-white dark:bg-gray-900">
|
||||
<header class="bg-white dark:bg-gray-800 shadow-sm fixed left-0 right-0 top-0 z-50">
|
||||
<nav class="container-default">
|
||||
<div class="flex justify-between h-16 items-center">
|
||||
<div class="flex">
|
||||
<a href="/" class="text-xl font-bold text-gray-900 dark:text-white">Solar Network</a>
|
||||
</div>
|
||||
<div class="flex items-center ml-auto">
|
||||
@if (Context.Request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out _))
|
||||
{
|
||||
<a href="/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>
|
||||
<form method="post" asp-page="/Account/Profile" asp-page-handler="Logout" class="inline">
|
||||
<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>
|
||||
<body class="h-full bg-base-200">
|
||||
<header class="navbar bg-base-100/35 backdrop-blur-md shadow-xl fixed left-0 right-0 top-0 z-50 px-5">
|
||||
<div class="flex-1">
|
||||
<a class="btn btn-ghost text-xl">Solar Network</a>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<ul class="menu menu-horizontal menu-sm px-1">
|
||||
@if (Context.Request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out _))
|
||||
{
|
||||
<li class="tooltip tooltip-bottom" data-tip="Profile">
|
||||
<a href="//account/profile">
|
||||
<span class="material-symbols-outlined">account_circle</span>
|
||||
</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>
|
||||
}
|
||||
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>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</li>
|
||||
}
|
||||
else
|
||||
{
|
||||
<li class="tooltip tooltip-bottom" data-tip="Login">
|
||||
<a href="//auth/login"><span class="material-symbols-outlined">login</span></a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@* The header 64px + The footer 56px = 118px *@
|
||||
<main class="h-full">
|
||||
<main class="h-full pt-16">
|
||||
@RenderBody()
|
||||
</main>
|
||||
|
||||
<footer class="bg-white dark:bg-gray-800 fixed bottom-0 left-0 right-0 shadow-[0_-1px_3px_0_rgba(0,0,0,0.1)]">
|
||||
<div class="container-default" style="padding: 1rem 0;">
|
||||
<p class="text-center text-gray-500 dark:text-gray-400">
|
||||
© @DateTime.Now.Year Solsynth LLC. All
|
||||
rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@await RenderSectionAsync("Scripts", required: false)
|
||||
</body>
|
||||
</html>
|
@ -6,96 +6,86 @@
|
||||
ViewData["Title"] = "Magic Spell";
|
||||
}
|
||||
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div class="max-w-lg w-full mx-auto p-6">
|
||||
<div class="text-center">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Magic Spell</h1>
|
||||
<div class="hero min-h-full bg-base-200">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-md">
|
||||
<h1 class="text-5xl font-bold mb-4">Magic Spell</h1>
|
||||
|
||||
@if (Model.IsSuccess)
|
||||
{
|
||||
<div class="p-4 bg-green-100 dark:bg-green-900 rounded-lg mb-6">
|
||||
<p class="text-green-800 dark:text-green-200">The spell was applied successfully!</p>
|
||||
<p class="text-green-800 dark:text-green-200 opacity-80">Now you can close this page.</p>
|
||||
<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>The spell was applied successfully!</span>
|
||||
<p>Now you can close this page.</p>
|
||||
</div>
|
||||
}
|
||||
else if (Model.CurrentSpell == null)
|
||||
{
|
||||
<div class="p-4 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
|
||||
<p class="text-yellow-800 dark:text-yellow-200">The spell was expired or does not exist.</p>
|
||||
<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>The spell was expired or does not exist.</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div
|
||||
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="mb-2">
|
||||
<p>
|
||||
<span class="font-medium">The spell is for </span>
|
||||
<span
|
||||
class="font-bold">@System.Text.RegularExpressions.Regex.Replace(Model.CurrentSpell!.Type.ToString(), "([a-z])([A-Z])", "$1 $2")</span>
|
||||
</p>
|
||||
<p><span class="font-medium">for @@</span>@Model.CurrentSpell.Account?.Name</p>
|
||||
</div>
|
||||
<div class="text-sm opacity-80">
|
||||
@if (Model.CurrentSpell.ExpiresAt.HasValue)
|
||||
{
|
||||
<p>Available until @Model.CurrentSpell.ExpiresAt.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 class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
@System.Text.RegularExpressions.Regex.Replace(Model.CurrentSpell!.Type.ToString(), "([a-z])([A-Z])", "$1 $2")
|
||||
</h2>
|
||||
<p>for @@ @Model.CurrentSpell.Account?.Name</p>
|
||||
<div class="text-sm opacity-80">
|
||||
@if (Model.CurrentSpell.ExpiresAt.HasValue)
|
||||
{
|
||||
<p>Available until @Model.CurrentSpell.ExpiresAt.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>
|
||||
|
||||
<form method="post" class="mt-4">
|
||||
<input type="hidden" asp-for="CurrentSpell!.Id"/>
|
||||
|
||||
<button type="submit"
|
||||
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">
|
||||
Apply
|
||||
</button>
|
||||
</form>
|
||||
@if (Model.CurrentSpell?.Type == MagicSpellType.AuthPasswordReset)
|
||||
{
|
||||
<div class="form-control w-full max-w-xs">
|
||||
<label class="label" asp-for="NewPassword">
|
||||
<span class="label-text">New Password</span>
|
||||
</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="font-semibold text-gray-700 dark:text-gray-300 mb-1">Solar Network</div>
|
||||
<div class="text-gray-600 dark:text-gray-400">
|
||||
<a href="https://solsynth.dev" class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||
Solsynth LLC
|
||||
</a>
|
||||
© @DateTime.Now.Year
|
||||
<br/>
|
||||
Powered by
|
||||
<a href="https://github.com/Solsynth/DysonNetwork"
|
||||
class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||
DysonNetwork.Sphere
|
||||
</a>
|
||||
|
||||
<div class="mt-8 text-center text-sm">
|
||||
<div class="font-semibold mb-1">Solar Network</div>
|
||||
<div class="text-base-content/70">
|
||||
<a href="https://solsynth.dev" class="link link-hover">
|
||||
Solsynth LLC
|
||||
</a>
|
||||
© @DateTime.Now.Year
|
||||
<br/>
|
||||
Powered by
|
||||
<a href="https://github.com/Solsynth/DysonNetwork" class="link link-hover">
|
||||
DysonNetwork.Sphere
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,6 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Sphere.Account;
|
||||
using DysonNetwork.Sphere.Pages.Posts;
|
||||
using DysonNetwork.Sphere.Permission;
|
||||
using DysonNetwork.Sphere.Publisher;
|
||||
using DysonNetwork.Sphere.Storage;
|
||||
@ -8,24 +9,27 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using NpgsqlTypes;
|
||||
|
||||
namespace DysonNetwork.Sphere.Post;
|
||||
|
||||
[ApiController]
|
||||
[Route("/posts")]
|
||||
[Route("/api/posts")]
|
||||
public class PostController(
|
||||
AppDatabase db,
|
||||
PostService ps,
|
||||
PublisherService pub,
|
||||
RelationshipService rels,
|
||||
IServiceScopeFactory factory,
|
||||
ActionLogService als
|
||||
)
|
||||
: ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<Post>>> ListPosts([FromQuery] int offset = 0, [FromQuery] int take = 20,
|
||||
[FromQuery(Name = "pub")] string? pubName = null)
|
||||
public async Task<ActionResult<List<Post>>> ListPosts(
|
||||
[FromQuery] int offset = 0,
|
||||
[FromQuery] int take = 20,
|
||||
[FromQuery(Name = "pub")] string? pubName = null
|
||||
)
|
||||
{
|
||||
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
||||
var currentUser = currentUserValue as Account.Account;
|
||||
@ -62,6 +66,9 @@ public class PostController(
|
||||
[HttpGet("{id:guid}")]
|
||||
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);
|
||||
var currentUser = currentUserValue as Account.Account;
|
||||
var userFriends = currentUser is null ? [] : await rels.ListAccountFriends(currentUser);
|
||||
@ -83,6 +90,50 @@ public class PostController(
|
||||
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")]
|
||||
public async Task<ActionResult<List<Post>>> ListReplies(Guid id, [FromQuery] int offset = 0,
|
||||
[FromQuery] int take = 20)
|
||||
|
@ -35,6 +35,7 @@ public class Publisher : ModelBase, IIdentifiedResource
|
||||
[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<PublisherMember> Members { get; set; } = new List<PublisherMember>();
|
||||
[JsonIgnore] public ICollection<PublisherFeature> Features { get; set; } = new List<PublisherFeature>();
|
||||
|
||||
[JsonIgnore]
|
||||
public ICollection<PublisherSubscription> Subscriptions { get; set; } = new List<PublisherSubscription>();
|
||||
@ -98,6 +99,6 @@ public class PublisherFeature : ModelBase
|
||||
|
||||
public abstract class PublisherFeatureFlag
|
||||
{
|
||||
public static List<string> AllFlags => [Developer];
|
||||
public static string Developer = "develop";
|
||||
public static List<string> AllFlags => [Develop];
|
||||
public static string Develop = "develop";
|
||||
}
|
@ -11,7 +11,7 @@ using NodaTime;
|
||||
namespace DysonNetwork.Sphere.Publisher;
|
||||
|
||||
[ApiController]
|
||||
[Route("/publishers")]
|
||||
[Route("/api/publishers")]
|
||||
public class PublisherController(
|
||||
AppDatabase db,
|
||||
PublisherService ps,
|
||||
|
@ -8,6 +8,13 @@ namespace DysonNetwork.Sphere.Publisher;
|
||||
|
||||
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";
|
||||
|
||||
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.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));
|
||||
return isEnabled.Value;
|
||||
|
@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
namespace DysonNetwork.Sphere.Publisher;
|
||||
|
||||
[ApiController]
|
||||
[Route("/publishers")]
|
||||
[Route("/api/publishers")]
|
||||
public class PublisherSubscriptionController(
|
||||
PublisherSubscriptionService subs,
|
||||
AppDatabase db,
|
||||
|
@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
namespace DysonNetwork.Sphere.Realm;
|
||||
|
||||
[ApiController]
|
||||
[Route("/realms/{slug}")]
|
||||
[Route("/api/realms/{slug}")]
|
||||
public class RealmChatController(AppDatabase db, RealmService rs) : ControllerBase
|
||||
{
|
||||
[HttpGet("chat")]
|
||||
|
@ -9,7 +9,7 @@ using NodaTime;
|
||||
namespace DysonNetwork.Sphere.Realm;
|
||||
|
||||
[ApiController]
|
||||
[Route("/realms")]
|
||||
[Route("/api/realms")]
|
||||
public class RealmController(
|
||||
AppDatabase db,
|
||||
RealmService rs,
|
||||
|
@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace DysonNetwork.Sphere.Safety;
|
||||
|
||||
[ApiController]
|
||||
[Route("/safety/reports")]
|
||||
[Route("/api/safety/reports")]
|
||||
public class AbuseReportController(
|
||||
SafetyService safety
|
||||
) : ControllerBase
|
||||
@ -54,13 +54,13 @@ public class AbuseReportController(
|
||||
[RequiredPermission("safety", "reports.view")]
|
||||
[ProducesResponseType<List<AbuseReport>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<AbuseReport>>> GetReports(
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int offset = 0,
|
||||
[FromQuery] int take = 20,
|
||||
[FromQuery] bool includeResolved = false
|
||||
)
|
||||
{
|
||||
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();
|
||||
return Ok(reports);
|
||||
}
|
||||
@ -70,14 +70,15 @@ public class AbuseReportController(
|
||||
[ProducesResponseType<List<AbuseReport>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult<List<AbuseReport>>> GetMyReports(
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int offset = 0,
|
||||
[FromQuery] int take = 20,
|
||||
[FromQuery] bool includeResolved = false)
|
||||
[FromQuery] bool includeResolved = false
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
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();
|
||||
return Ok(reports);
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.Net;
|
||||
using DysonNetwork.Sphere.Connection;
|
||||
using DysonNetwork.Sphere.Permission;
|
||||
using DysonNetwork.Sphere.Storage;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
@ -14,6 +15,7 @@ public static class ApplicationConfiguration
|
||||
{
|
||||
app.MapMetrics();
|
||||
app.MapOpenApi();
|
||||
app.UseMiddleware<ClientTypeMiddleware>();
|
||||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
@ -34,6 +36,7 @@ public static class ApplicationConfiguration
|
||||
app.UseWebSockets();
|
||||
app.UseRateLimiter();
|
||||
app.UseHttpsRedirection();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseMiddleware<PermissionMiddleware>();
|
||||
|
||||
|
@ -24,6 +24,8 @@ using NodaTime.Serialization.SystemTextJson;
|
||||
using StackExchange.Redis;
|
||||
using System.Text.Json;
|
||||
using System.Threading.RateLimiting;
|
||||
using DysonNetwork.Sphere.Auth.OidcProvider.Options;
|
||||
using DysonNetwork.Sphere.Auth.OidcProvider.Services;
|
||||
using DysonNetwork.Sphere.Connection.WebReader;
|
||||
using DysonNetwork.Sphere.Developer;
|
||||
using DysonNetwork.Sphere.Discovery;
|
||||
@ -234,6 +236,9 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<SafetyService>();
|
||||
services.AddScoped<DiscoveryService>();
|
||||
services.AddScoped<CustomAppService>();
|
||||
|
||||
services.Configure<OidcProviderOptions>(configuration.GetSection("OidcProvider"));
|
||||
services.AddScoped<OidcProviderService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
namespace DysonNetwork.Sphere.Sticker;
|
||||
|
||||
[ApiController]
|
||||
[Route("/stickers")]
|
||||
[Route("/api/stickers")]
|
||||
public class StickerController(AppDatabase db, StickerService st) : ControllerBase
|
||||
{
|
||||
private async Task<IActionResult> _CheckStickerPackPermissions(Guid packId, Account.Account currentUser,
|
||||
|
@ -7,7 +7,7 @@ using Minio.DataModel.Args;
|
||||
namespace DysonNetwork.Sphere.Storage;
|
||||
|
||||
[ApiController]
|
||||
[Route("/files")]
|
||||
[Route("/api/files")]
|
||||
public class FileController(
|
||||
AppDatabase db,
|
||||
FileService fs,
|
||||
@ -17,13 +17,27 @@ public class FileController(
|
||||
) : ControllerBase
|
||||
{
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult> OpenFile(string id, [FromQuery] bool original = false)
|
||||
public async Task<ActionResult> OpenFile(
|
||||
string id,
|
||||
[FromQuery] bool download = false,
|
||||
[FromQuery] bool original = false,
|
||||
[FromQuery] string? overrideMimeType = null
|
||||
)
|
||||
{
|
||||
// Support the file extension for client side data recognize
|
||||
string? fileExtension = null;
|
||||
if (id.Contains("."))
|
||||
{
|
||||
var splitedId = id.Split('.');
|
||||
id = splitedId.First();
|
||||
fileExtension = splitedId.Last();
|
||||
}
|
||||
|
||||
var file = await fs.GetFileAsync(id);
|
||||
if (file is null) return NotFound();
|
||||
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(file.StorageUrl)) return Redirect(file.StorageUrl);
|
||||
|
||||
|
||||
if (file.UploadedTo is null)
|
||||
{
|
||||
var tusStorePath = configuration.GetValue<string>("Tus:StorePath")!;
|
||||
@ -61,12 +75,33 @@ public class FileController(
|
||||
return BadRequest(
|
||||
"Failed to configure client for remote destination, file got an invalid storage remote.");
|
||||
|
||||
var headers = new Dictionary<string, string>();
|
||||
if (fileExtension is not null)
|
||||
{
|
||||
if (MimeTypes.TryGetMimeType(fileExtension, out var mimeType))
|
||||
headers.Add("Response-Content-Type", mimeType);
|
||||
}
|
||||
else if (overrideMimeType is not null)
|
||||
{
|
||||
headers.Add("Response-Content-Type", overrideMimeType);
|
||||
}
|
||||
else if (file.MimeType is not null && !file.MimeType!.EndsWith("unknown"))
|
||||
{
|
||||
headers.Add("Response-Content-Type", file.MimeType);
|
||||
}
|
||||
|
||||
if (download)
|
||||
{
|
||||
headers.Add("Response-Content-Disposition", $"attachment; filename=\"{file.Name}\"");
|
||||
}
|
||||
|
||||
var bucket = dest.Bucket;
|
||||
var openUrl = await client.PresignedGetObjectAsync(
|
||||
new PresignedGetObjectArgs()
|
||||
.WithBucket(bucket)
|
||||
.WithObject(fileName)
|
||||
.WithExpiry(3600)
|
||||
.WithHeaders(headers)
|
||||
);
|
||||
|
||||
return Redirect(openUrl);
|
||||
@ -107,7 +142,7 @@ public class FileController(
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("/maintenance/migrateReferences")]
|
||||
[Authorize]
|
||||
[RequiredPermission("maintenance", "files.references")]
|
||||
|
@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
namespace DysonNetwork.Sphere.Wallet;
|
||||
|
||||
[ApiController]
|
||||
[Route("/orders")]
|
||||
[Route("/api/orders")]
|
||||
public class OrderController(PaymentService payment, AuthService auth, AppDatabase db) : ControllerBase
|
||||
{
|
||||
[HttpGet("{id:guid}")]
|
||||
|
@ -2,6 +2,7 @@ using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Sphere.Wallet.PaymentHandlers;
|
||||
@ -51,7 +52,7 @@ public class AfdianPaymentHandler(
|
||||
var sign = CalculateSign(token, userId, paramsJson, ts);
|
||||
|
||||
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
|
||||
{
|
||||
@ -107,7 +108,7 @@ public class AfdianPaymentHandler(
|
||||
var sign = CalculateSign(token, userId, paramsJson, ts);
|
||||
|
||||
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
|
||||
{
|
||||
@ -176,7 +177,7 @@ public class AfdianPaymentHandler(
|
||||
var sign = CalculateSign(token, userId, paramsJson, ts);
|
||||
|
||||
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
|
||||
{
|
||||
@ -442,4 +443,4 @@ public class SkuDetailItem
|
||||
[JsonPropertyName("album_id")] public string AlbumId { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("pic")] public string Picture { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
|
@ -7,16 +7,43 @@ namespace DysonNetwork.Sphere.Wallet;
|
||||
|
||||
public record class SubscriptionTypeData(
|
||||
string Identifier,
|
||||
decimal BasePrice
|
||||
string? GroupIdentifier,
|
||||
string Currency,
|
||||
decimal BasePrice,
|
||||
int? RequiredLevel = null
|
||||
)
|
||||
{
|
||||
public static readonly Dictionary<string, SubscriptionTypeData> SubscriptionDict =
|
||||
new()
|
||||
{
|
||||
[SubscriptionType.Twinkle] = new SubscriptionTypeData(SubscriptionType.Twinkle, 0),
|
||||
[SubscriptionType.Stellar] = new SubscriptionTypeData(SubscriptionType.Stellar, 10),
|
||||
[SubscriptionType.Nova] = new SubscriptionTypeData(SubscriptionType.Nova, 20),
|
||||
[SubscriptionType.Supernova] = new SubscriptionTypeData(SubscriptionType.Supernova, 30)
|
||||
[SubscriptionType.Twinkle] = new SubscriptionTypeData(
|
||||
SubscriptionType.Twinkle,
|
||||
SubscriptionType.StellarProgram,
|
||||
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 =
|
||||
@ -65,7 +92,7 @@ public abstract class SubscriptionPaymentMethod
|
||||
public enum SubscriptionStatus
|
||||
{
|
||||
Unpaid,
|
||||
Paid,
|
||||
Active,
|
||||
Expired,
|
||||
Cancelled
|
||||
}
|
||||
@ -125,7 +152,7 @@ public class Subscription : ModelBase
|
||||
if (BegunAt > now) return false;
|
||||
if (EndedAt.HasValue && now > EndedAt.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;
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ using DysonNetwork.Sphere.Wallet.PaymentHandlers;
|
||||
namespace DysonNetwork.Sphere.Wallet;
|
||||
|
||||
[ApiController]
|
||||
[Route("/subscriptions")]
|
||||
[Route("/api/subscriptions")]
|
||||
public class SubscriptionController(SubscriptionService subscriptions, AfdianPaymentHandler afdian, AppDatabase db) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
@ -180,11 +180,12 @@ public class SubscriptionController(SubscriptionService subscriptions, AfdianPay
|
||||
}
|
||||
|
||||
[HttpPost("order/restore/afdian")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> RestorePurchaseFromAfdian([FromBody] RestorePurchaseRequest request)
|
||||
{
|
||||
var order = await afdian.GetOrderAsync(request.OrderId);
|
||||
if (order is null) return NotFound($"Order with ID {request.OrderId} was not found.");
|
||||
|
||||
|
||||
var subscription = await subscriptions.CreateSubscriptionFromOrder(order);
|
||||
return Ok(subscription);
|
||||
}
|
||||
@ -200,4 +201,4 @@ public class SubscriptionController(SubscriptionService subscriptions, AfdianPay
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -29,7 +29,7 @@ public class SubscriptionRenewalJob(
|
||||
// Find subscriptions that need renewal (due for renewal and are still active)
|
||||
var subscriptionsToRenew = await db.WalletSubscriptions
|
||||
.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.IsFreeTrial) // Exclude free trials
|
||||
.OrderBy(s => s.RenewalAt) // Process oldest first
|
||||
@ -49,6 +49,17 @@ public class SubscriptionRenewalJob(
|
||||
"Processing renewal for subscription {SubscriptionId} (Identifier: {Identifier}) for account {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
|
||||
var currentCycle = subscription.EndedAt!.Value - subscription.BegunAt;
|
||||
|
||||
|
@ -32,21 +32,39 @@ public class SubscriptionService(
|
||||
bool noop = false
|
||||
)
|
||||
{
|
||||
var subscriptionTemplate = SubscriptionTypeData
|
||||
var subscriptionInfo = SubscriptionTypeData
|
||||
.SubscriptionDict.TryGetValue(identifier, out var template)
|
||||
? template
|
||||
: null;
|
||||
if (subscriptionTemplate is null)
|
||||
if (subscriptionInfo is null)
|
||||
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);
|
||||
|
||||
var existingSubscription = await GetSubscriptionAsync(account.Id, identifier);
|
||||
var existingSubscription = await GetSubscriptionAsync(account.Id, subscriptionsInGroup);
|
||||
if (existingSubscription is not null && !noop)
|
||||
throw new InvalidOperationException($"Active subscription with identifier {identifier} already exists.");
|
||||
if (existingSubscription is not null)
|
||||
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)
|
||||
{
|
||||
var prevFreeTrial = await db.WalletSubscriptions
|
||||
@ -77,7 +95,7 @@ public class SubscriptionService(
|
||||
Status = SubscriptionStatus.Unpaid,
|
||||
PaymentMethod = paymentMethod,
|
||||
PaymentDetails = paymentDetails,
|
||||
BasePrice = subscriptionTemplate.BasePrice,
|
||||
BasePrice = subscriptionInfo.BasePrice,
|
||||
CouponId = couponData?.Id,
|
||||
Coupon = couponData,
|
||||
RenewalAt = (isFreeTrial || !isAutoRenewal) ? null : now.Plus(cycleDuration.Value),
|
||||
@ -140,7 +158,7 @@ public class SubscriptionService(
|
||||
existingSubscription.PaymentDetails.OrderId = order.Id;
|
||||
existingSubscription.EndedAt = order.BegunAt.Plus(cycleDuration);
|
||||
existingSubscription.RenewalAt = order.BegunAt.Plus(cycleDuration);
|
||||
existingSubscription.Status = SubscriptionStatus.Paid;
|
||||
existingSubscription.Status = SubscriptionStatus.Active;
|
||||
|
||||
db.Update(existingSubscription);
|
||||
await db.SaveChangesAsync();
|
||||
@ -153,7 +171,7 @@ public class SubscriptionService(
|
||||
BegunAt = order.BegunAt,
|
||||
EndedAt = order.BegunAt.Plus(cycleDuration),
|
||||
IsActive = true,
|
||||
Status = SubscriptionStatus.Paid,
|
||||
Status = SubscriptionStatus.Active,
|
||||
Identifier = subscriptionIdentifier,
|
||||
PaymentMethod = provider,
|
||||
PaymentDetails = new PaymentDetails
|
||||
@ -186,10 +204,15 @@ public class SubscriptionService(
|
||||
var subscription = await GetSubscriptionAsync(accountId, identifier);
|
||||
if (subscription is null)
|
||||
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.");
|
||||
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;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
@ -221,9 +244,15 @@ public class SubscriptionService(
|
||||
.FirstOrDefaultAsync();
|
||||
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(
|
||||
null,
|
||||
WalletCurrency.GoldenPoint,
|
||||
subscriptionInfo.Currency,
|
||||
subscription.FinalPrice,
|
||||
appIdentifier: SubscriptionOrderIdentifier,
|
||||
meta: new Dictionary<string, object>()
|
||||
@ -264,7 +293,7 @@ public class SubscriptionService(
|
||||
subscription.EndedAt = nextEndedAt;
|
||||
}
|
||||
|
||||
subscription.Status = SubscriptionStatus.Paid;
|
||||
subscription.Status = SubscriptionStatus.Active;
|
||||
|
||||
db.Update(subscription);
|
||||
await db.SaveChangesAsync();
|
||||
@ -294,7 +323,7 @@ public class SubscriptionService(
|
||||
// Find active subscriptions that have passed their end date
|
||||
var expiredSubscriptions = await db.WalletSubscriptions
|
||||
.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)
|
||||
.Take(batchSize)
|
||||
.ToListAsync();
|
||||
@ -319,7 +348,7 @@ public class SubscriptionService(
|
||||
{
|
||||
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == subscription.AccountId);
|
||||
if (account is null) return;
|
||||
|
||||
|
||||
AccountService.SetCultureInfo(account);
|
||||
|
||||
var humanReadableName =
|
||||
@ -345,10 +374,10 @@ public class SubscriptionService(
|
||||
|
||||
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
|
||||
var cacheKey = $"{SubscriptionCacheKeyPrefix}{accountId}:{identifier}";
|
||||
var cacheKey = $"{SubscriptionCacheKeyPrefix}{accountId}:{string.Join(",", identifiers)}";
|
||||
|
||||
// Try to get the subscription from cache first
|
||||
var (found, cachedSubscription) = await cache.GetAsyncWithStatus<Subscription>(cacheKey);
|
||||
@ -359,15 +388,13 @@ public class SubscriptionService(
|
||||
|
||||
// If not in cache, get from database
|
||||
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)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
// Cache the result if found (with 30 minutes expiry)
|
||||
if (subscription != null)
|
||||
{
|
||||
await cache.SetAsync(cacheKey, subscription, TimeSpan.FromMinutes(30));
|
||||
}
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
namespace DysonNetwork.Sphere.Wallet;
|
||||
|
||||
[ApiController]
|
||||
[Route("/wallets")]
|
||||
[Route("/api/wallets")]
|
||||
public class WalletController(AppDatabase db, WalletService ws, PaymentService payment) : ControllerBase
|
||||
{
|
||||
[HttpPost]
|
||||
|
@ -23,10 +23,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Jwt": {
|
||||
"AuthToken": {
|
||||
"PublicKeyPath": "Keys/PublicKey.pem",
|
||||
"PrivateKeyPath": "Keys/PrivateKey.pem"
|
||||
},
|
||||
"OidcProvider": {
|
||||
"IssuerUri": "https://nt.solian.app",
|
||||
"PublicKeyPath": "Keys/PublicKey.pem",
|
||||
"PrivateKeyPath": "Keys/PrivateKey.pem",
|
||||
"AccessTokenLifetime": "01:00:00",
|
||||
"RefreshTokenLifetime": "30.00:00:00",
|
||||
"AuthorizationCodeLifetime": "00:30:00",
|
||||
"RequireHttpsMetadata": true
|
||||
},
|
||||
"Tus": {
|
||||
"StorePath": "Uploads"
|
||||
},
|
||||
@ -117,4 +126,4 @@
|
||||
"127.0.0.1",
|
||||
"::1"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -6,7 +6,8 @@
|
||||
"css:build": "npx @tailwindcss/cli -i ./wwwroot/css/site.css -o ./wwwroot/css/styles.css"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "^4.1.7",
|
||||
"@tailwindcss/postcss": "^4.1.7",
|
||||
"@tailwindcss/cli": "^4.1.7"
|
||||
"daisyui": "^5.0.46"
|
||||
}
|
||||
}
|
@ -1,10 +1,88 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@plugin "daisyui";
|
||||
|
||||
@layer theme, base, components, utilities;
|
||||
|
||||
@import "tailwindcss/theme.css" layer(theme);
|
||||
@import "tailwindcss/preflight.css" layer(base);
|
||||
@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 {
|
||||
html, body {
|
||||
padding: 0;
|
||||
@ -12,6 +90,10 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
|
||||
}
|
||||
|
||||
/* For Firefox. */
|
||||
* {
|
||||
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 {
|
||||
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -8,10 +8,12 @@
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABlurHashEncoder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fb87f853683828cb934127af9a42b22cf516412af1e61ae2ff4935ae82aff_003FBlurHashEncoder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABodyBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fc5c8aba04a29d49c65d772c9ffcd93ac7eb38ccbb49a5f506518a0b9bdcaa75_003FBodyBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABucketArgs_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fd515fb889657fcdcace3fed90735057b458ff9e0bb60bded7c8fe8b3a4673c_003FBucketArgs_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABucketArgs_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fd515fb889657fcdcace3fed90735057b458ff9e0bb60bded7c8fe8b3a4673c_003FBucketArgs_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AChapterData_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffef366b36a224d469ff150d30f9a866d23c00_003Fe6_003F64a6c0f7_003FChapterData_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_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_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>
|
||||
@ -50,6 +52,7 @@
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AITusStore_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003Fb1_003F7e861de5_003FITusStore_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonSerializerOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F5703920a18f94462b4354fab05326e6519a200_003F35_003F8536fc49_003FJsonSerializerOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJwtBearerExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ff611a1225a3445458f2ca3f102eed5bdcd10_003F07_003F030df6ba_003FJwtBearerExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJwtSecurityTokenHandler_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F19f907d47c6f4a2ea68238bf22de133a16600_003Fa0_003F66d8c35f_003FJwtSecurityTokenHandler_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJwtSecurityTokenHandler_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F477051138f1f40de9077b7b1cdc55c6215fb0_003Ff5_003Fd716e016_003FJwtSecurityTokenHandler_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AKestrelServerLimits_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F1e2e5dfcafad4407b569dd5df56a2fbf274e00_003Fa4_003F39445f62_003FKestrelServerLimits_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AKnownResamplers_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fef3339e864a448e2b1ec6fa7bbf4c6661fee00_003Fb3_003Fcdb3e080_003FKnownResamplers_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
@ -60,6 +63,7 @@
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANotFoundResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003F28_003F290250f5_003FNotFoundResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANotFound_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Ff2c049af93e430aac427e8ff3cc9edd8763d5c9f006d7121ed1c5921585cba_003FNotFound_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANpgsqlEntityTypeBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fccb1faacaea4420db96b09857fc56178a1600_003Fd9_003F9acf9507_003FNpgsqlEntityTypeBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANullable_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F3bef61b8a21d4c8e96872ecdd7782fa0e55000_003F79_003F4ab1c673_003FNullable_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANullable_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003F6a_003Fea17bf26_003FNullable_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOk_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F01d30b32e2ff422cb80129ca2a441c4242600_003F3b_003F237bf104_003FOk_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOptionsConfigurationServiceCollectionExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6622dea924b14dc7aa3ee69d7c84e5735000_003Fe0_003F024ba0b7_003FOptionsConfigurationServiceCollectionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
@ -68,6 +72,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_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_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_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>
|
||||
@ -81,6 +86,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_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_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_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>
|
||||
|
Reference in New Issue
Block a user