using WebDrop.Core;
namespace WebDrop.Storage;
///
/// 磁盘 blob 存储。键形如 "channelHex/itemHex/index.digest",直接映射为目录与文件。
/// 键只能由服务生成(十六进制和数字),这里仍做路径逃逸检查作为纵深防御。
///
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 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 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;
}
}