Compare commits

...

28 Commits

Author SHA1 Message Date
29d752bdd9 🔀 Merge pull request 'Add Cloudflare (Dyte) as a provider of call' (#2) from refactor/cloudflare-call into master
Reviewed-on: #2
2025-07-11 18:28:06 +00:00
b12e3315fe 🐛 Fixes of bugs 2025-07-12 01:54:00 +08:00
ce3958d397 🔀 Merge pull request '更新 DysonNetwork.Sphere/Resources/Localization/AccountEventResource.zh-hans.resx' (#1) from a123lsw-patch-1 into master
Reviewed-on: #1
Reviewed-by: LittleSheep <littlesheep@noreply.localhost>
2025-07-11 17:26:23 +00:00
26ea2503a4 更新 DysonNetwork.Sphere/Resources/Localization/AccountEventResource.zh-hans.resx
2025-07-11 16:11:15 +00:00
d6ce068490 更新 DysonNetwork.Sphere/Resources/Localization/AccountEventResource.zh-hans.resx 2025-07-11 16:02:15 +00:00
da4ee81c95 🐛 Bug fixes on swagger ui 2025-07-11 23:41:39 +08:00
bec294365f ♻️ Refactored webhook receiver in realtime call 2025-07-11 23:07:32 +08:00
51a8b684fd 更新 DysonNetwork.Sphere/Resources/Localization/AccountEventResource.zh-hans.resx 2025-07-11 14:36:31 +00:00
7b026eeae1 🧱 Add cloudflare realtime service 2025-07-11 21:07:38 +08:00
4dd4542c37 更新 DysonNetwork.Sphere/Resources/Localization/AccountEventResource.zh-hans.resx
突然想起来忘上传了
2025-07-10 17:23:58 +00:00
2a3918134f 🐛 More file endpoint override options 2025-07-10 21:16:03 +08:00
734e5ca4a0 File opening response type overriding 2025-07-10 19:49:55 +08:00
ff0789904d Improved open file endpoint 2025-07-10 19:28:15 +08:00
17330fc104 🐛 Fixes for websocket 2025-07-10 15:57:00 +08:00
7c0ad46deb 🐛 Fix api redirect 2025-07-10 15:30:30 +08:00
b8fcd0d94f Post web view 2025-07-10 15:15:20 +08:00
fc6edd7378 💥 Add /api prefix for json endpoints with redirect 2025-07-10 14:18:02 +08:00
1f2cdb146d 🐛 Serval bug fixes 2025-07-10 12:53:45 +08:00
be236a27c6 🐛 Serval bug fixes and improvement to web page 2025-07-10 03:08:39 +08:00
99c36ae548 💄 Optimized the authorized page style 2025-07-10 02:28:27 +08:00
ed2961a5d5 💄 Restyled web pages 2025-07-10 01:53:44 +08:00
08b5ffa02f 🐛 Fix afdian got wrong URL to request 2025-07-09 22:51:14 +08:00
837a123c3b 🐛 Trying to fix payment handler 2025-07-09 22:00:06 +08:00
ad1166190f 🐛 Bug fixes 2025-07-09 21:43:39 +08:00
8e8c938132 🐛 Fix restore purchase in afdian 2025-07-09 21:39:38 +08:00
8e5b6ace45 Skip subscribed check in message 2025-07-07 13:08:31 +08:00
5757526ea5 Get user blocked users infra 2025-07-03 21:57:16 +08:00
6a9cd0905d Unblock user 2025-07-03 21:47:17 +08:00
65 changed files with 3890 additions and 1571 deletions

View File

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

View File

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

View File

@ -12,7 +12,7 @@ namespace DysonNetwork.Sphere.Account;
[Authorize]
[ApiController]
[Route("/accounts/me")]
[Route("/api/accounts/me")]
public class AccountCurrentController(
AppDatabase db,
AccountService accounts,

View File

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

View File

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

View File

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

View File

@ -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}");
}
}

View File

@ -8,7 +8,7 @@ namespace DysonNetwork.Sphere.Activity;
/// Activity is a universal feed that contains multiple kinds of data. Personalized and generated dynamically.
/// </summary>
[ApiController]
[Route("/activities")]
[Route("/api/activities")]
public class ActivityController(
ActivityService acts
) : ControllerBase

View File

