Редактор открыл запись в Strapi, заменил обложку, нажал сохранить, и в панели всё выглядит правильно. Мобильное приложение при этом упорно показывает старую картинку. Разбираться хочется сразу с кэшем, но чаще дело в другом: у записи и у файла разные маршруты. Запись публикуется по своим правилам, файл загружается и привязывается по своим, а телефон получает лишь адрес, по которому надо этот файл забрать. Если не развести эти этапы, любая история про «обложка не обновилась» превращается в гадание.

Эта статья про то, как устроить для мобильного клиента API и загрузки Strapi так, чтобы телефон видел ровно то, что должен, а человек, вскрывший приложение и достав из него секрет, не получил ничего лишнего. Пример условный: каталог статей с обложками и профили пользователей с аватарами. Всё описано применительно к Strapi 5; на Strapi 4 и ранних minor-релизах интерфейс и часть маршрутов могут отличаться.

Три двери в одном сервере

В Strapi с точки зрения безопасности существуют три разных входа, и путать их — самая дорогая ошибка в этой теме.

Первая дверь — административная панель. Её используют редакторы и администраторы; права у них описываются через RBAC в разделе Settings → Administration panel → Roles (документация RBAC). Первый созданный администратор становится Super Admin, и права этой роли отредактировать нельзя. Стандартный Author управляет своим контентом, Editor создаёт, правит и публикует любой. Для типов контента настраиваются действия create, read, update, delete и publish, причём можно ограничивать доступ по полям.

Вторая дверь — Content API для обычных пользователей приложения. За неё отвечает Users & Permissions: эти люди управляются отдельно от администраторов и в панель не попадают. Аутентификация идёт через JWT, а права задаются группам. По умолчанию есть роли Public для не вошедших и Authenticated для вошедших, можно создавать свои (Users & Permissions). В роли отмечаются разрешённые действия, и интерфейс показывает, какие маршруты API за ними стоят.

Третья дверь — API tokens. Это ключи для внешних запросов к Content API, и они работают без создания пользовательской учётной записи. Content API tokens и Admin tokens разделены: каждый тип отвергается на маршрутах другого (API Tokens). Поэтому токен не заменяет вход редактора, а вход пользователя приложения не заменяет токен.

Полезно держать в голове простое правило: редактор говорит с панелью, телефон говорит с Content API, а между ними нет моста. Если в мобильный клиент попадает что-то, что открывает больше, чем чтение и действия самого пользователя, значит, где-то выбран не тот вид доступа.

Как новая запись доходит до экрана телефона

Возьмём каталог статей. Draft & Publish по умолчанию выключен и включается отдельно для нужного типа контента в Content-Type Builder (Draft & Publish). Если включить, новая запись сначала является черновиком, а сохранение черновика и публикация — разные действия. Редактор может сто раз нажать «сохранить», а в публичную выдачу запись попадёт только после публикации.

Теперь путь целиком. Редактор в панели создаёт статью, заполняет поля, прикладывает обложку и публикует. Телефон без входа обращается к Content API за списком статей. Чтобы это сработало, для роли Public у типа «Статья» должны быть включены find и findOne, и больше ничего. Create, update и delete оставляем выключенными: интерфейс Users & Permissions покажет, какие маршруты открываются при каждом флажке, и это удобно сверять глазами.

Первое разочарование ждёт сразу после включения find: статья приходит, а обложки нет. Это не поломка. REST API по умолчанию не включает связи, media fields, компоненты и dynamic zones, для них нужен populate (Population & Field Selection). Параметр fields выбирает обычные поля и populate не заменяет. Значение populate=* раскрывает один уровень глубины, для вложенных нужна отдельная структура запроса. Важная деталь: связанный тип попадёт в ответ, только если для него разрешён find. Если у статьи есть автор как связь, а у типа «Автор» find закрыт для Public, телефон автора не получит, сколько бы раз вы ни дописывали populate.

Есть ещё практическое следствие. Пагинация верхнего уровня с populate совместима, а пагинации внутри включаемых связей нет. Если у статьи сотня вложенных элементов, лучше пересмотреть модель или сделать отдельный запрос, чем ждать, что телефону отдадут «первые десять».

