Files
WebDrop/server/tests/WebDrop.Api.Tests/FakeWebDav.cs
T
justme 72a5e5b839 feat(server): split file storage into a separate file service with tickets, receipts and WebDAV backup
- new projects WebDrop.FileApi (file service), WebDrop.FileProtocol (shared tickets and receipts) and WebDrop.Sync (WebDAV)
- file content of any size goes to the file service; chat, avatars and space structure stay on the main backend
- tickets issued by the main backend, receipts by the file service, deletion queue and daily reconciliation
- daily database snapshot and file sync to external storage (OpenList/WebDAV) with a 30 day retention
- reorganize Core, Storage and Api into feature folders (namespaces unchanged)
- web client uploads file frames to the file service and reads them with tickets
2026-10-17 15:25:16 +08:00

82 lines
4.0 KiB
C#

using System.Collections.Concurrent;
using System.Text;
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>只实现备份用到的 WebDAV 子集(MKCOL、PUT、DELETE、PROPFIND Depth 1),数据放在内存里,带基本认证。</summary>
public sealed class FakeWebDav : IAsyncDisposable
{
private readonly WebApplication _app;
public ConcurrentDictionary<string, byte[]> Files { get; } = new();
public ConcurrentDictionary<string, bool> Dirs { get; } = new();
public bool Down { get; set; }
public string Url { get; private set; } = "";
public const string User = "backup", Password = "p@ss";
public FakeWebDav()
{
var builder = WebApplication.CreateSlimBuilder();
builder.Configuration["urls"] = "http://127.0.0.1:0";
builder.Logging.ClearProviders();
_app = builder.Build();
_app.Map("/dav/{**path}", async (HttpContext ctx, string? path) =>
{
if (Down) { ctx.Response.StatusCode = 503; return; }
string expected = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{User}:{Password}"));
if (ctx.Request.Headers.Authorization != expected) { ctx.Response.StatusCode = 401; return; }
path = Uri.UnescapeDataString(path ?? "").Trim('/');
switch (ctx.Request.Method)
{
case "MKCOL":
ctx.Response.StatusCode = Dirs.TryAdd(path, true) ? 201 : 405;
break;
case "PUT":
using (var ms = new MemoryStream()) { await ctx.Request.Body.CopyToAsync(ms); Files[path] = ms.ToArray(); }
ctx.Response.StatusCode = 201;
break;
case "DELETE":
{
bool removed = Files.TryRemove(path, out _);
foreach (var key in Files.Keys.Where(k => k.StartsWith(path + "/", StringComparison.Ordinal)).ToList()) { Files.TryRemove(key, out _); removed = true; }
foreach (var key in Dirs.Keys.Where(k => k == path || k.StartsWith(path + "/", StringComparison.Ordinal)).ToList()) { Dirs.TryRemove(key, out _); removed = true; }
ctx.Response.StatusCode = removed ? 204 : 404;
break;
}
case "PROPFIND":
{
if (path.Length > 0 && !Dirs.ContainsKey(path)) { ctx.Response.StatusCode = 404; return; }
string prefix = path.Length == 0 ? "" : path + "/";
var children = Files.Keys.Concat(Dirs.Keys).Where(k => k.StartsWith(prefix, StringComparison.Ordinal) && k.Length > prefix.Length && !k[prefix.Length..].Contains('/')).Distinct().Order();
var xml = new StringBuilder("<?xml version=\"1.0\"?><d:multistatus xmlns:d=\"DAV:\">");
xml.Append($"<d:response><d:href>/dav/{path}/</d:href></d:response>");
foreach (var child in children) xml.Append($"<d:response><d:href>/dav/{child}</d:href></d:response>");
xml.Append("</d:multistatus>");
ctx.Response.StatusCode = 207;
ctx.Response.ContentType = "application/xml";
await ctx.Response.WriteAsync(xml.ToString());
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('/') + "/dav";
}
public async ValueTask DisposeAsync()
{
await _app.StopAsync();
await _app.DisposeAsync();
}
}