- 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
91 lines
3.0 KiB
C#
91 lines
3.0 KiB
C#
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;
|
|
}
|
|
}
|