using System.Security.Cryptography; using System.Text; using JobTrackerApi.Data; using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Services; public static class AvatarStorage { private const string FilePrefix = "file:"; public static async Task StageAsync(string dataRoot, string userId, byte[] bytes, CancellationToken cancellationToken) { var key = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(userId))).ToLowerInvariant(); var folder = Path.Combine(dataRoot, "Avatars", key); Directory.CreateDirectory(folder); var path = Path.Combine(folder, $"avatar-{Guid.NewGuid():N}.png"); try { await File.WriteAllBytesAsync(path, bytes, cancellationToken); } catch { TryDelete(FilePrefix + path); throw; } return FilePrefix + path; } public static IEnumerable PortraitFiles(string dataRoot, string ownerKey) { var root = Path.Combine(dataRoot, "Avatars"); var folder = Path.Combine(root, ownerKey); if (ownerKey.Length != 64 || !ownerKey.All(Uri.IsHexDigit) || !Directory.Exists(folder) || (File.GetAttributes(root) & FileAttributes.ReparsePoint) != 0 || (File.GetAttributes(folder) & FileAttributes.ReparsePoint) != 0) yield break; foreach (var path in Directory.EnumerateFiles(folder, "avatar*", new EnumerationOptions { AttributesToSkip = FileAttributes.ReparsePoint, IgnoreInaccessible = true })) { var name = Path.GetFileName(path); if (name is "avatar.png" or "avatar.jpg" or "avatar.webp" || (name.StartsWith("avatar-", StringComparison.Ordinal) && name.EndsWith(".png", StringComparison.Ordinal) && name.Length == 43 && Guid.TryParseExact(name[7..^4], "N", out _))) yield return path; } } public static async Task SweepOrphansAsync(JobTrackerContext db, string dataRoot, DateTimeOffset now, CancellationToken token) { var root = Path.Combine(dataRoot, "Avatars"); if (!Directory.Exists(root) || (File.GetAttributes(root) & FileAttributes.ReparsePoint) != 0) return 0; var candidates = Directory.EnumerateDirectories(root, "*", new EnumerationOptions { AttributesToSkip = FileAttributes.ReparsePoint, IgnoreInaccessible = true }) .Take(1000).SelectMany(folder => PortraitFiles(dataRoot, Path.GetFileName(folder)).Take(1000)) .Where(path => File.GetLastWriteTimeUtc(path) <= now.AddDays(-1).UtcDateTime) .Take(1000).Select(path => FilePrefix + Path.GetFullPath(path)).ToList(); var referenced = (await db.Users.AsNoTracking().Where(user => candidates.Contains(user.AvatarImageDataUrl!)) .Select(user => user.AvatarImageDataUrl!).ToListAsync(token)) .ToHashSet(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); var removed = 0; foreach (var candidate in candidates) { token.ThrowIfCancellationRequested(); if (!referenced.Contains(candidate) && TryDelete(candidate)) removed++; } return removed; } public static bool TryDelete(string? value) { try { Delete(value); return true; } catch (IOException) { return false; } catch (UnauthorizedAccessException) { return false; } } public static async Task StoreAsync(string dataRoot, string userId, byte[] bytes, string contentType, CancellationToken cancellationToken) { var userKey = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(userId))).ToLowerInvariant(); var folder = Path.Combine(dataRoot, "Avatars", userKey); Directory.CreateDirectory(folder); var extension = contentType switch { "image/png" => ".png", "image/webp" => ".webp", _ => ".jpg" }; var path = Path.Combine(folder, "avatar" + extension); foreach (var old in Directory.EnumerateFiles(folder, "avatar.*")) if (!string.Equals(old, path, StringComparison.OrdinalIgnoreCase)) File.Delete(old); await File.WriteAllBytesAsync(path, bytes, cancellationToken); return FilePrefix + path; } public static string? Resolve(string? value) { if (string.IsNullOrWhiteSpace(value) || !value.StartsWith(FilePrefix, StringComparison.Ordinal)) return value; var path = value[FilePrefix.Length..]; if (!File.Exists(path)) return null; var contentType = Path.GetExtension(path).ToLowerInvariant() switch { ".png" => "image/png", ".webp" => "image/webp", _ => "image/jpeg" }; return $"data:{contentType};base64,{Convert.ToBase64String(File.ReadAllBytes(path))}"; } public static void Delete(string? value) { if (value?.StartsWith(FilePrefix, StringComparison.Ordinal) != true) return; var path = value[FilePrefix.Length..]; if (File.Exists(path)) File.Delete(path); } }