Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions .cursor/debug-85a457.log

Large diffs are not rendered by default.

5,644 changes: 5,644 additions & 0 deletions .cursor/debug-d9986e.log

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions .cursor/debug.log
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,9 @@
{"location":"UserService.cs:SearchUsersAsync:entry","message":"Search entry","data":{"currentUserId":"00000000-0000-0000-0000-000000000005","query":"ad","limit":15},"timestamp":1771306695323,"hypothesisId":"H2"}
{"location":"UserService.cs:SearchUsersAsync:exclude","message":"Exclude list built","data":{"friendOrPendingCount":3,"excludeCount":4,"allFriendRequestsForUser":4},"timestamp":1771306695383,"hypothesisId":"H1"}
{"location":"UserService.cs:SearchUsersAsync:exit","message":"Search result","data":{"resultCount":1,"resultIds":["00000000-0000-0000-0000-000000000006"]},"timestamp":1771306695412,"hypothesisId":"H2"}
{"location":"UserService.cs:SearchUsersAsync:entry","message":"Search entry","data":{"currentUserId":"00000000-0000-0000-0000-000000000004","query":"a","limit":15},"timestamp":1771570953880,"hypothesisId":"H2"}
{"location":"UserService.cs:SearchUsersAsync:exclude","message":"Exclude list built","data":{"friendOrPendingCount":0,"excludeCount":1,"allFriendRequestsForUser":0},"timestamp":1771570953947,"hypothesisId":"H1"}
{"location":"UserService.cs:SearchUsersAsync:exit","message":"Search result","data":{"resultCount":9,"resultIds":["00000000-0000-0000-0000-000000000001","00000000-0000-0000-0000-000000000005","00000000-0000-0000-0000-000000000006","932d1aad-af10-49f9-a2b4-bd17ac0acd5d","e7a104df-6914-47dd-8a3a-9ac01355f5cd"]},"timestamp":1771570953980,"hypothesisId":"H2"}
{"location":"UserService.cs:SearchUsersAsync:entry","message":"Search entry","data":{"currentUserId":"00000000-0000-0000-0000-000000000004","query":"ad","limit":15},"timestamp":1771570954217,"hypothesisId":"H2"}
{"location":"UserService.cs:SearchUsersAsync:exclude","message":"Exclude list built","data":{"friendOrPendingCount":0,"excludeCount":1,"allFriendRequestsForUser":0},"timestamp":1771570954254,"hypothesisId":"H1"}
{"location":"UserService.cs:SearchUsersAsync:exit","message":"Search result","data":{"resultCount":4,"resultIds":["00000000-0000-0000-0000-000000000001","00000000-0000-0000-0000-000000000005","00000000-0000-0000-0000-000000000006","e7a104df-6914-47dd-8a3a-9ac01355f5cd"]},"timestamp":1771570954272,"hypothesisId":"H2"}
343 changes: 343 additions & 0 deletions Source/backend/src/TableWorks.API/.cursor/debug.log

Large diffs are not rendered by default.

120 changes: 120 additions & 0 deletions Source/backend/src/TableWorks.API/Hubs/BoardHub.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using ASideNote.Application.Interfaces;
using ASideNote.Core.Entities;
using ASideNote.Core.Interfaces;
using System.Security.Claims;

namespace ASideNote.API.Hubs;

