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:
Leonid Pershin
2026-07-27 01:37:33 +03:00
co-authored by Claude Opus 5
parent 9d1c6d2fc3
commit 0442056367
109 changed files with 13469 additions and 13459 deletions
@@ -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;
}
}