Files
transcriber/docs/research/pocketbase.md
T
av c9b7765646 хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
2026-08-23 08:06:04 +03:00

21 KiB
Raw Blame History

PocketBase: что даёт панель администратора

Записка о прошлом. PocketBase ушла из проекта целиком 2026-08-22 — ADR-2026-08-22-storage-without-pocketbase. Панели у сервиса нет, и ничто из описанного ниже сегодня не работает. Записка остаётся затем, что ею мерили цену потери: возврат остановленной записи в работу делает подкоманда cmd/devtools resume, а остальное приносят отдельные задачи.

Отвечает на вопрос разведки pocketbase-admin-fit: что панель показывает и правит по трём частям — записи, пользователи, файлы, — и хватает ли этого, чтобы держать перевод хранилища в планах.

Как снималось

Версия 0.39.10, выпуск от 2026-07-30 (./pocketbase --version). Смотрел на пустой базе в каталоге вне репозитория, боевые данные не участвовали. Прогонов было два:

  • готовый бинарникpocketbase serve --http=127.0.0.1:8099, суперпользователь заведён командой pocketbase superuser create. Возможности панели снимал её же запросами (/api/collections, /api/logs, /api/backups, /api/crons, /api/settings) и поиском по её собранному коду;
  • своя сборка, где PocketBase подключён библиотекой к пустому приложению на Go, — так, как предполагает задача pocketbase-storage.

Оба прогона удалены вместе с песочницей.

Правка записей — работает целиком

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

Групповой операции над отмеченными записями в панели нет: удаление идёт по одной. Проверял поиском по её коду — строк вида «удалить отмеченное» в нём не нашлось, тогда как «Export as CSV» и «Download JSON» нашлись.

Пользователи — только те, кого туда положат

Панель показывает свою коллекцию пользователей и ничего больше. Отсюда следствие для целевого входа: пользователи Authelia в панели не появятся, если вход делает само приложение. Пустая база заводит шесть коллекций, из них одна пользовательская (users) и пять служебных, включая _externalAuths — связь записи с внешним провайдером.

Второй путь есть, и он работает: вход можно отдать самой PocketBase. У пользовательской коллекции настраивается провайдер oidc с произвольными адресами; я включил его на адреса вида https://auth.example.com/api/oidc/..., и клиент немедленно стал получать провайдера в списке способов входа. Тогда учётные записи заводятся сами, и панель их видит.

В саму панель Authelia не пускает. Вход суперпользователя — своя почта и свой пароль:

  • включить oidc у коллекции суперпользователей не удалось: запрос принимается, но возвращает коллекцию с выключенным oauth2;
  • включить второй фактор у неё же не удалось тоже — ответ 403.

Ограничить панель списком адресов можно: настройка superuserIPs принимает адреса и подсети. Ею же можно запереть себя — после того как я поставил туда чужой адрес, все запросы суперпользователя, включая запрос на сброс настройки, стали отвечать 403. Команды сброса в наборе нет: он состоит из migrate, superuser, update и serve.

Файлы — только свои

Файл живёт полем записи, и раскладку на диске выбирает PocketBase:

pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>.ogg
pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>.ogg.attrs

Проверено загрузкой файла в 200 КБ: имя sample.ogg превратилось в sample_uztrv6wvz3.ogg, рядом лёг файл атрибутов.

Уточнено 2026-08-12: суффикс дописывает конструктор имени, а не укладка. Имя, заданное после конструктора, ложится на диск дословно — см. pocketbase-defaults.md.

Сегодняшняя раскладка data/files с именами-UUID панели не видна. Путь она покажет строкой — прослушать и скачать запись по ней нельзя. Способа сослаться на файл, уже лежащий на диске мимо её каталога, нет.

Поле помечается защищённым, и тогда файл не отдаётся по прямой ссылке: без токена ответ 404, с выданным файловым токеном — 200.

Резервные копии накрывают ровно её каталог. Файлы, оставленные снаружи, в них не попадут — то есть панель и встроенное резервное копирование покупаются одной и той же ценой.

Побочное: CGO уходит

Библиотечная сборка встала при CGO_ENABLED=0 — PocketBase ходит в SQLite через modernc.org/sqlite, а не через mattn/go-sqlite3. Требование CGO записано сегодня свойством стека в ../../CLAUDE.md, и перевод его снимает.