[Authorize]
public sealed class BoardHub : Hub
{
public const string GroupPrefix = "board:";
private readonly IBoardAccessService _boardAccess;
private readonly IBoardPresenceService _presence;
private readonly IRepository<User> _userRepo;
private readonly ILogger<BoardHub> _logger;

public BoardHub(
IBoardAccessService boardAccess,
IBoardPresenceService presence,
IRepository<User> userRepo,
ILogger<BoardHub> logger)
{
_boardAccess = boardAccess;
_presence = presence;
_userRepo = userRepo;
_logger = logger;
}

public override async Task OnDisconnectedAsync(Exception? exception)
{
var boardIds = _presence.RemovePresence(Context.ConnectionId!);
var userId = GetUserId();
foreach (var boardId in boardIds)
{
if (userId.HasValue)
await Clients.Group(GroupPrefix + boardId.ToString()).SendAsync("UserLeft", userId.Value);
}
await base.OnDisconnectedAsync(exception);
}

public async Task JoinBoard(Guid boardId)
{
var cancellationToken = Context.ConnectionAborted;
var userId = GetUserId();
if (userId is null)
{
_logger.LogWarning("JoinBoard: User not authenticated. Claims present: {HasUser}",
Context.User != null);
throw new HubException("Unauthorized");
}

var hasAccess = await _boardAccess.HasReadAccessAsync(userId.Value, boardId, cancellationToken);
if (!hasAccess)
{
_logger.LogWarning("JoinBoard: User {UserId} has no read access to board {BoardId}", userId, boardId);
throw new HubException("Access denied to this board.");
}

await Groups.AddToGroupAsync(Context.ConnectionId, GroupPrefix + boardId.ToString(), cancellationToken);

var displayName = await _userRepo.Query()
.Where(u => u.Id == userId.Value)
.Select(u => u.Username)
.FirstOrDefaultAsync(cancellationToken) ?? userId.Value.ToString();

_presence.AddPresence(boardId, Context.ConnectionId!, userId.Value, displayName);

var presenceList = _presence.GetPresence(boardId)
.Select(p => new { userId = p.UserId, displayName = p.DisplayName })
.ToList();
await Clients.Caller.SendAsync("PresenceList", presenceList, cancellationToken);
await Clients.OthersInGroup(GroupPrefix + boardId.ToString()).SendAsync("UserJoined", userId.Value, displayName, cancellationToken);

_logger.LogDebug("JoinBoard: User {UserId} joined board {BoardId}", userId, boardId);
}

public async Task LeaveBoard(Guid boardId)
{
var cancellationToken = Context.ConnectionAborted;
await Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupPrefix + boardId.ToString(), cancellationToken);
var leftUserId = _presence.LeaveBoard(boardId, Context.ConnectionId!);
if (leftUserId.HasValue)
await Clients.OthersInGroup(GroupPrefix + boardId.ToString()).SendAsync("UserLeft", leftUserId.Value, cancellationToken);
}

public async Task UserFocusingItem(Guid boardId, string itemType, string? itemId)
{
var userId = GetUserId();
if (userId is null) return;
var groupName = GroupPrefix + boardId.ToString();
await Clients.OthersInGroup(groupName).SendAsync("UserFocusingItem", userId.Value, itemType ?? "", itemId);
}

public async Task CursorPosition(Guid boardId, double x, double y)
{
var userId = GetUserId();
if (userId is null) return;
var groupName = GroupPrefix + boardId.ToString();
await Clients.OthersInGroup(groupName).SendAsync("CursorPosition", userId.Value, x, y);
}

/// <summary>Broadcast text cursor position within a note/card editor for collaborative editing.</summary>
public async Task TextCursorPosition(Guid boardId, string itemType, string itemId, string field, int position)
{
var userId = GetUserId();
if (userId is null) return;
var groupName = GroupPrefix + boardId.ToString();
await Clients.OthersInGroup(groupName).SendAsync("TextCursorPosition", userId.Value, itemType ?? "", itemId ?? "", field ?? "content", position);
}

private Guid? GetUserId()
{
var sub = Context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? Context.User?.FindFirst("sub")?.Value;
return Guid.TryParse(sub, out var id) ? id : null;
}
}
103 changes: 103 additions & 0 deletions Source/backend/src/TableWorks.API/Hubs/NotebookHub.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using ASideNote.Application.Interfaces;
using ASideNote.Core.Entities;
using ASideNote.Core.Interfaces;
using System.Security.Claims;

namespace ASideNote.API.Hubs;

