хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
+103
-109
@@ -43,27 +43,22 @@ Telegram — связи чата с учётной записью сервис
|
||||
сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда
|
||||
уходит содержимое записи» и «Что вне модели».
|
||||
|
||||
**Третий сдвиг — панель администратора.** Решением от 2026-08-11
|
||||
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)) хранилищем
|
||||
становится PocketBase, и вместе с ним на том же порту появляется панель по
|
||||
адресу `/_/`: доступ ко всем записям, всем файлам и всем пользователям разом.
|
||||
Порт опубликован в интернет через обратный прокси, а сама PocketBase вход в
|
||||
панель через Authelia не пускает — у неё свой пароль суперпользователя.
|
||||
**Закрывает панель контур, а не приложение:** решением владельца от 2026-08-11
|
||||
адрес `/_/` закрывает Authelia на обратном прокси, пропуская только группу
|
||||
администраторов. Задачи в беклоге у этого нет — работа принадлежит выкладке, а
|
||||
она вне модели («Что вне модели», строка про контур).
|
||||
**Третьего сдвига — панели администратора — больше нет, и это снятие.** Решением
|
||||
от 2026-08-11 хранилищем становилась PocketBase, и вместе с ней на том же порту
|
||||
появлялась панель `/_/`: доступ ко всем записям, всем файлам и всем пользователям
|
||||
разом, закрываемый не приложением, а правилом обратного прокси. 2026-08-22,
|
||||
задачей `storage-without-pocketbase`, встроенное хранилище убрано целиком:
|
||||
панели не существует, второго периметра на порту сервиса не осталось, и правилу
|
||||
прокси нечего закрывать.
|
||||
|
||||
**И этот барьер обходится подменой одного знака.** Маршрутизатор сравнивает
|
||||
сегменты пути **после** раскодирования, поэтому `/%5f/` попадает в ту же группу,
|
||||
что и `/_/`, а правило прокси написано на литерал и такой формы не видит.
|
||||
Проверено прогоном 2026-08-15 ревью задачи `spa-skeleton`: обе формы отвечают
|
||||
байт в байт, и весь клиент панели грузится анониму. Вход в приложение при этом
|
||||
не обходится — `/%61pp/me` отвечает `401`. Дефект старше задачи, которая его
|
||||
нашла, и **сегодня не закрыт**: лечение — приведение пути к канонической форме на
|
||||
стороне сервиса, и глухая проверка тут не годится, потому что сломает скачивание
|
||||
файлов с пробелами и не-латиницей в имени. Половину пути проверить нечем: правило
|
||||
прокси живёт в `pet-project-server`, вне этого репозитория.
|
||||
**Вместе с панелью снят и дефект подменённого знака.** Маршрутизатор сравнивал
|
||||
сегменты пути после раскодирования, поэтому `/%5f/` попадал в ту же группу, что и
|
||||
`/_/`, а правило прокси, написанное на литерал, такой формы не видело — весь
|
||||
клиент панели грузился анониму (проверено прогоном 2026-08-15 ревью задачи
|
||||
`spa-skeleton`). Лечится он теперь тем, что за обоими адресами не стоит ничего:
|
||||
оба попадают под общее правило неизвестного пути и отдают разметку приложения.
|
||||
Проверено прогоном 2026-08-22: `/_/`, `/%5f/` и всякий путь под `/api/` отвечают
|
||||
байт в байт тем же, чем отвечает выдуманный путь вне корней сервиса.
|
||||
|
||||
**Четвёртый сдвиг был — секрет клиента в базе, — и он снят.** Задача
|
||||
`oidc-login` 2026-08-12 клала адреса провайдера, идентификатор клиента и его
|
||||
@@ -82,14 +77,17 @@ Telegram — связи чата с учётной записью сервис
|
||||
`Remote-*` прокси обязан перезаписывать, а не пропускать**. Выкладку запускает
|
||||
человек.
|
||||
|
||||
**То же требование распространяется на `X-Forwarded-For`, и по другой причине.**
|
||||
С 2026-08-22 сервис называет этот заголовок хранилищу источником адреса
|
||||
спрашивающего — иначе счётчик ограничителя частоты ключуется адресом пира, а
|
||||
пир теперь всегда один, и бюджет становится общим на весь сервис. Прокси,
|
||||
дописывающий `X-Forwarded-For` к присланному вместо замены, отдаёт ключ счётчика
|
||||
самому спрашивающему: тот меняет значение и обходит ограничитель. Барьером
|
||||
узнавания этот заголовок при этом не служит — кто пришёл, решает адрес самого
|
||||
соединения.
|
||||
**`X-Forwarded-For` сервис читает сам, и правило чтения закрывает дописывание.**
|
||||
С 2026-08-22 адрес спрашивающего ограничитель частоты берёт из этого заголовка:
|
||||
иначе счётчик ведётся по адресу пира, а пир теперь всегда один — прокси, — и
|
||||
бюджет становится общим на весь сервис. Цепочка читается **справа налево**,
|
||||
доверенные адреса отбрасываются, и ключом становится первый недоверенный: левым
|
||||
значением распоряжается сам спрашивающий, а правое приписал ближайший к нам
|
||||
прокси. Заголовок читается всеми строками, а не одной: цепочка законно приходит
|
||||
несколькими. Прокси, дописывающий `X-Forwarded-For` к присланному, этим правилом
|
||||
покрыт, и требования «перезаписывать, а не дописывать» у сервиса к нему нет — в
|
||||
отличие от `Remote-*`. Барьером узнавания заголовок при этом не служит: кто
|
||||
пришёл, решает адрес самого соединения.
|
||||
|
||||
**Ширина перечня доверенных адресов — тоже цена, и она принимается сознательно.**
|
||||
Перечень задаёт, чьему `Remote-User` верить, и всякий, кто дотянулся до сервиса
|
||||
@@ -150,7 +148,7 @@ Telegram — связи чата с учётной записью сервис
|
||||
Сегодня запись покидает наш сервер двумя путями: файл уезжает в Yandex Object
|
||||
Storage, оттуда его читает SpeechKit. Третий путь — ответ в Telegram — исчез
|
||||
2026-08-14 вместе с убранным входом: текст теперь достаётся только своим адресом
|
||||
и в панели владельца.
|
||||
приложения.
|
||||
|
||||
Целевой периметр добавляет три пути, каждый — своей задачей:
|
||||
|
||||
@@ -170,44 +168,40 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда
|
||||
путь не предполагался.
|
||||
|
||||
- **Путь на диске** выбирает хранилище:
|
||||
`data/storage/<коллекция>/<запись>/<имя>`. **Имя задаёт сервис** —
|
||||
`<uuid><расширение>`, — а умолчание PocketBase, строящее имя из имени
|
||||
отправителя, не применяется: имя отправителя в хранилище не попадает.
|
||||
Расширение берётся из имени отправителя через `filepath.Ext` без проверки
|
||||
- **Путь на диске** выбирает сервис: `data/records/<ULID записи>/<имя>`. Обе
|
||||
части задаёт он сам — подкаталог назван идентификатором записи, имя файла это
|
||||
`<ULID><расширение>`, — и имя, данное отправителем, не попадает ни в одну из
|
||||
них. Расширение берётся из имени отправителя через `filepath.Ext` без проверки
|
||||
списком; `filepath.Ext` режет по последней точке и не пропускает разделитель
|
||||
каталогов, но это единственное, что стоит между входом и именем файла.
|
||||
каталогов, но это единственное, что стоит между входом и именем файла. Длина
|
||||
расширения при этом ограничена числом — иначе `x.` с четырьмястами знаками
|
||||
роняет заведение временного файла.
|
||||
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
|
||||
расширением. Бакет один на все записи, префикса по пользователю нет. С
|
||||
2026-08-14 копия там файлом записи не считается: она существует лишь потому,
|
||||
что провайдер читает аудио по адресу, и её ключ живёт в строке попытки
|
||||
распознавания.
|
||||
- **Вторая раскладка файла на диске** появилась 2026-08-14 вместе с сохранённым
|
||||
ответом провайдера: `data/storage/<recognitions>/<попытка>/<имя>.payload`. Имя
|
||||
задаёт сервис, как и у аудио. Содержимое там — **полный текст речи**, а не
|
||||
метаданные, поэтому поле помечено защищённым, правило просмотра коллекции
|
||||
оставлено пустым, и ссылка на вложение подпадает под тот же запрет, что и
|
||||
ссылка на аудио: в журнал она не пишется. Проверено прогоном: без сессии, с
|
||||
чужим и со своим токеном файла ссылка отвечает «не найдено».
|
||||
- **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
|
||||
помечено защищённым задачей `oidc-login` 2026-08-12: пройти по ссылке теперь
|
||||
можно только с коротким токеном файла, который выдаётся по сессии, и запрос
|
||||
без него получает «не найдено». Сама ссылка отзыва по-прежнему не имеет —
|
||||
токен сужает круг и живёт недолго, но выданное не отзывается. Отсюда запрет
|
||||
остаётся: **имя файла в хранилище в журнал не пишется**
|
||||
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку
|
||||
целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
|
||||
- **Идентификатор записи** — 15 знаков, выдаёт хранилище. Он же единственное,
|
||||
что защищает карточку записи и её текст.
|
||||
- **Поверхность самого хранилища.** Вместе с переводом наружу выходят
|
||||
`/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`,
|
||||
`/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то
|
||||
есть доступны они только владельцу панели, — и коллекции, заведённые
|
||||
2026-08-14, тоже: содержимое записи отдаёт собственный адрес сервиса, а не
|
||||
поверхность хранилища. Коды, снятые прогоном, —
|
||||
[database.md](database.md), «Коллекции», норма —
|
||||
[storage](../openspec/specs/storage/spec.md), «Наружу хранилище отдаёт только
|
||||
то, что заказано».
|
||||
- **Сохранённый ответ провайдера** лежит третьим файлом в том же подкаталоге
|
||||
записи, под именем, которое задаёт сервис. Содержимое там — **полный текст
|
||||
речи**, а не метаданные, поэтому закрыт он наравне с расшифровкой: адреса,
|
||||
которым его читают снаружи, у сервиса нет вовсе, а путь к нему не пишется ни в
|
||||
журнал, ни в метку метрики, ни в ответ.
|
||||
- **Адрес файла** — `GET /app/audiorecords/{id}/file?copy=original|normalized`.
|
||||
Право пройти по нему даёт **узнавание пришедшего и владение записью**, и
|
||||
судится оно там же, где отдаётся файл. Значений на предъявителя сервис не
|
||||
выдаёт вовсе: короткий токен файла ушёл 2026-08-22 вместе со встроенным
|
||||
хранилищем, и отзыв доступа доходит до файла сразу, а не через срок жизни
|
||||
выданного значения. Запрет при этом остаётся: **имя файла на диске в журнал не
|
||||
пишется** — строка журнала стала бы бессрочным ключом к чужой записи. В журнал
|
||||
идёт расширение своим полем.
|
||||
- **Идентификатор записи** — ULID, 26 знаков, выдаёт приложение. Он же
|
||||
единственное, что защищает карточку записи, её текст и её файл сверх владения.
|
||||
- **Чужой поверхности на порту сервиса нет.** Адреса `/api/collections/...`,
|
||||
`/api/logs`, `/api/backups`, `/api/settings`, `/api/crons` и панель `/_/` ушли
|
||||
вместе со встроенным хранилищем 2026-08-22. Отвечает сервис только своими
|
||||
адресами, а всё прочее идёт общим правилом неизвестного пути — норму держит
|
||||
[webapp](../openspec/specs/webapp/spec.md). Что содержимое записи закрыто
|
||||
везде, где лежит, нормирует [storage](../openspec/specs/storage/spec.md).
|
||||
|
||||
Целевой периметр добавляет сюда три вещи, и все три — от новых задач:
|
||||
|
||||
@@ -227,33 +221,34 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
заголовка: пересылаемым распоряжается тот, кто шлёт запрос. Значения,
|
||||
переживающего запрос, сервис не выдаёт вовсе — ни куки, ни токена, — и потому
|
||||
отзыв доступа у Authelia действует со следующего обращения.
|
||||
Предъявленный собственный токен хранилища побеждает заголовок: им работает
|
||||
владелец панели, и подмена его учётной записью пользователя отобрала бы у него
|
||||
панель. Протухший и негодный токен предъявленными не считаются.
|
||||
**Область узнавания сужена** до корня приложения и адреса выдачи файлового
|
||||
токена: собственная поверхность хранилища под неё не подпадает, иначе узнанный
|
||||
переписал бы себе ключ учётной записи на чужое имя.
|
||||
Собственных токенов сервис не принимает вовсе: значения, предъявленного
|
||||
запросом и дающего доступ помимо заголовка, у него не существует. Прежде такое
|
||||
значение било заголовок — им работал владелец панели; панели нет, и правило
|
||||
приоритета осталось бы правилом без предмета.
|
||||
**Область узнавания — корень приложения**, и выводится она из объявленного
|
||||
адресного пространства сервиса: слои одеты на корень целиком, вторым списком
|
||||
адресов область не описывается. Проба здоровья, метрики и ресурсы приложения
|
||||
под неё не подпадают — иначе запрос за каждой картинкой стоил бы обращения к
|
||||
базе, а первый такой запрос с новым именем — записи в неё.
|
||||
- **Учётная запись** — заводится первым обращением с новым логином и находится
|
||||
по нему же дальше. Ключ — колонка `provider_login`, уникальная; править её
|
||||
снаружи нельзя, все пять правил доступа коллекции пользователей закрыты шагом
|
||||
схемы `202608220001`.
|
||||
- **Файл записи** — короткий токен файла, который берёт узнанный. Поле файла
|
||||
помечено защищённым, правило просмотра коллекции пускает только владельца
|
||||
файла, и ссылка `/api/files/...` перестала быть правом пройти по ней. Одного
|
||||
заголовка мало: порядок здесь «узнавание → токен файла → ссылка». **Это
|
||||
единственное значение, переживающее запрос**, и на его срок отзыв доступа до
|
||||
файловой ссылки не доходит.
|
||||
снаружи нельзя, потому что адреса правки учётной записи у сервиса нет вовсе:
|
||||
своих экранов профиля он не заводит, а поверхности хранилища, правившей запись
|
||||
библиотечным правилом, не осталось.
|
||||
- **Файл записи** — узнавание пришедшего и владение записью, судимые в самом
|
||||
обработчике отдачи. Отказ наступает **на обращении за файлом**: другого места,
|
||||
где он мог бы наступить, у сервиса не осталось. Значений, переживающих запрос,
|
||||
сервис не выдаёт ни одного, поэтому отзыв доступа доходит и до файла.
|
||||
- **Кто допущен** — **решает Authelia, а не сервис.** Своей проверки группы
|
||||
приложение не делает: кого пускать, определяет правило провайдера на этого
|
||||
клиента. Правило живёт **вне репозитория**, в настройках выкладки, и по коду
|
||||
его не проверить. Клиент, настроенный слишком широко, открывает сервис
|
||||
всякому, у кого есть учётная запись в общей Authelia. Решение владельца от
|
||||
2026-08-12.
|
||||
- **Собственный вход хранилища закрыт целиком.** Создание записи, вход по
|
||||
паролю, одноразовый код, обмен кода у внешнего провайдера, восстановление
|
||||
доступа и продление — ни один не даёт доступа и не меняет учётной записи:
|
||||
хранилище заводит коллекцию пользователей открытой, и без этого закрытия
|
||||
узнавание обходилось бы двумя запросами.
|
||||
- **Собственного входа у сервиса нет вовсе.** Создание записи, вход по паролю,
|
||||
одноразовый код, обмен кода у внешнего провайдера, восстановление доступа и
|
||||
продление принадлежали встроенному хранилищу и ушли вместе с ним: закрывать
|
||||
больше нечего, и адресов этих не существует.
|
||||
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты неузнанному:
|
||||
учётной записи нет ни у пробы, ни у сборщика. Заголовок их ответа не меняет и
|
||||
учётной записи на них не заводит. Наружу их закрывает правило обратного
|
||||
@@ -284,23 +279,27 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
Откуда он берётся — из группы OIDC или из конфигурации — не решено
|
||||
(`admin-stats-screen`).
|
||||
|
||||
**Панель администратора в эту таблицу не входит и разграничению не подчиняется.**
|
||||
Суперпользователь PocketBase видит все записи, все файлы и всех пользователей
|
||||
мимо любого из четырёх механизмов, а пускает его свой пароль, а не Authelia.
|
||||
Замер показал, что закрыть панель провайдером OIDC или вторым фактором нельзя:
|
||||
обе настройки у коллекции суперпользователей отклоняются. Остаётся ограничение
|
||||
по списку адресов (`superuserIPs`), и оно же запирает владельца, если список
|
||||
задан неверно: сброса в наборе команд нет.
|
||||
**Панели администратора в этой таблице нет, и это снятие, а не пропуск.** До
|
||||
2026-08-22 суперпользователь встроенного хранилища видел все записи, все файлы и
|
||||
всех пользователей мимо любого из механизмов разграничения, а пускал его свой
|
||||
пароль, а не Authelia. Хранилище ушло, панели не существует, и разграничение у
|
||||
сервиса осталось одно — владение записью.
|
||||
|
||||
Владелец сервиса взамен получил одно действие и один инструмент: подкоманда
|
||||
`cmd/devtools resume` возвращает остановленную запись в работу. Она ходит **в тот
|
||||
же каталог данных**, то есть требует доступа к файлам сервера, а не к сети:
|
||||
поверхности, открытой в интернет, у неё нет вовсе.
|
||||
|
||||
## Что чувствительнее чего
|
||||
|
||||
1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка;
|
||||
это самое чувствительное, что здесь есть. С 2026-08-14 оно живёт не одной
|
||||
колонкой, а шестью коллекциями: сама запись (заголовок и краткое описание),
|
||||
колонкой, а шестью таблицами: сама запись (заголовок и краткое описание),
|
||||
`texts` (расшифровка и вычитанный текст), `structures` (реплики со временем),
|
||||
`recognitions` (**сырой ответ провайдера вложением — полный текст речи**),
|
||||
`recognitions` (попытка распознавания; **сохранённый ответ провайдера —
|
||||
полный текст речи — лежит файлом в подкаталоге записи**),
|
||||
`record_events` (журнал событий, содержимого не несёт) и `topics` (словарь
|
||||
тем человека). Всякая новая коллекция, куда содержимое переезжает, закрывается
|
||||
тем человека). Всякая новая таблица, куда содержимое переезжает, закрывается
|
||||
наравне с записью — норму держит спека `storage`.
|
||||
2. **Ключи Yandex Cloud** — `speech_kit_api_key` и пара ключей Object Storage.
|
||||
Утечка оплачивается деньгами и доступом к бакету.
|
||||
@@ -325,14 +324,10 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
5. **Статистика потребления** (`usage-accounting`). Текста записей не содержит,
|
||||
но говорит, кто и когда пользовался сервисом и сколько; страница расхода
|
||||
открыта только владельцу.
|
||||
6. **Пароль владельца от панели.** Открывает все записи, все файлы и всех
|
||||
пользователей разом, то есть стоит вровень с самым чувствительным из списка
|
||||
выше. Второй секрет после токенов пользователей, который лежит **не в
|
||||
конфигурации**: его отпечаток хранит сама база, а задаёт пароль сам владелец
|
||||
по приглашению, которое сервис печатает в журнал при первом запуске. У
|
||||
приглашения тридцать минут жизни, и после того как владелец заведён, оно не
|
||||
печатается вовсе — иначе строка журнала отдавала бы панель всякому его
|
||||
читателю навсегда.
|
||||
Пароля владельца от панели в этом списке больше нет: он ушёл 2026-08-22 вместе с
|
||||
самой панелью. Секрет, появившийся только ради перевода на встроенное хранилище,
|
||||
пропал, и ключа под него в конфигурации не заводится по той простой причине, что
|
||||
заводить нечего.
|
||||
|
||||
Тексты расшифровок в логи не пишутся — логируется длина текста и
|
||||
идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано
|
||||
@@ -355,7 +350,7 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
Это закрыто задачей `no-user-filename-in-log` 2026-08-11 вместе с самим именем.
|
||||
Заодно у метки размера принятой записи пропала ведущая точка (`.mp3` стало
|
||||
`mp3`) — форма выровнялась с меткой конвертации, которая точку не носила
|
||||
никогда. Ряды, собранные до выкладки, перестают пополняться: панель, отобранная
|
||||
никогда. Ряды, собранные до выкладки, перестают пополняться: график, отобранный
|
||||
по старому значению, покажет пустоту, и это не поломка.
|
||||
Требование важно тем, что `GET /metrics` открыт вместе с остальным: без
|
||||
приведения хвост читал бы кто угодно из интернета, а множеством значений метки
|
||||
@@ -422,14 +417,13 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а
|
||||
строки потребления текста не содержат.
|
||||
|
||||
**Руками запись сегодня не удаляется, и прежняя строка об этом была неверна.**
|
||||
Проверено прогоном 2026-08-14: содержимое живёт в коллекциях, перечисленных
|
||||
выше («Что чувствительнее чего»), связи приложений с записью обязательны и
|
||||
каскада не имеют, поэтому удаление самой
|
||||
строки записи отвергается хранилищем, а удаление её файлов проходит молча.
|
||||
Владелец, выполнивший прежнюю процедуру, стирает аудио и **оставляет полный
|
||||
текст речи** — расшифровку, разбивку по репликам и сырой ответ провайдера
|
||||
файлом на диске. Порядок, которым запись убирается на самом деле: сперва
|
||||
строки приложений — журнал событий, попытка распознавания вместе с её
|
||||
вложением, структура, тексты, — потом сама запись, потом её файлы. До
|
||||
**Руками запись сегодня убирается только запросом к базе, и порядок в нём
|
||||
несущий.** Содержимое живёт в таблицах, перечисленных выше («Что чувствительнее
|
||||
чего»), связи приложений с записью обязательны и каскада не имеют, поэтому
|
||||
удаление самой строки отвергается базой, пока живы приложения. Порядок такой:
|
||||
сперва строки приложений — журнал событий, попытка распознавания, структура,
|
||||
тексты, связи с темами, — потом сама запись, потом её файлы. Файлы при этом
|
||||
убираются **одним движением**: подкаталог записи под её идентификатором. Тот,
|
||||
кто убрал только файлы, стирает аудио и **оставляет полный текст речи** —
|
||||
расшифровку, разбивку по репликам и сохранённый ответ провайдера. До
|
||||
`delete-record` это единственный способ, и он ручной целиком.
|
||||
|
||||
Reference in New Issue
Block a user