Для мобильного клиента удобно закрепить запрос к каталогу как константу: список полей, нужный populate для обложки и верхнеуровневая пагинация. Тогда любое изменение набора данных в экране видно как изменение запроса, а не как загадочное исчезновение картинок.

Загрузка, связь и адрес — три разных события

Вернёмся к сцене с обложкой. Загрузка файла — это только добавление его в хранилище. Привязка к записи — отдельное действие. Выдача адреса телефону — третье, и она зависит от populate и от того, какой файл сейчас связан с полем.

Через REST загрузка делается запросом POST /api/upload с FormData, где параметр files содержит файлы. Чтобы сразу связать файл с записью, описаны параметры ref, refId и field (REST API — Upload files). Там же доступны GET /api/upload/files для списка, GET /api/upload/files/:id для одного файла и DELETE /api/upload/files/:id для удаления. В актуальной документации постраничная выдача называется GET /api/upload/files/page, тогда как /api/upload/files возвращает весь массив без пагинации. На большой библиотеке это отличие стоит учитывать, чтобы мобильный экран не тянул тысячи записей. GraphQL загрузку медиа не поддерживает: нужен REST либо Media Library.

Теперь о двух тонкостях, которые чаще всего и порождают историю про старую обложку.

  • У файлов нет Draft & Publish: файл имеет один documentId. Это не значит, что связанная запись тоже без черновиков. Режим публикации записи живёт отдельно от файла.
  • REST-загрузки попадают в автоматически создаваемую папку API Uploads. Папки Media Library относятся только к панели администрирования и в Content API не входят, поэтому как механизм разграничения доступа к файлам они не годятся.

Что происходит, когда редактор «меняет обложку»? Если он в панели выбирает другой файл в поле записи, меняется связь. Если запись при этом имеет статус Modified, то есть опубликована, но в черновике есть изменения, публичная выдача продолжает показывать опубликованную версию, а значит старую обложку. Пока изменение не опубликовано, телефон правильно показывает прежнюю картинку, и ничего чинить не нужно. Это самая частая причина жалобы «приложение не обновилось», и она вообще не про сеть и не про кэш.

Если же запись опубликована и содержит новую связь, а на экране всё равно старое, стоит смотреть на сам адрес файла. У новой обложки новый файл, значит, и адрес другой. Если же редактор заменял содержимое существующего файла через замену, адрес может остаться прежним, и тогда в игру входят заголовки кэширования. Для локальной выдачи доступны параметры koa-static, в том числе max-age (Media Library). Это настройка HTTP-выдачи, а не разрешение пользователя: она влияет на то, как долго клиент и промежуточные узлы считают файл свежим, и права доступа здесь ни при чём.

Размеры, варианты и неприятные сюрпризы с весом

Телефон не должен получать обложку в оригинальном размере. Настройка Responsive friendly upload создаёт варианты изображений, а размеры задаются через breakpoints. Но тут легко споткнуться: изменение breakpoints затрагивает только новые изображения, старые автоматически не пересоздаются (Media Library). Если вы решили, что для вашего экрана нужен другой набор размеров, готовьтесь к тому, что весь старый каталог останется со старыми вариантами, пока вы не перезагрузите файлы. Лучше определить breakpoints до массовой загрузки, а не после.

Второе — предел размера. Он задаётся в Upload и должен согласовываться с body middleware; прокси, балансировщик или межсетевой экран перед сервером тоже могут обрезать запрос. Для локального провайдера sizeLimit задаётся в байтах и действует на загрузку и замену файлов, а при увеличении лимита нужно согласовать maxFileSize у body parser middleware (Local Upload provider). Признак рассогласования знакомый: небольшая картинка проходит, крупная падает, причём ошибка приходит не от Strapi, а от слоя перед ним. Тогда надо идти по цепочке слоёв от клиента к серверу и смотреть, кто первым отказывает.

Третье — нагрузка. Параллельная загрузка в панели и параллельная обработка файлов сервером регулируются отдельно, и повышение параллельности увеличивает нагрузку. На небольшом VPS массовая загрузка сотни фотографий с созданием вариантов легко съедает процессор и память, поэтому лучше оставить параллельность скромной, чем потом объяснять редактору, почему панель тормозит в момент импорта.

