🐛 Bug fixes and improvements

This commit is contained in:
LittleSheep 2025-05-01 00:47:26 +08:00
parent 758186f674
commit 84a88222bd
13 changed files with 111 additions and 59 deletions

@ -4,7 +4,6 @@ using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
@ -15,8 +14,8 @@ public class AccountController(
AppDatabase db,
FileService fs,
AuthService auth,
MagicSpellService spells,
IMemoryCache memCache
AccountService accounts,
MagicSpellService spells
) : ControllerBase
{
[HttpGet("{name}")]
@ -138,7 +137,7 @@ public class AccountController(
if (request.Nick is not null) account.Nick = request.Nick;
if (request.Language is not null) account.Language = request.Language;
memCache.Remove($"user_${account.Id}");
await accounts.PurgeAccountCache(account);
await db.SaveChangesAsync();
return account;
@ -199,7 +198,7 @@ public class AccountController(
db.Update(profile);
await db.SaveChangesAsync();
memCache.Remove($"user_${userId}");
await accounts.PurgeAccountCache(currentUser);
return profile;
}

@ -1,12 +1,23 @@
using Casbin;
using DysonNetwork.Sphere.Permission;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public class AccountService(AppDatabase db, PermissionService pm)
public class AccountService(AppDatabase db, PermissionService pm, IMemoryCache cache)
{
public async Task PurgeAccountCache(Account account)
{
var sessions = await db.AuthSessions.Where(e => e.Account.Id == account.Id).Select(e => e.Id)
.ToListAsync();
foreach (var session in sessions)
{
cache.Remove($"dyn_auth_{session}");
}
}
public async Task<Account?> LookupAccount(string probe)
{
var account = await db.Accounts.Where(a => a.Name == probe).FirstOrDefaultAsync();

@ -45,6 +45,8 @@ public class MagicSpellService(AppDatabase db, EmailService email, ILogger<Magic
// TODO replace the baseurl
var link = $"https://api.sn.solsynth.dev/spells/{Uri.EscapeDataString(spell.Spell)}";
logger.LogError($"Sending magic spell... {link}");
try
{

@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Post;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -36,7 +37,6 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C
public class PushNotificationSubscribeRequest
{
[MaxLength(4096)] public string DeviceId { get; set; } = null!;
[MaxLength(4096)] public string DeviceToken { get; set; } = null!;
public NotificationPushProvider Provider { get; set; }
}
@ -47,12 +47,16 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C
[FromBody] PushNotificationSubscribeRequest request
)
{
HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue);
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account;
if (currentUser == null) return Unauthorized();
var currentSession = currentSessionValue as Session;
if (currentSession == null) return Unauthorized();
var result =
await nty.SubscribePushNotification(currentUser, request.Provider, request.DeviceId, request.DeviceToken);
await nty.SubscribePushNotification(currentUser, request.Provider, currentSession.Challenge.DeviceId!,
request.DeviceToken);
return Ok(result);
}

@ -72,11 +72,10 @@ public class NotificationService
DeviceId = deviceId,
DeviceToken = deviceToken,
Provider = provider,
Account = account,
AccountId = account.Id,
};
_db.Add(subscription);
_db.NotificationPushSubscriptions.Add(subscription);
await _db.SaveChangesAsync();
return subscription;

@ -20,8 +20,9 @@ public class AuthController(
{
public class ChallengeRequest
{
[Required] public ChallengePlatform Platform { get; set; }
[Required] [MaxLength(256)] public string Account { get; set; } = string.Empty;
[MaxLength(512)] public string? DeviceId { get; set; }
[Required] [MaxLength(512)] public string DeviceId { get; set; }
public List<string> Audiences { get; set; } = new();
public List<string> Scopes { get; set; } = new();
}
@ -52,6 +53,7 @@ public class AuthController(
Account = account,
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)),
StepTotal = 1,
Platform = request.Platform,
Audiences = request.Audiences,
Scopes = request.Scopes,
IpAddress = ipAddress,
@ -125,6 +127,8 @@ public class AuthController(
}
catch
{
challenge.FailedAttempts++;
await db.SaveChangesAsync();
return BadRequest();
}

@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using Microsoft.VisualStudio.Web.CodeGenerators.Mvc.Templates.BlazorIdentity.Pages;
using NodaTime;
namespace DysonNetwork.Sphere.Auth;
@ -8,6 +9,7 @@ namespace DysonNetwork.Sphere.Auth;
public class Session : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string? Label { get; set; }
public Instant? LastGrantedAt { get; set; }
public Instant? ExpiredAt { get; set; }
@ -15,6 +17,23 @@ public class Session : ModelBase
[JsonIgnore] public Challenge Challenge { get; set; } = null!;
}
public enum ChallengeType
{
Login,
OAuth
}
public enum ChallengePlatform
{
Unidentified,
Web,
Ios,
Android,
MacOs,
Windows,
Linux
}
public class Challenge : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
@ -22,6 +41,8 @@ public class Challenge : ModelBase
public int StepRemain { get; set; }
public int StepTotal { get; set; }
public int FailedAttempts { get; set; }
public ChallengePlatform Platform { get; set; } = ChallengePlatform.Unidentified;
public ChallengeType Type { get; set; } = ChallengeType.Login;
[Column(TypeName = "jsonb")] public List<long> BlacklistFactors { get; set; } = new();
[Column(TypeName = "jsonb")] public List<string> Audiences { get; set; } = new();
[Column(TypeName = "jsonb")] public List<string> Scopes { get; set; } = new();

@ -7,29 +7,30 @@ public class UserInfoMiddleware(RequestDelegate next, IMemoryCache cache)
{
public async Task InvokeAsync(HttpContext context, AppDatabase db)
{
var userIdClaim = context.User.FindFirst("user_id")?.Value;
if (userIdClaim is not null && long.TryParse(userIdClaim, out var userId))
var sessionIdClaim = context.User.FindFirst("session_id")?.Value;
if (sessionIdClaim is not null && Guid.TryParse(sessionIdClaim, out var sessionId))
{
if (!cache.TryGetValue($"user_{userId}", out Account.Account? user))
if (!cache.TryGetValue($"dyn_auth_{sessionId}", out Session? session))
{
user = await db.Accounts
.Include(e => e.Profile)
.Include(e => e.Profile.Picture)
.Include(e => e.Profile.Background)
.Where(e => e.Id == userId)
session = await db.AuthSessions
.Include(e => e.Challenge)
.Include(e => e.Account)
.Include(e => e.Account.Profile)
.Include(e => e.Account.Profile.Picture)
.Include(e => e.Account.Profile.Background)
.Where(e => e.Id == sessionId)
.FirstOrDefaultAsync();
if (user is not null)
if (session is not null)
{
cache.Set($"user_{userId}", user, TimeSpan.FromMinutes(10));
cache.Set($"dyn_auth_{sessionId}", session, TimeSpan.FromHours(1));
}
}
if (user is not null)
if (session is not null)
{
context.Items["CurrentUser"] = user;
var prefix = user.IsSuperuser ? "super:" : "";
context.Items["CurrentIdentity"] = $"{prefix}{userId}";
context.Items["CurrentUser"] = session.Account;
context.Items["CurrentSession"] = session;
}
}

@ -1,6 +1,5 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
using DysonNetwork.Sphere.Account;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
@ -11,13 +10,10 @@ namespace DysonNetwork.Sphere.Connection;
[Route("/ws")]
public class WebSocketController : ControllerBase
{
// Concurrent dictionary to store active WebSocket connections.
// Key: Tuple (AccountId, DeviceId); Value: WebSocket and CancellationTokenSource
private static readonly ConcurrentDictionary<
(long AccountId, string DeviceId),
(WebSocket Socket, CancellationTokenSource Cts)
> ActiveConnections =
new ConcurrentDictionary<(long, string), (WebSocket, CancellationTokenSource)>();
> ActiveConnections = new();
[Route("/ws")]
[Authorize]
@ -26,18 +22,18 @@ public class WebSocketController : ControllerBase
{
if (HttpContext.WebSockets.IsWebSocketRequest)
{
// Get AccountId from HttpContext
if (
!HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue)
|| currentUserValue is not Account.Account currentUser
)
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue);
if (currentUserValue is not Account.Account currentUser ||
currentSessionValue is not Auth.Session currentSession)
{
HttpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
long accountId = currentUser.Id;
// Verify deviceId
var accountId = currentUser.Id;
var deviceId = currentSession.Challenge.DeviceId;
if (string.IsNullOrEmpty(deviceId))
{
HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
@ -45,14 +41,11 @@ public class WebSocketController : ControllerBase
}
using var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
// Create a CancellationTokenSource for this connection
var cts = new CancellationTokenSource();
var connectionKey = (accountId, deviceId);
// Add the connection to the active connections dictionary
if (!ActiveConnections.TryAdd(connectionKey, (webSocket, cts)))
{
// Failed to add
await webSocket.CloseAsync(
WebSocketCloseStatus.InternalServerError,
"Failed to establish connection.",
@ -71,7 +64,6 @@ public class WebSocketController : ControllerBase
}
finally
{
// Connection is closed, remove it from the active connections dictionary
ActiveConnections.TryRemove(connectionKey, out _);
cts.Dispose();
}
@ -88,25 +80,21 @@ public class WebSocketController : ControllerBase
CancellationToken cancellationToken
)
{
// Buffer for receiving messages.
var buffer = new byte[1024 * 4];
try
{
// We don't handle receiving data, so we ignore the return.
var receiveResult = await webSocket.ReceiveAsync(
new ArraySegment<byte>(buffer),
cancellationToken
);
while (!receiveResult.CloseStatus.HasValue)
{
// Keep connection alive and wait for close requests
receiveResult = await webSocket.ReceiveAsync(
new ArraySegment<byte>(buffer),
cancellationToken
);
}
// Close connection
await webSocket.CloseAsync(
receiveResult.CloseStatus.Value,
receiveResult.CloseStatusDescription,
@ -144,5 +132,4 @@ public class WebSocketController : ControllerBase
);
}
}
}
}

@ -15,7 +15,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace DysonNetwork.Sphere.Migrations
{
[DbContext(typeof(AppDatabase))]
[Migration("20250429115700_InitialMigration")]
[Migration("20250430163514_InitialMigration")]
partial class InitialMigration
{
/// <inheritdoc />
@ -508,6 +508,10 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("character varying(1024)")
.HasColumnName("nonce");
b.Property<int>("Platform")
.HasColumnType("integer")
.HasColumnName("platform");
b.Property<List<string>>("Scopes")
.IsRequired()
.HasColumnType("jsonb")
@ -521,6 +525,10 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("integer")
.HasColumnName("step_total");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
@ -566,6 +574,11 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("Label")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("label");
b.Property<Instant?>("LastGrantedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_granted_at");

@ -171,6 +171,8 @@ namespace DysonNetwork.Sphere.Migrations
step_remain = table.Column<int>(type: "integer", nullable: false),
step_total = table.Column<int>(type: "integer", nullable: false),
failed_attempts = table.Column<int>(type: "integer", nullable: false),
platform = table.Column<int>(type: "integer", nullable: false),
type = table.Column<int>(type: "integer", nullable: false),
blacklist_factors = table.Column<List<long>>(type: "jsonb", nullable: false),
audiences = table.Column<List<string>>(type: "jsonb", nullable: false),
scopes = table.Column<List<string>>(type: "jsonb", nullable: false),
@ -326,6 +328,7 @@ namespace DysonNetwork.Sphere.Migrations
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
label = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
last_granted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
account_id = table.Column<long>(type: "bigint", nullable: false),

@ -505,6 +505,10 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("character varying(1024)")
.HasColumnName("nonce");
b.Property<int>("Platform")
.HasColumnType("integer")
.HasColumnName("platform");
b.Property<List<string>>("Scopes")
.IsRequired()
.HasColumnType("jsonb")
@ -518,6 +522,10 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("integer")
.HasColumnName("step_total");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
@ -563,6 +571,11 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("Label")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("label");
b.Property<Instant?>("LastGrantedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_granted_at");

@ -24,6 +24,7 @@ using NodaTime.Serialization.SystemTextJson;
using Quartz;
using tusdotnet;
using tusdotnet.Models;
using tusdotnet.Models.Configuration;
using File = System.IO.File;
var builder = WebApplication.CreateBuilder(args);
@ -34,7 +35,6 @@ builder.WebHost.ConfigureKestrel(options => options.Limits.MaxRequestBodySize =
// Add services to the container.
builder.Services.AddDbContext<AppDatabase>();
builder.Services.AddMemoryCache();
builder.Services.AddHttpClient();
builder.Services.AddControllers().AddJsonOptions(options =>
@ -189,7 +189,7 @@ var tusDiskStore = new tusdotnet.Stores.TusDiskStore(
app.MapTus("/files/tus", (_) => Task.FromResult<DefaultTusConfiguration>(new()
{
Store = tusDiskStore,
Events = new()
Events = new Events
{
OnAuthorizeAsync = async eventContext =>
{
@ -203,8 +203,8 @@ app.MapTus("/files/tus", (_) => Task.FromResult<DefaultTusConfiguration>(new()
}
var httpContext = eventContext.HttpContext;
var user = httpContext.User;
if (!user.Identity?.IsAuthenticated ?? true)
var user = httpContext.Items["CurrentUser"] as Account;
if (user is null)
{
eventContext.FailRequest(HttpStatusCode.Unauthorized);
return;
@ -223,12 +223,7 @@ app.MapTus("/files/tus", (_) => Task.FromResult<DefaultTusConfiguration>(new()
OnFileCompleteAsync = async eventContext =>
{
var httpContext = eventContext.HttpContext;
var user = httpContext.User;
var userId = long.Parse(user.FindFirst("user_id")!.Value);
var db = httpContext.RequestServices.GetRequiredService<AppDatabase>();
var account = await db.Accounts.FindAsync(userId);
if (account is null) return;
if (httpContext.Items["CurrentUser"] is not Account user) return;
var file = await eventContext.GetFileAsync();
var metadata = await file.GetMetadataAsync(eventContext.CancellationToken);
@ -238,7 +233,7 @@ app.MapTus("/files/tus", (_) => Task.FromResult<DefaultTusConfiguration>(new()
var fileService = eventContext.HttpContext.RequestServices.GetRequiredService<FileService>();
var info = await fileService.AnalyzeFileAsync(account, file.Id, fileStream, fileName, contentType);
var info = await fileService.AnalyzeFileAsync(user, file.Id, fileStream, fileName, contentType);
var jsonOptions = httpContext.RequestServices.GetRequiredService<IOptions<JsonOptions>>().Value
.JsonSerializerOptions;