# 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](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](../../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`). **Журнал запросов пишет строку запроса целиком.** `activityLogger` на корневом роутере кладёт `RequestURI` полем `url` в таблицу `_logs`, ретеншен по умолчанию `MaxDays: 5`. Значит всё, что пришло параметром адреса, оседает там на пять суток; проект умолчание не переопределяет. ## Что отвергнуто и почему - **Держать файлы на диске как сейчас, а в базе — путь строкой.** Отвергнуто: панель тогда не даёт по файлам ничего, и встроенные копии их не накрывают. Довод, ради которого перевод затевался, пропадает целиком. - **Оставить вход у приложения, а PocketBase взять только хранилищем.** Отвергнуто: пользователей панель в этом случае не показывает вовсе, и одна из трёх частей вопроса остаётся без ответа навсегда, а не до какой-то задачи. - **Отказаться от перевода.** Отвергнуто человеком 2026-08-11 при выборе из трёх способов: вместе с панелью отказ выбрасывал бы уход CGO и встроенное резервное копирование, которых у сервиса-архива нет никаких.