mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
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:
@@ -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!)),
|
||||
|
||||
@@ -13,6 +13,7 @@ public class EchoHubDbContext : DbContext
|
||||
public DbSet<Attachment> Attachments => Set<Attachment>();
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
public DbSet<ChannelMembership> ChannelMemberships => Set<ChannelMembership>();
|
||||
public DbSet<InviteCode> InviteCodes => Set<InviteCode>();
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
@@ -98,6 +99,14 @@ public class EchoHubDbContext : DbContext
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<InviteCode>(entity =>
|
||||
{
|
||||
entity.HasKey(i => i.Id);
|
||||
entity.HasIndex(i => i.Code).IsUnique();
|
||||
entity.Property(i => i.Code).IsRequired().HasMaxLength(32);
|
||||
entity.Property(i => i.CreatedByUsername).IsRequired().HasMaxLength(50);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RefreshToken>(entity =>
|
||||
{
|
||||
entity.HasKey(r => r.Id);
|
||||
|
||||
+373
@@ -0,0 +1,373 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using EchoHub.Server.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EchoHub.Server.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(EchoHubDbContext))]
|
||||
[Migration("20260717165218_AddInvitesAndReplies")]
|
||||
partial class AddInvitesAndReplies
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.Attachment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AsciiPreview")
|
||||
.HasMaxLength(64000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("FileSize")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("MessageId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MessageId");
|
||||
|
||||
b.ToTable("Attachments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("CreatedByUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EncryptionSalt")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WrappedRoomKey")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Channels");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ChannelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("JoinedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("UserId", "ChannelId");
|
||||
|
||||
b.HasIndex("ChannelId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("ChannelMemberships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.InviteCode", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("CreatedByUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CreatedByUsername")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("ExpiresAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxUses")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UseCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("InviteCodes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AttachmentFileName")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("AttachmentFileSize")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AttachmentUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ChannelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EmbedJson")
|
||||
.HasMaxLength(32000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("ReplyToMessageId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("SenderUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SenderUsername")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("SentAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ChannelId");
|
||||
|
||||
b.HasIndex("SentAt");
|
||||
|
||||
b.ToTable("Messages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ExpiresAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("RevokedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TokenHash");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("RefreshTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AvatarAscii")
|
||||
.HasMaxLength(10000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsBanned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsMuted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("LastSeenAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("MutedUntil")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("NicknameColor")
|
||||
.HasMaxLength(7)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("StatusMessage")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.Attachment", b =>
|
||||
{
|
||||
b.HasOne("EchoHub.Core.Models.Message", "Message")
|
||||
.WithMany("Attachments")
|
||||
.HasForeignKey("MessageId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Message");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||
{
|
||||
b.HasOne("EchoHub.Core.Models.Channel", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ChannelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("EchoHub.Core.Models.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||
{
|
||||
b.HasOne("EchoHub.Core.Models.Channel", "Channel")
|
||||
.WithMany("Messages")
|
||||
.HasForeignKey("ChannelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Channel");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("EchoHub.Core.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||
{
|
||||
b.Navigation("Messages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||
{
|
||||
b.Navigation("Attachments");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EchoHub.Server.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddInvitesAndReplies : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "ReplyToMessageId",
|
||||
table: "Messages",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "InviteCodes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Code = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
CreatedByUserId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
CreatedByUsername = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
|
||||
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
ExpiresAt = table.Column<long>(type: "INTEGER", nullable: true),
|
||||
MaxUses = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
UseCount = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_InviteCodes", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_InviteCodes_Code",
|
||||
table: "InviteCodes",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "InviteCodes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ReplyToMessageId",
|
||||
table: "Messages");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,45 @@ namespace EchoHub.Server.Data.Migrations
|
||||
b.ToTable("ChannelMemberships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.InviteCode", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("CreatedByUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CreatedByUsername")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("ExpiresAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxUses")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UseCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("InviteCodes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -146,6 +185,9 @@ namespace EchoHub.Server.Data.Migrations
|
||||
.HasMaxLength(32000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("ReplyToMessageId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("SenderUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
@@ -99,11 +99,11 @@ public class ChatHub : Hub<IEchoHubClient>
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendMessage(string channelName, string content)
|
||||
public async Task SendMessage(string channelName, string content, Guid? replyToMessageId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var error = await _chatService.SendMessageAsync(CurrentUserId, CurrentUsername, channelName, content, Context.ConnectionId);
|
||||
var error = await _chatService.SendMessageAsync(CurrentUserId, CurrentUsername, channelName, content, Context.ConnectionId, replyToMessageId);
|
||||
if (error is not null)
|
||||
await Clients.Caller.Error(error);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using EchoHub.Core.Constants;
|
||||
using EchoHub.Core.Contracts;
|
||||
using EchoHub.Core.DTOs;
|
||||
using EchoHub.Core.Models;
|
||||
using EchoHub.Core.Security;
|
||||
using EchoHub.Server.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -151,7 +152,7 @@ public class ChatService : IChatService
|
||||
_logger.LogInformation("{User} left channel '{Channel}'", username, channelName);
|
||||
}
|
||||
|
||||
public async Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null)
|
||||
public async Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null, Guid? replyToMessageId = null)
|
||||
{
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
|
||||
@@ -198,6 +199,15 @@ public class ChatService : IChatService
|
||||
}
|
||||
}
|
||||
|
||||
// Replies must target an existing message in the same channel
|
||||
Message? replyTarget = null;
|
||||
if (replyToMessageId is { } replyId)
|
||||
{
|
||||
replyTarget = await db.Messages.FirstOrDefaultAsync(m => m.Id == replyId && m.ChannelId == channel.Id);
|
||||
if (replyTarget is null)
|
||||
return "The message you're replying to no longer exists.";
|
||||
}
|
||||
|
||||
// Attempt to fetch link embeds for URLs in the plaintext message
|
||||
List<EmbedDto>? embeds = null;
|
||||
try
|
||||
@@ -223,6 +233,7 @@ public class ChatService : IChatService
|
||||
SenderUserId = userId,
|
||||
SenderUsername = username,
|
||||
EmbedJson = dbEmbedJson,
|
||||
ReplyToMessageId = replyTarget?.Id,
|
||||
};
|
||||
|
||||
db.Messages.Add(message);
|
||||
@@ -238,7 +249,8 @@ public class ChatService : IChatService
|
||||
channelName,
|
||||
message.SentAt,
|
||||
Embeds: embeds,
|
||||
SenderDisplayName: sender?.DisplayName);
|
||||
SenderDisplayName: sender?.DisplayName,
|
||||
ReplyTo: replyTarget is null ? null : BuildReplyRef(replyTarget));
|
||||
|
||||
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto, originConnectionId));
|
||||
|
||||
@@ -258,8 +270,29 @@ public class ChatService : IChatService
|
||||
return await GetChannelHistoryInternalAsync(db, channelName, count, offset);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the wire reference for a reply target. Plaintext snippets are truncated
|
||||
/// server-side; end-to-end room ciphertext must pass through whole (a truncated blob
|
||||
/// can't be decrypted), so the client truncates those after decrypting.
|
||||
/// </summary>
|
||||
private ReplyRefDto BuildReplyRef(Message target)
|
||||
{
|
||||
const int maxSnippetLength = 120;
|
||||
|
||||
// Strip the at-rest layer; E2E room content stays $RC1$ ciphertext
|
||||
var plain = _encryption.Decrypt(target.Content);
|
||||
if (!RoomCrypto.IsRoomCiphertext(plain) && plain.Length > maxSnippetLength)
|
||||
plain = plain[..maxSnippetLength] + "…";
|
||||
|
||||
return new ReplyRefDto(target.Id, target.SenderUsername, _encryption.Encrypt(plain));
|
||||
}
|
||||
|
||||
public async Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage)
|
||||
{
|
||||
// SignalR happily binds any int to the enum parameter — reject undefined values
|
||||
if (!Enum.IsDefined(status))
|
||||
return "Invalid status. Use online, away, dnd, or invisible.";
|
||||
|
||||
if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength)
|
||||
return $"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters.";
|
||||
|
||||
@@ -382,19 +415,33 @@ public class ChatService : IChatService
|
||||
if (channel is null)
|
||||
return [];
|
||||
|
||||
// Left join: tombstoned messages (deleted accounts, SenderUserId cleared) must
|
||||
// still appear in history — an inner join would silently drop them.
|
||||
var raw = await db.Messages
|
||||
.Where(m => m.ChannelId == channel.Id)
|
||||
.OrderByDescending(m => m.SentAt)
|
||||
.Skip(offset)
|
||||
.Take(count)
|
||||
.Join(db.Users,
|
||||
.GroupJoin(db.Users,
|
||||
m => m.SenderUserId,
|
||||
u => u.Id,
|
||||
(m, u) => new { m, u.NicknameColor, u.DisplayName })
|
||||
(m, users) => new { m, users })
|
||||
.SelectMany(x => x.users.DefaultIfEmpty(),
|
||||
(x, u) => new { x.m, NicknameColor = u != null ? u.NicknameColor : null, DisplayName = u != null ? u.DisplayName : null })
|
||||
.ToListAsync();
|
||||
|
||||
raw.Reverse();
|
||||
|
||||
// Reply targets referenced by this batch, for quote snippets
|
||||
var replyIds = raw
|
||||
.Where(x => x.m.ReplyToMessageId.HasValue)
|
||||
.Select(x => x.m.ReplyToMessageId!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var replyTargets = replyIds.Count > 0
|
||||
? (await db.Messages.Where(m => replyIds.Contains(m.Id)).ToListAsync()).ToDictionary(m => m.Id)
|
||||
: new Dictionary<Guid, Message>();
|
||||
|
||||
var messageIds = raw.Select(x => x.m.Id).ToList();
|
||||
var attachmentsByMessage = (await db.Attachments
|
||||
.Where(a => messageIds.Contains(a.MessageId))
|
||||
@@ -459,7 +506,10 @@ public class ChatService : IChatService
|
||||
x.m.SentAt,
|
||||
attachments,
|
||||
embeds,
|
||||
x.DisplayName));
|
||||
x.DisplayName,
|
||||
x.m.ReplyToMessageId is { } rid && replyTargets.TryGetValue(rid, out var replyTarget)
|
||||
? BuildReplyRef(replyTarget)
|
||||
: null));
|
||||
}
|
||||
|
||||
// Lazily delete the pruned messages (+ their attachment rows) as they're encountered.
|
||||
|
||||
@@ -11,13 +11,24 @@ namespace EchoHub.Server.Services;
|
||||
public class UserService : IUserService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public UserService(IServiceScopeFactory scopeFactory)
|
||||
public UserService(IServiceScopeFactory scopeFactory, IConfiguration configuration)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public async Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null)
|
||||
/// <summary>Registration mode from config: "open" (default), "invite", or "closed".</summary>
|
||||
public string RegistrationMode =>
|
||||
(_configuration["Server:Registration"] ?? "open").Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"invite" => "invite",
|
||||
"closed" => "closed",
|
||||
_ => "open",
|
||||
};
|
||||
|
||||
public async Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null, string? inviteCode = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
return UserOperationResult.Fail(UserError.ValidationFailed, "Username and password are required.");
|
||||
@@ -35,6 +46,10 @@ public class UserService : IUserService
|
||||
|
||||
var normalizedUsername = username.ToLowerInvariant().Trim();
|
||||
|
||||
// Reserved: deleted accounts' messages are re-attributed to this name
|
||||
if (normalizedUsername == Controllers.UsersController.DeletedUserName)
|
||||
return UserOperationResult.Fail(UserError.ValidationFailed, "This username is reserved.");
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||
|
||||
@@ -43,6 +58,24 @@ public class UserService : IUserService
|
||||
|
||||
var isFirstUser = !await db.Users.AnyAsync();
|
||||
|
||||
// Registration gate. The very first account (server owner bootstrap) is always allowed —
|
||||
// otherwise a fresh invite/closed server could never mint its first admin.
|
||||
if (!isFirstUser)
|
||||
{
|
||||
switch (RegistrationMode)
|
||||
{
|
||||
case "closed":
|
||||
return UserOperationResult.Fail(UserError.ValidationFailed,
|
||||
"Registration is closed on this server.");
|
||||
|
||||
case "invite":
|
||||
var inviteError = await TryConsumeInviteAsync(db, inviteCode);
|
||||
if (inviteError is not null)
|
||||
return UserOperationResult.Fail(UserError.ValidationFailed, inviteError);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
@@ -58,6 +91,32 @@ public class UserService : IUserService
|
||||
return UserOperationResult.Success(ToProfileDto(user));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates and consumes one use of an invite code. Returns an error message, or null on
|
||||
/// success. The increment is a guarded UPDATE so two racing registrations can't both take
|
||||
/// a code's last use.
|
||||
/// </summary>
|
||||
private static async Task<string?> TryConsumeInviteAsync(EchoHubDbContext db, string? inviteCode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(inviteCode))
|
||||
return "Registration is invite-only on this server. An invite code is required.";
|
||||
|
||||
var code = inviteCode.Trim().ToUpperInvariant();
|
||||
var invite = await db.InviteCodes.FirstOrDefaultAsync(i => i.Code == code);
|
||||
|
||||
if (invite is null || invite.UseCount >= invite.MaxUses)
|
||||
return "Invalid invite code.";
|
||||
|
||||
if (invite.ExpiresAt.HasValue && invite.ExpiresAt.Value <= DateTimeOffset.UtcNow)
|
||||
return "This invite code has expired.";
|
||||
|
||||
var consumed = await db.InviteCodes
|
||||
.Where(i => i.Id == invite.Id && i.UseCount < i.MaxUses)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(i => i.UseCount, i => i.UseCount + 1));
|
||||
|
||||
return consumed == 1 ? null : "Invalid invite code.";
|
||||
}
|
||||
|
||||
public async Task<UserOperationResult> AuthenticateUserAsync(string username, string password)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"PublicServer": false,
|
||||
"PublicHosts": [],
|
||||
"Tags": [],
|
||||
"Admins": []
|
||||
"Admins": [],
|
||||
"Registration": "open"
|
||||
},
|
||||
"Storage": {
|
||||
"CleanupIntervalHours": 1,
|
||||
|
||||
Reference in New Issue
Block a user