@ -126,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))
{

View File

@ -11,7 +11,7 @@ using DysonNetwork.Sphere.Connection;
namespace DysonNetwork.Sphere.Auth;
[ApiController]
[Route("/auth")]
[Route("/api/auth")]
public class AuthController(
AppDatabase db,
AccountService accounts,

View File

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

View File

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

View File

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

View File

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

View File

@ -13,7 +13,7 @@ using NodaTime;
namespace DysonNetwork.Sphere.Chat;
[ApiController]
[Route("/chat")]
[Route("/api/chat")]
public class ChatRoomController(
AppDatabase db,
FileReferenceService fileRefService,

View File

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

View File

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

View File

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

View File

@ -10,33 +10,33 @@ namespace DysonNetwork.Sphere.Chat.Realtime;
/// <summary>
/// LiveKit implementation of the real-time communication service
/// </summary>
public class LivekitRealtimeService : IRealtimeService
public class LiveKitRealtimeService : IRealtimeService
{
private readonly AppDatabase _db;
private readonly ICacheService _cache;
private readonly WebSocketService _ws;
private readonly RealtimeStatusService _callStatus;
private readonly ILogger<LivekitRealtimeService> _logger;
private readonly ILogger<LiveKitRealtimeService> _logger;
private readonly RoomServiceClient _roomService;
private readonly AccessToken _accessToken;
private readonly WebhookReceiver _webhookReceiver;
public LivekitRealtimeService(
public LiveKitRealtimeService(
IConfiguration configuration,
ILogger<LivekitRealtimeService> logger,
ILogger<LiveKitRealtimeService> logger,
AppDatabase db,
ICacheService cache,
WebSocketService ws
RealtimeStatusService callStatus
)
{
_logger = logger;
// Get LiveKit configuration from appsettings
var host = configuration["RealtimeChat:Endpoint"] ??
var host = configuration["Realtime:LiveKit:Endpoint"] ??
throw new ArgumentNullException("Endpoint configuration is required");
var apiKey = configuration["RealtimeChat:ApiKey"] ??
var apiKey = configuration["Realtime:LiveKit:ApiKey"] ??
throw new ArgumentNullException("ApiKey configuration is required");
var apiSecret = configuration["RealtimeChat:ApiSecret"] ??
var apiSecret = configuration["Realtime:LiveKit:ApiSecret"] ??
throw new ArgumentNullException("ApiSecret configuration is required");
_roomService = new RoomServiceClient(host, apiKey, apiSecret);
@ -45,7 +45,7 @@ public class LivekitRealtimeService : IRealtimeService
_db = db;
_cache = cache;
_ws = ws;
_callStatus = callStatus;
}
/// <inheritdoc />
@ -112,6 +112,11 @@ public class LivekitRealtimeService : IRealtimeService
/// <inheritdoc />
public string GetUserToken(Account.Account account, string sessionId, bool isAdmin = false)
{
return GetUserTokenAsync(account, sessionId, isAdmin).GetAwaiter().GetResult();
}
public Task<string> GetUserTokenAsync(Account.Account account, string sessionId, bool isAdmin = false)
{
var token = _accessToken.WithIdentity(account.Name)
.WithName(account.Nick)
@ -128,7 +133,7 @@ public class LivekitRealtimeService : IRealtimeService
.WithMetadata(JsonSerializer.Serialize(new Dictionary<string, string>
{ { "account_id", account.Id.ToString() } }))
.WithTtl(TimeSpan.FromHours(1));
return token.ToJwt();
return Task.FromResult(token.ToJwt());
}
public async Task ReceiveWebhook(string body, string authHeader)
@ -159,7 +164,8 @@ public class LivekitRealtimeService : IRealtimeService
evt.Room.Name, evt.Participant.Identity);
// Broadcast participant list update to all participants
await _BroadcastParticipantUpdate(evt.Room.Name);
var info = await GetRoomParticipantsAsync(evt.Room.Name);
await _callStatus.BroadcastParticipantUpdate(evt.Room.Name, info);
}
break;
@ -174,14 +180,15 @@ public class LivekitRealtimeService : IRealtimeService
evt.Room.Name, evt.Participant.Identity);
// Broadcast participant list update to all participants
await _BroadcastParticipantUpdate(evt.Room.Name);
var info = await GetRoomParticipantsAsync(evt.Room.Name);
await _callStatus.BroadcastParticipantUpdate(evt.Room.Name, info);
}
break;
}
}
private static string _GetParticipantsKey(string roomName)
private static string _GetParticipantsKey(string roomName)
=> $"RoomParticipants_{roomName}";
private async Task _AddParticipantToCache(string roomName, ParticipantInfo participant)
@ -201,7 +208,7 @@ public class LivekitRealtimeService : IRealtimeService
}
// Get the current participants list
var participants = await _cache.GetAsync<List<ParticipantCacheItem>>(participantsKey) ??
var participants = await _cache.GetAsync<List<ParticipantInfoItem>>(participantsKey) ??
[];
// Check if the participant already exists
@ -241,7 +248,7 @@ public class LivekitRealtimeService : IRealtimeService
}
// Get current participants list
var participants = await _cache.GetAsync<List<ParticipantCacheItem>>(participantsKey);
var participants = await _cache.GetAsync<List<ParticipantInfoItem>>(participantsKey);
if (participants == null || !participants.Any())
return;
@ -253,36 +260,24 @@ public class LivekitRealtimeService : IRealtimeService
}
// Helper method to get participants in a room
public async Task<List<ParticipantCacheItem>> GetRoomParticipantsAsync(string roomName)
public async Task<List<ParticipantInfoItem>> GetRoomParticipantsAsync(string roomName)
{
var participantsKey = _GetParticipantsKey(roomName);
return await _cache.GetAsync<List<ParticipantCacheItem>>(participantsKey) ?? [];
return await _cache.GetAsync<List<ParticipantInfoItem>>(participantsKey) ?? [];
}
// Class to represent a participant in the cache
public class ParticipantCacheItem
{
public string Identity { get; set; } = null!;
public string Name { get; set; } = null!;
public Guid? AccountId { get; set; }
public ParticipantInfo.Types.State State { get; set; }
public Dictionary<string, string> Metadata { get; set; } = new();
public DateTime JoinedAt { get; set; }
}
private ParticipantCacheItem CreateParticipantCacheItem(ParticipantInfo participant)
private ParticipantInfoItem CreateParticipantCacheItem(ParticipantInfo participant)
{
// Try to parse account ID from metadata
Guid? accountId = null;
var metadata = new Dictionary<string, string>();
if (string.IsNullOrEmpty(participant.Metadata))
return new ParticipantCacheItem
return new ParticipantInfoItem
{
Identity = participant.Identity,
Name = participant.Name,
AccountId = accountId,
State = participant.State,
Metadata = metadata,
JoinedAt = DateTime.UtcNow
};
@ -300,92 +295,13 @@ public class LivekitRealtimeService : IRealtimeService
_logger.LogError(ex, "Failed to parse participant metadata");
}
return new ParticipantCacheItem
return new ParticipantInfoItem
{
Identity = participant.Identity,
Name = participant.Name,
AccountId = accountId,
State = participant.State,
Metadata = metadata,
JoinedAt = DateTime.UtcNow
};
}
// Broadcast participant update to all participants in a room
private async Task _BroadcastParticipantUpdate(string roomName)
{
try
{
// Get the room ID from the session name
var roomInfo = await _db.ChatRealtimeCall
.Where(c => c.SessionId == roomName && c.EndedAt == null)
.Select(c => new { c.RoomId, c.Id })
.FirstOrDefaultAsync();
if (roomInfo == null)
{
_logger.LogWarning("Could not find room info for session: {SessionName}", roomName);
return;
}
// Get current participants
var livekitParticipants = await GetRoomParticipantsAsync(roomName);
// Get all room members who should receive this update
var roomMembers = await _db.ChatMembers
.Where(m => m.ChatRoomId == roomInfo.RoomId && m.LeaveAt == null)
.Select(m => m.AccountId)
.ToListAsync();
// Get member profiles for participants who have account IDs
var accountIds = livekitParticipants
.Where(p => p.AccountId.HasValue)
.Select(p => p.AccountId!.Value)
.ToList();
var memberProfiles = new Dictionary<Guid, ChatMember>();
if (accountIds.Count != 0)
{
memberProfiles = await _db.ChatMembers
.Where(m => m.ChatRoomId == roomInfo.RoomId && accountIds.Contains(m.AccountId))
.Include(m => m.Account)
.ThenInclude(m => m.Profile)
.ToDictionaryAsync(m => m.AccountId, m => m);
}
// Convert to CallParticipant objects
var participants = livekitParticipants.Select(p => new CallParticipant
{
Identity = p.Identity,
Name = p.Name,
AccountId = p.AccountId,
JoinedAt = p.JoinedAt,
Profile = p.AccountId.HasValue && memberProfiles.TryGetValue(p.AccountId.Value, out var profile)
? profile
: null
}).ToList();
// Create the update packet with CallParticipant objects
var updatePacket = new WebSocketPacket
{
Type = WebSocketPacketType.CallParticipantsUpdate,
Data = new Dictionary<string, object>
{
{ "room_id", roomInfo.RoomId },
{ "call_id", roomInfo.Id },
{ "participants", participants }
}
};
// Send the update to all members
foreach (var accountId in roomMembers)
{
_ws.SendPacketToAccount(accountId, updatePacket);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error broadcasting participant update for room {RoomName}", roomName);
}
}
}
}

