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 _broadcasters; private readonly LinkEmbedService _embedService; private readonly IMessageEncryptionService _encryption; private readonly ILogger _logger; public ChatService( IServiceScopeFactory scopeFactory, PresenceTracker presenceTracker, IEnumerable broadcasters, LinkEmbedService embedService, IMessageEncryptionService encryption, ILogger logger) { _scopeFactory = scopeFactory; _presenceTracker = presenceTracker; _broadcasters = broadcasters; _embedService = embedService; _encryption = encryption; _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(); 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 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(); 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 History, string? Error)> JoinChannelAsync( string connectionId, Guid userId, string username, string channelName) { channelName = channelName.ToLowerInvariant().Trim(); if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) return ([], "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens."); using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); if (channel is null) { // Auto-recreate #general if it was somehow removed if (channelName == HubConstants.DefaultChannel) { channel = new Channel { Id = Guid.NewGuid(), Name = HubConstants.DefaultChannel, Topic = "General discussion", CreatedByUserId = Guid.Empty, }; db.Channels.Add(channel); await db.SaveChangesAsync(); _logger.LogWarning("Default channel '{Channel}' was missing and has been recreated", HubConstants.DefaultChannel); } else { return ([], $"Channel '{channelName}' does not exist. Create it first via the channel list."); } } // Persist membership so the channel shows in the user's channel list var hasMembership = await db.ChannelMemberships .AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id); if (!hasMembership) { db.ChannelMemberships.Add(new ChannelMembership { UserId = userId, ChannelId = channel.Id, }); await db.SaveChangesAsync(); } var isNewJoin = _presenceTracker.JoinChannel(username, channelName); if (isNewJoin) { await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, connectionId)); _logger.LogInformation("{User} joined channel '{Channel}'", username, channelName); } var history = await GetChannelHistoryInternalAsync(db, channelName, HubConstants.DefaultHistoryCount); return (history, null); } 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 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(); 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? 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, Type = MessageType.Text, 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, MessageType.Text, null, null, message.SentAt, embeds); await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto)); _logger.LogDebug("{User} sent message in '{Channel}'", username, channelName); return null; } public async Task> GetChannelHistoryAsync(string channelName, int count) { channelName = channelName.ToLowerInvariant().Trim(); count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount); using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); return await GetChannelHistoryInternalAsync(db, channelName, count); } public async Task 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(); 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> GetOnlineUsersAsync(string channelName) { channelName = channelName.ToLowerInvariant().Trim(); var onlineUsernames = _presenceTracker.GetOnlineUsersInChannel(channelName); using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); return await db.Users .Where(u => onlineUsernames.Contains(u.Username)) .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 action) { foreach (var broadcaster in _broadcasters) { try { await action(broadcaster); } catch (Exception ex) { _logger.LogError(ex, "Broadcaster {Type} failed", broadcaster.GetType().Name); } } } public async Task GetUserProfileAsync(string username) { username = username.ToLowerInvariant(); using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username); if (user is null) return null; return new UserProfileDto( user.Id, user.Username, user.DisplayName, user.Bio, user.NicknameColor, user.AvatarAscii, user.Status, user.StatusMessage, user.Role, user.CreatedAt, user.LastSeenAt); } public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName) { channelName = channelName.ToLowerInvariant().Trim(); using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); if (channel is null) return (null, false); return (channel.Topic, true); } public async Task> GetChannelListAsync() { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var channels = await db.Channels.OrderBy(c => c.Name).ToListAsync(); return channels.Select(c => new ChannelListItem( c.Name, c.Topic, _presenceTracker.GetOnlineUsersInChannel(c.Name).Count)).ToList(); } public Task> GetChannelsForUserAsync(string username) => Task.FromResult(_presenceTracker.GetChannelsForUser(username)); public async Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password) { username = username.ToLowerInvariant(); using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username); if (user is null) return null; if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash)) return null; return (user.Id, user.Username); } /// /// Collapse consecutive newlines and cap total line count to prevent newline spam. /// 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(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> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count) { 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) .Take(count) .Join(db.Users, m => m.SenderUserId, u => u.Id, (m, u) => new { m, u.NicknameColor }) .ToListAsync(); raw.Reverse(); return raw.Select(x => { // Decrypt DB content (handles both encrypted and plaintext via prefix detection) var plaintext = _encryption.Decrypt(x.m.Content); var embedJsonPlain = _encryption.DecryptNullable(x.m.EmbedJson); List? embeds = null; if (embedJsonPlain is not null) { try { embeds = JsonSerializer.Deserialize>(embedJsonPlain); } catch { /* ignore malformed JSON */ } } // Encrypt for transport — client decrypts return new MessageDto( x.m.Id, _encryption.Encrypt(plaintext), x.m.SenderUsername, x.NicknameColor, channelName, x.m.Type, x.m.AttachmentUrl, x.m.AttachmentFileName, x.m.SentAt, embeds); }).ToList(); } }