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

IX. Strapi и медиа

Контент, 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[Браузер] -->|media domain /filename| Caddy
  Caddy -->|website endpoint| S3
  Browser -->|legacy /api/media/filename| Web
  Web -->|фиксированный Host| S3

Страницы используют серверный repository напрямую; tRPC предоставляет те же данные внешнему типизированному клиенту. Webhook обновляет кеш Next.js напрямую. React cache дополнительно объединяет повторные чтения внутри render.

Уведомление об изменениях

В Settings → Webhooks создайте webhook с URL https://your-site.example/api/webhooks/strapi, подставив публичный адрес сайта. В production Strapi отклоняет внутренние адреса Docker вроде http://web:3000. Добавьте header Authorization: Bearer <STRAPI_WEBHOOK_SECRET>. Включите события entry create/update/delete/publish/unpublish и media create/update/delete: изменение описания фотографии тоже должно обновлять сайт. Секрет должен совпадать с environment Next.js. Проверка возвращает 200 {"revalidated":true}. Кнопка проверки соединения отправляет trigger-test: ответ 200 {"received":true} подтверждает доступ и секрет, не инвалидируя страницы. Для автоматического обновления webhook нужно сохранить и включить в CMS; без него после истечения часа очередное обращение инициирует страховочное обновление. Это не верхняя граница устаревания: при отказе CMS сохраняется последняя успешная версия. Контракт ISR.

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

МодельРедактируемые данные
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:external
pnpm cms:dry-run --snapshot var/content-import/external.json
pnpm cms:import --snapshot var/content-import/external.json
pnpm dev

Strapi доступен на http://127.0.0.1:1337/admin. При первом открытии создайте администратора через обычную форму CMS. Read-only token создаётся независимо от этого аккаунта: команда использует стандартный Content API token service закреплённой версии Strapi. Token сохраняется в игнорируемый .env с правами 0600 и не печатается.

Для полного стека выполните 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 --snapshot var/content-import/external.json
pnpm cms:import --snapshot var/content-import/external.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.

Публичные S3-файлы читаются напрямую: браузер → Caddy → Garage website endpoint. CMS-адаптер проверяет источник и допустимый filename (с префиксом /strapi/ или без него), затем формирует публичный /filename URL из CMS_MEDIA_PUBLIC_URL. Обложки, галереи, логотипы и Blocks используют этот путь. Next.js не скачивает и не буферизует эти файлы; изображения сейчас не оптимизируются через next/image (unoptimized). Credentials браузеру не передаются.

Старая local media сохраняет provider/URL и том strapi_uploads. Только ссылки /uploads/filename преобразуются в /api/media/filename: существующий proxy сначала проверяет S3, при 404 читает старую загрузку из CMS. Он проверяет MIME, deadline 10 секунд и лимит 25 MiB; этот резервный путь всё ещё буферизует ответ. Filename — один сегмент с разрешённым raster image extension; чужой origin запрещён.

Garage/Caddy отдают файлы по HTTP, включая частичные запросы. На стенде проверено: Range: bytes=0-15 возвращает 206, Content-Range, Accept-Ranges: bytes и ровно 16 байт JPEG. Это проверка транспорта, а не реализация видеоплеера или HLS.

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/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 публикуется после всех его страниц.

Публичные адреса без названия CMS

Сайт выдаёт https://media.gheilt.mxsource.xyz/filename.jpg. В Garage ключи по-прежнему лежат под strapi/: Caddy добавляет этот префикс внутренним rewrite. Старые публичные /strapi/filename.jpg продолжают работать. Объекты и ссылки в базе CMS не мигрируются; адаптер нормализует их при чтении. Старые local uploads по-прежнему обслуживаются через /api/media/. Rewrite сохраняет query string и потоковый proxy, включая частичные HTTP-запросы.

Команды внешнего импорта выше сохранены как исторический ручной путь. Автоматические полные стенды используют опубликованную текущую CMS и Garage: инструкция.