- шаг схемы закрывает поверхность, которую хранилище приносит открытой: собственную регистрацию, вход по паролю и одноразовый код — без этого закрытие приёма обходилось двумя запросами - продление сессии выключено, срок семь суток: иначе отзыв доступа у провайдера до сервиса не доходит никогда - файл записи отдаётся вошедшему по токену файла — пересмотр ADR-2026-08-12-file-link-open-but-not-logged
15 KiB
PocketBase: что даёт панель администратора
Отвечает на вопрос разведки 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, и перевод его снимает.
Бинарник пробника — 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).
Журнал запросов пишет строку запроса целиком. activityLogger на корневом
роутере кладёт RequestURI полем url в таблицу _logs, ретеншен по умолчанию
MaxDays: 5. Значит всё, что пришло параметром адреса, оседает там на пять
суток; проект умолчание не переопределяет.
Что отвергнуто и почему
- Держать файлы на диске как сейчас, а в базе — путь строкой. Отвергнуто: панель тогда не даёт по файлам ничего, и встроенные копии их не накрывают. Довод, ради которого перевод затевался, пропадает целиком.
- Оставить вход у приложения, а PocketBase взять только хранилищем. Отвергнуто: пользователей панель в этом случае не показывает вовсе, и одна из трёх частей вопроса остаётся без ответа навсегда, а не до какой-то задачи.
- Отказаться от перевода. Отвергнуто человеком 2026-08-11 при выборе из трёх способов: вместе с панелью отказ выбрасывал бы уход CGO и встроенное резервное копирование, которых у сервиса-архива нет никаких.