Repository navigation
Expand file tree
/
Copy pathFileSystemStorage.cs
More file actions
175 lines (142 loc) · 6.13 KB
/
Copy pathFileSystemStorage.cs
File metadata and controls
175 lines (142 loc) · 6.13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
using Sa.Data.TempFolder;
using Sa.HybridFileStorage.Domain;
using System.Runtime.CompilerServices;
namespace Sa.HybridFileStorage.FileSystem;
/// <summary>
/// fs://share/tenant/filename
/// </summary>
/// <remarks>
/// All file I/O — writing, reading and deleting — is delegated to the keyed
/// <see cref="ITempFolder"/> this storage was registered with (see
/// <see cref="Setup.AddSaFileSystemFileStorage"/>), so the storage no longer opens
/// <c>FileStream</c>s or calls <c>File.Delete</c> itself: path guarding, cleanup activity and
/// retries belong to the temp folder, and this type only maps a file ID to a relative path and
/// shapes the result.
/// </remarks>
internal sealed class FileSystemStorage(
ITempFolder tempFolder,
FileSystemStorageOptions options,
TimeProvider? timeProvider = null) : IFileStorage
{
private const string SchemeSeparator = "://";
// The temp folder hands out a fully qualified root; the trim keeps the prefix comparisons
// below free of a trailing separator.
private readonly string _root = Path.TrimEndingDirectorySeparator(tempFolder.RootPath);
private readonly string _basePathScope = Path.TrimEndingDirectorySeparator(
Path.Combine(tempFolder.RootPath, options.Basket));
private readonly string _schemePrefix = $"{options.StorageType}{SchemeSeparator}";
private readonly string _storageType = options.StorageType;
private readonly bool _isReadOnly = options.IsReadOnly;
private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
public string Basket => options.Basket;
public string StorageType => _storageType;
public bool IsReadOnly => _isReadOnly;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void EnsureWritable()
{
if (_isReadOnly)
HybridFileStorageThrowHelper.ThrowWritableException();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool CanProcess(string? fileId)
{
if (string.IsNullOrEmpty(fileId)) return false;
if (!fileId.AsSpan().StartsWith(_schemePrefix.AsSpan(), StringComparison.Ordinal))
return false;
return IsPathWithinBase(GetFullPathFast(fileId));
}
public async Task<StorageResult> UploadAsync(
UploadFileInput metadata,
Stream fileStream,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fileStream);
EnsureWritable();
metadata.Validate();
string filename = PathSanitizer.SanitizeRelativePath(metadata.FileName);
// The layout and the file ID stay exactly as before: {Basket}/{tenant}/{file}, joined
// with forward slashes so the fs:// prefix reads the same on every platform. The temp
// folder resolves and guards the path against its root.
string relativePath = string.Concat(Basket, "/", metadata.TenantId.ToString(), "/", filename);
var (_, absolutePath) = await tempFolder
.WriteAsync(fileStream, relativePath, cancellationToken)
.ConfigureAwait(false);
return new StorageResult(
FileId: string.Concat(_schemePrefix, relativePath),
AbsoluteUrl: absolutePath,
StorageType: _storageType,
UploadedAt: _timeProvider.GetUtcNow());
}
public Task<bool> DownloadAsync(
string fileId,
Func<Stream, CancellationToken, Task> loadStream,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fileId);
ArgumentNullException.ThrowIfNull(loadStream);
if (!CanProcess(fileId))
return Task.FromResult(false);
// The temp folder already reads a missing file as `false` and rejects a directory path;
// a non-existent file therefore keeps the provider's historical "return false" contract.
return tempFolder.ReadAsync(FileIdToPath(fileId), loadStream, cancellationToken);
}
public async Task<bool> DeleteAsync(string fileId, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fileId);
EnsureWritable();
if (!CanProcess(fileId))
return false;
try
{
return await tempFolder
.DeleteFileAsync(FileIdToPath(fileId), cancellationToken)
.ConfigureAwait(false);
}
catch (IOException)
{
// A transient failure that survived the temp folder's retries reads as "not deleted"
// here — the same contract the provider had when it deleted files itself.
return false;
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private string GetFullPathFast(string fileId)
{
var relativePath = FileIdToPath(fileId);
return Path.Combine(_root, relativePath);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static string FileIdToPath(string fileId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(fileId);
ReadOnlySpan<char> span = fileId.AsSpan();
int separatorIndex = span.IndexOf(SchemeSeparator.AsSpan());
if (separatorIndex == -1)
HybridFileStorageThrowHelper.ThrowInvalidFileIdFormat();
return span[(separatorIndex + SchemeSeparator.Length)..].ToString();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private bool IsPathWithinBase(string path)
{
return Path.GetFullPath(path)
.AsSpan()
.StartsWith(_basePathScope.AsSpan(), StringComparison.Ordinal);
}
public Task<FileMetadata?> GetMetadataAsync(
string fileId,
CancellationToken cancellationToken = default)
{
if (!CanProcess(fileId))
return Task.FromResult<FileMetadata?>(null);
if (!FileIdParser.TryParse(fileId, out var basket, out var tenantId, out _, out var fileName))
return Task.FromResult<FileMetadata?>(null);
var metadata = new FileMetadata
{
StorageType = StorageType,
Basket = basket,
FileName = fileName,
TenantId = tenantId
};
return Task.FromResult<FileMetadata?>(metadata);
}
}