Контент, Strapi и фотографии в S3
Оглавление · Далее: инфраструктура
Единственный редакционный источник
Сайт генерирует страницы из опубликованных документов Strapi при сборке и обновляет их через ISR после webhook. Новостей, событий, партнёров и слайдов в исходном JSON приложения нет. Готовые страницы хранятся в кеше Next.js; ошибка CMS не подменяется пустыми данными. Шрифт и CSS остаются ресурсами приложения.
Исходник схемы
flowchart LR Editor[Редактор] --> CMS[Strapi: Draft and Publish] CMS --> DB[(PostgreSQL)] CMS -->|upload provider| S3[Garage: bucket media] Web[Next.js server] -->|read-only token / tagged cache| CMS Web -->|Zod contracts| HTML[Страница или tRPC] Browser[Браузер] -->|/api/media/filename| Web Web -->|фиксированный Host| S3
Страницы используют серверный repository напрямую; tRPC предоставляет те же данные
внешнему типизированному клиенту. Webhook обновляет кеш Next.js напрямую без Hatchet.
React cache дополнительно объединяет повторные чтения внутри render.
Уведомление об изменениях
В Settings → Webhooks создайте webhook с URL
http://web:3000/api/webhooks/strapi для Docker Compose или URL сайта с этим путём
для внешнего вызова. Добавьте header Authorization: Bearer <STRAPI_WEBHOOK_SECRET>.
Включите события entry create/update/delete/publish/unpublish. Секрет должен
совпадать с environment Next.js. Проверка возвращает 200 {"revalidated":true}.
Для автоматического обновления webhook нужно сохранить и включить в CMS;
без него работает только страховочная ревалидация раз в час.
Модели и связи
| Модель | Редактируемые данные |
|---|---|
| News | title, slug, summary, category, publishedOn, content Blocks, cover, gallery |
| Event | те же тексты/media, startsAt, nullable endsAt, location, price |
| Page | title, slug, summary, content, cover, gallery, order |
| FAQ | question, answer Blocks, order |
| Partner | title, href, logo, group, order |
| Site, single type | название, описание, даты, статус, место, copyright, logo/favicon, slides, awards, navigation, sections |
Все редакционные модели используют Draft & Publish. Site.sections содержит
компоненты со связью на Page; слайды ссылаются на media. Информационные страницы
доступны по /history, /traditions, /directions, /placement, /rules.
sourceId и sourceUrl хранят происхождение импорта. Это технические поля, не текст
для посетителя. Служебная коллекция Import asset связывает source URL/checksum с
upload file и не имеет публичных API routes. Не редактируйте её вручную.
Старые news.publicationDate и body сохранены аддитивно. Новый frontend использует
content, а дату берёт из publishedOn; для старой CMS-записи допустима дата
publicationDate в той же CMS. Это не fallback на файлы приложения.
Локальная подготовка
pnpm install --frozen-lockfile
pnpm setup
pnpm infra:up
pnpm cms:up
pnpm cms:token
pnpm cms:snapshot
pnpm cms:dry-run
pnpm cms:import
pnpm dev
Strapi доступен на http://127.0.0.1:1337/admin. При первом открытии создайте администратора
через обычную форму CMS. Read-only token создаётся независимо от этого аккаунта:
команда использует стандартный Content API token service закреплённой версии Strapi.
Token сохраняется в игнорируемый .env с правами 0600 и не печатается.
Для полного стека создайте также Hatchet token: pnpm infra:token, затем
pnpm stack:up. Сайт будет на http://localhost:8080, CMS — http://cms.localhost:8080/admin,
фотографии — на /api/media/... сайта или http://media.localhost:8080/strapi/filename.
cms:import останавливает работающую локальную CMS, запускает один отдельный процесс
импорта и возвращает CMS после завершения. Не запускайте два импорта одновременно.
Во время этого шага сайт временно не может читать CMS. Dry-run не останавливает CMS.
Снимок оригинального сайта
Источник — публичный API https://etnosportapi.npotau.ru, событие 1. Snapshot загружает
основную запись, список и полные тексты всех новостей, программу и подробности всех
уникальных sub_events, FAQ и исторические отметки. Не более четырёх параллельных
запросов; deadline 30 секунд, три попытки. Сбой подробной записи прерывает снимок.
Снимок и manifest записываются в var/content-import/, исключённый из Git и Docker
build context. Manifest содержит время, источник, SHA-256 и реальные количества.
Исходный HTML остаётся в архиве для сравнения. Сайт не читает этот архив.
Снимок от 2 октября 2026 содержит 94 новости, 41 пункт программы, 15 FAQ, 10 исторических отметок и 44 записи партнёров. После нормализации это 200 документов: 94 News + 41 Event + 5 Page + 15 FAQ + 44 Partner + 1 Site. Числа не зашиты в клиент. Промослайдов 11, исторических фотографий 6. Регистрация, SMS, кабинеты и тестовые контакты исходного API не переносятся.
Если одно sub_event появляется в разные дни/часы, sourceId включает дату и начало:
event:1:day:YYYY-MM-DD:item:ID:start:HH-MM. Так расписание не схлопывается по одному ID.
Архивное расписание 2025 года сохраняется рядом с новостями 2026 года; статус праздника
не превращает старые события в будущие. Конец события может отсутствовать.
Повторяемость и режимы импорта
pnpm cms:import
pnpm cms:import --snapshot var/content-import/snapshot.json --report var/content-import/retry.json
pnpm cms:import --update-existing
Обычный запуск ищет draft документа по sourceId. Найденная запись пропускается,
даже если текст источника изменился: редакторская правка остаётся. Только явно
указанный --update-existing обновляет и публикует импортированные документы.
Он не удаляет демонстрационные записи или документы без sourceId.
Dry-run разбирает снимок и формирует отчёт без загрузки Strapi и доступа к базе/S3. Он проверяет преобразование данных, но не доступность исходных фотографий, token или upload provider. В dry-run skipped означает просмотренные, не записанные документы.
Отчёт содержит created, updated, skipped, failed, filesCreated, filesReused, warnings,
количество records, источник/время и режим. Сумма четырёх документных счётчиков должна
совпасть с records. failed > 0 даёт ненулевой exit code; такой перенос не готов
для переключения production. Предупреждения о заменённых iframe не считаются ошибкой:
встраивание удаляется, безопасная ссылка на видео сохраняется.
Создание/publish выполняется через Strapi Document Service. Draft и published могут
иметь разные SQL row IDs при общем documentId; count(*) таблицы не равен числу
редакторских документов. Не вводите raw SQL unique на sourceId, игнорируя этот механизм.
HTML, Blocks и безопасное отображение
parse5 разбирает исходную разметку. Импортёр сохраняет параграфы, заголовки, списки,
цитаты, жирный/курсив/подчёркивание, ссылки и изображения. Script/style/активные
элементы не исполняются; iframe превращается в обычную ссылку и отмечается в отчёте.
Картинки-эмодзи VK с известным Unicode-кодом становятся соответствующим символом.
Разрешены http/https/mailto/tel, внутренние пути и якоря. javascript: и подобные
ссылки не проходят runtime-контракт. Пробелы вокруг безопасного URL нормализуются.
Next.js использует официальный @strapi/blocks-react-renderer; React экранирует
текст. dangerouslySetInnerHTML не используется.
Файлы и Garage
Downloader разрешает конкретные source hosts, перепроверяет redirects, ограничивает файл 25 MiB и проверяет реальный формат/декодирование через sharp. Расширение берётся из содержимого, а не URL или HTTP Content-Type. Доверенный оригинальный SVG-логотип преобразуется в PNG; глобальное разрешение SVG для пользовательских uploads не включено.
SHA-256 исходных байтов служит ключом каталога Import asset. Повтор URL или checksum
использует существующий upload. Если upload успел сохраниться, а запись каталога упала,
повтор находит файл по стабильному import-CHECKSUM.ext, не загружая его заново.
Успешный каталог записывается до публикации документа с media-связями.
Provider — @strapi/provider-upload-aws-s3, endpoint внутри Docker http://s3:3900,
bucket media, prefix strapi/, region garage, path-style. Garage не поддерживает
AWS ACL; ACL: undefined задан явно, чтобы provider не подставил public-read.
Публичное чтение включено через Garage website endpoint.
Старая local media сохраняет provider/URL и том strapi_uploads. Proxy сначала
проверяет S3, при 404 может читать старый /uploads/filename напрямую из CMS.
Credentials и cookies в запрос браузера не попадают и в static uploads не пересылаются.
Filename — один сегмент с разрешённым raster image extension; arbitrary URL запрещён.
Proxy проверяет MIME, deadline 10 секунд и лимит 25 MiB. Ответ буферизуется до отправки:
это даёт строгую ошибку до 200, но расходует память пропорционально файлу (с копиями
буферов); для больших нагрузок нужен streaming/direct media origin/CDN.
Bucket публичный: Draft & Publish управляет видимостью записей, не секретностью уже загруженных файлов. Для конфиденциальных вложений нужна другая схема доступа. Один контейнер Garage на одном VPS не обеспечивает отказоустойчивость. Копировать нужно и metadata, и data после остановки писателей/Garage; восстановление проверяется.
Редакторский сценарий без пересборки
- В Content Manager создайте News с title, slug, publishedOn и content.
- Сохраните draft: его нет в
/newsи по slug. - Publish: обновление страницы показывает запись, detail и metadata.
- Измените текст и republish: обновление страницы показывает правку без build.
- Unpublish: список очищается от записи, detail даёт notFound.
- Загрузите изображение и проверьте preview, обложку, gallery и чтение без Basic Auth.
Проверки этого пользовательского процесса ручные; автоматических UI-тестов нет. Серверные тесты отдельно проверяют pagination, контракты, отказ CMS и proxy.
Production и откат
Первый релиз готовит CMS, резервную копию и token, но оставляет прежний web при
CONTENT_READY=false. Затем явный импорт на VPS, проверка отчёта и установка
CONTENT_READY. Только после этого релиз применяет новый web. Startup CMS никогда
не импортирует контент автоматически. Подробные команды — в deployment.
При ошибке web возвращаются прежние web/functions/Caddy images. База, CMS и S3 не откатываются автоматически: старый CMS image может изменить новые schemas. Резервная копия и отдельный проверенный план восстановления нужны для отката данных.
CLI ждёт фоновые onCommit callbacks публикации: в Strapi 5.56 они вызываются без await. Это небольшой локальный адаптер только для одноразового importer; обычная CMS не изменена. При обновлении Strapi повторите regression и настоящий импорт/shutdown.
Где смотреть код
Модели CMS, компоненты, импортёр, CLI, contracts, repository, proxy, Garage.
Новая редакторская новость без publishedOn и прежнего publicationDate использует штатный publishedAt. Импортированные новости сохраняют дату источника. Запись и публикация импортера выполняются одной транзакцией Document Service; Site публикуется после всех его страниц.