View File

@ -0,0 +1,91 @@
using DysonNetwork.Sphere.Connection;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Chat.Realtime;
public class ParticipantInfoItem
{
public string Identity { get; set; } = null!;
public string Name { get; set; } = null!;
public Guid? AccountId { get; set; }
public Dictionary<string, string> Metadata { get; set; } = new();
public DateTime JoinedAt { get; set; }
}
public class RealtimeStatusService(AppDatabase db, WebSocketService ws, ILogger<RealtimeStatusService> logger)
{
// Broadcast participant update to all participants in a room
public async Task BroadcastParticipantUpdate(string roomName, List<ParticipantInfoItem> participantsInfo)
{
try
{
// Get the room ID from the session name
var roomInfo = await db.ChatRealtimeCall
.Where(c => c.SessionId == roomName && c.EndedAt == null)
.Select(c => new { c.RoomId, c.Id })
.FirstOrDefaultAsync();
if (roomInfo == null)
{
logger.LogWarning("Could not find room info for session: {SessionName}", roomName);
return;
}
// Get all room members who should receive this update
var roomMembers = await db.ChatMembers
.Where(m => m.ChatRoomId == roomInfo.RoomId && m.LeaveAt == null)
.Select(m => m.AccountId)
.ToListAsync();
// Get member profiles for participants who have account IDs
var accountIds = participantsInfo
.Where(p => p.AccountId.HasValue)
.Select(p => p.AccountId!.Value)
.ToList();
var memberProfiles = new Dictionary<Guid, ChatMember>();
if (accountIds.Count != 0)
{
memberProfiles = await db.ChatMembers
.Where(m => m.ChatRoomId == roomInfo.RoomId && accountIds.Contains(m.AccountId))
.Include(m => m.Account)
.ThenInclude(m => m.Profile)
.ToDictionaryAsync(m => m.AccountId, m => m);
}
// Convert to CallParticipant objects
var participants = participantsInfo.Select(p => new CallParticipant
{
Identity = p.Identity,
Name = p.Name,
AccountId = p.AccountId,
JoinedAt = p.JoinedAt,
Profile = p.AccountId.HasValue && memberProfiles.TryGetValue(p.AccountId.Value, out var profile)
? profile
: null
}).ToList();
// Create the update packet with CallParticipant objects
var updatePacket = new WebSocketPacket
{
Type = WebSocketPacketType.CallParticipantsUpdate,
Data = new Dictionary<string, object>
{
{ "room_id", roomInfo.RoomId },
{ "call_id", roomInfo.Id },
{ "participants", participants }
}
};
// Send the update to all members
foreach (var accountId in roomMembers)
{
ws.SendPacketToAccount(accountId, updatePacket);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error broadcasting participant update for room {RoomName}", roomName);
}
}
}

View File

@ -1,5 +1,4 @@
using DysonNetwork.Sphere.Chat.Realtime;
using Livekit.Server.Sdk.Dotnet;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@ -7,13 +6,8 @@ using Swashbuckle.AspNetCore.Annotations;
namespace DysonNetwork.Sphere.Chat;
public class RealtimeChatConfiguration
{
public string Endpoint { get; set; } = null!;
}
[ApiController]
[Route("/chat/realtime")]
[Route("/api/chat/realtime")]
public class RealtimeCallController(
IConfiguration configuration,
AppDatabase db,
@ -21,9 +15,6 @@ public class RealtimeCallController(
IRealtimeService realtime
) : ControllerBase
{
private readonly RealtimeChatConfiguration _config =
configuration.GetSection("RealtimeChat").Get<RealtimeChatConfiguration>()!;
/// <summary>
/// This endpoint is especially designed for livekit webhooks,
/// for update the call participates and more.
@ -36,9 +27,9 @@ public class RealtimeCallController(
using var reader = new StreamReader(Request.Body);
var postData = await reader.ReadToEndAsync();
var authHeader = Request.Headers.Authorization.ToString();
await realtime.ReceiveWebhook(postData, authHeader);
return Ok();
}
@ -91,39 +82,17 @@ public class RealtimeCallController(
return BadRequest("Call session is not properly configured.");
var isAdmin = member.Role >= ChatMemberRole.Moderator;
var userToken = realtime.GetUserToken(currentUser, ongoingCall.SessionId, isAdmin);
var userToken = await realtime.GetUserTokenAsync(currentUser, ongoingCall.SessionId, isAdmin);
// Get LiveKit endpoint from configuration
var endpoint = _config.Endpoint ??
throw new InvalidOperationException("LiveKit endpoint configuration is missing");
// Inject the ChatRoomService
var chatRoomService = HttpContext.RequestServices.GetRequiredService<ChatRoomService>();
// Get current participants from the LiveKit service
var participants = new List<CallParticipant>();
if (realtime is LivekitRealtimeService livekitService)
var endpoint = configuration[$"Realtime:{realtime.ProviderName}:Endpoint"] ?? realtime.ProviderName switch
{
var roomParticipants = await livekitService.GetRoomParticipantsAsync(ongoingCall.SessionId);
participants = [];
foreach (var p in roomParticipants)
{
var participant = new CallParticipant
{
Identity = p.Identity,
Name = p.Name,
AccountId = p.AccountId,
JoinedAt = p.JoinedAt
};
// Fetch the ChatMember profile if we have an account ID
if (p.AccountId.HasValue)
participant.Profile = await chatRoomService.GetRoomMember(p.AccountId.Value, roomId);
participants.Add(participant);
}
}
// Unusable for sure, just for placeholder
"LiveKit" => "https://livekit.cloud",
"Cloudflare" => "https://rtk.realtime.cloudflare.com/v2",
// Unusable for sure, just for placeholder
_ => "https://example.com"
};
// Create the response model
var response = new JoinCallResponse
@ -133,8 +102,7 @@ public class RealtimeCallController(
Token = userToken,
CallId = ongoingCall.Id,
RoomName = ongoingCall.SessionId,
IsAdmin = isAdmin,
Participants = participants
IsAdmin = isAdmin
};
return Ok(response);
@ -192,7 +160,7 @@ public class JoinCallResponse
public string Provider { get; set; } = null!;
/// <summary>
/// The LiveKit server endpoint
/// The provider server endpoint
/// </summary>
public string Endpoint { get; set; } = null!;
@ -215,11 +183,6 @@ public class JoinCallResponse
/// Whether the user is the admin of the call
/// </summary>
public bool IsAdmin { get; set; }
/// <summary>
/// Current participants in the call
/// </summary>
public List<CallParticipant> Participants { get; set; } = new();
}
/// <summary>
@ -231,22 +194,22 @@ public class CallParticipant
/// The participant's identity (username)
/// </summary>
public string Identity { get; set; } = null!;
/// <summary>
/// The participant's display name
/// </summary>
public string Name { get; set; } = null!;
/// <summary>
/// The participant's account ID if available
/// </summary>
public Guid? AccountId { get; set; }
/// <summary>
/// The participant's profile in the chat
/// </summary>
public ChatMember? Profile { get; set; }
/// <summary>
/// When the participant joined the call
/// </summary>

View File

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

View File

@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Connection.WebReader;
[ApiController]
[Route("/feeds/articles")]
[Route("/api/feeds/articles")]
public class WebArticleController(AppDatabase db) : ControllerBase
{
/// <summary>

View File

@ -7,7 +7,7 @@ namespace DysonNetwork.Sphere.Connection.WebReader;
[Authorize]
[ApiController]
[Route("/publishers/{pubName}/feeds")]
[Route("/api/publishers/{pubName}/feeds")]
public class WebFeedController(WebFeedService webFeed, PublisherService ps) : ControllerBase
{
public record WebFeedRequest(

View File

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

View File

@ -38,14 +38,14 @@ public class CustomApp : ModelBase, IIdentifiedResource
[NotMapped] public string ResourceIdentifier => "custom-app/" + Id;
}
public class CustomAppLinks : ModelBase
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 : ModelBase
public class CustomAppOauthConfig
{
[MaxLength(1024)] public string? ClientUri { get; set; }
[MaxLength(4096)] public string[] RedirectUris { get; set; } = [];

View File

@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Developer;
[ApiController]
[Route("/developers/{pubName}/apps")]
[Route("/api/developers/{pubName}/apps")]
public class CustomAppController(CustomAppService customApps, PublisherService ps) : ControllerBase
{
public record CustomAppRequest(

View File

@ -9,7 +9,7 @@ using NodaTime;
namespace DysonNetwork.Sphere.Developer;
[ApiController]
[Route("/developers")]
[Route("/api/developers")]
public class DeveloperController(
AppDatabase db,
PublisherService ps,

View File

@ -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")]

View File

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

View File

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

View File

@ -4,10 +4,10 @@
ViewData["Title"] = "Authorize Application";
}
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 transition-colors duration-200 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 bg-white dark:bg-gray-800 p-8 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 transition-colors duration-200">
<div class="text-center">
<h2 class="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
<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))
@ -16,21 +16,25 @@
<div class="flex items-center justify-center">
@if (!string.IsNullOrEmpty(Model.AppLogo))
{
<div class="relative h-16 w-16 flex-shrink-0">
<img class="h-16 w-16 rounded-lg object-cover border border-gray-200 dark:border-gray-700"
src="@Model.AppLogo"
alt="@Model.AppName logo"
onerror="this.onerror=null; this.src='data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iY3VycmVudENvbG9yIiBjbGFzczz0idy02IGgtNiB0ZXh0LWdyYXktNDAwIj48cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xOS42MDMgMy4wMDRjMS4xNTMgMCAyLjI2LjE4IDMuMzI5LjUxM2EuNzUuNzUgMCAwMS0uMzI4IDEuNDQ1IDE2LjkzIDE2LjkzIDAgMDAtNi4wMS0xuMTAzLjc1Ljc1IDAgMDEtLjY2OC0uNzQ4VjMuNzVBLjc1Ljc1IDAgMDExNi41IDNoMy4xMDN6TTMxLjUgMjEuMDA4Yy0uU0UuNzUgNzUuNzUgMCAwMTEuOTQ4LjA1MWMuMDgxLjkxOC4wNTIgMS44MzgtLjA4NiAyLjczNGEuNzUuNzUgMCAwMS0xLjQ5LS4xNTkgMjEuMzg2IDIxLjM4NiAwIDAwLjA2LTIuNTc1Ljc1Ljc1IDAgMDExLjU3OC0uMDQzem0tMy4wMi0xOC4wMmMuMDYgMS4wOTIuMDQgMi4xODctLjA1NiAzLjI3MmEuNzUuNzUgMCAwMS0xLjQ5Mi0uMTY0Yy4wOTItLjg3NC4xMDctMS43NTYuMDUxLTIuNjA3YS43NS43NSAwIDAxMS40OTctLjEwMXpNNS42MzcgNS42MzJjLS4zMzQuMS0uNjc2LjE4NS0xLjAyNi4yNTdhLjc1Ljc1IDAgMDEuMTQ5LTEuNDljLjQyNS0uMDg1Ljg1Mi0uMTg5IDEuMjY4LS4zMDRhLjc1Ljc1IDAgMDEuMzYgMS40Mzd6TTMuMzMgMTkuNjUzYy0uMTY1LS4zNS0uMzA4LS43MDctNDIuNjUzLjc1Ljc1IDAgMS4zODgtLjU0MiAxLjQ5LTEuMjg1LjA0Ni0uMzI2LjEwNi0uNjUyLjE4LS45NzZhLjc1Ljc1IDAgMTExLjQ2LS41M2MuMTA2LjQzNy4xODkuODgxLjI0NSAxLjM0OGEuNzUuNzUgMCAwMS0xLjQ5LjIzM3pNTEuMzUzIDIuNzY3YS43NS43NSAwIDAxLjc1LS4wMTdsLjAwMS4wMDFoLjAwMWwuMDAyLjAwMWwuMDA3LjAwM2wuMDI0LjAxM2MuMDIuMDEuMDQ1LjAyNS4wNzkuMDQ2LjA2Ny4wNDIuMTYxLjEwMi4yNzUuMTc4LjIzMi4xNTEuNTUuMzgzLjg1Ni42NjdsLjAyNy4wMjRjLjYxNi41NTggMS4yMTIgMS4xNzYgMS43MzMgMS44NDNhLjc1Ljc1IDAgMDEtMS4yNC44N2MtLjQ3LS42NzEtMS4wMzEtMS4yOTItMS42LFsxLjYxNi0xLjYxNmEzLjc1IDMuNzUgMCAwMC01LjMwNS01LjMwNGwtMS4wNi0xuMDZBNy4yNDMgNy4yNDMgMCAwMTUxLjM1MyAyLjc2N3pNNDQuMzc5IDkuNjRsLTEuNTYgMS41NmE2Ljk5IDYuOTkgMCAwMTIuMjMgNC4zMzcgNi45OSA2Ljk5IDAgMDEtMi4yMyA1LjE3NmwtMS41Ni0xLjU2QTguNDkgOC40OSAwIDAwNDUuNSAxNS41YzAtMi4yOTYtLjg3NC00LjQzLTIuMTIxLTYuMDF6bS0zLjUzLTMuNTNsLTEuMDYxLTEuMDZhNy4yNDMgNy4yNDMgMCAwMTkuMTkyIDkuE2x0LTEuMDYgMS4wNjFhNS43NDkgNS43NDkgMCAwMC04LjEzLTguMTN6TTM0LjUgMTUuNWE4Ljk5IDguOTkgMCAwMC0yLjYzMSA2LjM2OS43NS43NSAwIDExLTEuNDk0LS43MzlDNzIuMzkgMjAuMDYgMzMuNSAxNy41NzUgMzMuNSAxNS41YzAtMi4zNzYgMS4wOTktNC40MzggMi44MTEtNS44MTJsLS4zOTYtLjM5NmEuNzUuNzUgMCAwMTEuMDYtMS4wNkwzNy41IDkuNDRWMmgtL4wMDJhLjc1Ljc1IDAgMDEtLjc0OC0uNzVWMS41YS43NS43NSAwIDAxLjc1LS43NWg2YS43NS43NSAwIDAxLjc1Ljc1di4yNWEwIC43NS0uNzUuNzVoLS4wMDF2Ny40NGwzLjUzNy0zLjUzN2EuNzUuNzUgMCAwMTEuMDYgMS4wNmwtLjM5Ni4zOTZDMzUuNDAxIDExLjA2MiAzNC41IDEzLjEyNCAzNC41IDE1LjV6TTM5IDIuMjV2Ni4wMy0uMDAyYTEuNSAxLjUgMCAwMS0uNDQ0IDEuMDZsLTEuMDYxIDEuMDZBOC40OSA4LjQ5IDAgMDAzOSAxNS41YzAgMi4yOTYtLjg3NCA0LjQzLTIuMTIxIDYuMDFsMS41NiAxLjU2QTYuOTkgNi45OSAwIDAwNDIgMTUuNWE2Ljk5IDYuOTkgMCAwMC0yLjIzLTUuMTc2bC0xLjU2LTEuNTZhMS41IDEuNSAwIDAxLS40NC0xLjA2VjIuMjVoLTF6TTI0IDkuNzVhLjc1Ljc1IDAgMDEtLjc1Ljc1di0uNWMwLS40MTQuMzM2LS43NS43NS0uNzVoLjVjLjQxNCAwIC43NS4zMzYuNzUuNzV2LjVhLjc1Ljc1IDAgMDEtLjc1Ljc1aC0uNXpNMTkuNSAxM2EuNzUuNzUgMCAwMS0uNzUtLjc1di0uNWMwLS40MTQuMzM2LS43NS43NS0uNzVoLjVjLjQxNCAwIC43NS4zMzYuNzUuNzV2LjVhLjc1Ljc1IDAgMDEtLjc1Ljc1aC0uNXpNMTUgMTYuMjVhLjc1Ljc1IDAgMDEuNzUtLjc1aC41Yy40MTQgMCAuNzUuMzM2Ljc1Ljc1di41YS43NS43NSAwIDAxLS43NS43NWgtLjVhLjc1Ljc1IDAgMDEtLjc1LS43NXYtLjV6IiBjbGlwLXJ1bGU9ImV2ZW5vZGQiLz48L3N2Zz4=';">
<div class="absolute inset-0 flex items-center justify-center bg-gray-100 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600">
<span class="text-xs font-medium text-gray-500 dark:text-gray-300">@Model.AppName?[0]</span>
<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 text-gray-900 dark:text-white">@Model.AppName</h3>
<h3 class="text-lg font-medium">@Model.AppName</h3>
@if (!string.IsNullOrEmpty(Model.AppUri))
{
<a href="@Model.AppUri" class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300 transition-colors duration-200" target="_blank" rel="noopener noreferrer">
<a href="@Model.AppUri" class="text-sm link link-primary" target="_blank" rel="noopener noreferrer">
@Model.AppUri
</a>
}
@ -38,77 +42,59 @@
</div>
</div>
}
<p class="mt-6 text-sm text-gray-600 dark:text-gray-300">
wants to access your account with the following permissions:
<p class="mt-6 text-sm text-center">
When you authorize this application, you consent to the following permissions:
</p>
</div>
<div class="mt-6">
<ul class="border border-gray-200 dark:border-gray-700 rounded-lg divide-y divide-gray-200 dark:divide-gray-700 overflow-hidden">
@if (Model.Scope != null)
{
var scopeDescriptions = new Dictionary<string, (string Name, string Description)>
<div class="mt-4">
<ul class="menu bg-base-200 rounded-box w-full">
@if (Model.Scope != null)
{
["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")
};
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 class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150">
<div class="flex items-start">
<div class="flex-shrink-0 pt-0.5">
<svg class="h-5 w-5 text-green-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
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>
<div class="ml-3">
<p class="text-sm font-medium text-gray-900 dark:text-white">@scopeInfo.Item1</p>
<p class="text-xs text-gray-500 dark:text-gray-400">@scopeInfo.Item2</p>
</div>
</div>
</li>
<div>
<p class="font-medium">@scopeInfo.Item1</p>
<p class="text-xs text-base-content/70">@scopeInfo.Item2</p>
</div>
</a>
</li>
}
}
}
</ul>
<div class="mt-4 text-xs text-gray-500 dark:text-gray-400">
<p>By authorizing, you allow this application to access your information on your behalf.</p>
</ul>
</div>
</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" name="code_challenge" value="@HttpContext.Request.Query["code_challenge"]" />
<input type="hidden" name="code_challenge_method" value="@HttpContext.Request.Query["code_challenge_method"]" />
<input type="hidden" name="response_mode" value="@HttpContext.Request.Query["response_mode"]" />
<div class="flex flex-col space-y-3">
<button type="submit" name="allow" value="true"
class="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-800 transition-colors duration-200">
Allow
</button>
<button type="submit" name="allow" value="false"
class="w-full flex justify-center py-2.5 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-800 transition-colors duration-200">
Deny
</button>
</div>
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<p class="text-xs text-center text-gray-500 dark:text-gray-400">
You can change these permissions later in your account settings.
</p>
</div>
</form>
<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>
@ -124,4 +110,4 @@
_ => scope
};
}
}
}