Локальные файлы или внешнее хранилище: что случится при переезде

Теоретически разница между провайдерами кажется скучной. Практически она проявляется в день, когда сервер надо переносить.

Local Upload provider хранит ресурсы Media Library в файловой системе сервера. Это самый простой вариант: ничего лишнего, изображения отдаются с собственного origin, и для них не нужна дополнительная настройка security middleware, потому что self разрешён по умолчанию. Для небольшого каталога на отдельном VPS этого часто достаточно. Но последствие очевидно: файлы живут там же, где процесс Strapi. Схемы типов контента переезжают с кодом, а не с данными, и это касается и записей в базе, и файлов на диске (Deployment). Если вы перенесли код и базу, но забыли каталог загрузок, записи будут на месте, а картинки превратятся в пустые прямоугольники с битыми адресами. Телефон при этом получит от API совершенно корректный ответ, а проблема будет в файловой системе.

Внешнее хранилище снимает эту связку. Официальный провайдер поддерживает Amazon S3 и описывает подключение S3-совместимых сервисов, а конфигурация лежит в config/plugins; конфигурации окружений позволяют задать разные параметры для разных сред (Amazon S3 provider). Для выдачи через собственный домен описаны baseUrl и rootPath. При таком раскладе перенос сервера Strapi не трогает файлы: новая машина подключается к тому же хранилищу, а в ответах API адреса остаются прежними.

Но у вынесенного хранилища своя ловушка. Если вы решили сменить провайдера уже при наличии данных, адреса, сохранённые для существующих файлов, не обязаны волшебным образом совпасть с новым местом. Миграция файлов и проверка адресов в ответах API — отдельная работа, которую лучше отрепетировать на копии, а не на рабочем каталоге.

Для приватных файлов S3 даёт механизм подписанных адресов: при ACL: private URL подписываются автоматически, срок действия задаётся signedUrlExpires. Специальные правила подписи требуют локального переопределения провайдера с isPrivate и getSignedUrl, а пользовательские условия доступа нужно реализовывать отдельно. Отсюда важный практический вывод. Подписанная ссылка ограничивает время доступа, но не доказывает, что при её выдаче кто-то проверил, имеет ли пользователь право на файл. Если аватары приватные, решение «кому выдавать ссылку» принимает ваша логика, а не сам факт подписи.

И ещё одно. Нельзя считать, что снятие разрешения API автоматически закрывает уже известную прямую ссылку на публичный файл. Закрытие find на типе контента останавливает выдачу записей, но файл в публичном хранилище остаётся доступным тем, у кого есть его адрес. Если конфиденциальность важна, смотреть надо на провайдера и фактическую выдачу, а не только на галочки в ролях.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Опубликовано — не значит «закрыто для остальных»

Вернёмся к состояниям записи. В панели Draft редактируется, а Published показывает опубликованную версию в режиме чтения. Статус Published означает опубликованную запись без несохранённых изменений, Modified — опубликованную запись с изменениями черновика, Draft — ещё не опубликованную.

Для работы мобильного клиента из этого вытекают три правила обращения.

  1. Публичный телефон должен видеть только то, что имеет опубликованную версию. Если запись в состоянии Draft, анонимный клиент её не получит, и это нормально.
  2. Состояние Modified не надо считать ошибкой: у записи есть рабочая публикация, а правки ждут. Обновление обложки в таком состоянии на телефоне не появится, пока редактор не опубликует изменение.
  3. Файл подчиняется собственным правилам. Что запись в черновике, не означает, что её обложка скрыта: если файл уже загружен в публичное хранилище, по адресу он доступен вне зависимости от статуса записи.

Последний пункт неприятно удивляет команды, которые заливают обложку к ещё не вышедшей статье. Скрыта запись, но не картинка. Если анонс нельзя показывать до выхода, обложку лучше загружать в момент публикации либо хранить в приватном bucket с подписанными ссылками, выдаваемыми только по проверенному праву.

Какой секрет в какой роли оказывается в телефоне

Теперь главный вопрос: что получит человек, который распакует приложение и вытащит из него всё, что там лежит. Всё, что встроено в мобильный клиент, надо считать известным каждому, кто захочет посмотреть.

Разберём варианты по операциям, которые реально нужны телефону.

