Refactor Dockerfile and documentation for non-root user setup. Update CLAUDE.md and README.md to clarify media storage permissions and user requirements. Modify appsettings for local development database configuration. Enhance MediaEndpoints by removing unused regex and improving file resolution logic. Update BumperPreview to use SHA-256 for asset ID generation, ensuring better security practices.
ci / build-backend (push) Successful in 1m33s
ci / build-frontend (push) Successful in 1m2s
ci / tests (push) Successful in 1m52s
ci / sonar (push) Successful in 4m24s

This commit is contained in:
Leonid Pershin
2026-07-26 21:41:33 +03:00
parent e79769be81
commit b52080e026
9 changed files with 103 additions and 51 deletions
+6 -3
View File
@@ -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`; база и пользователь на нём создаются
заранее вручную, приложение их не сидит). Миграции применяются авто на старте
+8 -1
View File
@@ -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"]
+17
View File
@@ -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
@@ -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
/// <summary>Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
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);
@@ -1,4 +1,10 @@
{
// Локальная БД разработчика. В appsettings.json строки подключения нет намеренно: этот файл
// едет в образ, и креды в нём (даже заведомо игрушечные) — это и находка сканера, и приглашение
// однажды поправить их «на месте» вместо ConnectionStrings__Default из окружения.
"ConnectionStrings": {
"Default": "Host=localhost;Port=5432;Database=telewave;Username=telewave;Password=telewave"
},
"Logging": {
"LogLevel": {
"Default": "Information",
@@ -1,7 +1,4 @@
{
"ConnectionStrings": {
"Default": "Host=localhost;Port=5432;Database=telewave;Username=telewave;Password=telewave"
},
"Jwt": {
"Issuer": "TeleWave",
"Audience": "TeleWave",
@@ -3,10 +3,16 @@ using System.Security.Cryptography;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>
/// Детерминированный id ассета-превью для блока заставки: один и тот же на каждый повторный рендер,
/// Детерминированный id ассета-превью для подблока заставки: один и тот же на каждый повторный рендер,
/// поэтому предпросмотр перезаписывает единственный каталог assets/{id}, а не плодит новые.
/// </summary>
public static class BumperPreview
{
public static Guid AssetId(Guid templateId) => new(MD5.HashData(templateId.ToByteArray()));
/// <summary>
/// Свёртка id подблока в стабильный id превью. Не защита — просто способ получить из одного GUID
/// другой, воспроизводимо; берём SHA-256 и первые 16 байт, чтобы в коде не оставалось вызовов
/// сломанных хеш-функций, которые потом приходится каждый раз объяснять сканерам.
/// </summary>
public static Guid AssetId(Guid variantId) =>
new(SHA256.HashData(variantId.ToByteArray()).AsSpan(0, 16));
}
@@ -7,20 +7,28 @@ namespace TeleWave.Application.Library;
/// вытаскиваются из имён по мере надобности (сводка в списке шоу, метка в расписании админки).</summary>
public static class EpisodeName
{
/// <summary>Потолок на один Match. Шаблоны ниже линейные и в катастрофический бэктрекинг не уходят,
/// но имя файла приходит извне (загрузка, inbox), а разбор идёт в цикле по всей библиотеке —
/// страховка стоит ноль, а её отсутствие превращает любую будущую правку шаблона в риск.</summary>
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)
+44 -15
View File
@@ -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)
делает уже само приложение.
---