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:
1 parent
d3da3597cc
commit
ccb2c074d6
19 files changed
+827
-278
No files matched your search
@@ -0,0 +1,89 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Xml.Linq;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace WebDrop.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 只实现备份用到的 S3 子集(PUT、HEAD、DELETE、ListObjectsV2),路径风格,数据放在内存里。
|
||||
/// 检查 SigV4 的凭据字段和 x-amz-content-sha256 与正文是否一致;不重算签名本身(签名算法由真实服务兜底)。
|
||||
/// </summary>
|
||||
public sealed class FakeS3 : IAsyncDisposable
|
||||
{
|
||||
private readonly WebApplication _app;
|
||||
/// <summary>键(不含存储桶)→ 内容</summary>
|
||||
public ConcurrentDictionary<string, byte[]> Files { get; } = new();
|
||||
public bool Down { get; set; }
|
||||
public string Url { get; private set; } = "";
|
||||
public const string AccessKey = "AKIATEST", SecretKey = "secret", Bucket = "bk";
|
||||
|
||||
public FakeS3()
|
||||
{
|
||||
var builder = WebApplication.CreateSlimBuilder();
|
||||
builder.Configuration["urls"] = "http://127.0.0.1:0";
|
||||
builder.Logging.ClearProviders();
|
||||
_app = builder.Build();
|
||||
_app.Map("/{bucket}/{**key}", async (HttpContext ctx, string bucket, string? key) =>
|
||||
{
|
||||
if (Down) { ctx.Response.StatusCode = 503; return; }
|
||||
string auth = ctx.Request.Headers.Authorization.ToString();
|
||||
if (bucket != Bucket || !auth.StartsWith($"AWS4-HMAC-SHA256 Credential={AccessKey}/", StringComparison.Ordinal) || !auth.Contains("Signature=") || !ctx.Request.Headers.ContainsKey("x-amz-date"))
|
||||
{ ctx.Response.StatusCode = 403; return; }
|
||||
key = Uri.UnescapeDataString(key ?? "");
|
||||
switch (ctx.Request.Method)
|
||||
{
|
||||
case "PUT":
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await ctx.Request.Body.CopyToAsync(ms);
|
||||
var body = ms.ToArray();
|
||||
if (!string.Equals(ctx.Request.Headers["x-amz-content-sha256"], Convert.ToHexStringLower(SHA256.HashData(body)), StringComparison.Ordinal)) { ctx.Response.StatusCode = 400; return; }
|
||||
Files[key] = body;
|
||||
ctx.Response.StatusCode = 200;
|
||||
break;
|
||||
}
|
||||
case "HEAD": ctx.Response.StatusCode = Files.ContainsKey(key) ? 200 : 404; break;
|
||||
case "DELETE": Files.TryRemove(key, out _); ctx.Response.StatusCode = 204; break;
|
||||
case "GET":
|
||||
{
|
||||
string prefix = ctx.Request.Query["prefix"].ToString(), delimiter = ctx.Request.Query["delimiter"].ToString();
|
||||
var keys = Files.Keys.Where(k => k.StartsWith(prefix, StringComparison.Ordinal)).Order(StringComparer.Ordinal).ToList();
|
||||
XNamespace ns = "http://s3.amazonaws.com/doc/2006-03-01/";
|
||||
var root = new XElement(ns + "ListBucketResult", new XElement(ns + "IsTruncated", "false"));
|
||||
var folders = new SortedSet<string>(StringComparer.Ordinal);
|
||||
foreach (var k in keys)
|
||||
{
|
||||
int cut = delimiter.Length == 0 ? -1 : k.IndexOf(delimiter, prefix.Length, StringComparison.Ordinal);
|
||||
if (cut < 0) root.Add(new XElement(ns + "Contents", new XElement(ns + "Key", k)));
|
||||
else folders.Add(k[..(cut + delimiter.Length)]);
|
||||
}
|
||||
foreach (var f in folders) root.Add(new XElement(ns + "CommonPrefixes", new XElement(ns + "Prefix", f)));
|
||||
ctx.Response.StatusCode = 200;
|
||||
ctx.Response.ContentType = "application/xml";
|
||||
await ctx.Response.WriteAsync(root.ToString(), Encoding.UTF8);
|
||||
break;
|
||||
}
|
||||
default: ctx.Response.StatusCode = 405; break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
await _app.StartAsync();
|
||||
Url = _app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First().TrimEnd('/');
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _app.StopAsync();
|
||||
await _app.DisposeAsync();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user