[Authorize]
public sealed class NotebookHub : Hub
{
public const string GroupPrefix = "notebook:";
private readonly INotebookAccessService _notebookAccess;
private readonly INotebookPresenceService _presence;
private readonly IRepository<User> _userRepo;
private readonly ILogger<NotebookHub> _logger;

public NotebookHub(
INotebookAccessService notebookAccess,
INotebookPresenceService presence,
IRepository<User> userRepo,
ILogger<NotebookHub> logger)
{
_notebookAccess = notebookAccess;
_presence = presence;
_userRepo = userRepo;
_logger = logger;
}

public override async Task OnDisconnectedAsync(Exception? exception)
{
var notebookIds = _presence.RemovePresence(Context.ConnectionId!);
var userId = GetUserId();
foreach (var notebookId in notebookIds)
{
if (userId.HasValue)
await Clients.Group(GroupPrefix + notebookId.ToString()).SendAsync("UserLeft", userId.Value);
}
await base.OnDisconnectedAsync(exception);
}

public async Task JoinNotebook(Guid notebookId)
{
var cancellationToken = Context.ConnectionAborted;
var userId = GetUserId();
if (userId is null)
{
_logger.LogWarning("JoinNotebook: User not authenticated.");
throw new HubException("Unauthorized");
}

var hasAccess = await _notebookAccess.HasReadAccessAsync(userId.Value, notebookId, cancellationToken);
if (!hasAccess)
{
_logger.LogWarning("JoinNotebook: User {UserId} has no read access to notebook {NotebookId}", userId, notebookId);
throw new HubException("Access denied to this notebook.");
}

await Groups.AddToGroupAsync(Context.ConnectionId, GroupPrefix + notebookId.ToString(), cancellationToken);

var displayName = await _userRepo.Query()
.Where(u => u.Id == userId.Value)
.Select(u => u.Username)
.FirstOrDefaultAsync(cancellationToken) ?? userId.Value.ToString();

_presence.AddPresence(notebookId, Context.ConnectionId!, userId.Value, displayName);

var presenceList = _presence.GetPresence(notebookId)
.Select(p => new { userId = p.UserId, displayName = p.DisplayName })
.ToList();
await Clients.Caller.SendAsync("PresenceList", presenceList, cancellationToken);
await Clients.OthersInGroup(GroupPrefix + notebookId.ToString()).SendAsync("UserJoined", userId.Value, displayName, cancellationToken);

_logger.LogDebug("JoinNotebook: User {UserId} joined notebook {NotebookId}", userId, notebookId);
}

public async Task LeaveNotebook(Guid notebookId)
{
var cancellationToken = Context.ConnectionAborted;
await Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupPrefix + notebookId.ToString(), cancellationToken);
var leftUserId = _presence.LeaveNotebook(notebookId, Context.ConnectionId!);
if (leftUserId.HasValue)
await Clients.OthersInGroup(GroupPrefix + notebookId.ToString()).SendAsync("UserLeft", leftUserId.Value, cancellationToken);
}

/// <summary>Broadcast text cursor position within the notebook editor for collaborative editing.</summary>
public async Task TextCursorPosition(Guid notebookId, int position)
{
var userId = GetUserId();
if (userId is null) return;
var groupName = GroupPrefix + notebookId.ToString();
await Clients.OthersInGroup(groupName).SendAsync("TextCursorPosition", userId.Value, position);
}