Уточнено 2026-08-12: перевод состоялся, и требования CGO в стеке больше нет — ../../CLAUDE.md, «Стек»: компилятор C нужен только детектору гонок в гейте.

Бинарник пробника — 33 954 634 байта против 43 498 904 у сегодняшнего приложения (go build без флагов). Числа не сравнимы напрямую: в пробнике нет ни бота, ни клиента SpeechKit, ни клиента Object Storage. Что даст сборка после перевода, не замерялось.

Панель отдаётся по адресу /_/ того же порта, что и остальное приложение, — и в библиотечной сборке тоже: пустое приложение с одним своим обработчиком отвечало на /_/ кодом 200.

Вход через OIDC: что выяснилось при реализации

Дописано 2026-08-12 задачей oidc-login. Все находки ниже получены одним способом: чтением исходников pocketbase@v0.39.10 из кеша модулей и прогонами против настоящего хранилища на временном каталоге — в ходе ревью того change. Живой Authelia в прогонах не было ни разу: провайдера подменял свой httptest-сервер.

Коллекция users приходит открытой. Системный шаг библиотеки заводит её с CreateRule = "" (создание доступно анониму) и PasswordAuth.Enabled = true (migrations/1640988000_init.go, core/collection_model_auth_options.go). Прогон подтвердил: POST /api/collections/users/records200, следом auth-with-password200 с токеном. То есть закрытие API за вход обходится двумя запросами, пока эта поверхность не закрыта своим шагом схемы.

Правило создания нельзя закрывать полностью. CreateRule = nil означает «только суперпользователь», а запись при первом входе заводит внутренний запрос самого обмена, идущий без таких прав (apis/record_crud.go: проверка !hasSuperuserAuth && collection.CreateRule == nil). Прогон: с nil вход кончался 401, учётных записей 0. Работает правило @request.context = "oauth2" — контекст ставит сам обмен (core.RequestInfoContextOAuth2), а посторонний запрос приходит с контекстом по умолчанию. Открывать правило пустой строкой при этом нельзя: публичный обмен принимает поля создаваемой записи от вызывающего.

Обмен кода наружу не экспортирован. Пакет apis отдаёт ошибки, middleware, NewRouter, Serve и обёртки; сам обмен — неэкспортированная функция за маршрутом POST /api/collections/{c}/auth-with-oauth2, принимающая provider, code, codeVerifier, redirectURL. Собственный /api/oauth2-redirect служит другому — он ищет клиента realtime-подписки по параметру state, то есть обслуживает всплывающее окно JS-клиента, а не серверный вход.

apis.NewRouter не идемпотентна: собирать её нужно один раз и держать, а не создавать заново при каждом вызове. Она зовёт bindRealtimeEvents и bindUIExtensions, а те вешают девять обработчиков на приложение и без поля Id; hook.Bind такому генерирует новый идентификатор и добавляет. Замер: пять вызовов подряд подняли OnModelAfterUpdateSuccess с 4 до 14, а 3000 вызовов — время сотни сохранений записи с 3.86 мс до 59.8 мс и кучу на 5013 КиБ. Освобождения нет, только перезапуск.

Связывание учётной записи идёт по sub, а не найдя — по почте. Обмен ищет запись в _externalAuths по providerId, и лишь затем FindAuthRecordByEmail (apis/record_auth_with_oauth2.go). Отсюда цена открытой регистрации: запись, заведённая посторонним на чужой адрес почты, достаётся первому же настоящему входу с этим адресом.

Защищённое поле файла судится двумя вещами сразу — коротким токеном файла из строки запроса и правилом просмотра коллекции (apis/file.go). Незаданное правило означает «только суперпользователь», поэтому одной пометки Protected мало: прогон показал 404 анониму, вошедшему кукой, вошедшему заголовком и вошедшему с законно полученным токеном файла — пока правило не назначено.

Сессия по умолчанию продлеваема бессрочно. Токен несёт поле refreshable=true, и POST /api/collections/{c}/auth-refresh меняет его на новый с новым сроком. Прогон: три продления подряд, каждое 200, exp растёт. Настройки «выдавать непродлеваемую сессию» у коллекции нет — закрывается только слоем приложения поверх маршрута.