Какие полномочия получает извлёкший секрет
Что нужно приложениюЧем это обеспечитьЧто получит тот, кто достал секрет
Читать каталог без входаРоль Public: find и findOne для нужных типовСекрета нет: те же данные доступны любому, кто знает адрес
Читать каталог, но не показывать внешнимRead-only API token или Custom с нужными endpointsЧтение всего, что разрешено токену, пока он не отозван или не истёк
Профиль и аватар конкретного пользователяВход пользователя, JWT, роль Authenticated или своя рольТолько права этого пользователя и роли, а не права всего приложения
Редактирование контентаВход в панель администрирования, RBACНе должно быть в мобильном клиенте вообще

Логика проста. Для публичного каталога токен в приложении ничего не защищает: если читать можно всем, то создавайте разрешения у Public и не носите в клиенте лишних секретов. Токен имеет смысл там, где чтение нужно разрешить приложению, но не открывать всему интернету, и тогда надо помнить, что токен в клиенте — это ключ, которым может воспользоваться любой, кто его извлёк, уже вне приложения.

Среди типов токенов Read-only обращается только к функциям find и findOne. Full access — это именно то, что в мобильный клиент не кладут: достав его, человек получает полный доступ к Content API, включая изменение и удаление. Custom позволяет выбрать разрешения отдельных endpoints и подходит, когда нужна нестандартная комбинация. Токен может иметь срок действия, а интерфейс позволяет редактировать, удалять и регенерировать токены. Документация советует минимальные права, ротацию долгоживущих токенов и хранение в менеджере секретов. Если изменить соль API_TOKEN_SALT, все существующие API tokens станут недействительными. Это удобная аварийная кнопка, но пользоваться ей стоит осознанно: вместе с подозрительными токенами отвалятся и честные.

Отдельно отметим, какие полномочия открывает вход пользователя. JWT вошедшего пользователя несёт права его роли, поэтому, если роль Authenticated позволяет update у типа «Профиль» без ограничения на владельца, любой вошедший сможет править чужие профили. Права задаются группе, а не конкретной записи, и тонкую привязку «только своё» придётся реализовывать отдельно. Это стоит проверить явным запросом, а не предполагать по названию роли. Кстати, роль для новых пользователей выбирается в Advanced settings. Если оставить там что-то более щедрое, чем нужно, каждый новый регистрант получит лишние права сразу. Public удалить нельзя, а пользователи удалённой иной роли переводятся в Public — то есть при чистке ролей следите, что произойдёт с людьми, чья роль исчезает.

Права администраторов к этому не имеют отношения, но и забывать о них нельзя. Права на настройки API Tokens и управление администраторами выделены отдельно от прав на редактирование контента, так что редактору не нужно давать возможность создавать токены. А у Upload (Media Library) отдельно перечислены доступ к библиотеке, загрузка, изменение и удаление, скачивание и копирование ссылки. Редактору, которому надо только менять обложки, достаточно загрузки и доступа к библиотеке, а удаление чужих файлов можно не открывать.

Один объект, три запроса

Проверку удобно строить вокруг одной опубликованной статьи с обложкой и проходить её тремя глазами: анонимный телефон, вошедший пользователь, редактор. Ниже — действия, а не абстрактные критерии.

  1. Анонимный телефон. Запросите список статей и одну статью по идентификатору без заголовка Authorization, с populate для обложки. Критерий: статья и адрес обложки пришли. Затем повторите запрос без populate: обложки быть не должно, и это подтверждает, что вы понимаете, откуда берётся поле. Попробуйте от того же имени выполнить создание и удаление: ожидается отказ. Откройте адрес обложки отдельным запросом и убедитесь, что файл отдаётся.
  2. Вошедший пользователь. Получите JWT обычным входом и повторите чтение с заголовком Authorization как bearer-токеном. Критерий: тот же каталог плюс то, что разрешено роли Authenticated. Затем попытайтесь изменить чужую запись профиля. Если получилось, права роли шире, чем нужно, и именно здесь пора их сужать или добавлять проверку владельца.
  3. Редактор. Войдите в панель, измените статью и сохраните без публикации. Запрос анонимного телефона должен по-прежнему возвращать прежнюю версию, а статус записи в панели показывать Modified. Опубликуйте: телефон должен увидеть новую обложку. Заодно попробуйте пойти с токеном или JWT пользователя на административные маршруты: каждый тип должен отвергаться там, где не предназначен.