private Guid? GetUserId()
{
var sub = Context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? Context.User?.FindFirst("sub")?.Value;
return Guid.TryParse(sub, out var id) ? id : null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,21 +77,6 @@ public async Task InvokeAsync(HttpContext context)
{
_logger.LogError(exception, "Unhandled exception occurred.");

// Development-only: append to local debug log (skip on Render/Linux to avoid wrong path)
if (_env.IsDevelopment())
{
try
{
var logPath = Path.Combine(Directory.GetCurrentDirectory(), ".cursor", "debug.log");
var dir = Path.GetDirectoryName(logPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
var inner = exception.InnerException;
var line = JsonSerializer.Serialize(new { timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), location = "ExceptionHandlingMiddleware.cs", message = "500 returned", data = new { exType = exception.GetType().FullName, exMessage = exception.Message, innerType = inner?.GetType().FullName, innerMessage = inner?.Message }, hypothesisId = "H5" }) + Environment.NewLine;
await File.AppendAllTextAsync(logPath, line);
}
catch { }
}

context.Response.ContentType = "application/json";

// Missing table (e.g. Notebooks/NotebookPages) = schema out of date; return 503 so deployers know to run migrations
Expand Down
42 changes: 42 additions & 0 deletions Source/backend/src/TableWorks.API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ static void LoadEnvFile()
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSignalR();

// ---------------------------------------------------------------------------
// API Versioning
Expand Down Expand Up @@ -173,6 +174,23 @@ static void LoadEnvFile()
ValidAudience = jwtSection["Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret))
};

// SignalR: read JWT from query string (for WebSocket and HTTP negotiation)
// The SignalR client sends token via accessTokenFactory which adds it to query string
options.Events = new Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
// Read token from query string for SignalR hub requests (both HTTP negotiation and WebSocket)
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -288,6 +306,9 @@ static void LoadEnvFile()
builder.Services.AddScoped<IBoardConnectionService, BoardConnectionService>();
builder.Services.AddScoped<IBoardImageService, BoardImageService>();
builder.Services.AddScoped<IBoardService, BoardService>();
builder.Services.AddScoped<IBoardAccessService, BoardAccessService>();
builder.Services.AddScoped<ASideNote.Application.Interfaces.IBoardHubBroadcaster, ASideNote.API.Services.BoardHubBroadcaster>();
builder.Services.AddSingleton<IBoardPresenceService, ASideNote.API.Services.BoardPresenceService>();
builder.Services.AddScoped<IProjectService, ProjectService>();
builder.Services.AddScoped<ITagService, TagService>();
builder.Services.AddScoped<IFolderService, FolderService>();
Expand All @@ -296,7 +317,10 @@ static void LoadEnvFile()
builder.Services.AddScoped<IDrawingService, DrawingService>();
builder.Services.AddScoped<ICalendarEventService, CalendarEventService>();
builder.Services.AddScoped<INotebookService, NotebookService>();
builder.Services.AddScoped<INotebookAccessService, ASideNote.Application.Services.NotebookAccessService>();
builder.Services.AddScoped<INotebookExportService, NotebookExportService>();
builder.Services.AddScoped<ASideNote.Application.Interfaces.INotebookHubBroadcaster, ASideNote.API.Services.NotebookHubBroadcaster>();
builder.Services.AddSingleton<ASideNote.Application.Interfaces.INotebookPresenceService, ASideNote.API.Services.NotebookPresenceService>();
builder.Services.AddScoped<IUserStorageService, ASideNote.Infrastructure.Services.UserStorageService>();
builder.Services.AddScoped<IImageResolver, ASideNote.Infrastructure.Services.ImageResolverService>();

Expand Down Expand Up @@ -409,6 +433,22 @@ static void LoadEnvFile()

app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseCors("AllowedOrigins");

// SignalR: Extract token from query string and add to Authorization header for HTTP requests
app.Use(async (context, next) =>
{
if (context.Request.Path.StartsWithSegments("/hubs") &&
string.IsNullOrEmpty(context.Request.Headers.Authorization))
{
var token = context.Request.Query["access_token"].FirstOrDefault();
if (!string.IsNullOrEmpty(token))
{
context.Request.Headers.Authorization = $"Bearer {token}";
}
}
await next();
});

app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
Expand Down Expand Up @@ -442,6 +482,8 @@ static void LoadEnvFile()
}).AllowAnonymous();

app.MapControllers();
app.MapHub<ASideNote.API.Hubs.BoardHub>("/hubs/board");
app.MapHub<ASideNote.API.Hubs.NotebookHub>("/hubs/notebook");

app.Run();

Expand Down
Loading
Loading