feat: Add invite codes and message replies functionality

- Introduced a new migration to add InviteCodes table and ReplyToMessageId column in Messages.
- Updated ChatHub to support replying to messages.
- Enhanced ChatService to handle message replies and validate reply targets.
- Modified UserService to implement invite-only registration mode with invite code consumption.
- Added configuration options for registration modes in appsettings.
- Created unit tests for new features including invite code registration and message reply formatting.
This commit is contained in:
HueByte
2026-07-17 19:47:57 +02:00
parent bb987dda82
commit 3281064720
44 changed files with 2484 additions and 74 deletions
@@ -28,7 +28,7 @@ public class AuthController : ControllerBase
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
{
var result = await _userService.RegisterUserAsync(request.Username, request.Password, request.DisplayName);
var result = await _userService.RegisterUserAsync(request.Username, request.Password, request.DisplayName, request.InviteCode);
if (!result.IsSuccess)
return MapUserError(result);
@@ -0,0 +1,129 @@
using System.Security.Claims;
using System.Security.Cryptography;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Data;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers;
/// <summary>
/// Invite-code management for invite-gated registration (Admin+ only).
/// Codes live in this server's own database — there is no central service.
/// </summary>
[ApiController]
[Route("api/invites")]
[Authorize]
[EnableRateLimiting("general")]
public class InvitesController : ControllerBase
{
private const int MaxActiveInvites = 200;
private readonly EchoHubDbContext _db;
private readonly ILogger<InvitesController> _logger;
public InvitesController(EchoHubDbContext db, ILogger<InvitesController> logger)
{
_db = db;
_logger = logger;
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateInviteRequest request)
{
var (caller, error) = await GetCallerAsync(ServerRole.Admin);
if (error is not null) return error;
var maxUses = request.MaxUses ?? 1;
if (maxUses is < 1 or > 1000)
return BadRequest(new ErrorResponse("MaxUses must be between 1 and 1000."));
if (request.ExpiresInHours is < 1 or > 24 * 365)
return BadRequest(new ErrorResponse("ExpiresInHours must be between 1 and 8760."));
if (await _db.InviteCodes.CountAsync(i => i.UseCount < i.MaxUses) >= MaxActiveInvites)
return BadRequest(new ErrorResponse($"Too many active invites (max {MaxActiveInvites}). Revoke unused ones first."));
var invite = new InviteCode
{
Id = Guid.NewGuid(),
Code = GenerateCode(),
CreatedByUserId = caller!.Id,
CreatedByUsername = caller.Username,
CreatedAt = DateTimeOffset.UtcNow,
ExpiresAt = request.ExpiresInHours is { } hours ? DateTimeOffset.UtcNow.AddHours(hours) : null,
MaxUses = maxUses,
};
_db.InviteCodes.Add(invite);
await _db.SaveChangesAsync();
_logger.LogInformation("Invite code created by {User} (uses: {MaxUses}, expires: {Expires})",
caller.Username, invite.MaxUses, invite.ExpiresAt?.ToString("u") ?? "never");
return Ok(ToDto(invite));
}
[HttpGet]
public async Task<IActionResult> List()
{
var (_, error) = await GetCallerAsync(ServerRole.Admin);
if (error is not null) return error;
var invites = await _db.InviteCodes
.OrderByDescending(i => i.CreatedAt)
.ToListAsync();
return Ok(invites.Select(ToDto).ToList());
}
[HttpDelete("{code}")]
public async Task<IActionResult> Revoke(string code)
{
var (caller, error) = await GetCallerAsync(ServerRole.Admin);
if (error is not null) return error;
var normalized = code.Trim().ToUpperInvariant();
var invite = await _db.InviteCodes.FirstOrDefaultAsync(i => i.Code == normalized);
if (invite is null)
return NotFound(new ErrorResponse("Invite code not found."));
_db.InviteCodes.Remove(invite);
await _db.SaveChangesAsync();
_logger.LogInformation("Invite code revoked by {User}", caller!.Username);
return Ok();
}
/// <summary>Unguessable, unambiguous code like "K7QM-3XPF" (no 0/O/1/I).</summary>
private static string GenerateCode()
{
const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
Span<char> chars = stackalloc char[8];
for (var i = 0; i < chars.Length; i++)
chars[i] = alphabet[RandomNumberGenerator.GetInt32(alphabet.Length)];
return $"{new string(chars[..4])}-{new string(chars[4..])}";
}
private static InviteDto ToDto(InviteCode i) =>
new(i.Code, i.CreatedByUsername, i.CreatedAt, i.ExpiresAt, i.MaxUses, i.UseCount);
private async Task<(User? Caller, IActionResult? Error)> GetCallerAsync(ServerRole minimumRole)
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null)
return (null, Unauthorized(new ErrorResponse("Authentication required.")));
var caller = await _db.Users.FindAsync(Guid.Parse(userIdClaim));
if (caller is null)
return (null, Unauthorized(new ErrorResponse("User not found.")));
if (caller.Role < minimumRole)
return (null, StatusCode(403, new ErrorResponse($"Requires {minimumRole} role or higher.")));
return (caller, null);
}
}
@@ -31,11 +31,19 @@ public class ServerController : ControllerBase
var userCount = await _db.Users.CountAsync();
var channelCount = await _db.Channels.CountAsync();
var registrationMode = (_config["Server:Registration"] ?? "open").Trim().ToLowerInvariant() switch
{
"invite" => "invite",
"closed" => "closed",
_ => "open",
};
var status = new ServerStatusDto(
_config["Server:Name"] ?? "EchoHub Server",
_config["Server:Description"],
userCount,
channelCount);
channelCount,
registrationMode);
return Ok(status);
}
@@ -1,13 +1,16 @@
using System.Security.Claims;
using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.Models;
using EchoHub.Core.Services;
using EchoHub.Core.DTOs;
using EchoHub.Server.Config;
using EchoHub.Server.Data;
using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers;
@@ -17,15 +20,45 @@ namespace EchoHub.Server.Controllers;
[EnableRateLimiting("general")]
public class UsersController : ControllerBase
{
/// <summary>
/// Username tombstoned messages are re-attributed to after account deletion.
/// Reserved — <see cref="UserService"/> refuses to register it.
/// </summary>
public const string DeletedUserName = "deleted-user";
private readonly IUserService _userService;
private readonly ImageToAsciiService _asciiService;
private readonly UploadLimits _uploadLimits;
private readonly EchoHubDbContext _db;
private readonly IMessageEncryptionService _encryption;
private readonly FileStorageService _fileStorage;
private readonly PresenceTracker _presenceTracker;
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
private readonly IConfiguration _config;
private readonly ILogger<UsersController> _logger;
public UsersController(IUserService userService, ImageToAsciiService asciiService, UploadLimits uploadLimits)
public UsersController(
IUserService userService,
ImageToAsciiService asciiService,
UploadLimits uploadLimits,
EchoHubDbContext db,
IMessageEncryptionService encryption,
FileStorageService fileStorage,
PresenceTracker presenceTracker,
IEnumerable<IChatBroadcaster> broadcasters,
IConfiguration config,
ILogger<UsersController> logger)
{
_userService = userService;
_asciiService = asciiService;
_uploadLimits = uploadLimits;
_db = db;
_encryption = encryption;
_fileStorage = fileStorage;
_presenceTracker = presenceTracker;
_broadcasters = broadcasters;
_config = config;
_logger = logger;
}
[HttpGet("{username}/profile")]
@@ -85,6 +118,139 @@ public class UsersController : ControllerBase
return Ok(new AvatarUploadResponse(asciiArt));
}
/// <summary>
/// Everything the server stores about the caller, as stored: profile, their messages
/// (end-to-end encrypted room content stays ciphertext — the server never had plaintext),
/// and metadata of their uploaded attachments. "You own the data" made demonstrable.
/// </summary>
[HttpGet("me/export")]
public async Task<IActionResult> ExportMyData()
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var userId = Guid.Parse(userIdClaim);
var profile = await _userService.GetUserByIdAsync(userId);
if (profile is null)
return Unauthorized(new ErrorResponse("User not found."));
var messages = await _db.Messages
.Where(m => m.SenderUserId == userId)
.OrderBy(m => m.SentAt)
.Join(_db.Channels, m => m.ChannelId, c => c.Id, (m, c) => new { m, ChannelName = c.Name })
.ToListAsync();
var messageIds = messages.Select(x => x.m.Id).ToList();
var attachments = await _db.Attachments
.Where(a => messageIds.Contains(a.MessageId))
.ToListAsync();
var messageById = messages.ToDictionary(x => x.m.Id, x => x);
var export = new UserDataExportDto(
DateTimeOffset.UtcNow,
_config["Server:Name"] ?? "EchoHub Server",
profile,
messages.Select(x => new ExportedMessageDto(
x.m.Id,
x.ChannelName,
x.m.SentAt,
// Strip only the server's at-rest layer; room ciphertext passes through as-is
_encryption.Decrypt(x.m.Content),
x.m.ReplyToMessageId)).ToList(),
attachments.Select(a =>
{
var owner = messageById[a.MessageId];
return new ExportedAttachmentDto(
a.FileName, a.Url, a.FileSize, a.Kind.ToString(),
owner.ChannelName, owner.m.SentAt);
}).ToList());
_logger.LogInformation("{User} exported their data ({Messages} messages, {Attachments} attachments)",
profile.Username, export.Messages.Count, export.Attachments.Count);
return Ok(export);
}
/// <summary>
/// Self-service account deletion (password re-confirmed). Removes the account, its refresh
/// tokens and memberships (FK cascade), and every attachment blob the user uploaded.
/// Their messages are kept but tombstoned to <see cref="DeletedUserName"/> — deleting them
/// outright would silently gut other people's conversations.
/// </summary>
[HttpDelete("me")]
public async Task<IActionResult> DeleteMyAccount([FromBody] DeleteAccountRequest request)
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var user = await _db.Users.FindAsync(Guid.Parse(userIdClaim));
if (user is null)
return Unauthorized(new ErrorResponse("User not found."));
if (string.IsNullOrEmpty(request.Password) || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
return Unauthorized(new ErrorResponse("Password is incorrect."));
if (user.Role == ServerRole.Owner
&& !await _db.Users.AnyAsync(u => u.Role == ServerRole.Owner && u.Id != user.Id))
{
return BadRequest(new ErrorResponse(
"You are the only Owner of this server. Promote another Owner (or shut the server down) before deleting this account."));
}
var username = user.Username;
// Their uploaded blobs: attachments hanging off their messages
var attachmentInfo = await _db.Messages
.Where(m => m.SenderUserId == user.Id)
.SelectMany(m => m.Attachments)
.Select(a => new { a.Id, a.Url })
.ToListAsync();
foreach (var fileId in attachmentInfo
.Select(a => a.Url.Split('/').LastOrDefault())
.Where(id => !string.IsNullOrEmpty(id)))
{
try { _fileStorage.DeleteFile(fileId!); }
catch (Exception ex) { _logger.LogWarning(ex, "Failed to delete blob {FileId} during account deletion", fileId); }
}
var attachmentIds = attachmentInfo.Select(a => a.Id).ToList();
await _db.Attachments.Where(a => attachmentIds.Contains(a.Id)).ExecuteDeleteAsync();
// Tombstone their messages, then remove the account (cascades tokens + memberships)
await _db.Messages
.Where(m => m.SenderUserId == user.Id)
.ExecuteUpdateAsync(s => s
.SetProperty(m => m.SenderUserId, Guid.Empty)
.SetProperty(m => m.SenderUsername, DeletedUserName));
_db.Users.Remove(user);
await _db.SaveChangesAsync();
// Kick their live sessions and clear them from user lists everywhere
var (connectionIds, channels) = _presenceTracker.ForceRemoveUser(username);
foreach (var channel in channels)
await BroadcastToAllAsync(b => b.SendUserLeftAsync(channel, username));
if (connectionIds.Count > 0)
await BroadcastToAllAsync(b => b.ForceDisconnectUserAsync(connectionIds, "Account deleted."));
_logger.LogInformation("Account '{User}' self-deleted ({Attachments} attachment blobs removed)",
username, attachmentIds.Count);
return Ok();
}
private async Task BroadcastToAllAsync(Func<IChatBroadcaster, Task> action)
{
foreach (var broadcaster in _broadcasters)
{
try { await action(broadcaster); }
catch { /* logged by broadcaster */ }
}
}
private IActionResult MapUserError(UserOperationResult result) => result.Error switch
{
UserError.ValidationFailed => BadRequest(new ErrorResponse(result.ErrorMessage!)),