using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Processing; using SixLabors.ImageSharp.Formats.Jpeg; using Nuuru.Server.Auth; using Nuuru.Server.Data; using Nuuru.Server.DTOs.Booru; using Nuuru.Server.DTOs.Forum; using Nuuru.Server.DTOs.User; using Nuuru.Server.Extensions; using Nuuru.Server.Models; using Nuuru.Server.Services.Storage; using Nuuru.Server.Services.BBCode; using Nuuru.Server.Services.Search; namespace Nuuru.Server.Services { public interface IUserService { Task GetUserProfileAsync(Guid userId); Task GetUserStatsAsync(Guid userId); Task> GetUserPostsAsync(Guid userId, int page, int pageSize); Task> GetUserThreadsAsync(Guid userId, int page, int pageSize); Task UpdateProfileAsync(Guid userId, UpdateProfileDto updateDto); Task GetAvatarAsync(Guid userId); Task UploadAvatarAsync(Guid userId, Stream fileStream, string fileName, long fileLength, CropRect? crop = null); Task DeleteAvatarAsync(Guid userId); Task GetBackgroundImageAsync(Guid userId); Task UploadBackgroundImageAsync(Guid userId, Stream fileStream, string fileName, long fileLength); Task DeleteBackgroundImageAsync(Guid userId); Task<(bool Success, int? NewFeaturedThreadId, string? ErrorMessage)> FeatureThreadAsync(Guid userId, int? threadId); Task> SearchUsersForMentionAsync(string query, int limit = 10); } public class BackgroundUploadResult { public bool Success { get; set; } public string? BackgroundImageUrl { get; set; } public string? ErrorMessage { get; set; } public static BackgroundUploadResult Ok(string url) => new() { Success = true, BackgroundImageUrl = url }; public static BackgroundUploadResult Fail(string error) => new() { Success = false, ErrorMessage = error }; } public class UserMentionDto { public required Guid Id { get; set; } public required string UserName { get; set; } public string? AvatarUrl { get; set; } } public class UserServiceResult { public bool Success { get; set; } public string? ErrorMessage { get; set; } public static UserServiceResult Ok() => new() { Success = true }; public static UserServiceResult Fail(string error) => new() { Success = false, ErrorMessage = error }; } public class AvatarUploadResult { public bool Success { get; set; } public string? AvatarUrl { get; set; } public string? ErrorMessage { get; set; } public static AvatarUploadResult Ok(string avatarUrl) => new() { Success = true, AvatarUrl = avatarUrl }; public static AvatarUploadResult Fail(string error) => new() { Success = false, ErrorMessage = error }; } public record CropRect(int X, int Y, int Size); public class UserService : IUserService { private static readonly string[] AllowedAvatarTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"]; private static readonly string[] AllowedBackgroundTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"]; private const long MaxAvatarSize = 5 * 1024 * 1024; // 5MB private const long MaxBackgroundSize = 5 * 1024 * 1024; // 5MB private const int AvatarSize = 512; private const int BackgroundMaxWidth = 500; private readonly UserManager _userManager; private readonly ApplicationDbContext _context; private readonly IFileStorageService _fileStorageService; private readonly IBBCodeService _bbCodeService; private readonly ITokenService _tokenService; private readonly IUserBadgeService _userBadgeService; private readonly IDefaultQueryFilterService _defaultQueryFilterService; private readonly ISiteSettingsService _siteSettings; private readonly IFavoriteService _favoriteService; private readonly ILogger _logger; public UserService( UserManager userManager, ApplicationDbContext context, IFileStorageService fileStorageService, IBBCodeService bbCodeService, ITokenService tokenService, IUserBadgeService userBadgeService, IDefaultQueryFilterService defaultQueryFilterService, ISiteSettingsService siteSettings, IFavoriteService favoriteService, ILogger logger) { _userManager = userManager; _context = context; _fileStorageService = fileStorageService; _bbCodeService = bbCodeService; _tokenService = tokenService; _userBadgeService = userBadgeService; _defaultQueryFilterService = defaultQueryFilterService; _siteSettings = siteSettings; _favoriteService = favoriteService; _logger = logger; } public async Task GetUserProfileAsync(Guid userId) { var user = await _userManager.FindByIdAsync(userId.ToString()); if (user == null) return null; // Get display info (badges and role color) var displayInfo = await _userBadgeService.GetUserDisplayInfoAsync(userId); // Convert badge IDs to full badge DTOs var badges = displayInfo.Badges .Select(id => { if (id.StartsWith("clan:")) { var parts = id.Split(':', 3); return new UserBadgeDto { Id = id, DisplayName = parts.Length > 2 ? parts[2] : "Clan", Color = displayInfo.ClanColor ?? "#ffffff" }; } // Ban badges have expiry appended: "blocked_global:1713139200" var colonIdx = id.IndexOf(':'); var lookupId = colonIdx >= 0 ? id[..colonIdx] : id; var badge = UserBadges.GetById(lookupId); return badge != null ? new UserBadgeDto { Id = id, DisplayName = badge.DisplayName, Color = badge.Color } : null; }) .Where(b => b != null) .Select(b => b!) .ToList(); // Use COUNT query instead of loading all posts var totalUploads = await _context.BooruPosts.CountAsync(p => p.UploaderId == userId); var bointsEnabled = await _siteSettings.GetBoolAsync("boints.enabled", false); var clansEnabled = bointsEnabled && await _siteSettings.GetBoolAsync("clans.enabled", false); var clanInfo = clansEnabled ? await _context.ClanMembers .Where(m => m.UserId == userId) .Select(m => new { m.ClanId, m.Clan.Tag, m.Clan.Color, m.Clan.BadgeStorageIdentifier, m.Clan.UseBadgeAsTag }) .FirstOrDefaultAsync() : null; return new UserProfileDto { Id = user.Id, UserName = user.UserName!, Status = user.Status, Biography = user.Biography, BiographyHtml = user.BiographyHtml, DateCreated = user.DateCreated, TotalUploads = totalUploads, AvatarUrl = user.AvatarStorageIdentifier != null ? $"/api/user/{user.UserName}/avatar?v={user.AvatarStorageIdentifier[..8]}" : null, BackgroundImageUrl = ApplicationUser.GetBackgroundImageUrl(user.UserName, user.BackgroundImageStorageIdentifier), RoleColor = displayInfo.RoleColor, RoleName = displayInfo.RoleName, Badges = badges, ReactionScore = user.ReactionScore, Boints = bointsEnabled ? user.Boints : 0, ClanId = clanInfo?.ClanId, ClanTag = clanInfo?.Tag, ClanColor = clanInfo?.Color, ClanBadgeUrl = clanInfo is { UseBadgeAsTag: true, BadgeStorageIdentifier: not null } ? $"/api/clans/{clanInfo.ClanId}/badge?v={clanInfo.BadgeStorageIdentifier[..8]}" : null, ActiveBanZones = displayInfo.ActiveBanZones.Select(z => z.ToString()).ToList(), ActiveBans = displayInfo.ActiveBans, ForcedDisplayName = bointsEnabled ? user.ActiveForcedDisplayName : null, HasProfileBorder = bointsEnabled && user.HasProfileBorder, FeaturedThreadId = user.FeaturedThreadId }; } public async Task GetUserStatsAsync(Guid userId) { var user = await _userManager.FindByIdAsync(userId.ToString()); if (user == null) return null; // Use COUNT queries instead of loading all entities into memory var totalUploads = await _context.BooruPosts.CountAsync(p => p.UploaderId == userId); var totalComments = await _context.BooruComments.CountAsync(c => c.UserId == userId); var totalForumThreads = await _context.ForumThreads.CountAsync(t => t.AuthorId == userId); var totalForumPosts = await _context.ForumPosts.CountAsync(p => p.AuthorId == userId); var totalFavorites = await _favoriteService.GetUserFavoriteCountAsync(userId); return new UserStatsDto { TotalUploads = totalUploads, TotalComments = totalComments, TotalFavorites = totalFavorites, TotalForumThreads = totalForumThreads, TotalForumPosts = totalForumPosts, MemberSince = user.DateCreated, ReactionScore = user.ReactionScore, }; } public async Task> GetUserPostsAsync(Guid userId, int page, int pageSize) { var query = _context.BooruPosts .Include(p => p.Uploader) .Include(p => p.PostTags) .ThenInclude(pt => pt.Tag) .ThenInclude(t => t.Category) .Where(p => p.UploaderId == userId) .AsQueryable(); query = await _defaultQueryFilterService.ApplyDefaultFiltersAsync(query); var posts = await query .OrderByDescending(p => p.UploadedAt) .Skip((page - 1) * pageSize) .Take(pageSize) .AsSplitQuery() .ToListAsync(); // Batch-fetch display info var uploaderIds = posts .Where(p => p.Uploader != null) .Select(p => p.Uploader!.Id) .Distinct(); var displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync(uploaderIds); return posts.ToDto(displayInfoMap); } public async Task> GetUserThreadsAsync(Guid userId, int page, int pageSize) { var threads = await _context.ForumThreads .Include(t => t.Author) .Include(t => t.Category) .Include(t => t.LastPost).ThenInclude(p => p!.Author) .Where(t => t.AuthorId == userId) .OrderByDescending(t => t.LastPostAt) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); // Batch-fetch display info for both thread authors and last post authors var authorIds = threads .SelectMany(t => new[] { t.Author?.Id, t.LastPost?.Author?.Id }) .Where(id => id.HasValue) .Select(id => id!.Value) .Distinct(); var displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync(authorIds); return threads.ToDto(displayInfoMap: displayInfoMap); } public async Task UpdateProfileAsync(Guid userId, UpdateProfileDto updateDto) { var user = await _userManager.FindByIdAsync(userId.ToString()); if (user == null) return UserServiceResult.Fail("User not found"); // Update password if provided if (!string.IsNullOrWhiteSpace(updateDto.NewPassword)) { if (string.IsNullOrWhiteSpace(updateDto.CurrentPassword)) return UserServiceResult.Fail("Current password is required to set a new password"); var passwordCheck = await _userManager.CheckPasswordAsync(user, updateDto.CurrentPassword); if (!passwordCheck) return UserServiceResult.Fail("Current password is incorrect"); var passwordResult = await _userManager.ChangePasswordAsync(user, updateDto.CurrentPassword, updateDto.NewPassword); if (!passwordResult.Succeeded) { var errors = string.Join(", ", passwordResult.Errors.Select(e => e.Description)); return UserServiceResult.Fail(errors); } // Password changes must invalidate all active refresh tokens. await _tokenService.RevokeAllRefreshTokensForUserAsync(user.Id); } // Update biography and status if (updateDto.Biography != null) { user.Biography = updateDto.Biography; user.BiographyHtml = string.IsNullOrWhiteSpace(updateDto.Biography) ? null : _bbCodeService.Parse(updateDto.Biography); } if (updateDto.Status != null) { user.Status = updateDto.Status; } var updateResult = await _userManager.UpdateAsync(user); if (!updateResult.Succeeded) { var errors = string.Join(", ", updateResult.Errors.Select(e => e.Description)); return UserServiceResult.Fail(errors); } return UserServiceResult.Ok(); } public async Task GetAvatarAsync(Guid userId) { var user = await _userManager.FindByIdAsync(userId.ToString()); if (user == null || string.IsNullOrEmpty(user.AvatarStorageIdentifier)) return null; return await _fileStorageService.GetFileAsync(user.AvatarStorageIdentifier); } public async Task UploadAvatarAsync(Guid userId, Stream fileStream, string fileName, long fileLength, CropRect? crop = null) { if (fileLength > MaxAvatarSize) return AvatarUploadResult.Fail("Avatar must be less than 5MB"); // Detect MIME type using var memoryStream = new MemoryStream(); await fileStream.CopyToAsync(memoryStream); memoryStream.Position = 0; var detectedMime = Utilities.MIME.DetectMIME(memoryStream, fileName); if (!AllowedAvatarTypes.Contains(detectedMime)) return AvatarUploadResult.Fail("Invalid file type. Allowed: JPG, PNG, GIF, WebP"); var user = await _userManager.FindByIdAsync(userId.ToString()); if (user == null) return AvatarUploadResult.Fail("User not found"); // Delete old avatar if exists if (!string.IsNullOrEmpty(user.AvatarStorageIdentifier)) { await _fileStorageService.DeleteFileAsync(user.AvatarStorageIdentifier); } // Resize to 512x512 and convert to WebP via FFmpeg memoryStream.Position = 0; var tempInput = Path.GetTempFileName(); var tempOutput = Path.ChangeExtension(Path.GetTempFileName(), ".webp"); try { await using (var fs = System.IO.File.Create(tempInput)) { await memoryStream.CopyToAsync(fs); } var scaleFilter = crop != null ? $"crop={crop.Size}:{crop.Size}:{crop.X}:{crop.Y},scale={AvatarSize}:{AvatarSize}" : $"scale={AvatarSize}:{AvatarSize}:force_original_aspect_ratio=increase,crop={AvatarSize}:{AvatarSize}"; await FFMpegCore.FFMpegArguments .FromFileInput(tempInput) .OutputToFile(tempOutput, overwrite: true, options => options .WithCustomArgument($"-vf {scaleFilter}") .WithCustomArgument("-quality 85 -loop 0") .ForceFormat("webp")) .ProcessAsynchronously(); await using var outputStream = System.IO.File.OpenRead(tempOutput); var result = await _fileStorageService.SaveFileAsync( outputStream, "avatar.webp", new FileStorageOptions { ContentType = "image/webp", UploaderId = userId, IsPublic = true }); if (!result.Success) return AvatarUploadResult.Fail("Failed to save avatar"); // Update user user.AvatarStorageIdentifier = result.FileIdentifier; var updateResult = await _userManager.UpdateAsync(user); if (!updateResult.Succeeded) { var errors = string.Join(", ", updateResult.Errors.Select(e => e.Description)); return AvatarUploadResult.Fail(errors); } _logger.LogInformation("Avatar uploaded for user {UserId}", userId); return AvatarUploadResult.Ok($"/api/user/{user.UserName}/avatar?v={result.FileIdentifier[..8]}"); } finally { TryDeleteFile(tempInput); TryDeleteFile(tempOutput); } } public async Task DeleteAvatarAsync(Guid userId) { var user = await _userManager.FindByIdAsync(userId.ToString()); if (user == null) return UserServiceResult.Fail("User not found"); if (string.IsNullOrEmpty(user.AvatarStorageIdentifier)) return UserServiceResult.Fail("No avatar to delete"); await _fileStorageService.DeleteFileAsync(user.AvatarStorageIdentifier); user.AvatarStorageIdentifier = null; var updateResult = await _userManager.UpdateAsync(user); if (!updateResult.Succeeded) { var errors = string.Join(", ", updateResult.Errors.Select(e => e.Description)); return UserServiceResult.Fail(errors); } _logger.LogInformation("Avatar deleted for user {UserId}", userId); return UserServiceResult.Ok(); } public async Task GetBackgroundImageAsync(Guid userId) { var user = await _userManager.FindByIdAsync(userId.ToString()); if (user == null || string.IsNullOrEmpty(user.BackgroundImageStorageIdentifier)) return null; return await _fileStorageService.GetFileAsync(user.BackgroundImageStorageIdentifier); } public async Task UploadBackgroundImageAsync(Guid userId, Stream fileStream, string fileName, long fileLength) { if (fileLength > MaxBackgroundSize) return BackgroundUploadResult.Fail("Background image must be less than 5MB"); using var memoryStream = new MemoryStream(); await fileStream.CopyToAsync(memoryStream); memoryStream.Position = 0; var detectedMime = Utilities.MIME.DetectMIME(memoryStream, fileName); if (!AllowedBackgroundTypes.Contains(detectedMime)) return BackgroundUploadResult.Fail("Invalid file type. Allowed: JPG, PNG, GIF, WebP"); var user = await _userManager.FindByIdAsync(userId.ToString()); if (user == null) return BackgroundUploadResult.Fail("User not found"); // Delete old background if exists if (!string.IsNullOrEmpty(user.BackgroundImageStorageIdentifier)) { await _fileStorageService.DeleteFileAsync(user.BackgroundImageStorageIdentifier); } // Resize to max width and convert to WebP via FFmpeg memoryStream.Position = 0; var tempInput = Path.GetTempFileName(); var tempOutput = Path.ChangeExtension(Path.GetTempFileName(), ".webp"); try { await using (var fs = System.IO.File.Create(tempInput)) { await memoryStream.CopyToAsync(fs); } var scaleFilter = $"scale='min({BackgroundMaxWidth},iw)':-1"; await FFMpegCore.FFMpegArguments .FromFileInput(tempInput) .OutputToFile(tempOutput, overwrite: true, options => options .WithCustomArgument($"-vf {scaleFilter}") .WithCustomArgument("-quality 85 -loop 0") .ForceFormat("webp")) .ProcessAsynchronously(); await using var outputStream = System.IO.File.OpenRead(tempOutput); var result = await _fileStorageService.SaveFileAsync( outputStream, "background.webp", new FileStorageOptions { ContentType = "image/webp", UploaderId = userId, IsPublic = true }); if (!result.Success) return BackgroundUploadResult.Fail("Failed to save background image"); user.BackgroundImageStorageIdentifier = result.FileIdentifier; var updateResult = await _userManager.UpdateAsync(user); if (!updateResult.Succeeded) { var errors = string.Join(", ", updateResult.Errors.Select(e => e.Description)); return BackgroundUploadResult.Fail(errors); } _logger.LogInformation("Background image uploaded for user {UserId}", userId); return BackgroundUploadResult.Ok($"/api/user/{user.UserName}/background?v={result.FileIdentifier[..8]}"); } finally { TryDeleteFile(tempInput); TryDeleteFile(tempOutput); } } public async Task DeleteBackgroundImageAsync(Guid userId) { var user = await _userManager.FindByIdAsync(userId.ToString()); if (user == null) return UserServiceResult.Fail("User not found"); if (string.IsNullOrEmpty(user.BackgroundImageStorageIdentifier)) return UserServiceResult.Fail("No background image to delete"); await _fileStorageService.DeleteFileAsync(user.BackgroundImageStorageIdentifier); user.BackgroundImageStorageIdentifier = null; var updateResult = await _userManager.UpdateAsync(user); if (!updateResult.Succeeded) { var errors = string.Join(", ", updateResult.Errors.Select(e => e.Description)); return UserServiceResult.Fail(errors); } _logger.LogInformation("Background image deleted for user {UserId}", userId); return UserServiceResult.Ok(); } public async Task<(bool Success, int? NewFeaturedThreadId, string? ErrorMessage)> FeatureThreadAsync(Guid userId, int? threadId) { var user = await _userManager.FindByIdAsync(userId.ToString()); if (user == null) return (false, null, "User not found"); user.FeaturedThreadId = user.FeaturedThreadId == threadId ? null : threadId; var updateResult = await _userManager.UpdateAsync(user); if (!updateResult.Succeeded) { var errors = string.Join(", ", updateResult.Errors.Select(e => e.Description)); return (false, null, errors); } _logger.LogInformation("User {UserId} featured thread {ThreadId} (Action: {Action})", userId, threadId, user.FeaturedThreadId == null ? "Unfeatured" : "Featured"); return (true, user.FeaturedThreadId, null); } private static string GetExtensionFromMime(string mime) => mime switch { "image/jpeg" => "jpg", "image/png" => "png", "image/gif" => "gif", "image/webp" => "webp", _ => "bin" }; public async Task> SearchUsersForMentionAsync(string query, int limit = 10) { if (string.IsNullOrWhiteSpace(query) || query.Length < 1) return []; limit = Math.Clamp(limit, 1, 20); var lowerQuery = query.ToLower(); var users = await _userManager.Users .Where(u => !u.IsSystemAccount) .Where(u => u.NormalizedUserName != null && u.NormalizedUserName.StartsWith(lowerQuery.ToUpper())) .OrderBy(u => u.NormalizedUserName == lowerQuery.ToUpper() ? 0 : 1) .ThenBy(u => u.UserName!.Length) .Take(limit) .Select(u => new UserMentionDto { Id = u.Id, UserName = u.UserName!, AvatarUrl = u.AvatarStorageIdentifier != null ? $"/api/user/{u.UserName}/avatar?v={u.AvatarStorageIdentifier.Substring(0, 8)}" : null }) .ToListAsync(); return users; } private static void TryDeleteFile(string path) { try { if (System.IO.File.Exists(path)) System.IO.File.Delete(path); } catch { } } } }