98 lines
4.9 KiB
C#
98 lines
4.9 KiB
C#
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<string> 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<string> 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<int> 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<string> 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);
|
|
}
|
|
}
|