Files
wx-win-agent/node-agent/WxAgent.Service/ArtifactStore.cs
T

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 { } }
}