using System.Security.Cryptography; using Microsoft.AspNetCore.Http; using System.Text.Json; namespace WxAgent.Service; public sealed record ArtifactInfo(string ArtifactId, string PrincipalId, string FileName, string ContentType, long Length, string Sha256, DateTimeOffset ExpiresAt); public sealed class ArtifactStore(ServiceOptions options) { private const long MaxBytes = 50L * 1024 * 1024; private readonly string root = Path.Combine(options.DataDirectory, "artifacts"); private static readonly HashSet Types = new(StringComparer.OrdinalIgnoreCase) { ".txt", ".png", ".jpg", ".jpeg", ".gif", ".pdf", ".zip" }; public async Task SaveAsync(string principalId, IFormFile file, CancellationToken ct) { if (file.Length is <= 0 or > MaxBytes) throw new ServiceException("FileTooLarge", 413, "Single file limit is 50 MiB."); var extension = Path.GetExtension(file.FileName); if (!Types.Contains(extension)) throw new ServiceException("FileTypeRejected", 415, "File type is not allowed."); Directory.CreateDirectory(root); var id = Guid.NewGuid().ToString("N"); var path = Path.Combine(root, id + ".bin"); var metadataPath = Path.Combine(root, id + ".json"); try { await using var output = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); await using var input = file.OpenReadStream(); var buffer = new byte[81920]; long total = 0; int read; while ((read = await input.ReadAsync(buffer, ct)) != 0) { total += read; if (total > MaxBytes) throw new ServiceException("FileTooLarge", 413, "Single file limit is 50 MiB."); await output.WriteAsync(buffer.AsMemory(0, read), ct); hash.AppendData(buffer, 0, read); } var info = new ArtifactInfo(id, principalId, Path.GetFileName(file.FileName), file.ContentType ?? "application/octet-stream", total, Convert.ToHexString(hash.GetHashAndReset()), DateTimeOffset.UtcNow.AddHours(24)); await File.WriteAllTextAsync(metadataPath, JsonSerializer.Serialize(info, ServiceHost.Json), ct); return info; } catch { TryDelete(path); TryDelete(metadataPath); throw; } } public ArtifactInfo Get(string principalId, string id) { if (!IsId(id)) throw new ServiceException("NotFound", 404, "Artifact not found."); var info = Read(id); if (info.PrincipalId != principalId) throw new ServiceException("NotFound", 404, "Artifact not found."); if (info.ExpiresAt <= DateTimeOffset.UtcNow) { Delete(id); throw new ServiceException("Expired", 410, "Artifact expired."); } return info; } public FileStream Open(string principalId, string id) { Get(principalId, id); return new FileStream(Path.Combine(root, id + ".bin"), FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); } public void Delete(string id) { if (IsId(id)) { TryDelete(Path.Combine(root, id + ".bin")); TryDelete(Path.Combine(root, id + ".json")); } } private ArtifactInfo Read(string id) { try { return JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(root, id + ".json")), ServiceHost.Json) ?? throw new InvalidDataException(); } catch { throw new ServiceException("NotFound", 404, "Artifact not found."); } } private static bool IsId(string id) => id.Length == 32 && id.All(Uri.IsHexDigit); private static void TryDelete(string path) { try { File.Delete(path); } catch { } } }