docs: решено перевести хранилище и файлы записей на PocketBase

- разведка pocketbase-admin-fit ответила замером панели версии 0.39.10:
  записка в docs/research/pocketbase.md, решение — в ADR
- панель показывает файлы и пользователей только своих, поэтому файлы
  переезжают в её раскладку, а вход идёт через её провайдера OIDC
- периметр расширился панелью на /_/ и паролем суперпользователя;
  закрывает её Authelia на прокси, задачи в беклоге у этого нет
This commit is contained in:
av
2026-08-11 13:03:38 +03:00
parent ba7b4f37a6
commit 10ffe8bec3
10 changed files with 299 additions and 34 deletions
@@ -0,0 +1,80 @@
# Хранилище, файлы и вход переезжают в PocketBase
- **Дата:** 2026-08-11
- **Источник:** [../research/pocketbase.md](../research/pocketbase.md) — записка
разведки `pocketbase-admin-fit`
## Решение
PocketBase заменяет SQLite с goqu и goose и берёт на себя три вещи разом:
состояние задач и метаданные, файлы записей своим полем и своей раскладкой на
диске, вход пользователей через Authelia своим провайдером OIDC. Панель
администратора работает по всем трём частям только в таком составе.
## Почему
Разведка мерила панель по трём частям, и порознь ни одна перевода не оправдывала.
Правку записей панель даёт целиком, а две остальные упираются в то, где лежат
данные. Цитата из источника, раздел про пользователей:
> Панель показывает свою коллекцию пользователей и ничего больше. Отсюда
> следствие для целевого входа: **пользователи Authelia в панели не появятся,
> если вход делает само приложение**.
И раздел про файлы:
> Сегодняшняя раскладка `data/files` с именами-UUID панели не видна. Путь строкой
> она покажет строкой — прослушать и скачать запись по ней нельзя. Способа
> сослаться на файл, уже лежащий на диске мимо её каталога, нет.
Там же то, что связывает файлы с сохранностью архива:
> **Резервные копии накрывают ровно её каталог.** Файлы, оставленные снаружи, в
> них не попадут — то есть панель и встроенное резервное копирование покупаются
> одной и той же ценой.
Отвергнуты два половинчатых пути, и оба по одной причине — они покупают перевод,
не покупая того, ради чего он затевался:
> **Держать файлы на диске как сейчас, а в базе — путь строкой.** Отвергнуто:
> панель тогда не даёт по файлам ничего, и встроенные копии их не накрывают.
> Довод, ради которого перевод затевался, пропадает целиком.
>
> **Оставить вход у приложения, а PocketBase взять только хранилищем.**
> Отвергнуто: пользователей панель в этом случае не показывает вовсе, и одна из
> трёх частей вопроса остаётся без ответа навсегда, а не до какой-то задачи.
Запись попадает в журнал по двум основаниям сразу. **Откат дорогой:** меняется
раскладка файлов на диске, а она в `../../CLAUDE.md` названа необратимой.
**Пересматривается прежнее решение:** вход через OIDC собирались делать в самом
приложении — так это записано в `../architecture.md`, «Открытые вопросы», и так
поставлена задача `oidc-login`. Парного статуса «заменено на» прежняя запись не
получает: своего ADR у неё нет, решение жило открытым вопросом архитектуры.
## Последствия
- `+` панель даёт владельцу править записи, видеть пользователей и слушать сами
файлы. Проверено на версии 0.39.10; в библиотечной сборке панель отдаётся по
адресу `/_/` того же порта.
- `+` база и записи съезжаются под один каталог, и копия сервера накрывает их
разом. Своё копирование по расписанию с выгрузкой в S3-совместимое хранилище у
PocketBase тоже есть, но копии сервер уже делает своими средствами — берём мы
встроенное или нет, здесь не решено.
- `+` требование CGO уходит: PocketBase ходит в SQLite через
`modernc.org/sqlite`, и пробник собрался при `CGO_ENABLED=0`. Свойство стека в
`../../CLAUDE.md` перестаёт быть верным.
- `` раскладка `data/files` меняется необратимо: файл ложится в
`pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>` рядом с
файлом атрибутов. Момент перехода назначает человек; данные прежней базы не
переносятся по прежнему решению задачи `pocketbase-storage`.
- `` вход перестаёт быть нашим: задача `oidc-login` переписывается с
собственной обработки ответа провайдера на настройку провайдера в PocketBase.
Что делать с сессией и где она живёт, решает уже не наш код.
- `` появляется секрет, которого не было: пароль суперпользователя панели. Сама
PocketBase Authelia к панели не подпускает — ни OIDC, ни второй фактор у
коллекции суперпользователей включить не удалось. Своё ограничение по списку
адресов у неё есть, но им же можно запереть себя: сброса в наборе команд нет.
- `` панель висит на том же порту, что и приложение, а порт опубликован в
интернет через обратный прокси. **Закрывает её контур:** тем же решением адрес
`/_/` закрывает Authelia на прокси, пропуская группу администраторов. Приложение
тут ни при чём, и задачи в беклоге у этого нет.
+2 -3
View File
@@ -32,9 +32,8 @@
| Дата | Запись | Статус |
| --- | --- | --- |
| 2026-08-11 | [Хранилище, файлы и вход переезжают в PocketBase](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md) | |
| 2026-08-11 | [Проверки не зовут внешних программ](ADR-2026-08-11-stub-adapters-in-tests.md) | |
Решения, принятые до заведения канона 2026-08-10, источника в архиве изменений
не имеют — сочинять их задним числом правило запрещает. Ближайшие кандидаты
назовёт первое же изменение, которое тронет хранилище или вход: замена SQLite на
PocketBase и вход через OIDC оба подпадают под критерий «дорогой откат».
не имеют — сочинять их задним числом правило запрещает.
+17 -5
View File
@@ -119,11 +119,18 @@
## Открытые вопросы
- **Хранилище.** Пробуем PocketBase взамен SQLite с goqu и goose. Не решено, чем
становится конвейер задач: таблицей PocketBase с тем же захватом или чем-то
другим. Данные не переносим — начинаем с чистого листа.
- **Учётные записи.** Вход через OIDC, провайдер — Authelia. Не решено, где
живёт сессия и как связываются пользователь Telegram и пользователь веба.
- **Хранилище.** PocketBase заменяет SQLite с goqu и goose, файлы переезжают в её
раскладку на диске — решено 2026-08-11,
[ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md), замер панели
в [research/pocketbase.md](research/pocketbase.md). Требование CGO этим
снимается. Не решено, чем становится конвейер задач: таблицей PocketBase с тем
же захватом или чем-то другим — разведка `job-queue-choice`. Данные не
переносим — начинаем с чистого листа.
- **Учётные записи.** Вход через OIDC, провайдер — Authelia, а ответ провайдера
обрабатывает PocketBase, а не наш код (тот же ADR). Не решено, где живёт сессия
и как связываются пользователь Telegram и пользователь веба. Панель
администратора при этом Authelia не закрывает: у неё свой пароль
суперпользователя.
- **Приложение.** Экранов нет вовсе, есть только API. Решено делать SPA,
устанавливаемое на телефон; фреймворк выбирает разведка
`spa-framework-choice`, и до её итога
@@ -149,6 +156,11 @@
`usage-accounting`.
- **Срок хранения.** Записи и тексты решено хранить бессрочно (паспорт,
2026-08-11), а рост каталога `data/files` ничем не ограничен и не наблюдается.
- **Резервные копии.** Копии делает сервер своими средствами, и приложение о них
ничего не знает. После переезда на PocketBase не решено, хватит ли копировать
её каталог файлами, или приложению нужна команда выгрузки: база под нагрузкой
копируется файлом не всегда целой. Своё копирование по расписанию у PocketBase
есть — берём мы его или нет, тоже не решено.
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
сервис определяет содержимое сам, то ли часть записей теряется на этом.
+8 -4
View File
@@ -58,7 +58,10 @@
- **Собственные модели.** Не обучаем и не держим у себя ни модель распознавания,
ни языковую модель: и речь, и выводы из текста считает внешний сервис.
- **Управление учётными записями.** Пользователей заводит и проверяет внешний
провайдер, свою регистрацию и свои пароли не делаем.
провайдер, свою регистрацию и свои пароли не делаем. Одно исключение появилось
2026-08-11 вместе с решением про PocketBase: в её панель администратора
владелец входит своим паролем, потому что закрыть её провайдером она не
умеет.
- **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не
обрабатываем.
- **Диктофон.** Запись звука делает телефон, а приложение принимает готовый
@@ -106,6 +109,7 @@
которой пользуемся: она и задаёт потолок по длине записи и формату.
- **Whisper и его серверные обёртки** — запасной путь, если внешний сервис
перестанет устраивать по цене или по качеству русской речи.
- **PocketBase** — кандидат в хранилище взамен сегодняшнего SQLite. Источником
учётных записей его не рассматриваем: вход решено делать через OIDC у Authelia
([architecture.md](architecture.md), «Открытые вопросы»).
- **PocketBase** — хранилище взамен сегодняшнего SQLite, решено 2026-08-11
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)). Учётные
записи оно хранит и получает от Authelia своим провайдером OIDC, но источником
их не становится: заводит и проверяет людей по-прежнему Authelia.
+5 -3
View File
@@ -8,8 +8,8 @@
## Как снималось
Ничего не снималось. Записей нет: замеров на живом потоке не делали, поведение
внешних сервисов на границах не проверяли.
На живом потоке не снималось ничего: поведение внешних сервисов на границах не
проверяли. Единственная запись сделана на пустой базе в песочнице.
Внешних источников, о которых разведка нужна, четыре — Telegram Bot API, Yandex
SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, что стоит
@@ -19,4 +19,6 @@ SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, чт
## Записи
Записей нет.
| Дата | Запись | О чём |
| --- | --- | --- |
| 2026-08-11 | [PocketBase: что даёт панель администратора](pocketbase.md) | Записи, пользователи и файлы в панели версии 0.39.10 |
+108
View File
@@ -0,0 +1,108 @@
# 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`, рядом лёг файл атрибутов.
Сегодняшняя раскладка `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`.
## Что отвергнуто и почему
- **Держать файлы на диске как сейчас, а в базе — путь строкой.** Отвергнуто:
панель тогда не даёт по файлам ничего, и встроенные копии их не накрывают.
Довод, ради которого перевод затевался, пропадает целиком.
- **Оставить вход у приложения, а PocketBase взять только хранилищем.**
Отвергнуто: пользователей панель в этом случае не показывает вовсе, и одна из
трёх частей вопроса остаётся без ответа навсегда, а не до какой-то задачи.
- **Отказаться от перевода.** Отвергнуто человеком на чекпоинте 2026-08-11:
вместе с панелью отказ выбрасывал бы уход CGO и встроенное резервное
копирование, которых у сервиса-архива нет никаких.
+31
View File
@@ -17,6 +17,17 @@
сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда
уходит содержимое записи» и «Что вне модели».
**Третий сдвиг — панель администратора.** Решением от 2026-08-11
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)) хранилищем
становится PocketBase, и вместе с ним на том же порту появляется панель по
адресу `/_/`: доступ ко всем записям, всем файлам и всем пользователям разом.
Порт опубликован в интернет через обратный прокси, а сама PocketBase вход в
панель через Authelia не пускает — у неё свой пароль суперпользователя.
**Закрывает панель контур, а не приложение:** решением владельца от 2026-08-11
адрес `/_/` закрывает Authelia на обратном прокси, пропуская только группу
администраторов. Задачи в беклоге у этого нет — работа принадлежит выкладке, а
она вне модели («Что вне модели», строка про контур).
Отсюда главное следствие, из которого читается всё остальное: **`POST /api/audio`
доступен кому угодно из интернета**. Отправитель не назван, не ограничен по числу
запросов и не ограничен по размеру файла.
@@ -93,6 +104,14 @@ Telegram отправителю.
заодно сообщал бы, что запись у кого-то уже есть.
- **Файлы фрагментов** (`long-audio-chunking`) ложатся рядом с исходным в тот же
плоский каталог — раскладка `data/files` меняется, и это необратимо.
- **Раскладку выбирает PocketBase** (`pocketbase-storage`), и плоского каталога
не остаётся вовсе: файл ложится в
`pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>` рядом с
файлом атрибутов. Имя, данное отправителем, в путь при этом попадает — сегодня
от него берётся только расширение. Файл уходит не с диска напрямую, а по ссылке
вида `/api/files/<коллекция>/<запись>/<имя>`; закрытым он становится, только если
поле помечено защищённым, и тогда нужен отдельный файловый токен. Замер —
[research/pocketbase.md](research/pocketbase.md).
- **Имя отправляемого документа** (`long-text-delivery`) собирается из
идентификатора задачи: имя, данное пользователем, в него не попадает.
@@ -126,6 +145,14 @@ Telegram отправителю.
Откуда он берётся — из группы OIDC или из конфигурации — не решено
(`admin-stats-screen`).
**Панель администратора в эту таблицу не входит и разграничению не подчиняется.**
Суперпользователь PocketBase видит все записи, все файлы и всех пользователей
мимо любого из четырёх механизмов, а пускает его свой пароль, а не Authelia.
Замер показал, что закрыть панель провайдером OIDC или вторым фактором нельзя:
обе настройки у коллекции суперпользователей отклоняются. Остаётся ограничение
по списку адресов (`superuserIPs`), и оно же запирает владельца, если список
задан неверно: сброса в наборе команд нет.
## Что чувствительнее чего
1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка;
@@ -153,6 +180,10 @@ Telegram отправителю.
5. **Статистика потребления** (`usage-accounting`). Текста записей не содержит,
но говорит, кто и когда пользовался сервисом и сколько; страница расхода
открыта только владельцу.
6. **Пароль суперпользователя панели** (`pocketbase-storage`). Открывает все
записи, все файлы и всех пользователей разом, то есть стоит вровень с самым
чувствительным из списка выше. Второй секрет после токенов пользователей,
который лежит **не в конфигурации**: его отпечаток хранит сама база.
Тексты расшифровок в логи не пишутся — логируется длина текста и
идентификаторы. **Имя файла, данное отправителем, пишется**: строка