< Summary

Information
Class: Services.FileStorageServices.ServerFileStorage
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/FileStorageServices/ServerFileStorage.cs
Line coverage
91%
Covered lines: 71
Uncovered lines: 7
Coverable lines: 78
Total lines: 168
Line coverage: 91%
Branch coverage
71%
Covered branches: 30
Total branches: 42
Branch coverage: 71.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)50%22100%
UploadAsync()80%111080%
OpenReadAsync(...)100%22100%
ListAsync(...)50%44100%
ExistsAsync(...)100%11100%
DeleteAsync(...)100%22100%
GetPublicUrl(...)50%22100%
ToInfo(...)100%11100%
ToDirectoryInfo(...)100%210%
ToPhysicalPath(...)50%4475%
NormalizeFilePath(...)50%44100%
NormalizeDirectoryPath(...)50%22100%
Normalize(...)100%88100%
Combine(...)50%22100%

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";
 10    public string? RootPath { get; set; }
 11    public string? FileBrowserBaseUrl { get; set; }
 12    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
 1038    public ServerFileStorage(IOptions<ServerFileStorageOptions> options, IHostEnvironment environment)
 39    {
 1040        _options = options.Value;
 1041        _rootPath = Path.GetFullPath(string.IsNullOrWhiteSpace(_options.RootPath)
 1042            ? Path.Combine(environment.ContentRootPath, "server-files")
 1043            : _options.RootPath!);
 1044        Directory.CreateDirectory(_rootPath);
 1045    }
 46
 47    public async Task<ServerFileInfo> UploadAsync(ServerFileUpload upload, CancellationToken cancellationToken = default
 48    {
 1149        ArgumentNullException.ThrowIfNull(upload);
 1150        ArgumentNullException.ThrowIfNull(upload.Content);
 1151        var relativePath = NormalizeFilePath(upload.RelativePath);
 852        var targetPath = ToPhysicalPath(relativePath);
 853        Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
 854        if (!upload.Overwrite && File.Exists(targetPath))
 055            throw new IOException($"Файл «{relativePath}» уже существует.");
 56
 857        var temporaryPath = targetPath + ".upload-" + Guid.NewGuid().ToString("N");
 858        long total = 0;
 59        try
 60        {
 861            await using (var output = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None
 862                             bufferSize: 81920, FileOptions.Asynchronous | FileOptions.SequentialScan))
 63            {
 864                var buffer = new byte[81920];
 65                int read;
 1666                while ((read = await upload.Content.ReadAsync(buffer, cancellationToken)) > 0)
 67                {
 868                    total += read;
 869                    if (total > _options.MaxUploadBytes)
 070                        throw new InvalidOperationException($"Размер файла превышает лимит {_options.MaxUploadBytes} бай
 871                    await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
 72                }
 873            }
 874            File.Move(temporaryPath, targetPath, upload.Overwrite);
 875        }
 076        catch
 77        {
 078            File.Delete(temporaryPath);
 079            throw;
 80        }
 81
 882        return ToInfo(new FileInfo(targetPath), relativePath);
 883    }
 84
 85    public Task<ServerFileDownload> OpenReadAsync(string relativePath, CancellationToken cancellationToken = default)
 86    {
 487        cancellationToken.ThrowIfCancellationRequested();
 488        var normalized = NormalizeFilePath(relativePath);
 489        var path = ToPhysicalPath(normalized);
 490        var info = new FileInfo(path);
 591        if (!info.Exists) throw new FileNotFoundException("Файл не найден в серверном хранилище.", normalized);
 392        Stream stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
 393            bufferSize: 81920, FileOptions.Asynchronous | FileOptions.SequentialScan);
 394        return Task.FromResult(new ServerFileDownload(normalized, stream, info.Length, null));
 95    }
 96
 97    public Task<IReadOnlyList<ServerFileInfo>> ListAsync(string relativeDirectory = "", CancellationToken cancellationTo
 98    {
 299        cancellationToken.ThrowIfCancellationRequested();
 2100        var normalized = NormalizeDirectoryPath(relativeDirectory);
 2101        var path = ToPhysicalPath(normalized);
 2102        if (!Directory.Exists(path)) return Task.FromResult<IReadOnlyList<ServerFileInfo>>([]);
 2103        var items = new DirectoryInfo(path).EnumerateFileSystemInfos()
 6104            .OrderByDescending(x => x is DirectoryInfo).ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
 3105            .Select(item => item is FileInfo file
 3106                ? ToInfo(file, Combine(normalized, file.Name))
 3107                : ToDirectoryInfo((DirectoryInfo)item, Combine(normalized, item.Name)))
 2108            .ToList();
 2109        return Task.FromResult<IReadOnlyList<ServerFileInfo>>(items);
 110    }
 111
 112    public Task<bool> ExistsAsync(string relativePath, CancellationToken cancellationToken = default)
 113    {
 5114        cancellationToken.ThrowIfCancellationRequested();
 5115        var path = ToPhysicalPath(NormalizeFilePath(relativePath));
 5116        return Task.FromResult(File.Exists(path));
 117    }
 118
 119    public Task DeleteAsync(string relativePath, CancellationToken cancellationToken = default)
 120    {
 3121        cancellationToken.ThrowIfCancellationRequested();
 3122        var path = ToPhysicalPath(NormalizeFilePath(relativePath));
 6123        if (File.Exists(path)) File.Delete(path);
 3124        return Task.CompletedTask;
 125    }
 126
 127    public string GetPublicUrl(string relativePath)
 128    {
 11129        var normalized = NormalizeFilePath(relativePath);
 11130        if (string.IsNullOrWhiteSpace(_options.FileBrowserBaseUrl)) return string.Empty;
 11131        return _options.FileBrowserBaseUrl!.TrimEnd('/') + "/" + string.Join('/', normalized.Split('/').Select(Uri.Escap
 132    }
 133
 134    private ServerFileInfo ToInfo(FileInfo info, string relativePath) =>
 11135        new(relativePath, info.Name, info.Length, info.LastWriteTimeUtc, false, GetPublicUrl(relativePath));
 136
 137    private ServerFileInfo ToDirectoryInfo(DirectoryInfo info, string relativePath) =>
 0138        new(relativePath, info.Name, 0, info.LastWriteTimeUtc, true, GetPublicUrl(relativePath));
 139
 140    private string ToPhysicalPath(string relativePath)
 141    {
 22142        var path = Path.GetFullPath(Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar)));
 22143        if (!path.StartsWith(_rootPath + Path.DirectorySeparatorChar, StringComparison.Ordinal) && !string.Equals(path, 
 0144            throw new InvalidOperationException("Недопустимый путь к файлу.");
 22145        return path;
 146    }
 147
 148    private static string NormalizeFilePath(string path)
 149    {
 34150        var normalized = Normalize(path);
 31151        if (string.IsNullOrEmpty(normalized) || normalized.EndsWith('/')) throw new ArgumentException("Нужен относительн
 31152        return normalized;
 153    }
 154
 2155    private static string NormalizeDirectoryPath(string path) => string.IsNullOrWhiteSpace(path) ? string.Empty : Normal
 156
 157    private static string Normalize(string path)
 158    {
 36159        ArgumentException.ThrowIfNullOrWhiteSpace(path);
 37160        if (Path.IsPathRooted(path)) throw new ArgumentException("Путь должен быть относительным.", nameof(path));
 35161        var normalized = path.Replace('\\', '/').Trim('/');
 109162        if (normalized.Split('/').Any(x => x is "." or ".." || string.IsNullOrWhiteSpace(x)))
 2163            throw new ArgumentException("Путь не должен содержать . или ...", nameof(path));
 33164        return normalized;
 165    }
 166
 3167    private static string Combine(string directory, string name) => string.IsNullOrEmpty(directory) ? name : directory +
 168}