59 lines
3.7 KiB
C#
59 lines
3.7 KiB
C#
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<string> Types = new(StringComparer.OrdinalIgnoreCase)
|
|
{ ".txt", ".png", ".jpg", ".jpeg", ".gif", ".pdf", ".zip" };
|
|
|
|
public async Task<ArtifactInfo> 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<ArtifactInfo>(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 { } }
|
|
}
|