Учебное руководство
Главы руководства
На этой странице

Контент, 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; без него работает только страховочная ревалидация раз в час.

Модели и связи

МодельРедактируемые данные
Newstitle, slug, summary, category, publishedOn, content Blocks, cover, gallery
Eventте же тексты/media, startsAt, nullable endsAt, location, price
Pagetitle, slug, summary, content, cover, gallery, order
FAQquestion, answer Blocks, order
Partnertitle, 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; восстановление проверяется.

Редакторский сценарий без пересборки

  1. В Content Manager создайте News с title, slug, publishedOn и content.
  2. Сохраните draft: его нет в /news и по slug.
  3. Publish: обновление страницы показывает запись, detail и metadata.
  4. Измените текст и republish: обновление страницы показывает правку без build.
  5. Unpublish: список очищается от записи, detail даёт notFound.
  6. Загрузите изображение и проверьте 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 публикуется после всех его страниц.