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");
}
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; }
public void EnsureDirectories()
{
Directory.CreateDirectory(InboxDir);
Directory.CreateDirectory(UploadsDir);
Directory.CreateDirectory(OriginalsDir);
Directory.CreateDirectory(AssetsDir);
Directory.CreateDirectory(BumpersDir);
Directory.CreateDirectory(MetadataDir);
}
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 BumperChannelDir(Guid channelId) =>
EnsureWithinRoot(Path.Combine(BumpersDir, channelId.ToString("N")));
/// Путь к файлу шаблона заставки (kind — «background»/«music», extension — с точкой).
public string BumperFilePath(Guid channelId, string kind, string extension) =>
EnsureWithinRoot(Path.Combine(BumpersDir, channelId.ToString("N"), kind + 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;
}
}