Normalize line endings to LF via .gitattributes
Репозиторий хранил фронтенд в CRLF, а часть бэкенда — вперемешку, хотя CI и Docker-сборка работают под Linux. Прибиваем LF атрибутом `* text=auto eol=lf` и разово нормализуем дерево, чтобы форматтеры не переписывали файлы целиком на каждом прогоне. Коммит чисто механический: изменений содержимого нет, только концы строк. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9d1c6d2fc3
commit
0442056367
@@ -1,171 +1,171 @@
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Media;
|
||||
using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Infrastructure.Media;
|
||||
|
||||
/// <summary>Файловая реализация <see cref="IMediaStorage"/> поверх <see cref="MediaPathResolver"/>.</summary>
|
||||
public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStorage
|
||||
{
|
||||
private const int CopyBufferSize = 1024 * 1024;
|
||||
|
||||
public long GetAvailableFreeSpaceBytes()
|
||||
{
|
||||
try
|
||||
{
|
||||
return new DriveInfo(paths.AssetsDir).AvailableFreeSpace;
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or IOException)
|
||||
{
|
||||
// Не блокируем загрузку, если ФС не отдаёт метрику (например экзотическая точка монтирования).
|
||||
return long.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> SaveUploadAsync(
|
||||
Stream content,
|
||||
string extension,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
Directory.CreateDirectory(paths.UploadsDir);
|
||||
var token = Guid.NewGuid().ToString("N") + extension.ToLowerInvariant();
|
||||
var path = paths.UploadPath(token);
|
||||
|
||||
await using var file = new FileStream(
|
||||
path,
|
||||
FileMode.CreateNew,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
CopyBufferSize,
|
||||
useAsync: true
|
||||
);
|
||||
await content.CopyToAsync(file, CopyBufferSize, cancellationToken);
|
||||
return token;
|
||||
}
|
||||
|
||||
public Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = paths.UploadPath(uploadToken);
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public IReadOnlyList<IMediaStorage.ManualInboxFile> ListManualInbox(int max)
|
||||
{
|
||||
if (!Directory.Exists(paths.ManualDir))
|
||||
return [];
|
||||
|
||||
return Directory
|
||||
.EnumerateFiles(paths.ManualDir, "*", SearchOption.AllDirectories)
|
||||
.Take(Math.Max(1, max))
|
||||
.Select(path => new FileInfo(path))
|
||||
.Where(file => file.Exists)
|
||||
.Select(file => new IMediaStorage.ManualInboxFile(
|
||||
// Разделитель нормализуем: путь уезжает в URL и обратно приходит строкой запроса.
|
||||
Path.GetRelativePath(paths.ManualDir, file.FullName).Replace('\\', '/'),
|
||||
file.Name,
|
||||
file.Length
|
||||
))
|
||||
.OrderBy(f => f.RelativePath, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public Task CleanupManualLeftoversAsync(
|
||||
string relativePath,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var path = paths.ManualPath(relativePath);
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
if (directory is null || !Directory.Exists(directory))
|
||||
return Task.CompletedTask;
|
||||
|
||||
// Спутник — файл, чьё имя начинается с имени забранного (без расширения) и точки:
|
||||
// так ловятся и «Серия.srt», и «Серия.ru.srt». Видеофайлы исключены намеренно —
|
||||
// «Серия.Extended.mkv» это не мусор, а другой материал.
|
||||
// Отбор — своим сравнением, а не маской поиска: в имени файла на Linux законно встречается
|
||||
// «*», и маска захватила бы чужие файлы. Код удаляет — он обязан быть буквальным.
|
||||
var prefix = Path.GetFileNameWithoutExtension(path) + ".";
|
||||
foreach (var sibling in Directory.EnumerateFiles(directory))
|
||||
{
|
||||
var name = Path.GetFileName(sibling);
|
||||
if (!name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
if (MediaFormats.IsAllowed(name))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
File.Delete(sibling);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Файл занят или уже удалён — не повод валить импорт целиком.
|
||||
}
|
||||
}
|
||||
|
||||
// Опустевший подкаталог тоже мусор. Корень manual/ не трогаем: он нужен всегда.
|
||||
if (
|
||||
!string.Equals(directory, paths.ManualDir, StringComparison.Ordinal)
|
||||
&& !Directory.EnumerateFileSystemEntries(directory).Any()
|
||||
)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(directory);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Каталог занят — оставим как есть.
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task PromoteToOriginalAsync(
|
||||
MediaSource source,
|
||||
string sourceToken,
|
||||
Guid assetId,
|
||||
string extension,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var sourcePath = source switch
|
||||
{
|
||||
MediaSource.Inbox => paths.InboxPath(sourceToken),
|
||||
MediaSource.ManualInbox => paths.ManualPath(sourceToken),
|
||||
_ => paths.UploadPath(sourceToken),
|
||||
};
|
||||
|
||||
if (!File.Exists(sourcePath))
|
||||
throw new FileNotFoundException("Исходный файл не найден в хранилище.", sourcePath);
|
||||
|
||||
Directory.CreateDirectory(paths.OriginalsDir);
|
||||
var destination = paths.OriginalPath(assetId, extension);
|
||||
File.Move(sourcePath, destination, overwrite: true);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAssetArtifactsAsync(
|
||||
Guid assetId,
|
||||
string extension,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
// Каталог сегментов удаляется рекурсивно (может быть много .ts) — офлоадим с вызывающего потока
|
||||
// (запрос/фоновый сервис), чтобы не блокировать его на время файлового I/O.
|
||||
Task.Run(
|
||||
() =>
|
||||
{
|
||||
var original = paths.OriginalPath(assetId, extension);
|
||||
if (File.Exists(original))
|
||||
File.Delete(original);
|
||||
|
||||
var assetDir = paths.AssetDir(assetId);
|
||||
if (Directory.Exists(assetDir))
|
||||
Directory.Delete(assetDir, recursive: true);
|
||||
},
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Media;
|
||||
using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Infrastructure.Media;
|
||||
|
||||
/// <summary>Файловая реализация <see cref="IMediaStorage"/> поверх <see cref="MediaPathResolver"/>.</summary>
|
||||
public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStorage
|
||||
{
|
||||
private const int CopyBufferSize = 1024 * 1024;
|
||||
|
||||
public long GetAvailableFreeSpaceBytes()
|
||||
{
|
||||
try
|
||||
{
|
||||
return new DriveInfo(paths.AssetsDir).AvailableFreeSpace;
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or IOException)
|
||||
{
|
||||
// Не блокируем загрузку, если ФС не отдаёт метрику (например экзотическая точка монтирования).
|
||||
return long.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> SaveUploadAsync(
|
||||
Stream content,
|
||||
string extension,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
Directory.CreateDirectory(paths.UploadsDir);
|
||||
var token = Guid.NewGuid().ToString("N") + extension.ToLowerInvariant();
|
||||
var path = paths.UploadPath(token);
|
||||
|
||||
await using var file = new FileStream(
|
||||
path,
|
||||
FileMode.CreateNew,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
CopyBufferSize,
|
||||
useAsync: true
|
||||
);
|
||||
await content.CopyToAsync(file, CopyBufferSize, cancellationToken);
|
||||
return token;
|
||||
}
|
||||
|
||||
public Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = paths.UploadPath(uploadToken);
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public IReadOnlyList<IMediaStorage.ManualInboxFile> ListManualInbox(int max)
|
||||
{
|
||||
if (!Directory.Exists(paths.ManualDir))
|
||||
return [];
|
||||
|
||||
return Directory
|
||||
.EnumerateFiles(paths.ManualDir, "*", SearchOption.AllDirectories)
|
||||
.Take(Math.Max(1, max))
|
||||
.Select(path => new FileInfo(path))
|
||||
.Where(file => file.Exists)
|
||||
.Select(file => new IMediaStorage.ManualInboxFile(
|
||||
// Разделитель нормализуем: путь уезжает в URL и обратно приходит строкой запроса.
|
||||
Path.GetRelativePath(paths.ManualDir, file.FullName).Replace('\\', '/'),
|
||||
file.Name,
|
||||
file.Length
|
||||
))
|
||||
.OrderBy(f => f.RelativePath, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public Task CleanupManualLeftoversAsync(
|
||||
string relativePath,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var path = paths.ManualPath(relativePath);
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
if (directory is null || !Directory.Exists(directory))
|
||||
return Task.CompletedTask;
|
||||
|
||||
// Спутник — файл, чьё имя начинается с имени забранного (без расширения) и точки:
|
||||
// так ловятся и «Серия.srt», и «Серия.ru.srt». Видеофайлы исключены намеренно —
|
||||
// «Серия.Extended.mkv» это не мусор, а другой материал.
|
||||
// Отбор — своим сравнением, а не маской поиска: в имени файла на Linux законно встречается
|
||||
// «*», и маска захватила бы чужие файлы. Код удаляет — он обязан быть буквальным.
|
||||
var prefix = Path.GetFileNameWithoutExtension(path) + ".";
|
||||
foreach (var sibling in Directory.EnumerateFiles(directory))
|
||||
{
|
||||
var name = Path.GetFileName(sibling);
|
||||
if (!name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
if (MediaFormats.IsAllowed(name))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
File.Delete(sibling);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Файл занят или уже удалён — не повод валить импорт целиком.
|
||||
}
|
||||
}
|
||||
|
||||
// Опустевший подкаталог тоже мусор. Корень manual/ не трогаем: он нужен всегда.
|
||||
if (
|
||||
!string.Equals(directory, paths.ManualDir, StringComparison.Ordinal)
|
||||
&& !Directory.EnumerateFileSystemEntries(directory).Any()
|
||||
)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(directory);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Каталог занят — оставим как есть.
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task PromoteToOriginalAsync(
|
||||
MediaSource source,
|
||||
string sourceToken,
|
||||
Guid assetId,
|
||||
string extension,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var sourcePath = source switch
|
||||
{
|
||||
MediaSource.Inbox => paths.InboxPath(sourceToken),
|
||||
MediaSource.ManualInbox => paths.ManualPath(sourceToken),
|
||||
_ => paths.UploadPath(sourceToken),
|
||||
};
|
||||
|
||||
if (!File.Exists(sourcePath))
|
||||
throw new FileNotFoundException("Исходный файл не найден в хранилище.", sourcePath);
|
||||
|
||||
Directory.CreateDirectory(paths.OriginalsDir);
|
||||
var destination = paths.OriginalPath(assetId, extension);
|
||||
File.Move(sourcePath, destination, overwrite: true);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAssetArtifactsAsync(
|
||||
Guid assetId,
|
||||
string extension,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
// Каталог сегментов удаляется рекурсивно (может быть много .ts) — офлоадим с вызывающего потока
|
||||
// (запрос/фоновый сервис), чтобы не блокировать его на время файлового I/O.
|
||||
Task.Run(
|
||||
() =>
|
||||
{
|
||||
var original = paths.OriginalPath(assetId, extension);
|
||||
if (File.Exists(original))
|
||||
File.Delete(original);
|
||||
|
||||
var assetDir = paths.AssetDir(assetId);
|
||||
if (Directory.Exists(assetDir))
|
||||
Directory.Delete(assetDir, recursive: true);
|
||||
},
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,111 +1,111 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace TeleWave.Infrastructure.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Единая точка резолва путей хранилища + защита от path traversal. Любой путь, собранный из
|
||||
/// внешних данных (имя загруженного файла, имя из inbox/), проверяется на нахождение внутри корня.
|
||||
/// </summary>
|
||||
public sealed class MediaPathResolver
|
||||
{
|
||||
private readonly string _root;
|
||||
|
||||
public MediaPathResolver(IOptions<StorageOptions> options)
|
||||
{
|
||||
_root = Path.GetFullPath(options.Value.RootPath);
|
||||
InboxDir = Path.Combine(_root, "inbox");
|
||||
ManualDir = Path.Combine(_root, "manual");
|
||||
UploadsDir = Path.Combine(_root, "uploads");
|
||||
OriginalsDir = Path.Combine(_root, "originals");
|
||||
AssetsDir = Path.Combine(_root, "assets");
|
||||
BumpersDir = Path.Combine(_root, "bumpers");
|
||||
ImagesDir = Path.Combine(_root, "images");
|
||||
}
|
||||
|
||||
public string InboxDir { get; }
|
||||
|
||||
/// <summary>Ручной inbox: сканером не разбирается, файлы забирает админ из UI сразу в шоу.</summary>
|
||||
public string ManualDir { get; }
|
||||
public string UploadsDir { get; }
|
||||
public string OriginalsDir { get; }
|
||||
public string AssetsDir { get; }
|
||||
|
||||
/// <summary>Сырые файлы шаблонов заставок (фон/музыка) по каналам — не режутся на HLS.</summary>
|
||||
public string BumpersDir { get; }
|
||||
|
||||
/// <summary>Общий реестр изображений (галерея): файлы images/{imageId}{ext}.</summary>
|
||||
public string ImagesDir { get; }
|
||||
|
||||
public void EnsureDirectories()
|
||||
{
|
||||
Directory.CreateDirectory(InboxDir);
|
||||
Directory.CreateDirectory(ManualDir);
|
||||
Directory.CreateDirectory(UploadsDir);
|
||||
Directory.CreateDirectory(OriginalsDir);
|
||||
Directory.CreateDirectory(AssetsDir);
|
||||
Directory.CreateDirectory(BumpersDir);
|
||||
Directory.CreateDirectory(ImagesDir);
|
||||
}
|
||||
|
||||
public string BumperTemplateDir(Guid templateId) =>
|
||||
EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N")));
|
||||
|
||||
/// <summary>Путь к файлу блока заставки (kind — «audio»/«background», extension — с точкой).</summary>
|
||||
public string BumperTemplateFilePath(Guid templateId, string kind, string extension) =>
|
||||
EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N"), kind + extension));
|
||||
|
||||
/// <summary>Путь к файлу изображения общего реестра (extension — с точкой).</summary>
|
||||
public string ImagePath(Guid imageId, string extension) =>
|
||||
EnsureWithinRoot(Path.Combine(ImagesDir, imageId.ToString("N") + extension));
|
||||
|
||||
public string OriginalPath(Guid assetId, string extension) =>
|
||||
EnsureWithinRoot(Path.Combine(OriginalsDir, assetId.ToString("N") + extension));
|
||||
|
||||
public string AssetDir(Guid assetId) =>
|
||||
EnsureWithinRoot(Path.Combine(AssetsDir, assetId.ToString("N")));
|
||||
|
||||
public static string AssetRelativePath(Guid assetId) => $"assets/{assetId:N}";
|
||||
|
||||
/// <summary>Путь к файлу сегмента внутри каталога ассета (имя файла проверяется на traversal).</summary>
|
||||
public string SegmentPath(Guid assetId, string fileName)
|
||||
{
|
||||
var assetDir = AssetDir(assetId);
|
||||
return EnsureWithin(assetDir, Path.Combine(assetDir, fileName));
|
||||
}
|
||||
|
||||
/// <summary>Резолвит имя файла внутри uploads/ (токен загрузки), проверяя выход за пределы каталога.</summary>
|
||||
public string UploadPath(string token) =>
|
||||
EnsureWithin(UploadsDir, Path.Combine(UploadsDir, token));
|
||||
|
||||
/// <summary>Резолвит имя файла внутри inbox/, проверяя выход за пределы каталога.</summary>
|
||||
public string InboxPath(string fileName) =>
|
||||
EnsureWithin(InboxDir, Path.Combine(InboxDir, fileName));
|
||||
|
||||
/// <summary>
|
||||
/// Резолвит путь внутри manual/. Путь относительный и может содержать подкаталоги — качалки
|
||||
/// раскладывают файлы по папкам, — поэтому проверка на выход за пределы каталога здесь
|
||||
/// обязательна: строка приходит из запроса.
|
||||
/// </summary>
|
||||
public string ManualPath(string relativePath) =>
|
||||
EnsureWithin(ManualDir, Path.Combine(ManualDir, relativePath));
|
||||
|
||||
private string EnsureWithinRoot(string candidate) => EnsureWithin(_root, candidate);
|
||||
|
||||
private static string EnsureWithin(string baseDir, string candidate)
|
||||
{
|
||||
var full = Path.GetFullPath(candidate);
|
||||
var normalizedBase = baseDir.EndsWith(Path.DirectorySeparatorChar)
|
||||
? baseDir
|
||||
: baseDir + Path.DirectorySeparatorChar;
|
||||
|
||||
if (
|
||||
!full.StartsWith(normalizedBase, StringComparison.Ordinal)
|
||||
&& !string.Equals(full, baseDir, StringComparison.Ordinal)
|
||||
)
|
||||
throw new UnauthorizedAccessException(
|
||||
$"Путь '{candidate}' выходит за пределы каталога хранилища."
|
||||
);
|
||||
|
||||
return full;
|
||||
}
|
||||
}
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace TeleWave.Infrastructure.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Единая точка резолва путей хранилища + защита от path traversal. Любой путь, собранный из
|
||||
/// внешних данных (имя загруженного файла, имя из inbox/), проверяется на нахождение внутри корня.
|
||||
/// </summary>
|
||||
public sealed class MediaPathResolver
|
||||
{
|
||||
private readonly string _root;
|
||||
|
||||
public MediaPathResolver(IOptions<StorageOptions> options)
|
||||
{
|
||||
_root = Path.GetFullPath(options.Value.RootPath);
|
||||
InboxDir = Path.Combine(_root, "inbox");
|
||||
ManualDir = Path.Combine(_root, "manual");
|
||||
UploadsDir = Path.Combine(_root, "uploads");
|
||||
OriginalsDir = Path.Combine(_root, "originals");
|
||||
AssetsDir = Path.Combine(_root, "assets");
|
||||
BumpersDir = Path.Combine(_root, "bumpers");
|
||||
ImagesDir = Path.Combine(_root, "images");
|
||||
}
|
||||
|
||||
public string InboxDir { get; }
|
||||
|
||||
/// <summary>Ручной inbox: сканером не разбирается, файлы забирает админ из UI сразу в шоу.</summary>
|
||||
public string ManualDir { get; }
|
||||
public string UploadsDir { get; }
|
||||
public string OriginalsDir { get; }
|
||||
public string AssetsDir { get; }
|
||||
|
||||
/// <summary>Сырые файлы шаблонов заставок (фон/музыка) по каналам — не режутся на HLS.</summary>
|
||||
public string BumpersDir { get; }
|
||||
|
||||
/// <summary>Общий реестр изображений (галерея): файлы images/{imageId}{ext}.</summary>
|
||||
public string ImagesDir { get; }
|
||||
|
||||
public void EnsureDirectories()
|
||||
{
|
||||
Directory.CreateDirectory(InboxDir);
|
||||
Directory.CreateDirectory(ManualDir);
|
||||
Directory.CreateDirectory(UploadsDir);
|
||||
Directory.CreateDirectory(OriginalsDir);
|
||||
Directory.CreateDirectory(AssetsDir);
|
||||
Directory.CreateDirectory(BumpersDir);
|
||||
Directory.CreateDirectory(ImagesDir);
|
||||
}
|
||||
|
||||
public string BumperTemplateDir(Guid templateId) =>
|
||||
EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N")));
|
||||
|
||||
/// <summary>Путь к файлу блока заставки (kind — «audio»/«background», extension — с точкой).</summary>
|
||||
public string BumperTemplateFilePath(Guid templateId, string kind, string extension) =>
|
||||
EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N"), kind + extension));
|
||||
|
||||
/// <summary>Путь к файлу изображения общего реестра (extension — с точкой).</summary>
|
||||
public string ImagePath(Guid imageId, string extension) =>
|
||||
EnsureWithinRoot(Path.Combine(ImagesDir, imageId.ToString("N") + extension));
|
||||
|
||||
public string OriginalPath(Guid assetId, string extension) =>
|
||||
EnsureWithinRoot(Path.Combine(OriginalsDir, assetId.ToString("N") + extension));
|
||||
|
||||
public string AssetDir(Guid assetId) =>
|
||||
EnsureWithinRoot(Path.Combine(AssetsDir, assetId.ToString("N")));
|
||||
|
||||
public static string AssetRelativePath(Guid assetId) => $"assets/{assetId:N}";
|
||||
|
||||
/// <summary>Путь к файлу сегмента внутри каталога ассета (имя файла проверяется на traversal).</summary>
|
||||
public string SegmentPath(Guid assetId, string fileName)
|
||||
{
|
||||
var assetDir = AssetDir(assetId);
|
||||
return EnsureWithin(assetDir, Path.Combine(assetDir, fileName));
|
||||
}
|
||||
|
||||
/// <summary>Резолвит имя файла внутри uploads/ (токен загрузки), проверяя выход за пределы каталога.</summary>
|
||||
public string UploadPath(string token) =>
|
||||
EnsureWithin(UploadsDir, Path.Combine(UploadsDir, token));
|
||||
|
||||
/// <summary>Резолвит имя файла внутри inbox/, проверяя выход за пределы каталога.</summary>
|
||||
public string InboxPath(string fileName) =>
|
||||
EnsureWithin(InboxDir, Path.Combine(InboxDir, fileName));
|
||||
|
||||
/// <summary>
|
||||
/// Резолвит путь внутри manual/. Путь относительный и может содержать подкаталоги — качалки
|
||||
/// раскладывают файлы по папкам, — поэтому проверка на выход за пределы каталога здесь
|
||||
/// обязательна: строка приходит из запроса.
|
||||
/// </summary>
|
||||
public string ManualPath(string relativePath) =>
|
||||
EnsureWithin(ManualDir, Path.Combine(ManualDir, relativePath));
|
||||
|
||||
private string EnsureWithinRoot(string candidate) => EnsureWithin(_root, candidate);
|
||||
|
||||
private static string EnsureWithin(string baseDir, string candidate)
|
||||
{
|
||||
var full = Path.GetFullPath(candidate);
|
||||
var normalizedBase = baseDir.EndsWith(Path.DirectorySeparatorChar)
|
||||
? baseDir
|
||||
: baseDir + Path.DirectorySeparatorChar;
|
||||
|
||||
if (
|
||||
!full.StartsWith(normalizedBase, StringComparison.Ordinal)
|
||||
&& !string.Equals(full, baseDir, StringComparison.Ordinal)
|
||||
)
|
||||
throw new UnauthorizedAccessException(
|
||||
$"Путь '{candidate}' выходит за пределы каталога хранилища."
|
||||
);
|
||||
|
||||
return full;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user