Редактор открыл запись в 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 — ещё не опубликованную.
Для работы мобильного клиента из этого вытекают три правила обращения.
- Публичный телефон должен видеть только то, что имеет опубликованную версию. Если запись в состоянии Draft, анонимный клиент её не получит, и это нормально.
- Состояние Modified не надо считать ошибкой: у записи есть рабочая публикация, а правки ждут. Обновление обложки в таком состоянии на телефоне не появится, пока редактор не опубликует изменение.
- Файл подчиняется собственным правилам. Что запись в черновике, не означает, что её обложка скрыта: если файл уже загружен в публичное хранилище, по адресу он доступен вне зависимости от статуса записи.
Последний пункт неприятно удивляет команды, которые заливают обложку к ещё не вышедшей статье. Скрыта запись, но не картинка. Если анонс нельзя показывать до выхода, обложку лучше загружать в момент публикации либо хранить в приватном 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) отдельно перечислены доступ к библиотеке, загрузка, изменение и удаление, скачивание и копирование ссылки. Редактору, которому надо только менять обложки, достаточно загрузки и доступа к библиотеке, а удаление чужих файлов можно не открывать.
Один объект, три запроса
Проверку удобно строить вокруг одной опубликованной статьи с обложкой и проходить её тремя глазами: анонимный телефон, вошедший пользователь, редактор. Ниже — действия, а не абстрактные критерии.
- Анонимный телефон. Запросите список статей и одну статью по идентификатору без заголовка Authorization, с populate для обложки. Критерий: статья и адрес обложки пришли. Затем повторите запрос без populate: обложки быть не должно, и это подтверждает, что вы понимаете, откуда берётся поле. Попробуйте от того же имени выполнить создание и удаление: ожидается отказ. Откройте адрес обложки отдельным запросом и убедитесь, что файл отдаётся.
- Вошедший пользователь. Получите JWT обычным входом и повторите чтение с заголовком Authorization как bearer-токеном. Критерий: тот же каталог плюс то, что разрешено роли Authenticated. Затем попытайтесь изменить чужую запись профиля. Если получилось, права роли шире, чем нужно, и именно здесь пора их сужать или добавлять проверку владельца.
- Редактор. Войдите в панель, измените статью и сохраните без публикации. Запрос анонимного телефона должен по-прежнему возвращать прежнюю версию, а статус записи в панели показывать 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 для обложки, в ответе другой адрес файла, и телефон скачивает новую картинку.
В магазине приложений при этом ничего не менялось: ни сборка, ни версия, ни описание. Весь экран изменился потому, что приложение делает один и тот же запрос, а сервер отвечает по-новому. Пользователь просто открывает каталог, а там другая обложка. Так и должна выглядеть работа с контентом: редактор владеет содержимым, телефон владеет только чтением, а цепочка между ними состоит из понятных звеньев — публикация, связь файла и запись, адрес. Если какое-то из них порвётся, вы знаете, какой запрос и в какой роли это покажет.