- 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
90 lines
4.4 KiB
C#
90 lines
4.4 KiB
C#
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();
|
|
}
|
|
}
|