- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
21 KiB
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/records → 200, следом
auth-with-password → 200 с токеном. То есть закрытие 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; сырой запрос и чтение через запись коллекции
совпадают побайтово. «Умолчания у колонки нет» верно по замыслу — пустое значение
не совпадает ни с кем, — но не буквально на уровне схемы.