< Summary

Information
Class: Services.FileStorageServices.ServerFileStorageOptions
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/FileStorageServices/ServerFileStorage.cs
Line coverage
100%
Covered lines: 3
Uncovered lines: 0
Coverable lines: 3
Total lines: 168
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_RootPath()100%11100%
get_FileBrowserBaseUrl()100%11100%
get_MaxUploadBytes()100%11100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/FileStorageServices/ServerFileStorage.cs

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