Перейти к содержимому

Медиа и S3

Картинки и файлы, которые вы вставляете в блоки, PageCraft у себя не хранит. У каждого сайта своё S3-совместимое хранилище: туда кладётся файл, оттуда он отдаётся посетителям опубликованной страницы. Пока хранилище не подключено, поля типа «изображение» можно заполнять только ссылками на чужие ресурсы.

Настройки сайта → раздел S3. Поля:

Поле Что это
endpoint адрес S3-совместимого сервиса, например https://s3.storage.example
bucket имя бакета
region регион; если не указан — us-east-1
access_key ключ доступа
secret_key секрет
public_url база публичных ссылок; пусто — используется endpoint/bucket
path_prefix папка внутри бакета; пусто — файлы лягут в корень, по умолчанию uploads

public_url отделён от endpoint намеренно: файлы часто раздаются через CDN или собственный домен, а не напрямую из бакета. Ссылка, которая попадёт в блок и на опубликованную страницу, собирается именно из public_url.

Хранилище должно быть публично читаемым — PageCraft не подписывает ссылки на скачивание и не проксирует отдачу. Запись нужна только PageCraft, чтение — всем посетителям витрины.

Кнопка «Проверить» (POST /api/sites/{id}/s3/test) кладёт в корень бакета однобайтовый объект .pagecraft-healthcheck-<hex>.bin и пытается его удалить.

Проверка подтверждает право записи, и только его. Неудачное удаление не считается ошибкой: политика бакета вполне может запрещать DELETE, а загружать при этом позволять. Обратная сторона — зелёная проверка не гарантирует, что удаление медиа из интерфейса сработает, и тестовые объекты могут копиться в корне бакета. Их видно по префиксу .pagecraft-healthcheck-, чистятся они вручную.

Ответ всегда 200, результат — в теле: { "ok": true, "error": null }. Тест можно прогнать и до сохранения настроек, передав их в теле запроса.

В ответах API secret_key и токены провайдера маскируются восьмёркой точек ••••••••. Форма показывает маску, и при сохранении значение, равное маске, игнорируется — в базе остаётся прежний секрет. Не тронули поле — ничего не изменилось. Побочный эффект: записать в секрет буквально •••••••• нельзя.

Медиабиблиотека открывается из шапки или из поля блока с типом «изображение» (в последнем случае выбранный файл сразу подставляется в поле).

Что она умеет: показать загруженное, залить файл с диска, удалить файл.

Два ограничения интерфейса стоит знать заранее:

  • выбор файла ограничен image/*, и список показывает только изображения — расширения jpg, jpeg, png, gif, webp, svg, avif. Файл другого типа, залитый через API, в бакете есть и по ссылке работает, но в библиотеке не появится;
  • удалять можно только объекты внутри path_prefix — ключ за пределами префикса отклоняется с ошибкой доступа.

Имя файла не сохраняется. Ключ генерируется: {path_prefix}/{ГГГГММДД}_{16 hex-символов}.{расширение} — исходные имена конфликтуют, содержат пробелы и кириллицу, а иногда лишнее в названии.

Внутренний контур (сессия в браузере):

Метод и путь Что делает
GET /api/sites/{id}/media список изображений в префиксе
POST /api/sites/{id}/s3/upload загрузить файлом (multipart/form-data, поле file)
POST /api/sites/{id}/s3/upload-from-url скачать по ссылке и положить в бакет
DELETE /api/sites/{id}/media?key=... удалить объект
POST /api/sites/{id}/s3/test проверить доступ

Публичный контур V1 (API-ключ в Authorization: Bearer pb_...):

Метод и путь Что делает
GET /api/v1/sites/{id}/s3/status настроено ли хранилище и проходит ли запись
POST /api/v1/sites/{id}/s3/upload загрузить файл, в ответе { "url": "..." }

GET /api/v1/sites/{id}/s3/status отвечает 200 всегда, статус — в теле:

{ "configured": true, "writable": true, "error": null }

configured — заполнены ли настройки, writable — прошёл ли реальный тест записи, error — сообщение от хранилища при провале. Разделение даёт простое правило: 200 — разбирайте тело, 5xx — повторяйте запрос.

Загрузка файлом принимает любой тип: MIME определяется на сервере по содержимому, расширение берётся из имени файла. Ограничения нет намеренно — файл попадает в чужой бакет, на чужой домен, и политика допустимых типов — зона ответственности его владельца. Агентские сценарии кладут туда видео, PDF и архивы, а не только картинки.

Загрузка по ссылке ведёт себя иначе: расширение определяется по Content-Type ответа, а если тип незнакомый — по расширению в URL. Если и оно не из списка jpg, jpeg, png, gif, webp, svg, avif, файл всё равно будет загружен, но как .jpg с MIME image/jpeg. Этот путь рассчитан на изображения; всё остальное грузите файлом.

Потолок — 100 MB (client_max_body_size в nginx и лимиты PHP). Больший файл получит 413. Таймаут запроса — 300 секунд, а сам объект уходит в бакет целиком из памяти: потоковой многочастной загрузки нет.

Код Когда
400 S3 не настроен для сайта, файл не передан, невалидный URL
403 ключ при удалении лежит вне path_prefix
404 сайт не найден или принадлежит другому пользователю
413 файл больше 100 MB
502 хранилище недоступно или отказало — ошибка внешней системы, не наша

Для ИИ-агентов есть скилл pagecraft-s3 — два скрипта поверх V1 API. Порядок в нём жёсткий:

  1. node scripts/s3-status.mjs <site_id> — обязательный первый шаг. configured: false → хранилище не подключено, дальше идти нельзя; writable: false → показать error пользователю и остановиться.
  2. node scripts/s3-upload.mjs <site_id> <file> — на каждый файл, в ответе публичный URL.
  3. Полученный URL подставляется в placeholder_values блока при сборке страницы (скилл pagecraft-pages).

Скиллу нужен PAGECRAFT_API_KEY — ключ вида pb_..., создаётся в интерфейсе PageCraft. Необязательная PAGECRAFT_API_URL меняет хост, префикс /api/v1 скрипты добавляют сами.

  • Не обрабатывает изображения: ни сжатия, ни превью, ни вариантов под экраны.
  • Не убирает мусор: файл, на который никто не ссылается, остаётся в бакете. Удаление сайта тоже не чистит хранилище.
  • Не следит за доступностью: сменили ключи или удалили бакет — картинки на опубликованных страницах отвалятся, и PageCraft об этом не узнает.
  • backend/src/Service/S3Service.php — подпись AWS Signature V4, put/list/delete, testConnection
  • backend/src/Service/Site/UploadSiteMediaService.php, UploadSiteMediaFromUrlService.php, DeleteSiteMediaService.php
  • backend/src/Service/Query/Site/SiteMediaListQueryService.php
  • backend/src/Controller/Site/*/api/sites/{id}/s3/upload, /media, /s3/test
  • backend/src/Controller/V1/Site/UploadSiteMediaPublicAction.php, SiteS3StatusPublicAction.php
  • backend/src/Service/Site/UpdateSiteService.php, backend/src/Assembler/Site/SiteAssembler.php — маскирование секретов
  • skills/pagecraft-s3/SKILL.md
  • front/src/components/MediaFinderModal.tsx, SiteSettingsDrawer.tsx