diff --git a/CLAUDE.md b/CLAUDE.md index 99ab2a4..406d96e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,9 +84,12 @@ - Один образ: Api раздаёт REST (`/api`) и статику SPA из `wwwroot` (fallback на `index.html`). Один origin, база API — относительный `/api`. - Multi-stage Dockerfile: node (фронт) → dotnet sdk (publish + копирование в `wwwroot`) → aspnet - runtime. Никакого gosu/root-drop и Data Protection key-ring — секретов на диске пока нет - (JWT-ключ — обычная конфигурация, не шифруемый секрет at-rest); если появятся зашифрованные - данные (например API-ключи внешних сервисов) — добавлять Data Protection по образцу PnvPanel. + runtime. Контейнер работает под непривилегированным пользователем образа (`USER $APP_UID`, + uid/gid 1654) — но **статически**, без gosu/entrypoint-скриптов и подгонки uid на старте: права + на bind-mount хранилища выставляет оператор на хосте (см. `docs/server-storage-setup.md` § 5). + Data Protection key-ring не заводим — секретов на диске пока нет (JWT-ключ — обычная конфигурация, + не шифруемый секрет at-rest); появятся зашифрованные данные (например API-ключи внешних + сервисов) — добавлять Data Protection по образцу PnvPanel. - docker-compose: только `app` — PostgreSQL живёт вне compose (внешний сервер/хост, адрес и креды — в `ConnectionStrings__Default` из `.env`; база и пользователь на нём создаются заранее вручную, приложение их не сидит). Миграции применяются авто на старте diff --git a/Dockerfile b/Dockerfile index 705a372..0689a75 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,9 @@ FROM node:22-alpine AS frontend WORKDIR /app/frontend RUN corepack enable COPY frontend/package.json frontend/pnpm-lock.yaml ./ -RUN corepack prepare pnpm@11.9.0 --activate && pnpm install --frozen-lockfile +# --ignore-scripts: ни один пакет фронта не требует postinstall, поэтому lifecycle-скрипты зависимостей +# в сборке не выполняются — компрометация любого пакета в дереве не даёт исполнения кода на этапе install. +RUN corepack prepare pnpm@11.9.0 --activate && pnpm install --frozen-lockfile --ignore-scripts COPY frontend/ ./ RUN pnpm build @@ -34,6 +36,11 @@ ENV ASPNETCORE_ENVIRONMENT=Production \ ASPNETCORE_HTTP_PORTS=8080 EXPOSE 8080 COPY --from=backend /app/publish ./ +# Работаем под непривилегированным пользователем образа aspnet (app, uid/gid 1654 — $APP_UID задан +# в самом образе). Порт 8080 непривилегированный, установка пакетов уже позади, приложению нужна +# только запись в /media. ВАЖНО: bind-mount пробрасывает права хоста как есть — каталог хранилища +# должен принадлежать uid 1654, иначе контейнер не сможет писать (см. docs/server-storage-setup.md § 5). +USER $APP_UID HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \ CMD curl -f http://localhost:8080/health || exit 1 ENTRYPOINT ["dotnet", "TeleWave.Api.dll"] diff --git a/README.md b/README.md index 581f937..035e73a 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,23 @@ docker compose up -d --build # → http://localhost:8085 (админ — логин/пароль из .env, AdminSeed__Username/Password) ``` +**Права на медиахранилище.** Контейнер работает под непривилегированным пользователем (uid/gid +`1654`), а bind-mount пробрасывает права хоста как есть — каталог хранилища должен принадлежать +этому uid, иначе приложение стартует, но любая запись в `/media` упадёт с `Permission denied`. +Полная процедура (включая группу, через которую вы сами кладёте файлы в `inbox/` и `manual/`) — +[`docs/server-storage-setup.md`](docs/server-storage-setup.md), шаг 5: + +```bash +sudo groupadd -g 1654 telewave +sudo usermod -aG telewave "$USER" # затем перелогиниться +sudo chown -R 1654:1654 /srv/telewave/media +sudo chmod -R 750 /srv/telewave/media +sudo chmod 2775 /srv/telewave/media/inbox /srv/telewave/media/manual +``` + +> Обновляетесь с версии, где контейнер работал под root? Это и есть вся миграция: остановите +> контейнер (`docker compose down`), выполните команды выше, поднимайте новый образ. + ## Локальная разработка ```bash diff --git a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs index f2c1d33..6af816e 100644 --- a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs @@ -1,5 +1,4 @@ using System.Text; -using System.Text.RegularExpressions; using LiteCqrs; using Microsoft.Extensions.Options; using TeleWave.Api.Common; @@ -19,8 +18,6 @@ namespace TeleWave.Api.Endpoints; public static class MediaEndpoints { - private static readonly Regex SegmentFileName = new(@"^seg\d{1,6}\.ts$", RegexOptions.Compiled); - public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app) { var admin = app.MapGroup("/api/admin/media") @@ -177,16 +174,7 @@ public static class MediaEndpoints /// Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут. private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths) { - string indexPath; - try - { - indexPath = paths.SegmentPath(id, "index.m3u8"); - } - catch (UnauthorizedAccessException) - { - return Results.NotFound(); - } - if (!File.Exists(indexPath)) + if (SegmentFiles.TryResolveExisting(paths, id, "index.m3u8") is not { } indexPath) return Results.NotFound(); var baseUrl = $"/api/admin/media/{id}/preview/"; @@ -206,19 +194,10 @@ public static class MediaEndpoints private static IResult PreviewSegment(Guid id, string file, MediaPathResolver paths) { - if (!SegmentFileName.IsMatch(file)) + if (!SegmentFiles.IsSegmentName(file)) return Results.NotFound(); - string path; - try - { - path = paths.SegmentPath(id, file); - } - catch (UnauthorizedAccessException) - { - return Results.NotFound(); - } - if (!File.Exists(path)) + if (SegmentFiles.TryResolveExisting(paths, id, file) is not { } path) return Results.NotFound(); return Results.File(path, "video/mp2t", enableRangeProcessing: true); diff --git a/backend/src/TeleWave.Api/appsettings.Development.json b/backend/src/TeleWave.Api/appsettings.Development.json index 09b30c4..60a2cdf 100644 --- a/backend/src/TeleWave.Api/appsettings.Development.json +++ b/backend/src/TeleWave.Api/appsettings.Development.json @@ -1,4 +1,10 @@ { + // Локальная БД разработчика. В appsettings.json строки подключения нет намеренно: этот файл + // едет в образ, и креды в нём (даже заведомо игрушечные) — это и находка сканера, и приглашение + // однажды поправить их «на месте» вместо ConnectionStrings__Default из окружения. + "ConnectionStrings": { + "Default": "Host=localhost;Port=5432;Database=telewave;Username=telewave;Password=telewave" + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/backend/src/TeleWave.Api/appsettings.json b/backend/src/TeleWave.Api/appsettings.json index 8ac69ee..f7db417 100644 --- a/backend/src/TeleWave.Api/appsettings.json +++ b/backend/src/TeleWave.Api/appsettings.json @@ -1,7 +1,4 @@ { - "ConnectionStrings": { - "Default": "Host=localhost;Port=5432;Database=telewave;Username=telewave;Password=telewave" - }, "Jwt": { "Issuer": "TeleWave", "Audience": "TeleWave", diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPreview.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPreview.cs index 881061b..9dc4784 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPreview.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPreview.cs @@ -3,10 +3,16 @@ using System.Security.Cryptography; namespace TeleWave.Application.Broadcast.Bumpers; /// -/// Детерминированный id ассета-превью для блока заставки: один и тот же на каждый повторный рендер, +/// Детерминированный id ассета-превью для подблока заставки: один и тот же на каждый повторный рендер, /// поэтому предпросмотр перезаписывает единственный каталог assets/{id}, а не плодит новые. /// public static class BumperPreview { - public static Guid AssetId(Guid templateId) => new(MD5.HashData(templateId.ToByteArray())); + /// + /// Свёртка id подблока в стабильный id превью. Не защита — просто способ получить из одного GUID + /// другой, воспроизводимо; берём SHA-256 и первые 16 байт, чтобы в коде не оставалось вызовов + /// сломанных хеш-функций, которые потом приходится каждый раз объяснять сканерам. + /// + public static Guid AssetId(Guid variantId) => + new(SHA256.HashData(variantId.ToByteArray()).AsSpan(0, 16)); } diff --git a/backend/src/TeleWave.Application/Library/EpisodeName.cs b/backend/src/TeleWave.Application/Library/EpisodeName.cs index 0129e75..93dcef5 100644 --- a/backend/src/TeleWave.Application/Library/EpisodeName.cs +++ b/backend/src/TeleWave.Application/Library/EpisodeName.cs @@ -7,20 +7,28 @@ namespace TeleWave.Application.Library; /// вытаскиваются из имён по мере надобности (сводка в списке шоу, метка в расписании админки). public static class EpisodeName { + /// Потолок на один Match. Шаблоны ниже линейные и в катастрофический бэктрекинг не уходят, + /// но имя файла приходит извне (загрузка, inbox), а разбор идёт в цикле по всей библиотеке — + /// страховка стоит ноль, а её отсутствие превращает любую будущую правку шаблона в риск. + private static readonly TimeSpan MatchTimeout = TimeSpan.FromSeconds(1); + private static readonly Regex SxxEyy = new( @"[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})", - RegexOptions.Compiled + RegexOptions.Compiled, + MatchTimeout ); private static readonly Regex NxNN = new( @"(?:^|[^\d])(\d{1,2})x(\d{1,3})", - RegexOptions.Compiled | RegexOptions.IgnoreCase + RegexOptions.Compiled | RegexOptions.IgnoreCase, + MatchTimeout ); // Ведущий номер серии: «01. Название», «02 - Название», «03_Название» — сезон считаем первым. private static readonly Regex LeadingNumber = new( @"^\s*(\d{1,3})[\s._)\]-]", - RegexOptions.Compiled + RegexOptions.Compiled, + MatchTimeout ); public static (int Season, int Episode)? Parse(string? name) diff --git a/docs/server-storage-setup.md b/docs/server-storage-setup.md index 442a719..0e1f5bf 100644 --- a/docs/server-storage-setup.md +++ b/docs/server-storage-setup.md @@ -8,7 +8,7 @@ Runbook: разметка, форматирование и монтирован > разделов), но каждый шаг ниже содержит проверку — не пропускайте их. Итог: раздел `sdb1` (ext4) смонтирован в `/srv/telewave/media`, внутри созданы рабочие каталоги, -запись из контейнера (сейчас работает под root) возможна. +запись из контейнера (работает под непривилегированным uid 1654) возможна. --- @@ -106,12 +106,28 @@ findmnt /srv/telewave/media sudo mkdir -p /srv/telewave/media/{inbox,manual,uploads,originals,assets} ``` -**Владелец.** Контейнер сейчас работает под `root` (в Dockerfile нет `USER`), а bind-mount -пробрасывает права хоста внутрь как есть. Поэтому достаточно оставить владельцем root: +**Владелец.** Контейнер работает под непривилегированным пользователем `app` образа aspnet — +**uid/gid 1654** (`USER $APP_UID` в Dockerfile). Bind-mount пробрасывает права хоста внутрь как +есть, никакого маппинга uid не происходит: контейнер увидит ровно те номера, что стоят на хосте. +Значит, владельцем хранилища должен быть 1654. + +Имени `app` на хосте нет — заводим группу с этим gid, чтобы права были читаемыми в `ls` и чтобы +в неё можно было добавить себя: ```bash -sudo chown -R root:root /srv/telewave/media -sudo chmod -R 755 /srv/telewave/media +sudo groupadd -g 1654 telewave # «уже существует» — не ошибка, идём дальше +sudo usermod -aG telewave "$USER" # чтобы класть файлы в inbox/manual под собой +``` + +> Членство в группе применяется **только к новым сессиям**: перелогиньтесь (или `newgrp telewave`), +> иначе следующая команда отработает, а записать файл вы всё равно не сможете. Проверка — `id` +> должен показать `telewave` в списке групп. + +Теперь владелец и базовые права: + +```bash +sudo chown -R 1654:1654 /srv/telewave/media +sudo chmod -R 750 /srv/telewave/media ``` Два каталога наполняете вы, а не приложение: @@ -122,17 +138,20 @@ sudo chmod -R 755 /srv/telewave/media (Медиа → «Из папки manual»), выбираются галочками и сразу привязываются к шоу. Импортированные файлы уходят из каталога так же, как из `inbox/`. -Чтобы класть в них файлы вручную (SFTP/rsync) под своим пользователем, откройте на запись именно -эти каталоги вашей группе: +Их открываем группе на запись, плюс setgid (`2` в начале режима) — чтобы файлы, положенные вами по +SFTP/rsync, наследовали группу `telewave`, а не вашу личную, и приложение их видело: ```bash -sudo chown root:$(id -gn) /srv/telewave/media/inbox /srv/telewave/media/manual -sudo chmod 775 /srv/telewave/media/inbox /srv/telewave/media/manual +sudo chmod 2775 /srv/telewave/media/inbox /srv/telewave/media/manual ``` -> При переходе контейнера на non-root пользователя (если позже добавим `USER` в Dockerfile — -> в aspnet-образе это обычно uid `1654`), сменить владельца на этот uid: -> `sudo chown -R 1654:1654 /srv/telewave/media` (кроме `inbox` и `manual`, оставленных вам). +Забрать файл из каталога приложение сможет в любом случае: удаление зависит от прав на **каталог** +(владелец — 1654), а не на сам файл, поэтому чужой umask у ваших загрузок импорту не мешает. + +**Обновление существующей установки.** Если хранилище было заведено раньше, когда контейнер работал +под root, — те же команды и есть вся миграция: выполните их на остановленном контейнере +(`docker compose down`), затем поднимайте новый образ. До смены владельца приложение стартует, но +любая запись в `/media` будет падать с `Permission denied`. --- @@ -185,14 +204,24 @@ Docker — это важно, потому что файлы в `inbox/` кла ## 7. Проверка записи из контейнера -После добавления тома и пересборки образа убедиться, что контейнер пишет на диск: +После добавления тома и пересборки образа убедиться, что контейнер работает под нужным uid и пишет +на диск: ```bash +docker compose exec app id # ожидается uid=1654 gid=1654 docker compose exec app sh -c 'touch /media/.wtest && ls -l /media/.wtest && rm /media/.wtest' ``` -Команда должна отработать без ошибок доступа. На этом подготовка хоста завершена — дальнейшее -(создание `MediaAsset`, нарезка ffmpeg) делает уже само приложение. +И что вы сами можете класть файлы вручную (под своим пользователем, не через sudo): + +```bash +touch /srv/telewave/media/inbox/.wtest && rm /srv/telewave/media/inbox/.wtest +``` + +Обе команды должны отработать без ошибок доступа. `Permission denied` в первой — не сделан +`chown -R 1654:1654` из шага 5; во второй — вы не в группе `telewave` либо не перелогинились после +`usermod`. На этом подготовка хоста завершена — дальнейшее (создание `MediaAsset`, нарезка ffmpeg) +делает уже само приложение. ---