Если анонимный запрос видит черновик, неверно сопоставлены публикация и права. Если вошедший пользователь может править чужое, надо сужать роль. Если редакторский токен случайно годится для приложения, значит, перепутаны типы токенов. Каждый из этих сбоев ловится одним запросом, а не чтением настроек глазами.

Отдельный VPS: что действительно нужно этому серверу

Strapi можно развернуть на обычном сервере, причём документация разделяет настройку окружения, сборку панели и запуск приложения (Deployment). Перед production-запуском панель собирают для production, а при изменении кода или административной конфигурации статическую панель пересобирают. Рекомендуется менеджер процессов и изолированные окружения: staging не должен подключаться к production-базе. Секреты для сборки и сервера передают из хранилища секретов окружения, рассчитывать на закоммиченный .env нельзя. Пошаговая установка разобрана отдельно в статье про установку и настройку Strapi на VPS, поэтому здесь остановимся на том, что связано именно с мобильным клиентом.

Первое: схема. Типы контента переезжают с кодом, и изменение схемы при запуске меняет базу. Удаление типа удаляет его таблицу. Для мобильного приложения это важно, потому что старые версии клиента живут у пользователей месяцами: если вы переименовали поле или убрали тип, телефоны с прежним кодом начнут получать не то, что ждут. Меняйте схему так, чтобы старый запрос продолжал работать, пока старые версии приложения в обращении.

Второе: готовность. Маршрут /_health при готовности сервера возвращает HTTP 204. Это признак того, что приложение поднялось, но не проверка пользовательского сценария. Ответ 204 не скажет, что у Public включён find, что populate отдаёт обложку и что файл по адресу открывается. Поэтому после запуска или переноса прогоняйте именно те три запроса из предыдущего раздела.

Третье: ресурсы. VPS MAATRIX предлагает NVMe, AMD EPYC и root-доступ, а линейка тарифов начинается с Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) и идёт к Uab (2 vCPU, 4 ГБ, 80 ГБ), Heka (4 vCPU, 8 ГБ, 160 ГБ), Maat (8 vCPU, 16 ГБ, 240 ГБ) и Djed (16 vCPU, 32 ГБ, 320 ГБ). Это размеры ресурсов, а не обещание ёмкости: сколько запросов выдержит каталог, зависит от того, как вы строите запросы, сколько вариантов изображений создаёте и как настроена параллельность обработки. С локальным провайдером учитывайте и диск: в нём живут и база, и все варианты каждой загруженной картинки, а варианты по breakpoints умножают число файлов. Если же файлы вынесены во внешнее хранилище, диск сервера можно рассчитывать главным образом под систему, базу и логи. Сервер можно выбрать в одной из доступных локаций (в публичном предложении перечислены UK, US, RU и FR), и для мобильного клиента разумно ориентироваться на то, откуда к вам чаще приходят пользователи, замерив это самостоятельно, а не полагаясь на общие соображения. Оплата возможна картой РФ и через СБП, а VPS создаётся автоматически за пару минут после оплаты; детали заказа и наличие образов лучше посмотреть на странице аренды VPS.

Когда обложка поменялась, а магазин не трогали

Вернёмся к редактору из начала. Теперь у него всё устроено иначе. Он загружает новую обложку, привязывает её к статье, видит статус Modified и понимает: изменение ждёт публикации. Нажимает «Опубликовать». Приложение при очередном запросе получает статью с populate для обложки, в ответе другой адрес файла, и телефон скачивает новую картинку.

В магазине приложений при этом ничего не менялось: ни сборка, ни версия, ни описание. Весь экран изменился потому, что приложение делает один и тот же запрос, а сервер отвечает по-новому. Пользователь просто открывает каталог, а там другая обложка. Так и должна выглядеть работа с контентом: редактор владеет содержимым, телефон владеет только чтением, а цепочка между ними состоит из понятных звеньев — публикация, связь файла и запись, адрес. Если какое-то из них порвётся, вы знаете, какой запрос и в какой роли это покажет.