View File

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

View File

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

View File

@ -1,36 +1,36 @@
@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">
<input type="hidden" asp-for="ReturnUrl" value="@returnUrl" />
<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 class="mt-8 flex flex-col text-sm text-center">
<span class="text-gray-900 dark:text-white opacity-80">Have no account?</span>
<a href="https://solian.app/#/auth/create-account"
class="text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300">
Create a new account →
</a>
</div>
</div>
</div>

View File

@ -1,58 +1,104 @@
@page "/web/auth/challenge/{id:guid}/select-factor"
@page "//auth/challenge/{id:guid}/select-factor"
@using DysonNetwork.Sphere.Account
@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="SelectedFactorId" 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
};
}
}

View File

@ -1,77 +1,99 @@
@page "/web/auth/challenge/{id:guid}/verify/{factorId:guid}"
@page "//auth/challenge/{id:guid}/verify/{factorId:guid}"
@using DysonNetwork.Sphere.Account
@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"
type="password"
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"); }
}
}

View File

@ -32,16 +32,24 @@ namespace DysonNetwork.Sphere.Pages.Auth
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();
@ -52,6 +60,12 @@ 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))
@ -79,7 +93,7 @@ namespace DysonNetwork.Sphere.Pages.Auth
{ "account_id", AuthChallenge.AccountId }
}, Request, AuthChallenge.Account);
return await ExchangeTokenAndRedirect();
return await ExchangeTokenAndRedirect(AuthChallenge);
}
else
@ -131,14 +145,10 @@ namespace DysonNetwork.Sphere.Pages.Auth
}
}
private async Task<IActionResult> ExchangeTokenAndRedirect()
private async Task<IActionResult> ExchangeTokenAndRedirect(Challenge challenge)
{
var challenge = await db.AuthChallenges
.Include(e => e.Account)
.FirstOrDefaultAsync(e => e.Id == Id);
if (challenge == null) return BadRequest("Authorization code not found or expired.");
if (challenge.StepRemain != 0) return BadRequest("Challenge not yet completed.");
await db.Entry(challenge).ReloadAsync();
if (challenge.StepRemain != 0) return BadRequest($"Challenge not yet completed. Remaining steps: {challenge.StepRemain}");
var session = await db.AuthSessions
.FirstOrDefaultAsync(e => e.ChallengeId == challenge.Id);
@ -160,7 +170,7 @@ namespace DysonNetwork.Sphere.Pages.Auth
Response.Cookies.Append(AuthConstants.CookieTokenName, token, new CookieOptions
{
HttpOnly = true,
Secure = !configuration.GetValue<bool>("Debug"),
Secure = Request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/"
});

View File

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

View File

@ -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">λ&nbsp;</span> API Docs
</a>
<a href="https://kb.solsynth.dev" target="_blank" class="btn-text">
Learn more <span aria-hidden="true">→</span>
</a>
</div>
<div 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">λ&nbsp;</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>

View File

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

View File

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

View File

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

View File

@ -5,34 +5,54 @@
<meta charset="utf-8"/>
<meta 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="/web/account/profile" class="text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300 px-3 py-2 rounded-md text-sm font-medium">Profile</a>
<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 *@
<main class="h-full">
<main class="h-full pt-16">
@RenderBody()
</main>

View File

@ -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>
&copy; @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>
&copy; @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>

View File

@ -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;
@ -13,7 +14,7 @@ using NpgsqlTypes;
namespace DysonNetwork.Sphere.Post;
[ApiController]
[Route("/posts")]
[Route("/api/posts")]
public class PostController(
AppDatabase db,
PostService ps,
@ -65,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);

View File

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

View File

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

View File

@ -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")]

View File

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

View File

@ -95,4 +95,64 @@
<data name="FortuneTipNegativeTitle_1" xml:space="preserve">
<value>抽卡</value>
</data>
<data name="FortuneTipPositiveTitle_8" xml:space="preserve">
<value>学习</value>
</data>
<data name="FortuneTipPositiveContent_8" xml:space="preserve">
<value>效率200%</value>
</data>
<data name="FortuneTipNegativeTitle_8" xml:space="preserve">
<value>学习</value>
</data>
<data name="FortuneTipNegativeContent_8" xml:space="preserve">
<value>效率50%</value>
</data>
<data name="FortuneTipPositiveTitle_9" xml:space="preserve">
<value>编曲</value>
</data>
<data name="FortuneTipPositiveContent_9" xml:space="preserve">
<value>灵感爆棚</value>
</data>
<data name="FortuneTipNegativeTitle_9" xml:space="preserve">
<value>编曲</value>
</data>
<data name="FortuneTipNegativeContent_9" xml:space="preserve">
<value>FL Studio未响应</value>
</data>
<data name="FortuneTipPositiveTitle_10" xml:space="preserve">
<value>摄影</value>
</data>
<data name="FortuneTipPositiveContent_10" xml:space="preserve">
<value>刀锐奶化</value>
</data>
<data name="FortuneTipNegativeTitle_10" xml:space="preserve">
<value>摄影</value>
</data>
<data name="FortuneTipNegativeContent_10" xml:space="preserve">
<value>"NO CARD"</value>
</data>
<data name="FortuneTipPositiveTitle_11" xml:space="preserve">
<value>焊 PCB</value>
</data>
<data name="FortuneTipPositiveContent_11" xml:space="preserve">
<value>上电,启动,好耶!</value>
</data>
<data name="FortuneTipNegativeTitle_11" xml:space="preserve">
<value>焊 PCB</value>
</data>
<data name="FortuneTipNegativeContent_11" xml:space="preserve">
<value>斯~不烫</value>
</data>
<data name="FortuneTipPositiveTitle_12" xml:space="preserve">
<value>AE 启动</value>
</data>
<data name="FortuneTipPositiveContent_12" xml:space="preserve">
<value>帧渲染时间 20ms</value>
</data>
<data name="FortuneTipNegativeTitle_12" xml:space="preserve">
<value>AE 启动</value>
</data>
<data name="FortuneTipNegativeContent_12" xml:space="preserve">
<value>咩~</value>
</data>
</root>

View File

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

View File

@ -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>();

View File

@ -229,7 +229,8 @@ public static class ServiceCollectionExtensions
services.AddScoped<WalletService>();
services.AddScoped<SubscriptionService>();
services.AddScoped<PaymentService>();
services.AddScoped<IRealtimeService, LivekitRealtimeService>();
services.AddScoped<RealtimeStatusService>();
services.AddRealtimeService(configuration);
services.AddScoped<WebReaderService>();
services.AddScoped<WebFeedService>();
services.AddScoped<AfdianPaymentHandler>();
@ -242,4 +243,22 @@ public static class ServiceCollectionExtensions
return services;
}
}
private static IServiceCollection AddRealtimeService(this IServiceCollection services, IConfiguration configuration)
{
var provider = configuration["Realtime:Provider"];
switch (provider)
{
case "Cloudflare":
services.AddHttpClient<IRealtimeService, CloudflareRealtimeService>();
break;
case "LiveKit":
services.AddScoped<IRealtimeService, LiveKitRealtimeService>();
break;
default:
throw new NotSupportedException($"Realtime provider '{provider}' is not supported.");
}
return services;
}
}

