feat(files): file service owns file domains, quotas and S3 backup

- file domains are created, queried, sized and deleted by the file service
  (POST/GET/DELETE /file/v1/domains), with per-domain quota enforced on write
- /file/v1/options publishes the quota choices; nothing about files lives on
  the host any more
- tickets carry the issuing space and a unique id: a domain only accepts tickets
  from its own space, and read/delete tickets work once (no replaying a counted
  download)
- uploads stay pending until the client confirms them; abandoned ones are swept
  after 24h, and an upload ticket can cancel its own bytes
- backup target is S3 (SigV4 client, no SDK) instead of WebDAV
- CORS allows the methods the browser really uses (POST, DELETE) and exposes Retry-After
This commit is contained in:
justme committed 2026-10-19 13:38:29 +08:00
1 parent d3da3597cc
commit ccb2c074d6
19 files changed
+827 -278

No files matched your search

+13 -12
View File
@@ -5,21 +5,22 @@ using static WebDrop.Api.Tests.ServerFixture;
namespace WebDrop.Api.Tests;
/// <summary>主后端每天把数据库快照压缩后传到外部存储(WebDAV),按保留期清理旧的。</summary>
/// <summary>主后端每天把数据库快照压缩后传到外部存储(S3 协议),按保留期清理旧的。</summary>
public sealed class BackupTests : IAsyncLifetime
{
private readonly FakeWebDav _dav = new();
private readonly FakeS3 _s3 = new();
private ServerFixture _server = null!;
public async Task InitializeAsync()
{
await _dav.StartAsync();
await _s3.StartAsync();
_server = ServerFixture.WithSettings(new()
{
["WebDrop:Backup:WebDav:Url"] = _dav.Url,
["WebDrop:Backup:WebDav:Username"] = FakeWebDav.User,
["WebDrop:Backup:WebDav:Password"] = FakeWebDav.Password,
["WebDrop:Backup:WebDav:BasePath"] = "webdrop",
["WebDrop:Backup:S3:Endpoint"] = _s3.Url,
["WebDrop:Backup:S3:AccessKey"] = FakeS3.AccessKey,
["WebDrop:Backup:S3:SecretKey"] = FakeS3.SecretKey,
["WebDrop:Backup:S3:BasePath"] = "webdrop",
["WebDrop:Backup:S3:Bucket"] = FakeS3.Bucket,
["WebDrop:Backup:RetainDays"] = "30",
});
await _server.InitializeAsync();
@@ -28,7 +29,7 @@ public sealed class BackupTests : IAsyncLifetime
public async Task DisposeAsync()
{
await _server.DisposeAsync();
await _dav.DisposeAsync();
await _s3.DisposeAsync();
}
[Fact]
@@ -40,18 +41,18 @@ public sealed class BackupTests : IAsyncLifetime
// 40 天前做的一份,和今天的一份
var old = await worker.RunOnceAsync(DateTime.Now.AddDays(-40), CancellationToken.None);
Assert.Equal(0, old.Purged);
Assert.True(_dav.Files.ContainsKey($"webdrop/chat/{old.Name}"));
Assert.True(_s3.Files.ContainsKey($"webdrop/chat/{old.Name}"));
var today = await worker.RunOnceAsync(DateTime.Now, CancellationToken.None);
Assert.Equal(1, today.Purged); // 超过 30 天的那份被清掉
Assert.False(_dav.Files.ContainsKey($"webdrop/chat/{old.Name}"));
Assert.True(_dav.Files.ContainsKey($"webdrop/chat/{today.Name}"));
Assert.False(_s3.Files.ContainsKey($"webdrop/chat/{old.Name}"));
Assert.True(_s3.Files.ContainsKey($"webdrop/chat/{today.Name}"));
// 备份能还原成数据库,里面有刚建的频道和账号
string path = Path.Combine(Path.GetTempPath(), "restore-" + Guid.NewGuid().ToString("N") + ".db");
try
{
using (var input = new MemoryStream(_dav.Files[$"webdrop/chat/{today.Name}"]))
using (var input = new MemoryStream(_s3.Files[$"webdrop/chat/{today.Name}"]))
using (var zip = new GZipStream(input, CompressionMode.Decompress))
using (var output = File.Create(path))
zip.CopyTo(output);