mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
476 lines
18 KiB
C#
476 lines
18 KiB
C#
using System.Text.Json;
|
|
using EchoHub.Core.Constants;
|
|
using EchoHub.Core.Contracts;
|
|
using EchoHub.Core.DTOs;
|
|
using EchoHub.Core.Models;
|
|
using EchoHub.Server.Data;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace EchoHub.Server.Services;
|
|
|
|
public class ChatService : IChatService
|
|
{
|
|
private readonly IServiceScopeFactory _scopeFactory;
|
|
private readonly PresenceTracker _presenceTracker;
|
|
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
|
private readonly LinkEmbedService _embedService;
|
|
private readonly IMessageEncryptionService _encryption;
|
|
private readonly IChannelService _channelService;
|
|
private readonly FileStorageService _fileStorage;
|
|
private readonly ILogger<ChatService> _logger;
|
|
|
|
public ChatService(
|
|
IServiceScopeFactory scopeFactory,
|
|
PresenceTracker presenceTracker,
|
|
IEnumerable<IChatBroadcaster> broadcasters,
|
|
LinkEmbedService embedService,
|
|
IMessageEncryptionService encryption,
|
|
IChannelService channelService,
|
|
FileStorageService fileStorage,
|
|
ILogger<ChatService> logger)
|
|
{
|
|
_scopeFactory = scopeFactory;
|
|
_presenceTracker = presenceTracker;
|
|
_broadcasters = broadcasters;
|
|
_embedService = embedService;
|
|
_encryption = encryption;
|
|
_channelService = channelService;
|
|
_fileStorage = fileStorage;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task UserConnectedAsync(string connectionId, Guid userId, string username)
|
|
{
|
|
_presenceTracker.UserConnected(connectionId, userId, username);
|
|
|
|
using var scope = _scopeFactory.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
|
|
|
var user = await db.Users.FindAsync(userId);
|
|
if (user is not null)
|
|
{
|
|
user.LastSeenAt = DateTimeOffset.UtcNow;
|
|
user.Status = UserStatus.Online;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
_logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", username, connectionId);
|
|
}
|
|
|
|
public async Task<string?> UserDisconnectedAsync(string connectionId)
|
|
{
|
|
var preDisconnectUsername = _presenceTracker.GetUsernameForConnection(connectionId);
|
|
var channelsBeforeDisconnect = preDisconnectUsername is not null
|
|
? _presenceTracker.GetChannelsForUser(preDisconnectUsername)
|
|
: [];
|
|
|
|
var username = _presenceTracker.UserDisconnected(connectionId);
|
|
|
|
if (username is not null && !_presenceTracker.IsOnline(username))
|
|
{
|
|
using var scope = _scopeFactory.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
|
|
|
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
|
|
if (user is not null)
|
|
{
|
|
user.LastSeenAt = DateTimeOffset.UtcNow;
|
|
user.Status = UserStatus.Invisible;
|
|
await db.SaveChangesAsync();
|
|
|
|
var presence = new UserPresenceDto(
|
|
username,
|
|
user.DisplayName,
|
|
user.NicknameColor,
|
|
UserStatus.Invisible,
|
|
user.StatusMessage,
|
|
user.Role);
|
|
|
|
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channelsBeforeDisconnect, presence));
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", connectionId);
|
|
return username;
|
|
}
|
|
|
|
public async Task<(List<MessageDto> History, string? Error, bool PasswordRequired)> JoinChannelAsync(
|
|
string connectionId, Guid userId, string username, string channelName, string? password = null)
|
|
{
|
|
channelName = channelName.ToLowerInvariant().Trim();
|
|
|
|
// Delegate channel validation + membership (incl. password gate) to ChannelService
|
|
var (success, error, passwordRequired) = await _channelService.EnsureChannelMembershipAsync(userId, channelName, password);
|
|
if (!success)
|
|
return ([], error, passwordRequired);
|
|
|
|
var isNewJoin = _presenceTracker.JoinChannel(username, channelName);
|
|
|
|
if (isNewJoin)
|
|
{
|
|
// Fetch presence data so clients can update their lists incrementally
|
|
UserPresenceDto? presence = null;
|
|
try
|
|
{
|
|
using var presenceScope = _scopeFactory.CreateScope();
|
|
var presenceDb = presenceScope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
|
var user = await presenceDb.Users.FindAsync(userId);
|
|
if (user is not null)
|
|
{
|
|
presence = new UserPresenceDto(
|
|
user.Username, user.DisplayName, user.NicknameColor,
|
|
user.Status, user.StatusMessage, user.Role);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "Failed to fetch presence for {User} on join", username);
|
|
}
|
|
|
|
// Don't broadcast join for invisible users — they still get history but stay hidden
|
|
if (presence is null || presence.Status != UserStatus.Invisible)
|
|
{
|
|
await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, presence, connectionId));
|
|
}
|
|
|
|
_logger.LogInformation("{User} joined channel '{Channel}'", username, channelName);
|
|
}
|
|
|
|
var history = await GetChannelHistoryAsync(channelName, HubConstants.DefaultHistoryCount);
|
|
return (history, null, false);
|
|
}
|
|
|
|
public async Task LeaveChannelAsync(string connectionId, string username, string channelName)
|
|
{
|
|
channelName = channelName.ToLowerInvariant().Trim();
|
|
_presenceTracker.LeaveChannel(username, channelName);
|
|
await BroadcastToAllAsync(b => b.SendUserLeftAsync(channelName, username));
|
|
_logger.LogInformation("{User} left channel '{Channel}'", username, channelName);
|
|
}
|
|
|
|
public async Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content)
|
|
{
|
|
channelName = channelName.ToLowerInvariant().Trim();
|
|
|
|
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
|
return "Invalid channel name.";
|
|
|
|
// Decrypt content (client sends encrypted; IRC sends plaintext — Decrypt handles both)
|
|
var plaintext = _encryption.Decrypt(content);
|
|
|
|
// Strip encryption prefix if a user typed it literally (prevents spoofing)
|
|
while (plaintext.StartsWith("$ENC$"))
|
|
plaintext = plaintext["$ENC$".Length..];
|
|
|
|
if (string.IsNullOrWhiteSpace(plaintext))
|
|
return "Message content cannot be empty.";
|
|
|
|
if (plaintext.Length > HubConstants.MaxMessageLength)
|
|
return $"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters.";
|
|
|
|
// Sanitize on plaintext: collapse excessive newlines
|
|
plaintext = SanitizeNewlines(plaintext);
|
|
|
|
using var scope = _scopeFactory.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
|
|
|
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
|
if (channel is null)
|
|
return $"Channel '{channelName}' does not exist.";
|
|
|
|
var sender = await db.Users.FindAsync(userId);
|
|
|
|
// Check mute status
|
|
if (sender is not null && sender.IsMuted)
|
|
{
|
|
if (sender.MutedUntil.HasValue && sender.MutedUntil.Value <= DateTimeOffset.UtcNow)
|
|
{
|
|
sender.IsMuted = false;
|
|
sender.MutedUntil = null;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
else
|
|
{
|
|
return "You are muted and cannot send messages.";
|
|
}
|
|
}
|
|
|
|
// Attempt to fetch link embeds for URLs in the plaintext message
|
|
List<EmbedDto>? embeds = null;
|
|
try
|
|
{
|
|
embeds = await _embedService.TryGetEmbedsAsync(plaintext);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to fetch link embeds for message in '{Channel}'", channelName);
|
|
}
|
|
|
|
// Store in DB — encrypted at rest if enabled, plaintext otherwise
|
|
var embedJson = embeds is not null ? JsonSerializer.Serialize(embeds) : null;
|
|
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(plaintext) : plaintext;
|
|
var dbEmbedJson = _encryption.EncryptDatabaseEnabled ? _encryption.EncryptNullable(embedJson) : embedJson;
|
|
|
|
var message = new Message
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Content = dbContent,
|
|
SentAt = DateTimeOffset.UtcNow,
|
|
ChannelId = channel.Id,
|
|
SenderUserId = userId,
|
|
SenderUsername = username,
|
|
EmbedJson = dbEmbedJson,
|
|
};
|
|
|
|
db.Messages.Add(message);
|
|
await db.SaveChangesAsync();
|
|
|
|
// Broadcast encrypted for SignalR clients; IRC broadcaster gets plaintext
|
|
var encryptedContent = _encryption.Encrypt(plaintext);
|
|
var messageDto = new MessageDto(
|
|
message.Id,
|
|
encryptedContent,
|
|
message.SenderUsername,
|
|
sender?.NicknameColor,
|
|
channelName,
|
|
message.SentAt,
|
|
Embeds: embeds);
|
|
|
|
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
|
|
|
|
_logger.LogDebug("{User} sent message in '{Channel}'", username, channelName);
|
|
return null;
|
|
}
|
|
|
|
public async Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count, int offset = 0)
|
|
{
|
|
channelName = channelName.ToLowerInvariant().Trim();
|
|
count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
|
|
offset = Math.Max(offset, 0);
|
|
|
|
using var scope = _scopeFactory.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
|
|
|
return await GetChannelHistoryInternalAsync(db, channelName, count, offset);
|
|
}
|
|
|
|
public async Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage)
|
|
{
|
|
if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength)
|
|
return $"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters.";
|
|
|
|
using var scope = _scopeFactory.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
|
|
|
var user = await db.Users.FindAsync(userId);
|
|
if (user is null)
|
|
return "User not found.";
|
|
|
|
user.Status = status;
|
|
user.StatusMessage = statusMessage?.Trim();
|
|
user.LastSeenAt = DateTimeOffset.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
|
|
var presence = new UserPresenceDto(
|
|
user.Username,
|
|
user.DisplayName,
|
|
user.NicknameColor,
|
|
status,
|
|
statusMessage,
|
|
user.Role);
|
|
|
|
var channels = _presenceTracker.GetChannelsForUser(username);
|
|
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence));
|
|
|
|
return null;
|
|
}
|
|
|
|
public async Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channelName)
|
|
{
|
|
channelName = channelName.ToLowerInvariant().Trim();
|
|
var onlineUsernames = _presenceTracker.GetOnlineUsersInChannel(channelName);
|
|
|
|
using var scope = _scopeFactory.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
|
|
|
return await db.Users
|
|
.Where(u => onlineUsernames.Contains(u.Username) && u.Status != UserStatus.Invisible)
|
|
.Select(u => new UserPresenceDto(
|
|
u.Username,
|
|
u.DisplayName,
|
|
u.NicknameColor,
|
|
u.Status,
|
|
u.StatusMessage,
|
|
u.Role))
|
|
.ToListAsync();
|
|
}
|
|
|
|
public Task BroadcastMessageAsync(string channelName, MessageDto message)
|
|
=> BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, message));
|
|
|
|
public Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null)
|
|
=> BroadcastToAllAsync(b => b.SendChannelUpdatedAsync(channel, channelName));
|
|
|
|
private async Task BroadcastToAllAsync(Func<IChatBroadcaster, Task> action)
|
|
{
|
|
foreach (var broadcaster in _broadcasters)
|
|
{
|
|
try
|
|
{
|
|
await action(broadcaster);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Broadcaster {Type} failed", broadcaster.GetType().Name);
|
|
}
|
|
}
|
|
}
|
|
|
|
public Task<List<string>> GetChannelsForUserAsync(string username)
|
|
=> Task.FromResult(_presenceTracker.GetChannelsForUser(username));
|
|
|
|
/// <summary>
|
|
/// Collapse consecutive newlines and cap total line count to prevent newline spam.
|
|
/// </summary>
|
|
private static string SanitizeNewlines(string content)
|
|
{
|
|
// Normalize \r\n → \n
|
|
content = content.Replace("\r\n", "\n").Replace('\r', '\n');
|
|
|
|
// Collapse consecutive blank/whitespace-only lines into max 1 blank line
|
|
var lines = content.Split('\n');
|
|
var result = new List<string>(lines.Length);
|
|
int consecutiveBlanks = 0;
|
|
|
|
foreach (var line in lines)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(line))
|
|
{
|
|
consecutiveBlanks++;
|
|
if (consecutiveBlanks <= HubConstants.MaxConsecutiveNewlines)
|
|
result.Add(line);
|
|
}
|
|
else
|
|
{
|
|
consecutiveBlanks = 0;
|
|
result.Add(line);
|
|
}
|
|
}
|
|
|
|
// Cap total lines
|
|
if (result.Count > HubConstants.MaxMessageNewlines)
|
|
result = result.Take(HubConstants.MaxMessageNewlines).ToList();
|
|
|
|
return string.Join('\n', result);
|
|
}
|
|
|
|
private async Task<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count, int offset = 0)
|
|
{
|
|
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
|
if (channel is null)
|
|
return [];
|
|
|
|
var raw = await db.Messages
|
|
.Where(m => m.ChannelId == channel.Id)
|
|
.OrderByDescending(m => m.SentAt)
|
|
.Skip(offset)
|
|
.Take(count)
|
|
.Join(db.Users,
|
|
m => m.SenderUserId,
|
|
u => u.Id,
|
|
(m, u) => new { m, u.NicknameColor })
|
|
.ToListAsync();
|
|
|
|
raw.Reverse();
|
|
|
|
var messageIds = raw.Select(x => x.m.Id).ToList();
|
|
var attachmentsByMessage = (await db.Attachments
|
|
.Where(a => messageIds.Contains(a.MessageId))
|
|
.ToListAsync())
|
|
.GroupBy(a => a.MessageId)
|
|
.ToDictionary(g => g.Key, g => g.ToList());
|
|
|
|
// Attachment blobs can be pruned by retention while the message rows remain. Check what's
|
|
// actually on disk (one scan) so we never render a dead download, and so we can drop
|
|
// attachment-only messages whose files are all gone.
|
|
var storedFileIds = attachmentsByMessage.Count > 0 ? _fileStorage.GetStoredFileIds() : [];
|
|
|
|
var result = new List<MessageDto>(raw.Count);
|
|
var deadMessageIds = new List<Guid>();
|
|
|
|
foreach (var x in raw)
|
|
{
|
|
// Decrypt DB content (handles both encrypted and plaintext via prefix detection)
|
|
var plaintext = _encryption.Decrypt(x.m.Content);
|
|
|
|
List<AttachmentDto>? attachments = null;
|
|
var hadAttachments = attachmentsByMessage.TryGetValue(x.m.Id, out var atts) && atts.Count > 0;
|
|
if (hadAttachments)
|
|
{
|
|
// Keep only attachments whose underlying file still exists on disk.
|
|
var live = atts!.Where(a => storedFileIds.Contains(FileIdFromUrl(a.Url))).ToList();
|
|
|
|
// Attachment-only message whose files are all gone → prune it entirely.
|
|
if (live.Count == 0 && string.IsNullOrEmpty(plaintext))
|
|
{
|
|
deadMessageIds.Add(x.m.Id);
|
|
continue;
|
|
}
|
|
|
|
if (live.Count > 0)
|
|
{
|
|
attachments = live.Select(a => new AttachmentDto(
|
|
a.Kind,
|
|
a.Url,
|
|
a.FileName,
|
|
a.FileSize,
|
|
// Preview re-encrypted for transport; client decrypts (and room-decrypts for E2E)
|
|
_encryption.EncryptNullable(_encryption.DecryptNullable(a.AsciiPreview)))).ToList();
|
|
}
|
|
}
|
|
|
|
var embedJsonPlain = _encryption.DecryptNullable(x.m.EmbedJson);
|
|
List<EmbedDto>? embeds = null;
|
|
if (embedJsonPlain is not null)
|
|
{
|
|
try { embeds = JsonSerializer.Deserialize<List<EmbedDto>>(embedJsonPlain); }
|
|
catch { /* ignore malformed JSON */ }
|
|
}
|
|
|
|
// Encrypt for transport — client decrypts
|
|
result.Add(new MessageDto(
|
|
x.m.Id,
|
|
_encryption.Encrypt(plaintext),
|
|
x.m.SenderUsername,
|
|
x.NicknameColor,
|
|
channelName,
|
|
x.m.SentAt,
|
|
attachments,
|
|
embeds));
|
|
}
|
|
|
|
// Lazily delete the pruned messages (+ their attachment rows) as they're encountered.
|
|
if (deadMessageIds.Count > 0)
|
|
{
|
|
try
|
|
{
|
|
await db.Attachments.Where(a => deadMessageIds.Contains(a.MessageId)).ExecuteDeleteAsync();
|
|
await db.Messages.Where(m => deadMessageIds.Contains(m.Id)).ExecuteDeleteAsync();
|
|
_logger.LogInformation("Pruned {Count} attachment-only messages with missing files in '{Channel}'",
|
|
deadMessageIds.Count, channelName);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to prune messages with missing attachments in '{Channel}'", channelName);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>Extracts the storage file id from an attachment URL (e.g. "/api/files/{id}").</summary>
|
|
private static string FileIdFromUrl(string url) => url.Split('/')[^1];
|
|
}
|