хранилище, файлы записей и очередь переведены на встроенную PocketBase
- записи, метаданные и файлы съехались под один каталог данных; появилась панель владельца, а gin, goqu, goose и требование CGO ушли - захват задачи стал одним запросом с RETURNING; заведены число попыток, состояние dead и нарастающая пауза вместо признака is_error - имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с идентификатором записи оно собирало бы ссылку на скачивание
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
# Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале
|
||||
|
||||
- **Дата:** 2026-08-12
|
||||
- **Источник:** [../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md](../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md),
|
||||
раздел «Поле файла не помечаем защищённым, но ссылка не уезжает в журнал»
|
||||
|
||||
## Решение
|
||||
|
||||
Поле файла в хранилище **не помечается защищённым**: ссылка
|
||||
`/api/files/<коллекция>/<запись>/<имя>` работает без токена, и право пройти по
|
||||
ней даёт знание самой ссылки. Взамен имя файла в хранилище **не пишется в журнал
|
||||
ни в каком виде** — ни на успешном пути, ни в тексте отказа.
|
||||
|
||||
## Почему
|
||||
|
||||
Очевидный подход к файлам, отдаваемым в интернет, — закрыть их: у хранилища для
|
||||
этого есть пометка «защищённое поле», и тогда файл отдаётся только по отдельному
|
||||
файловому токену. Мы от неё отказываемся, и цитата из источника называет причину:
|
||||
|
||||
> Защищённое поле требует отдельного файлового токена. Не помечаем: сегодня право
|
||||
> прочитать задачу даёт знание её идентификатора, и файл встаёт вровень с
|
||||
> `GET /api/status/:id`, а не ниже.
|
||||
|
||||
Отказ дешёв ровно до тех пор, пока ссылку неоткуда взять. Перевод хранилища это
|
||||
условие сломал, и вот чем:
|
||||
|
||||
> Изъятие выписано под путь на диске: `data/files/<uuid>.ogg` читателю журнала
|
||||
> бесполезен. После перевода имя файла в хранилище — это последняя часть ссылки
|
||||
> `/api/files/...`, по которой запись скачивает кто угодно; строка журнала стала
|
||||
> бы бессрочным ключом к чужому аудио.
|
||||
|
||||
Путь был построен ревью и прогнан: отказ чтения из хранилища нёс ключ файла
|
||||
целиком, строка уходила в журнал, а анонимный запрос по собранному адресу
|
||||
отвечал `200` с телом записи. Второй путь шёл через отказ выгрузки в Object
|
||||
Storage — тот несёт полный URL объекта.
|
||||
|
||||
Отсюда вторая половина решения, без которой первая недопустима: **отказы
|
||||
обрываются**. Наружу идёт свой текст с идентификатором записи, а чужая цепочка
|
||||
`%w` — нет. В журнал приёма вместо имени идёт расширение собственным полем;
|
||||
прослеживаемость от этого не страдает.
|
||||
|
||||
Запись попадает в журнал как **намеренный отказ от очевидного подхода**: закрыть
|
||||
файлы токеном предложат снова, и без записанной причины предложение выглядит
|
||||
бесплатным.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` ссылка работает без токена, и приёмка проверяется обычным запросом; будущее
|
||||
приложение получает файл без отдельного механизма выдачи токенов.
|
||||
- `+` изъятие из инварианта приватности не расширилось: в журнале по-прежнему
|
||||
только расширение, а не имя.
|
||||
- `−` ссылка, единожды утёкшая, работает бессрочно: отзыва у неё нет, а файлы не
|
||||
удаляются вовсе. Утечка возможна не только журналом — любой будущий экран,
|
||||
показывающий ссылку, наследует это свойство.
|
||||
- `−` появилась норма, которую держит не построение, а внимание: всякий новый
|
||||
отказ хранилища надо обрывать руками. Норму сторожат требование capability
|
||||
`storage` и проверка журнала, но компилятор — нет.
|
||||
- `−` диагностируемость отказов упала: обрывая цепочку, мы теряем причину. У
|
||||
выгрузки в Object Storage это смягчено — сохраняется класс отказа SDK
|
||||
(`AccessDenied`, `NoSuchBucket`), в котором адреса не бывает.
|
||||
- Решение действует до разграничения доступа: задачи `oidc-login` и
|
||||
`record-ownership` меняют условие, и тогда пометку стоит пересмотреть новой
|
||||
записью.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Каталог данных задаётся одним ключом `[storage] data_dir`
|
||||
|
||||
- **Дата:** 2026-08-12
|
||||
- **Источник:** [../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md](../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md),
|
||||
раздел «Ключи конфигурации: два пути заменяются одним каталогом»
|
||||
|
||||
## Решение
|
||||
|
||||
Ключи `[database] path` и `[storage] path` уходят. Вместо них — один
|
||||
`[storage] data_dir` со значением `data`: база и файлы записей лежат под одним
|
||||
каталогом, и по-другому хранилище не умеет.
|
||||
|
||||
Выбор сделан человеком 2026-08-12 из трёх названных вариантов.
|
||||
|
||||
## Почему
|
||||
|
||||
Имя ключа конфигурации проект объявил необратимым
|
||||
([../../CLAUDE.md](../../CLAUDE.md), «Работа»): переименование правится не в
|
||||
одном файле, а в конфигурации на сервере и в выкладке, и молча ломает запуск.
|
||||
Поэтому выбор ушёл человеку, а не был принят по ходу.
|
||||
|
||||
Цитата источника о цене каждого варианта:
|
||||
|
||||
> - `[storage] data_dir` — **выбрано**. Ключ назван по назначению, как названы и
|
||||
> сегодняшние; смена библиотеки через год имени не тронет. Слово `storage` при
|
||||
> этом уже занято capability, но в конфигурации оно значит ровно то же — где
|
||||
> лежат данные;
|
||||
> - `[pocketbase] data_dir` — прямее всего читается тем, кто знает библиотеку, и
|
||||
> вписывает имя поставщика в необратимый ключ. Смена библиотеки потребует
|
||||
> второго необратимого переименования;
|
||||
> - `[data] dir` — короче и нейтральнее всех, но `data` в проекте уже значит
|
||||
> каталог на диске, и секция с таким именем читается как «настройки каталога»,
|
||||
> а не «настройки хранилища».
|
||||
|
||||
Запись попадает в журнал по **дорогому откату**: переименование ключа стоит
|
||||
правки конфигурации на сервере и в выкладке, а ошибка проявляется отказом старта.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` имя ключа не называет поставщика, и смена библиотеки хранилища второго
|
||||
необратимого переименования не потребует.
|
||||
- `+` каталог данных один, и запрет «боевой каталог не трогать» покрывает и базу,
|
||||
и записи одной строкой.
|
||||
- `−` слово `storage` в проекте теперь значит три вещи: capability, само
|
||||
хранилище и секцию конфигурации. Поле записи о файле от этого переименовано в
|
||||
`location` — чтобы смыслов было три, а не четыре.
|
||||
- `−` прежние конфигурации несовместимы: сервис на старом `config.toml`
|
||||
поднимется на умолчании `data`, а не на прежних путях. Данные при этом не
|
||||
переносятся по решению задачи, так что цена нулевая ровно сейчас и была бы не
|
||||
нулевой при переносе.
|
||||
@@ -32,6 +32,8 @@
|
||||
|
||||
| Дата | Запись | Статус |
|
||||
| --- | --- | --- |
|
||||
| 2026-08-12 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](ADR-2026-08-12-file-link-open-but-not-logged.md) | |
|
||||
| 2026-08-12 | [Каталог данных задаётся одним ключом `[storage] data_dir`](ADR-2026-08-12-single-data-dir-config-key.md) | |
|
||||
| 2026-08-11 | [Границу распознавания доменного признака держит норма, а не код](ADR-2026-08-11-domain-marker-boundary-by-norm.md) | |
|
||||
| 2026-08-11 | [Отказ, который решено не проверять, объявляется поимённо](ADR-2026-08-11-errcheck-check-blank.md) | |
|
||||
| 2026-08-11 | [Наружу расширение выходит только приведённым к перечню](ADR-2026-08-11-known-format-label.md) | |
|
||||
|
||||
+35
-36
@@ -8,16 +8,19 @@
|
||||
[passport.md](passport.md) и в [tasks/ROADMAP.md](../tasks/ROADMAP.md); что из
|
||||
этого ещё не решено — в разделе «Открытые вопросы».
|
||||
|
||||
Заведены две capability, и каждая описана частично:
|
||||
Заведены три capability:
|
||||
|
||||
- [intake](../openspec/specs/intake/spec.md) — **только приём по HTTP**: его
|
||||
нормируют проверки, написанные задачей `http-handler-tests-never-green`
|
||||
2026-08-11;
|
||||
- [pipeline](../openspec/specs/pipeline/spec.md) — **только пустой прогон
|
||||
воркера**: задача `errors-as-instead-of-typecast` 2026-08-11. Переходы
|
||||
состояний, захват и срок его протухания, отмена контекста посреди шага в неё
|
||||
**не** переехали и остаются долгом; что именно не описано, перечисляет раздел
|
||||
`Purpose` самой спеки.
|
||||
- [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
|
||||
задачи и срок его протухания, число попыток, состояние «мертва» и пауза перед
|
||||
повтором: задачи `errors-as-instead-of-typecast` 2026-08-11 и
|
||||
`pocketbase-storage` 2026-08-12. Переходы состояний и отмена контекста посреди шага остаются
|
||||
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
|
||||
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
|
||||
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage`
|
||||
2026-08-12.
|
||||
|
||||
Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в
|
||||
коде. Задача, которая его трогает, дописывает спеку своей capability.
|
||||
@@ -26,17 +29,16 @@
|
||||
|
||||
- **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и
|
||||
делят одну базу. Отдельного воркер-процесса нет намеренно.
|
||||
- **Очередь таблицей.** Состояние задачи лежит в SQLite, воркер забирает работу
|
||||
запросом с захватом. Внешний брокер не заводим: нагрузка — единицы записей в
|
||||
день (оценка владельца, не замер). Готовую библиотеку очереди тоже не заводим —
|
||||
решено 2026-08-11,
|
||||
- **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища, воркер
|
||||
забирает работу одним запросом с захватом. Внешний брокер не заводим: нагрузка
|
||||
— единицы записей в день (оценка владельца, не замер). Готовую библиотеку
|
||||
очереди тоже не заводим — решено 2026-08-11,
|
||||
[ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение
|
||||
кандидатов в [research/job-queue.md](research/job-queue.md).
|
||||
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: спека заведена, но это в ней не описано -->
|
||||
- **Шаг конвейера идемпотентен по повтору.** Задача, брошенная на середине,
|
||||
достаётся снова по истечении срока захвата и проходит шаг заново.
|
||||
- **Ядро зависит от интерфейсов.** `internal/service` знает только
|
||||
`internal/contract`; ffmpeg, Yandex, Telegram и SQLite подставляются в
|
||||
`internal/contract`; ffmpeg, Yandex, Telegram и хранилище подставляются в
|
||||
`main.go`.
|
||||
|
||||
## Компоненты
|
||||
@@ -54,12 +56,15 @@
|
||||
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
|
||||
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit |
|
||||
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
|
||||
| Репозитории | `internal/adapter/repo/sqlite` | Задачи и файлы, запросы через goqu |
|
||||
| Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом |
|
||||
| Панель владельца | там же, `panel.go` | Правка задачи в панели проходит те же правила перехода, что и правка из кода |
|
||||
|
||||
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: спека заведена, но это в ней не описано -->
|
||||
|
||||
Конвейер: `created` → `converted` → `transcribe` → `done` либо `failed`. Три
|
||||
воркера двигают по одному переходу, каждый опрашивает базу раз в секунду.
|
||||
воркера двигают по одному переходу, каждый опрашивает базу раз в секунду. Задача,
|
||||
исчерпавшая попытки, уходит в `dead` мимо этой цепочки: её переводит туда не шаг,
|
||||
а тот, кто её захватил.
|
||||
|
||||
## Внешние границы и форматы
|
||||
|
||||
@@ -91,7 +96,7 @@
|
||||
| Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
|
||||
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
|
||||
| ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла» | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
|
||||
| SQLite (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
|
||||
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
|
||||
| Диск | Запись файла падает, задача не заводится | — | — | — |
|
||||
|
||||
- **Кто заметит отказ и когда:** пользователь Telegram — сразу, по молчанию бота
|
||||
@@ -107,7 +112,9 @@
|
||||
| Что | Где |
|
||||
| --- | --- |
|
||||
| Приём аудио и заведение задачи | `TranscribeService.createTranscribeJob` — через него идут оба входа |
|
||||
| Захват задачи воркером | `TranscriptJobRepository.FindAndAcquire` |
|
||||
| Правка задачи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает |
|
||||
| Захват задачи воркером | `TranscriptJobRepository.FindAndAcquire` — один запрос с `RETURNING` |
|
||||
| Рабочая копия файла на диске | `FileRepository.Localize`, `Stage`, `StageEmpty` — они же дают единственный способ её убрать (`WorkFile.Close`); зовёт его шаг |
|
||||
| Переход задачи в состояние | `entity.TranscribeJob.MoveToState` — чистит служебные поля прошлого состояния |
|
||||
| Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю |
|
||||
| Разбор конфигурации | `internal/config.LoadConfig` |
|
||||
@@ -131,14 +138,9 @@
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- **Хранилище.** 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 этим
|
||||
снимается. Чем становится конвейер задач, решено 2026-08-11 — см. «Очередь»
|
||||
ниже. Данные не переносим — начинаем с чистого листа.
|
||||
- **Учётные записи.** Вход через OIDC, провайдер — Authelia, а ответ провайдера
|
||||
обрабатывает PocketBase, а не наш код (тот же ADR). Не решено, где живёт сессия
|
||||
обрабатывает PocketBase, а не наш код
|
||||
([ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)). Не решено, где живёт сессия
|
||||
и как связываются пользователь Telegram и пользователь веба. Панель
|
||||
администратора при этом Authelia не закрывает: у неё свой пароль
|
||||
суперпользователя.
|
||||
@@ -168,25 +170,22 @@
|
||||
копится — записи о потреблении или счётчики — решает задача
|
||||
`usage-accounting`.
|
||||
- **Срок хранения.** Записи и тексты решено хранить бессрочно (паспорт,
|
||||
2026-08-11), а рост каталога `data/files` ничем не ограничен и не наблюдается.
|
||||
2026-08-11), а рост каталога данных ничем не ограничен и не наблюдается.
|
||||
- **Резервные копии.** Копии делает сервер своими средствами, и приложение о них
|
||||
ничего не знает. После переезда на PocketBase не решено, хватит ли копировать
|
||||
её каталог файлами, или приложению нужна команда выгрузки: база под нагрузкой
|
||||
копируется файлом не всегда целой. Своё копирование по расписанию у PocketBase
|
||||
есть — берём мы его или нет, тоже не решено.
|
||||
ничего не знает. Не решено, хватит ли копировать каталог данных файлами, или
|
||||
приложению нужна команда выгрузки: база под нагрузкой копируется файлом не
|
||||
всегда целой. Своё копирование по расписанию у PocketBase есть — берём мы его
|
||||
или нет, тоже не решено.
|
||||
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
|
||||
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
|
||||
сервис определяет содержимое сам, то ли часть записей теряется на этом.
|
||||
- **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но
|
||||
конвертер этот случай не проверялся.
|
||||
- **Очередь.** Модель очереди решена 2026-08-11: остаётся своей таблицей и
|
||||
становится коллекцией PocketBase, захват сворачивается в один запрос с
|
||||
`RETURNING`, число попыток ложится колонкой, а исчерпавшая их задача переходит
|
||||
в состояние «мертва» вместо `is_error = 1`
|
||||
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)). Пишет это
|
||||
`pocketbase-storage` тем же заходом, что и хранилище. Не решено, отказываться
|
||||
ли от холостого опроса: три воркера дают 259 200 запросов в сутки при нагрузке
|
||||
в единицы записей в день, и во что это обходится, никто не мерил.
|
||||
- **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12
|
||||
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)) и нормирована
|
||||
спекой `pipeline`. Не решено, отказываться ли от холостого опроса: три воркера
|
||||
дают 259 200 запросов в сутки при нагрузке в единицы записей в день, и во что
|
||||
это обходится, никто не мерил.
|
||||
- **Наблюдаемость.** `/metrics` остаётся и развивается. Чем — дописывать
|
||||
счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой —
|
||||
решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
|
||||
- **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется
|
||||
**приложением** в момент создания записи.
|
||||
*Расхождение:* идентификаторы записей выдаёт хранилище — 15 знаков
|
||||
собственного алфавита. Своей точки генерации у приложения нет, и `ORDER BY id`
|
||||
хронологией не является: порядок берут по колонке времени с ключом.
|
||||
- Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология),
|
||||
компактен и удобен в URL и логах (без дефисов — grep и двойной клик берут id
|
||||
целиком), глобально уникален между таблицами — поиск по голому id находит все
|
||||
@@ -43,14 +46,21 @@
|
||||
|
||||
- Enum-поля (`state`, `source`, …) — обычный `TEXT` без `CHECK`; допустимые
|
||||
значения держит код.
|
||||
*Расхождение:* перечень состояний задачи закрыт схемой (`SelectField`), а не
|
||||
кодом — ради панели владельца: правка руками не должна заводить состояние,
|
||||
которого конвейер не знает. Цена названа: шестое состояние потребует нового
|
||||
шага схемы.
|
||||
- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например
|
||||
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
|
||||
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
|
||||
Единая точка генерации — приложение, а не умолчание в схеме: так забытая
|
||||
вставка падает громко. Измерение длительности — не метка времени.
|
||||
- Миграции — goose (`migrations/`): SQL-файлы для DDL; Go-миграции
|
||||
(`goose.AddMigrationContext`) — когда нужен код (генерация id, заполнение
|
||||
задним числом). При изменении структуры обновляем схему
|
||||
- Миграции — шаги PocketBase на Go (`internal/adapter/repo/pocketbase`):
|
||||
коллекции и их поля заводятся кодом. При изменении структуры обновляем схему
|
||||
[../database.md](../database.md) тем же изменением.
|
||||
- Добавляя колонку, соблюдай инвариант «Новая колонка правится во всех четырёх
|
||||
местах» — [CLAUDE.md](../../CLAUDE.md), «Инварианты».
|
||||
- Время в **сыром запросе** кладётся и сравнивается тем же видом, каким
|
||||
хранилище пишет свои `created`/`updated`. Сравнение строк побайтово, и
|
||||
разошедшийся вид обращает условие в постоянную истину или ложь — молча.
|
||||
- Выборка «следующей» записи с `LIMIT 1` дополняется ключом в `ORDER BY`:
|
||||
сравнение по неуникальному значению делает порядок обработки
|
||||
невоспроизводимым.
|
||||
|
||||
@@ -202,8 +202,13 @@ Object Storage, скачивание файла из Telegram и опрос оп
|
||||
периодически, на `INFO` они забивают разбор шумом. В продакшене при базовом
|
||||
`INFO` они не пишутся.
|
||||
|
||||
*Расхождение:* `sloggin` пишет все запросы одинаково, `/health` и `/metrics`
|
||||
попадают в лог наравне с остальными.
|
||||
Расхождения здесь больше нет: слой журналирования запросов свой,
|
||||
`main.go`, хук `OnServe` — вместе с gin ушёл и `sloggin`. `/health` и `/metrics`
|
||||
идут на `DEBUG`, то есть при боевом `INFO` не пишутся вовсе.
|
||||
|
||||
Хранилище ведёт **свой** журнал запросов в собственной таблице, и он виден
|
||||
владельцу в панели. Заменой потоку процесса он не служит: в журнал контейнера,
|
||||
по которому разбирают отказы, эта таблица не попадает.
|
||||
|
||||
## Безопасность: что не логируем
|
||||
|
||||
|
||||
+107
-59
@@ -1,101 +1,149 @@
|
||||
# Схема хранилища
|
||||
|
||||
СУБД, миграции, правило времени и идентификаторов.
|
||||
Хранилище, коллекции, правило времени и идентификаторов.
|
||||
|
||||
СУБД — SQLite, драйвер `mattn/go-sqlite3` (нужен CGO). Запросы строит
|
||||
`doug-martin/goqu` с диалектом `sqlite3`. Миграции — `pressly/goose`, каталог
|
||||
`migrations/`, вшит в бинарник через `//go:embed migrations/*.sql` в `main.go` и
|
||||
накатывается при старте. Новый файл достаточно положить в каталог.
|
||||
Хранилище — **встроенная PocketBase 0.39.10**: она держит и базу, и файлы
|
||||
записей под одним каталогом данных. Ключ конфигурации — `[storage] data_dir`,
|
||||
умолчание `data`. В SQLite библиотека ходит через `modernc.org/sqlite`, поэтому
|
||||
CGO сборке не нужен.
|
||||
|
||||
**Идентификаторы** — UUID v4 строкой.
|
||||
Схему двигают **шаги миграций PocketBase** на Go, каталог
|
||||
`internal/adapter/repo/pocketbase`, файл шага — `migrations.go`. Шаг
|
||||
регистрируется при загрузке пакета, а накатывается при подъёме хранилища
|
||||
(`pocketbase.New`), прежде чем стартуют воркеры и сервер. Применённый шаг не
|
||||
переписывается — изменение только новым шагом: применённое хранилище считает по
|
||||
имени файла.
|
||||
|
||||
**Время** — локальная зона процесса, UTC не навязан. Колонки `created_at` и
|
||||
`updated_at` проставляет приложение, а не СУБД; умолчание `CURRENT_TIMESTAMP`
|
||||
стоит только у `files.created_at`.
|
||||
**Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита.
|
||||
Свои UUID остались только в **именах файлов**: имя, под которым запись ложится в
|
||||
хранилище, задаёт сервис, и это `<uuid><расширение>`.
|
||||
|
||||
**Время** — вид хранилища: строка `2006-01-02 15:04:05.000Z` в UTC. Колонки
|
||||
`created` и `updated` проставляет само хранилище; те же поля в сыром запросе
|
||||
захвата кладёт наш код — **тем же видом**, потому что сравнение строк в SQLite
|
||||
побайтово, и разошедшийся вид обратил бы условие срока в постоянную истину или
|
||||
постоянную ложь молча.
|
||||
|
||||
Того, что единой точки генерации идентификатора и времени нет, здесь не
|
||||
повторяем: перечень единых точек и их отсутствий держит
|
||||
[architecture.md](architecture.md), «Единые точки проекта».
|
||||
|
||||
Переход на PocketBase запланирован, но не начат — здесь описано сегодняшнее
|
||||
состояние. Открытые вопросы перехода — в
|
||||
[architecture.md](architecture.md), раздел «Открытые вопросы».
|
||||
|
||||
## Таблицы
|
||||
## Коллекции
|
||||
|
||||
### `files`
|
||||
|
||||
Один файл на одну физическую копию: исходник, результат конвертации и копия в
|
||||
Object Storage — три разные записи.
|
||||
|
||||
| Колонка | Тип | Что |
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `id` | TEXT PK | UUID файла |
|
||||
| `storage` | TEXT | `local` или `s3` |
|
||||
| `file_name` | TEXT | Имя в хранилище: UUID с расширением |
|
||||
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
||||
| `file` | file | Сам файл; пусто у копии в Object Storage |
|
||||
| `location` | select | `local` или `s3` |
|
||||
| `object_key` | TEXT | Ключ объекта; пусто у местной копии |
|
||||
| `size` | INTEGER | Размер в байтах |
|
||||
| `created_at` | DATETIME | Умолчание `CURRENT_TIMESTAMP` |
|
||||
| `created`, `updated` | DATETIME | Проставляет хранилище |
|
||||
|
||||
Поле названо `location`, а не `storage`: последним словом зовут само хранилище и
|
||||
capability, и третий смысл развёл бы одно слово по разным вещам.
|
||||
|
||||
### `transcribe_jobs`
|
||||
|
||||
Задача расшифровки и она же очередь.
|
||||
|
||||
| Колонка | Тип | Что |
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `id` | TEXT PK | UUID задачи |
|
||||
| `state` | TEXT | `created`, `converted`, `transcribe`, `done`, `failed` |
|
||||
| `source` | TEXT | `api`, `telegram`, `unknown`; умолчание `unknown` |
|
||||
| `file_id` | TEXT FK → `files.id` | **Текущий** файл задачи: шаг конвейера переставляет ссылку на свой результат |
|
||||
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
||||
| `state` | select | `created`, `converted`, `transcribe`, `done`, `failed`, `dead`; перечень закрыт схемой |
|
||||
| `source` | select | `api`, `telegram`, `unknown` |
|
||||
| `file` | relation → `files` | **Текущий** файл задачи: шаг конвейера переставляет ссылку на свой результат |
|
||||
| `delay_time` | DATETIME | Не брать задачу раньше этого времени |
|
||||
| `acquisition_id` | TEXT | Кто захватил задачу |
|
||||
| `acquire_time` | DATETIME | Когда захватил; по нему считается протухание |
|
||||
| `attempts` | INTEGER ≥ 0 | Число попыток: растёт при захвате, обнуляется на шаге без отказа |
|
||||
| `recognition_op_id` | TEXT | Идентификатор операции в Yandex Cloud |
|
||||
| `transcription_text` | TEXT | Результат распознавания |
|
||||
| `is_error` | BOOLEAN | Задача с `1` из выборки исключена навсегда |
|
||||
| `transcription_text` | editor | Результат распознавания |
|
||||
| `error_text` | TEXT | Текст ошибки, машинный |
|
||||
| `tg_chat_id` | INTEGER | Куда отправить результат |
|
||||
| `tg_reply_message_id` | INTEGER | С каким сообщением связать |
|
||||
| `created_at`, `updated_at` | DATETIME | Проставляет приложение |
|
||||
| `created`, `updated` | DATETIME | Проставляет хранилище |
|
||||
|
||||
Индексов, кроме первичных ключей, нет. Выборка воркера идёт полным перебором по
|
||||
`state`, `is_error`, `delay_time` и `acquire_time`.
|
||||
Индекс один — по `state`: выборка воркера идёт по нему, паузе и сроку захвата.
|
||||
Прежней колонки `is_error` нет: задача выбывает из выборки состоянием, и способ
|
||||
этот один.
|
||||
|
||||
**Состояния `failed` и `dead` — разные приговоры.** В `failed` задачу переводит
|
||||
шаг, рассудивший об этой записи окончательно; в `dead` она уходит без такого
|
||||
суждения — мы повторяли и перестали. Ни один шаг конвейера в `dead` не переводит
|
||||
сам: это делает тот, кто захватил задачу с превышенным счётчиком.
|
||||
|
||||
**Правила доступа обеих коллекций пусты**, то есть перечислять и читать записи
|
||||
может только владелец панели. Проверено прогоном: анонимный запрос к
|
||||
`/api/collections/*/records` отвечает `403`, к `/api/logs`, `/api/backups`,
|
||||
`/api/settings` и `/api/crons` — `401`.
|
||||
|
||||
## Представление данных
|
||||
|
||||
Чем физически лежит запись и что происходит при чтении и записи.
|
||||
|
||||
- **Расшифровка лежит целиком в колонке `transcription_text`** одной строкой.
|
||||
- **Расшифровка лежит целиком в поле `transcription_text`** одной строкой.
|
||||
Запись длиной в час даёт десятки килобайт в одной ячейке; читается она
|
||||
целиком при каждом `GetByID` и при каждом захвате задачи воркером.
|
||||
- **Аудио в базе не лежит.** На диске — каталог `data/files`, плоский, имя файла
|
||||
равно UUID с расширением. Ни файлы, ни объекты в Object Storage не удаляются
|
||||
после завершения задачи: каталог и бакет растут неограниченно.
|
||||
- **Захват задачи — два запроса подряд, не транзакция.** Сперва `UPDATE …
|
||||
WHERE id = (SELECT … LIMIT 1)` проставляет `acquisition_id`, затем отдельный
|
||||
`SELECT … WHERE acquisition_id = ?` читает строку. Репозиторий сверяет число
|
||||
затронутых строк с ожидаемым, но между запросами задачу может перехватить
|
||||
другой воркер с тем же значением — на одном процессе это не наблюдалось.
|
||||
- **Список колонок задан не одним местом** — четырьмя запросами файла
|
||||
`internal/adapter/repo/sqlite/transcript_job_repo.go`. Правило правки всех
|
||||
четырёх и его severity — инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты».
|
||||
целиком при каждом чтении задачи и при каждом захвате.
|
||||
- **Аудио лежит в раскладке хранилища:**
|
||||
`data/storage/<коллекция>/<запись>/<имя>` рядом с файлом атрибутов. Имя задаёт
|
||||
сервис — `<uuid><расширение>`; собственного суффикса хранилище не дописывает,
|
||||
потому что умолчание, строящее имя из имени отправителя, не применяется. Ни
|
||||
файлы, ни объекты в Object Storage не удаляются после завершения задачи:
|
||||
каталог и бакет растут неограниченно.
|
||||
- **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
|
||||
не помечено защищённым: право прочитать запись даёт знание её идентификатора,
|
||||
и файл встаёт вровень с опросом готовности задачи. Поэтому имя файла в
|
||||
хранилище **в журнал не пишется** — оно последняя часть ссылки.
|
||||
- **Захват задачи — один запрос с `RETURNING`**, мимо записей коллекции.
|
||||
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
|
||||
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
|
||||
заведения **и по ключу**: время неуникально, и без ключа порядок обработки
|
||||
невоспроизводим.
|
||||
- **Запись результата условна по признаку захвата.** Шаг, чей захват за время
|
||||
работы достался другому, завершается без записи и без ответа отправителю.
|
||||
- **Список колонок задан четырьмя местами** — `applyToRecord`, `recordToJob`,
|
||||
константой `acquireColumns` и структурой `acquiredRow`, — плюс шагом схемы.
|
||||
Все четыре лежат в одном пакете, но компилятор видит два: правило правки и его
|
||||
серьёзность (critical/major) — инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты».
|
||||
- **Отказ хранилища наружу не выходит дословно.** Он несёт ключ файла целиком, а
|
||||
ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают
|
||||
свой текст с идентификатором записи, а цепочку `%w` обрывают. То же у выгрузки
|
||||
в Object Storage: отказ SDK несёт полный URL объекта.
|
||||
|
||||
## Настройки с числовым значением
|
||||
|
||||
| Настройка | Значение | Где |
|
||||
| --- | --- | --- |
|
||||
| Срок захвата, конвертация и распознавание | 1 час | `service/transcribe.go`, вызовы `findJob` |
|
||||
| Срок захвата, проверка операции | 24 часа | там же |
|
||||
| Пауза воркера между попытками | 1 секунда | `controller/worker/worker.go` |
|
||||
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` |
|
||||
| Задержка между проверками операции | 5 секунд | там же |
|
||||
| Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` |
|
||||
| Память под multipart-загрузку | 32 МиБ | `main.go`, `router.MaxMultipartMemory` |
|
||||
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` |
|
||||
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` |
|
||||
| Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` |
|
||||
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` |
|
||||
| Настройка | Значение | Где | Откуда число |
|
||||
| --- | --- | --- | --- |
|
||||
| Предел попыток | 5 | `service/transcribe.go` | обычное умолчание, не замер |
|
||||
| Пауза перед повтором | `2^(попытка−1)` с, потолок 5 минут | там же | то же |
|
||||
| Срок захвата, конвертация | 8 часов | там же | потолок записи 6 часов плюс запас |
|
||||
| Срок захвата, распознавание | 8 часов | там же | то же |
|
||||
| Срок захвата, проверка операции | 1 час | там же | опрос идёт секунды |
|
||||
| Задержка перед первой проверкой операции | 10 секунд | там же | как было |
|
||||
| Задержка между проверками операции | 5 секунд | там же | как было |
|
||||
| Пауза воркера между попытками | 1 секунда | `controller/worker/worker.go` | как было |
|
||||
| Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` | предел Telegram |
|
||||
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
|
||||
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
|
||||
| Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` | — |
|
||||
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
|
||||
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
|
||||
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
|
||||
|
||||
Чего среди настроек **нет**: режим журналирования SQLite не задан (значение по
|
||||
умолчанию, не WAL), таймаут занятости не задан, размер пула соединений не задан,
|
||||
срока хранения файлов и объектов нет вовсе. Таймаутов у обращений к Telegram, S3 и
|
||||
SpeechKit тоже нет — ни одного.
|
||||
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у
|
||||
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля
|
||||
библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело
|
||||
на 32 МиБ раньше обработчика. Оставленные умолчания отвергали бы всё длиннее
|
||||
примерно пяти минут. Таймаут чтения запроса снят: шесть часов записи по
|
||||
медленному каналу переживают любой фиксированный, а стойкость к целенаправленной
|
||||
нагрузке объявлена вне модели угроз.
|
||||
|
||||
Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер
|
||||
пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения
|
||||
файлов и объектов нет вовсе. Таймаутов у
|
||||
обращений к Telegram, S3 и SpeechKit тоже нет — ни одного.
|
||||
|
||||
@@ -22,6 +22,7 @@ SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, чт
|
||||
|
||||
| Дата | Запись | О чём |
|
||||
| --- | --- | --- |
|
||||
| 2026-08-12 | [PocketBase: умолчания, которые ломают штатный сценарий](pocketbase-defaults.md) | Потолок файла 5 МиБ, тело 32 МиБ, таймаут чтения, суффикс имени, хук правки |
|
||||
| 2026-08-11 | [gRPC-клиент SpeechKit: когда закрытие вообще может отказать](grpc-client-close.md) | Ленивое соединение и два исхода `Close` в grpc v1.74.2 |
|
||||
| 2026-08-11 | [Фреймворк приложения: Svelte, Vue и React на одном экране](spa-framework.md) | Размер собранной статики, цена шага сборки, что у трёх кандидатов одинаково |
|
||||
| 2026-08-11 | [Очередь задач: своя таблица против готовой библиотеки](job-queue.md) | Цена River и goqite в пакетах, захват одним запросом, чего нет для PocketBase |
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# PocketBase: умолчания, которые ломают штатный сценарий
|
||||
|
||||
Наблюдения, снятые по ходу задачи `pocketbase-storage` уже на своём коде. От
|
||||
[записки разведки](pocketbase.md) отличаются предметом: та мерила, **что даёт
|
||||
панель**, эта — **что библиотека делает молча**, если её не переубедить.
|
||||
|
||||
Все четыре наблюдения нашлись ревью, а не чтением документации: три из них
|
||||
выглядят как «значение по умолчанию — нет ограничения», а значат обратное.
|
||||
|
||||
## Как снималось
|
||||
|
||||
Версия **0.39.10**, та же, что у первой записки. Прогоны — на пустом каталоге
|
||||
данных во временном каталоге и на поднятом сервере `127.0.0.1:18099`; боевые
|
||||
данные и ключи не участвовали. Числа ниже сняты 2026-08-11 и 2026-08-12.
|
||||
|
||||
## Нулевой потолок у поля файла значит 5 МиБ, а не «без предела»
|
||||
|
||||
`&core.FileField{MaxSize: 0}` читается библиотекой как её собственное умолчание:
|
||||
|
||||
```
|
||||
core/field_file.go:28 const DefaultFileFieldMaxSize int64 = 5 << 20
|
||||
core/field_file.go:310 if f.MaxSize <= 0 { return DefaultFileFieldMaxSize }
|
||||
```
|
||||
|
||||
Проверено укладкой: файл в 6 МиБ отвергается на сохранении записи —
|
||||
`the maximum allowed file size is 5242880 bytes`. Прогон через боевой роутер дал
|
||||
границу дословно:
|
||||
|
||||
| тело запроса | ответ |
|
||||
| --- | --- |
|
||||
| 4 194 304 байта | `201` |
|
||||
| 5 238 784 байта | `201` |
|
||||
| 5 246 976 байт | `500` |
|
||||
| 34 603 008 байт | `413` |
|
||||
|
||||
**Цена для сервиса:** 5 МиБ — это примерно 5,5 минут mp3 при 128 кбит/с. Отвергалась
|
||||
бы не только длинная запись на приёме: результат конвертации в ogg переваливает
|
||||
тот же порог примерно на пятой минуте, и **уже принятая** задача исчерпывала бы
|
||||
попытки на шаге конвертации.
|
||||
|
||||
## Тело запроса режется на 32 МиБ раньше обработчика
|
||||
|
||||
`apis/base.go:36` вешает `BodyLimit(DefaultMaxBodySize)` на **корневой** роутер,
|
||||
то есть и на чужие маршруты; `apis/middlewares_body_limit.go:14` —
|
||||
`const DefaultMaxBodySize int64 = 32 << 20`. Ответ `413` уходит мимо обработчика,
|
||||
без строки в журнале приёма (последняя строка таблицы выше).
|
||||
|
||||
Снимается на маршруте: `.Bind(apis.BodyLimit(<своё число>))`.
|
||||
|
||||
## Таймаут чтения запроса — пять минут
|
||||
|
||||
`apis/serve.go:151` ставит `ReadTimeout: 5 * time.Minute`. Заливка шестичасовой
|
||||
записи по медленному каналу его переживает: соединение рвётся на середине.
|
||||
Снимается в хуке `OnServe` — `se.Server.ReadTimeout = 0`.
|
||||
|
||||
## Суффикс к имени файла дописывает конструктор, а не укладка
|
||||
|
||||
Первая записка наблюдала `sample.ogg → sample_uztrv6wvz3.ogg` и читала это как
|
||||
свойство хранилища. Наблюдение верно **только когда имя строит сама библиотека**:
|
||||
десять случайных знаков добавляет `normalizeName`, вызываемый из
|
||||
`filesystem.NewFileFrom*`. Имя, положенное в поле `File.Name` после
|
||||
конструктора, ложится на диск дословно:
|
||||
|
||||
```
|
||||
задано 11111111-2222-3333-4444-555555555555.mp3
|
||||
на диске 11111111-2222-3333-4444-555555555555.mp3
|
||||
```
|
||||
|
||||
**Цена:** тот, кто задаёт имя сам, не получает от суффикса никакой
|
||||
неугадываемости — и защищать ссылку на файл ему приходится другим.
|
||||
|
||||
## Хук правки записи не различает, кто пишет
|
||||
|
||||
`app.OnRecordUpdate(<коллекция>)` — событие **модели**: оно срабатывает на каждом
|
||||
`app.Save`, включая сохранение из собственного кода. Хук, написанный «для
|
||||
панели», правил записи конвейера: проверено прогоном — задержка, поставленная
|
||||
шагом вместе со сменой состояния, обнулялась тем же сохранением.
|
||||
|
||||
Различает источник `app.OnRecordUpdateRequest(<коллекция>)`: оно поднимается
|
||||
только на правку запросом, а код, пишущий мимо HTTP-слоя, под него не попадает.
|
||||
|
||||
## Приглашение завести владельца панели живёт полчаса
|
||||
|
||||
`apis/installer.go:31` — `systemSuperuser.NewStaticAuthToken(30 * time.Minute)`;
|
||||
печатается только пока владельца нет (`needInstallerSuperuser`). Проверено
|
||||
прогоном: при первом запуске строка со ссылкой в журнале есть, после заведения
|
||||
владельца при следующем запуске её нет.
|
||||
|
||||
## Чего эта записка не узнала
|
||||
|
||||
- **Во что обходится потолок в 8 ГиБ на диске.** Число выбрано расчётом из
|
||||
шестичасовой записи с запасом на видео, а не замером: настоящего распределения
|
||||
длин у сервиса нет.
|
||||
- **Как ведёт себя укладка файла в несколько гигабайт.** Самая длинная проверенная
|
||||
запись — 9,6 МБ (десять минут mp3). Потоковую укладку это подтверждает, предел
|
||||
— нет.
|
||||
- **Поведение под одновременной правкой панели и конвейера в бою.** Проверено
|
||||
тестом на одной машине, не живой нагрузкой.
|
||||
@@ -69,6 +69,10 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
|
||||
Проверено загрузкой файла в 200 КБ: имя `sample.ogg` превратилось в
|
||||
`sample_uztrv6wvz3.ogg`, рядом лёг файл атрибутов.
|
||||
|
||||
*Уточнено 2026-08-12:* суффикс дописывает конструктор имени, а не укладка. Имя,
|
||||
заданное после конструктора, ложится на диск дословно — см.
|
||||
[pocketbase-defaults.md](pocketbase-defaults.md).
|
||||
|
||||
Сегодняшняя раскладка `data/files` с именами-UUID панели не видна. Путь она
|
||||
покажет строкой — прослушать и скачать запись по ней нельзя. Способа сослаться
|
||||
на файл, уже лежащий на диске мимо её каталога, нет.
|
||||
|
||||
@@ -197,6 +197,28 @@ API и имя не откатываются обратной правкой по
|
||||
поймать их было некому. У восстановленных нет поля «Чем воспроизведён», и
|
||||
выдумывать его задним числом нельзя.
|
||||
|
||||
## 2026-08-12 — образ не собирался, и этого не увидел никто [проскочил]
|
||||
|
||||
**Что сломалось.** `go mod tidy` поднял директиву `go` в `go.mod` до `1.25.0` —
|
||||
её требует PocketBase, — а `Dockerfile` продолжал собирать на `golang:1.24-alpine`
|
||||
с `GOTOOLCHAIN=local`. `task image` упал бы на шаге сборки: выкладки задачи
|
||||
`pocketbase-storage` не существовало бы вовсе.
|
||||
|
||||
**Почему не поймали.** Все шесть проходов ревью и весь гейт видели зелёное:
|
||||
`go build ./...` идёт на хостовом Go, а образ не собирает **ни один шаг гейта**.
|
||||
Расхождение выглядело согласованным ещё и потому, что `CLAUDE.md` и `README.md`
|
||||
обещали Go 1.24 — то есть три места из четырёх говорили одно и то же, и неверными
|
||||
были именно они.
|
||||
|
||||
Нашлось не проходом, а триажем — при проверке чужих починок на месте, когда он
|
||||
собрал образ руками. То есть поймано случайным свойством прогона, а не
|
||||
устройством конвейера: проверь триаж починки чтением, дефект уехал бы в мердж.
|
||||
|
||||
**Чем чинится на будущее.** Сборка образа гейтом не проверяется намеренно —
|
||||
дорого. Дешёвая замена: шаг, сверяющий версию сборщика в `Dockerfile` с
|
||||
директивой `go` в `go.mod`. Строкой сравнения, без docker. Заведено урожаем
|
||||
ревью.
|
||||
|
||||
## 2026-08-11 — норма требовала от сервиса недостижимого [пойман ревью]
|
||||
|
||||
- **Где:** дельта-спека `pipeline` задачи `errors-as-instead-of-typecast`, абзац
|
||||
|
||||
+33
-26
@@ -85,16 +85,27 @@ Telegram отправителю.
|
||||
Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда
|
||||
путь не предполагался.
|
||||
|
||||
- **Путь на диске** — `filepath.Join(cfg.Storage.Path, fileId + ext)`, где
|
||||
`fileId` наш UUID, а **`ext` берётся из имени файла отправителя** через
|
||||
`filepath.Ext`. Расширение в путь попадает без проверки списком; `filepath.Ext`
|
||||
режет по последней точке и не пропускает разделитель каталогов, но это
|
||||
единственное, что стоит между входом и именем файла.
|
||||
- **Путь на диске** выбирает хранилище:
|
||||
`data/storage/<коллекция>/<запись>/<имя>`. **Имя задаёт сервис** —
|
||||
`<uuid><расширение>`, — а умолчание PocketBase, строящее имя из имени
|
||||
отправителя, не применяется: имя отправителя в хранилище не попадает.
|
||||
Расширение берётся из имени отправителя через `filepath.Ext` без проверки
|
||||
списком; `filepath.Ext` режет по последней точке и не пропускает разделитель
|
||||
каталогов, но это единственное, что стоит между входом и именем файла.
|
||||
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
|
||||
расширением. Бакет один на все записи, префикса по пользователю нет.
|
||||
- **Каталог** один и плоский: `data/files` целиком, вложенности нет.
|
||||
- **Идентификатор задачи** — UUID v4. Он же единственное, что защищает
|
||||
`GET /api/status/:id`.
|
||||
- **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла не
|
||||
помечено защищённым, поэтому ссылка сама по себе и есть право пройти по ней, а
|
||||
отзыва у неё нет. Отсюда запрет: **имя файла в хранилище в журнал не пишется**
|
||||
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку
|
||||
целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
|
||||
- **Идентификатор задачи** — 15 знаков, выдаёт хранилище. Он же единственное,
|
||||
что защищает `GET /api/status/:id`.
|
||||
- **Поверхность самого хранилища.** Вместе с переводом наружу выходят
|
||||
`/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`,
|
||||
`/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то
|
||||
есть доступны они только владельцу панели; проверено прогоном — записи отдают
|
||||
`403`, служебные разделы `401`.
|
||||
|
||||
Целевой периметр добавляет сюда три вещи, и все три — от новых задач:
|
||||
|
||||
@@ -103,15 +114,7 @@ Telegram отправителю.
|
||||
поиск отдавал бы чужую расшифровку тому, кто угадал или добыл тот же файл, и
|
||||
заодно сообщал бы, что запись у кого-то уже есть.
|
||||
- **Файлы фрагментов** (`long-audio-chunking`) ложатся рядом с исходным в тот же
|
||||
плоский каталог — раскладка `data/files` меняется, и это необратимо.
|
||||
- **Раскладку выбирает PocketBase** (`pocketbase-storage`), и плоского каталога
|
||||
не остаётся вовсе: файл ложится в
|
||||
`pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>` рядом с
|
||||
файлом атрибутов. Имя, данное отправителем, в путь при этом попадает — сегодня
|
||||
от него берётся только расширение. Файл уходит не с диска напрямую, а по ссылке
|
||||
вида `/api/files/<коллекция>/<запись>/<имя>`; закрытым он становится, только если
|
||||
поле помечено защищённым, и тогда нужен отдельный файловый токен. Замер —
|
||||
[research/pocketbase.md](research/pocketbase.md).
|
||||
плоский каталог — раскладка каталога данных меняется, и это необратимо.
|
||||
- **Имя отправляемого документа** (`long-text-delivery`) собирается из
|
||||
идентификатора задачи: имя, данное пользователем, в него не попадает.
|
||||
|
||||
@@ -180,10 +183,14 @@ Telegram отправителю.
|
||||
5. **Статистика потребления** (`usage-accounting`). Текста записей не содержит,
|
||||
но говорит, кто и когда пользовался сервисом и сколько; страница расхода
|
||||
открыта только владельцу.
|
||||
6. **Пароль суперпользователя панели** (`pocketbase-storage`). Открывает все
|
||||
записи, все файлы и всех пользователей разом, то есть стоит вровень с самым
|
||||
чувствительным из списка выше. Второй секрет после токенов пользователей,
|
||||
который лежит **не в конфигурации**: его отпечаток хранит сама база.
|
||||
6. **Пароль владельца от панели.** Открывает все записи, все файлы и всех
|
||||
пользователей разом, то есть стоит вровень с самым чувствительным из списка
|
||||
выше. Второй секрет после токенов пользователей, который лежит **не в
|
||||
конфигурации**: его отпечаток хранит сама база, а задаёт пароль сам владелец
|
||||
по приглашению, которое сервис печатает в журнал при первом запуске. У
|
||||
приглашения тридцать минут жизни, и после того как владелец заведён, оно не
|
||||
печатается вовсе — иначе строка журнала отдавала бы панель всякому его
|
||||
читателю навсегда.
|
||||
|
||||
Тексты расшифровок в логи не пишутся — логируется длина текста и
|
||||
идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано
|
||||
@@ -194,10 +201,10 @@ Telegram отправителю.
|
||||
инварианта приватности из [../CLAUDE.md](../CLAUDE.md), а не незакрытый остаток.
|
||||
Расширение берётся из имени отправителя дословно (`filepath.Ext`), поэтому имя
|
||||
`запись.тайное-слово` отдаёт `тайное-слово`, а `Разговор с Петровым 11.08` —
|
||||
`08`. Оно стоит в собственном имени файла на диске, а путь к файлу логируется.
|
||||
Читает этот журнал владелец сервиса. Нормализация расширения на диске — отдельная
|
||||
работа, задачи на неё пока нет: формат имени файла объявлен необратимым и меняется
|
||||
решением человека.
|
||||
`08`. В журнал оно идёт собственным полем, а не в составе имени файла: по нему
|
||||
прослеживается путь записи. Читает этот журнал владелец сервиса. Нормализация
|
||||
расширения в хранилище — отдельная работа, задачи на неё пока нет: формат имени
|
||||
файла объявлен необратимым и меняется решением человека.
|
||||
|
||||
**Наружу хвост не выходит.** Метки метрик (`file_extension` у
|
||||
`transcriber_input_file_size_bytes`, `source_format` у
|
||||
@@ -236,7 +243,7 @@ Telegram отправителю.
|
||||
замер: `research/` пуст, потолок длины стоит открытым вопросом
|
||||
`architecture.md`, «Долгие записи», — а квот нет и не будет: решено считать расход и показывать его владельцу, а не отказывать
|
||||
(цель `usage-stats`). Перебравшего останавливает разговор или отзыв доступа в
|
||||
Authelia. Рост каталога `data/files` при этом ничем не наблюдается —
|
||||
Authelia. Рост каталога данных при этом ничем не наблюдается —
|
||||
открытый вопрос `architecture.md`.
|
||||
- **Перерасход денег на внешних сервисах.** Распознавание и языковая модель
|
||||
оплачиваются по факту; потолка на пользователя нет по тому же решению.
|
||||
|
||||
Reference in New Issue
Block a user