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
This commit is contained in:
1 parent
d66c93fe98
commit
72a5e5b839
84 files changed
+2156
-39
No files matched your search
@@ -0,0 +1,90 @@
|
||||
using WebDrop.Core;
|
||||
|
||||
namespace WebDrop.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// 磁盘 blob 存储。键形如 "channelHex/itemHex/index.digest",直接映射为目录与文件。
|
||||
/// 键只能由服务生成(十六进制和数字),这里仍做路径逃逸检查作为纵深防御。
|
||||
/// </summary>
|
||||
public sealed class DiskBlobStore : IBlobStore
|
||||
{
|
||||
private readonly string _root;
|
||||
|
||||
public DiskBlobStore(string root)
|
||||
{
|
||||
_root = Path.GetFullPath(root);
|
||||
Directory.CreateDirectory(_root);
|
||||
}
|
||||
|
||||
public async ValueTask PutAsync(string key, ReadOnlyMemory<byte> data, CancellationToken ct = default)
|
||||
{
|
||||
string path = Resolve(key);
|
||||
// 键里含内容摘要:文件已存在就意味着内容相同,无需重写(也避免并发覆盖在 Windows 上冲突)
|
||||
if (File.Exists(path)) return;
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
|
||||
// 先写临时文件再重命名,读者永远看不到半个文件
|
||||
string tmp = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
await using (var fs = new FileStream(tmp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.Asynchronous))
|
||||
{
|
||||
await fs.WriteAsync(data, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
File.Move(tmp, path, overwrite: false);
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException && File.Exists(path))
|
||||
{
|
||||
File.Delete(tmp); // 并发写入同一内容,对方先到了
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<byte[]?> ReadAsync(string key, CancellationToken ct = default)
|
||||
{
|
||||
string path = Resolve(key);
|
||||
try
|
||||
{
|
||||
return await File.ReadAllBytesAsync(path, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Delete(string key)
|
||||
{
|
||||
try { File.Delete(Resolve(key)); }
|
||||
catch (DirectoryNotFoundException) { }
|
||||
}
|
||||
|
||||
public void DeletePrefix(string prefix)
|
||||
{
|
||||
string dir = Resolve(prefix);
|
||||
try { if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); }
|
||||
catch (DirectoryNotFoundException) { }
|
||||
}
|
||||
|
||||
public IEnumerable<(string Key, DateTimeOffset LastWrite)> Enumerate()
|
||||
{
|
||||
foreach (string file in Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
if (file.EndsWith(".tmp", StringComparison.Ordinal)) continue;
|
||||
string key = Path.GetRelativePath(_root, file).Replace('\\', '/');
|
||||
yield return (key, new DateTimeOffset(File.GetLastWriteTimeUtc(file), TimeSpan.Zero));
|
||||
}
|
||||
}
|
||||
|
||||
private string Resolve(string key)
|
||||
{
|
||||
string full = Path.GetFullPath(Path.Combine(_root, key));
|
||||
if (!full.StartsWith(_root + Path.DirectorySeparatorChar, StringComparison.Ordinal))
|
||||
throw new ArgumentException("非法 blob 键", nameof(key));
|
||||
return full;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user