Подпись сессии считается от секрета коллекции и ключа записи, обе величины в базе (core/record_query.go, FindAuthRecordByToken). Отсюда два следствия: сессия переживает перезапуск сервиса сама, а смена ключа записи (Record.RefreshTokenKey()) обесценивает все её выданные сессии разом.

Куки библиотека не читает вовсе — сессию берёт только заголовком Authorization (apis/middlewares.go, getAuthTokenFromRequest).

Учётную запись обмен ищет двумя способами подряд, а связь с провайдером уникальна. Сперва — по неизменяемому признаку провайдера, а не найдя — по адресу почты (apis/record_auth_with_oauth2.go, ветка case authUser.Email != ""FindAuthRecordByEmail). Найденной записи он пытается добавить связь, а на ней стоит уникальный индекс idx_externalAuths_record_provider (collectionRef, recordRef, provider). Отсюда исход, обратный ожидаемому: два разных признака провайдера с одной почтой не сливаются в одного владельца молча — второй вход отвергается, обмен отдаёт 400, сервис — 401 со строкой Failed to exchange provider code, а настоящая причина остаётся в журнале хранилища строкой failed to save linked rel: … Value must be unique. Дописано 2026-08-15 задачей про заглушку OIDC; получено прогоном против временного каталога — чтение исходников давало ту же цепочку, но противоположную развязку.

Журнал запросов пишет строку запроса целиком. activityLogger на корневом роутере кладёт RequestURI полем url в таблицу _logs, ретеншен по умолчанию MaxDays: 5. Значит всё, что пришло параметром адреса, оседает там на пять суток; проект умолчание не переопределяет.

Что отвергнуто и почему

  • Держать файлы на диске как сейчас, а в базе — путь строкой. Отвергнуто: панель тогда не даёт по файлам ничего, и встроенные копии их не накрывают. Довод, ради которого перевод затевался, пропадает целиком.
  • Оставить вход у приложения, а PocketBase взять только хранилищем. Отвергнуто: пользователей панель в этом случае не показывает вовсе, и одна из трёх частей вопроса остаётся без ответа навсегда, а не до какой-то задачи.
  • Отказаться от перевода. Отвергнуто человеком 2026-08-11 при выборе из трёх способов: вместе с панелью отказ выбрасывал бы уход CGO и встроенное резервное копирование, которых у сервиса-архива нет никаких.

Разграничение по владельцу: что выяснилось при реализации

Дописано 2026-08-14 задачей record-ownership. Все находки ниже получены одним способом: чтением исходников pocketbase@v0.39.10 из кеша модулей и прогонами против настоящего хранилища на временном каталоге — в ходе ревью того change.

Связь с выключенным каскадом не удерживает целостность при удалении. core/record_model.go, deleteRefRecords: при CascadeDelete = false и необязательном поле хранилище вынимает идентификатор из поля связи и сохраняет запись через SaveNoValidate. То есть «не уносить записи следом» и «сохранить у них владельца» — разные вещи, и связь даёт только первое.

Наружу проходит только ошибка роутера. apis/record_crud.go заворачивает отказ хука в firstApiError(err, e.BadRequestError("Failed to delete record. Make sure that the record is not part of a required relation reference.", err)), а firstApiError берёт первый аргумент, только если он *router.ApiError. Обычная ошибка из хука до ответа не доезжает вовсе, и спрашивающий получает библиотечную подсказку про обязательную связь — в нашем случае указывающую не на ту связь.

apis/file.go выдаёт токен файла на предъявителя, а не на файл. О файле при выдаче он не спрашивает. Владельца судит переход по ссылке: правило просмотра коллекции проверяет защищённое поле файла по учётной записи из токена. Значит чужой токен получить можно всегда, а скачать по нему чужой файл — нет.

Проверка сессии с именем коллекции отвечает 403, а не 401. apis.RequireAuth("users") пускает только запись названной коллекции; предъявитель из другой — например, владелец панели — узнан, но не годится, и код отказа это различает.

Связь в SQLite лежит пустой строкой, а не NULL. RelationField.ColumnType даёт TEXT DEFAULT '' NOT NULL; сырой запрос и чтение через запись коллекции совпадают побайтово. «Умолчания у колонки нет» верно по замыслу — пустое значение не совпадает ни с кем, — но не буквально на уровне схемы.