| | | 1 | | using Microsoft.Extensions.Hosting; |
| | | 2 | | using Microsoft.Extensions.Options; |
| | | 3 | | |
| | | 4 | | namespace Services.FileStorageServices; |
| | | 5 | | |
| | | 6 | | /// <summary>Настройки общего NFS-хранилища Studio2, опубликованного через Filebrowser.</summary> |
| | | 7 | | public sealed class ServerFileStorageOptions |
| | | 8 | | { |
| | | 9 | | public const string SectionName = "ServerFileStorage"; |
| | | 10 | | public string? RootPath { get; set; } |
| | | 11 | | public string? FileBrowserBaseUrl { get; set; } |
| | | 12 | | public long MaxUploadBytes { get; set; } = 1024L * 1024 * 1024; |
| | | 13 | | } |
| | | 14 | | |
| | | 15 | | public sealed record ServerFileUpload(string RelativePath, Stream Content, string? ContentType = null, bool Overwrite = |
| | | 16 | | public sealed record ServerFileInfo(string RelativePath, string Name, long Length, DateTimeOffset LastWriteTimeUtc, bool |
| | | 17 | | public sealed record ServerFileDownload(string RelativePath, Stream Content, long Length, string? ContentType); |
| | | 18 | | |
| | | 19 | | public interface IServerFileStorage |
| | | 20 | | { |
| | | 21 | | Task<ServerFileInfo> UploadAsync(ServerFileUpload upload, CancellationToken cancellationToken = default); |
| | | 22 | | Task<ServerFileDownload> OpenReadAsync(string relativePath, CancellationToken cancellationToken = default); |
| | | 23 | | Task<IReadOnlyList<ServerFileInfo>> ListAsync(string relativeDirectory = "", CancellationToken cancellationToken = d |
| | | 24 | | Task<bool> ExistsAsync(string relativePath, CancellationToken cancellationToken = default); |
| | | 25 | | Task DeleteAsync(string relativePath, CancellationToken cancellationToken = default); |
| | | 26 | | string GetPublicUrl(string relativePath); |
| | | 27 | | } |
| | | 28 | | |
| | | 29 | | /// <summary> |
| | | 30 | | /// Потоковый и безопасный доступ к общему серверному хранилищу. |
| | | 31 | | /// Все пути относительные: попытки выйти за RootPath блокируются до операции ввода-вывода. |
| | | 32 | | /// </summary> |
| | | 33 | | public sealed class ServerFileStorage : IServerFileStorage |
| | | 34 | | { |
| | | 35 | | private readonly ServerFileStorageOptions _options; |
| | | 36 | | private readonly string _rootPath; |
| | | 37 | | |
| | 10 | 38 | | public ServerFileStorage(IOptions<ServerFileStorageOptions> options, IHostEnvironment environment) |
| | | 39 | | { |
| | 10 | 40 | | _options = options.Value; |
| | 10 | 41 | | _rootPath = Path.GetFullPath(string.IsNullOrWhiteSpace(_options.RootPath) |
| | 10 | 42 | | ? Path.Combine(environment.ContentRootPath, "server-files") |
| | 10 | 43 | | : _options.RootPath!); |
| | 10 | 44 | | Directory.CreateDirectory(_rootPath); |
| | 10 | 45 | | } |
| | | 46 | | |
| | | 47 | | public async Task<ServerFileInfo> UploadAsync(ServerFileUpload upload, CancellationToken cancellationToken = default |
| | | 48 | | { |
| | 11 | 49 | | ArgumentNullException.ThrowIfNull(upload); |
| | 11 | 50 | | ArgumentNullException.ThrowIfNull(upload.Content); |
| | 11 | 51 | | var relativePath = NormalizeFilePath(upload.RelativePath); |
| | 8 | 52 | | var targetPath = ToPhysicalPath(relativePath); |
| | 8 | 53 | | Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); |
| | 8 | 54 | | if (!upload.Overwrite && File.Exists(targetPath)) |
| | 0 | 55 | | throw new IOException($"Файл «{relativePath}» уже существует."); |
| | | 56 | | |
| | 8 | 57 | | var temporaryPath = targetPath + ".upload-" + Guid.NewGuid().ToString("N"); |
| | 8 | 58 | | long total = 0; |
| | | 59 | | try |
| | | 60 | | { |
| | 8 | 61 | | await using (var output = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None |
| | 8 | 62 | | bufferSize: 81920, FileOptions.Asynchronous | FileOptions.SequentialScan)) |
| | | 63 | | { |
| | 8 | 64 | | var buffer = new byte[81920]; |
| | | 65 | | int read; |
| | 16 | 66 | | while ((read = await upload.Content.ReadAsync(buffer, cancellationToken)) > 0) |
| | | 67 | | { |
| | 8 | 68 | | total += read; |
| | 8 | 69 | | if (total > _options.MaxUploadBytes) |
| | 0 | 70 | | throw new InvalidOperationException($"Размер файла превышает лимит {_options.MaxUploadBytes} бай |
| | 8 | 71 | | await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken); |
| | | 72 | | } |
| | 8 | 73 | | } |
| | 8 | 74 | | File.Move(temporaryPath, targetPath, upload.Overwrite); |
| | 8 | 75 | | } |
| | 0 | 76 | | catch |
| | | 77 | | { |
| | 0 | 78 | | File.Delete(temporaryPath); |
| | 0 | 79 | | throw; |
| | | 80 | | } |
| | | 81 | | |
| | 8 | 82 | | return ToInfo(new FileInfo(targetPath), relativePath); |
| | 8 | 83 | | } |
| | | 84 | | |
| | | 85 | | public Task<ServerFileDownload> OpenReadAsync(string relativePath, CancellationToken cancellationToken = default) |
| | | 86 | | { |
| | 4 | 87 | | cancellationToken.ThrowIfCancellationRequested(); |
| | 4 | 88 | | var normalized = NormalizeFilePath(relativePath); |
| | 4 | 89 | | var path = ToPhysicalPath(normalized); |
| | 4 | 90 | | var info = new FileInfo(path); |
| | 5 | 91 | | if (!info.Exists) throw new FileNotFoundException("Файл не найден в серверном хранилище.", normalized); |
| | 3 | 92 | | Stream stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, |
| | 3 | 93 | | bufferSize: 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); |
| | 3 | 94 | | return Task.FromResult(new ServerFileDownload(normalized, stream, info.Length, null)); |
| | | 95 | | } |
| | | 96 | | |
| | | 97 | | public Task<IReadOnlyList<ServerFileInfo>> ListAsync(string relativeDirectory = "", CancellationToken cancellationTo |
| | | 98 | | { |
| | 2 | 99 | | cancellationToken.ThrowIfCancellationRequested(); |
| | 2 | 100 | | var normalized = NormalizeDirectoryPath(relativeDirectory); |
| | 2 | 101 | | var path = ToPhysicalPath(normalized); |
| | 2 | 102 | | if (!Directory.Exists(path)) return Task.FromResult<IReadOnlyList<ServerFileInfo>>([]); |
| | 2 | 103 | | var items = new DirectoryInfo(path).EnumerateFileSystemInfos() |
| | 6 | 104 | | .OrderByDescending(x => x is DirectoryInfo).ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase) |
| | 3 | 105 | | .Select(item => item is FileInfo file |
| | 3 | 106 | | ? ToInfo(file, Combine(normalized, file.Name)) |
| | 3 | 107 | | : ToDirectoryInfo((DirectoryInfo)item, Combine(normalized, item.Name))) |
| | 2 | 108 | | .ToList(); |
| | 2 | 109 | | return Task.FromResult<IReadOnlyList<ServerFileInfo>>(items); |
| | | 110 | | } |
| | | 111 | | |
| | | 112 | | public Task<bool> ExistsAsync(string relativePath, CancellationToken cancellationToken = default) |
| | | 113 | | { |
| | 5 | 114 | | cancellationToken.ThrowIfCancellationRequested(); |
| | 5 | 115 | | var path = ToPhysicalPath(NormalizeFilePath(relativePath)); |
| | 5 | 116 | | return Task.FromResult(File.Exists(path)); |
| | | 117 | | } |
| | | 118 | | |
| | | 119 | | public Task DeleteAsync(string relativePath, CancellationToken cancellationToken = default) |
| | | 120 | | { |
| | 3 | 121 | | cancellationToken.ThrowIfCancellationRequested(); |
| | 3 | 122 | | var path = ToPhysicalPath(NormalizeFilePath(relativePath)); |
| | 6 | 123 | | if (File.Exists(path)) File.Delete(path); |
| | 3 | 124 | | return Task.CompletedTask; |
| | | 125 | | } |
| | | 126 | | |
| | | 127 | | public string GetPublicUrl(string relativePath) |
| | | 128 | | { |
| | 11 | 129 | | var normalized = NormalizeFilePath(relativePath); |
| | 11 | 130 | | if (string.IsNullOrWhiteSpace(_options.FileBrowserBaseUrl)) return string.Empty; |
| | 11 | 131 | | return _options.FileBrowserBaseUrl!.TrimEnd('/') + "/" + string.Join('/', normalized.Split('/').Select(Uri.Escap |
| | | 132 | | } |
| | | 133 | | |
| | | 134 | | private ServerFileInfo ToInfo(FileInfo info, string relativePath) => |
| | 11 | 135 | | new(relativePath, info.Name, info.Length, info.LastWriteTimeUtc, false, GetPublicUrl(relativePath)); |
| | | 136 | | |
| | | 137 | | private ServerFileInfo ToDirectoryInfo(DirectoryInfo info, string relativePath) => |
| | 0 | 138 | | new(relativePath, info.Name, 0, info.LastWriteTimeUtc, true, GetPublicUrl(relativePath)); |
| | | 139 | | |
| | | 140 | | private string ToPhysicalPath(string relativePath) |
| | | 141 | | { |
| | 22 | 142 | | var path = Path.GetFullPath(Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar))); |
| | 22 | 143 | | if (!path.StartsWith(_rootPath + Path.DirectorySeparatorChar, StringComparison.Ordinal) && !string.Equals(path, |
| | 0 | 144 | | throw new InvalidOperationException("Недопустимый путь к файлу."); |
| | 22 | 145 | | return path; |
| | | 146 | | } |
| | | 147 | | |
| | | 148 | | private static string NormalizeFilePath(string path) |
| | | 149 | | { |
| | 34 | 150 | | var normalized = Normalize(path); |
| | 31 | 151 | | if (string.IsNullOrEmpty(normalized) || normalized.EndsWith('/')) throw new ArgumentException("Нужен относительн |
| | 31 | 152 | | return normalized; |
| | | 153 | | } |
| | | 154 | | |
| | 2 | 155 | | private static string NormalizeDirectoryPath(string path) => string.IsNullOrWhiteSpace(path) ? string.Empty : Normal |
| | | 156 | | |
| | | 157 | | private static string Normalize(string path) |
| | | 158 | | { |
| | 36 | 159 | | ArgumentException.ThrowIfNullOrWhiteSpace(path); |
| | 37 | 160 | | if (Path.IsPathRooted(path)) throw new ArgumentException("Путь должен быть относительным.", nameof(path)); |
| | 35 | 161 | | var normalized = path.Replace('\\', '/').Trim('/'); |
| | 109 | 162 | | if (normalized.Split('/').Any(x => x is "." or ".." || string.IsNullOrWhiteSpace(x))) |
| | 2 | 163 | | throw new ArgumentException("Путь не должен содержать . или ...", nameof(path)); |
| | 33 | 164 | | return normalized; |
| | | 165 | | } |
| | | 166 | | |
| | 3 | 167 | | private static string Combine(string directory, string name) => string.IsNullOrEmpty(directory) ? name : directory + |
| | | 168 | | } |