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.
This commit is contained in:
@@ -84,9 +84,12 @@
|
|||||||
- Один образ: Api раздаёт REST (`/api`) и статику SPA из `wwwroot` (fallback на `index.html`).
|
- Один образ: Api раздаёт REST (`/api`) и статику SPA из `wwwroot` (fallback на `index.html`).
|
||||||
Один origin, база API — относительный `/api`.
|
Один origin, база API — относительный `/api`.
|
||||||
- Multi-stage Dockerfile: node (фронт) → dotnet sdk (publish + копирование в `wwwroot`) → aspnet
|
- Multi-stage Dockerfile: node (фронт) → dotnet sdk (publish + копирование в `wwwroot`) → aspnet
|
||||||
runtime. Никакого gosu/root-drop и Data Protection key-ring — секретов на диске пока нет
|
runtime. Контейнер работает под непривилегированным пользователем образа (`USER $APP_UID`,
|
||||||
(JWT-ключ — обычная конфигурация, не шифруемый секрет at-rest); если появятся зашифрованные
|
uid/gid 1654) — но **статически**, без gosu/entrypoint-скриптов и подгонки uid на старте: права
|
||||||
данные (например API-ключи внешних сервисов) — добавлять Data Protection по образцу PnvPanel.
|
на bind-mount хранилища выставляет оператор на хосте (см. `docs/server-storage-setup.md` § 5).
|
||||||
|
Data Protection key-ring не заводим — секретов на диске пока нет (JWT-ключ — обычная конфигурация,
|
||||||
|
не шифруемый секрет at-rest); появятся зашифрованные данные (например API-ключи внешних
|
||||||
|
сервисов) — добавлять Data Protection по образцу PnvPanel.
|
||||||
- docker-compose: только `app` — PostgreSQL живёт вне compose (внешний сервер/хост, адрес и
|
- docker-compose: только `app` — PostgreSQL живёт вне compose (внешний сервер/хост, адрес и
|
||||||
креды — в `ConnectionStrings__Default` из `.env`; база и пользователь на нём создаются
|
креды — в `ConnectionStrings__Default` из `.env`; база и пользователь на нём создаются
|
||||||
заранее вручную, приложение их не сидит). Миграции применяются авто на старте
|
заранее вручную, приложение их не сидит). Миграции применяются авто на старте
|
||||||
|
|||||||
+8
-1
@@ -5,7 +5,9 @@ FROM node:22-alpine AS frontend
|
|||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
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/ ./
|
COPY frontend/ ./
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
@@ -34,6 +36,11 @@ ENV ASPNETCORE_ENVIRONMENT=Production \
|
|||||||
ASPNETCORE_HTTP_PORTS=8080
|
ASPNETCORE_HTTP_PORTS=8080
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
COPY --from=backend /app/publish ./
|
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 \
|
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \
|
||||||
CMD curl -f http://localhost:8080/health || exit 1
|
CMD curl -f http://localhost:8080/health || exit 1
|
||||||
ENTRYPOINT ["dotnet", "TeleWave.Api.dll"]
|
ENTRYPOINT ["dotnet", "TeleWave.Api.dll"]
|
||||||
|
|||||||
@@ -42,6 +42,23 @@ docker compose up -d --build
|
|||||||
# → http://localhost:8085 (админ — логин/пароль из .env, AdminSeed__Username/Password)
|
# → 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
|
```bash
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using TeleWave.Api.Common;
|
using TeleWave.Api.Common;
|
||||||
@@ -19,8 +18,6 @@ namespace TeleWave.Api.Endpoints;
|
|||||||
|
|
||||||
public static class MediaEndpoints
|
public static class MediaEndpoints
|
||||||
{
|
{
|
||||||
private static readonly Regex SegmentFileName = new(@"^seg\d{1,6}\.ts$", RegexOptions.Compiled);
|
|
||||||
|
|
||||||
public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
var admin = app.MapGroup("/api/admin/media")
|
var admin = app.MapGroup("/api/admin/media")
|
||||||
@@ -177,16 +174,7 @@ public static class MediaEndpoints
|
|||||||
/// <summary>Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
|
/// <summary>Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
|
||||||
private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths)
|
private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths)
|
||||||
{
|
{
|
||||||
string indexPath;
|
if (SegmentFiles.TryResolveExisting(paths, id, "index.m3u8") is not { } indexPath)
|
||||||
try
|
|
||||||
{
|
|
||||||
indexPath = paths.SegmentPath(id, "index.m3u8");
|
|
||||||
}
|
|
||||||
catch (UnauthorizedAccessException)
|
|
||||||
{
|
|
||||||
return Results.NotFound();
|
|
||||||
}
|
|
||||||
if (!File.Exists(indexPath))
|
|
||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
|
|
||||||
var baseUrl = $"/api/admin/media/{id}/preview/";
|
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)
|
private static IResult PreviewSegment(Guid id, string file, MediaPathResolver paths)
|
||||||
{
|
{
|
||||||
if (!SegmentFileName.IsMatch(file))
|
if (!SegmentFiles.IsSegmentName(file))
|
||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
|
|
||||||
string path;
|
if (SegmentFiles.TryResolveExisting(paths, id, file) is not { } path)
|
||||||
try
|
|
||||||
{
|
|
||||||
path = paths.SegmentPath(id, file);
|
|
||||||
}
|
|
||||||
catch (UnauthorizedAccessException)
|
|
||||||
{
|
|
||||||
return Results.NotFound();
|
|
||||||
}
|
|
||||||
if (!File.Exists(path))
|
|
||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
|
|
||||||
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
|
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": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
{
|
{
|
||||||
"ConnectionStrings": {
|
|
||||||
"Default": "Host=localhost;Port=5432;Database=telewave;Username=telewave;Password=telewave"
|
|
||||||
},
|
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
"Issuer": "TeleWave",
|
"Issuer": "TeleWave",
|
||||||
"Audience": "TeleWave",
|
"Audience": "TeleWave",
|
||||||
|
|||||||
@@ -3,10 +3,16 @@ using System.Security.Cryptography;
|
|||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Детерминированный id ассета-превью для блока заставки: один и тот же на каждый повторный рендер,
|
/// Детерминированный id ассета-превью для подблока заставки: один и тот же на каждый повторный рендер,
|
||||||
/// поэтому предпросмотр перезаписывает единственный каталог assets/{id}, а не плодит новые.
|
/// поэтому предпросмотр перезаписывает единственный каталог assets/{id}, а не плодит новые.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class BumperPreview
|
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>
|
/// вытаскиваются из имён по мере надобности (сводка в списке шоу, метка в расписании админки).</summary>
|
||||||
public static class EpisodeName
|
public static class EpisodeName
|
||||||
{
|
{
|
||||||
|
/// <summary>Потолок на один Match. Шаблоны ниже линейные и в катастрофический бэктрекинг не уходят,
|
||||||
|
/// но имя файла приходит извне (загрузка, inbox), а разбор идёт в цикле по всей библиотеке —
|
||||||
|
/// страховка стоит ноль, а её отсутствие превращает любую будущую правку шаблона в риск.</summary>
|
||||||
|
private static readonly TimeSpan MatchTimeout = TimeSpan.FromSeconds(1);
|
||||||
|
|
||||||
private static readonly Regex SxxEyy = new(
|
private static readonly Regex SxxEyy = new(
|
||||||
@"[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})",
|
@"[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})",
|
||||||
RegexOptions.Compiled
|
RegexOptions.Compiled,
|
||||||
|
MatchTimeout
|
||||||
);
|
);
|
||||||
|
|
||||||
private static readonly Regex NxNN = new(
|
private static readonly Regex NxNN = new(
|
||||||
@"(?:^|[^\d])(\d{1,2})x(\d{1,3})",
|
@"(?:^|[^\d])(\d{1,2})x(\d{1,3})",
|
||||||
RegexOptions.Compiled | RegexOptions.IgnoreCase
|
RegexOptions.Compiled | RegexOptions.IgnoreCase,
|
||||||
|
MatchTimeout
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ведущий номер серии: «01. Название», «02 - Название», «03_Название» — сезон считаем первым.
|
// Ведущий номер серии: «01. Название», «02 - Название», «03_Название» — сезон считаем первым.
|
||||||
private static readonly Regex LeadingNumber = new(
|
private static readonly Regex LeadingNumber = new(
|
||||||
@"^\s*(\d{1,3})[\s._)\]-]",
|
@"^\s*(\d{1,3})[\s._)\]-]",
|
||||||
RegexOptions.Compiled
|
RegexOptions.Compiled,
|
||||||
|
MatchTimeout
|
||||||
);
|
);
|
||||||
|
|
||||||
public static (int Season, int Episode)? Parse(string? name)
|
public static (int Season, int Episode)? Parse(string? name)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Runbook: разметка, форматирование и монтирован
|
|||||||
> разделов), но каждый шаг ниже содержит проверку — не пропускайте их.
|
> разделов), но каждый шаг ниже содержит проверку — не пропускайте их.
|
||||||
|
|
||||||
Итог: раздел `sdb1` (ext4) смонтирован в `/srv/telewave/media`, внутри созданы рабочие каталоги,
|
Итог: раздел `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}
|
sudo mkdir -p /srv/telewave/media/{inbox,manual,uploads,originals,assets}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Владелец.** Контейнер сейчас работает под `root` (в Dockerfile нет `USER`), а bind-mount
|
**Владелец.** Контейнер работает под непривилегированным пользователем `app` образа aspnet —
|
||||||
пробрасывает права хоста внутрь как есть. Поэтому достаточно оставить владельцем root:
|
**uid/gid 1654** (`USER $APP_UID` в Dockerfile). Bind-mount пробрасывает права хоста внутрь как
|
||||||
|
есть, никакого маппинга uid не происходит: контейнер увидит ровно те номера, что стоят на хосте.
|
||||||
|
Значит, владельцем хранилища должен быть 1654.
|
||||||
|
|
||||||
|
Имени `app` на хосте нет — заводим группу с этим gid, чтобы права были читаемыми в `ls` и чтобы
|
||||||
|
в неё можно было добавить себя:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo chown -R root:root /srv/telewave/media
|
sudo groupadd -g 1654 telewave # «уже существует» — не ошибка, идём дальше
|
||||||
sudo chmod -R 755 /srv/telewave/media
|
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»), выбираются галочками и сразу привязываются к шоу. Импортированные
|
(Медиа → «Из папки manual»), выбираются галочками и сразу привязываются к шоу. Импортированные
|
||||||
файлы уходят из каталога так же, как из `inbox/`.
|
файлы уходят из каталога так же, как из `inbox/`.
|
||||||
|
|
||||||
Чтобы класть в них файлы вручную (SFTP/rsync) под своим пользователем, откройте на запись именно
|
Их открываем группе на запись, плюс setgid (`2` в начале режима) — чтобы файлы, положенные вами по
|
||||||
эти каталоги вашей группе:
|
SFTP/rsync, наследовали группу `telewave`, а не вашу личную, и приложение их видело:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo chown root:$(id -gn) /srv/telewave/media/inbox /srv/telewave/media/manual
|
sudo chmod 2775 /srv/telewave/media/inbox /srv/telewave/media/manual
|
||||||
sudo chmod 775 /srv/telewave/media/inbox /srv/telewave/media/manual
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> При переходе контейнера на non-root пользователя (если позже добавим `USER` в Dockerfile —
|
Забрать файл из каталога приложение сможет в любом случае: удаление зависит от прав на **каталог**
|
||||||
> в aspnet-образе это обычно uid `1654`), сменить владельца на этот uid:
|
(владелец — 1654), а не на сам файл, поэтому чужой umask у ваших загрузок импорту не мешает.
|
||||||
> `sudo chown -R 1654:1654 /srv/telewave/media` (кроме `inbox` и `manual`, оставленных вам).
|
|
||||||
|
**Обновление существующей установки.** Если хранилище было заведено раньше, когда контейнер работал
|
||||||
|
под root, — те же команды и есть вся миграция: выполните их на остановленном контейнере
|
||||||
|
(`docker compose down`), затем поднимайте новый образ. До смены владельца приложение стартует, но
|
||||||
|
любая запись в `/media` будет падать с `Permission denied`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -185,14 +204,24 @@ Docker — это важно, потому что файлы в `inbox/` кла
|
|||||||
|
|
||||||
## 7. Проверка записи из контейнера
|
## 7. Проверка записи из контейнера
|
||||||
|
|
||||||
После добавления тома и пересборки образа убедиться, что контейнер пишет на диск:
|
После добавления тома и пересборки образа убедиться, что контейнер работает под нужным uid и пишет
|
||||||
|
на диск:
|
||||||
|
|
||||||
```bash
|
```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'
|
docker compose exec app sh -c 'touch /media/.wtest && ls -l /media/.wtest && rm /media/.wtest'
|
||||||
```
|
```
|
||||||
|
|
||||||
Команда должна отработать без ошибок доступа. На этом подготовка хоста завершена — дальнейшее
|
И что вы сами можете класть файлы вручную (под своим пользователем, не через sudo):
|
||||||
(создание `MediaAsset`, нарезка ffmpeg) делает уже само приложение.
|
|
||||||
|
```bash
|
||||||
|
touch /srv/telewave/media/inbox/.wtest && rm /srv/telewave/media/inbox/.wtest
|
||||||
|
```
|
||||||
|
|
||||||
|
Обе команды должны отработать без ошибок доступа. `Permission denied` в первой — не сделан
|
||||||
|
`chown -R 1654:1654` из шага 5; во второй — вы не в группе `telewave` либо не перелогинились после
|
||||||
|
`usermod`. На этом подготовка хоста завершена — дальнейшее (создание `MediaAsset`, нарезка ffmpeg)
|
||||||
|
делает уже само приложение.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user