71 lines
5.6 KiB
C#
71 lines
5.6 KiB
C#
using FileService.Application.Ports;
|
|
using FileService.Application.StorageContracts;
|
|
using FileService.Domain.ValueObjects;
|
|
using IM.Commons;
|
|
using IM.InitCommon;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace FileService.Infrastructure.Storage;
|
|
public class LocalStorageAdapter(IStorageRedisCache redis, IOptionsSnapshot<StorageOptions> options) : IObjectStoragePort, ILocalChunkStorage
|
|
{
|
|
private StorageProviderOptions Provider => options.Value.Providers["Local"];
|
|
public string ProviderCode => "Local";
|
|
public static string SafePath(string root, params string[] segments)
|
|
{
|
|
var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
|
var path = Path.GetFullPath(Path.Combine(new[] { fullRoot }.Concat(segments).ToArray()));
|
|
if (!path.StartsWith(fullRoot, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) throw new InvalidOperationException("存储路径超出允许根目录");
|
|
for (var dir = Path.GetDirectoryName(path); dir != null && dir.Length >= fullRoot.Length; dir = Path.GetDirectoryName(dir))
|
|
if (Directory.Exists(dir) && File.GetAttributes(dir).HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("不允许使用存储目录链接");
|
|
if (File.Exists(path) && File.GetAttributes(path).HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("不允许使用文件链接");
|
|
return path;
|
|
}
|
|
public async Task<StorageLocation> PutObjectAsync(PutObjectCommand command, CancellationToken token)
|
|
{
|
|
var path = SafePath(Provider.LocalRootPath!, command.Bucket, command.ObjectKey);
|
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
|
await using var stream = new FileStream(path, FileMode.CreateNew);
|
|
await command.Content.CopyToAsync(stream, token);
|
|
if (stream.Length != command.ContentLength) throw new InvalidOperationException("文件实际大小与申报大小不符");
|
|
return new(ProviderCode, command.Bucket, command.ObjectKey, Provider.Region);
|
|
}
|
|
public string? GetPublicUrl(StorageLocation location) => !string.IsNullOrEmpty(Provider.PublicBucket) && location.Bucket == Provider.PublicBucket
|
|
? $"{(Provider.PublicBaseUrl ?? Provider.LocalUploadApiBaseUrl ?? "").TrimEnd('/')}/static/{string.Join('/', location.ObjectKey.Replace('\\', '/').Split('/').Select(Uri.EscapeDataString))}" : null;
|
|
public Task<Stream> OpenReadAsync(StorageLocation location, CancellationToken token) => Task.FromResult<Stream>(File.OpenRead(SafePath(Provider.LocalRootPath!, location.Bucket, location.ObjectKey)));
|
|
public Task<InitiateUploadResult> InitUploadAsync(InitiateUploadCommand command, CancellationToken token) => Task.FromResult(new InitiateUploadResult(Guid.NewGuid().ToString(), new StorageLocation(ProviderCode, command.Bucket, command.ObjectKey, Provider.Region)));
|
|
public Task<PresignedUrl> GenerateUploadUrlAsync(GenerateUploadUrlCommand command, CancellationToken token) => Task.FromResult(new PresignedUrl(
|
|
Provider.LocalUploadApiBaseUrl!.TrimEnd('/') + $"/local/parts/upload?sessionId={Uri.EscapeDataString(command.UploadSessionId)}&partNumber={command.PartNumber}", "POST", new Dictionary<string, string>(), DateTimeOffset.UtcNow.Add(command.ExpiresIn)));
|
|
public async Task SavePartAsync(SaveLocalPartCommand command)
|
|
{
|
|
if (!Guid.TryParse(command.UploadSessionId, out _) || command.PartNumber < 1) throw new InvalidOperationException("分片参数无效");
|
|
var path = SafePath(Provider.LocalRootPath!, "staging", command.UploadSessionId, $"{command.PartNumber}.part");
|
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
|
await using var stream = File.Create(path);
|
|
await command.Stream.CopyToAsync(stream);
|
|
if (stream.Length != command.ContentLength) throw new InvalidOperationException("分片实际大小不符");
|
|
}
|
|
public async Task<CompleteUploadResult> CompleteUploadAsync(CompleteUploadCommand command, CancellationToken token)
|
|
{
|
|
var cache = await redis.GetAsync(command.UploadSessionId) ?? throw new InvalidOperationException("上传任务已过期");
|
|
var final = SafePath(Provider.LocalRootPath!, command.Bucket, command.ObjectKey);
|
|
Directory.CreateDirectory(Path.GetDirectoryName(final)!);
|
|
var temporary = final + ".merging";
|
|
await using (var output = File.Create(temporary)) {
|
|
foreach (var part in command.Parts.OrderBy(x => x.PartNumber)) {
|
|
await using var input = File.OpenRead(SafePath(Provider.LocalRootPath!, "staging", command.UploadSessionId, $"{part.PartNumber}.part"));
|
|
await input.CopyToAsync(output, token);
|
|
}
|
|
if (output.Length != cache.FileSize) throw new InvalidOperationException("合并文件大小不符");
|
|
}
|
|
File.Move(temporary, final, true);
|
|
// Keep parts available for idempotent retry after response loss.
|
|
return new(new StorageLocation(ProviderCode, command.Bucket, command.ObjectKey, command.Region), null, cache.FileSize);
|
|
}
|
|
public async Task<Result<object>> MergeAsync(string sessionId, string objectKey, IReadOnlyList<UploadPart> parts)
|
|
{
|
|
var cache = await redis.GetAsync(sessionId) ?? throw new InvalidOperationException("上传任务已过期");
|
|
await CompleteUploadAsync(new CompleteUploadCommand(ProviderCode: cache.ProviderCode, Bucket: cache.Bucket, Region: cache.Region, ObjectKey: objectKey, UploadSessionId: sessionId, Parts: parts), CancellationToken.None);
|
|
return Result.Success();
|
|
}
|
|
}
|