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)
+делает уже само приложение.
---