using Microsoft.Extensions.Options; namespace TeleWave.Infrastructure.Media; /// /// Единая точка резолва путей хранилища + защита от path traversal. Любой путь, собранный из /// внешних данных (имя загруженного файла, имя из inbox/), проверяется на нахождение внутри корня. /// public sealed class MediaPathResolver { private readonly string _root; public MediaPathResolver(IOptions options) { _root = Path.GetFullPath(options.Value.RootPath); InboxDir = Path.Combine(_root, "inbox"); UploadsDir = Path.Combine(_root, "uploads"); OriginalsDir = Path.Combine(_root, "originals"); AssetsDir = Path.Combine(_root, "assets"); BumpersDir = Path.Combine(_root, "bumpers"); MetadataDir = Path.Combine(_root, "metadata"); ImagesDir = Path.Combine(_root, "images"); } public string InboxDir { get; } public string UploadsDir { get; } public string OriginalsDir { get; } public string AssetsDir { get; } /// Сырые файлы шаблонов заставок (фон/музыка) по каналам — не режутся на HLS. public string BumpersDir { get; } /// Картинки метаданных (постеры/кадры), скачанные локально. public string MetadataDir { get; } /// Общий реестр изображений (галерея): файлы images/{imageId}{ext}. public string ImagesDir { get; } public void EnsureDirectories() { Directory.CreateDirectory(InboxDir); Directory.CreateDirectory(UploadsDir); Directory.CreateDirectory(OriginalsDir); Directory.CreateDirectory(AssetsDir); Directory.CreateDirectory(BumpersDir); Directory.CreateDirectory(MetadataDir); Directory.CreateDirectory(ImagesDir); } public string MetadataShowDir(Guid showId) => EnsureWithinRoot(Path.Combine(MetadataDir, "shows", showId.ToString("N"))); /// Абсолютный путь к файлу постера шоу (extension — с точкой). public string MetadataShowPosterPath(Guid showId, string extension) => EnsureWithinRoot( Path.Combine(MetadataDir, "shows", showId.ToString("N"), "poster" + extension) ); /// Относительный путь постера от корня (для хранения в БД и отдачи). public string MetadataShowPosterRelative(Guid showId, string extension) => $"metadata/shows/{showId:N}/poster{extension}"; public string MetadataEpisodeDir(Guid episodeId) => EnsureWithinRoot(Path.Combine(MetadataDir, "episodes", episodeId.ToString("N"))); public string MetadataEpisodeStillPath(Guid episodeId, string extension) => EnsureWithinRoot( Path.Combine(MetadataDir, "episodes", episodeId.ToString("N"), "still" + extension) ); public string MetadataEpisodeStillRelative(Guid episodeId, string extension) => $"metadata/episodes/{episodeId:N}/still{extension}"; /// Абсолютный путь по относительному (с защитой от traversal) или null, если вне корня. public string? ResolveRelative(string relativePath) { try { return EnsureWithinRoot(Path.Combine(_root, relativePath)); } catch (UnauthorizedAccessException) { return null; } } public string BumperTemplateDir(Guid templateId) => EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N"))); /// Путь к файлу блока заставки (kind — «audio»/«background», extension — с точкой). public string BumperTemplateFilePath(Guid templateId, string kind, string extension) => EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N"), kind + extension)); /// Путь к файлу изображения общего реестра (extension — с точкой). 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 string AssetRelativePath(Guid assetId) => $"assets/{assetId:N}"; /// Путь к файлу сегмента внутри каталога ассета (имя файла проверяется на traversal). public string SegmentPath(Guid assetId, string fileName) { var assetDir = AssetDir(assetId); return EnsureWithin(assetDir, Path.Combine(assetDir, fileName)); } /// Резолвит имя файла внутри uploads/ (токен загрузки), проверяя выход за пределы каталога. public string UploadPath(string token) => EnsureWithin(UploadsDir, Path.Combine(UploadsDir, token)); /// Резолвит имя файла внутри inbox/, проверяя выход за пределы каталога. public string InboxPath(string fileName) => EnsureWithin(InboxDir, Path.Combine(InboxDir, fileName)); 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; } }