View File

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

View File

@ -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")]

View File

@ -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}")]

View File

@ -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!;
}
}

View File

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

View File

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

View File

@ -85,10 +85,18 @@
"FromName": "Alphabot",
"SubjectPrefix": "Solar Network"
},
"RealtimeChat": {
"Endpoint": "https://solar-network-im44o8gq.livekit.cloud",
"ApiKey": "APIs6TiL8wj3A4j",
"ApiSecret": "SffxRneIwTnlHPtEf3zicmmv3LUEl7xXael4PvWZrEhE"
"Realtime": {
"Provider": "Cloudflare",
"LiveKit": {
"Endpoint": "https://solar-network-im44o8gq.livekit.cloud",
"ApiKey": "APIs6TiL8wj3A4j",
"ApiSecret": "SffxRneIwTnlHPtEf3zicmmv3LUEl7xXael4PvWZrEhE"
},
"Cloudflare": {
"ApiKey": "",
"ApiSecret": "",
"PreferredRegion": "us-east-1"
}
},
"GeoIp": {
"DatabasePath": "./Keys/GeoLite2-City.mmdb"

View File

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

View File

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

View File

@ -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>
@ -54,6 +56,7 @@
<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>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ALivekitModels_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Ff013807925809b234b3ca1be31567576bb8bda08f2c4fa5d290d4d14d8134_003FLivekitModels_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ALivekitRoom_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F82666257d5ad47354add7af860f66dd85df55ec93e92e8a45891b9bff7bf80ac_003FLivekitRoom_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMailboxAddress_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8e03e47c46b7469f97abc40667cbcf9b133000_003Fa6_003F83324248_003FMailboxAddress_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMediaAnalysis_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffef366b36a224d469ff150d30f9a866d23c00_003Fd7_003F5c138865_003FMediaAnalysis_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
@ -70,6 +73,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 +85,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASourceCustom_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fdaa8d9c408cd4b4286bbef7e35f1a42e31c00_003F45_003F5839ca6c_003FSourceCustom_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F3bef61b8a21d4c8e96872ecdd7782fa0e55000_003F7a_003F870020d0_003FStackFrameIterator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003Fdf_003F3fcdc4d2_003FStackFrameIterator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fe6898c1ddf974e16b95b114722270029e55000_003F6b_003F7530575d_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>