Compare commits
23
Commits
aa20b229f9
...
eacaf76d5f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eacaf76d5f
|
||
|
|
f4d8c7ed50
|
||
|
|
bb9a67929c
|
||
|
|
bc5c35790e
|
||
|
|
f494dcb83e
|
||
|
|
d8d6bcc193
|
||
|
|
8bcd2c0059
|
||
|
|
37ccda3677
|
||
|
|
e1dfe662ea
|
||
|
|
0353517ec4
|
||
|
|
1d243ad2f6
|
||
|
|
6c7f006e75
|
||
|
|
8bffd30955
|
||
|
|
3925c637f3
|
||
|
|
35bde75b1f
|
||
|
|
870b6bc829
|
||
|
|
4a052ec99b
|
||
|
|
b46be019fc
|
||
|
|
8739b18a9f
|
||
|
|
ddc34b3182
|
||
|
|
c44f0e7582
|
||
|
|
d676df8a27
|
||
|
|
09228f23d8
|
+177
@@ -1,15 +1,152 @@
|
|||||||
|
# Линтеры проекта. Перечень правил и их дома — docs/conventions/go-linters.md,
|
||||||
|
# «Механизировано»; здесь только настройка и «почему именно так».
|
||||||
|
#
|
||||||
|
# Базовый набор v2 (`default: standard`) — errcheck, govet, ineffassign,
|
||||||
|
# staticcheck, unused. Сверх него включено то, что механизирует конвенции: то,
|
||||||
|
# что проверяет правило, прозой в конвенциях не остаётся.
|
||||||
version: "2"
|
version: "2"
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
default: standard
|
default: standard
|
||||||
enable:
|
enable:
|
||||||
|
# docs/conventions/errors.md: сравнение ошибок через errors.Is и errors.As.
|
||||||
- errorlint
|
- errorlint
|
||||||
|
# docs/conventions/errors.md: ошибки — только stdlib.
|
||||||
|
- depguard
|
||||||
|
# docs/conventions/logging.md: форма вызова slog.
|
||||||
|
- sloglint
|
||||||
|
# Опечатка в комментарии и в тексте ошибки читается как термин проекта.
|
||||||
|
- misspell
|
||||||
|
# Запреты по месту: чем судят ответ в проверках, чем читают время, откуда
|
||||||
|
# берут конфигурацию, куда пишут вывод. Подробности у каждого правила ниже.
|
||||||
|
- forbidigo
|
||||||
|
# Отмена доходит до внешнего вызова: запрос и внешний процесс заводятся с
|
||||||
|
# контекстом. Инвариант «принятая запись не теряется молча» держится
|
||||||
|
# остановкой на середине, а не только записью в лог: `ffmpeg`, заведённый
|
||||||
|
# без контекста, переживает остановку воркера и дожёвывает чужую запись.
|
||||||
|
- noctx
|
||||||
|
# Контекст приезжает сверху, а не заводится по месту. `context.Background()`
|
||||||
|
# внутри адаптера обрывает цепочку отмены ровно на границе с платным
|
||||||
|
# внешним сервисом — там, где отмена и нужна.
|
||||||
|
- contextcheck
|
||||||
|
# Тело ответа закрывается. `errcheck` его не видит: `(io.ReadCloser).Close`
|
||||||
|
# объявлен в `exclude-functions` ниже, и незакрытое тело от невыясненного
|
||||||
|
# `Close` этим списком не отличается.
|
||||||
|
- bodyclose
|
||||||
|
# `return nil` после проверенной ошибки — это молчаливая потеря отказа,
|
||||||
|
# прямо запрещённая инвариантом об очереди (CLAUDE.md, major).
|
||||||
|
- nilerr
|
||||||
|
# Отказ выборки не теряется: неспрошенный `rows.Err()` превращает оборванное
|
||||||
|
# чтение в пустой результат.
|
||||||
|
- rowserrcheck
|
||||||
|
# `Rows` и `Stmt` закрываются: незакрытая выборка держит соединение.
|
||||||
|
- sqlclosecheck
|
||||||
|
# Форма утверждений в проверках: перепутанные местами «ожидалось/получено»,
|
||||||
|
# `assert` там, где после провала продолжать нельзя, `require` из горутины.
|
||||||
|
- testifylint
|
||||||
|
# Подавление — это решение: строчное `//nolint` обязано называть линтер и
|
||||||
|
# причину, а протухшее подавление обязано краснеть. Тот же порядок, что у
|
||||||
|
# подавлений в этом файле, но применённый к комментариям в коде.
|
||||||
|
- nolintlint
|
||||||
settings:
|
settings:
|
||||||
|
forbidigo:
|
||||||
|
# `analyze-types` включает суждение по типу приёмника, а не по печатному
|
||||||
|
# тексту вызова. Правилу о заголовках это необходимо (см. ниже), прочим
|
||||||
|
# правилам не мешает: имена пакетов в шаблонах те же.
|
||||||
|
analyze-types: true
|
||||||
|
forbid:
|
||||||
|
# Вывод идёт в журнал: строка в stdout мимо slog не имеет ни уровня, ни
|
||||||
|
# полей, и в разборе постфактум её не найти. Встроенные `print`/`println`
|
||||||
|
# названы тем же правилом: запрет на одно имя обходится соседним.
|
||||||
|
- pattern: '^fmt\.Print.*$'
|
||||||
|
msg: 'пишем через slog, а не в stdout напрямую (docs/conventions/logging.md)'
|
||||||
|
- pattern: '^print(ln)?$'
|
||||||
|
msg: 'пишем через slog, а не в stdout напрямую (docs/conventions/logging.md)'
|
||||||
|
# Конфигурация приезжает из TOML. Перечислены все способы прочитать
|
||||||
|
# окружение, а не один: `os.Getenv` без соседей обходится `os.LookupEnv`
|
||||||
|
# одной правкой. Окружение читает только godotenv в main.go — он кладёт
|
||||||
|
# .env в окружение процесса, а не в настройки.
|
||||||
|
#
|
||||||
|
# Чего правило не ловит: `fmt.Fprintln(os.Stdout, …)` и
|
||||||
|
# `os.Stdout.WriteString` — первый аргумент по имени функции не судится.
|
||||||
|
# Этот остаток назван прозой в docs/conventions/logging.md.
|
||||||
|
- pattern: '^os\.(Getenv|LookupEnv|Environ|ExpandEnv)$'
|
||||||
|
msg: 'конфигурация только из TOML (docs/conventions/config.md)'
|
||||||
|
# Единая точка чтения времени — internal/clock: метка времени в UTC
|
||||||
|
# (`clock.Now`), измерение длительности с монотонными часами
|
||||||
|
# (`clock.Start`). Прежде время брали по месту, и хранилище сравнивало
|
||||||
|
# строками времена из разных зон.
|
||||||
|
- pattern: '^time\.Now$'
|
||||||
|
msg: 'время читают clock.Now (метка) и clock.Start (длительность) — docs/conventions/database.md'
|
||||||
|
# Проверка ответа судит по **готовому ответу**, а не по изменяемому
|
||||||
|
# состоянию обработчика. `httptest` устроен зеркально настоящему серверу:
|
||||||
|
# `Header()` отдаёт живую карту, доступную и после записи ответа, а
|
||||||
|
# снимок, который получит клиент, лежит отдельно и читается через
|
||||||
|
# `Result()`. Проверка, читающая живую карту, зелена при неработающем
|
||||||
|
# коде — класс всплывал трижды (docs/review.md, записи 2026-08-10,
|
||||||
|
# 2026-08-11 и 2026-08-12) и трижды стоил зелёного гейта.
|
||||||
|
#
|
||||||
|
# Правило судит по типу приёмника, и в этом весь смысл: запрет на
|
||||||
|
# цепочку `w.Header().Get` обходится одной лишней строкой —
|
||||||
|
# `h := w.Header()`, — а также чтением по индексу карты и обходом
|
||||||
|
# `range`. По типу под правило попадают все эти формы разом. Текстом его
|
||||||
|
# записать нельзя ещё и потому, что `.Header` носят и запрос
|
||||||
|
# (`req.Header.Set` в проверках законен), и снимок ответа
|
||||||
|
# (`w.Result().Header` — как раз то, к чему правило ведёт).
|
||||||
|
#
|
||||||
|
# Приёмник назван поимённо: подставной сервер в проверках отдаёт
|
||||||
|
# заголовок через `w.Header().Set`, но у него приёмник —
|
||||||
|
# `http.ResponseWriter`, и под правило он не попадает.
|
||||||
|
- pattern: '^httptest\.ResponseRecorder\.Header$'
|
||||||
|
msg: 'проверка судит ответ по живой карте заголовков: читай w.Result().Header'
|
||||||
|
# `HeaderMap` — тот же живой снимок прежним именем поля. Правило второе,
|
||||||
|
# потому что об устарелости поля говорит `staticcheck` (SA1019), а о том,
|
||||||
|
# почему по нему не судят ответ, — только это сообщение.
|
||||||
|
- pattern: '^httptest\.ResponseRecorder\.HeaderMap$'
|
||||||
|
msg: 'проверка судит ответ по живой карте заголовков: читай w.Result().Header'
|
||||||
|
|
||||||
|
sloglint:
|
||||||
|
# Стиль вызова один — пары «ключ-значение». `kv-only` запрещает атрибуты
|
||||||
|
# (`slog.String` и прочие) **целиком**, а не только смешение с парами:
|
||||||
|
# смешение и так запрещено умолчанием `no-mixed-args`. Решение осознанное —
|
||||||
|
# один стиль на весь код, — и записано строкой в
|
||||||
|
# docs/conventions/logging.md, «Сообщение».
|
||||||
|
no-mixed-args: true
|
||||||
|
kv-only: true
|
||||||
|
# `msg` — константа: сообщение с подставленным значением не сгруппировать
|
||||||
|
# отбором, а данные для этого и кладут в поля.
|
||||||
|
static-msg: true
|
||||||
|
# `key-naming-case` не включаем: словарь полей намеренно смешанный —
|
||||||
|
# доменные поля `snake_case`, системные домены с точкой (`http.method`,
|
||||||
|
# `ext.service`). См. docs/conventions/logging.md, «Поля: словарь имён».
|
||||||
|
|
||||||
|
depguard:
|
||||||
|
rules:
|
||||||
|
main:
|
||||||
|
deny:
|
||||||
|
- pkg: github.com/pkg/errors
|
||||||
|
desc: 'ошибки — только stdlib errors и fmt.Errorf (docs/conventions/errors.md)'
|
||||||
|
- pkg: github.com/cockroachdb/errors
|
||||||
|
desc: 'стек-трейс избыточен, контекст несёт цепочка %w (docs/conventions/errors.md)'
|
||||||
|
|
||||||
|
nolintlint:
|
||||||
|
# Подавление без причины снимают при первом же неудобстве: снимающий не
|
||||||
|
# знает, что оно ловило. Те же два требования, что у подавлений в этом
|
||||||
|
# файле, — имя линтера и причина строкой.
|
||||||
|
require-explanation: true
|
||||||
|
require-specific: true
|
||||||
|
# Подавление, которому нечего подавлять, — след починенного места, и
|
||||||
|
# краснеть оно обязано: иначе перечень подавлений врёт.
|
||||||
|
allow-unused: false
|
||||||
|
|
||||||
errcheck:
|
errcheck:
|
||||||
# Без этого `_ = x.Close()` снимает замечание, и критерий «отказ не
|
# Без этого `_ = x.Close()` снимает замечание, и критерий «отказ не
|
||||||
# теряется молча» принимается реализацией, которая его теряет. Отказ,
|
# теряется молча» принимается реализацией, которая его теряет. Отказ,
|
||||||
# который решено не проверять, теперь объявляют ниже поимённо — заметно.
|
# который решено не проверять, теперь объявляют ниже поимённо — заметно.
|
||||||
check-blank: true
|
check-blank: true
|
||||||
|
# Непроверенное приведение типа паникует, а не отдаёт ошибку, поэтому
|
||||||
|
# `check-blank` его не ловит: `v := x.(T)` вовсе не про присваивание в `_`.
|
||||||
|
check-type-assertions: true
|
||||||
exclude-functions:
|
exclude-functions:
|
||||||
# Закрытие через defer и лучшая-попытка уборки файла — осознанно без проверки
|
# Закрытие через defer и лучшая-попытка уборки файла — осознанно без проверки
|
||||||
- (io.Closer).Close
|
- (io.Closer).Close
|
||||||
@@ -20,6 +157,46 @@ linters:
|
|||||||
# Метод сам логирует ошибку отправки, вызывающему она не нужна
|
# Метод сам логирует ошибку отправки, вызывающему она не нужна
|
||||||
- (*git.vakhrushev.me/av/transcriber/internal/controller/tg.TelegramController).send
|
- (*git.vakhrushev.me/av/transcriber/internal/controller/tg.TelegramController).send
|
||||||
|
|
||||||
|
exclusions:
|
||||||
|
rules:
|
||||||
|
# Правило о заголовках живёт только в файлах проверок: в рабочем коде
|
||||||
|
# `Header()` и есть способ отдать заголовок.
|
||||||
|
- linters:
|
||||||
|
- forbidigo
|
||||||
|
path-except: '_test\.go$'
|
||||||
|
text: 'живой карте заголовков'
|
||||||
|
# Единая точка чтения времени сама читает время — иначе ей нечем.
|
||||||
|
- linters:
|
||||||
|
- forbidigo
|
||||||
|
path: 'internal/clock/'
|
||||||
|
text: 'time.Now'
|
||||||
|
# Проверка читает окружение **своего прогона** — `PATH`, чтобы убрать из
|
||||||
|
# него каталог с `go`, и `os.Environ()`, чтобы передать окружение дочернему
|
||||||
|
# процессу. Настройками приложения это не является. Исключение объявлено по
|
||||||
|
# тексту сообщения, а не по имени функции: правило называет четыре имени, и
|
||||||
|
# исключение обязано покрывать те же четыре.
|
||||||
|
- linters:
|
||||||
|
- forbidigo
|
||||||
|
path: '_test\.go$'
|
||||||
|
text: 'конфигурация только из TOML'
|
||||||
|
# Проверки строят время фикстур, а не метку домена: `time.Now` в них не
|
||||||
|
# обходит единую точку, а задаёт вход. Запрет здесь стоил бы обязательного
|
||||||
|
# обряда на каждый срок захвата в фикстуре и не поймал бы ничего.
|
||||||
|
- linters:
|
||||||
|
- forbidigo
|
||||||
|
path: '_test\.go$'
|
||||||
|
text: 'time.Now'
|
||||||
|
# `httptest.NewRequest` строит фикстуру для обработчика в том же процессе:
|
||||||
|
# внешнего собеседника за ней нет, и отменять у неё нечего — правило здесь
|
||||||
|
# говорит не о том, что мы имели в виду. Изъятие названо по имени этой
|
||||||
|
# функции, а не выключением `noctx` на проверках целиком: настоящий внешний
|
||||||
|
# вызов из проверки — `http.Get`, `exec.Command` — правилу по-прежнему
|
||||||
|
# подсуден.
|
||||||
|
- linters:
|
||||||
|
- noctx
|
||||||
|
path: '_test\.go$'
|
||||||
|
text: 'httptest\.NewRequest'
|
||||||
|
|
||||||
formatters:
|
formatters:
|
||||||
enable:
|
enable:
|
||||||
- gofmt
|
- gofmt
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Yandex SpeechKit и возвращает текст туда, откуда пр
|
|||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
|
|
||||||
Go 1.25 (CGO не нужен), встроенная PocketBase — хранилище, файлы записей и
|
Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), встроенная PocketBase — хранилище, файлы записей и
|
||||||
панель администратора, — `go-telegram-bot-api`, `aws-sdk-go-v2` для Object
|
панель администратора, — `go-telegram-bot-api`, `aws-sdk-go-v2` для Object
|
||||||
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка —
|
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка —
|
||||||
Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`.
|
Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`.
|
||||||
@@ -33,10 +33,15 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
|
|||||||
|
|
||||||
Что нарушать нельзя.
|
Что нарушать нельзя.
|
||||||
|
|
||||||
- **Секрет не покидает конфиг.** Токен бота, ключ SpeechKit и пара ключей Object
|
- **Секрет не покидает конфиг.** Токен бота, ключ SpeechKit, пара ключей Object
|
||||||
Storage не попадают в git, в лог, в ответ пользователю и в колонку
|
Storage и секрет клиента OIDC не попадают в git, в лог, в ответ пользователю и
|
||||||
`error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют вручную во
|
в колонку `error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют
|
||||||
всех местах выкладки. **critical**
|
вручную во всех местах выкладки. **critical**
|
||||||
|
*Изъятие:* секрет клиента OIDC живёт ещё и в настройках коллекции
|
||||||
|
пользователей хранилища — туда его кладёт приведение настроек при каждом
|
||||||
|
подъёме, потому что применённый шаг схемы не переписывается и не пережил бы
|
||||||
|
ротации. Чтение файла базы равносильно чтению этого секрета; перечисленные
|
||||||
|
места запрета это не отменяет.
|
||||||
- **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла
|
- **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла
|
||||||
пользователя и его сообщение в лог не пишутся — только длина и
|
пользователя и его сообщение в лог не пишутся — только длина и
|
||||||
идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера.
|
идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера.
|
||||||
@@ -82,7 +87,7 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build ./... # CGO не нужен
|
go build ./... # CGO не нужен
|
||||||
go test ./...
|
go test ./... # в гейте идёт с -race, и там нужен компилятор C
|
||||||
go vet ./...
|
go vet ./...
|
||||||
gofmt -l .
|
gofmt -l .
|
||||||
golangci-lint run
|
golangci-lint run
|
||||||
@@ -100,17 +105,60 @@ task gate # весь набор проверок разом
|
|||||||
|
|
||||||
- **Команда целиком:** `task gate`. База диффа — переменная `BASE`, по умолчанию
|
- **Команда целиком:** `task gate`. База диффа — переменная `BASE`, по умолчанию
|
||||||
`origin/master`; переопределяется `task gate BASE=<rev>`.
|
`origin/master`; переопределяется `task gate BASE=<rev>`.
|
||||||
|
- **Какое правило чем проверяется** — конвенция
|
||||||
|
[docs/conventions/go-linters.md](docs/conventions/go-linters.md). Здесь
|
||||||
|
семантика гейта, там перечень правил, подавлений и место настройки каждого;
|
||||||
|
перечень здесь не повторяется.
|
||||||
- **Где логи шагов:** вывод команды, отдельного файла нет.
|
- **Где логи шагов:** вывод команды, отдельного файла нет.
|
||||||
- **Что означает каждый исход:** ненулевой код любого шага роняет гейт. У
|
- **Что означает каждый исход:** ненулевой код любого шага роняет гейт. У
|
||||||
`docs.py check`, `tasks.py check` и `openspec.py check` словарь кодов общий:
|
`docs.py check`, `tasks.py check`, `openspec.py check` и
|
||||||
0 сошлось, 1 дрейф, 2 ошибка употребления, 3 окружение (не корень проекта,
|
`scripts/check-go-version.sh` словарь кодов общий: 0 сошлось, 1 дрейф,
|
||||||
каталог не найден), 4 внутренний сбой.
|
2 ошибка употребления, 3 окружение (не корень проекта, каталог или файл не
|
||||||
|
найден), 4 внутренний сбой. Последний своего словаря не заводит намеренно:
|
||||||
|
четвёртый шаг с собственной семантикой сделал бы это утверждение неверным.
|
||||||
|
Тому же словарю следуют **обёртки шагов** в `Taskfile.yml` — все, включая
|
||||||
|
`tests`, `migrations`, `shell`, `dockerfile` и `vulns`: недостающий инструмент
|
||||||
|
— отказ окружения, код 3. У `tests` это отсутствие CGO или компилятора C, без
|
||||||
|
которых не работает детектор гонок — тесты он в этом случае всё равно гоняет,
|
||||||
|
без `-race`, и краснеет уже после них. У `migrations` код 3 — неразрешимая
|
||||||
|
база диффа, отсутствующий каталог шагов и каталог без единого шага; код 1 —
|
||||||
|
переписанный шаг схемы. Сами чужие инструменты (`shellcheck`, `hadolint`, `govulncheck`,
|
||||||
|
`golangci-lint`) держат свои коды, и гейту от них нужно только «ненулевой».
|
||||||
|
Недостающий скрипт — отказ окружения, код 3. Наружу все эти коды приходят одним: сам `task` на
|
||||||
|
любой отказ шага выходит с 201, а код шага печатает строкой
|
||||||
|
(«exit status 3»), поэтому словарь читается по коду скрипта.
|
||||||
- **Что красит безусловно и почему:** отказ сборки, тестов, `go vet`,
|
- **Что красит безусловно и почему:** отказ сборки, тестов, `go vet`,
|
||||||
неотформатированный файл, находка `golangci-lint`, дрейф раскладки документов,
|
гонка, найденная детектором (`go test -race`), переписанный применённый шаг
|
||||||
дрейф каталога задач, форма `openspec/config.yaml`. Машина проверяет всё
|
схемы,
|
||||||
|
неотформатированный файл, находка `golangci-lint`, расхождение объявленных
|
||||||
|
версий Go, дрейф раскладки документов,
|
||||||
|
дрейф каталога задач, форма `openspec/config.yaml`, достижимая из кода
|
||||||
|
уязвимость в зависимостях (`govulncheck`), находка `shellcheck` в скриптах
|
||||||
|
оболочки и `hadolint` в `Dockerfile`. Машина проверяет всё
|
||||||
перечисленное, и это не обсуждается. Шаг, чей скрипт не найден, краснеет с
|
перечисленное, и это не обсуждается. Шаг, чей скрипт не найден, краснеет с
|
||||||
именем недостающего плагина, а не пропускается молча.
|
именем недостающего плагина, а не пропускается молча.
|
||||||
|
- **Что ловит pre-commit, а что только гейт.** `lefthook.yml` гоняет на
|
||||||
|
**затронутых файлах** дешёвую часть: `gofmt` (правит на месте и добавляет в
|
||||||
|
коммит), `golangci-lint` по пакетам тронутых файлов, `shellcheck`, `hadolint`,
|
||||||
|
`gitleaks` по индексу. Только гейту остаются сборка, `go vet`, тесты целиком,
|
||||||
|
сверка версий Go, три сверки документов и `govulncheck`: они смотрят всё
|
||||||
|
дерево либо требуют сети, а pre-commit обязан быть быстрым.
|
||||||
|
- **Шагу `vulns` нужна сеть**, и он один такой: база уязвимостей живёт на
|
||||||
|
vuln.go.dev. Без сети шаг краснеет, а не пропускается молча; сам инструмент
|
||||||
|
ставится `go install golang.org/x/vuln/cmd/govulncheck@latest`. Судит он
|
||||||
|
достижимость из кода: находка в модуле, чей уязвимый символ мы не вызываем,
|
||||||
|
шаг не роняет. Такая сегодня одна — `GO-2026-5932` в
|
||||||
|
`golang.org/x/crypto/openpgp`, исправления у неё нет вовсе.
|
||||||
- **Чего в гейте намеренно нет и кто тогда обязан это гонять:**
|
- **Чего в гейте намеренно нет и кто тогда обязан это гонять:**
|
||||||
|
- **сборка образа** — дорога, и отказ от неё сознательный. Дешёвая замена
|
||||||
|
стоит шагом сверки версий: он сравнивает строки и ловит расхождение, из-за
|
||||||
|
которого образ перестаёт собираться, но собираемости не проверяет. Собрать
|
||||||
|
образ по-прежнему может только человек — `task image`, и на подъёме версии
|
||||||
|
это обязательно;
|
||||||
|
- собираемость `Dockerfile`: `hadolint` судит форму, а не сборку, и два его
|
||||||
|
правила подавлены поимённо — `DL3007` до задачи `pin-runtime-image-base` и
|
||||||
|
`DL3018` по существу (alpine не держит старые версии пакетов, закрепление
|
||||||
|
ломает сборку через недели). Причины стоят строками в `Taskfile.yml`;
|
||||||
- `gitleaks` — висит на pre-commit в `lefthook.yml` и смотрит только индекс
|
- `gitleaks` — висит на pre-commit в `lefthook.yml` и смотрит только индекс
|
||||||
коммита. Полную историю никто не проверяет;
|
коммита. Полную историю никто не проверяет;
|
||||||
- согласованность документов между собой и с кодом — её судят агенты, зовёт
|
- согласованность документов между собой и с кодом — её судят агенты, зовёт
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM docker.io/library/golang:1.25-alpine AS build-env
|
FROM docker.io/library/golang:1.26-alpine AS build-env
|
||||||
|
|
||||||
# Сборочных зависимостей нет: хранилище ходит в SQLite через modernc.org/sqlite,
|
# Сборочных зависимостей нет: хранилище ходит в SQLite через modernc.org/sqlite,
|
||||||
# и CGO больше не требуется.
|
# и CGO больше не требуется.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
## Технологии
|
## Технологии
|
||||||
|
|
||||||
|
- **Язык**: Go 1.26, CGO не нужен
|
||||||
- **Веб-фреймворк**: gin-gonic/gin
|
- **Веб-фреймворк**: gin-gonic/gin
|
||||||
- **Telegram**: go-telegram-bot-api
|
- **Telegram**: go-telegram-bot-api
|
||||||
- **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3)
|
- **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3)
|
||||||
@@ -113,9 +114,9 @@ transcriber/
|
|||||||
## Разработка
|
## Разработка
|
||||||
|
|
||||||
Схему двигают шаги миграций PocketBase на Go —
|
Схему двигают шаги миграций PocketBase на Go —
|
||||||
`internal/adapter/repo/pocketbase`. Непринятые шаги накатываются при подъёме
|
`internal/adapter/repo/pocketbase/migrations`, файл на шаг. Непринятые шаги
|
||||||
хранилища, прежде чем стартуют воркеры и сервер. Применённый шаг не
|
накатываются при подъёме хранилища, прежде чем стартуют воркеры и сервер.
|
||||||
переписывается: изменение — только новым файлом шага.
|
Применённый шаг не переписывается: изменение — только новым файлом шага.
|
||||||
|
|
||||||
Проверки перед коммитом — одной командой:
|
Проверки перед коммитом — одной командой:
|
||||||
|
|
||||||
|
|||||||
+157
-4
@@ -9,6 +9,12 @@ vars:
|
|||||||
# Пути скриптов проверки. Умолчания — канонические пути маркетплейса, чтобы
|
# Пути скриптов проверки. Умолчания — канонические пути маркетплейса, чтобы
|
||||||
# переустановка плагина не меняла Taskfile. Шаги независимы: каждый ловит
|
# переустановка плагина не меняла Taskfile. Шаги независимы: каждый ловит
|
||||||
# дрейф своего каталога, и выпадение одного не подменяется другим.
|
# дрейф своего каталога, и выпадение одного не подменяется другим.
|
||||||
|
#
|
||||||
|
# Недостающий скрипт — отказ окружения у всех обёрток ниже, и код у него 3 по
|
||||||
|
# общему словарю (CLAUDE.md, раздел «Гейт»). Прежний код 1 значил «дрейф» и
|
||||||
|
# отправлял читателя искать разъехавшееся там, где просто неполно дерево. Сам
|
||||||
|
# `task` отдаёт наружу свой 201 на любой отказ шага, поэтому словарь читается
|
||||||
|
# по коду скрипта, а не по коду `task`.
|
||||||
DOCS_PY: '{{.DOCS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-docs/skills/canon/scripts/docs.py"}}'
|
DOCS_PY: '{{.DOCS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-docs/skills/canon/scripts/docs.py"}}'
|
||||||
TASKS_PY: '{{.TASKS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-tasks/skills/tasks/scripts/tasks.py"}}'
|
TASKS_PY: '{{.TASKS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-tasks/skills/tasks/scripts/tasks.py"}}'
|
||||||
OPENSPEC_PY: '{{.OPENSPEC_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-code/skills/openspec/scripts/openspec.py"}}'
|
OPENSPEC_PY: '{{.OPENSPEC_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-code/skills/openspec/scripts/openspec.py"}}'
|
||||||
@@ -27,11 +33,139 @@ tasks:
|
|||||||
echo "gofmt: файлы выше не отформатированы"
|
echo "gofmt: файлы выше не отформатированы"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
- go test ./...
|
- task: tests
|
||||||
- golangci-lint run
|
- golangci-lint run
|
||||||
|
- task: shell
|
||||||
|
- task: dockerfile
|
||||||
|
- task: go-version
|
||||||
|
- task: migrations
|
||||||
- task: docs
|
- task: docs
|
||||||
- task: tasks
|
- task: tasks
|
||||||
- task: openspec
|
- task: openspec
|
||||||
|
# Последним: единственный шаг, которому нужна сеть, и самый долгий.
|
||||||
|
- task: vulns
|
||||||
|
|
||||||
|
tests:
|
||||||
|
desc: 'Тесты с детектором гонок'
|
||||||
|
cmds:
|
||||||
|
# Гонки ищет детектор, а не чтение кода: у сервиса три воркера ходят в одну
|
||||||
|
# очередь, и «результат пишет только держатель захвата» — утверждение о
|
||||||
|
# одновременном доступе. Детектору нужен CGO и компилятор C; сборка
|
||||||
|
# приложения по-прежнему обходится без них (CLAUDE.md, «Стек»), поэтому их
|
||||||
|
# отсутствие — отказ окружения, код 3, а не отказ проверки.
|
||||||
|
# Окружение проверяется **после** обычного прогона, а не вместо него:
|
||||||
|
# отсутствие компилятора отнимает у гейта поиск гонок, но не должно
|
||||||
|
# отнимать сами тесты. Порядок проверок — сперва компилятор: без него
|
||||||
|
# совет «включи CGO_ENABLED=1» бесполезен.
|
||||||
|
- |
|
||||||
|
if ! command -v gcc >/dev/null 2>&1 && ! command -v clang >/dev/null 2>&1; then
|
||||||
|
go test ./... || exit 1
|
||||||
|
echo "тесты прошли, но гонки не искали: детектору нужен компилятор C"
|
||||||
|
echo "ни gcc, ни clang не найдены в PATH; поставь: apt install gcc"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
if [ "$(go env CGO_ENABLED)" != "1" ]; then
|
||||||
|
go test ./... || exit 1
|
||||||
|
echo "тесты прошли, но гонки не искали: детектору нужен CGO"
|
||||||
|
echo "CGO_ENABLED=$(go env CGO_ENABLED); включи: CGO_ENABLED=1 task gate"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
go test -race ./...
|
||||||
|
|
||||||
|
migrations:
|
||||||
|
desc: 'Применённый шаг схемы не переписывается'
|
||||||
|
cmds:
|
||||||
|
# Инвариант CLAUDE.md (critical): хранилище считает применённое по имени
|
||||||
|
# файла шага, поэтому изменить уехавший шаг нельзя — только добавить новый.
|
||||||
|
# Компилятор этого не держит, и до этого шага не держало ничто.
|
||||||
|
#
|
||||||
|
# Судится каталог шагов против базы диффа: у файла шага допустим один
|
||||||
|
# статус — `A`. Правка (`M`), удаление (`D`) и переименование (`R`) красят.
|
||||||
|
# `migrations.go` под правило не подпадает: строка `Register` у нового шага
|
||||||
|
# прибавляется именно там, и запрет на него запретил бы заведение шага.
|
||||||
|
- |
|
||||||
|
if ! git rev-parse --verify --quiet "{{.BASE}}" >/dev/null 2>&1; then
|
||||||
|
echo "база диффа не найдена: {{.BASE}}"
|
||||||
|
echo "задай свою: task migrations BASE=<rev>"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
# Каталог шагов берётся из docs/.docs.json — там он уже записан ключом
|
||||||
|
# `migrations` для сверки документов. Свой литерал завёл бы факту второй
|
||||||
|
# дом: каталог переехал бы, а один из двух стражей молча позеленел.
|
||||||
|
dir=$(python3 -c 'import json,sys; print(json.load(open("docs/.docs.json"))["migrations"])' 2>/dev/null) || dir=""
|
||||||
|
if [ -z "$dir" ] || [ ! -d "$dir" ]; then
|
||||||
|
echo "каталог шагов схемы не найден: ключ migrations в docs/.docs.json → '$dir'"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
# Страж предмета: правило, потерявшее файлы, стало бы вечно зелёным от
|
||||||
|
# одного переименования — тот же приём, что у правил `internal/archrules`.
|
||||||
|
if [ -z "$(ls "$dir" | grep -E '^[0-9]{12}_.*\.go$')" ]; then
|
||||||
|
echo "в $dir нет ни одного файла шага: правило потеряло предмет"
|
||||||
|
echo "поправь шаблон имени в этом шаге либо ключ migrations в docs/.docs.json"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
# Баз две, и вторая обязательна. `{{.BASE}}` отвечает на «шаг уже уехал»
|
||||||
|
# ровно настолько, насколько свеж `origin/master`: отставшая ссылка
|
||||||
|
# читает весь каталог как добавленный, и правило молчит. `HEAD` ловит
|
||||||
|
# правку закоммиченного шага в рабочем дереве независимо от ссылки.
|
||||||
|
for base in {{.BASE}} HEAD; do
|
||||||
|
touched=$(git diff --name-status "$base" -- "$dir" \
|
||||||
|
| grep -E '[0-9]{12}_[^/]*\.go$' \
|
||||||
|
| grep -vE '^A[[:space:]]' || true)
|
||||||
|
if [ -n "$touched" ]; then
|
||||||
|
echo "база $base:"
|
||||||
|
echo "$touched"
|
||||||
|
echo "применённый шаг схемы переписан: изменение схемы — только новым файлом шага"
|
||||||
|
echo "(CLAUDE.md, «Инварианты», critical: хранилище считает применённое по имени файла)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
shell:
|
||||||
|
desc: 'shellcheck на скрипты оболочки'
|
||||||
|
cmds:
|
||||||
|
# Скриптов два и оба свои: шаг сверки версий и `docker/entrypoint.sh`.
|
||||||
|
# Второй в образ копируется, но не исполняется — `ENTRYPOINT` в
|
||||||
|
# `Dockerfile` закомментирован, — и проверяется он именно поэтому: код,
|
||||||
|
# который никто не гоняет, портится незаметно. Ни один из двух не виден ни
|
||||||
|
# `go vet`, ни `golangci-lint`.
|
||||||
|
- |
|
||||||
|
if ! command -v shellcheck >/dev/null 2>&1; then
|
||||||
|
echo "shellcheck не найден в PATH"
|
||||||
|
echo "поставь: apt install shellcheck (или https://github.com/koalaman/shellcheck)"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
shellcheck scripts/check-go-version.sh docker/entrypoint.sh
|
||||||
|
|
||||||
|
dockerfile:
|
||||||
|
desc: 'hadolint на Dockerfile'
|
||||||
|
cmds:
|
||||||
|
# DL3007 (`alpine:latest` у рантайм-слоя) подавлен: это открытая задача
|
||||||
|
# `pin-runtime-image-base`, и до её решения шаг краснел бы на известном.
|
||||||
|
# DL3018 (закрепить версии пакетов `apk`) подавлен по существу: alpine не
|
||||||
|
# держит старые версии в репозитории, и закрепление ломает сборку через
|
||||||
|
# недели — то есть лечение хуже болезни.
|
||||||
|
- |
|
||||||
|
if ! command -v hadolint >/dev/null 2>&1; then
|
||||||
|
echo "hadolint не найден в PATH"
|
||||||
|
echo "поставь: https://github.com/hadolint/hadolint/releases"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
hadolint --ignore DL3007 --ignore DL3018 Dockerfile
|
||||||
|
|
||||||
|
go-version:
|
||||||
|
desc: 'Одна версия Go в go.mod, Dockerfile, CLAUDE.md и README.md'
|
||||||
|
cmds:
|
||||||
|
# Скрипт лежит в самом репозитории, а не в плагине: его отсутствие значит
|
||||||
|
# сломанное дерево, а не непоставленный плагин, и переопределять путь
|
||||||
|
# нечем и незачем. Код отсутствия — 3, как у прочих обёрток.
|
||||||
|
- |
|
||||||
|
py=scripts/check-go-version.sh
|
||||||
|
if [ ! -f "$py" ]; then
|
||||||
|
echo "$py не найден: дерево репозитория неполно"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
sh "$py"
|
||||||
|
|
||||||
docs:
|
docs:
|
||||||
desc: 'Раскладка docs/ против канона'
|
desc: 'Раскладка docs/ против канона'
|
||||||
@@ -42,7 +176,7 @@ tasks:
|
|||||||
if [ ! -f "$py" ]; then
|
if [ ! -f "$py" ]; then
|
||||||
echo "docs.py не найден: $py"
|
echo "docs.py не найден: $py"
|
||||||
echo "поставь плагин av-dev-docs либо задай путь: task docs DOCS_PY=<путь>"
|
echo "поставь плагин av-dev-docs либо задай путь: task docs DOCS_PY=<путь>"
|
||||||
exit 1
|
exit 3
|
||||||
fi
|
fi
|
||||||
python3 "$py" check --base {{.BASE}}
|
python3 "$py" check --base {{.BASE}}
|
||||||
|
|
||||||
@@ -54,7 +188,7 @@ tasks:
|
|||||||
if [ ! -f "$py" ]; then
|
if [ ! -f "$py" ]; then
|
||||||
echo "tasks.py не найден: $py"
|
echo "tasks.py не найден: $py"
|
||||||
echo "поставь плагин av-dev-tasks либо задай путь: task tasks TASKS_PY=<путь>"
|
echo "поставь плагин av-dev-tasks либо задай путь: task tasks TASKS_PY=<путь>"
|
||||||
exit 1
|
exit 3
|
||||||
fi
|
fi
|
||||||
python3 "$py" check --dir tasks
|
python3 "$py" check --dir tasks
|
||||||
|
|
||||||
@@ -66,10 +200,29 @@ tasks:
|
|||||||
if [ ! -f "$py" ]; then
|
if [ ! -f "$py" ]; then
|
||||||
echo "openspec.py не найден: $py"
|
echo "openspec.py не найден: $py"
|
||||||
echo "поставь плагин av-dev-code либо задай путь: task openspec OPENSPEC_PY=<путь>"
|
echo "поставь плагин av-dev-code либо задай путь: task openspec OPENSPEC_PY=<путь>"
|
||||||
exit 1
|
exit 3
|
||||||
fi
|
fi
|
||||||
python3 "$py" check --dir .
|
python3 "$py" check --dir .
|
||||||
|
|
||||||
|
vulns:
|
||||||
|
desc: 'Достижимые из кода уязвимости в зависимостях'
|
||||||
|
cmds:
|
||||||
|
# `govulncheck` — внешний инструмент, а не плагин и не файл репозитория:
|
||||||
|
# ставится `go install golang.org/x/vuln/cmd/govulncheck@latest`. Его
|
||||||
|
# отсутствие — отказ окружения, код 3, как у прочих обёрток.
|
||||||
|
#
|
||||||
|
# Свой код 3 у самого инструмента значит «уязвимость найдена» и с кодом
|
||||||
|
# обёртки совпадает; различает их сообщение — обёртка называет недостающий
|
||||||
|
# инструмент. Шагу нужна сеть: база уязвимостей живёт на vuln.go.dev, и без
|
||||||
|
# сети шаг краснеет, а не пропускается молча.
|
||||||
|
- |
|
||||||
|
if ! command -v govulncheck >/dev/null 2>&1; then
|
||||||
|
echo "govulncheck не найден в PATH"
|
||||||
|
echo "поставь: go install golang.org/x/vuln/cmd/govulncheck@latest"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
govulncheck ./...
|
||||||
|
|
||||||
# Контракт роли app_image (pet-project-server): собрать полный образ и затегать
|
# Контракт роли app_image (pet-project-server): собрать полный образ и затегать
|
||||||
# его $BUILD_ID. Деплой целиком: `inv pl -- transcriber` в pet-project-server.
|
# его $BUILD_ID. Деплой целиком: `inv pl -- transcriber` в pet-project-server.
|
||||||
image:
|
image:
|
||||||
|
|||||||
@@ -33,6 +33,32 @@ object_storage_region = "ru-central1"
|
|||||||
# Endpoint Object Storage
|
# Endpoint Object Storage
|
||||||
object_storage_endpoint = "https://storage.yandexcloud.net/"
|
object_storage_endpoint = "https://storage.yandexcloud.net/"
|
||||||
|
|
||||||
|
# Вход через внешнего провайдера OIDC (Authelia).
|
||||||
|
# Без заполненной секции сервис не поднимается: молча выключенный вход оставил бы
|
||||||
|
# API открытым наружу.
|
||||||
|
[auth]
|
||||||
|
# Адрес, куда сервис уводит человека на вход
|
||||||
|
auth_url = "https://auth.example.com/api/oidc/authorization"
|
||||||
|
|
||||||
|
# Адрес, где код обменивается на токен
|
||||||
|
token_url = "https://auth.example.com/api/oidc/token"
|
||||||
|
|
||||||
|
# Адрес, откуда берутся сведения о вошедшем
|
||||||
|
user_info_url = "https://auth.example.com/api/oidc/userinfo"
|
||||||
|
|
||||||
|
# Идентификатор клиента, заведённого у провайдера
|
||||||
|
client_id = "transcriber"
|
||||||
|
|
||||||
|
# Секрет клиента; приходит из выкладки, в git не коммитится
|
||||||
|
client_secret = ""
|
||||||
|
|
||||||
|
# Адрес возврата; тот же, что записан клиенту у провайдера
|
||||||
|
redirect_url = "https://transcriber.example.com/auth/callback"
|
||||||
|
|
||||||
|
# Признак `Secure` у куки сессии. Умолчание true; false только для локального
|
||||||
|
# запуска по http://localhost, где браузер такую куку не сохранит
|
||||||
|
secure_cookie = true
|
||||||
|
|
||||||
# Telegram Bot Configuration
|
# Telegram Bot Configuration
|
||||||
[telegram]
|
[telegram]
|
||||||
# Токен Telegram бота (получить у @BotFather в Telegram)
|
# Токен Telegram бота (получить у @BotFather в Telegram)
|
||||||
|
|||||||
@@ -12,21 +12,21 @@ if [ "${USER}" != "transcriber" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "${USER_GID}" ]; then
|
if [ -z "${USER_GID}" ]; then
|
||||||
USER_GID="$(id -g ${USER})"
|
USER_GID="$(id -g "${USER}")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "${USER_UID}" ]; then
|
if [ -z "${USER_UID}" ]; then
|
||||||
USER_UID="$(id -u ${USER})"
|
USER_UID="$(id -u "${USER}")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Change GID for USER?
|
# Change GID for USER?
|
||||||
if [ -n "${USER_GID}" ] && [ "${USER_GID}" != "$(id -g ${USER})" ]; then
|
if [ -n "${USER_GID}" ] && [ "${USER_GID}" != "$(id -g "${USER}")" ]; then
|
||||||
sed -i -e "s/^${USER}:\([^:]*\):[0-9]*/${USER}:\1:${USER_GID}/" /etc/group
|
sed -i -e "s/^${USER}:\([^:]*\):[0-9]*/${USER}:\1:${USER_GID}/" /etc/group
|
||||||
sed -i -e "s/^${USER}:\([^:]*\):\([0-9]*\):[0-9]*/${USER}:\1:\2:${USER_GID}/" /etc/passwd
|
sed -i -e "s/^${USER}:\([^:]*\):\([0-9]*\):[0-9]*/${USER}:\1:\2:${USER_GID}/" /etc/passwd
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Change UID for USER?
|
# Change UID for USER?
|
||||||
if [ -n "${USER_UID}" ] && [ "${USER_UID}" != "$(id -u ${USER})" ]; then
|
if [ -n "${USER_UID}" ] && [ "${USER_UID}" != "$(id -u "${USER}")" ]; then
|
||||||
sed -i -e "s/^${USER}:\([^:]*\):[0-9]*:\([0-9]*\)/${USER}:\1:${USER_UID}:\2/" /etc/passwd
|
sed -i -e "s/^${USER}:\([^:]*\):[0-9]*:\([0-9]*\)/${USER}:\1:${USER_UID}:\2/" /etc/passwd
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"canon": 14,
|
"canon": 14,
|
||||||
"migrations": "migrations"
|
"migrations": "internal/adapter/repo/pocketbase/migrations"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ PocketBase заменяет SQLite с goqu и goose и берёт на себя
|
|||||||
`pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>` рядом с
|
`pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>` рядом с
|
||||||
файлом атрибутов. Момент перехода назначает человек; данные прежней базы не
|
файлом атрибутов. Момент перехода назначает человек; данные прежней базы не
|
||||||
переносятся по прежнему решению задачи `pocketbase-storage`.
|
переносятся по прежнему решению задачи `pocketbase-storage`.
|
||||||
|
*Уточнено 2026-08-12:* каталог задаётся ключом `[storage] data_dir` со
|
||||||
|
значением `data`. Суффикс из десяти знаков дописывает конструктор имени,
|
||||||
|
которого сервис не зовёт, — имя задаёт он сам. Действующая раскладка —
|
||||||
|
[../database.md](../database.md), «Представление данных».
|
||||||
- `−` вход перестаёт быть нашим: задача `oidc-login` переписывается с
|
- `−` вход перестаёт быть нашим: задача `oidc-login` переписывается с
|
||||||
собственной обработки ответа провайдера на настройку провайдера в PocketBase.
|
собственной обработки ответа провайдера на настройку провайдера в PocketBase.
|
||||||
Что делать с сессией и где она живёт, решает уже не наш код.
|
Что делать с сессией и где она живёт, решает уже не наш код.
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Кого пускать в сервис, решает правило провайдера, а не сервис
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-12
|
||||||
|
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
|
||||||
|
раздел «Кого пускать, решает провайдер, а не сервис»
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Сервис пускает всякого, кого пропустил провайдер, и **своей проверки допуска не
|
||||||
|
делает**. Кто допущен, определяет правило Authelia на этого клиента — настройка
|
||||||
|
выкладки, лежащая вне репозитория.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Authelia — общий провайдер контура, а не выделенный под этот сервис: учётная
|
||||||
|
запись в ней есть у всякого, кому её завели ради любого другого сервиса на том же
|
||||||
|
сервере. Ревью дизайна назвало следствие прямо: механизм, приглашающий «второго
|
||||||
|
человека», приглашает всех, кто уже есть у провайдера.
|
||||||
|
|
||||||
|
Очевидный ответ — проверять принадлежность к названной в конфиге группе своим
|
||||||
|
кодом. Владелец от него отказался: это завело бы **второе место**, где решается
|
||||||
|
допуск, и решать его пришлось бы в двух местах согласованно.
|
||||||
|
|
||||||
|
Цена отказа названа в источнике и повторена в модели угроз:
|
||||||
|
|
||||||
|
> Правило живёт вне репозитория, в настройках выкладки, и сервис на него
|
||||||
|
> полагается так же, как полагается на обратный прокси в части панели
|
||||||
|
> администратора. Настроенный слишком широко клиент открывает сервис всем, у кого
|
||||||
|
> есть учётная запись в общей Authelia, — и проверить это по коду нельзя.
|
||||||
|
|
||||||
|
Запись заводится как **намеренный отказ от очевидного подхода**: проверку группы
|
||||||
|
предложат снова, и без записанной причины она выглядит бесплатной.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` допуск решается в одном месте, а не в двух; изменение круга допущенных не
|
||||||
|
требует ни правки кода, ни выкладки.
|
||||||
|
- `+` сервис не читает из ответа провайдера ничего сверх нужного для заведения
|
||||||
|
записи — ни групп, ни ролей.
|
||||||
|
- `−` защита сервиса стала свойством настройки, лежащей в другом репозитории, и
|
||||||
|
ревью её проверить не может: ни один проход не увидит, что клиент настроен
|
||||||
|
слишком широко.
|
||||||
|
- `−` ошибка в настройке клиента не имеет наблюдаемого признака внутри сервиса:
|
||||||
|
посторонний, которого пропустила Authelia, выглядит как законный пользователь.
|
||||||
|
- `−` разграничения по владельцу нет, поэтому цена ошибки в настройке — все
|
||||||
|
записи и все расшифровки разом, а не одна учётная запись. Сузит это
|
||||||
|
`record-ownership`.
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
- **Дата:** 2026-08-12
|
- **Дата:** 2026-08-12
|
||||||
- **Источник:** [../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md](../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md),
|
- **Источник:** [../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md](../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md),
|
||||||
раздел «Поле файла не помечаем защищённым, но ссылка не уезжает в журнал»
|
раздел «Поле файла не помечаем защищённым, но ссылка не уезжает в журнал»
|
||||||
|
- **Статус:** заменено на [ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md)
|
||||||
|
|
||||||
## Решение
|
## Решение
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-12
|
||||||
|
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
|
||||||
|
раздел «Вход и возврат ведёт наш код, разбор ответа — хранилище»
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Обработчик возврата от провайдера зовёт **собственный адрес хранилища**
|
||||||
|
`auth-with-oauth2` внутри процесса, через его же роутер, а не по сети и не
|
||||||
|
разбирая ответ провайдера своими руками.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Решение [ADR-2026-08-11-pocketbase-storage-with-admin-panel](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)
|
||||||
|
отдало разбор ответа провайдера хранилищу: только тогда учётные записи заводятся
|
||||||
|
сами и видны в панели. Это решение не пересматривается — пересматривается способ
|
||||||
|
до него дотянуться.
|
||||||
|
|
||||||
|
Проверка исходников библиотеки версии 0.39.10 показала, что обмен наружу не
|
||||||
|
экспортирован: он живёт неэкспортированной функцией за собственным маршрутом.
|
||||||
|
Остались три формы, и владелец выбрал первую:
|
||||||
|
|
||||||
|
> (а) внутрипроцессный вызов собственного маршрута `auth-with-oauth2`: решение
|
||||||
|
> 2026-08-11 соблюдено дословно, цена — петля «наш обработчик → наш роутер → наш
|
||||||
|
> обработчик», разбор JSON-ответа и потеря типизированной ошибки; (б) сборка
|
||||||
|
> обмена из экспортированных кусков с сохранением записи и связи через `app.Save`:
|
||||||
|
> прямой код без петли, цена — пересмотр решения 2026-08-11 отдельным ADR; (в)
|
||||||
|
> отложить вход до появления фронтенда.
|
||||||
|
|
||||||
|
Запись заводится как **намеренный отказ от очевидного подхода**: собрать обмен
|
||||||
|
своими руками выглядит проще и дешевле, и предложение вернётся, если причина не
|
||||||
|
записана.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` разбор ответа провайдера, заведение учётной записи и связь её с внешним
|
||||||
|
провайдером остаются за хранилищем — решение 2026-08-11 соблюдено дословно, а
|
||||||
|
не «по духу».
|
||||||
|
- `+` наш код не знает ни одного поля ответа провайдера: обновление библиотеки
|
||||||
|
под смену формата ответа доезжает само.
|
||||||
|
- `−` петля через собственный роутер: обработчик зовёт сервис, частью которого
|
||||||
|
сам является. Это новый для проекта вид узла, и его придётся объяснять на
|
||||||
|
каждом следующем изменении.
|
||||||
|
- `−` ответ разбирается текстом, типизированная ошибка теряется: причина отказа
|
||||||
|
обмена доступна только кодом состояния.
|
||||||
|
- `−` роутер хранилища пришлось собирать **один раз** и держать полем: его
|
||||||
|
сборка вешает обработчики на само приложение и без идентификатора, поэтому
|
||||||
|
повторная не заменяет прежние. Ревью кода нашло это построенным путём —
|
||||||
|
анонимный запрос копил обработчики без предела, а каждое сохранение задачи
|
||||||
|
конвейером проходило по всем накопленным.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-12
|
||||||
|
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
|
||||||
|
раздел «Что изменило ревью кода», плюс отчёт триажа
|
||||||
|
[../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
|
||||||
|
пункт 3
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Поле файла в хранилище **помечается защищённым**, а правило просмотра коллекции
|
||||||
|
файлов пускает всякого узнанного. Ссылка `/api/files/<коллекция>/<запись>/<имя>`
|
||||||
|
перестаёт быть правом пройти по ней: нужен короткий токен файла, который берут,
|
||||||
|
предъявив сессию.
|
||||||
|
|
||||||
|
Запись заменяет [ADR-2026-08-12-file-link-open-but-not-logged](ADR-2026-08-12-file-link-open-but-not-logged.md).
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Прежнее решение было обусловленным и само назвало условие своего пересмотра:
|
||||||
|
|
||||||
|
> Решение действует до разграничения доступа: задачи `oidc-login` и
|
||||||
|
> `record-ownership` меняют условие, и тогда пометку стоит пересмотреть новой
|
||||||
|
> записью.
|
||||||
|
|
||||||
|
Условие наступило. Прежний довод — «право прочитать задачу даёт знание её
|
||||||
|
идентификатора, и файл встаёт вровень с `GET /api/status/:id`» — держался на том,
|
||||||
|
что опрос готовности открыт анонимно. Этот change закрывает опрос за вход, и
|
||||||
|
файл, оставшийся открытым, стал бы единственным анонимным путём к содержимому
|
||||||
|
записи — самому чувствительному, что есть у проекта.
|
||||||
|
|
||||||
|
Вторая половина прежнего решения остаётся в силе: имя файла в журнал по-прежнему
|
||||||
|
не пишется. Защищённое поле сужает право пройти, но не отменяет запрета —
|
||||||
|
строка журнала со ссылкой собирала бы половину ключа.
|
||||||
|
|
||||||
|
Пометки самой по себе оказалось мало, и это выяснило ревью кода прогоном:
|
||||||
|
защищённый файл судится **и** токеном, **и** правилом просмотра коллекции, а
|
||||||
|
незаданное правило означает «только владелец панели». Файл не получал ни аноним,
|
||||||
|
ни вошедший — сценарий спеки не исполнялся вовсе. Правило назначено тем же шагом
|
||||||
|
схемы.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` содержимое записи перестало быть доступным по одному знанию ссылки; после
|
||||||
|
закрытия API это был последний анонимный путь к нему.
|
||||||
|
- `+` условие, названное прежней записью, отработало как задумано: решение
|
||||||
|
пересмотрено записью, а не молча.
|
||||||
|
- `−` ссылка усложнилась для потребителя: браузер с одной кукой файла не
|
||||||
|
получает, нужен порядок «сессия → токен файла → ссылка». Будущее приложение
|
||||||
|
обязано этот шаг делать, и задача про прослушивание записи начинается с него.
|
||||||
|
- `−` разграничения по владельцу нет: токен файла берёт всякий вошедший, и по
|
||||||
|
ссылке он получит **любую** запись, а не только свою. Сужение приносит
|
||||||
|
`record-ownership`; до неё круг сузился с «кто угодно из интернета» до «кто
|
||||||
|
угодно из вошедших», и это меньше, чем кажется.
|
||||||
|
- `−` отзыва у выданного токена нет, как не было у ссылки; смягчает только его
|
||||||
|
короткий срок.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Сессия живёт семь суток и не продлевает саму себя
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-12
|
||||||
|
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
|
||||||
|
раздел «Что изменило ревью кода», плюс отчёт триажа
|
||||||
|
[../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
|
||||||
|
пункт 6
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Срок жизни сессии — **семь суток**, назначается при каждом подъёме сервиса.
|
||||||
|
Продление сессии **выключено**: адрес, которым хранилище меняет предъявленное
|
||||||
|
значение на новое, закрыт слоем приложения.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Умолчание хранилища — пять суток и продлеваемая сессия. Второе делает первое
|
||||||
|
бессмысленным, и это выяснило ревью кода замером: предъявитель одного живого
|
||||||
|
значения продлевает себе доступ бессрочно, никуда не входя.
|
||||||
|
|
||||||
|
Значение имеет то, на чём держится вся остановка перерасхода. Паспорт опирается
|
||||||
|
на **отзыв доступа в Authelia** как на способ остановить того, кто тратит слишком
|
||||||
|
много. Но сервис после входа к провайдеру не обращается: подпись сессии считается
|
||||||
|
от значений в базе, и отзыв у провайдера до сервиса доходит **только** истечением
|
||||||
|
срока. При живом продлении не доходит никогда — человек, которому закрыли доступ,
|
||||||
|
сохраняет его навсегда.
|
||||||
|
|
||||||
|
Отвергнуто и названо ценой:
|
||||||
|
|
||||||
|
> сверяться с провайдером по расписанию — новая связь с Authelia и обработка её
|
||||||
|
> недоступности, работа шире задачи; принять как есть — тогда паспорт теряет
|
||||||
|
> способ остановить того, кто тратит слишком много.
|
||||||
|
|
||||||
|
Число семь суток выбрано владельцем как компромисс: реже входить против дольше
|
||||||
|
ждать, пока отзыв доедет.
|
||||||
|
|
||||||
|
Срок назначается **при подъёме, а не шагом схемы**, и это отдельное решение с
|
||||||
|
причиной: применённый шаг не переписывается, поэтому число, положенное туда,
|
||||||
|
разошлось бы со сроком жизни куки при первой же правке — браузер получил бы
|
||||||
|
новый срок, а хранилище продолжило выдавать прежний.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` отзыв доступа у провайдера доходит до сервиса гарантированно, максимум за
|
||||||
|
семь суток; без этого он не доходил вовсе.
|
||||||
|
- `+` срок жизни сессии стал числом, которое кто-то выбрал, и правится он в одном
|
||||||
|
месте вместе со сроком куки.
|
||||||
|
- `−` человек перевходит раз в неделю, и это заметно: своей страницы у сервиса
|
||||||
|
нет, так что вход начинается с перехода по адресу входа руками.
|
||||||
|
- `−` семь суток — всё ещё окно, в которое отозванный доступ работает. Немедленно
|
||||||
|
закрыть чужую сессию можно только руками в панели, обновив ключ токенов записи;
|
||||||
|
своего адреса у этого нет.
|
||||||
|
- `−` закрытие продления сделано слоем приложения, а не настройкой коллекции:
|
||||||
|
библиотека выдаёт сессию продлеваемой всегда, и отключить это в ней нечем.
|
||||||
|
Слой придётся помнить при всякой правке маршрутов.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# ADR-2026-08-12. Спекой нормируется и инструмент сборки, а не только поведение сервиса
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-12
|
||||||
|
- **Источник:** [openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md](../../openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md), раздел `Decisions`, Решение 2
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Заведена capability `toolchain` — четвёртая, и первая, которая описывает **не
|
||||||
|
поведение сервиса** для его потребителей, а поведение инструмента, которым сервис
|
||||||
|
собирают. Потребитель у неё другой: тот, кто собирает.
|
||||||
|
|
||||||
|
Требование о согласованности объявленной версии Go живёт нормой в
|
||||||
|
[openspec/specs/toolchain/spec.md](../../openspec/specs/toolchain/spec.md), а не
|
||||||
|
прозой в памятке.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Три существующие capability — `intake`, `pipeline`, `storage` — все про то, что
|
||||||
|
сервис делает для своих потребителей, а преамбула `architecture.md` прямо
|
||||||
|
говорила «поведение системы здесь не описывается — нормативно оно живёт в
|
||||||
|
`openspec/specs/`». Согласованность версий сборки под это определение не
|
||||||
|
подходит, и натяжение признано прямо в источнике:
|
||||||
|
|
||||||
|
> Признаём натяжение: три существующие capability описывают поведение сервиса для
|
||||||
|
> его потребителей, а `toolchain` описывает поведение инструмента разработки.
|
||||||
|
> Потребитель у него другой — тот, кто собирает сервис. Правило `config.yaml`
|
||||||
|
> говорит «поведение **или домен** системы»; инструмент сборки — домен, и именно
|
||||||
|
> как домен он здесь и назван.
|
||||||
|
|
||||||
|
Очевидные пути отвергнуты оба:
|
||||||
|
|
||||||
|
> **Отвергнуто: дописать в `pipeline`.** `pipeline` нормирует прогон воркера и
|
||||||
|
> захват задачи — поведение работающего сервиса. Версия сборщика с ним не
|
||||||
|
> меняется вместе.
|
||||||
|
>
|
||||||
|
> **Отвергнуто: обойтись без дельта-спеки.** Изменение вводит проверяемое
|
||||||
|
> требование — «расхождение роняет набор проверок», — и требование без дома
|
||||||
|
> проверяется только памятью того, кто его завёл. Обещание «образ собирается» уже
|
||||||
|
> один раз жило в трёх документах и во всех трёх было неверным.
|
||||||
|
|
||||||
|
Последнее и есть довод, перевесивший чистоту определения: дефект 2026-08-12
|
||||||
|
случился именно потому, что утверждение о версии сборки жило только прозой, в
|
||||||
|
трёх местах сразу, и никто не отвечал за его истинность.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` у правила о версиях есть нормативный дом со сценариями, и по нему видно, что
|
||||||
|
проверено, а что оставлено человеку. Три требования, двадцать два сценария.
|
||||||
|
- `+` следующая задача про инструмент сборки знает, куда дописывать, и не заводит
|
||||||
|
вторую спеку о том же.
|
||||||
|
- `−` определение capability в проекте стало шире, чем «поведение сервиса», и
|
||||||
|
граница теперь проходит по слову «домен». Следующее пограничное решение будет
|
||||||
|
ссылаться на этот прецедент — в том числе тогда, когда ссылаться не стоило бы.
|
||||||
|
- `−` асимметрия: четыре однородных шага гейта живут в двух разных домах. У трёх
|
||||||
|
плагинных (`docs.py`, `tasks.py`, `openspec.py`) нормативного дома нет вовсе,
|
||||||
|
только строка в памятке; у четвёртого есть спека. Либо дома появятся у
|
||||||
|
остальных, либо асимметрия останется навсегда.
|
||||||
|
- `−` имя `toolchain` выбрано в том числе из-за настройки среды разработчика:
|
||||||
|
первая редакция звалась `build`, и глобальный запрет чтения каталогов с таким
|
||||||
|
именем сделал спеку нечитаемой для проходов ревью. Имя, выбранное под
|
||||||
|
ограничение инструмента, а не под предмет, — слабое основание, и при следующем
|
||||||
|
пересмотре его стоит перепроверить.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# ADR-2026-08-12. Объявленную версию Go шаг гейта читает из репозитория, а не спрашивает у инструмента
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-12
|
||||||
|
- **Источник:** [openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md](../../openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md), раздел `Decisions`, Решения 1 и 6
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Шаг сверки версий добывает числа чтением файлов и **не зовёт `go` ни в каком
|
||||||
|
виде** — ни `go mod edit -json`, ни `go list -m`, ни `go env`. Директива
|
||||||
|
`toolchain` в `go.mod` при этом запрещена: её наличие роняет шаг.
|
||||||
|
|
||||||
|
Дословно из источника:
|
||||||
|
|
||||||
|
> Способ это не самый удобный: разбор директивы через `go mod edit -json` короче
|
||||||
|
> и надёжнее регулярного выражения. Он же и опасный: вызов `go` тянет за собой
|
||||||
|
> `GOTOOLCHAIN`, `$PATH` и установленный тулчейн, а при непустом `GOTOOLCHAIN`
|
||||||
|
> `go` вправе полезть в сеть за нужной версией — то есть требование «без сети»
|
||||||
|
> перестало бы выполняться. Хуже того, исход шага стал бы зависеть от машины, а
|
||||||
|
> не от коммита.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Причина не в аккуратности, а в том, что **ровно этой подменой и держался дефект,
|
||||||
|
ради которого шаг заведён**. 2026-08-12 требование модуля уехало на 1.25,
|
||||||
|
сборочный образ остался на 1.24, образ перестал собираться — и восемь шагов гейта
|
||||||
|
с шестью проходами ревью показали зелёное, потому что `go build ./...` шёл на
|
||||||
|
хостовом Go. Проверка судила по тому, что стоит на машине, вместо того что
|
||||||
|
записано в коммите. Шаг, зовущий `go`, воспроизвёл бы ту же подмену внутри себя:
|
||||||
|
зелёный там, где стоит нужная версия, и другой ответ на другой машине.
|
||||||
|
|
||||||
|
Отсюда же запрет `toolchain`. Директива — штатный механизм Go и очевидное
|
||||||
|
решение задачи расхождения: она заставила бы Go скачать нужную версию самому, и
|
||||||
|
сверять стало бы нечего. Отвергнута намеренно:
|
||||||
|
|
||||||
|
> Директива `toolchain` заставила бы Go скачивать нужный тулчейн сам, и
|
||||||
|
> расхождение с образом перестало бы ломать сборку. Но она же превращает сборку
|
||||||
|
> образа в сетевую операцию, а сборочный слой качает тулчейн при каждой сборке.
|
||||||
|
> Дороже и менее предсказуемо, чем строка сравнения.
|
||||||
|
|
||||||
|
Вдобавок она вводит **пятое место**, называющее версию, — то, которого закрытый
|
||||||
|
перечень из четырёх мест не знает: при `toolchain go1.27.0` четыре объявленных
|
||||||
|
числа сойдутся, а собирать будет пятое.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` исход шага есть функция коммита. Проверено прогоном: с `PATH`, где нет
|
||||||
|
`go`, шаг даёт тот же код выхода и тот же вывод.
|
||||||
|
- `+` требование «без сети» выполняется по построению, а не обещанием: под
|
||||||
|
`strace` шаг не делает ни одного сетевого вызова.
|
||||||
|
- `+` пятое место закрыто: `toolchain` в `go.mod` роняет шаг с названной
|
||||||
|
причиной.
|
||||||
|
- `−` разбор держится на регулярных выражениях `sed`/`awk` вместо готового
|
||||||
|
разбора, который дал бы сам `go`. Это дороже в сопровождении и хрупче: правка
|
||||||
|
образца ломает смежный случай беззвучно.
|
||||||
|
- `−` запрет `toolchain` придётся снять или пересмотреть, если зависимость
|
||||||
|
однажды потребует версию выше той, что стоит у нас. Тогда эта запись
|
||||||
|
пересматривается, а не обходится.
|
||||||
|
- `−` проверять сам скрипт нечем: `shellcheck` в гейт не заведён, тестов у него
|
||||||
|
нет. Из девятнадцати сценариев нормы машина гоняет один — тот, где всё
|
||||||
|
сошлось. Остаток объявлен и уехал отдельной задачей.
|
||||||
+7
-1
@@ -32,7 +32,13 @@
|
|||||||
|
|
||||||
| Дата | Запись | Статус |
|
| Дата | Запись | Статус |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 2026-08-12 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](ADR-2026-08-12-file-link-open-but-not-logged.md) | |
|
| 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | |
|
||||||
|
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | |
|
||||||
|
| 2026-08-12 | [Кого пускать в сервис, решает правило провайдера, а не сервис](ADR-2026-08-12-access-delegated-to-provider.md) | |
|
||||||
|
| 2026-08-12 | [Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса](ADR-2026-08-12-oidc-exchange-via-own-route.md) | |
|
||||||
|
| 2026-08-12 | [Спекой нормируется и инструмент сборки, а не только поведение сервиса](ADR-2026-08-12-spec-norms-build-toolchain.md) | |
|
||||||
|
| 2026-08-12 | [Объявленную версию Go шаг гейта читает из репозитория, а не спрашивает у инструмента](ADR-2026-08-12-version-read-from-repo-not-from-tool.md) | |
|
||||||
|
| 2026-08-12 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](ADR-2026-08-12-file-link-open-but-not-logged.md) | заменено на [ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md) |
|
||||||
| 2026-08-12 | [Каталог данных задаётся одним ключом `[storage] data_dir`](ADR-2026-08-12-single-data-dir-config-key.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-domain-marker-boundary-by-norm.md) | |
|
||||||
| 2026-08-11 | [Отказ, который решено не проверять, объявляется поимённо](ADR-2026-08-11-errcheck-check-blank.md) | |
|
| 2026-08-11 | [Отказ, который решено не проверять, объявляется поимённо](ADR-2026-08-11-errcheck-check-blank.md) | |
|
||||||
|
|||||||
+56
-23
@@ -8,11 +8,16 @@
|
|||||||
[passport.md](passport.md) и в [tasks/ROADMAP.md](../tasks/ROADMAP.md); что из
|
[passport.md](passport.md) и в [tasks/ROADMAP.md](../tasks/ROADMAP.md); что из
|
||||||
этого ещё не решено — в разделе «Открытые вопросы».
|
этого ещё не решено — в разделе «Открытые вопросы».
|
||||||
|
|
||||||
Заведены три capability:
|
Заведены пять capability. Четыре первые нормируют **поведение сервиса** для его
|
||||||
|
потребителей; пятая — исключение из первого абзаца: она нормирует не сервис, а
|
||||||
|
инструмент, которым его собирают, и потребитель у неё другой — тот, кто собирает.
|
||||||
|
|
||||||
- [intake](../openspec/specs/intake/spec.md) — **только приём по HTTP**: его
|
- [intake](../openspec/specs/intake/spec.md) — **только приём по HTTP**: приём и
|
||||||
нормируют проверки, написанные задачей `http-handler-tests-never-green`
|
опрос за сессией, имя отправителя не доходит ни до хранилища, ни до журнала,
|
||||||
2026-08-11;
|
метка метрики несёт только известное расширение. Задачи
|
||||||
|
`http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11,
|
||||||
|
`pocketbase-storage` и `oidc-login` 2026-08-12. Приём из Telegram здесь не
|
||||||
|
описан;
|
||||||
- [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
|
- [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
|
||||||
задачи и срок его протухания, число попыток, состояние «мертва» и пауза перед
|
задачи и срок его протухания, число попыток, состояние «мертва» и пауза перед
|
||||||
повтором: задачи `errors-as-instead-of-typecast` 2026-08-11 и
|
повтором: задачи `errors-as-instead-of-typecast` 2026-08-11 и
|
||||||
@@ -20,6 +25,15 @@
|
|||||||
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
|
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
|
||||||
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
|
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
|
||||||
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage`
|
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage`
|
||||||
|
2026-08-12;
|
||||||
|
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
|
||||||
|
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
|
||||||
|
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
|
||||||
|
2026-08-12. Разграничения записей по владельцу здесь нет: всякий вошедший
|
||||||
|
видит всё, что видел прежде аноним;
|
||||||
|
- [toolchain](../openspec/specs/toolchain/spec.md) — каким инструментом и какой
|
||||||
|
его версии собирается сервис: одно число версии Go во всех местах, где она
|
||||||
|
названа, и шаг гейта, который это сверяет. Задача `go-1-26-upgrade`
|
||||||
2026-08-12.
|
2026-08-12.
|
||||||
|
|
||||||
Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в
|
Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в
|
||||||
@@ -29,17 +43,26 @@
|
|||||||
|
|
||||||
- **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и
|
- **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и
|
||||||
делят одну базу. Отдельного воркер-процесса нет намеренно.
|
делят одну базу. Отдельного воркер-процесса нет намеренно.
|
||||||
- **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища, воркер
|
- **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища; неделимость
|
||||||
забирает работу одним запросом с захватом. Внешний брокер не заводим: нагрузка
|
захвата и порядок выборки нормирует
|
||||||
— единицы записей в день (оценка владельца, не замер). Готовую библиотеку
|
[pipeline](../openspec/specs/pipeline/spec.md), «Захват задачи неделим».
|
||||||
очереди тоже не заводим — решено 2026-08-11,
|
Внешний брокер не заводим: нагрузка — единицы записей в день (оценка владельца,
|
||||||
|
не замер). Готовую библиотеку очереди тоже не заводим — решено 2026-08-11,
|
||||||
[ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение
|
[ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение
|
||||||
кандидатов в [research/job-queue.md](research/job-queue.md).
|
кандидатов в [research/job-queue.md](research/job-queue.md).
|
||||||
- **Шаг конвейера идемпотентен по повтору.** Задача, брошенная на середине,
|
- **Шаг конвейера идемпотентен по повтору.** Что делает срок захвата и когда
|
||||||
достаётся снова по истечении срока захвата и проходит шаг заново.
|
задача возвращается в работу, нормирует
|
||||||
|
[pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается
|
||||||
|
в работу»; здесь это принцип письма шага, а не описание поведения.
|
||||||
- **Ядро зависит от интерфейсов.** `internal/service` знает только
|
- **Ядро зависит от интерфейсов.** `internal/service` знает только
|
||||||
`internal/contract`; ffmpeg, Yandex, Telegram и хранилище подставляются в
|
`internal/contract`; ffmpeg, Yandex, Telegram и хранилище подставляются в
|
||||||
`main.go`.
|
`main.go`. Правило механизировано тестами-сканерами `internal/archrules`, и
|
||||||
|
они же держат обратные направления: транспорты не знают друг о друге, адаптер
|
||||||
|
не знает ни ядра, ни транспортов.
|
||||||
|
*Изъятие:* транспорт **вправе** знать адаптер хранилища — `controller/http`
|
||||||
|
импортирует `adapter/repo/pocketbase`, потому что HTTP-поверхность и есть
|
||||||
|
роутер этого хранилища, а не наш сервер поверх него. Правила на это
|
||||||
|
направление нет намеренно.
|
||||||
|
|
||||||
## Компоненты
|
## Компоненты
|
||||||
|
|
||||||
@@ -57,7 +80,8 @@
|
|||||||
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit |
|
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit |
|
||||||
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
|
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
|
||||||
| Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом |
|
| Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом |
|
||||||
| Панель владельца | там же, `panel.go` | Правка задачи в панели проходит те же правила перехода, что и правка из кода |
|
| Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций |
|
||||||
|
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Правка задачи в панели проходит те же правила перехода, что и правка из кода |
|
||||||
|
|
||||||
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: спека заведена, но это в ней не описано -->
|
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: спека заведена, но это в ней не описано -->
|
||||||
|
|
||||||
@@ -69,8 +93,11 @@
|
|||||||
## Внешние границы и форматы
|
## Внешние границы и форматы
|
||||||
|
|
||||||
- **Telegram Bot API.** Вход — обновления длинным опросом, выход — сообщения.
|
- **Telegram Bot API.** Вход — обновления длинным опросом, выход — сообщения.
|
||||||
Файл скачивается по ссылке `file.Link(token)` обычным `http.Get`. Telegram не
|
Файл скачивается по ссылке `file.Link(token)` запросом с контекстом, клиентом
|
||||||
отдаёт файлы больше 20 МиБ — это потолок приёма из бота.
|
самого бота. Клиента заводит единая точка `internal/adapter/telegram`: токен
|
||||||
|
стоит в пути каждого обращения, и снятие адреса с отказа живёт там —
|
||||||
|
[conventions/logging.md](conventions/logging.md), «Безопасность: что не
|
||||||
|
логируем». Telegram не отдаёт файлы больше 20 МиБ — это потолок приёма из бота.
|
||||||
- **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с
|
- **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с
|
||||||
`UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением.
|
`UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением.
|
||||||
- **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель
|
- **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель
|
||||||
@@ -94,8 +121,9 @@
|
|||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| Telegram Bot API | Бот не стартует, приложение продолжает работу без него | Скачивание файла висит бесконечно | Длинный опрос пуст, новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации |
|
| Telegram Bot API | Бот не стартует, приложение продолжает работу без него | Скачивание файла висит бесконечно | Длинный опрос пуст, новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации |
|
||||||
| Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
|
| Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
|
||||||
|
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
|
||||||
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
|
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
|
||||||
| ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла» | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
|
| ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла». Остановка сервиса — исход другой: процесс убивают контекстом, и задача остаётся на повтор, не тратя попытки | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
|
||||||
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
|
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
|
||||||
| Диск | Запись файла падает, задача не заводится | — | — | — |
|
| Диск | Запись файла падает, задача не заводится | — | — | — |
|
||||||
|
|
||||||
@@ -118,13 +146,14 @@
|
|||||||
| Переход задачи в состояние | `entity.TranscribeJob.MoveToState` — чистит служебные поля прошлого состояния |
|
| Переход задачи в состояние | `entity.TranscribeJob.MoveToState` — чистит служебные поля прошлого состояния |
|
||||||
| Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю |
|
| Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю |
|
||||||
| Разбор конфигурации | `internal/config.LoadConfig` |
|
| Разбор конфигурации | `internal/config.LoadConfig` |
|
||||||
|
| Чтение времени | `internal/clock` — `Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера |
|
||||||
| Метрики | `internal/metrics`, префикс имени `transcriber_` |
|
| Метрики | `internal/metrics`, префикс имени `transcriber_` |
|
||||||
| Значения метки формата | `internal/metrics.FormatLabel` — приводит расширение к закрытому перечню, прочее заменяет на `other`; нормирует спека `intake` |
|
| Значения метки формата | `internal/metrics.FormatLabel` — приводит расширение к закрытому перечню, прочее заменяет на `other`; нормирует спека `intake` |
|
||||||
|
|
||||||
Единых точек, которых **нет** и которые ожидались бы: идентификаторы
|
Единых точек, которых **нет** и которые ожидались бы: идентификаторы
|
||||||
генерируются вызовом `uuid.NewString()` по месту, время — вызовом `time.Now()`
|
генерируются вызовом `uuid.NewString()` по месту, отображения доменной ошибки в
|
||||||
по месту, отображения доменной ошибки в код HTTP-ответа нет — обработчик решает
|
код HTTP-ответа нет — обработчик решает сам. Время из этого перечня ушло
|
||||||
сам.
|
2026-08-13: его читает `internal/clock`, и запрет держит линтер.
|
||||||
|
|
||||||
## Деплой
|
## Деплой
|
||||||
|
|
||||||
@@ -138,11 +167,15 @@
|
|||||||
|
|
||||||
## Открытые вопросы
|
## Открытые вопросы
|
||||||
|
|
||||||
- **Учётные записи.** Вход через OIDC, провайдер — Authelia, а ответ провайдера
|
- **Учётные записи.** Вход через OIDC решён и развёрнут 2026-08-12: провайдер —
|
||||||
обрабатывает PocketBase, а не наш код
|
Authelia, ответ провайдера обрабатывает PocketBase, а не наш код
|
||||||
([ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)). Не решено, где живёт сессия
|
([ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)), сессия
|
||||||
и как связываются пользователь Telegram и пользователь веба. Панель
|
живёт кукой `transcriber_session` и сама себя не продлевает. Норма —
|
||||||
администратора при этом Authelia не закрывает: у неё свой пароль
|
[access](../openspec/specs/access/spec.md), решения —
|
||||||
|
[ADR-2026-08-12-session-without-refresh](adr/ADR-2026-08-12-session-without-refresh.md)
|
||||||
|
и [ADR-2026-08-12-oidc-exchange-via-own-route](adr/ADR-2026-08-12-oidc-exchange-via-own-route.md).
|
||||||
|
**Не решено одно:** как связываются пользователь Telegram и пользователь веба.
|
||||||
|
Панель администратора при этом Authelia не закрывает: у неё свой пароль
|
||||||
суперпользователя.
|
суперпользователя.
|
||||||
- **Приложение.** Экранов нет вовсе, есть только API. Решено делать SPA,
|
- **Приложение.** Экранов нет вовсе, есть только API. Решено делать SPA,
|
||||||
устанавливаемое на телефон, а фреймворком взят Vue 3 с роутером пятой версии и
|
устанавливаемое на телефон, а фреймворком взят Vue 3 с роутером пятой версии и
|
||||||
|
|||||||
+18
-28
@@ -6,7 +6,8 @@
|
|||||||
|
|
||||||
**Прозой остаётся только то, что не выражается правилом.** Свойство, ставшее
|
**Прозой остаётся только то, что не выражается правилом.** Свойство, ставшее
|
||||||
правилом линтера или тестом-сканером, отсюда **удаляется** и переезжает в
|
правилом линтера или тестом-сканером, отсюда **удаляется** и переезжает в
|
||||||
перечень «Механизировано» ниже. Причина: файл на несколько сотен строк
|
перечень «Механизировано» записи [go-linters.md](go-linters.md) — дома правил,
|
||||||
|
которыми машина читает код. Причина: файл на несколько сотен строк
|
||||||
размазывает внимание по тривиальному — и модель, и человек добросовестно
|
размазывает внимание по тривиальному — и модель, и человек добросовестно
|
||||||
проверят именование и не дойдут до формы решения.
|
проверят именование и не дойдут до формы решения.
|
||||||
|
|
||||||
@@ -17,12 +18,13 @@ severity — в [CLAUDE.md](../../CLAUDE.md).
|
|||||||
|
|
||||||
Четыре записи перенесены из проекта jellybit — тот же Go, тот же автор, те же
|
Четыре записи перенесены из проекта jellybit — тот же Go, тот же автор, те же
|
||||||
задачи. Код transcriber написан раньше и **части правил не следует**: ключи —
|
задачи. Код transcriber написан раньше и **части правил не следует**: ключи —
|
||||||
UUID вместо ULID, время берётся `time.Now()` по месту, лог пишется на каждом
|
UUID вместо ULID, лог пишется на каждом шаге и дублируется воркером, `msg` —
|
||||||
шаге и дублируется воркером.
|
предложение с заглавной буквы вместо константной категории.
|
||||||
|
|
||||||
Из этого перечня одно уже закрыто: доменные ошибки проверялись приведением типа
|
Из этого перечня закрыты два. Доменные ошибки проверялись приведением типа до
|
||||||
до 2026-08-11, задача `errors-as-instead-of-typecast`. Приведение типа на этом
|
2026-08-11, задача `errors-as-instead-of-typecast`. Время брали `time.Now()` по
|
||||||
месте больше не долг, а регрессия.
|
месту до 2026-08-13 — теперь его читает единая точка `internal/clock`, и правило
|
||||||
|
держит линтер. Оба места больше не долг, а регрессия.
|
||||||
|
|
||||||
Пятая, `web-ui.md`, тоже пришла оттуда, но не прижилась: jellybit работает на
|
Пятая, `web-ui.md`, тоже пришла оттуда, но не прижилась: jellybit работает на
|
||||||
htmx, а здесь решено делать SPA — и перенесённый текст снят целиком.
|
htmx, а здесь решено делать SPA — и перенесённый текст снят целиком.
|
||||||
@@ -46,27 +48,15 @@ htmx, а здесь решено делать SPA — и перенесённы
|
|||||||
- [web-ui.md](web-ui.md) — веб-UI: Vue 3 с Vite и статикой в бинарнике,
|
- [web-ui.md](web-ui.md) — веб-UI: Vue 3 с Vite и статикой в бинарнике,
|
||||||
однофайловые компоненты, таблица маршрутов, состояние в экране, одна обёртка
|
однофайловые компоненты, таблица маршрутов, состояние в экране, одна обёртка
|
||||||
над `fetch`, показ ошибок и состояний списка.
|
над `fetch`, показ ошибок и состояний списка.
|
||||||
|
- [go-linters.md](go-linters.md) — линтеры и механизированные проверки: лестница
|
||||||
|
механизации, два круга (pre-commit и гейт), перечень правил и подавлений,
|
||||||
|
порядок заведения нового правила. Про инструменты, а не про то, как писать
|
||||||
|
тесты.
|
||||||
|
|
||||||
## Механизировано
|
## Что из этого проверяет машина
|
||||||
|
|
||||||
Проверяется командами из [CLAUDE.md](../../CLAUDE.md); прозой не дублируется и в
|
Перечень правил, доведённых до проверки, и место настройки каждого — в записи
|
||||||
промптах ревью не пересказывается.
|
[go-linters.md](go-linters.md). Там же сказано, что из перечисленного в прочих
|
||||||
|
записях осталось прозой и потому проверяется человеком на каждом ревью заново, и
|
||||||
| Правило | Где механизировано |
|
там же названы остатки правил — то, что правило не ловит. Числа механизированного
|
||||||
| --- | --- |
|
здесь нет намеренно: оно протухает при каждом новом правиле.
|
||||||
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml` → `errorlint` |
|
|
||||||
| Непроверенное возвращаемое значение ошибки | `.golangci.yml` → `errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close` и `send` |
|
|
||||||
| Форматирование исходников | `.golangci.yml` → `gofmt` |
|
|
||||||
| Подозрительные конструкции языка | `.golangci.yml` → `govet`, `staticcheck`, `ineffassign`, `unused` |
|
|
||||||
| Секреты в коммите | `lefthook.yml` → `gitleaks git --staged` |
|
|
||||||
| Раскладка документов, битые ссылки, миграция без правки `database.md` | `docs.py check` |
|
|
||||||
|
|
||||||
Не названное здесь место механизации означает, что проход по конвенциям будет
|
|
||||||
добросовестно проверять уже проверенное.
|
|
||||||
|
|
||||||
**Из перечисленного в записях правилом выражено одно** — сравнение ошибок через
|
|
||||||
`errors.Is` и `errors.As` (`errorlint`, строка таблицы выше). Прозой остаётся всё
|
|
||||||
прочее: ни константный `msg` лога (`sloglint`), ни запрет `fmt.Print*` и
|
|
||||||
`os.Getenv` (`forbidigo`), ни запрет сторонних пакетов ошибок (`depguard`), ни
|
|
||||||
архитектурные тесты-сканеры. Это следующий шаг переноса в правило: свойство,
|
|
||||||
оставшееся прозой, проверяет человек на каждом ревью заново.
|
|
||||||
|
|||||||
@@ -8,9 +8,10 @@
|
|||||||
комментариями снабжена половина полей; валидации на старте нет вовсе, кроме
|
комментариями снабжена половина полей; валидации на старте нет вовсе, кроме
|
||||||
проверки пустых ключей внутри адаптеров.
|
проверки пустых ключей внутри адаптеров.
|
||||||
|
|
||||||
**Механизировано:** ничего. Запрет `os.Getenv` для конфигурации правилом линтера
|
**Механизировано:** запрет `os.Getenv` — `forbidigo` в `.golangci.yml`
|
||||||
не выражен, и `godotenv` в `main.go` загружает `.env` — то есть окружение сейчас
|
([go-linters.md](go-linters.md), «Механизировано»). Он держит правило «настройки
|
||||||
участвует.
|
приезжают из TOML»; `godotenv` в `main.go` по-прежнему загружает `.env`, но кладёт
|
||||||
|
его в окружение процесса, а не в настройки приложения.
|
||||||
|
|
||||||
## Принципы
|
## Принципы
|
||||||
|
|
||||||
@@ -60,6 +61,11 @@ users_while_list = ["<@name>"] # кому отвечает бот; стр
|
|||||||
*Расхождение:* секции `[server]` в `config.dist.toml` не хватает поля
|
*Расхождение:* секции `[server]` в `config.dist.toml` не хватает поля
|
||||||
`users_while_list`, из-за чего бот на свежем конфиге отвечает отказом всем.
|
`users_while_list`, из-за чего бот на свежем конфиге отвечает отказом всем.
|
||||||
|
|
||||||
|
*Расхождение:* адреса провайдера в секции `[auth]` образца заполнены примерами
|
||||||
|
вида `https://auth.example.com/...`, а не оставлены пустыми: пустой адрес не
|
||||||
|
говорит, какой формы значение здесь ждут. Пустым оставлен только
|
||||||
|
`client_secret` — он и есть секрет.
|
||||||
|
|
||||||
## Поля по дискриминатору `type`
|
## Поля по дискриминатору `type`
|
||||||
|
|
||||||
Когда набор полей секции зависит от поля-дискриминатора `type` (выбор одного из
|
Когда набор полей секции зависит от поля-дискриминатора `type` (выбор одного из
|
||||||
@@ -87,7 +93,7 @@ Ansible из `pet-project-server`). Приложение просто читае
|
|||||||
|
|
||||||
- Секретные поля transcriber: `telegram.bot_token`, `yandex.speech_kit_api_key`,
|
- Секретные поля transcriber: `telegram.bot_token`, `yandex.speech_kit_api_key`,
|
||||||
`yandex.object_storage_access_key_id`,
|
`yandex.object_storage_access_key_id`,
|
||||||
`yandex.object_storage_secret_access_key`.
|
`yandex.object_storage_secret_access_key`, `auth.client_secret`.
|
||||||
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
|
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
|
||||||
владелец — пользователь процесса (`1000:1000`).
|
владелец — пользователь процесса (`1000:1000`).
|
||||||
- В `config.dist.toml` секретные поля — пустые строки.
|
- В `config.dist.toml` секретные поля — пустые строки.
|
||||||
@@ -115,10 +121,20 @@ TOML. Пустой токен бота ловится в `NewTelegramController`
|
|||||||
конструкторе распознавателя, и вот там процесс уже выходит с кодом 1. Единого
|
конструкторе распознавателя, и вот там процесс уже выходит с кодом 1. Единого
|
||||||
места проверки нет.
|
места проверки нет.
|
||||||
|
|
||||||
|
Секция `[auth]` — первая, у которой проверка своя и стоит на старте:
|
||||||
|
`AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет
|
||||||
|
процесс с перечнем незаполненных ключей. Причина в цене умолчания: поднявшись с
|
||||||
|
молча выключенным входом, сервис остался бы открытым наружу, а узнать об этом
|
||||||
|
было бы неоткуда. Сообщение называет **имена ключей**, а не значения — значение
|
||||||
|
`client_secret` в журнал попасть не должно.
|
||||||
|
|
||||||
## Структура в коде
|
## Структура в коде
|
||||||
|
|
||||||
- Весь разбор и проверка — в `internal/config`; наружу отдаётся готовая `Config`.
|
- Весь разбор и проверка — в `internal/config`; наружу отдаётся готовая `Config`.
|
||||||
- Одна корневая структура `Config` с под-структурами по секциям (`Server`,
|
- Одна корневая структура `Config` с под-структурами по секциям. Перечень секций
|
||||||
`Database`, `Storage`, `Yandex`, `Telegram`).
|
и полей здесь не повторяем: источник истины по составу — `config.dist.toml`,
|
||||||
|
действующие числа — [../database.md](../database.md), «Настройки с числовым
|
||||||
|
значением». Каталог данных задаётся одним ключом `[storage] data_dir`
|
||||||
|
([ADR](../adr/ADR-2026-08-12-single-data-dir-config-key.md)).
|
||||||
- Умолчания задаются в `defaultConfig()`, файл их перекрывает. Новое поле
|
- Умолчания задаются в `defaultConfig()`, файл их перекрывает. Новое поле
|
||||||
требует правки обоих мест.
|
требует правки обоих мест.
|
||||||
|
|||||||
@@ -2,14 +2,17 @@
|
|||||||
|
|
||||||
Как мы устраиваем таблицы и ключи. Актуальная схема — [../database.md](../database.md).
|
Как мы устраиваем таблицы и ключи. Актуальная схема — [../database.md](../database.md).
|
||||||
|
|
||||||
**Взято из проекта jellybit целиком.** Сегодняшний код transcriber этому не
|
**Взято из проекта jellybit целиком.** Сегодняшний код transcriber следует
|
||||||
следует ни в одном пункте: ключи — UUID v4, а не ULID; время — `time.Now()` по
|
этому частью: ключи — UUID v4, а не ULID, и единой точки их генерации нет. Время
|
||||||
месту вызова в локальной зоне, а не единой точкой в UTC; единой точки генерации
|
единой точкой читается с 2026-08-13 — `internal/clock`, метка в UTC, — и правило
|
||||||
и разбора нет. Правила действуют на новый код; переписывание существующего —
|
держит линтер. Правила действуют на новый код; переписывание существующего —
|
||||||
отдельная работа, и до неё расхождение читается как долг, а не как нарушение.
|
отдельная работа, и до неё расхождение читается как долг, а не как нарушение.
|
||||||
|
|
||||||
**Механизировано:** ничего. Ни правила линтера, ни теста-сканера под эти пункты
|
**Механизировано:** сверка изменённого шага схемы с
|
||||||
в transcriber нет.
|
[../database.md](../database.md) (`docs.py check`), чтение времени единой точкой
|
||||||
|
(`forbidigo` плюс `internal/clock`) и согласованность колонок очереди
|
||||||
|
(тест-сканер `internal/archrules`). Прочие пункты — прозой; адреса —
|
||||||
|
[go-linters.md](go-linters.md), «Механизировано».
|
||||||
|
|
||||||
## Первичные ключи — ULID, не автоинкремент
|
## Первичные ключи — ULID, не автоинкремент
|
||||||
|
|
||||||
@@ -55,8 +58,9 @@
|
|||||||
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
|
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
|
||||||
Единая точка генерации — приложение, а не умолчание в схеме: так забытая
|
Единая точка генерации — приложение, а не умолчание в схеме: так забытая
|
||||||
вставка падает громко. Измерение длительности — не метка времени.
|
вставка падает громко. Измерение длительности — не метка времени.
|
||||||
- Миграции — шаги PocketBase на Go (`internal/adapter/repo/pocketbase`):
|
- Миграции — шаги PocketBase на Go
|
||||||
коллекции и их поля заводятся кодом. При изменении структуры обновляем схему
|
(`internal/adapter/repo/pocketbase/migrations`, файл на шаг): коллекции и их
|
||||||
|
поля заводятся кодом. При изменении структуры обновляем схему
|
||||||
[../database.md](../database.md) тем же изменением.
|
[../database.md](../database.md) тем же изменением.
|
||||||
- Время в **сыром запросе** кладётся и сравнивается тем же видом, каким
|
- Время в **сыром запросе** кладётся и сравнивается тем же видом, каким
|
||||||
хранилище пишет свои `created`/`updated`. Сравнение строк побайтово, и
|
хранилище пишет свои `created`/`updated`. Сравнение строк побайтово, и
|
||||||
|
|||||||
@@ -9,9 +9,10 @@
|
|||||||
Главное: единой точки отображения доменной ошибки в ответ нет, обработчики
|
Главное: единой точки отображения доменной ошибки в ответ нет, обработчики
|
||||||
решают сами.
|
решают сами.
|
||||||
|
|
||||||
**Механизировано:** приведение типа и `err == ErrX` ловит `errorlint` в
|
**Механизировано:** приведение типа и `err == ErrX` ловит `errorlint`,
|
||||||
`.golangci.yml`. Запрета сторонних пакетов ошибок (`depguard`) нет — сторонних
|
сторонние пакеты ошибок — `depguard`, узнавание ошибки по тексту сообщения —
|
||||||
пакетов ошибок в проекте и так нет.
|
тест-сканер `internal/archrules`. Перечень и адреса —
|
||||||
|
[go-linters.md](go-linters.md), «Механизировано».
|
||||||
|
|
||||||
## Базовая идиома: stdlib
|
## Базовая идиома: stdlib
|
||||||
|
|
||||||
@@ -135,8 +136,11 @@ transcriber — **приложение, а не библиотека**: внеш
|
|||||||
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) —
|
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) —
|
||||||
это значения `error`.
|
это значения `error`.
|
||||||
- `recover` — на верхней границе обработчика, чтобы один паникующий запрос не
|
- `recover` — на верхней границе обработчика, чтобы один паникующий запрос не
|
||||||
ронял процесс. В transcriber это `gin.Recovery()`; у воркеров и у бота такой
|
ронял процесс. В transcriber его вешает роутер хранилища сам
|
||||||
границы **нет**: паника в шаге конвейера роняет процесс целиком.
|
(`apis.panicRecover`, слой с идентификатором `DefaultPanicRecoverMiddlewareId`
|
||||||
|
на каждом роутере PocketBase): паникующий обработчик отдаёт `500`, процесс
|
||||||
|
живёт. Своего слоя мы не пишем. У воркеров и у бота такой границы **нет**:
|
||||||
|
паника в шаге конвейера роняет процесс целиком.
|
||||||
|
|
||||||
## Несколько ошибок
|
## Несколько ошибок
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
# Линтеры и механизированные проверки
|
||||||
|
|
||||||
|
Конвенция о том, **чем машина читает наш код**: какие свойства доведены до
|
||||||
|
правила, чем каждое проверяется, когда оно запускается и что осталось человеку.
|
||||||
|
Свойство, ставшее правилом, из прозы соседних записей удаляется и появляется
|
||||||
|
здесь строкой — эта запись его принимает.
|
||||||
|
|
||||||
|
**Чего здесь нет: как писать тесты.** Запись говорит об инструментах и правилах —
|
||||||
|
линтерах, тестах-сканерах, шагах проверок, — а не о том, что должен утверждать
|
||||||
|
юнит-тест и какой у него оракул. Это другой предмет, и живёт он в
|
||||||
|
[../review.md](../review.md): «Типовые узлы» перечисляют свойства, которые тест
|
||||||
|
обязан проверять, и там же записано требование, чтобы проверка была **способна
|
||||||
|
упасть**. Тест-сканеры ниже попадают в эту запись не потому, что они тесты, а
|
||||||
|
потому, что они правила: у них нет ни фикстур, ни поведения — они читают
|
||||||
|
исходники.
|
||||||
|
|
||||||
|
Пока язык у проекта один, и запись названа по нему. Появится второй — у него
|
||||||
|
будет своя запись, а лестница и два круга останутся общими.
|
||||||
|
|
||||||
|
Устройство ниже **переносимо**: разделы «Лестница механизации», «Два круга» и
|
||||||
|
«Как заводят новое правило» — не особенность transcriber и переносятся в другой
|
||||||
|
Go-проект как есть. Своё здесь — перечень правил и подавлений.
|
||||||
|
|
||||||
|
## Границы: где что живёт
|
||||||
|
|
||||||
|
Чтобы факт не жил в двух местах:
|
||||||
|
|
||||||
|
- **семантика гейта** — команда целиком, база диффа, словарь кодов выхода, что
|
||||||
|
красит безусловно, чего в гейте намеренно нет и кто тогда обязан это гонять —
|
||||||
|
в [CLAUDE.md](../../CLAUDE.md), раздел «Гейт». Здесь это не повторяется: у гейта
|
||||||
|
один дом, и он у памятки, потому что её читают прежде работы;
|
||||||
|
- **как писать код** — соседние записи этой конвенции ([README.md](README.md) —
|
||||||
|
индекс). Свойство, ставшее правилом, оттуда удаляется и попадает в перечень
|
||||||
|
ниже; обратный перенос запрещён — правило, оставшееся ещё и прозой, проверяют
|
||||||
|
дважды;
|
||||||
|
- **настройка конвейера ревью, вопросы по темам и журнал дефектов** —
|
||||||
|
[../review.md](../review.md). Перечень ниже говорит этим вопросам, чего
|
||||||
|
спрашивать уже не нужно;
|
||||||
|
- **поведение сервиса** — нормативные спеки `openspec/specs/`. У шага сверки
|
||||||
|
версий Go поведение нормировано отдельно, спекой
|
||||||
|
[toolchain](../../openspec/specs/toolchain/spec.md): это единственная проверка
|
||||||
|
проекта, у которой есть своя capability, и потому единственная, чьи сценарии
|
||||||
|
проверяются построчно (`scripts/check_go_version_test.go`). Второй самодельный
|
||||||
|
шаг — `migrations` — нормы не имеет: он проверен мутацией на трёх исходах
|
||||||
|
(переписанный шаг, пустой каталог, чистое дерево), но регрессионных проверок у
|
||||||
|
него нет, и дрейф его собственного шаблона имени никто не поймает. Это
|
||||||
|
объявленный долг, а не умолчание.
|
||||||
|
|
||||||
|
## Лестница механизации
|
||||||
|
|
||||||
|
Свойство поднимается по ступеням, и ступень выбирают не по вкусу, а по тому,
|
||||||
|
чем свойство выражается. Верхняя ступень дешевле нижней в эксплуатации и дороже
|
||||||
|
в заведении, поэтому прыгать через ступень без нужды не надо.
|
||||||
|
|
||||||
|
1. **Проза конвенции.** Свойство названо словами, проверяет человек на каждом
|
||||||
|
ревью заново. Это ступень по умолчанию и худшая из всех: она стоит внимания
|
||||||
|
каждого прогона и молча перестаёт работать, когда внимание кончилось.
|
||||||
|
2. **Настройка готового линтера.** Свойство совпало с чужим правилом —
|
||||||
|
включается строкой в `.golangci.yml`. Дешевле всего; ограничение в том, что
|
||||||
|
правило чужое и говорит о том, о чём его написали.
|
||||||
|
3. **Запрет по имени** (`forbidigo`, `depguard`). Свойство выражается через «эту
|
||||||
|
функцию/пакет тут звать нельзя». Дешёво и точно, но требует **единой точки**,
|
||||||
|
куда запрещённое переносят: запрет без дома оставляет код без способа сделать
|
||||||
|
нужное.
|
||||||
|
4. **Тест-сканер исходников** (`internal/archrules`). Свойство — о структуре, а
|
||||||
|
не о вызове: направление зависимостей, согласованность двух перечней,
|
||||||
|
отсутствие идиомы. Пишется руками на `go/parser` или регулярном выражении,
|
||||||
|
зато читается как тест и ломается заметно.
|
||||||
|
5. **Свой шаг проверки** (`scripts/`, шаги `Taskfile.yml`). Свойство выходит за
|
||||||
|
пределы кода на Go: версия инструмента, форма `Dockerfile`, раскладка
|
||||||
|
документов. Дороже всех — у шага появляется своя норма и свои тесты.
|
||||||
|
|
||||||
|
Ступень, выбранная неверно, видна сразу. Запрет по имени, обходимый одной
|
||||||
|
лишней строкой, — это ступень 4, наряженная третьей: так было с правилом о
|
||||||
|
заголовках ответа, которое сначала запретило текст `\.Header\(\)\.Get`, а
|
||||||
|
обходилось присваиванием в переменную. Правило переписано на суждение **по типу
|
||||||
|
приёмника** (`analyze-types`), и это уже настоящая третья ступень.
|
||||||
|
|
||||||
|
## Два круга: pre-commit и гейт
|
||||||
|
|
||||||
|
Проверки идут двумя кругами, и круг выбирается по цене прогона.
|
||||||
|
|
||||||
|
| | pre-commit (`lefthook.yml`) | гейт (`task gate`) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Когда | на каждый коммит | перед тем как считать задачу сделанной |
|
||||||
|
| На чём | на **затронутых файлах** | на всём дереве |
|
||||||
|
| Сколько идёт | около секунды | десятки секунд |
|
||||||
|
| Что делает с находкой | `gofmt` правит и добавляет в коммит, прочее роняет коммит | роняет прогон |
|
||||||
|
|
||||||
|
Перечень работ pre-commit и то, что остаётся только гейту, — в
|
||||||
|
[CLAUDE.md](../../CLAUDE.md), раздел «Гейт». Здесь важен принцип: **pre-commit не
|
||||||
|
подменяет гейт**. Он ловит дешёвое и местное, а сборка, тесты целиком, сверки
|
||||||
|
документов и запрос к базе уязвимостей идут в гейте — иначе коммит стоил бы
|
||||||
|
минуту, и хук отключили бы через день.
|
||||||
|
|
||||||
|
Полный набор проверок в pre-commit не переносится сознательно; обратное решение
|
||||||
|
— «гонять всё на каждый коммит» — известно и отклонено по этой же причине.
|
||||||
|
|
||||||
|
## Механизировано
|
||||||
|
|
||||||
|
Проверяется командами из [CLAUDE.md](../../CLAUDE.md); прозой не дублируется и в
|
||||||
|
промптах ревью не пересказывается.
|
||||||
|
|
||||||
|
### Ошибки и отказы
|
||||||
|
|
||||||
|
| Правило | Где механизировано |
|
||||||
|
| --- | --- |
|
||||||
|
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml` → `errorlint` |
|
||||||
|
| Ошибка не узнаётся сравнением текста сообщения (`strings.Contains(err.Error(), …)`, `err.Error() == …`) | `internal/archrules` → `TestОшибкаНеУзнаётсяПоТексту` |
|
||||||
|
| Непроверенное возвращаемое значение ошибки | `.golangci.yml` → `errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close`, `os.Remove` и `send` |
|
||||||
|
| Непроверенное приведение типа (`v := x.(T)`) | `.golangci.yml` → `errcheck` с `check-type-assertions`. Отдельная настройка, потому что такое приведение паникует, а не возвращает ошибку, и `check-blank` его не видит |
|
||||||
|
| Проверенный отказ не оборачивается в `return nil` | `.golangci.yml` → `nilerr`. Механизирует половину инварианта «принятая запись не теряется молча»: молчаливый успех после отказа |
|
||||||
|
| Отказ выборки из хранилища не теряется (`rows.Err()`), а сама выборка закрывается | `.golangci.yml` → `rowserrcheck`, `sqlclosecheck`. **Профилактические: предмета в коде сегодня нет** — выборки идут через `dbx` хранилища, а из `database/sql` употребляются только `sql.NullString` и `sql.ErrNoRows`. Правила заведены на будущий сырой запрос; мутацией проверены на пробе, а не на своём коде |
|
||||||
|
| Ошибки — только stdlib, без сторонних пакетов | `.golangci.yml` → `depguard` |
|
||||||
|
|
||||||
|
### Структура и границы
|
||||||
|
|
||||||
|
| Правило | Где механизировано |
|
||||||
|
| --- | --- |
|
||||||
|
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules` → `TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` |
|
||||||
|
| Транспорты (`controller/http`, `controller/tg`, `controller/worker`) не знают друг о друге | `internal/archrules` → `TestТранспортыНеЗнаютДругОДруге` |
|
||||||
|
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules` → `TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
|
||||||
|
| Колонки очереди согласованы: перечень захвата ↔ структура захвата ↔ шаг схемы ↔ запись коллекции ↔ перенос поля в задачу | `internal/archrules` → четыре правила о захвате. Закрывает инвариант «колонки правятся в четырёх местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций: по файлу целиком условие выполнялось бы тегами `db:"…"` самой структуры, и правило было бы зелёным всегда |
|
||||||
|
|
||||||
|
### Отмена и внешний собеседник
|
||||||
|
|
||||||
|
| Правило | Где механизировано |
|
||||||
|
| --- | --- |
|
||||||
|
| Запрос и внешний процесс заводятся с контекстом (`exec.CommandContext`, `http.NewRequestWithContext`, `QueryContext`) | `.golangci.yml` → `noctx`. Единая точка не нужна: контекст приезжает доводом, а контракты `internal/contract` несут его первым |
|
||||||
|
| Контекст приезжает сверху, а не заводится по месту (`context.Background()` в середине цепочки) | `.golangci.yml` → `contextcheck` |
|
||||||
|
| Тело ответа HTTP закрывается | `.golangci.yml` → `bodyclose`. Отдельно от `errcheck`: там `(io.ReadCloser).Close` объявлен исключением, и незакрытое тело от невыясненного `Close` неотличимо |
|
||||||
|
|
||||||
|
### Время, вывод, конфигурация
|
||||||
|
|
||||||
|
| Правило | Где механизировано |
|
||||||
|
| --- | --- |
|
||||||
|
| Время читают `clock.Now` (метка, UTC) и `clock.Start` (длительность, монотонные часы) — не `time.Now` по месту | `.golangci.yml` → `forbidigo`; единая точка — `internal/clock` |
|
||||||
|
| Вывод идёт через `slog`, а не `fmt.Print*` и не встроенными `print`/`println` | `.golangci.yml` → `forbidigo`. Не ловит `fmt.Fprintln(os.Stdout, …)` — первый аргумент по имени функции не судится; остаток прозой в [logging.md](logging.md) |
|
||||||
|
| Конфигурация приезжает из TOML, а не из окружения | `.golangci.yml` → `forbidigo`: `os.Getenv`, `os.LookupEnv`, `os.Environ`, `os.ExpandEnv` — все четыре, иначе запрет обходится соседним именем |
|
||||||
|
| Форма вызова `slog`: только пары «ключ-значение», атрибуты (`slog.String` и прочие) не употребляются вовсе; `msg` — константа | `.golangci.yml` → `sloglint` (`kv-only` запрещает атрибуты целиком, а не только смешение) |
|
||||||
|
|
||||||
|
### Проверки о самих проверках
|
||||||
|
|
||||||
|
| Правило | Где механизировано |
|
||||||
|
| --- | --- |
|
||||||
|
| Проверка судит ответ по готовому ответу (`Result()`), а не по живой карте заголовков обработчика | `.golangci.yml` → `forbidigo` с `analyze-types`, находки только в `*_test.go`. Судит по типу приёмника (`httptest.ResponseRecorder`), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем `HeaderMap`. Остаётся ревью проверка, идущая мимо recorder — через свой `http.ResponseWriter` |
|
||||||
|
| Каждый сценарий нормы шага сверки версий проверен мутацией, а не памятью | `scripts/check_go_version_test.go` — 20 сценариев спеки `toolchain` плюс два свойства самого шага: исход не зависит от установленного `go`, и шаг не зовёт ни `go`, ни `docker`, ни сеть |
|
||||||
|
| Форма утверждения в проверках: «ожидалось» и «получено» не перепутаны местами, отказ судится `NoError`, а не `Nil`, `require` не зовут из горутины | `.golangci.yml` → `testifylint` |
|
||||||
|
| Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик, логгер и клиент бота; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Без компилятора C шаг гоняет тесты без детектора и краснеет кодом 3: гонки — не повод отнимать у гейта сами тесты |
|
||||||
|
| Строчное подавление называет линтер и причину, а протухшее краснеет | `.golangci.yml` → `nolintlint` (`require-explanation`, `require-specific`, `allow-unused: false`) |
|
||||||
|
|
||||||
|
### Форма кода и файлов вне Go
|
||||||
|
|
||||||
|
| Правило | Где механизировано |
|
||||||
|
| --- | --- |
|
||||||
|
| Форматирование исходников | `.golangci.yml` → `gofmt`; на pre-commit правится на месте |
|
||||||
|
| Подозрительные конструкции языка | `.golangci.yml` → `govet`, `staticcheck`, `ineffassign`, `unused` |
|
||||||
|
| Опечатка в комментарии и в тексте ошибки | `.golangci.yml` → `misspell` |
|
||||||
|
| Скрипты оболочки | `Taskfile.yml` → шаг `shell` (`shellcheck`), он же на pre-commit |
|
||||||
|
| Форма `Dockerfile` | `Taskfile.yml` → шаг `dockerfile` (`hadolint`), он же на pre-commit |
|
||||||
|
| Одно число версии Go в `go.mod`, `Dockerfile`, `CLAUDE.md` и `README.md` | `Taskfile.yml` → шаг `go-version` (`scripts/check-go-version.sh`) |
|
||||||
|
|
||||||
|
### Хранилище, документы, секреты, зависимости
|
||||||
|
|
||||||
|
| Правило | Где механизировано |
|
||||||
|
| --- | --- |
|
||||||
|
| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `A` | `Taskfile.yml` → шаг `migrations`. Закрывает инвариант CLAUDE.md (critical), которого не держит ни компилятор, ни хранилище: применённое считается по имени файла. Баз диффа две — `BASE` и `HEAD`: первая отвечает на «шаг уже уехал» ровно настолько, насколько свежа `origin/master`, вторая ловит правку закоммиченного шага независимо от неё. Каталог берётся из ключа `migrations` в `docs/.docs.json`, чтобы у факта не было второго дома; пустой каталог роняет шаг — правило, потерявшее предмет, молчать не должно. `migrations.go` под правило не подпадает: строка `Register` нового шага прибавляется именно там |
|
||||||
|
| Раскладка документов, битые ссылки, изменённый шаг схемы без правки `database.md` | `docs.py check`; каталог шагов задаёт ключ `migrations` в `docs/.docs.json` |
|
||||||
|
| Согласованность каталога задач, форма `openspec/config.yaml` | `tasks.py check`, `openspec.py check` |
|
||||||
|
| Секреты в коммите | `lefthook.yml` → `gitleaks git --staged` |
|
||||||
|
| Достижимая из кода уязвимость в зависимостях | `Taskfile.yml` → шаг `vulns` (`govulncheck ./...`) |
|
||||||
|
|
||||||
|
Не названное здесь место механизации означает, что проход по конвенциям будет
|
||||||
|
добросовестно проверять уже проверенное.
|
||||||
|
|
||||||
|
## Подавления: что и почему
|
||||||
|
|
||||||
|
Подавление — это решение, а не настройка, поэтому каждое названо поимённо и с
|
||||||
|
причиной. Причина живёт строкой рядом с подавлением (в `.golangci.yml` или
|
||||||
|
`Taskfile.yml`), а здесь — их перечень, чтобы видеть все разом.
|
||||||
|
|
||||||
|
| Подавлено | Где | Почему |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `errcheck` на `defer Close`, `os.Remove` и `send` | `.golangci.yml`, `exclude-functions` | Отказ, который решено не проверять, объявляют поимённо — так он заметен |
|
||||||
|
| Правило о заголовках вне `*_test.go` | `.golangci.yml`, `exclusions` | В рабочем коде `Header()` и есть способ отдать заголовок |
|
||||||
|
| `time.Now` внутри `internal/clock` | там же | Единой точке чтения времени нечем читать время иначе |
|
||||||
|
| Чтение времени и окружения в `*_test.go` | там же | Проверка строит вход прогона — фикстуру времени, `PATH`, окружение дочернего процесса, — а не метку домена и не настройки приложения. Исключение объявлено по тексту сообщения: правило называет четыре имени, и исключение обязано покрывать те же четыре |
|
||||||
|
| `noctx` на `httptest.NewRequest` в `*_test.go` | `.golangci.yml`, `exclusions` | Фикстура запроса к обработчику в том же процессе: внешнего собеседника за ней нет, отменять нечего. Изъятие названо по имени этой функции, а не выключением `noctx` на проверках: настоящий внешний вызов из проверки — `http.Get`, `exec.Command` — правилу по-прежнему подсуден, и это проверено мутацией |
|
||||||
|
| `SC1007` в `scripts/check-go-version.sh` | директива в скрипте | Ложное срабатывание на идиому `CDPATH= cd`, которая защищает `cd` от чужого `CDPATH` |
|
||||||
|
| `DL3007` (`alpine:latest`) | `Taskfile.yml`, шаг `dockerfile` | Открытая задача `pin-runtime-image-base`; до её решения шаг краснел бы на известном |
|
||||||
|
| `DL3018` (закрепить версии `apk`) | там же | Alpine не держит старые версии пакетов в репозитории: закрепление ломает сборку через недели |
|
||||||
|
|
||||||
|
## Что остаётся прозой
|
||||||
|
|
||||||
|
**Из перечисленного в записях конвенций правилом выражено не всё.** Прозой
|
||||||
|
остаётся то, чему нет ни готового правила, ни детерминированного оракула:
|
||||||
|
уровень лога по адресату, единая логирующая точка на доменной границе, словарь
|
||||||
|
имён полей, канонический вид идентификатора, естественные ключи у деталей.
|
||||||
|
Свойство, оставшееся прозой, проверяет человек на каждом ревью заново — это и
|
||||||
|
есть первая ступень лестницы, и подъём с неё всегда выигрыш.
|
||||||
|
|
||||||
|
Названы поимённо и **остатки правил** — то, что правило не ловит и потому
|
||||||
|
осталось человеку:
|
||||||
|
|
||||||
|
- вывод в stdout через `fmt.Fprintln(os.Stdout, …)` и `os.Stdout.WriteString`:
|
||||||
|
`forbidigo` судит по имени вызванной функции, а не по её первому аргументу;
|
||||||
|
- проверка, судящая ответ мимо recorder — через свой `http.ResponseWriter`;
|
||||||
|
- **отсутствие** контекста у сигнатуры: `contextcheck` ловит обрыв цепочки —
|
||||||
|
`context.Background()` там, где контекст был доводом, — но метод, у которого
|
||||||
|
довода нет вовсе, правилу не виден. Первый проброс контекста в новый адаптер
|
||||||
|
остаётся человеку;
|
||||||
|
- шаг `migrations` судит только те шаги схемы, которые **есть в базе диффа**: у
|
||||||
|
добавленного после неё файла статус `A`, и правка такого файла законна — он
|
||||||
|
ещё никуда не уехал. Отсюда следствие: при отставшей `origin/master` правило
|
||||||
|
молчит на всём каталоге, и на подозрении база задаётся руками
|
||||||
|
(`task migrations BASE=<rev>`);
|
||||||
|
- направление «транспорт не знает адаптера»: сегодня оно нарушено осознанно —
|
||||||
|
`controller/http` импортирует адаптер хранилища, потому что HTTP-поверхность и
|
||||||
|
есть роутер этого хранилища. Изъятие названо в
|
||||||
|
[../architecture.md](../architecture.md), «Принципы», и правила на это направление
|
||||||
|
нет.
|
||||||
|
|
||||||
|
Отдельно названы **правила, чей подъём отклонён**:
|
||||||
|
|
||||||
|
- `key-naming-case` у `sloglint` — словарь полей намеренно смешанный: доменные
|
||||||
|
поля `snake_case`, системные домены с точкой (`http.method`, `ext.service`);
|
||||||
|
- `msg-style: lowercased` у `sloglint` — это ровно конвенция «`msg` — короткая
|
||||||
|
константа в нижнем регистре», но код называет сообщения предложениями с
|
||||||
|
заглавной, и это объявленное *Расхождение*. Цена подъёма — переписать больше
|
||||||
|
ста вызовов, и она не заплачена;
|
||||||
|
- закрепление версий пакетов `apk` (`DL3018`) — см. подавления выше.
|
||||||
|
|
||||||
|
**Кандидат, ждущий решения:** `clock.Now` и `clock.Start` отдают один тип, поэтому
|
||||||
|
`time.Since(clock.Now())` компилируется и молча меряет длительность настенными
|
||||||
|
часами — ровно то, против чего пакет и написан. Держал бы это компилятор, будь у
|
||||||
|
`Start` свой тип с методом `Elapsed()`. Сегодня таких мест нет.
|
||||||
|
|
||||||
|
## Как заводят новое правило
|
||||||
|
|
||||||
|
Порядок один и тот же, и последние два шага пропускать нельзя.
|
||||||
|
|
||||||
|
1. **Найти дом.** Ступень лестницы выбирается по тому, чем свойство
|
||||||
|
выражается, а не по тому, что проще включить.
|
||||||
|
2. **Написать причину рядом.** Правило без причины снимают при первом же
|
||||||
|
неудобстве: тот, кто снимает, не знает, что оно ловило.
|
||||||
|
3. **Починить находки, а не подавить.** Подавление годится, когда правило
|
||||||
|
говорит не о том, что мы имели в виду; тогда оно попадает в перечень выше с
|
||||||
|
причиной. Подавление «пока некогда» — это отложенная работа, и её место в
|
||||||
|
каталоге задач, а не в конфиге.
|
||||||
|
4. **Проверить мутацией.** Внести ровно то нарушение, против которого правило
|
||||||
|
написано, и убедиться, что проверка краснеет и называет место. Правило,
|
||||||
|
принятое молчанием инструмента, — это не правило: прецеденты есть, и записаны
|
||||||
|
они в [../review.md](../review.md) (журнал 2026-08-11 про недостижимую норму,
|
||||||
|
2026-08-13 про обходимый текстовый запрет).
|
||||||
|
|
||||||
|
**Мутация обязана собираться.** Правка, снявшая последнее употребление
|
||||||
|
импорта, роняет сборку, а не проверку: вывод при этом похож на отказ, и
|
||||||
|
мутацию легко засчитать сработавшей. Прежде чем верить красному, убедись, что
|
||||||
|
красное — от проверки.
|
||||||
|
|
||||||
|
**Мутация ставится по одному нарушению на строку.** `golangci-lint` печатает
|
||||||
|
с одной строки исходника **одну** находку (умолчание `uniq-by-line`), и
|
||||||
|
мутация, задевшая сразу два правила, покажет только первое: так молчали
|
||||||
|
`sqlclosecheck` и `rowserrcheck` на пробе, где та же строка уже краснела от
|
||||||
|
`noctx`. Проверять правило пробой, где оно единственное нарушенное.
|
||||||
|
5. **Записать строкой здесь** и удалить прозу из конвенции, если правило её
|
||||||
|
заменило.
|
||||||
+30
-10
@@ -11,8 +11,13 @@ OpenSpec.
|
|||||||
категория; шаг конвейера логирует и себя, и свой исход, и при этом возвращает
|
категория; шаг конвейера логирует и себя, и свой исход, и при этом возвращает
|
||||||
ошибку выше, где её логируют снова.
|
ошибку выше, где её логируют снова.
|
||||||
|
|
||||||
**Механизировано:** ничего. Ни `sloglint`, ни `forbidigo` в `.golangci.yml` не
|
**Механизировано:** форма вызова — `sloglint`: только пары
|
||||||
включены, поэтому правилами не выражено ни одно из перечисленного ниже.
|
«ключ-значение», `msg` константой, **атрибуты (`slog.String` и прочие) не
|
||||||
|
употребляются вовсе**. Запрет `fmt.Print*` и встроенных `print`/`println` —
|
||||||
|
`forbidigo`; вывод в stdout через `fmt.Fprintln(os.Stdout, …)` правилом не
|
||||||
|
ловится и остаётся прозой этой записи. Прозой остаются также уровень по адресату,
|
||||||
|
единая логирующая точка и словарь имён полей: оракула у них нет. Адреса —
|
||||||
|
[go-linters.md](go-linters.md), «Механизировано».
|
||||||
|
|
||||||
## Принципы
|
## Принципы
|
||||||
|
|
||||||
@@ -236,16 +241,31 @@ Object Storage, скачивание файла из Telegram и опрос оп
|
|||||||
проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
|
проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
|
||||||
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
|
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
|
||||||
|
|
||||||
*Расхождение:* вычистки нет. Скачивание файла из Telegram идёт обычным
|
Разговор с Telegram этому правилу следует, и точка чистки одна на все вызовы —
|
||||||
`http.Get(file.Link(token))`, и ошибка этого вызова содержит токен бота. Сегодня
|
`internal/adapter/telegram`, `NewBot`. Токен стоит в пути **каждого** обращения к
|
||||||
она не логируется — то есть утечки нет, но защищает от неё только отсутствие
|
Bot API, поэтому чистка на месте употребления закрывала бы один вызов из пяти:
|
||||||
строки лога.
|
|
||||||
|
|
||||||
*Расхождение:* расширение берётся из имени отправителя дословно
|
- отказ транспорта разворачивает в первопричину клиент бота (`safeClient`), а
|
||||||
|
библиотека отдаёт наш отказ вызывающему нетронутым — этим закрыты `getFile`,
|
||||||
|
`sendMessage`, скачивание записи и `getMe` из конструктора;
|
||||||
|
- длинный опрос печатает свои отказы **пакетным логгером самой библиотеки**,
|
||||||
|
минуя наш `slog`; логгер подменён на вычищающий (`tgbotapi.SetLogger`), и
|
||||||
|
замена точная — токен известен.
|
||||||
|
|
||||||
|
Прежде здесь стоял `http.Get(file.Link(token))`, отказ уезжал в журнал вместе с
|
||||||
|
токеном, а конвенция числила это расхождением с оценкой «не логируется», которая
|
||||||
|
была неверной. Запись — [../review.md](../review.md), 2026-08-13; оракулом
|
||||||
|
служат проверки `internal/adapter/telegram/bot_test.go`, судящие по тексту
|
||||||
|
отказа и строке журнала.
|
||||||
|
|
||||||
|
*Изъятие, а не расхождение:* расширение берётся из имени отправителя дословно
|
||||||
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
|
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
|
||||||
расширением, и в журнал оно попадает полем пути. Наружу — в метку метрики — этот
|
расширением. В журнал оно идёт **собственным полем** строки приёма — это
|
||||||
хвост не выходит: там расширение приводится к перечню известных форматов. Остаток
|
объявленное изъятие инварианта приватности ([CLAUDE.md](../../CLAUDE.md),
|
||||||
описан в [../security.md](../security.md).
|
«Инварианты»); ни имени файла в хранилище, ни пути к нему в журнале нет вовсе
|
||||||
|
(норма — `openspec/specs/intake`). Наружу — в метку метрики — хвост не выходит:
|
||||||
|
там расширение приводится к перечню известных форматов. Остаток описан в
|
||||||
|
[../security.md](../security.md).
|
||||||
|
|
||||||
## Куда пишем и уровень
|
## Куда пишем и уровень
|
||||||
|
|
||||||
|
|||||||
+25
-8
@@ -8,11 +8,17 @@
|
|||||||
CGO сборке не нужен.
|
CGO сборке не нужен.
|
||||||
|
|
||||||
Схему двигают **шаги миграций PocketBase** на Go, каталог
|
Схему двигают **шаги миграций PocketBase** на Go, каталог
|
||||||
`internal/adapter/repo/pocketbase`, файл шага — `migrations.go`. Шаг
|
`internal/adapter/repo/pocketbase/migrations`, файл на шаг и имя файла — имя
|
||||||
регистрируется при загрузке пакета, а накатывается при подъёме хранилища
|
шага. Шаг регистрируется при загрузке пакета, а накатывается при подъёме
|
||||||
(`pocketbase.New`), прежде чем стартуют воркеры и сервер. Применённый шаг не
|
хранилища (`pocketbase.New`), прежде чем стартуют воркеры и сервер. Применённый
|
||||||
переписывается — изменение только новым шагом: применённое хранилище считает по
|
шаг не переписывается — изменение только новым шагом: применённое хранилище
|
||||||
имени файла.
|
считает по имени шага.
|
||||||
|
|
||||||
|
Каталог у шагов свой, а не файл внутри пакета репозитория, и причина внешняя:
|
||||||
|
шаг гейта сверяет изменённые шаги схемы с правкой этого документа по **префиксу
|
||||||
|
пути** (`docs/.docs.json`, ключ `migrations`), а префикс наводится только на
|
||||||
|
каталог. Имена коллекций живут там же, рядом с шагом, который их заводит; пакет
|
||||||
|
репозитория берёт их оттуда.
|
||||||
|
|
||||||
**Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита.
|
**Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита.
|
||||||
Свои UUID остались только в **именах файлов**: имя, под которым запись ложится в
|
Свои UUID остались только в **именах файлов**: имя, под которым запись ложится в
|
||||||
@@ -96,9 +102,17 @@ capability, и третий смысл развёл бы одно слово п
|
|||||||
файлы, ни объекты в Object Storage не удаляются после завершения задачи:
|
файлы, ни объекты в Object Storage не удаляются после завершения задачи:
|
||||||
каталог и бакет растут неограниченно.
|
каталог и бакет растут неограниченно.
|
||||||
- **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
|
- **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
|
||||||
не помечено защищённым: право прочитать запись даёт знание её идентификатора,
|
помечено защищённым шагом `202608120001`, а правило просмотра коллекции
|
||||||
и файл встаёт вровень с опросом готовности задачи. Поэтому имя файла в
|
пускает всякого вошедшего: пройти по ссылке можно только с коротким токеном
|
||||||
хранилище **в журнал не пишется** — оно последняя часть ссылки.
|
файла, который берут по сессии. Прежнее решение — «право прочитать запись даёт
|
||||||
|
знание её идентификатора» — отменено задачей `oidc-login` 2026-08-12. Имя файла
|
||||||
|
в хранилище **в журнал не пишется** по-прежнему: оно последняя часть ссылки.
|
||||||
|
- **Коллекция `users`** заводится самой библиотекой, а шаг `202608120001` её
|
||||||
|
сужает: создание записи разрешено только контексту обмена OIDC
|
||||||
|
(`@request.context = "oauth2"`), вход по паролю и одноразовый код выключены.
|
||||||
|
Без этого сужения закрытие API обходится двумя запросами — завести себе
|
||||||
|
запись и войти паролем. Продление сессии закрыто слоем в приложении, а не
|
||||||
|
настройкой коллекции: библиотека выдаёт сессию продлеваемой всегда.
|
||||||
- **Захват задачи — один запрос с `RETURNING`**, мимо записей коллекции.
|
- **Захват задачи — один запрос с `RETURNING`**, мимо записей коллекции.
|
||||||
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
|
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
|
||||||
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
|
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
|
||||||
@@ -134,6 +148,9 @@ capability, и третий смысл развёл бы одно слово п
|
|||||||
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
|
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
|
||||||
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
|
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
|
||||||
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
|
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
|
||||||
|
| Срок жизни сессии | 7 суток | `pbrepo.SessionDuration`, ставится при подъёме | решение владельца 2026-08-12; умолчание библиотеки в 5 суток никем не выбрано |
|
||||||
|
| Потолок времени на вход у провайдера | 10 минут | `controller/http/auth.go` | дольше носитель состояния не нужен |
|
||||||
|
| Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым |
|
||||||
|
|
||||||
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у
|
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у
|
||||||
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля
|
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля
|
||||||
|
|||||||
+17
-9
@@ -22,7 +22,7 @@
|
|||||||
| Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось |
|
| Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось |
|
||||||
| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи |
|
| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи |
|
||||||
| Пользователь Telegram | Отправить боту голосовое сообщение и получить текст ответом. Работает сегодня |
|
| Пользователь Telegram | Отправить боту голосовое сообщение и получить текст ответом. Работает сегодня |
|
||||||
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Работает сегодня, но токенов нет и доступ не разграничен |
|
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня почти не работает: приём и опрос закрыты сессией OIDC, а своего токена у программы нет — годится только кука, снятая из браузера. Токен приносит `api-tokens` |
|
||||||
|
|
||||||
**Основной вход — приложение**, бот и HTTP API дополняют его. До 2026-08-11
|
**Основной вход — приложение**, бот и HTTP API дополняют его. До 2026-08-11
|
||||||
основным был бот, и порядок здесь перевёрнут сознательно: диктофонная запись на
|
основным был бот, и порядок здесь перевёрнут сознательно: диктофонная запись на
|
||||||
@@ -68,8 +68,11 @@
|
|||||||
файл. Своей записи и работы без сети не делаем — граница цели
|
файл. Своей записи и работы без сети не делаем — граница цели
|
||||||
[web-access](../tasks/items/web-access.md).
|
[web-access](../tasks/items/web-access.md).
|
||||||
- **Файловое хранилище общего назначения.** Храним аудио и видео, отданные ради
|
- **Файловое хранилище общего назначения.** Храним аудио и видео, отданные ради
|
||||||
речи в них. Складом произвольных файлов, папками и общим доступом к чужим
|
речи в них. Складом произвольных файлов и папками сервис не становится. Общего
|
||||||
записям сервис не становится.
|
доступа к чужим записям целью тоже нет — но **сегодня он есть**: владельца у
|
||||||
|
записи в модели данных не существует, и всякий вошедший видит все записи
|
||||||
|
([security.md](security.md), «Периметр»). Это состояние, а не решение;
|
||||||
|
закрывает его `record-ownership`.
|
||||||
- **Учёт денег.** Считаем объём, минуты и токены по каждому пользователю и
|
- **Учёт денег.** Считаем объём, минуты и токены по каждому пользователю и
|
||||||
показываем их владельцу. Цен, счетов и отказов по исчерпании квоты не делаем:
|
показываем их владельцу. Цен, счетов и отказов по исчерпании квоты не делаем:
|
||||||
пользователя, потратившего слишком много, останавливает разговор или отзыв
|
пользователя, потратившего слишком много, останавливает разговор или отзыв
|
||||||
@@ -95,8 +98,10 @@
|
|||||||
отличает их по MIME-типу и расширению. Работает сегодня.
|
отличает их по MIME-типу и расширению. Работает сегодня.
|
||||||
5. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном,
|
5. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном,
|
||||||
получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не
|
получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не
|
||||||
увидит `done` и текст. Работает сегодня, но без токена и без разграничения
|
увидит `done` и текст. Сегодня доступно только предъявившему сессию OIDC:
|
||||||
доступа.
|
анонимный запрос обоими адресами отклоняется. Своего входа у программы нет —
|
||||||
|
его заводит `api-tokens`, — как нет и разграничения записей между
|
||||||
|
пользователями.
|
||||||
6. **Отказ на середине.** Конвертация или распознавание не удались — задача
|
6. **Отказ на середине.** Конвертация или распознавание не удались — задача
|
||||||
переходит в `failed`, а пользователь получает сообщение о том, что именно не
|
переходит в `failed`, а пользователь получает сообщение о том, что именно не
|
||||||
вышло, и предложение повторить.
|
вышло, и предложение повторить.
|
||||||
@@ -109,7 +114,10 @@
|
|||||||
которой пользуемся: она и задаёт потолок по длине записи и формату.
|
которой пользуемся: она и задаёт потолок по длине записи и формату.
|
||||||
- **Whisper и его серверные обёртки** — запасной путь, если внешний сервис
|
- **Whisper и его серверные обёртки** — запасной путь, если внешний сервис
|
||||||
перестанет устраивать по цене или по качеству русской речи.
|
перестанет устраивать по цене или по качеству русской речи.
|
||||||
- **PocketBase** — хранилище взамен сегодняшнего SQLite, решено 2026-08-11
|
**PocketBase** из референсов ушла: она больше не кандидат — в стек её перевела
|
||||||
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)). Учётные
|
задача `pocketbase-storage` 2026-08-12
|
||||||
записи оно хранит и получает от Authelia своим провайдером OIDC, но источником
|
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)); там она
|
||||||
их не становится: заводит и проверяет людей по-прежнему Authelia.
|
держит хранилище, файлы и панель владельца. Схема и
|
||||||
|
раскладка — [database.md](database.md). Учётные записи она хранит и получает от
|
||||||
|
Authelia своим провайдером OIDC, но источником их не становится: заводит и
|
||||||
|
проверяет людей по-прежнему Authelia.
|
||||||
|
|||||||
@@ -99,6 +99,77 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
|
|||||||
библиотечной сборке тоже: пустое приложение с одним своим обработчиком отвечало
|
библиотечной сборке тоже: пустое приложение с одним своим обработчиком отвечало
|
||||||
на `/_/` кодом `200`.
|
на `/_/` кодом `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`. Значит всё, что пришло параметром адреса, оседает там на пять
|
||||||
|
суток; проект умолчание не переопределяет.
|
||||||
|
|
||||||
## Что отвергнуто и почему
|
## Что отвергнуто и почему
|
||||||
|
|
||||||
- **Держать файлы на диске как сейчас, а в базе — путь строкой.** Отвергнуто:
|
- **Держать файлы на диске как сейчас, а в базе — путь строкой.** Отвергнуто:
|
||||||
|
|||||||
+263
-15
@@ -8,6 +8,11 @@
|
|||||||
Разделы ниже заполнены наперёд по коду и правятся по итогам прогонов: «Типовые
|
Разделы ниже заполнены наперёд по коду и правятся по итогам прогонов: «Типовые
|
||||||
ложноположительные» первым прогоном уже пользовались.
|
ложноположительные» первым прогоном уже пользовались.
|
||||||
|
|
||||||
|
Что уже проверяет машина и о чём поэтому спрашивать не нужно — конвенция
|
||||||
|
[conventions/go-linters.md](conventions/go-linters.md). Вопросы ниже — то, чего
|
||||||
|
машина не проверяет; свойства, которые обязан проверять тест, — в «Типовых
|
||||||
|
узлах».
|
||||||
|
|
||||||
### Типовые узлы
|
### Типовые узлы
|
||||||
|
|
||||||
Рода узлов проекта и проверяемые свойства к каждому.
|
Рода узлов проекта и проверяемые свойства к каждому.
|
||||||
@@ -38,12 +43,17 @@
|
|||||||
- вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в
|
- вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в
|
||||||
успех молча.
|
успех молча.
|
||||||
|
|
||||||
**Репозиторий SQLite** (`adapter/repo/sqlite`):
|
**Репозиторий хранилища** (`internal/adapter/repo/pocketbase`; шаги схемы —
|
||||||
|
подпакетом `migrations`):
|
||||||
|
|
||||||
- список колонок совпадает во всех четырёх запросах файла;
|
- список колонок совпадает во всех четырёх местах — `applyToRecord`,
|
||||||
- `NULL` в колонке разбирается в указатель, а не роняет `Scan`;
|
`recordToJob`, `acquireColumns`, `acquiredRow` — и в шаге схемы (инвариант
|
||||||
- захват задачи не выдаёт одну строку двум вызывающим;
|
[CLAUDE.md](../CLAUDE.md), «Инварианты»);
|
||||||
- ошибка драйвера транслируется в доменную у источника.
|
- захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только
|
||||||
|
держатель захвата;
|
||||||
|
- репозиторий кладёт время в сыром запросе тем же видом, каким хранилище пишет
|
||||||
|
свои `created`/`updated` ([database.md](database.md), «Представление данных»);
|
||||||
|
- отказ хранилища не выходит наружу дословно: он несёт ключ файла целиком.
|
||||||
|
|
||||||
**Обёртка над внешним процессом** (`adapter/converter/ffmpeg`,
|
**Обёртка над внешним процессом** (`adapter/converter/ffmpeg`,
|
||||||
`adapter/metaviewer/ffmpeg`):
|
`adapter/metaviewer/ffmpeg`):
|
||||||
@@ -95,20 +105,29 @@
|
|||||||
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
|
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
|
||||||
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
|
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
|
||||||
Новой находкой это не считается, пока не измерен рост.
|
Новой находкой это не считается, пока не измерен рост.
|
||||||
- **«HTTP API открыт без аутентификации».** Известно и записано первой строкой
|
- **«У записи нет владельца: вошедший видит чужие записи».** Не дефект и не
|
||||||
[security.md](security.md). Находкой считается только новая поверхность,
|
новость: приём, опрос и файл закрыты сессией OIDC с 2026-08-12, а
|
||||||
выставленная наружу, а не повторение этого факта.
|
разграничения по владельцу нет сознательно — [security.md](security.md),
|
||||||
|
«Периметр», и `openspec/specs/access`, «Purpose». Находкой считается новая
|
||||||
|
поверхность, выставленная наружу, либо путь к содержимому записи **без**
|
||||||
|
сессии, а не повторение этого факта.
|
||||||
|
|
||||||
### Вопросы по темам
|
### Вопросы по темам
|
||||||
|
|
||||||
Форма: `<тема>: <вопрос> (<провенанс>)`.
|
Форма: `<тема>: <вопрос> (<провенанс>)`.
|
||||||
|
|
||||||
- `operations`: пережил ли шаг конвейера отмену контекста на середине — воркеры
|
- `operations`: как шаг отвечает на отмену посреди работы — контекст доходит до
|
||||||
получают `ctx`, но ни один шаг его внутрь не передаёт (чтение `worker.go` и
|
внешнего собеседника и это держат правила `noctx` и `contextcheck`
|
||||||
`transcribe.go`, 2026-08-10).
|
([conventions/go-linters.md](conventions/go-linters.md), «Отмена и внешний
|
||||||
|
собеседник»), а исход прерванного шага нормой по-прежнему не описан
|
||||||
|
(`openspec/specs/pipeline`, `Purpose`). Спрашивать надо не «доходит ли», а «что
|
||||||
|
делает с задачей, деньгами и ответом отправителю» (чтение `worker.go` и
|
||||||
|
`transcribe.go`, 2026-08-13; прежний провенанс 2026-08-10 устарел вместе с
|
||||||
|
дефектом «остановка хоронила запись»).
|
||||||
- `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у
|
- `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у
|
||||||
одного из них таймаута нет (чтение `tg.go`, `s3.go`, `speechkit.go`,
|
одного из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
|
||||||
2026-08-10).
|
контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `tg.go`,
|
||||||
|
`s3.go`, `speechkit.go`, 2026-08-13).
|
||||||
- `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и
|
- `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и
|
||||||
возвращает её воркеру, который логирует снова (чтение `transcribe.go`,
|
возвращает её воркеру, который логирует снова (чтение `transcribe.go`,
|
||||||
2026-08-10).
|
2026-08-10).
|
||||||
@@ -132,6 +151,23 @@
|
|||||||
(CLAUDE.md, «Инварианты»).
|
(CLAUDE.md, «Инварианты»).
|
||||||
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним тестом — сегодня
|
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним тестом — сегодня
|
||||||
тестов два файла, и оба мимо конвейера.
|
тестов два файла, и оба мимо конвейера.
|
||||||
|
- `autotests`: судит ли проверка ответа по готовому ответу, а не по изменяемому
|
||||||
|
состоянию обработчика — **только там, где ответ идёт мимо recorder**, через
|
||||||
|
свой `http.ResponseWriter`. Обращение к живой карте recorder'а с
|
||||||
|
2026-08-12 роняет гейт правилом линтера
|
||||||
|
([conventions/go-linters.md](conventions/go-linters.md), «Механизировано»), и
|
||||||
|
спрашивать о нём не нужно.
|
||||||
|
- `security`: не открылась ли снова поверхность, которую приносит хранилище, —
|
||||||
|
собственная регистрация, вход по паролю, одноразовый код, восстановление
|
||||||
|
доступа, продление сессии. Всё это приходит включённым и закрывается нами
|
||||||
|
(задача `oidc-login` 2026-08-12).
|
||||||
|
- `security`: не появился ли второй способ получить сессию к тому же человеку —
|
||||||
|
заголовок вместо куки назван осознанно, прочие способы обязаны быть закрыты.
|
||||||
|
- `operations`: доходит ли отзыв доступа у провайдера до сервиса и за какой срок —
|
||||||
|
после входа сервис к провайдеру не обращается, и канал здесь один
|
||||||
|
(ADR-2026-08-12-session-without-refresh).
|
||||||
|
- `architecture`: не зовётся ли на каждый запрос то, что меняет состояние
|
||||||
|
приложения, — сборка роутера хранилища оказалась именно такой.
|
||||||
|
|
||||||
### Триггеры метки
|
### Триггеры метки
|
||||||
|
|
||||||
@@ -179,7 +215,14 @@ API и имя не откатываются обратной правкой по
|
|||||||
- `operations`: реальный профиль нагрузки. Проект работает на единицах записей в
|
- `operations`: реальный профиль нагрузки. Проект работает на единицах записей в
|
||||||
день, и утверждения о росте остаются условиями, а не замерами;
|
день, и утверждения о росте остаются условиями, а не замерами;
|
||||||
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
|
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
|
||||||
отдан внешней программе, и она вне нашей границы.
|
отдан внешней программе, и она вне нашей границы;
|
||||||
|
- `security`: поведение настоящей Authelia и её правило на нашего клиента.
|
||||||
|
Провайдера в прогоне нет, подменяет его свой сервер; кто допущен — настройка
|
||||||
|
выкладки вне репозитория, и по коду её не проверить
|
||||||
|
([adr/ADR-2026-08-12-access-delegated-to-provider.md](adr/ADR-2026-08-12-access-delegated-to-provider.md));
|
||||||
|
- `security`: поведение браузера с куками — применение `SameSite`, приём
|
||||||
|
`Set-Cookie` при переходе с чужого сайта. Браузера в прогоне нет, и находки
|
||||||
|
этого рода остаются гипотезами.
|
||||||
|
|
||||||
**Перестали проверять сознательно:**
|
**Перестали проверять сознательно:**
|
||||||
|
|
||||||
@@ -187,7 +230,13 @@ API и имя не откатываются обратной правкой по
|
|||||||
2026-08-11 — правда, звали так, что он всегда отказывал, — а теперь получают
|
2026-08-11 — правда, звали так, что он всегда отказывал, — а теперь получают
|
||||||
длительность от подставного источника. Своего теста у
|
длительность от подставного источника. Своего теста у
|
||||||
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в
|
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в
|
||||||
[adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md).
|
[adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md);
|
||||||
|
- **всё, что требует поднять сервис целиком.** Локальный запуск роняет адаптер
|
||||||
|
Telegram: он проверяет токен обращением к Telegram, а боевым токеном
|
||||||
|
запускаться запрещено. Значит поведенческая верификация живым прогоном
|
||||||
|
недоступна ни одной задаче, и заменяют её проверки поверх настоящего роутера
|
||||||
|
хранилища. Замечено 2026-08-12 задачей `oidc-login`; своей задачи на это пока
|
||||||
|
нет.
|
||||||
|
|
||||||
## Журнал дефектов
|
## Журнал дефектов
|
||||||
|
|
||||||
@@ -197,6 +246,199 @@ API и имя не откатываются обратной правкой по
|
|||||||
поймать их было некому. У восстановленных нет поля «Чем воспроизведён», и
|
поймать их было некому. У восстановленных нет поля «Чем воспроизведён», и
|
||||||
выдумывать его задним числом нельзя.
|
выдумывать его задним числом нельзя.
|
||||||
|
|
||||||
|
## 2026-08-13 — остановка сервиса хоронила конвертируемую запись [пойман ревью]
|
||||||
|
|
||||||
|
- **Где:** `internal/service/transcribe.go`, шаг конвертации — дефект завела та
|
||||||
|
же правка, что проложила контекст до `ffmpeg`
|
||||||
|
- **Симптом:** на прод не уехал, поймали до коммита. Выглядел бы так: обычная
|
||||||
|
выкладка посреди конвертации переводит здоровую запись в терминальное
|
||||||
|
`failed`, отправителю уходит «сбой конвертации файла», а вернуть задачу может
|
||||||
|
только владелец правкой в панели. Окно — часы: конвертация шестичасовой записи
|
||||||
|
идёт дольше часа по построению
|
||||||
|
- **Причина:** контекст дошёл до внешнего процесса, а различать его отмену шаг
|
||||||
|
не научили. Убитый по контексту `ffmpeg` отдаёт `signal: killed` — от
|
||||||
|
настоящего отказа (`exit status N`) эта ошибка неотличима ни типом, ни
|
||||||
|
`errors.Is`: различает только `ctx.Err()`. Шаг звал `failJob` на любой отказ
|
||||||
|
`Convert`. Хуже: `failJob` возвращает `nil`, поэтому воркер считал прогон
|
||||||
|
успешным, и метрика владельца — та, которой он замечает отказы, — не
|
||||||
|
шевелилась
|
||||||
|
- **Чем воспроизведён:** проверкой `TestShutdownDuringConversionKeepsJobRetryable`
|
||||||
|
с подставным конвертером, ведущим себя как убитый процесс: отдаёт отказ, не
|
||||||
|
несущий `context.Canceled`. Мутация снята — без развилки проверка краснеет
|
||||||
|
- **Почему не поймали раньше:** правка выглядела механической, «линтер потребовал
|
||||||
|
контекст». Цена оказалась в семантике очереди, а не в сигнатурах: отмена стала
|
||||||
|
значить разное на соседних шагах одного конвейера. Ни один линтер такого не
|
||||||
|
видит — это заметили три прохода ревью независимо, и все три построили путь
|
||||||
|
- **Что меняем:** прерванный шаг приговора не выносит — задача остаётся на
|
||||||
|
повтор, попытку не тратит (счётчик, выросший при захвате, возвращают назад) и
|
||||||
|
отправителю о несуществующем сбое не сообщает. Воркер не считает остановку
|
||||||
|
отказом и не пишет о ней владельцу. Задача не забирается вовсе, если нас уже
|
||||||
|
остановили. Остаток объявлен: норма отмены в спеке `pipeline` не описана, и
|
||||||
|
открытая задача `context-cancel-in-pipeline` этим закрыта не целиком
|
||||||
|
|
||||||
|
## 2026-08-13 — отказ скачивания уносил токен бота в журнал [проскочил]
|
||||||
|
|
||||||
|
- **Где:** `internal/controller/tg/tg.go`, скачивание записи по ссылке
|
||||||
|
`file.Link(c.bot.Token)`
|
||||||
|
- **Симптом:** не наблюдался, потому что журнал за этим местом никто не читал
|
||||||
|
построчно. Первый же сбой сети на скачивании писал в журнал
|
||||||
|
`Failed to download audio file` вместе с полным адресом запроса, а в адресе
|
||||||
|
Telegram держит токен бота (`…/bot<TOKEN>/…`). Инвариант «секрет не покидает
|
||||||
|
конфиг» помечен critical и необратим: утёкший токен отзывают руками
|
||||||
|
- **Причина:** `http.Get` возвращает `*url.Error`, и тот встраивает адрес
|
||||||
|
целиком. Отказ уходил в `fmt.Errorf("failed to download file: %w", err)`, а
|
||||||
|
оттуда — в `logger.Error` соседней строкой
|
||||||
|
- **Чем воспроизведён:** чтением цепочки от `http.Get` до вызова `logger.Error`
|
||||||
|
в трёх обработчиках; на живом боте не проверялся — боевым токеном запускаться
|
||||||
|
запрещено
|
||||||
|
- **Почему не поймали раньше:** правило было записано прозой и ровно про этот
|
||||||
|
случай — [conventions/logging.md](conventions/logging.md), «Ошибка
|
||||||
|
HTTP-транспорта несёт URL». Хуже: там же стояло объявленное *Расхождение* с
|
||||||
|
оценкой «сегодня она не логируется — то есть утечки нет», и оценка была
|
||||||
|
неверной. Строка лога существовала всё это время, но проза о ней не знала, а
|
||||||
|
машина прозу не проверяет
|
||||||
|
- **Что меняем:** чистка перенесена с места употребления на **границу клиента** —
|
||||||
|
`internal/adapter/telegram`, `NewBot`: свой `Do` разворачивает отказ в
|
||||||
|
первопричину, а подменённый логгер библиотеки вычищает токен из строк длинного
|
||||||
|
опроса, которые она печатает сама, мимо нашего `slog`. Транспорт бота токена
|
||||||
|
больше не получает: клиента ему отдают готовым. Расхождение в конвенции
|
||||||
|
закрыто, оценка в [security.md](security.md) исправлена
|
||||||
|
- **Чем закрыт от возврата:** проверками `internal/adapter/telegram/bot_test.go`
|
||||||
|
— четыре пути (`getFile`, `sendMessage`, конструктор, логгер библиотеки)
|
||||||
|
судятся по тексту отказа и строке журнала. Мутация снята: со снятой чисткой
|
||||||
|
три из них краснеют, печатая токен. Правило остаётся прозой (линтер не отличит
|
||||||
|
ссылку с секретом от ссылки без него), но у прозы теперь есть оракул
|
||||||
|
- **Как нашли:** первый путь — попутно, при разборе находок `noctx`: тот
|
||||||
|
потребовал переписать `http.Get` на запрос с контекстом, и цепочку пришлось
|
||||||
|
прочитать целиком. Остальные четыре — конвейером ревью в тот же день; правка,
|
||||||
|
закрывшая один путь, объявила класс закрытым в двух документах, и это едва не
|
||||||
|
осталось так
|
||||||
|
|
||||||
|
## 2026-08-13 — конец потока распознавания узнавался по тексту сообщения [пойман сканером]
|
||||||
|
|
||||||
|
- **Где:** `internal/adapter/recognizer/yandex/speechkit.go`, чтение потока
|
||||||
|
результата распознавания
|
||||||
|
- **Симптом:** сегодня не наблюдался — путь рабочий, пока библиотека отдаёт конец
|
||||||
|
потока значением `io.EOF`. Отказ с текстом «EOF» был бы принят за конец потока,
|
||||||
|
и расшифровка вернулась бы усечённой: пользователь получил бы половину записи
|
||||||
|
как готовый результат
|
||||||
|
- **Причина:** конец потока узнавался сравнением `err.Error() == "EOF"`. Текст
|
||||||
|
сообщения — не признак: его носит и чужая ошибка, а сменит его библиотека —
|
||||||
|
условие перестанет срабатывать вовсе, и оба исхода молчаливы
|
||||||
|
- **Чем воспроизведён:** не воспроизводился на живом сервисе — прогон на реальных
|
||||||
|
ключах запрещён. Найден тестом-сканером `internal/archrules` при его заведении
|
||||||
|
- **Почему не поймали раньше:** `errorlint` видит `err == ErrX` и приведение типа,
|
||||||
|
но матчинг по тексту не видит; прозой это правило записано не было, и ревью его
|
||||||
|
не спрашивало
|
||||||
|
- **Что меняем:** узнавание переведено на `errors.Is(err, io.EOF)`; класс закрыт
|
||||||
|
тестом-сканером (docs/conventions/go-linters.md, «Ошибки и отказы»)
|
||||||
|
|
||||||
|
## 2026-08-13 — правило гейта обходилось одной лишней строкой [пойман ревью]
|
||||||
|
|
||||||
|
- **Где:** `.golangci.yml`, правило `forbidigo` о суждении по живой карте
|
||||||
|
заголовков — заведено в тот же день задачей `response-assertions-judge-result`
|
||||||
|
- **Симптом:** правило ловило только прямую цепочку `w.Header().Get`. Присваивание
|
||||||
|
в переменную (`h := w.Header()`), чтение по индексу карты, обход `range` и поле
|
||||||
|
`HeaderMap` проходили гейт зелёными — то есть класс, стоивший трёх зелёных
|
||||||
|
гейтов, возвращался четвёртый раз, и уже без человеческой страховки: документы
|
||||||
|
успели снять его с прохода ревью
|
||||||
|
- **Причина:** `forbidigo` по умолчанию судит по печатному тексту вызова, а не по
|
||||||
|
типу значения. Правило, записанное текстом, отсекает одну форму записи, а не
|
||||||
|
свойство
|
||||||
|
- **Чем воспроизведён:** прогоном линтера на файле проверок с шестью формами
|
||||||
|
чтения живой карты: помечена была одна
|
||||||
|
- **Почему не поймали раньше:** правило проверили ровно тем нарушением, против
|
||||||
|
которого писали. Мутация была, но одна — нужна была по одной на каждую форму
|
||||||
|
- **Что меняем:** правило судит по типу приёмника (`analyze-types`,
|
||||||
|
`httptest.ResponseRecorder.Header` и `.HeaderMap`) и ловит все шесть форм;
|
||||||
|
проверено мутацией по каждой. Отсюда же строка в
|
||||||
|
docs/conventions/go-linters.md, «Лестница механизации»: запрет по имени, обходимый лишней строкой, — это ступень
|
||||||
|
тест-сканера, наряженная запретом
|
||||||
|
|
||||||
|
## 2026-08-12 — закрыли поверхность так, что войти не мог никто [пойман ревью]
|
||||||
|
|
||||||
|
- **Где:** шаг схемы `202608120001` задачи `oidc-login`, правило создания записи
|
||||||
|
в коллекции пользователей
|
||||||
|
- **Симптом:** `users.CreateRule = nil` закрывало создание записи для всех, кроме
|
||||||
|
владельца панели. Запись при первом входе заводит внутренний запрос самого
|
||||||
|
обмена, идущий без таких прав, — значит после выкладки вход не сработал бы ни
|
||||||
|
у кого, включая владельца, а приём и опрос уже были закрыты. Сервис остался бы
|
||||||
|
доступен только через Telegram, и чинилось бы это руками в панели
|
||||||
|
- **Причина:** закрывали ровно то, ради чего задача затевалась, — самостоятельную
|
||||||
|
регистрацию, которую хранилище приносит открытой. Глухое `nil` выглядит самым
|
||||||
|
надёжным её закрытием и отвергает заодно единственный законный путь заведения
|
||||||
|
записи. Различить их можно: обмен помечает свой запрос контекстом `oauth2`
|
||||||
|
- **Чем воспроизведён:** тестом против настоящего хранилища с подставным
|
||||||
|
провайдером: возврат от провайдера отвечал `401`, обращений к токен-эндпоинту
|
||||||
|
`1`, учётных записей после входа `0`. Причина изолирована тем же прогоном —
|
||||||
|
с открытым правилом возврат давал `302` и запись появлялась
|
||||||
|
- **Почему не поймали раньше:** все проверки задачи заводили учётную запись
|
||||||
|
прямым сохранением, мимо входа, и потому шли по коду, который в бою не
|
||||||
|
исполняется. Гейт был зелёным. Поймали два прохода независимо — разбор кода по
|
||||||
|
исходникам библиотеки и враждебный проход падающим тестом
|
||||||
|
- **Что меняем:** правило сузили до контекста обмена
|
||||||
|
(`@request.context = "oauth2"`), а в набор проверок добавили вход целиком через
|
||||||
|
подставного провайдера — от увода до куки сессии. Проверка, заводящая запись
|
||||||
|
мимо входа, больше не считается покрытием входа
|
||||||
|
|
||||||
|
## 2026-08-12 — проверка не могла упасть: читала живую карту заголовков вместо ответа [пойман ревью]
|
||||||
|
|
||||||
|
- **Где:** `internal/controller/http/auth_test.go`, проверка уборки носителя
|
||||||
|
состояния входа; сам дефект — в `auth.go`, уборка стояла в `defer`
|
||||||
|
- **Симптом:** носитель состояния и проверочного кода не убирался ни на успешном
|
||||||
|
возврате, ни на отказном, и жил свои десять минут. Одноразовость возврата
|
||||||
|
держалась ровно на этой уборке, то есть тоже не работала. Проверка при этом
|
||||||
|
была зелёной и утверждала обратное
|
||||||
|
- **Причина:** двойная. В коде — `defer` исполняется после того, как ответ уже
|
||||||
|
начали писать, а заголовки к этому моменту зафиксированы снимком, и позднейшая
|
||||||
|
правка их карты до браузера не доезжает. В проверке — `httptest` устроен
|
||||||
|
зеркально: `Header()` отдаёт живую карту, а снимок лежит отдельно и читается
|
||||||
|
через `Result()`. Проверка смотрела в живую карту и видела то, чего клиент не
|
||||||
|
получит
|
||||||
|
- **Чем воспроизведён:** отдельной программой вне проекта: на настоящем сервере
|
||||||
|
ответ приходил с пустым `Set-Cookie`, а тот же обработчик под `httptest`
|
||||||
|
показывал куку в `Header()` и не показывал в `Result()`
|
||||||
|
- **Почему не поймали раньше:** оракул был ложным по построению, и никакая
|
||||||
|
регрессия его не разбудила бы. Гейт зелёный. Поймали два прохода — сверка
|
||||||
|
требований и разбор кода, — оба воспроизведением, а не чтением
|
||||||
|
- **Что меняем:** уборка перенесена до записи ответа; все проверки этого файла
|
||||||
|
судят по `Result()`. Класс всплывает **третий раз** (2026-08-10 «тесты
|
||||||
|
http-обработчика ни разу не были зелёными», 2026-08-11 «проверка приёма не
|
||||||
|
могла упасть»), поэтому он же ушёл в конвенции правилом: проверка ответа
|
||||||
|
судит по готовому ответу, а не по изменяемому состоянию обработчика.
|
||||||
|
Механизировано 2026-08-12 задачей `response-assertions-judge-result` —
|
||||||
|
`forbidigo` в `.golangci.yml` роняет гейт на чтении живой карты заголовков в
|
||||||
|
файле проверок. Правило судит по **типу приёмника**, а не по тексту вызова, и
|
||||||
|
потому ловит любую форму чтения живой карты — цепочкой, через переменную, по
|
||||||
|
индексу, обходом, полем `HeaderMap`. Текстовый запрет ловил только прямую
|
||||||
|
цепочку и обходился одной лишней строкой — это назвал прогон ревью этой же
|
||||||
|
задачи. Проходу ревью остаётся проверка, идущая мимо recorder, через свой
|
||||||
|
`http.ResponseWriter`
|
||||||
|
|
||||||
|
## 2026-08-12 — каждый анонимный запрос навсегда замедлял запись в хранилище [пойман ревью]
|
||||||
|
|
||||||
|
- **Где:** `internal/controller/http/auth.go`, обмен кода собирал роутер
|
||||||
|
хранилища на каждый вызов
|
||||||
|
- **Симптом:** сборка роутера вешает девять обработчиков на само приложение и
|
||||||
|
без идентификатора, поэтому повторная не заменяет прежние, а добавляет.
|
||||||
|
Обработчики исполняются на каждой записи в хранилище, а конвейер пишет задачу на
|
||||||
|
каждом шаге. Освобождения нет — только перезапуск. Раскачивалось анонимно:
|
||||||
|
атакующий ставит себе куку состояния сам, и сверка сравнивает две его же
|
||||||
|
величины, а обмен исполняется раньше обращения к провайдеру
|
||||||
|
- **Причина:** функция сборки выглядит чистой — она возвращает роутер, и по имени
|
||||||
|
не видно, что она правит приложение. Решение звать собственный адрес хранилища
|
||||||
|
внутри процесса сделало эту сборку частью горячего пути
|
||||||
|
- **Чем воспроизведён:** замером на настоящем приложении: пять вызовов подряд
|
||||||
|
подняли число обработчиков одного события с 4 до 14; 3000 анонимных возвратов
|
||||||
|
довели сотню сохранений записи с 3.86 мс до 59.8 мс и кучу на 5013 КиБ. При
|
||||||
|
недоступном провайдере утечка сохранялась
|
||||||
|
- **Почему не поймали раньше:** ни один шаг гейта не смотрит на побочные эффекты
|
||||||
|
вызова библиотеки, а замер требует прогона. Поймали три прохода — архитектурный
|
||||||
|
зондом, враждебный падающим тестом, сверка требований чтением
|
||||||
|
- **Что меняем:** роутер собирается один раз и живёт полем обработчика; в набор
|
||||||
|
проверок добавлена та, что считает длину очереди обработчиков после двадцати
|
||||||
|
входов
|
||||||
|
|
||||||
## 2026-08-12 — образ не собирался, и этого не увидел никто [проскочил]
|
## 2026-08-12 — образ не собирался, и этого не увидел никто [проскочил]
|
||||||
|
|
||||||
**Что сломалось.** `go mod tidy` поднял директиву `go` в `go.mod` до `1.25.0` —
|
**Что сломалось.** `go mod tidy` поднял директиву `go` в `go.mod` до `1.25.0` —
|
||||||
@@ -219,6 +461,12 @@ API и имя не откатываются обратной правкой по
|
|||||||
директивой `go` в `go.mod`. Строкой сравнения, без docker. Заведено урожаем
|
директивой `go` в `go.mod`. Строкой сравнения, без docker. Заведено урожаем
|
||||||
ревью.
|
ревью.
|
||||||
|
|
||||||
|
**Закрыто** задачей `go-1-26-upgrade` 2026-08-12: шаг `go-version` в `task gate`
|
||||||
|
(`scripts/check-go-version.sh`). Сверяются четыре места, а не два, — `go.mod`,
|
||||||
|
`Dockerfile`, `CLAUDE.md`, `README.md`: в этом дефекте трое из четырёх врали
|
||||||
|
согласованно, и парная сверка не увидела бы документ, разошедшийся с
|
||||||
|
согласованным кодом. Норма — capability `toolchain`.
|
||||||
|
|
||||||
## 2026-08-11 — норма требовала от сервиса недостижимого [пойман ревью]
|
## 2026-08-11 — норма требовала от сервиса недостижимого [пойман ревью]
|
||||||
|
|
||||||
- **Где:** дельта-спека `pipeline` задачи `errors-as-instead-of-typecast`, абзац
|
- **Где:** дельта-спека `pipeline` задачи `errors-as-instead-of-typecast`, абзац
|
||||||
|
|||||||
+82
-28
@@ -2,14 +2,20 @@
|
|||||||
|
|
||||||
## Периметр
|
## Периметр
|
||||||
|
|
||||||
**Сервис открыт наружу: HTTP-порт опубликован в интернет через обратный прокси, и
|
**Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через
|
||||||
аутентификации не делает ни прокси, ни само приложение.** Находки строятся против
|
обратный прокси, а приём записи, опрос готовности и файл записи требуют входа
|
||||||
этого — сегодняшнего — периметра.
|
через OIDC у Authelia.** Вход развёрнут задачей `oidc-login` 2026-08-12. Открыты
|
||||||
|
без входа только проба здоровья и метрики. Находки строятся против этого —
|
||||||
|
сегодняшнего — периметра.
|
||||||
|
|
||||||
Целевой периметр: те же порты наружу, но вход через OIDC у Authelia, отдельный
|
Целевой периметр добавляет к нему отдельный вход для программ по личным токенам
|
||||||
вход для программ по личным токенам, два уровня доступа — пользователь видит
|
и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и
|
||||||
свои записи, владелец сервиса ещё и страницу расхода. Он **не** развёрнут;
|
страницу расхода. **Разграничения по владельцу нет:** всякий вошедший видит все
|
||||||
описанное ниже разграничение доступа относится только к Telegram.
|
записи и все расшифровки, как видел их прежде аноним. Его заводит задача
|
||||||
|
`record-ownership`.
|
||||||
|
|
||||||
|
Разграничение доступа в Telegram осталось прежним — белым списком, и с учётной
|
||||||
|
записью приложения он не связан.
|
||||||
|
|
||||||
**Целевой периметр шире сегодняшнего не только входом.** Содержимое записи
|
**Целевой периметр шире сегодняшнего не только входом.** Содержимое записи
|
||||||
начинает уходить на три новые стороны — языковой модели, в канал уведомлений и
|
начинает уходить на три новые стороны — языковой модели, в канал уведомлений и
|
||||||
@@ -28,9 +34,18 @@
|
|||||||
администраторов. Задачи в беклоге у этого нет — работа принадлежит выкладке, а
|
администраторов. Задачи в беклоге у этого нет — работа принадлежит выкладке, а
|
||||||
она вне модели («Что вне модели», строка про контур).
|
она вне модели («Что вне модели», строка про контур).
|
||||||
|
|
||||||
Отсюда главное следствие, из которого читается всё остальное: **`POST /api/audio`
|
**Четвёртый сдвиг — секрет клиента поселился в базе.** Задача `oidc-login`
|
||||||
доступен кому угодно из интернета**. Отправитель не назван, не ограничен по числу
|
2026-08-12 кладёт адреса провайдера, идентификатор клиента и его секрет в
|
||||||
запросов и не ограничен по размеру файла.
|
настройки коллекции пользователей, приводя их к конфигу при каждом подъёме
|
||||||
|
(применённый шаг схемы не переписывается, и положенный им секрет не пережил бы
|
||||||
|
ротации). Инвариант проекта запрещает секрету попадать в git, в лог, в ответ и в
|
||||||
|
`error_text`; база в этом перечне не значится, и запрет не нарушен. Но место
|
||||||
|
новое: **чтение файла базы теперь равносильно чтению секрета клиента**.
|
||||||
|
|
||||||
|
Отсюда главное следствие, из которого читается всё остальное: **`POST
|
||||||
|
/api/audio` требует входа, а число запросов и размер файла по-прежнему ничем не
|
||||||
|
ограничены**. Вошедший не ограничен ни в том, ни в другом, и тратит наши деньги
|
||||||
|
на распознавание столько, сколько захочет.
|
||||||
|
|
||||||
## Недоверенный вход
|
## Недоверенный вход
|
||||||
|
|
||||||
@@ -38,8 +53,8 @@
|
|||||||
|
|
||||||
| Вход | Канал | Кто может слать |
|
| Вход | Канал | Кто может слать |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой из интернета |
|
| Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела |
|
||||||
| Идентификатор задачи | `GET /api/status/:id` | Любой из интернета |
|
| Идентификатор задачи | `GET /api/status/:id` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой задачи |
|
||||||
| Голосовое, аудио, документ | Telegram, длинный опрос | Любой пользователь Telegram; обрабатывается только из белого списка |
|
| Голосовое, аудио, документ | Telegram, длинный опрос | Любой пользователь Telegram; обрабатывается только из белого списка |
|
||||||
| Имя файла в Telegram | Поле `file_path` ответа Bot API | Telegram, а через него — отправитель |
|
| Имя файла в Telegram | Поле `file_path` ответа Bot API | Telegram, а через него — отправитель |
|
||||||
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель по любому из каналов |
|
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель по любому из каналов |
|
||||||
@@ -94,9 +109,12 @@ Telegram отправителю.
|
|||||||
каталогов, но это единственное, что стоит между входом и именем файла.
|
каталогов, но это единственное, что стоит между входом и именем файла.
|
||||||
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
|
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
|
||||||
расширением. Бакет один на все записи, префикса по пользователю нет.
|
расширением. Бакет один на все записи, префикса по пользователю нет.
|
||||||
- **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла не
|
- **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
|
||||||
помечено защищённым, поэтому ссылка сама по себе и есть право пройти по ней, а
|
помечено защищённым задачей `oidc-login` 2026-08-12: пройти по ссылке теперь
|
||||||
отзыва у неё нет. Отсюда запрет: **имя файла в хранилище в журнал не пишется**
|
можно только с коротким токеном файла, который выдаётся по сессии, и запрос
|
||||||
|
без него получает «не найдено». Сама ссылка отзыва по-прежнему не имеет —
|
||||||
|
токен сужает круг и живёт недолго, но выданное не отзывается. Отсюда запрет
|
||||||
|
остаётся: **имя файла в хранилище в журнал не пишется**
|
||||||
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку
|
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку
|
||||||
целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
|
целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
|
||||||
- **Идентификатор задачи** — 15 знаков, выдаёт хранилище. Он же единственное,
|
- **Идентификатор задачи** — 15 знаков, выдаёт хранилище. Он же единственное,
|
||||||
@@ -124,18 +142,44 @@ Telegram отправителю.
|
|||||||
автора сообщения (`update.Message.From.String()`, то есть `@username` либо имя
|
автора сообщения (`update.Message.From.String()`, то есть `@username` либо имя
|
||||||
с фамилией), а не с числовым идентификатором. Имя пользователя Telegram
|
с фамилией), а не с числовым идентификатором. Имя пользователя Telegram
|
||||||
меняется владельцем в любой момент: список привязан к изменяемому значению.
|
меняется владельцем в любой момент: список привязан к изменяемому значению.
|
||||||
- **HTTP API** — ничего. Ни ключа, ни сессии, ни ограничения по адресу.
|
- **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется
|
||||||
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты вместе с
|
кукой `transcriber_session`, живёт семь суток, обесценивается выходом.
|
||||||
остальным.
|
Продление сессии закрыто: с ним предъявитель менял бы своё значение на новое
|
||||||
|
бессрочно, и семисуточный срок — единственное, чем отзыв доступа у провайдера
|
||||||
|
доходит до сервиса, — не значил бы ничего.
|
||||||
|
Предъявленный заголовок `Authorization` принимается тоже — это та же сессия и
|
||||||
|
та же проверка, но она названа здесь отдельно, потому что это второй способ
|
||||||
|
предъявить ту же сессию.
|
||||||
|
- **Файл записи** — короткий токен файла, который узнанный отправитель берёт у
|
||||||
|
хранилища, предъявив сессию. Поле файла помечено защищённым, правило просмотра
|
||||||
|
коллекции пускает всякого вошедшего, и ссылка `/api/files/...` перестала быть
|
||||||
|
правом пройти по ней. Браузер с одной лишь кукой файла не получает: порядок
|
||||||
|
здесь «сессия → токен файла → ссылка».
|
||||||
|
- **Кто допущен** — **решает Authelia, а не сервис.** Своей проверки группы
|
||||||
|
приложение не делает: кого пускать, определяет правило провайдера на этого
|
||||||
|
клиента. Правило живёт **вне репозитория**, в настройках выкладки, и по коду
|
||||||
|
его не проверить. Клиент, настроенный слишком широко, открывает сервис
|
||||||
|
всякому, у кого есть учётная запись в общей Authelia. Решение владельца от
|
||||||
|
2026-08-12.
|
||||||
|
- **Заведение учётной записи** — только входом у провайдера. Собственное
|
||||||
|
создание записи, вход по паролю, одноразовый код и восстановление доступа
|
||||||
|
выключены шагом схемы: хранилище заводит коллекцию пользователей открытой, и
|
||||||
|
без этого закрытия вход обходился бы двумя запросами.
|
||||||
|
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты без сессии:
|
||||||
|
её нет ни у пробы, ни у сборщика. Наружу их закрывает правило обратного
|
||||||
|
прокси — работа выкладки, и сервис на неё не полагается: содержимого записей
|
||||||
|
эти адреса не несут.
|
||||||
|
|
||||||
Владения записью в модели данных нет: у задачи нет пользователя. Пока API
|
Владения записью в модели данных по-прежнему нет: у задачи нет пользователя.
|
||||||
анонимен, знание UUID задачи и есть право её читать.
|
Знание UUID задачи и есть право её читать — теперь для всякого вошедшего, а не
|
||||||
|
для всякого встречного.
|
||||||
|
|
||||||
Целевой периметр заводит четыре механизма вместо одного белого списка:
|
Целевой периметр заводит четыре механизма вместо одного белого списка; первый из
|
||||||
|
них уже стоит:
|
||||||
|
|
||||||
| Механизм | Что даёт | Чья задача |
|
| Механизм | Что даёт | Чья задача |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты | `oidc-login` |
|
| Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты — **сделано 2026-08-12** | `oidc-login` |
|
||||||
| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» | `record-ownership` |
|
| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» | `record-ownership` |
|
||||||
| Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` |
|
| Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` |
|
||||||
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
|
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
|
||||||
@@ -223,8 +267,16 @@ Telegram отправителю.
|
|||||||
приходит путь, выданный самим Telegram. Настоящее имя документа дальше проверки
|
приходит путь, выданный самим Telegram. Настоящее имя документа дальше проверки
|
||||||
типа файла не идёт.
|
типа файла не идёт.
|
||||||
|
|
||||||
Токен бота попадает в URL скачивания файла (`file.Link(token)`), и этот URL
|
Токен бота стоит в пути **каждого** обращения к Bot API (`bot<TOKEN>/getFile`,
|
||||||
нигде не логируется.
|
`…/sendMessage`, `…/getMe`, `…/getUpdates`) и в ссылке на скачивание
|
||||||
|
(`file.Link(token)`). Сами адреса нигде не логируются, но до 2026-08-13 их
|
||||||
|
уносил **отказ транспорта**: `*url.Error` встраивает адрес целиком, а отказы
|
||||||
|
скачивания и отправки пишутся в журнал. Теперь адрес снимается на границе
|
||||||
|
клиента — `internal/adapter/telegram`, `NewBot`: свой `Do` чистит отказ, а
|
||||||
|
подменённый логгер библиотеки вычищает токен из строк длинного опроса, которые
|
||||||
|
она печатает сама. Транспорт бота токена больше не получает вовсе: клиента ему
|
||||||
|
отдают готовым. Правило — [conventions/logging.md](conventions/logging.md),
|
||||||
|
случай — [review.md](review.md), оракул — `internal/adapter/telegram/bot_test.go`.
|
||||||
|
|
||||||
## Что вне модели
|
## Что вне модели
|
||||||
|
|
||||||
@@ -239,10 +291,12 @@ Telegram отправителю.
|
|||||||
- **Стойкость к целенаправленной нагрузке.** Ограничения по числу запросов и по
|
- **Стойкость к целенаправленной нагрузке.** Ограничения по числу запросов и по
|
||||||
размеру файла нет, и защищаться от исчерпания диска мы сейчас не пытаемся.
|
размеру файла нет, и защищаться от исчерпания диска мы сейчас не пытаемся.
|
||||||
- **Исчерпание диска приглашёнными.** Записи и тексты хранятся бессрочно
|
- **Исчерпание диска приглашёнными.** Записи и тексты хранятся бессрочно
|
||||||
(паспорт, 2026-08-11), шестичасовая запись весит единицы гигабайт — оценка, а не
|
(паспорт, 2026-08-11). Шестичасовая запись весит единицы гигабайт — оценка, а
|
||||||
замер: `research/` пуст, потолок длины стоит открытым вопросом
|
не замер: распределения длин у сервиса нет, а самая длинная проверенная запись
|
||||||
`architecture.md`, «Долгие записи», — а квот нет и не будет: решено считать расход и показывать его владельцу, а не отказывать
|
— 9,6 МБ ([research/pocketbase-defaults.md](research/pocketbase-defaults.md)).
|
||||||
(цель `usage-stats`). Перебравшего останавливает разговор или отзыв доступа в
|
Потолок длины стоит открытым вопросом `architecture.md`, «Долгие записи». Квот
|
||||||
|
нет и не будет: решено считать расход и показывать его владельцу, а не
|
||||||
|
отказывать (цель `usage-stats`). Перебравшего останавливает разговор или отзыв доступа в
|
||||||
Authelia. Рост каталога данных при этом ничем не наблюдается —
|
Authelia. Рост каталога данных при этом ничем не наблюдается —
|
||||||
открытый вопрос `architecture.md`.
|
открытый вопрос `architecture.md`.
|
||||||
- **Перерасход денег на внешних сервисах.** Распознавание и языковая модель
|
- **Перерасход денег на внешних сервисах.** Распознавание и языковая модель
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
module git.vakhrushev.me/av/transcriber
|
module git.vakhrushev.me/av/transcriber
|
||||||
|
|
||||||
go 1.25.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.5.0
|
github.com/BurntSushi/toml v1.5.0
|
||||||
github.com/aws/aws-sdk-go-v2 v1.37.2
|
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.30.3
|
github.com/aws/aws-sdk-go-v2/config v1.30.3
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.3
|
github.com/aws/aws-sdk-go-v2/credentials v1.18.3
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.86.0
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3
|
||||||
|
github.com/aws/smithy-go v1.27.7
|
||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
@@ -17,25 +18,24 @@ require (
|
|||||||
github.com/prometheus/client_golang v1.23.0
|
github.com/prometheus/client_golang v1.23.0
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
github.com/yandex-cloud/go-genproto v0.17.0
|
github.com/yandex-cloud/go-genproto v0.17.0
|
||||||
google.golang.org/grpc v1.74.2
|
google.golang.org/grpc v1.82.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.2 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.2 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.2 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.2 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.27.0 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sso v1.27.0 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 // indirect
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 // indirect
|
||||||
github.com/aws/smithy-go v1.27.7 // indirect
|
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
@@ -70,9 +70,9 @@ require (
|
|||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.40.0 // indirect
|
golang.org/x/text v0.40.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||||
google.golang.org/protobuf v1.36.7 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
modernc.org/libc v1.74.1 // indirect
|
modernc.org/libc v1.74.1 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2
|
|||||||
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.37.2 h1:xkW1iMYawzcmYFYEV0UCMxc8gSsjCGEhBXQkdQywVbo=
|
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.37.2/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg=
|
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 h1:6GMWV6CNpA/6fbFHnoAjrv4+LGfyTqZz2LtCHnspgDg=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0/go.mod h1:/mXlTIVG9jbxkqDnr5UQNQxW1HRYxeGklkM9vAFeabg=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.30.3 h1:utupeVnE3bmB221W08P0Moz1lDI3OwYa2fBtUhl7TCc=
|
github.com/aws/aws-sdk-go-v2/config v1.30.3 h1:utupeVnE3bmB221W08P0Moz1lDI3OwYa2fBtUhl7TCc=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.30.3/go.mod h1:NDGwOEBdpyZwLPlQkpKIO7frf18BW8PaCmAM9iUxQmI=
|
github.com/aws/aws-sdk-go-v2/config v1.30.3/go.mod h1:NDGwOEBdpyZwLPlQkpKIO7frf18BW8PaCmAM9iUxQmI=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.3 h1:ptfyXmv+ooxzFwyuBth0yqABcjVIkjDL0iTYZBSbum8=
|
github.com/aws/aws-sdk-go-v2/credentials v1.18.3 h1:ptfyXmv+ooxzFwyuBth0yqABcjVIkjDL0iTYZBSbum8=
|
||||||
@@ -17,32 +17,30 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2 h1:nRniHAvjFJGUCl04F3WaAj7
|
|||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2/go.mod h1:eJDFKAMHHUvv4a0Zfa7bQb//wFNUXGrbFpYRCHe2kD0=
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2/go.mod h1:eJDFKAMHHUvv4a0Zfa7bQb//wFNUXGrbFpYRCHe2kD0=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3 h1:Nb2pUE30lySKPGdkiIJ1SZgHsjiebOiRNI7R9NA1WtM=
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3 h1:Nb2pUE30lySKPGdkiIJ1SZgHsjiebOiRNI7R9NA1WtM=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3/go.mod h1:BO5EKulvhBF1NXwui8lfnuDPBQQU5807yvWASZ/5n6k=
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3/go.mod h1:BO5EKulvhBF1NXwui8lfnuDPBQQU5807yvWASZ/5n6k=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 h1:sPiRHLVUIIQcoVZTNwqQcdtjkqkPopyYmIX0M5ElRf4=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2/go.mod h1:ik86P3sgV+Bk7c1tBFCwI3VxMoSEwl4YkRB9xn1s340=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 h1:ZdzDAg075H6stMZtbD2o+PyB933M/f20e9WmCBC17wA=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2/go.mod h1:eE1IIzXG9sdZCB0pNNpMpsYTLl4YdOQD3njiVN1e/E4=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.2 h1:sBpc8Ph6CpfZsEdkz/8bfg8WhKlWMCms5iWj6W/AW2U=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.2/go.mod h1:Z2lDojZB+92Wo6EKiZZmJid9pPrDJW2NNIXSlaEfVlU=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 h1:6+lZi2JeGKtCraAj1rpoZfKqnQ9SptseRZioejfUOLM=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0/go.mod h1:eb3gfbVIxIoGgJsi9pGne19dhCBpK6opTYpQqAmdy44=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.2 h1:blV3dY6WbxIVOFggfYIo2E1Q2lZoy5imS7nKgu5m6Tc=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.2/go.mod h1:cBWNeLBjHJRSmXAxdS7mwiMUEgx6zup4wQ9J+/PcsRQ=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.2 h1:oxmDEO14NBZJbK/M8y3brhMFEIGN4j8a6Aq8eY0sqlo=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.2/go.mod h1:4hH+8QCrk1uRWDPsVfsNDUup3taAjO8Dnx63au7smAU=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.2 h1:0hBNFAPwecERLzkhhBY+lQKUMpXSKVv4Sxovikrioms=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.2/go.mod h1:Vcnh4KyR4imrrjGN7A2kP2v9y6EPudqoPKXtnmBliPU=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.86.0 h1:utPhv4ECQzJIUbtx7vMN4A8uZxlQ5tSt1H1toPI41h8=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.86.0/go.mod h1:1/eZYtTWazDgVl96LmGdGktHFi7prAcGCrJ9JGvBITU=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.27.0 h1:j7/jTOjWeJDolPwZ/J4yZ7dUsxsWZEsxNwH5O7F8eEA=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.27.0 h1:j7/jTOjWeJDolPwZ/J4yZ7dUsxsWZEsxNwH5O7F8eEA=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.27.0/go.mod h1:M0xdEPQtgpNT7kdAX4/vOAPkFj60hSQRb7TvW9B0iug=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.27.0/go.mod h1:M0xdEPQtgpNT7kdAX4/vOAPkFj60hSQRb7TvW9B0iug=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 h1:ywQF2N4VjqX+Psw+jLjMmUL2g1RDHlvri3NxHA08MGI=
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 h1:ywQF2N4VjqX+Psw+jLjMmUL2g1RDHlvri3NxHA08MGI=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0/go.mod h1:Z+qv5Q6b7sWiclvbJyPSOT1BRVU9wfSUPaqQzZ1Xg3E=
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0/go.mod h1:Z+qv5Q6b7sWiclvbJyPSOT1BRVU9wfSUPaqQzZ1Xg3E=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 h1:bRP/a9llXSSgDPk7Rqn5GD/DQCGo6uk95plBFKoXt2M=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 h1:bRP/a9llXSSgDPk7Rqn5GD/DQCGo6uk95plBFKoXt2M=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0/go.mod h1:tgBsFzxwl65BWkuJ/x2EUs59bD4SfYKgikvFDJi1S58=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0/go.mod h1:tgBsFzxwl65BWkuJ/x2EUs59bD4SfYKgikvFDJi1S58=
|
||||||
github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw=
|
|
||||||
github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
|
|
||||||
github.com/aws/smithy-go v1.27.7 h1:Zgj5z4LfcDYoQIVk+n/yGdTkP/2y6ZT5vYxe0fp7bqE=
|
github.com/aws/smithy-go v1.27.7 h1:Zgj5z4LfcDYoQIVk+n/yGdTkP/2y6ZT5vYxe0fp7bqE=
|
||||||
github.com/aws/smithy-go v1.27.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
github.com/aws/smithy-go v1.27.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
@@ -145,18 +143,18 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
|||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/yandex-cloud/go-genproto v0.17.0 h1:uQ5Lr8B/xIyY1KrOm7pItYY3YT/DL1O8gVaY03ouYKM=
|
github.com/yandex-cloud/go-genproto v0.17.0 h1:uQ5Lr8B/xIyY1KrOm7pItYY3YT/DL1O8gVaY03ouYKM=
|
||||||
github.com/yandex-cloud/go-genproto v0.17.0/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo=
|
github.com/yandex-cloud/go-genproto v0.17.0/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
|
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||||
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
|
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||||
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
|
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||||
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
|
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||||
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
|
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||||
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
|
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
|
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||||
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
|
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||||
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
|
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
@@ -185,15 +183,17 @@ golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
|||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
|
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||||
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
|
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||||
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ffmpeg
|
package ffmpeg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -15,7 +16,7 @@ func NewFfmpegConverter() *FfmpegConverter {
|
|||||||
return &FfmpegConverter{}
|
return &FfmpegConverter{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *FfmpegConverter) Convert(src, dest string) error {
|
func (c *FfmpegConverter) Convert(ctx context.Context, src, dest string) error {
|
||||||
// Проверяем существование исходного файла
|
// Проверяем существование исходного файла
|
||||||
if _, err := os.Stat(src); os.IsNotExist(err) {
|
if _, err := os.Stat(src); os.IsNotExist(err) {
|
||||||
return fmt.Errorf("input file does not exist: %s", src)
|
return fmt.Errorf("input file does not exist: %s", src)
|
||||||
@@ -26,8 +27,9 @@ func (c *FfmpegConverter) Convert(src, dest string) error {
|
|||||||
return fmt.Errorf("ffmpeg not found in PATH: %w", err)
|
return fmt.Errorf("ffmpeg not found in PATH: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаем команду ffmpeg для конвертации в OGG
|
// Команда заводится с контекстом: отменённый контекст убивает процесс, а не
|
||||||
cmd := exec.Command(ffmpegExecutable,
|
// оставляет его дожёвывать чужую запись после остановки воркера.
|
||||||
|
cmd := exec.CommandContext(ctx, ffmpegExecutable,
|
||||||
"-i", src, // входной файл
|
"-i", src, // входной файл
|
||||||
"-c:a", "libvorbis", // кодек Vorbis для OGG
|
"-c:a", "libvorbis", // кодек Vorbis для OGG
|
||||||
"-q:a", "4", // качество аудио (0-10, где 4 - хорошее качество)
|
"-q:a", "4", // качество аудио (0-10, где 4 - хорошее качество)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ffmpeg
|
package ffmpeg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -26,7 +27,7 @@ func NewFfmpegMetaViewer() *FfmpegMetaViewer {
|
|||||||
return &FfmpegMetaViewer{}
|
return &FfmpegMetaViewer{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *FfmpegMetaViewer) GetInfo(src string) (*contract.AudioInfo, error) {
|
func (m *FfmpegMetaViewer) GetInfo(ctx context.Context, src string) (*contract.AudioInfo, error) {
|
||||||
// Проверяем существование исходного файла
|
// Проверяем существование исходного файла
|
||||||
if _, err := os.Stat(src); os.IsNotExist(err) {
|
if _, err := os.Stat(src); os.IsNotExist(err) {
|
||||||
return nil, fmt.Errorf("input file does not exist: %s", src)
|
return nil, fmt.Errorf("input file does not exist: %s", src)
|
||||||
@@ -37,8 +38,9 @@ func (m *FfmpegMetaViewer) GetInfo(src string) (*contract.AudioInfo, error) {
|
|||||||
return nil, fmt.Errorf("ffprobe not found in PATH: %w", err)
|
return nil, fmt.Errorf("ffprobe not found in PATH: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаем команду ffprobe для получения метаданных
|
// Команда заводится с контекстом: отправитель, закрывший соединение, не
|
||||||
cmd := exec.Command(ffprobeExecutable,
|
// оставляет за собой чтение метаданных чужого файла.
|
||||||
|
cmd := exec.CommandContext(ctx, ffprobeExecutable,
|
||||||
"-v", "quiet", // тихий режим (без лишнего вывода)
|
"-v", "quiet", // тихий режим (без лишнего вывода)
|
||||||
"-print_format", "json", // вывод в формате JSON
|
"-print_format", "json", // вывод в формате JSON
|
||||||
"-show_format", // показать информацию о формате
|
"-show_format", // показать информацию о формате
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package recognizer
|
package recognizer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
@@ -9,14 +10,14 @@ import (
|
|||||||
|
|
||||||
type MemoryAudioRecognizer struct{}
|
type MemoryAudioRecognizer struct{}
|
||||||
|
|
||||||
func (r *MemoryAudioRecognizer) Recognize(file io.Reader, fileName string) (operationID string, err error) {
|
func (r *MemoryAudioRecognizer) Recognize(ctx context.Context, file io.Reader, fileName string) (operationID string, err error) {
|
||||||
return uuid.NewString(), nil
|
return uuid.NewString(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *MemoryAudioRecognizer) GetRecognitionText(operationID string) (string, error) {
|
func (r *MemoryAudioRecognizer) GetRecognitionText(ctx context.Context, operationID string) (string, error) {
|
||||||
return "Foo bar, Baz.", nil
|
return "Foo bar, Baz.", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *MemoryAudioRecognizer) CheckRecognitionStatus(operationID string) (*entity.RecognitionResult, error) {
|
func (r *MemoryAudioRecognizer) CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
|
||||||
return entity.NewCompletedResult(), nil
|
return entity.NewCompletedResult(), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package yandex
|
package yandex
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
)
|
)
|
||||||
@@ -54,16 +56,31 @@ func (s *YandexAudioRecognizerService) Close() error {
|
|||||||
return s.sttService.Close()
|
return s.sttService.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *YandexAudioRecognizerService) Recognize(file io.Reader, fileName string) (string, error) {
|
// startRecognitionTimeout — сколько ждём принятия операции, когда нас уже
|
||||||
|
// остановили. Число меньше жёсткого предела остановки: иначе процесс убьют
|
||||||
|
// прежде, чем ответ дойдёт, и защита ничего не даст.
|
||||||
|
const startRecognitionTimeout = 10 * time.Second
|
||||||
|
|
||||||
err := s.s3Sevice.uploadFile(file, fileName)
|
func (s *YandexAudioRecognizerService) Recognize(ctx context.Context, file io.Reader, fileName string) (string, error) {
|
||||||
|
|
||||||
|
// Заливка отменяется штатно: она дорога по времени, а повтор её бесплатен —
|
||||||
|
// объект ложится под тем же ключом.
|
||||||
|
err := s.s3Sevice.uploadFile(ctx, file, fileName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
uri := s.s3Sevice.fileUrl(fileName)
|
uri := s.s3Sevice.fileUrl(fileName)
|
||||||
|
|
||||||
opId, err := s.sttService.recognizeFileFromS3(uri)
|
// А вот принятие операции от отмены защищено. Окно короткое и дорогое:
|
||||||
|
// SpeechKit может операцию принять и начать считать деньги, а ответ до нас
|
||||||
|
// не доедет — идентификатор потеряется навсегда, и повтор оплатит ту же
|
||||||
|
// запись второй раз. Свой предел вызову оставлен, чтобы остановка не ждала
|
||||||
|
// вечно.
|
||||||
|
startCtx, cancel := protectFromCancel(ctx, startRecognitionTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
opId, err := s.sttService.recognizeFileFromS3(startCtx, uri)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -71,12 +88,19 @@ func (s *YandexAudioRecognizerService) Recognize(file io.Reader, fileName string
|
|||||||
return opId, nil
|
return opId, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *YandexAudioRecognizerService) GetRecognitionText(operationID string) (string, error) {
|
// protectFromCancel отвязывает вызов от отмены родителя, оставляя ему значения
|
||||||
return s.sttService.getRecognitionText(operationID)
|
// родителя и собственный предел по времени. Употребляется там, где обрыв стоит
|
||||||
|
// дороже ожидания: у платной операции, чей результат нельзя переспросить.
|
||||||
|
func protectFromCancel(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||||
|
return context.WithTimeout(context.WithoutCancel(ctx), timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *YandexAudioRecognizerService) CheckRecognitionStatus(operationID string) (*entity.RecognitionResult, error) {
|
func (s *YandexAudioRecognizerService) GetRecognitionText(ctx context.Context, operationID string) (string, error) {
|
||||||
operation, err := s.sttService.checkOperationStatus(operationID)
|
return s.sttService.getRecognitionText(ctx, operationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *YandexAudioRecognizerService) CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
|
||||||
|
operation, err := s.sttService.checkOperationStatus(ctx, operationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package yandex
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Принятие операции распознавания защищено от отмены: остановка сервиса не
|
||||||
|
// должна обрывать вызов, который уже мог начать стоить денег и чей результат
|
||||||
|
// нельзя переспросить. Проверяется само средство защиты — проводка к нему
|
||||||
|
// оракула не имеет: клиент SpeechKit подставить нечем, а прогон на реальных
|
||||||
|
// ключах запрещён (CLAUDE.md, «Запреты»).
|
||||||
|
func TestProtectedContextSurvivesParentCancel(t *testing.T) {
|
||||||
|
parent, cancel := context.WithCancel(t.Context())
|
||||||
|
|
||||||
|
protected, release := protectFromCancel(parent, time.Minute)
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
require.Error(t, parent.Err(), "родитель отменён — иначе проверка судит не то")
|
||||||
|
assert.NoError(t, protected.Err(), "защищённый вызов пережил отмену родителя")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Защита не бессрочна: у вызова свой предел, иначе остановка ждала бы вечно.
|
||||||
|
func TestProtectedContextKeepsItsOwnDeadline(t *testing.T) {
|
||||||
|
protected, release := protectFromCancel(t.Context(), time.Minute)
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
deadline, ok := protected.Deadline()
|
||||||
|
|
||||||
|
require.True(t, ok, "у защищённого вызова обязан быть свой предел")
|
||||||
|
assert.WithinDuration(t, time.Now().Add(time.Minute), deadline, 5*time.Second)
|
||||||
|
}
|
||||||
@@ -67,8 +67,8 @@ func newYandexS3Service(cfg s3Config) (*yandexS3Service, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *yandexS3Service) uploadFile(file io.Reader, fileName string) error {
|
func (s *yandexS3Service) uploadFile(ctx context.Context, file io.Reader, fileName string) error {
|
||||||
_, err := s.uploader.Upload(context.Background(), &s3.PutObjectInput{
|
_, err := s.uploader.Upload(ctx, &s3.PutObjectInput{
|
||||||
Bucket: aws.String(s.bucketName),
|
Bucket: aws.String(s.bucketName),
|
||||||
Key: aws.String(fileName),
|
Key: aws.String(fileName),
|
||||||
Body: file,
|
Body: file,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
@@ -93,9 +94,7 @@ func (s *speechKitService) Close() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// recognizeFileFromS3 запускает асинхронное распознавание файла из S3
|
// recognizeFileFromS3 запускает асинхронное распознавание файла из S3
|
||||||
func (s *speechKitService) recognizeFileFromS3(s3URI string) (string, error) {
|
func (s *speechKitService) recognizeFileFromS3(ctx context.Context, s3URI string) (string, error) {
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Добавляем авторизацию и folder_id в контекст
|
// Добавляем авторизацию и folder_id в контекст
|
||||||
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
|
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
|
||||||
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
|
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
|
||||||
@@ -136,9 +135,7 @@ func (s *speechKitService) recognizeFileFromS3(s3URI string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetRecognitionResult получает результат распознавания по ID операции
|
// GetRecognitionResult получает результат распознавания по ID операции
|
||||||
func (s *speechKitService) getRecognitionText(operationID string) (string, error) {
|
func (s *speechKitService) getRecognitionText(ctx context.Context, operationID string) (string, error) {
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Добавляем авторизацию и folder_id в контекст
|
// Добавляем авторизацию и folder_id в контекст
|
||||||
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
|
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
|
||||||
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
|
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
|
||||||
@@ -157,7 +154,10 @@ func (s *speechKitService) getRecognitionText(operationID string) (string, error
|
|||||||
for {
|
for {
|
||||||
resp, err := stream.Recv()
|
resp, err := stream.Recv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "EOF" {
|
// Конец потока библиотека отдаёт ровно `io.EOF`. Прежде он узнавался
|
||||||
|
// сравнением текста сообщения: так же выглядел бы и настоящий отказ
|
||||||
|
// с текстом «EOF», и распознавание молча вернуло бы половину текста.
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("failed to receive recognition response: %w", err)
|
return "", fmt.Errorf("failed to receive recognition response: %w", err)
|
||||||
@@ -176,9 +176,7 @@ func (s *speechKitService) getRecognitionText(operationID string) (string, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkOperationStatus проверяет статус операции распознавания
|
// checkOperationStatus проверяет статус операции распознавания
|
||||||
func (s *speechKitService) checkOperationStatus(operationID string) (*operation.Operation, error) {
|
func (s *speechKitService) checkOperationStatus(ctx context.Context, operationID string) (*operation.Operation, error) {
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
|
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
|
||||||
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
|
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,15 @@ import (
|
|||||||
|
|
||||||
pb "github.com/pocketbase/pocketbase"
|
pb "github.com/pocketbase/pocketbase"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
)
|
|
||||||
|
|
||||||
// Имена коллекций. Они же — часть пути к файлу в раскладке хранилища и часть
|
// Шаги схемы регистрируются загрузкой своего пакета, а накатывает их
|
||||||
// адреса ссылки на него, поэтому меняются только новым шагом схемы.
|
// `RunAllMigrations` ниже. Импорт здесь пустой и явный, хотя соседние файлы
|
||||||
const (
|
// пакета и так берут оттуда имена коллекций: день, когда имена перестанут
|
||||||
FilesCollection = "files"
|
// читаться отсюда, унёс бы вместе с последней ссылкой и регистрацию — список
|
||||||
JobsCollection = "transcribe_jobs"
|
// шагов остался бы пустым, `RunAllMigrations` вернул бы `nil`, и приложение
|
||||||
|
// поднялось бы здоровым, но без коллекций. Отказ вылез бы не на старте, а на
|
||||||
|
// первом приёме записи.
|
||||||
|
_ "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||||
)
|
)
|
||||||
|
|
||||||
// New создаёт приложение хранилища на заданном каталоге данных и приводит его в
|
// New создаёт приложение хранилища на заданном каталоге данных и приводит его в
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||||
)
|
)
|
||||||
|
|
||||||
// workFile — рабочая копия файла на диске. Живёт во временном каталоге
|
// workFile — рабочая копия файла на диске. Живёт во временном каталоге
|
||||||
@@ -82,7 +84,7 @@ func (repo *FileRepository) Stage(ext string, content io.Reader) (contract.WorkF
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
|
func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
|
||||||
record, err := repo.app.FindRecordById(FilesCollection, fileID)
|
record, err := repo.app.FindRecordById(migrations.FilesCollection, fileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
|
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
|
||||||
}
|
}
|
||||||
@@ -115,7 +117,7 @@ func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
|
|||||||
// хранилище не попадает — путь к файлу читается в журнале, и инвариант
|
// хранилище не попадает — путь к файлу читается в журнале, и инвариант
|
||||||
// приватности этого не допускает. Свой суффикс хранилище допишет само.
|
// приватности этого не допускает. Свой суффикс хранилище допишет само.
|
||||||
func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile) (*entity.File, error) {
|
func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile) (*entity.File, error) {
|
||||||
collection, err := findCollection(repo.app, FilesCollection)
|
collection, err := findCollection(repo.app, migrations.FilesCollection)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -142,7 +144,7 @@ func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile) (*e
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (repo *FileRepository) CreateRemote(objectKey string, size int64) (*entity.File, error) {
|
func (repo *FileRepository) CreateRemote(objectKey string, size int64) (*entity.File, error) {
|
||||||
collection, err := findCollection(repo.app, FilesCollection)
|
collection, err := findCollection(repo.app, migrations.FilesCollection)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -160,7 +162,7 @@ func (repo *FileRepository) CreateRemote(objectKey string, size int64) (*entity.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
|
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
|
||||||
record, err := repo.app.FindRecordById(FilesCollection, id)
|
record, err := repo.app.FindRecordById(migrations.FilesCollection, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get file: %w", err)
|
return nil, fmt.Errorf("failed to get file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -168,7 +170,7 @@ func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (repo *FileRepository) Open(fileID string) (io.ReadCloser, error) {
|
func (repo *FileRepository) Open(fileID string) (io.ReadCloser, error) {
|
||||||
record, err := repo.app.FindRecordById(FilesCollection, fileID)
|
record, err := repo.app.FindRecordById(migrations.FilesCollection, fileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
|
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-14
@@ -1,22 +1,11 @@
|
|||||||
package pocketbase
|
package migrations
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/migrations"
|
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Схема заводится версионированными шагами, и применённый шаг не переписывается
|
|
||||||
// — только новым шагом. Инвариант проекта перенесён дословно: хранилище считает
|
|
||||||
// применённое по имени файла шага.
|
|
||||||
//
|
|
||||||
// Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его
|
|
||||||
// `apis.Serve` прежде, чем поднять сервер.
|
|
||||||
func init() {
|
|
||||||
migrations.Register(up202608110001, down202608110001, "202608110001_init.go")
|
|
||||||
}
|
|
||||||
|
|
||||||
func up202608110001(app core.App) error {
|
func up202608110001(app core.App) error {
|
||||||
files := core.NewBaseCollection(FilesCollection)
|
files := core.NewBaseCollection(FilesCollection)
|
||||||
files.Fields.Add(
|
files.Fields.Add(
|
||||||
@@ -118,5 +107,3 @@ func down202608110001(app core.App) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ptr[T any](v T) *T { return &v }
|
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultAuthTokenDuration — умолчание библиотеки, к которому возвращает откат.
|
||||||
|
const defaultAuthTokenDuration = 1209600
|
||||||
|
|
||||||
|
// up202608120001 закрывает поверхность, которую хранилище приносит своим
|
||||||
|
// системным шагом, и защищает файл записи.
|
||||||
|
//
|
||||||
|
// Коллекция пользователей заводится библиотекой с открытым созданием записи и
|
||||||
|
// включённым входом по паролю. Без этого шага закрытие API обходится двумя
|
||||||
|
// запросами: завести себе учётную запись, войти паролем, предъявить полученное
|
||||||
|
// заголовком. Отдельная цена открытого создания — захват учётной записи: обмен
|
||||||
|
// кода ищет запись сперва по неизменяемому признаку провайдера, а не найдя —
|
||||||
|
// по адресу почты, и запись, заведённая посторонним на чужой адрес, достаётся
|
||||||
|
// первому же настоящему входу с этим адресом.
|
||||||
|
func up202608120001(app core.App) error {
|
||||||
|
users, err := app.FindCollectionByNameOrId("users")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to find users collection: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Завести учётную запись можно только входом у провайдера.
|
||||||
|
//
|
||||||
|
// Правило именно такое, а не `nil`: запись при первом входе заводит
|
||||||
|
// внутренний запрос самого обмена, и он идёт без прав суперпользователя —
|
||||||
|
// глухое `nil` отвергло бы его наравне с посторонним, и войти не смог бы
|
||||||
|
// никто. Контекст `oauth2` ставит обмен (`core.RequestInfoContextOAuth2`),
|
||||||
|
// а посторонний запрос приходит с контекстом по умолчанию.
|
||||||
|
//
|
||||||
|
// Открывать правило пустой строкой нельзя: публичный обмен принимает поля
|
||||||
|
// создаваемой записи от вызывающего, и всякий владелец учётной записи у
|
||||||
|
// провайдера задал бы их сам.
|
||||||
|
users.CreateRule = ptr(`@request.context = "oauth2"`)
|
||||||
|
users.PasswordAuth.Enabled = false
|
||||||
|
users.OTP.Enabled = false
|
||||||
|
|
||||||
|
// Провайдер включается здесь с пустыми значениями: адреса, идентификатор
|
||||||
|
// клиента и секрет приходят из конфига при каждом подъёме. Положенный сюда
|
||||||
|
// секрет не пережил бы ротации — применённый шаг не переписывается.
|
||||||
|
users.OAuth2.Enabled = true
|
||||||
|
|
||||||
|
if err := app.Save(users); err != nil {
|
||||||
|
return fmt.Errorf("failed to close users collection surface: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := app.FindCollectionByNameOrId(FilesCollection)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to find files collection: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ссылка на файл перестаёт быть правом пройти по ней: до этого шага знание
|
||||||
|
// ссылки и было доступом, а отзыва у неё нет. Конвейер этим не затронут —
|
||||||
|
// он читает файл из файловой системы хранилища, а не по ссылке.
|
||||||
|
//
|
||||||
|
// Комментарий прежнего шага утверждает обратное — «защищённым поле не
|
||||||
|
// помечено намеренно». Прежний шаг не переписывается, поэтому решение
|
||||||
|
// отменяется здесь: право прочитать запись больше не даёт знание её
|
||||||
|
// идентификатора.
|
||||||
|
field, ok := files.Fields.GetByName("file").(*core.FileField)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("files collection has no file field")
|
||||||
|
}
|
||||||
|
field.Protected = true
|
||||||
|
|
||||||
|
// Одной пометки мало: защищённый файл судится ещё и правилом просмотра
|
||||||
|
// коллекции, а незаданное правило означает «только владелец панели» — файл
|
||||||
|
// не получил бы и вошедший. Правило пускает всякого узнанного: владельца у
|
||||||
|
// записи ещё нет, и сужать выборку эта задача не должна.
|
||||||
|
files.ViewRule = ptr(`@request.auth.id != ""`)
|
||||||
|
|
||||||
|
if err := app.Save(files); err != nil {
|
||||||
|
return fmt.Errorf("failed to protect record file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// down202608120001 возвращает умолчания библиотеки — те, что стояли до шага.
|
||||||
|
//
|
||||||
|
// Открытое создание записи сюда не возвращается намеренно: это ровно то, что
|
||||||
|
// шаг и закрывал, и откат, восстанавливающий анонимную регистрацию, оставил бы
|
||||||
|
// сервис хуже, чем он был до задачи. Срок жизни сессии возвращается
|
||||||
|
// умолчанием, а не нулём: нулевую длительность валидация коллекции отвергает, и
|
||||||
|
// прежний откат падал на ней, не дойдя до снятия защиты с файла.
|
||||||
|
func down202608120001(app core.App) error {
|
||||||
|
users, err := app.FindCollectionByNameOrId("users")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to find users collection: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
users.CreateRule = nil
|
||||||
|
users.PasswordAuth.Enabled = true
|
||||||
|
users.OTP.Enabled = true
|
||||||
|
users.OAuth2.Enabled = false
|
||||||
|
users.OAuth2.Providers = nil
|
||||||
|
users.AuthToken.Duration = defaultAuthTokenDuration
|
||||||
|
|
||||||
|
if err := app.Save(users); err != nil {
|
||||||
|
return fmt.Errorf("failed to restore users collection: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := app.FindCollectionByNameOrId(FilesCollection)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to find files collection: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if field, ok := files.Fields.GetByName("file").(*core.FileField); ok {
|
||||||
|
field.Protected = false
|
||||||
|
}
|
||||||
|
files.ViewRule = nil
|
||||||
|
|
||||||
|
if err := app.Save(files); err != nil {
|
||||||
|
return fmt.Errorf("failed to unprotect record file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Package migrations — шаги схемы хранилища и имена коллекций, которые они
|
||||||
|
// заводят.
|
||||||
|
//
|
||||||
|
// Схема заводится версионированными шагами, и применённый шаг не переписывается
|
||||||
|
// — только новым шагом. Инвариант проекта перенесён дословно: хранилище считает
|
||||||
|
// применённое по **имени шага**, а не по пути файла, поэтому имена в
|
||||||
|
// `Register` ниже не переносятся и не переименовываются, даже если файл переехал.
|
||||||
|
//
|
||||||
|
// Шаги лежат своим каталогом, а не файлом внутри пакета репозитория, и причина
|
||||||
|
// внешняя: сверка документов ловит изменённый шаг схемы при нетронутом
|
||||||
|
// `docs/database.md` по префиксу пути (`docs/.docs.json`, ключ `migrations`), а
|
||||||
|
// префикс наводится только на каталог. Пока шаги лежали файлом, наводить его
|
||||||
|
// было не на что, и проверка молчала на всякой правке схемы.
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
pbmigrations "github.com/pocketbase/pocketbase/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Имена коллекций живут здесь, рядом с шагом, который их заводит. Они же — часть
|
||||||
|
// пути к файлу в раскладке хранилища и часть адреса ссылки на него, поэтому
|
||||||
|
// меняются только новым шагом схемы.
|
||||||
|
const (
|
||||||
|
FilesCollection = "files"
|
||||||
|
JobsCollection = "transcribe_jobs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его
|
||||||
|
// `apis.Serve` прежде, чем поднять сервер.
|
||||||
|
func init() {
|
||||||
|
pbmigrations.Register(up202608110001, down202608110001, "202608110001_init.go")
|
||||||
|
pbmigrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptr[T any](v T) *T { return &v }
|
||||||
@@ -2,6 +2,8 @@ package pocketbase
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BindPanelRules подчиняет правку задачи в панели тем же правилам перехода, что
|
// BindPanelRules подчиняет правку задачи в панели тем же правилам перехода, что
|
||||||
@@ -20,7 +22,7 @@ import (
|
|||||||
// стиралась бы тем же сохранением, а число попыток мёртвой задачи — которое
|
// стиралась бы тем же сохранением, а число попыток мёртвой задачи — которое
|
||||||
// переход хранит намеренно — приходило бы владельцу нулём.
|
// переход хранит намеренно — приходило бы владельцу нулём.
|
||||||
func BindPanelRules(app core.App) {
|
func BindPanelRules(app core.App) {
|
||||||
app.OnRecordUpdateRequest(JobsCollection).BindFunc(func(e *core.RecordRequestEvent) error {
|
app.OnRecordUpdateRequest(migrations.JobsCollection).BindFunc(func(e *core.RecordRequestEvent) error {
|
||||||
original := e.Record.Original()
|
original := e.Record.Original()
|
||||||
if original == nil || original.GetString("state") == e.Record.GetString("state") {
|
if original == nil || original.GetString("state") == e.Record.GetString("state") {
|
||||||
return e.Next()
|
return e.Next()
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package pocketbase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProviderName — имя провайдера у коллекции пользователей. Библиотека знает его
|
||||||
|
// как обобщённый OIDC и по нему же ищет настройку при обмене кода.
|
||||||
|
const ProviderName = "oidc"
|
||||||
|
|
||||||
|
// SessionDuration — сколько живёт сессия вошедшего, семь суток. Число выбрано
|
||||||
|
// решением владельца от 2026-08-12; умолчание библиотеки в пять суток не
|
||||||
|
// применяется, потому что оно никем не выбрано.
|
||||||
|
//
|
||||||
|
// Применяется оно не шагом схемы, а при каждом подъёме — вместе с настройками
|
||||||
|
// провайдера, и потому живёт здесь, а не в каталоге шагов. Причина та же:
|
||||||
|
// применённый шаг не переписывается, и число, положенное туда, разошлось бы со
|
||||||
|
// сроком жизни куки при первой же правке — браузер получил бы новый срок, а
|
||||||
|
// хранилище продолжило выдавать прежний.
|
||||||
|
const SessionDuration = 7 * 24 * 60 * 60
|
||||||
|
|
||||||
|
// ProviderSettings — то, что приезжает из конфига и приводится к настройкам
|
||||||
|
// коллекции.
|
||||||
|
type ProviderSettings struct {
|
||||||
|
AuthURL string
|
||||||
|
TokenURL string
|
||||||
|
UserInfoURL string
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyProviderSettings приводит настройки провайдера у коллекции пользователей
|
||||||
|
// к значениям конфига.
|
||||||
|
//
|
||||||
|
// Делается это при каждом подъёме, а не однажды шагом схемы, и причина в
|
||||||
|
// инварианте: применённый шаг не переписывается. Секрет, положенный шагом, не
|
||||||
|
// пережил бы ротации — смена значения в конфиге до хранилища не доехала бы
|
||||||
|
// вовсе, и вход сломался бы после смены ключа, а починить это можно было бы
|
||||||
|
// только руками в панели.
|
||||||
|
//
|
||||||
|
// Секрет здесь не логируется и в текст ошибки не попадает: сообщение называет
|
||||||
|
// имя коллекции, а не значения.
|
||||||
|
func ApplyProviderSettings(app core.App, settings ProviderSettings) error {
|
||||||
|
users, err := app.FindCollectionByNameOrId("users")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to find users collection: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Срок жизни сессии живёт здесь, а не в шаге схемы: применённый шаг не
|
||||||
|
// переписывается, и правка числа не доехала бы до хранилища, разойдясь со
|
||||||
|
// сроком жизни куки.
|
||||||
|
users.AuthToken.Duration = SessionDuration
|
||||||
|
|
||||||
|
users.OAuth2.Enabled = true
|
||||||
|
users.OAuth2.Providers = []core.OAuth2ProviderConfig{{
|
||||||
|
Name: ProviderName,
|
||||||
|
ClientId: settings.ClientID,
|
||||||
|
ClientSecret: settings.ClientSecret,
|
||||||
|
AuthURL: settings.AuthURL,
|
||||||
|
TokenURL: settings.TokenURL,
|
||||||
|
UserInfoURL: settings.UserInfoURL,
|
||||||
|
DisplayName: "Authelia",
|
||||||
|
}}
|
||||||
|
|
||||||
|
if err := app.Save(users); err != nil {
|
||||||
|
return fmt.Errorf("failed to apply provider settings to users collection: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ import (
|
|||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TranscriptJobRepository struct {
|
type TranscriptJobRepository struct {
|
||||||
@@ -23,7 +27,7 @@ func NewTranscriptJobRepository(app core.App) *TranscriptJobRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error {
|
func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error {
|
||||||
collection, err := findCollection(repo.app, JobsCollection)
|
collection, err := findCollection(repo.app, migrations.JobsCollection)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -50,7 +54,7 @@ func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error {
|
|||||||
// LostAcquisitionError и результата не пишет.
|
// LostAcquisitionError и результата не пишет.
|
||||||
func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob, holder string) error {
|
func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob, holder string) error {
|
||||||
err := repo.app.RunInTransaction(func(txApp core.App) error {
|
err := repo.app.RunInTransaction(func(txApp core.App) error {
|
||||||
record, err := txApp.FindRecordById(JobsCollection, job.Id)
|
record, err := txApp.FindRecordById(migrations.JobsCollection, job.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to find transcribe job: %w", err)
|
return fmt.Errorf("failed to find transcribe job: %w", err)
|
||||||
}
|
}
|
||||||
@@ -77,7 +81,7 @@ func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob, holder stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (repo *TranscriptJobRepository) GetByID(id string) (*entity.TranscribeJob, error) {
|
func (repo *TranscriptJobRepository) GetByID(id string) (*entity.TranscribeJob, error) {
|
||||||
record, err := repo.app.FindRecordById(JobsCollection, id)
|
record, err := repo.app.FindRecordById(migrations.JobsCollection, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get transcribe job: %w", err)
|
return nil, fmt.Errorf("failed to get transcribe job: %w", err)
|
||||||
}
|
}
|
||||||
@@ -105,16 +109,22 @@ const acquireColumns = `id, state, source, file, error_text, acquisition_id, ` +
|
|||||||
// разделителем, обратил бы условие срока в постоянную истину или постоянную
|
// разделителем, обратил бы условие срока в постоянную истину или постоянную
|
||||||
// ложь — молча.
|
// ложь — молча.
|
||||||
func (repo *TranscriptJobRepository) FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error) {
|
func (repo *TranscriptJobRepository) FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error) {
|
||||||
now := types.NowDateTime()
|
// Метка времени берётся единой точкой, а не `types.NowDateTime()`: обёртка
|
||||||
|
// хранилища читает часы сама, и запрет линтера её не видит — новая метка в
|
||||||
|
// этом запросе обошла бы единую точку молча.
|
||||||
|
now, err := types.ParseDateTime(clock.Now())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse current time: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
query := repo.app.DB().NewQuery(`
|
query := repo.app.DB().NewQuery(`
|
||||||
UPDATE {{` + JobsCollection + `}}
|
UPDATE {{` + migrations.JobsCollection + `}}
|
||||||
SET acquisition_id = {:acquisition_id},
|
SET acquisition_id = {:acquisition_id},
|
||||||
acquire_time = {:now},
|
acquire_time = {:now},
|
||||||
attempts = attempts + 1,
|
attempts = attempts + 1,
|
||||||
updated = {:now}
|
updated = {:now}
|
||||||
WHERE id = (
|
WHERE id = (
|
||||||
SELECT id FROM {{` + JobsCollection + `}}
|
SELECT id FROM {{` + migrations.JobsCollection + `}}
|
||||||
WHERE state = {:state}
|
WHERE state = {:state}
|
||||||
AND (delay_time = '' OR delay_time IS NULL OR delay_time < {:now})
|
AND (delay_time = '' OR delay_time IS NULL OR delay_time < {:now})
|
||||||
AND (acquisition_id = '' OR acquisition_id IS NULL OR acquire_time < {:rotting})
|
AND (acquisition_id = '' OR acquisition_id IS NULL OR acquire_time < {:rotting})
|
||||||
@@ -140,7 +150,7 @@ func (repo *TranscriptJobRepository) FindAndAcquire(state, acquisitionId string,
|
|||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, &contract.JobNotFoundError{State: state, Message: "appropriate job not found"}
|
return nil, &contract.JobNotFoundError{State: state, Message: "appropriate job not found"}
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("failed to aquire job with state %s: %w", state, err)
|
return nil, fmt.Errorf("failed to acquire job with state %s: %w", state, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return row.toJob(), nil
|
return row.toJob(), nil
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import (
|
|||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newTestApp поднимает хранилище на пустом каталоге и накатывает схему — тем же
|
// newTestApp поднимает хранилище на пустом каталоге и накатывает схему — тем же
|
||||||
@@ -136,7 +138,7 @@ func TestFindAndAcquire_RottenAcquisitionIsHandedOutAgain(t *testing.T) {
|
|||||||
|
|
||||||
// Задним числом — записью коллекции, то есть тем же слоем, который пишет
|
// Задним числом — записью коллекции, то есть тем же слоем, который пишет
|
||||||
// собственные времена хранилища.
|
// собственные времена хранилища.
|
||||||
record, err := app.FindRecordById(JobsCollection, job.Id)
|
record, err := app.FindRecordById(migrations.JobsCollection, job.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
record.Set("acquire_time", types.NowDateTime().Add(-2*time.Hour))
|
record.Set("acquire_time", types.NowDateTime().Add(-2*time.Hour))
|
||||||
require.NoError(t, app.Save(record))
|
require.NoError(t, app.Save(record))
|
||||||
@@ -232,7 +234,7 @@ func TestSave_RefusesWriteFromLostAcquisition(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Задача досталась другому, пока шаг работал.
|
// Задача досталась другому, пока шаг работал.
|
||||||
record, err := app.FindRecordById(JobsCollection, mine.Id)
|
record, err := app.FindRecordById(migrations.JobsCollection, mine.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
record.Set("acquisition_id", "someone-else")
|
record.Set("acquisition_id", "someone-else")
|
||||||
require.NoError(t, app.Save(record))
|
require.NoError(t, app.Save(record))
|
||||||
@@ -279,7 +281,7 @@ func TestPanelRules_StateChangeByRequestClearsAcquisition(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, acquired.AcquisitionID)
|
require.NotNil(t, acquired.AcquisitionID)
|
||||||
|
|
||||||
record, err := app.FindRecordById(JobsCollection, job.Id)
|
record, err := app.FindRecordById(migrations.JobsCollection, job.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
record.Set("attempts", 5)
|
record.Set("attempts", 5)
|
||||||
record.Set("state", entity.StateDead)
|
record.Set("state", entity.StateDead)
|
||||||
@@ -360,7 +362,7 @@ func patchRecord(t *testing.T, app core.App, recordID, body string) {
|
|||||||
|
|
||||||
req := httptest.NewRequest(
|
req := httptest.NewRequest(
|
||||||
http.MethodPatch,
|
http.MethodPatch,
|
||||||
"/api/collections/"+JobsCollection+"/records/"+recordID,
|
"/api/collections/"+migrations.JobsCollection+"/records/"+recordID,
|
||||||
strings.NewReader(body),
|
strings.NewReader(body),
|
||||||
)
|
)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrEmptyToken — токен бота не задан. Отдельным значением, потому что подъём
|
||||||
|
// без Telegram — законный исход: сервис продолжает работать с HTTP API.
|
||||||
|
var ErrEmptyToken = errors.New("telegram bot token is empty")
|
||||||
|
|
||||||
|
// NewBot заводит клиента Bot API — и это **единая точка**, через которую с
|
||||||
|
// библиотекой разговаривают оба пакета: адаптер отправки и транспорт бота.
|
||||||
|
//
|
||||||
|
// Точка нужна ради инварианта «секрет не покидает конфиг». Токен живёт в пути
|
||||||
|
// каждого обращения к Bot API (`https://api.telegram.org/bot<TOKEN>/getFile`),
|
||||||
|
// а `http.Client` кладёт адрес запроса в `*url.Error` целиком. Библиотека
|
||||||
|
// отдаёт этот отказ вызывающему как есть, поэтому чистка на месте употребления
|
||||||
|
// закрывает ровно один вызов из пяти: остаются `getFile`, `sendMessage`,
|
||||||
|
// `getMe` из конструктора и длинный опрос. Здесь закрыты все.
|
||||||
|
func NewBot(token string, logger *slog.Logger) (*tgbotapi.BotAPI, error) {
|
||||||
|
return newBot(token, tgbotapi.APIEndpoint, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newBot принимает адрес отдельно — иначе проверка утечки токена ходила бы за
|
||||||
|
// подтверждением в живой Telegram, а боевым токеном запускаться запрещено.
|
||||||
|
func newBot(token, endpoint string, logger *slog.Logger) (*tgbotapi.BotAPI, error) {
|
||||||
|
if token == "" {
|
||||||
|
return nil, ErrEmptyToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// Длинный опрос живёт внутри библиотеки и печатает свой отказ пакетным
|
||||||
|
// логгером в stderr (`GetUpdatesChan`), минуя и наш `slog`, и чистку выше.
|
||||||
|
// Это самый частый путь: опрос идёт непрерывно, а скачивание — только когда
|
||||||
|
// кто-то прислал запись. Логгер пакетный, поэтому и подменяется один раз.
|
||||||
|
if err := tgbotapi.SetLogger(&redactingLogger{token: token, logger: logger}); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to set telegram logger: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tgbotapi.NewBotAPIWithClient(token, endpoint, &safeClient{inner: &http.Client{}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeClient — клиент, чей отказ не несёт адреса. Библиотека объявляет
|
||||||
|
// зависимость интерфейсом `HTTPClient` и возвращает наш отказ вызывающему
|
||||||
|
// нетронутым, поэтому чистка отсюда доходит до каждого вызова Bot API.
|
||||||
|
type safeClient struct {
|
||||||
|
inner *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *safeClient) Do(req *http.Request) (*http.Response, error) {
|
||||||
|
resp, err := c.inner.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, WithoutURL(err)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithoutURL снимает с отказа адрес запроса, сохраняя причину. Стандартный
|
||||||
|
// клиент кладёт в `*url.Error` полный URL, а в ссылке Telegram стоит токен
|
||||||
|
// бота: без этой чистки первый же сбой сети печатает секрет в журнал.
|
||||||
|
// Причина остаётся и узнаётся `errors.Is` по-прежнему.
|
||||||
|
func WithoutURL(err error) error {
|
||||||
|
var urlErr *url.Error
|
||||||
|
if errors.As(err, &urlErr) {
|
||||||
|
return urlErr.Err
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// redactingLogger отдаёт сообщения библиотеки нашему журналу, вычеркнув токен.
|
||||||
|
// Здесь чистится текст, а не ошибка: библиотека печатает уже отформатированную
|
||||||
|
// строку, и разбирать в ней `*url.Error` нечего. Замена точная — токен известен.
|
||||||
|
type redactingLogger struct {
|
||||||
|
token string
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
const redactedToken = "«токен»"
|
||||||
|
|
||||||
|
func (l *redactingLogger) Println(v ...any) {
|
||||||
|
l.write(strings.TrimSuffix(fmt.Sprintln(v...), "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *redactingLogger) Printf(format string, v ...any) {
|
||||||
|
l.write(fmt.Sprintf(format, v...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// write пишет на WARN: это сбой фонового цикла со штатным повтором, а не
|
||||||
|
// событие, требующее разбора (docs/conventions/logging.md, «Уровень —
|
||||||
|
// это адресат»). Сообщение нейтрально: тем же логгером библиотека печатает и
|
||||||
|
// отладку, если её включить, а разделить их она не даёт.
|
||||||
|
func (l *redactingLogger) write(message string) {
|
||||||
|
l.logger.Warn("Telegram library log",
|
||||||
|
"message", strings.ReplaceAll(message, l.token, redactedToken))
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Токен виден в пути каждого обращения к Bot API, а `http.Client` кладёт путь в
|
||||||
|
// `*url.Error` целиком. Проверки ниже судят по тексту: секрет не должен
|
||||||
|
// встречаться ни в отказе, ни в строке журнала. Утечка необратима — утёкший
|
||||||
|
// токен отзывают руками (CLAUDE.md, «Инварианты», critical).
|
||||||
|
const probeToken = "7654321:AAHsecretBOTtokenVALUE"
|
||||||
|
|
||||||
|
// getMeResponse — ответ, которым подставной Telegram пускает конструктор
|
||||||
|
// дальше: `NewBotAPIWithClient` ходит за `getMe` прежде, чем отдать клиента.
|
||||||
|
const getMeResponse = `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"probe","username":"probe_bot"}}`
|
||||||
|
|
||||||
|
func newProbeBot(t *testing.T, handler http.HandlerFunc) (*tgbotapi.BotAPI, *httptest.Server) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
bot, err := newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.DiscardHandler))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return bot, server
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отказ транспорта на любом вызове Bot API не несёт токена: чистка стоит на
|
||||||
|
// границе клиента, а не у места употребления, поэтому закрыты все вызовы разом.
|
||||||
|
func TestBotAPIFailureDoesNotCarryToken(t *testing.T) {
|
||||||
|
bot, server := newProbeBot(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
if _, err := w.Write([]byte(getMeResponse)); err != nil {
|
||||||
|
t.Errorf("подставной Telegram не смог ответить: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Собеседник исчез — так выглядит обрыв сети, DNS-сбой и недоступность
|
||||||
|
// api.telegram.org.
|
||||||
|
server.Close()
|
||||||
|
|
||||||
|
t.Run("getFile", func(t *testing.T) {
|
||||||
|
_, err := bot.GetFile(tgbotapi.FileConfig{FileID: "any"})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.NotContains(t, err.Error(), probeToken, "токен уехал в отказ: %v", err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("sendMessage", func(t *testing.T) {
|
||||||
|
_, err := bot.Send(tgbotapi.NewMessage(1, "текст"))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.NotContains(t, err.Error(), probeToken, "токен уехал в отказ: %v", err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отказ конструктора несёт тот же путь: `NewBotAPIWithClient` ходит за `getMe`,
|
||||||
|
// и контейнер, стартующий раньше сети, печатал бы токен в первую же секунду.
|
||||||
|
func TestBotConstructionFailureDoesNotCarryToken(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||||
|
server.Close()
|
||||||
|
|
||||||
|
_, err := newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.NotContains(t, err.Error(), probeToken, "токен уехал в отказ конструктора: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Длинный опрос печатает свои отказы пакетным логгером самой библиотеки, минуя
|
||||||
|
// наш `slog`. Логгер подменён — значит, и эта строка идёт через вычистку.
|
||||||
|
func TestLibraryLoggerRedactsToken(t *testing.T) {
|
||||||
|
journal := &bytes.Buffer{}
|
||||||
|
logger := slog.New(slog.NewTextHandler(journal, nil))
|
||||||
|
|
||||||
|
redacting := &redactingLogger{token: probeToken, logger: logger}
|
||||||
|
redacting.Println(errors.New(`Post "https://api.telegram.org/bot` + probeToken + `/getUpdates": dial tcp: refused`))
|
||||||
|
redacting.Printf("Failed to get updates from %s", "https://api.telegram.org/bot"+probeToken+"/getUpdates")
|
||||||
|
|
||||||
|
written := journal.String()
|
||||||
|
assert.NotContains(t, written, probeToken, "токен уехал в журнал: %s", written)
|
||||||
|
assert.Equal(t, 2, strings.Count(written, redactedToken), "вместо токена стоит пометка")
|
||||||
|
assert.Contains(t, written, "dial tcp", "причина отказа осталась")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пустой токен — законный исход подъёма без Telegram, и узнаётся он по смыслу.
|
||||||
|
func TestEmptyTokenIsRecognizedByValue(t *testing.T) {
|
||||||
|
_, err := NewBot("", slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, ErrEmptyToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithoutURL снимает адрес, но не причину: `errors.Is` по цепочке продолжает
|
||||||
|
// работать, иначе чистка стоила бы узнаваемости отказа.
|
||||||
|
func TestWithoutURLKeepsCause(t *testing.T) {
|
||||||
|
cause := errors.New("dial tcp: connection refused")
|
||||||
|
wrapped := &url.Error{Op: "Post", URL: "https://api.telegram.org/bot" + probeToken + "/getMe", Err: cause}
|
||||||
|
|
||||||
|
cleaned := WithoutURL(wrapped)
|
||||||
|
|
||||||
|
assert.NotContains(t, cleaned.Error(), probeToken)
|
||||||
|
require.ErrorIs(t, cleaned, cause)
|
||||||
|
assert.Equal(t, cause, WithoutURL(cause), "отказ без адреса не трогают")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Стык, которого не сторожил никто: подмена пакетного логгера держится одной
|
||||||
|
// строкой в `NewBot`, а снятие этой строки не роняло ни одной проверки. Оракул
|
||||||
|
// косвенный по необходимости — библиотека не отдаёт установленный логгер
|
||||||
|
// обратно, — поэтому он смотрит на исход: её собственная строка обязана
|
||||||
|
// оказаться в нашем журнале.
|
||||||
|
func TestLibraryLoggerIsActuallyInstalled(t *testing.T) {
|
||||||
|
journal := &bytes.Buffer{}
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
if _, err := w.Write([]byte(getMeResponse)); err != nil {
|
||||||
|
t.Errorf("подставной Telegram не смог ответить: %v", err)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
bot, err := newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.NewTextHandler(journal, nil)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Отладку библиотека печатает тем же логгером, что и отказы: включаем её,
|
||||||
|
// чтобы строка появилась без обрыва сети.
|
||||||
|
bot.Debug = true
|
||||||
|
_, err = bot.GetMe()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Contains(t, journal.String(), "Telegram library log",
|
||||||
|
"строка библиотеки прошла мимо нашего журнала: логгер не подменён")
|
||||||
|
}
|
||||||
@@ -16,7 +16,9 @@ type TelegramMessageSender struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewTelegramMessageSender(botToken string, logger *slog.Logger) (*TelegramMessageSender, error) {
|
func NewTelegramMessageSender(botToken string, logger *slog.Logger) (*TelegramMessageSender, error) {
|
||||||
bot, err := tgbotapi.NewBotAPI(botToken)
|
// Клиент заводится единой точкой: её отказ не несёт токена, а отказ
|
||||||
|
// конструктора несёт — `NewBotAPI` зовёт `getMe`.
|
||||||
|
bot, err := NewBot(botToken, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,503 @@
|
|||||||
|
// Package archrules — тесты-сканеры исходников для правил, которых не выражает
|
||||||
|
// ни линтер, ни компилятор: направление зависимостей между пакетами и
|
||||||
|
// согласованность перечня колонок очереди.
|
||||||
|
//
|
||||||
|
// Каждое правило здесь — бывшая строка прозы: у него есть детерминированный
|
||||||
|
// оракул, поэтому ему место в наборе проверок, а не в промпте ревью. Перечень
|
||||||
|
// механизированного — docs/conventions/go-linters.md.
|
||||||
|
//
|
||||||
|
// Пакет тестовый целиком: рабочего кода в нём нет и быть не должно.
|
||||||
|
package archrules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"go/parser"
|
||||||
|
"go/printer"
|
||||||
|
"go/token"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
modulePath = "git.vakhrushev.me/av/transcriber"
|
||||||
|
// repoRoot — корень репозитория относительно каталога пакета.
|
||||||
|
repoRoot = "../.."
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ядро — `internal/service`: оно знает только интерфейсы `internal/contract`, а
|
||||||
|
// ffmpeg, Yandex, Telegram и хранилище подставляются в `main.go`
|
||||||
|
// (docs/architecture.md, «Принципы»).
|
||||||
|
const core = "internal/service"
|
||||||
|
|
||||||
|
// Транспорты — входы в ядро. Общее у двух транспортов живёт в ядре, а не в
|
||||||
|
// одном из них: иначе второй начинает зависеть от первого и тащит его целиком.
|
||||||
|
var transports = map[string]bool{
|
||||||
|
"internal/controller/http": true,
|
||||||
|
"internal/controller/tg": true,
|
||||||
|
"internal/controller/worker": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapterPrefix = "internal/adapter/"
|
||||||
|
|
||||||
|
// Пакеты, названные константами выше, обязаны существовать. Иначе правила ниже
|
||||||
|
// стали бы вечно зелёными от одного `git mv`: обход по отсутствующему ключу
|
||||||
|
// карты идёт ноль раз и молчит.
|
||||||
|
func TestПакетыПравилСуществуют(t *testing.T) {
|
||||||
|
dirs := packageDirs(t)
|
||||||
|
for pkg := range transports {
|
||||||
|
if !dirs[pkg] {
|
||||||
|
t.Errorf(
|
||||||
|
"транспорт %s не найден в дереве: правило о транспортах потеряло "+
|
||||||
|
"предмет — переименуй его в этом файле",
|
||||||
|
pkg,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !dirs[core] {
|
||||||
|
t.Errorf(
|
||||||
|
"ядро %s не найдено в дереве: правила о ядре потеряли предмет — "+
|
||||||
|
"переименуй его в этом файле",
|
||||||
|
core,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var adapters int
|
||||||
|
for dir := range dirs {
|
||||||
|
if strings.HasPrefix(dir, adapterPrefix) {
|
||||||
|
adapters++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if adapters == 0 {
|
||||||
|
t.Errorf("под %s не найдено ни одного пакета: правило об адаптерах потеряло предмет", adapterPrefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestЯдроНеЗнаетОбАдаптерах(t *testing.T) {
|
||||||
|
for _, imp := range internalImports(t)[core] {
|
||||||
|
if strings.HasPrefix(imp, adapterPrefix) {
|
||||||
|
t.Errorf(
|
||||||
|
"%s импортирует адаптер %s: ядро зависит от интерфейсов "+
|
||||||
|
"internal/contract, а реализацию подставляет main.go",
|
||||||
|
core, imp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestЯдроНеЗнаетОТранспортах(t *testing.T) {
|
||||||
|
for _, imp := range internalImports(t)[core] {
|
||||||
|
if transports[imp] {
|
||||||
|
t.Errorf(
|
||||||
|
"%s импортирует транспорт %s: зависимость направлена не туда, "+
|
||||||
|
"ядро не знает, кто его позвал",
|
||||||
|
core, imp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestТранспортыНеЗнаютДругОДруге(t *testing.T) {
|
||||||
|
for pkg, imports := range internalImports(t) {
|
||||||
|
if !transports[pkg] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, imp := range imports {
|
||||||
|
if transports[imp] && imp != pkg {
|
||||||
|
t.Errorf(
|
||||||
|
"%s импортирует транспорт %s: общее у двух входов живёт в ядре",
|
||||||
|
pkg, imp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestАдаптерыНеЗнаютНиЯдра_НиТранспортов(t *testing.T) {
|
||||||
|
for pkg, imports := range internalImports(t) {
|
||||||
|
if !strings.HasPrefix(pkg, adapterPrefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, imp := range imports {
|
||||||
|
if imp == core || transports[imp] {
|
||||||
|
t.Errorf(
|
||||||
|
"адаптер %s импортирует %s: адаптер реализует интерфейс "+
|
||||||
|
"internal/contract и о вызывающем не знает",
|
||||||
|
pkg, imp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ошибку узнают `errors.Is` и `errors.As`. Сравнение текста сообщения ловит
|
||||||
|
// заодно и чужую ошибку с тем же текстом, а при смене текста в библиотеке
|
||||||
|
// перестаёт ловить вообще — молча. `errorlint` видит `err == ErrX` и приведение
|
||||||
|
// типа, но матчинг по тексту не видит: его ловит это правило.
|
||||||
|
//
|
||||||
|
// Прецедент: клиент SpeechKit узнавал конец потока сравнением `err.Error() ==
|
||||||
|
// "EOF"` — правилом это закрыто 2026-08-13.
|
||||||
|
//
|
||||||
|
// Форм записи одного и того же условия много, и правило перечисляет их все:
|
||||||
|
// равенство и **неравенство**, обратный порядок операндов, `switch` по тексту и
|
||||||
|
// поиск подстроки любым способом. Отрицание — самая частая форма, и текстовый
|
||||||
|
// запрет, ловящий только `==`, обходился бы ею молча.
|
||||||
|
//
|
||||||
|
// Ищутся все вхождения, а не первое: два места в одном файле иначе починили бы
|
||||||
|
// по одному за прогон.
|
||||||
|
func TestОшибкаНеУзнаётсяПоТексту(t *testing.T) {
|
||||||
|
patterns := []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`\.Error\(\)\s*(==|!=)`),
|
||||||
|
regexp.MustCompile(`(==|!=)\s*[\w.]+\.Error\(\)`),
|
||||||
|
regexp.MustCompile(`switch\s+[\w.]+\.Error\(\)`),
|
||||||
|
regexp.MustCompile(`strings\.\w+\([^)]*\.Error\(\)`),
|
||||||
|
regexp.MustCompile(`regexp\.\w+\([^)]*\.Error\(\)`),
|
||||||
|
regexp.MustCompile(`\.MatchString\([^)]*\.Error\(\)`),
|
||||||
|
}
|
||||||
|
for _, path := range goFiles(t) {
|
||||||
|
// Комментарии сняты разбором: объяснение, приводящее запрещённую форму
|
||||||
|
// в пример, — не код, и краснеть на нём правило не должно.
|
||||||
|
body := []byte(sourceWithoutComments(t, path))
|
||||||
|
rel, err := filepath.Rel(repoRoot, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("отношу путь %s: %v", path, err)
|
||||||
|
}
|
||||||
|
for _, re := range patterns {
|
||||||
|
for _, loc := range re.FindAllIndex(body, -1) {
|
||||||
|
t.Errorf(
|
||||||
|
"%s:%d — ошибку узнают errors.Is и errors.As, а не по тексту сообщения: %q",
|
||||||
|
rel, lineOf(body, loc[0]), strings.TrimSpace(string(body[loc[0]:loc[1]])),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Колонки очереди правятся в четырёх местах пакета хранилища плюс шаг схемы, и
|
||||||
|
// компилятор видит два из них (инвариант CLAUDE.md, «Инварианты», major).
|
||||||
|
// Колонка, забытая в паре `acquireColumns`/`acquiredRow`, приезжает из захвата
|
||||||
|
// нулевой, и первый же `Save` пишет этот ноль поверх сохранённого значения —
|
||||||
|
// поле теряется только у задачи, попавшей к воркеру.
|
||||||
|
//
|
||||||
|
// Правила ниже закрывают все четыре места плюс шаг схемы: перечень запроса,
|
||||||
|
// структуру захвата, запись коллекции (`applyToRecord`/`recordToJob`) и перенос
|
||||||
|
// поля в задачу (`toJob`). Литерал колонки ищется **в телах** нужных функций, а
|
||||||
|
// не в файле: файл держит и структуру с тегами `db:"…"`, и по ней условие
|
||||||
|
// выполнялось бы само собой.
|
||||||
|
const (
|
||||||
|
repoPkg = "internal/adapter/repo/pocketbase"
|
||||||
|
acquireFile = repoPkg + "/transcript_job_repo.go"
|
||||||
|
mappingFile = repoPkg + "/job_mapping.go"
|
||||||
|
migrationsPath = repoPkg + "/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Колонки, которые заводит и заполняет само хранилище: перечня запроса они
|
||||||
|
// касаются, а нашего кода — нет.
|
||||||
|
var storageOwned = map[string]bool{"id": true, "created": true, "updated": true}
|
||||||
|
|
||||||
|
func TestПереченьЗахватаСовпадаетСоСтруктурой(t *testing.T) {
|
||||||
|
query := acquireColumnNames(t)
|
||||||
|
row := rowColumnNames(t)
|
||||||
|
|
||||||
|
for _, col := range query {
|
||||||
|
if !row[col] {
|
||||||
|
t.Errorf(
|
||||||
|
"колонка %q есть в acquireColumns, но не в acquiredRow: из захвата "+
|
||||||
|
"она приедет нулевой, и первый Save затрёт сохранённое значение",
|
||||||
|
col,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
delete(row, col)
|
||||||
|
}
|
||||||
|
for col := range row {
|
||||||
|
t.Errorf(
|
||||||
|
"колонка %q есть в acquiredRow, но не в acquireColumns: запрос её не "+
|
||||||
|
"читает, и поле остаётся нулевым",
|
||||||
|
col,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestКолонкиЗахватаЗаведеныШагомСхемы(t *testing.T) {
|
||||||
|
declared := schemaFieldNames(t)
|
||||||
|
for _, col := range acquireColumnNames(t) {
|
||||||
|
if col == "id" {
|
||||||
|
continue // ключ заводит само хранилище, шаг схемы его не объявляет
|
||||||
|
}
|
||||||
|
if !declared[col] {
|
||||||
|
t.Errorf(
|
||||||
|
"колонка %q читается захватом, но ни один шаг схемы её не заводит: "+
|
||||||
|
"запрос отвалится на живой базе",
|
||||||
|
col,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Четвёртое место — путь через запись коллекции: `applyToRecord` пишет колонку,
|
||||||
|
// `recordToJob` читает. Ищется литерал **в телах этих функций**, а не в файле:
|
||||||
|
// в файле лежит и структура захвата со своими тегами `db:"…"`, и по ней условие
|
||||||
|
// выполнялось бы само собой — правило было бы зелёным всегда.
|
||||||
|
func TestКолонкиЗахватаЧитаютсяИЧерезЗапись(t *testing.T) {
|
||||||
|
write := funcBody(t, mappingFile, "func applyOwnedByPipeline(") +
|
||||||
|
funcBody(t, mappingFile, "func applyToRecord(")
|
||||||
|
read := funcBody(t, mappingFile, "func recordToJob(")
|
||||||
|
|
||||||
|
for _, col := range acquireColumnNames(t) {
|
||||||
|
if storageOwned[col] {
|
||||||
|
continue // эти колонки заводит и заполняет само хранилище
|
||||||
|
}
|
||||||
|
if !strings.Contains(write, `"`+col+`"`) {
|
||||||
|
t.Errorf(
|
||||||
|
"колонку %q читает захват, но её не пишет ни applyOwnedByPipeline, "+
|
||||||
|
"ни applyToRecord: путь через запись коллекции её потеряет",
|
||||||
|
col,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if !strings.Contains(read, `"`+col+`"`) {
|
||||||
|
t.Errorf(
|
||||||
|
"колонку %q читает захват, но recordToJob её не читает: задача, "+
|
||||||
|
"прочитанная не захватом, приедет без этого поля",
|
||||||
|
col,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пятое условие того же инварианта: колонка, доехавшая до структуры захвата,
|
||||||
|
// обязана попасть в задачу. `toJob` обращается к **полям**, а не к литералам,
|
||||||
|
// поэтому сверяются имена полей, а не имена колонок: поле, забытое здесь,
|
||||||
|
// приезжает из захвата прочитанным и теряется на последнем шаге.
|
||||||
|
func TestПоляСтруктурыЗахватаДоезжаютДоЗадачи(t *testing.T) {
|
||||||
|
body := funcBody(t, mappingFile, "func (r *acquiredRow) toJob()")
|
||||||
|
for _, field := range rowFieldNames(t) {
|
||||||
|
if !strings.Contains(body, "r."+field) {
|
||||||
|
t.Errorf(
|
||||||
|
"поле %s структуры захвата не читается в toJob: колонка приедет из "+
|
||||||
|
"запроса, но в задачу не попадёт",
|
||||||
|
field,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Чтение исходников ------------------------------------------------------
|
||||||
|
|
||||||
|
// acquireColumnNames достаёт имена колонок из константы `acquireColumns`. Она
|
||||||
|
// склеена из строковых литералов, поэтому берётся текстом, а не разбором типов:
|
||||||
|
// значение константы известно на месте.
|
||||||
|
func acquireColumnNames(t *testing.T) []string {
|
||||||
|
t.Helper()
|
||||||
|
body := readFile(t, acquireFile)
|
||||||
|
const marker = "const acquireColumns = "
|
||||||
|
start := strings.Index(body, marker)
|
||||||
|
if start < 0 {
|
||||||
|
t.Fatalf("в %s нет константы acquireColumns: правило потеряло предмет", acquireFile)
|
||||||
|
}
|
||||||
|
tail := body[start+len(marker):]
|
||||||
|
end := strings.Index(tail, "`\n")
|
||||||
|
if end < 0 {
|
||||||
|
t.Fatalf("не нашёл конец константы acquireColumns в %s", acquireFile)
|
||||||
|
}
|
||||||
|
var cols []string
|
||||||
|
for _, chunk := range strings.Split(strings.NewReplacer("`", "", "+", "", "\n", "", "\t", "").Replace(tail[:end]), ",") {
|
||||||
|
if col := strings.TrimSpace(chunk); col != "" {
|
||||||
|
cols = append(cols, col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cols) == 0 {
|
||||||
|
t.Fatalf("перечень acquireColumns прочитан пустым: правило потеряло предмет")
|
||||||
|
}
|
||||||
|
return cols
|
||||||
|
}
|
||||||
|
|
||||||
|
// rowColumnNames достаёт колонки из тегов `db:"…"` структуры `acquiredRow`.
|
||||||
|
func rowColumnNames(t *testing.T) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
out := map[string]bool{}
|
||||||
|
for _, m := range regexp.MustCompile("`db:\"([^\"]+)\"`").FindAllStringSubmatch(rowStruct(t), -1) {
|
||||||
|
out[m[1]] = true
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
t.Fatalf("у acquiredRow не прочитан ни один тег db: правило потеряло предмет")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// rowFieldNames достаёт имена полей структуры `acquiredRow` — те, к которым
|
||||||
|
// обращается `toJob`.
|
||||||
|
func rowFieldNames(t *testing.T) []string {
|
||||||
|
t.Helper()
|
||||||
|
var out []string
|
||||||
|
for _, m := range regexp.MustCompile(`(?m)^\t([A-Z]\w*)\s`).FindAllStringSubmatch(rowStruct(t), -1) {
|
||||||
|
out = append(out, m[1])
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
t.Fatalf("у acquiredRow не прочитано ни одно поле: правило потеряло предмет")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// rowStruct — текст объявления структуры `acquiredRow`.
|
||||||
|
func rowStruct(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
body := readFile(t, mappingFile)
|
||||||
|
start := strings.Index(body, "type acquiredRow struct {")
|
||||||
|
if start < 0 {
|
||||||
|
t.Fatalf("в %s нет структуры acquiredRow: правило потеряло предмет", mappingFile)
|
||||||
|
}
|
||||||
|
end := strings.Index(body[start:], "\n}")
|
||||||
|
if end < 0 {
|
||||||
|
t.Fatalf("не нашёл конец структуры acquiredRow в %s", mappingFile)
|
||||||
|
}
|
||||||
|
return body[start : start+end]
|
||||||
|
}
|
||||||
|
|
||||||
|
// funcBody — текст тела функции от её заголовка до закрывающей скобки в первой
|
||||||
|
// позиции строки. Пропавший заголовок — отказ, а не пустое тело: правило,
|
||||||
|
// потерявшее предмет, обязано краснеть, а не зеленеть.
|
||||||
|
func funcBody(t *testing.T, file, header string) string {
|
||||||
|
t.Helper()
|
||||||
|
body := readFile(t, file)
|
||||||
|
start := strings.Index(body, header)
|
||||||
|
if start < 0 {
|
||||||
|
t.Fatalf("в %s нет %s: правило потеряло предмет", file, header)
|
||||||
|
}
|
||||||
|
end := strings.Index(body[start:], "\n}")
|
||||||
|
if end < 0 {
|
||||||
|
t.Fatalf("не нашёл конец %s в %s", header, file)
|
||||||
|
}
|
||||||
|
return body[start : start+end]
|
||||||
|
}
|
||||||
|
|
||||||
|
// schemaFieldNames собирает имена полей, заведённых шагами схемы: `Name: "…"` в
|
||||||
|
// любом файле каталога шагов. Перечень объединённый — колонку заводит тот шаг,
|
||||||
|
// который её добавил, а переписывать применённый шаг нельзя.
|
||||||
|
func schemaFieldNames(t *testing.T) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join(repoRoot, migrationsPath)
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("читаю каталог шагов схемы: %v", err)
|
||||||
|
}
|
||||||
|
re := regexp.MustCompile(`Name:\s*"([^"]+)"`)
|
||||||
|
out := map[string]bool{}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("читаю %s: %v", e.Name(), err)
|
||||||
|
}
|
||||||
|
for _, m := range re.FindAllStringSubmatch(string(body), -1) {
|
||||||
|
out[m[1]] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
t.Fatalf("шаги схемы не объявили ни одного поля: правило потеряло предмет")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFile(t *testing.T, rel string) string {
|
||||||
|
t.Helper()
|
||||||
|
body, err := os.ReadFile(filepath.Join(repoRoot, rel))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("читаю %s: %v", rel, err)
|
||||||
|
}
|
||||||
|
return string(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// internalImports возвращает карту «пакет репозитория → его внутренние импорты».
|
||||||
|
// Файлы проверок не читаются: подставной адаптер в тесте ядра законен, а вот в
|
||||||
|
// рабочем коде — нет.
|
||||||
|
func internalImports(t *testing.T) map[string][]string {
|
||||||
|
t.Helper()
|
||||||
|
out := map[string][]string{}
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
for _, path := range goFiles(t) {
|
||||||
|
f, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("разбираю %s: %v", path, err)
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(repoRoot, filepath.Dir(path))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("отношу путь %s: %v", path, err)
|
||||||
|
}
|
||||||
|
for _, imp := range f.Imports {
|
||||||
|
if after, ok := strings.CutPrefix(strings.Trim(imp.Path.Value, `"`), modulePath+"/"); ok {
|
||||||
|
out[rel] = append(out[rel], after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
t.Fatal("не найдено ни одного файла с внутренними импортами: правило потеряло предмет")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceWithoutComments — исходник без комментариев: файл разбирается без них и
|
||||||
|
// печатается заново. Снимать комментарии текстом нельзя — строковый литерал со
|
||||||
|
// знаками `//` внутри перестал бы читаться.
|
||||||
|
func sourceWithoutComments(t *testing.T, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
f, err := parser.ParseFile(fset, path, nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("разбираю %s: %v", path, err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := printer.Fprint(&buf, fset, f); err != nil {
|
||||||
|
t.Fatalf("печатаю %s: %v", path, err)
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// packageDirs — каталоги репозитория с рабочим кодом на Go, путями от корня
|
||||||
|
// модуля. Каталог без импортов внутрь модуля тоже считается: правила о предмете
|
||||||
|
// говорят, а не о его зависимостях.
|
||||||
|
func packageDirs(t *testing.T) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
out := map[string]bool{}
|
||||||
|
for _, path := range goFiles(t) {
|
||||||
|
rel, err := filepath.Rel(repoRoot, filepath.Dir(path))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("отношу путь %s: %v", path, err)
|
||||||
|
}
|
||||||
|
out[rel] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// goFiles — все нерабочие каталоги отброшены, файлы проверок тоже: правила
|
||||||
|
// говорят о рабочем коде.
|
||||||
|
func goFiles(t *testing.T) []string {
|
||||||
|
t.Helper()
|
||||||
|
var files []string
|
||||||
|
err := filepath.WalkDir(repoRoot, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
switch d.Name() {
|
||||||
|
case ".git", "data", "docker", "node_modules":
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go") {
|
||||||
|
files = append(files, path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("обхожу репозиторий: %v", err)
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
func lineOf(body []byte, offset int) int {
|
||||||
|
return 1 + strings.Count(string(body[:offset]), "\n")
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Package clock — единая точка чтения времени.
|
||||||
|
//
|
||||||
|
// Прежде время брали `time.Now()` по месту вызова, и конвенция
|
||||||
|
// [docs/conventions/database.md] числила это долгом: метка времени в локальной
|
||||||
|
// зоне, а хранилище сравнивает времена **строками** побайтово. Долг закрыт
|
||||||
|
// заведением этого пакета; правило держит `forbidigo` в `.golangci.yml` —
|
||||||
|
// `time.Now` вне этого пакета запрещён.
|
||||||
|
//
|
||||||
|
// Метка времени и измерение длительности читаются по-разному, и потому здесь две
|
||||||
|
// функции, а не одна.
|
||||||
|
package clock
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Now — метка времени: UTC, как её пишет и сравнивает хранилище.
|
||||||
|
//
|
||||||
|
// Приведение к UTC снимает монотонные часы, и для метки это верно: её кладут в
|
||||||
|
// колонку и сравнивают с чужими значениями, а не с собственным прошлым
|
||||||
|
// показанием.
|
||||||
|
func Now() time.Time {
|
||||||
|
return time.Now().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start — начало измерения длительности: время **с монотонными часами**.
|
||||||
|
//
|
||||||
|
// Зоны у него нет намеренно: значение не выходит наружу и годится только на
|
||||||
|
// вход `time.Since`. Монотонные часы здесь и нужны — иначе перевод стрелок или
|
||||||
|
// поправка ntp посреди конвертации дала бы отрицательную или скачущую
|
||||||
|
// длительность в журнале и в метрике.
|
||||||
|
func Start() time.Time {
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/BurntSushi/toml"
|
||||||
)
|
)
|
||||||
@@ -12,6 +15,7 @@ type Config struct {
|
|||||||
Storage StorageConfig `toml:"storage"`
|
Storage StorageConfig `toml:"storage"`
|
||||||
Yandex YandexConfig `toml:"yandex"`
|
Yandex YandexConfig `toml:"yandex"`
|
||||||
Telegram TelegramConfig `toml:"telegram"`
|
Telegram TelegramConfig `toml:"telegram"`
|
||||||
|
Auth AuthConfig `toml:"auth"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
@@ -42,6 +46,69 @@ type TelegramConfig struct {
|
|||||||
UpdateTimeout int `toml:"update_timeout"`
|
UpdateTimeout int `toml:"update_timeout"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AuthConfig — вход через внешнего провайдера OIDC. Адреса, идентификатор
|
||||||
|
// клиента и секрет приезжают сюда и приводятся к настройкам коллекции
|
||||||
|
// пользователей при каждом подъёме: применённый шаг схемы не переписывается, и
|
||||||
|
// секрет, положенный однажды шагом, не пережил бы ротации.
|
||||||
|
type AuthConfig struct {
|
||||||
|
AuthURL string `toml:"auth_url"`
|
||||||
|
TokenURL string `toml:"token_url"`
|
||||||
|
UserInfoURL string `toml:"user_info_url"`
|
||||||
|
ClientID string `toml:"client_id"`
|
||||||
|
ClientSecret string `toml:"client_secret"`
|
||||||
|
// RedirectURL — адрес возврата, тот же, что записан клиенту у провайдера.
|
||||||
|
RedirectURL string `toml:"redirect_url"`
|
||||||
|
// SecureCookie — признак `Secure` у куки сессии. Умолчание «включено»;
|
||||||
|
// выключается только для локального запуска по `http://localhost`, где
|
||||||
|
// браузер такую куку не сохранит.
|
||||||
|
SecureCookie bool `toml:"secure_cookie"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate проверяет, что вход настроен целиком и что адреса — адреса. Пустое
|
||||||
|
// или негодное поле роняет старт: с молча выключенным входом сервис поднялся бы
|
||||||
|
// открытым наружу, а узнать об этом было бы неоткуда.
|
||||||
|
//
|
||||||
|
// Форма адреса проверяется здесь, а не только хранилищем, потому что хранилище
|
||||||
|
// отвергает негодный адрес позже — из хука подъёма, до регистрации пробы
|
||||||
|
// здоровья и метрик. Тогда владелец не получает даже кода состояния: сервис
|
||||||
|
// молча падает целиком, вместе с ботом и воркерами.
|
||||||
|
func (c AuthConfig) Validate() error {
|
||||||
|
values := map[string]string{
|
||||||
|
"auth_url": c.AuthURL,
|
||||||
|
"token_url": c.TokenURL,
|
||||||
|
"user_info_url": c.UserInfoURL,
|
||||||
|
"client_id": c.ClientID,
|
||||||
|
"client_secret": c.ClientSecret,
|
||||||
|
"redirect_url": c.RedirectURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
missing := make([]string, 0, len(values))
|
||||||
|
for name, value := range values {
|
||||||
|
if value == "" {
|
||||||
|
missing = append(missing, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
sort.Strings(missing)
|
||||||
|
// Названы имена ключей, а не значения: значение `client_secret` в
|
||||||
|
// сообщение об ошибке попасть не должно, оно уедет в журнал.
|
||||||
|
return fmt.Errorf("auth: не заполнены ключи: %s", strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
malformed := make([]string, 0, 4)
|
||||||
|
for _, name := range []string{"auth_url", "token_url", "user_info_url", "redirect_url"} {
|
||||||
|
parsed, err := url.Parse(values[name])
|
||||||
|
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||||
|
malformed = append(malformed, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(malformed) > 0 {
|
||||||
|
return fmt.Errorf("auth: ключи не похожи на адрес: %s", strings.Join(malformed, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// DefaultConfig returns a Config with default values
|
// DefaultConfig returns a Config with default values
|
||||||
func defaultConfig() *Config {
|
func defaultConfig() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
@@ -66,6 +133,9 @@ func defaultConfig() *Config {
|
|||||||
BotToken: "",
|
BotToken: "",
|
||||||
UpdateTimeout: 10,
|
UpdateTimeout: 10,
|
||||||
},
|
},
|
||||||
|
Auth: AuthConfig{
|
||||||
|
SecureCookie: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Проверка входа — единственная страховка от того, чтобы сервис поднялся с
|
||||||
|
// молча выключенным входом, то есть открытым наружу. До этих проверок она не
|
||||||
|
// исполнялась ни разу.
|
||||||
|
|
||||||
|
func validAuthConfig() AuthConfig {
|
||||||
|
return AuthConfig{
|
||||||
|
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||||
|
TokenURL: "https://auth.example.com/api/oidc/token",
|
||||||
|
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
|
||||||
|
ClientID: "transcriber",
|
||||||
|
ClientSecret: "secret-value",
|
||||||
|
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthConfigValidateAcceptsFilled(t *testing.T) {
|
||||||
|
if err := validAuthConfig().Validate(); err != nil {
|
||||||
|
t.Fatalf("заполненный конфиг отвергнут: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthConfigValidateNamesEveryMissingKey(t *testing.T) {
|
||||||
|
cases := map[string]func(*AuthConfig){
|
||||||
|
"auth_url": func(c *AuthConfig) { c.AuthURL = "" },
|
||||||
|
"token_url": func(c *AuthConfig) { c.TokenURL = "" },
|
||||||
|
"user_info_url": func(c *AuthConfig) { c.UserInfoURL = "" },
|
||||||
|
"client_id": func(c *AuthConfig) { c.ClientID = "" },
|
||||||
|
"client_secret": func(c *AuthConfig) { c.ClientSecret = "" },
|
||||||
|
"redirect_url": func(c *AuthConfig) { c.RedirectURL = "" },
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, clear := range cases {
|
||||||
|
t.Run(key, func(t *testing.T) {
|
||||||
|
cfg := validAuthConfig()
|
||||||
|
clear(&cfg)
|
||||||
|
|
||||||
|
err := cfg.Validate()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("пустой ключ %s пропущен — сервис поднимется с выключенным входом", key)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), key) {
|
||||||
|
t.Fatalf("имя ключа %s не названо: %v", key, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuthConfigValidateHidesSecretValue: сообщение об отказе уезжает в журнал,
|
||||||
|
// и значения секрета в нём быть не должно — только имя ключа.
|
||||||
|
func TestAuthConfigValidateHidesSecretValue(t *testing.T) {
|
||||||
|
cfg := validAuthConfig()
|
||||||
|
cfg.ClientSecret = "super-secret-value"
|
||||||
|
cfg.AuthURL = ""
|
||||||
|
|
||||||
|
err := cfg.Validate()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("отказа нет")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "super-secret-value") {
|
||||||
|
t.Fatalf("значение секрета попало в текст отказа: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuthConfigValidateRejectsMalformedURL: непустая строка, не похожая на
|
||||||
|
// адрес, отвергается здесь, а не позже — хранилище отказало бы уже из хука
|
||||||
|
// подъёма, до регистрации пробы здоровья, и сервис упал бы молча целиком.
|
||||||
|
func TestAuthConfigValidateRejectsMalformedURL(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"без схемы": "auth.example.com/api/oidc/authorization",
|
||||||
|
"пробел спереди": " https://auth.example.com/authorize",
|
||||||
|
"чужая схема": "ftp://auth.example.com/authorize",
|
||||||
|
"пустой хост": "https:///authorize",
|
||||||
|
"не адрес вовсе": "todo: заполнить",
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, value := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
cfg := validAuthConfig()
|
||||||
|
cfg.AuthURL = value
|
||||||
|
|
||||||
|
err := cfg.Validate()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("негодный адрес %q пропущен", value)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "auth_url") {
|
||||||
|
t.Fatalf("имя ключа не названо: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package contract
|
package contract
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
@@ -10,18 +11,23 @@ type AudioInfo struct {
|
|||||||
Seconds int // Длина аудиофайла в секундах
|
Seconds int // Длина аудиофайла в секундах
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Контекст первым доводом несут все интерфейсы, за которыми стоит внешний
|
||||||
|
// собеседник — процесс `ffmpeg`, S3, SpeechKit. Он здесь не украшение: остановка
|
||||||
|
// сервиса обязана доходить до чужой работы, а не оставлять её сиротой. Без него
|
||||||
|
// конвертация шестичасовой записи переживает остановку воркера, а запрос к
|
||||||
|
// платному распознаванию висит до собственного таймаута библиотеки.
|
||||||
type AudioMetaViewer interface {
|
type AudioMetaViewer interface {
|
||||||
GetInfo(src string) (*AudioInfo, error)
|
GetInfo(ctx context.Context, src string) (*AudioInfo, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AudioFileConverter interface {
|
type AudioFileConverter interface {
|
||||||
Convert(src, dest string) error
|
Convert(ctx context.Context, src, dest string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type AudioRecognizer interface {
|
type AudioRecognizer interface {
|
||||||
Recognize(file io.Reader, fileName string) (operationID string, err error)
|
Recognize(ctx context.Context, file io.Reader, fileName string) (operationID string, err error)
|
||||||
GetRecognitionText(operationID string) (string, error)
|
GetRecognitionText(ctx context.Context, operationID string) (string, error)
|
||||||
CheckRecognitionStatus(operationID string) (*entity.RecognitionResult, error)
|
CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type TelegramMessageSender interface {
|
type TelegramMessageSender interface {
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/router"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/security"
|
||||||
|
|
||||||
|
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SessionCookieName — имя куки сессии. Имя нормативно: его смена молча
|
||||||
|
// выкидывает всех вошедших.
|
||||||
|
SessionCookieName = "transcriber_session"
|
||||||
|
// stateCookieName — носитель состояния и проверочного кода PKCE. Живёт
|
||||||
|
// один вход и убирается на возврате, каким бы тот ни был.
|
||||||
|
stateCookieName = "transcriber_login"
|
||||||
|
|
||||||
|
// stateCookieMaxAge — потолок времени на вход у провайдера. Дольше носитель
|
||||||
|
// не нужен, а вечный носитель надолго фиксирует состояние.
|
||||||
|
stateCookieMaxAge = 10 * 60
|
||||||
|
|
||||||
|
// exchangeTimeout — потолок обмена кода у провайдера. Без него молчащий
|
||||||
|
// провайдер держит обработчик возврата открытым неограниченно долго, и «медленный»
|
||||||
|
// становится неотличим от «отказал».
|
||||||
|
exchangeTimeout = 15 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthHandler ведёт вход, возврат от провайдера и выход.
|
||||||
|
//
|
||||||
|
// Разбор ответа провайдера остаётся за хранилищем — решение от 2026-08-11.
|
||||||
|
// Обмен кода библиотека наружу не отдаёт: он живёт за её собственным адресом,
|
||||||
|
// поэтому обработчик возврата зовёт этот адрес внутри процесса, через её же
|
||||||
|
// роутер. Цена петли принята решением владельца от 2026-08-12: взамен учётные
|
||||||
|
// записи заводит хранилище и они видны в панели.
|
||||||
|
type AuthHandler struct {
|
||||||
|
app core.App
|
||||||
|
logger *slog.Logger
|
||||||
|
authURL string
|
||||||
|
redirectURL string
|
||||||
|
clientID string
|
||||||
|
secureCookie bool
|
||||||
|
|
||||||
|
// storageMux — роутер хранилища, через который идёт обмен кода. Собирается
|
||||||
|
// один раз: сборка вешает обработчики на само приложение и без
|
||||||
|
// идентификатора, поэтому повторная не заменяет прежние, а добавляет к ним.
|
||||||
|
// Собранный на каждый вход, он копил бы их без предела — и копил бы по
|
||||||
|
// запросу анонима, потому что обмен исполняется раньше обращения к
|
||||||
|
// провайдеру.
|
||||||
|
storageMux http.Handler
|
||||||
|
storageMuxOnce sync.Once
|
||||||
|
storageMuxErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthHandlerConfig struct {
|
||||||
|
AuthURL string
|
||||||
|
RedirectURL string
|
||||||
|
ClientID string
|
||||||
|
SecureCookie bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthHandler(app core.App, cfg AuthHandlerConfig, logger *slog.Logger) *AuthHandler {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &AuthHandler{
|
||||||
|
app: app,
|
||||||
|
logger: logger,
|
||||||
|
authURL: cfg.AuthURL,
|
||||||
|
redirectURL: cfg.RedirectURL,
|
||||||
|
clientID: cfg.ClientID,
|
||||||
|
secureCookie: cfg.SecureCookie,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register вешает адреса входа вне пространства `/api`: оно поделено с
|
||||||
|
// собственными адресами хранилища.
|
||||||
|
func (h *AuthHandler) Register(r *router.Router[*core.RequestEvent]) {
|
||||||
|
// Продление сессии закрывается на всём роутере: адрес приносит хранилище
|
||||||
|
// своим, и перехватить его можно только слоем.
|
||||||
|
r.Bind(BlockSessionRefresh())
|
||||||
|
|
||||||
|
r.GET("/auth/login", h.Login)
|
||||||
|
r.GET("/auth/callback", h.Callback)
|
||||||
|
// Выход берёт POST намеренно: по GET его срабатывание уносится переходом по
|
||||||
|
// чужой ссылке.
|
||||||
|
//
|
||||||
|
// Слой предъявления нужен и здесь: без него выход не знает, чью сессию
|
||||||
|
// обесценивать, — он убрал бы куку и отчитался успехом, оставив унесённое
|
||||||
|
// значение годным. Требования сессии при этом нет: выход без неё убирает
|
||||||
|
// куку и молчит.
|
||||||
|
r.POST("/auth/logout", h.Logout).Bind(SessionFromCookie())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login уводит человека к провайдеру, запомнив состояние и проверочный код
|
||||||
|
// PKCE у браузера.
|
||||||
|
func (h *AuthHandler) Login(e *core.RequestEvent) error {
|
||||||
|
state := security.RandomString(32)
|
||||||
|
verifier := security.RandomString(43)
|
||||||
|
|
||||||
|
e.SetCookie(&http.Cookie{
|
||||||
|
Name: stateCookieName,
|
||||||
|
Value: state + ":" + verifier,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: stateCookieMaxAge,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: h.secureCookie,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
query := url.Values{}
|
||||||
|
query.Set("response_type", "code")
|
||||||
|
query.Set("client_id", h.clientID)
|
||||||
|
query.Set("redirect_uri", h.redirectURL)
|
||||||
|
query.Set("scope", "openid profile email")
|
||||||
|
query.Set("state", state)
|
||||||
|
query.Set("code_challenge", security.S256Challenge(verifier))
|
||||||
|
query.Set("code_challenge_method", "S256")
|
||||||
|
|
||||||
|
separator := "?"
|
||||||
|
if strings.Contains(h.authURL, "?") {
|
||||||
|
separator = "&"
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.Redirect(http.StatusFound, h.authURL+separator+query.Encode())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callback принимает возврат от провайдера, сверяет состояние и меняет код на
|
||||||
|
// сессию средствами хранилища.
|
||||||
|
func (h *AuthHandler) Callback(e *core.RequestEvent) error {
|
||||||
|
// Носитель убирается всегда — и на успехе, и на отказе, — и убирается
|
||||||
|
// **до** записи ответа. Отложенная уборка не работает вовсе: заголовки
|
||||||
|
// фиксируются в момент, когда ответ начинают писать, и позднейшая правка их
|
||||||
|
// карты до браузера не доезжает. Состояние одноразовое ровно этим: пока
|
||||||
|
// носитель жив, переигранный возврат проходит сверку.
|
||||||
|
h.clearStateCookie(e)
|
||||||
|
|
||||||
|
query := e.Request.URL.Query()
|
||||||
|
|
||||||
|
// Всё, что ниже до обмена, — негодный ввод от пришедшего, а не поломка
|
||||||
|
// сервиса: владельцу разбирать нечего, и уровень здесь отладочный. Иначе
|
||||||
|
// обычный отказ человека у провайдера стал бы неотличим от «провайдер лежит».
|
||||||
|
if providerError := query.Get("error"); providerError != "" {
|
||||||
|
h.logger.Debug("Login rejected by provider",
|
||||||
|
"reason", knownProviderError(providerError), "capability", "access", "transport", "http")
|
||||||
|
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
|
||||||
|
}
|
||||||
|
|
||||||
|
state, verifier, err := h.readStateCookie(e)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Debug("Login state is missing or malformed",
|
||||||
|
"error", err, "capability", "access", "transport", "http")
|
||||||
|
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if query.Get("state") != state {
|
||||||
|
h.logger.Debug("Login state mismatch", "capability", "access", "transport", "http")
|
||||||
|
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
|
||||||
|
}
|
||||||
|
|
||||||
|
code := query.Get("code")
|
||||||
|
if code == "" {
|
||||||
|
h.logger.Debug("Provider returned no code", "capability", "access", "transport", "http")
|
||||||
|
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := h.exchange(e.Request.Context(), code, verifier)
|
||||||
|
if err != nil {
|
||||||
|
// Отказ обмена — уже про сервис и его связь с провайдером, поэтому
|
||||||
|
// уровень выше. Код провайдера в журнал не идёт: он и есть предъявитель
|
||||||
|
// входа.
|
||||||
|
h.logger.Error("Failed to exchange provider code",
|
||||||
|
"error", err, "capability", "access", "transport", "http")
|
||||||
|
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
|
||||||
|
}
|
||||||
|
|
||||||
|
h.setSessionCookie(e, token)
|
||||||
|
|
||||||
|
return e.Redirect(http.StatusFound, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout обесценивает выданные учётной записи сессии и убирает куку.
|
||||||
|
//
|
||||||
|
// Порядок обязателен: сперва обесценивание, потом уборка. При обратном порядке
|
||||||
|
// выход, разошедшийся с одновременным входом, оставил бы годную сессию, а
|
||||||
|
// человек был бы уверен, что вышел.
|
||||||
|
func (h *AuthHandler) Logout(e *core.RequestEvent) error {
|
||||||
|
if e.Auth != nil {
|
||||||
|
// Ключ токенов обновляется у свежей записи: между чтением и записью
|
||||||
|
// могла пройти чужая правка, и полное сохранение устаревшей записи
|
||||||
|
// затёрло бы её.
|
||||||
|
record, err := h.app.FindRecordById(e.Auth.Collection().Id, e.Auth.Id)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to load account for logout", "error", err, "transport", "http")
|
||||||
|
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Выйти не удалось"})
|
||||||
|
}
|
||||||
|
|
||||||
|
record.RefreshTokenKey()
|
||||||
|
if err := h.app.Save(record); err != nil {
|
||||||
|
h.logger.Error("Failed to revoke sessions", "error", err, "transport", "http")
|
||||||
|
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Выйти не удалось"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h.clearSessionCookie(e)
|
||||||
|
|
||||||
|
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// exchange зовёт собственный адрес хранилища внутри процесса. По сети запрос не
|
||||||
|
// идёт: роутер поднимается тот же, что обслуживает внешние запросы.
|
||||||
|
func (h *AuthHandler) exchange(ctx context.Context, code, verifier string) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, exchangeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
body, err := json.Marshal(map[string]string{
|
||||||
|
"provider": pbrepo.ProviderName,
|
||||||
|
"code": code,
|
||||||
|
"codeVerifier": verifier,
|
||||||
|
"redirectURL": h.redirectURL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to build exchange request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request, err := http.NewRequestWithContext(
|
||||||
|
ctx,
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/collections/users/auth-with-oauth2",
|
||||||
|
strings.NewReader(string(body)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to build exchange request: %w", err)
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
handler, err := h.storageHandler()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
// Тело ответа наружу не выносится: в нём приезжает описание отказа
|
||||||
|
// провайдера, а оно принадлежит журналу, а не человеку.
|
||||||
|
return "", fmt.Errorf("storage rejected the exchange with code %d", recorder.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read exchange response: %w", err)
|
||||||
|
}
|
||||||
|
if response.Token == "" {
|
||||||
|
return "", errors.New("exchange response carries no session")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// knownProviderError приводит причину отказа к перечню известных.
|
||||||
|
//
|
||||||
|
// Значение приходит строкой запроса и целиком задаётся тем, кто её шлёт: без
|
||||||
|
// приведения аноним пишет в журнал что угодно и сколько угодно — предел один,
|
||||||
|
// размер заголовков. Журнал же единственное место, где наблюдаются инварианты о
|
||||||
|
// молчаливой потере задачи, и вытеснять его чужим текстом нельзя.
|
||||||
|
//
|
||||||
|
// Приём тот же, каким расширение записи приводится к перечню форматов.
|
||||||
|
func knownProviderError(value string) string {
|
||||||
|
switch value {
|
||||||
|
case "access_denied", "invalid_request", "invalid_scope", "server_error",
|
||||||
|
"temporarily_unavailable", "unauthorized_client", "unsupported_response_type",
|
||||||
|
"interaction_required", "login_required", "consent_required":
|
||||||
|
return value
|
||||||
|
default:
|
||||||
|
return "other"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// storageHandler собирает роутер хранилища один раз и отдаёт его всем
|
||||||
|
// последующим обменам.
|
||||||
|
func (h *AuthHandler) storageHandler() (http.Handler, error) {
|
||||||
|
h.storageMuxOnce.Do(func() {
|
||||||
|
router, err := apis.NewRouter(h.app)
|
||||||
|
if err != nil {
|
||||||
|
h.storageMuxErr = fmt.Errorf("failed to build storage router: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mux, err := router.BuildMux()
|
||||||
|
if err != nil {
|
||||||
|
h.storageMuxErr = fmt.Errorf("failed to build storage router: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.storageMux = mux
|
||||||
|
})
|
||||||
|
|
||||||
|
return h.storageMux, h.storageMuxErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) readStateCookie(e *core.RequestEvent) (state, verifier string, err error) {
|
||||||
|
cookie, err := e.Request.Cookie(stateCookieName)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("login state cookie is missing: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
state, verifier, found := strings.Cut(cookie.Value, ":")
|
||||||
|
if !found || state == "" || verifier == "" {
|
||||||
|
return "", "", errors.New("login state cookie is malformed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return state, verifier, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) setSessionCookie(e *core.RequestEvent, token string) {
|
||||||
|
e.SetCookie(&http.Cookie{
|
||||||
|
Name: SessionCookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: pbrepo.SessionDuration,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: h.secureCookie,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) clearSessionCookie(e *core.RequestEvent) {
|
||||||
|
e.SetCookie(&http.Cookie{
|
||||||
|
Name: SessionCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: h.secureCookie,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) clearStateCookie(e *core.RequestEvent) {
|
||||||
|
e.SetCookie(&http.Cookie{
|
||||||
|
Name: stateCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: h.secureCookie,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Проверки этого файла судят допуск: кого пускают к приёму и опросу, чем
|
||||||
|
// предъявляется сессия, что её прекращает и какие адреса остаются открытыми.
|
||||||
|
|
||||||
|
// TestApiRequiresSession — первый критерий приёмки. Запрос без сессии получает
|
||||||
|
// отказ и ничего не заводит, а проба здоровья и метрики остаются открытыми.
|
||||||
|
func TestApiRequiresSession(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
t.Run("приём записи без сессии", func(t *testing.T) {
|
||||||
|
req := createMultipartRequest(t, "test.mp3", []byte("audio"))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
env.mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||||
|
assert.NotContains(t, w.Body.String(), "job_id")
|
||||||
|
|
||||||
|
// Ни файла, ни задачи: отказ наступает раньше, чем запись попадает в
|
||||||
|
// хранилище.
|
||||||
|
files, err := env.app.FindAllRecords(migrations.FilesCollection)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, files)
|
||||||
|
|
||||||
|
jobs, err := env.app.FindAllRecords(migrations.JobsCollection)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, jobs)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("опрос готовности без сессии", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/status/anything", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
env.mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||||
|
assert.NotContains(t, w.Body.String(), "transcription_text")
|
||||||
|
assert.NotContains(t, w.Body.String(), "created_at")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnknownJobIsIndistinguishableWithoutSession: по кодам ответа без сессии не
|
||||||
|
// перебирается список заведённых задач.
|
||||||
|
func TestUnknownJobIsIndistinguishableWithoutSession(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
created := httptest.NewRecorder()
|
||||||
|
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio")))
|
||||||
|
require.Equal(t, http.StatusCreated, created.Code)
|
||||||
|
|
||||||
|
jobs, err := env.app.FindAllRecords(migrations.JobsCollection)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, jobs, 1)
|
||||||
|
|
||||||
|
existing := httptest.NewRecorder()
|
||||||
|
env.mux.ServeHTTP(existing, httptest.NewRequest(http.MethodGet, "/api/status/"+jobs[0].Id, nil))
|
||||||
|
|
||||||
|
missing := httptest.NewRecorder()
|
||||||
|
env.mux.ServeHTTP(missing, httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil))
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, existing.Code)
|
||||||
|
assert.Equal(t, missing.Code, existing.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpenEndpointsStayOpen — вторая сторона границы: проба здоровья и метрики
|
||||||
|
// сессии не требуют. Маршруты вешает `main`, поэтому здесь собирается такой же
|
||||||
|
// роутер с теми же двумя адресами.
|
||||||
|
func TestOpenEndpointsStayOpen(t *testing.T) {
|
||||||
|
app := newTestStorage(t)
|
||||||
|
|
||||||
|
r, err := apis.NewRouter(app)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
r.GET("/health", func(e *core.RequestEvent) error {
|
||||||
|
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
})
|
||||||
|
r.GET("/metrics", func(e *core.RequestEvent) error {
|
||||||
|
return e.String(http.StatusOK, "# metrics")
|
||||||
|
})
|
||||||
|
|
||||||
|
mux, err := r.BuildMux()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for _, path := range []string{"/health", "/metrics"} {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code, "адрес %s обязан отвечать без сессии", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSessionSurvivesRestart — второй критерий приёмки. Подпись сессии считается
|
||||||
|
// от секрета коллекции и ключа записи, оба лежат в базе, поэтому выкладка
|
||||||
|
// вошедших не выкидывает.
|
||||||
|
func TestSessionSurvivesRestart(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
before := httptest.NewRecorder()
|
||||||
|
env.serve(before, createMultipartRequest(t, "test.mp3", []byte("audio")))
|
||||||
|
require.Equal(t, http.StatusCreated, before.Code)
|
||||||
|
|
||||||
|
// Сервер пересоздаётся на том же хранилище — то же, что перезапуск процесса
|
||||||
|
// поверх прежнего каталога данных.
|
||||||
|
r, err := apis.NewRouter(env.app)
|
||||||
|
require.NoError(t, err)
|
||||||
|
env.handler.Register(r)
|
||||||
|
|
||||||
|
mux, err := r.BuildMux()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
// Прежняя кука прошла проверку: до обработчика дошло, и он ответил про
|
||||||
|
// ненайденную задачу, а не про отсутствующую сессию.
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLogoutClosesAccess — третий критерий приёмки. Выход обесценивает выданные
|
||||||
|
// сессии, а не только убирает куку.
|
||||||
|
func TestLogoutClosesAccess(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
|
||||||
|
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||||
|
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||||
|
ClientID: "transcriber",
|
||||||
|
SecureCookie: true,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
r, err := apis.NewRouter(env.app)
|
||||||
|
require.NoError(t, err)
|
||||||
|
authHandler.Register(r)
|
||||||
|
env.handler.Register(r)
|
||||||
|
|
||||||
|
mux, err := r.BuildMux()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
logout := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
|
||||||
|
logout.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
|
||||||
|
logoutResponse := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(logoutResponse, logout)
|
||||||
|
require.Equal(t, http.StatusOK, logoutResponse.Code)
|
||||||
|
|
||||||
|
// Куку выход убирает.
|
||||||
|
assert.Contains(t, logoutResponse.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")
|
||||||
|
|
||||||
|
// И прежнее значение больше не открывает доступ — этого уборка куки сама по
|
||||||
|
// себе не даёт: унесённое значение работало бы до истечения срока.
|
||||||
|
after := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
|
||||||
|
after.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
|
||||||
|
afterResponse := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(afterResponse, after)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, afterResponse.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLogoutWhenAccountIsGone: учётной записи, которой предъявлена сессия, уже
|
||||||
|
// нет — выход отвечает отказом и не делает вид, что закрыл доступ.
|
||||||
|
func TestLogoutWhenAccountIsGone(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
|
||||||
|
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||||
|
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||||
|
ClientID: "transcriber",
|
||||||
|
SecureCookie: true,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
r, err := apis.NewRouter(env.app)
|
||||||
|
require.NoError(t, err)
|
||||||
|
authHandler.Register(r)
|
||||||
|
|
||||||
|
mux, err := r.BuildMux()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Сессия выдана, а запись удалена — так выглядит гонка выхода с удалением
|
||||||
|
// учётной записи в панели.
|
||||||
|
require.NoError(t, env.app.Delete(env.account))
|
||||||
|
|
||||||
|
logout := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
|
||||||
|
logout.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, logout)
|
||||||
|
|
||||||
|
// Записи нет — проверка сессии её не находит, и до обесценивания дело не
|
||||||
|
// доходит: выход отвечает успехом, убрав куку. Доступа при этом всё равно
|
||||||
|
// не осталось, потому что не осталось учётной записи.
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(t, w.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")
|
||||||
|
|
||||||
|
after := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
|
||||||
|
after.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
|
||||||
|
afterResponse := httptest.NewRecorder()
|
||||||
|
env.mux.ServeHTTP(afterResponse, after)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, afterResponse.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLogoutWithoutSession: выход без сессии убирает куку и молчит.
|
||||||
|
func TestLogoutWithoutSession(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
|
||||||
|
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||||
|
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||||
|
ClientID: "transcriber",
|
||||||
|
SecureCookie: true,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
r, err := apis.NewRouter(env.app)
|
||||||
|
require.NoError(t, err)
|
||||||
|
authHandler.Register(r)
|
||||||
|
|
||||||
|
mux, err := r.BuildMux()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/auth/logout", nil))
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(t, w.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginRedirectsToProvider: вход уводит к провайдеру и запоминает состояние
|
||||||
|
// у браузера.
|
||||||
|
func TestLoginRedirectsToProvider(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
|
||||||
|
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||||
|
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||||
|
ClientID: "transcriber",
|
||||||
|
SecureCookie: true,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
r, err := apis.NewRouter(env.app)
|
||||||
|
require.NoError(t, err)
|
||||||
|
authHandler.Register(r)
|
||||||
|
|
||||||
|
mux, err := r.BuildMux()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusFound, w.Code)
|
||||||
|
|
||||||
|
location := w.Result().Header.Get("Location")
|
||||||
|
assert.Contains(t, location, "https://auth.example.com/api/oidc/authorization")
|
||||||
|
assert.Contains(t, location, "code_challenge_method=S256")
|
||||||
|
assert.Contains(t, location, "client_id=transcriber")
|
||||||
|
|
||||||
|
// Носитель состояния несёт те же признаки защиты, что и кука сессии.
|
||||||
|
stateCookie := w.Result().Header.Get("Set-Cookie")
|
||||||
|
assert.Contains(t, stateCookie, stateCookieName)
|
||||||
|
assert.Contains(t, stateCookie, "HttpOnly")
|
||||||
|
assert.Contains(t, stateCookie, "Secure")
|
||||||
|
assert.Contains(t, stateCookie, "SameSite=Lax")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCallbackRejectsForeignState — возврат с невыданным состоянием сессии не
|
||||||
|
// открывает и учётной записи не заводит.
|
||||||
|
func TestCallbackRejectsForeignState(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
|
||||||
|
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||||
|
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||||
|
ClientID: "transcriber",
|
||||||
|
SecureCookie: true,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
r, err := apis.NewRouter(env.app)
|
||||||
|
require.NoError(t, err)
|
||||||
|
authHandler.Register(r)
|
||||||
|
|
||||||
|
mux, err := r.BuildMux()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
accountsBefore, err := env.app.FindAllRecords("users")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cookie *http.Cookie
|
||||||
|
query string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "состояния не выдавали вовсе",
|
||||||
|
cookie: nil,
|
||||||
|
query: "?code=whatever&state=foreign",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "состояние не совпало с выданным",
|
||||||
|
cookie: &http.Cookie{Name: stateCookieName, Value: "issued:verifier"},
|
||||||
|
query: "?code=whatever&state=foreign",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "провайдер вернул отказ",
|
||||||
|
cookie: &http.Cookie{Name: stateCookieName, Value: "issued:verifier"},
|
||||||
|
query: "?error=access_denied&state=issued",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/auth/callback"+tc.query, nil)
|
||||||
|
if tc.cookie != nil {
|
||||||
|
req.AddCookie(tc.cookie)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||||
|
|
||||||
|
accountsAfter, err := env.app.FindAllRecords("users")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, accountsAfter, len(accountsBefore))
|
||||||
|
|
||||||
|
// Носитель убирается и на отказном возврате: иначе состояние
|
||||||
|
// осталось бы годным для новой попытки.
|
||||||
|
assert.Contains(t, w.Result().Header.Get("Set-Cookie"), stateCookieName+"=;")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHeaderBeatsCookie: предъявленный заголовок побеждает куку.
|
||||||
|
func TestHeaderBeatsCookie(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "totally-invalid-session"})
|
||||||
|
req.Header.Set("Authorization", env.session)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
env.mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
// Прошёл заголовок: иначе негодная кука дала бы отказ.
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSelfServiceAccountsAreClosed — то, ради чего задача вообще имеет смысл.
|
||||||
|
// Пока создание записи и вход по паролю открыты, закрытие приёма обходится
|
||||||
|
// двумя запросами.
|
||||||
|
func TestSelfServiceAccountsAreClosed(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
t.Run("завести учётную запись самому нельзя", func(t *testing.T) {
|
||||||
|
body := strings.NewReader(`{"email":"intruder@example.com","password":"12345678901","passwordConfirm":"12345678901"}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/collections/users/records", body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
env.mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.NotEqual(t, http.StatusOK, w.Code)
|
||||||
|
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("вход паролем недоступен", func(t *testing.T) {
|
||||||
|
body := strings.NewReader(`{"identity":"person@example.com","password":"whatever"}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/collections/users/auth-with-password", body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
env.mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAccessGrantingValuesAreNotLogged — четвёртый критерий приёмки, расширенный
|
||||||
|
// ревью дизайна: не печатается ничто, что даёт доступ.
|
||||||
|
//
|
||||||
|
// Проверка ищет в журнале **значения**, а не имена полей: значение, уехавшее под
|
||||||
|
// другим ключом, поиск по ключу не разбудил бы.
|
||||||
|
func TestAccessGrantingValuesAreNotLogged(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
created := httptest.NewRecorder()
|
||||||
|
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio")))
|
||||||
|
require.Equal(t, http.StatusCreated, created.Code)
|
||||||
|
|
||||||
|
journal := env.journal.String()
|
||||||
|
require.NotEmpty(t, journal, "журнал пуст — проверке не на чем сработать")
|
||||||
|
|
||||||
|
assert.NotContains(t, journal, env.session,
|
||||||
|
"значение сессии в журнале: строка стала бы ключом к чужому доступу")
|
||||||
|
assert.NotContains(t, journal, env.account.Email(),
|
||||||
|
"адрес почты в журнале: он приходит от провайдера и принадлежит человеку")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProviderSecretIsNotLogged: секрет клиента не появляется в журнале при
|
||||||
|
// приведении настроек провайдера к конфигу.
|
||||||
|
func TestProviderSecretIsNotLogged(t *testing.T) {
|
||||||
|
app := newTestStorage(t)
|
||||||
|
|
||||||
|
const secret = "super-secret-client-value"
|
||||||
|
|
||||||
|
require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
|
||||||
|
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||||
|
TokenURL: "https://auth.example.com/api/oidc/token",
|
||||||
|
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
|
||||||
|
ClientID: "transcriber",
|
||||||
|
ClientSecret: secret,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Настройка доехала до хранилища — иначе проверка отсутствия секрета в
|
||||||
|
// журнале прошла бы на невыполненной работе.
|
||||||
|
users, err := app.FindCollectionByNameOrId("users")
|
||||||
|
require.NoError(t, err)
|
||||||
|
provider, found := users.OAuth2.GetProviderConfig(pbrepo.ProviderName)
|
||||||
|
require.True(t, found)
|
||||||
|
assert.Equal(t, secret, provider.ClientSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProviderSecretRotationReachesStorage: смена секрета в конфиге доезжает до
|
||||||
|
// хранилища. Положенный однажды шагом схемы, он бы не доехал — применённый шаг
|
||||||
|
// не переписывается.
|
||||||
|
func TestProviderSecretRotationReachesStorage(t *testing.T) {
|
||||||
|
app := newTestStorage(t)
|
||||||
|
|
||||||
|
settings := pbrepo.ProviderSettings{
|
||||||
|
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||||
|
TokenURL: "https://auth.example.com/api/oidc/token",
|
||||||
|
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
|
||||||
|
ClientID: "transcriber",
|
||||||
|
ClientSecret: "first-secret",
|
||||||
|
}
|
||||||
|
require.NoError(t, pbrepo.ApplyProviderSettings(app, settings))
|
||||||
|
|
||||||
|
settings.ClientSecret = "rotated-secret"
|
||||||
|
require.NoError(t, pbrepo.ApplyProviderSettings(app, settings))
|
||||||
|
|
||||||
|
users, err := app.FindCollectionByNameOrId("users")
|
||||||
|
require.NoError(t, err)
|
||||||
|
provider, found := users.OAuth2.GetProviderConfig(pbrepo.ProviderName)
|
||||||
|
require.True(t, found)
|
||||||
|
assert.Equal(t, "rotated-secret", provider.ClientSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecordFileIsProtected: ссылка на файл перестала быть правом пройти по ней.
|
||||||
|
func TestRecordFileIsProtected(t *testing.T) {
|
||||||
|
app := newTestStorage(t)
|
||||||
|
|
||||||
|
files, err := app.FindCollectionByNameOrId(migrations.FilesCollection)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
field, ok := files.Fields.GetByName("file").(*core.FileField)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.True(t, field.Protected,
|
||||||
|
"поле файла не защищено: знание ссылки снова стало бы доступом, а отзыва у неё нет")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecordFileNeedsSession: ссылка на файл записи без сессии отказывает, а
|
||||||
|
// конвейер тот же файл по-прежнему читает — он ходит в файловую систему, а не по
|
||||||
|
// ссылке.
|
||||||
|
func TestRecordFileNeedsSession(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
created := httptest.NewRecorder()
|
||||||
|
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio content")))
|
||||||
|
require.Equal(t, http.StatusCreated, created.Code)
|
||||||
|
|
||||||
|
files, err := env.app.FindAllRecords(migrations.FilesCollection)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, files, 1)
|
||||||
|
|
||||||
|
names := files[0].GetStringSlice("file")
|
||||||
|
require.Len(t, names, 1)
|
||||||
|
|
||||||
|
link := "/api/files/" + migrations.FilesCollection + "/" + files[0].Id + "/" + names[0]
|
||||||
|
|
||||||
|
anonymous := httptest.NewRecorder()
|
||||||
|
env.mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, link, nil))
|
||||||
|
// Отказ приходит кодом «не найдено»: защищённый файл не раскрывает даже
|
||||||
|
// своего существования. До пометки поля защищённым эта же ссылка отдавала
|
||||||
|
// содержимое кому угодно — знание ссылки и было доступом.
|
||||||
|
assert.Equal(t, http.StatusNotFound, anonymous.Code,
|
||||||
|
"ссылка отдала файл без сессии: знание ссылки снова стало доступом")
|
||||||
|
assert.NotContains(t, anonymous.Body.String(), "audio content")
|
||||||
|
|
||||||
|
// Конвейер читает тот же файл своим путём — из файловой системы хранилища.
|
||||||
|
fileRepo := pbrepo.NewFileRepository(env.app)
|
||||||
|
reader, err := fileRepo.Open(files[0].Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() {
|
||||||
|
assert.NoError(t, reader.Close())
|
||||||
|
}()
|
||||||
|
|
||||||
|
content := make([]byte, len("audio content"))
|
||||||
|
_, err = reader.Read(content)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "audio content", string(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSessionLifetimeIsAssigned: срок жизни сессии назначен нами, а не достался
|
||||||
|
// умолчанием библиотеки в пять суток.
|
||||||
|
//
|
||||||
|
// Назначается он приведением настроек при подъёме, а не шагом схемы: применённый
|
||||||
|
// шаг не переписывается, и число, положенное туда, разошлось бы со сроком жизни
|
||||||
|
// куки при первой же правке.
|
||||||
|
func TestSessionLifetimeIsAssigned(t *testing.T) {
|
||||||
|
app := newTestStorage(t)
|
||||||
|
|
||||||
|
users, err := app.FindCollectionByNameOrId("users")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEqual(t, int64(pbrepo.SessionDuration), users.AuthToken.Duration,
|
||||||
|
"шаг схемы назначил срок сам — тогда правка числа до хранилища не доедет")
|
||||||
|
|
||||||
|
require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
|
||||||
|
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||||
|
TokenURL: "https://auth.example.com/api/oidc/token",
|
||||||
|
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
|
||||||
|
ClientID: "transcriber",
|
||||||
|
ClientSecret: "local-test-secret",
|
||||||
|
}))
|
||||||
|
|
||||||
|
users, err = app.FindCollectionByNameOrId("users")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(pbrepo.SessionDuration), users.AuthToken.Duration)
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Проверки этого файла проходят вход целиком — от увода к провайдеру до куки
|
||||||
|
// сессии. Без них сердцевина изменения не исполнялась ни разу: прочие проверки
|
||||||
|
// заводят учётную запись прямым сохранением и останавливаются раньше обмена.
|
||||||
|
|
||||||
|
// fakeProvider — подставной провайдер OIDC. Отдаёт токен и сведения о человеке,
|
||||||
|
// считая обращения: по счётчику видно, дошло ли до сети вообще.
|
||||||
|
type fakeProvider struct {
|
||||||
|
server *httptest.Server
|
||||||
|
tokenHits int
|
||||||
|
failToken bool
|
||||||
|
subject string
|
||||||
|
emailValue string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeProvider(t *testing.T) *fakeProvider {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
provider := &fakeProvider{subject: "person-sub-1", emailValue: "person@example.com"}
|
||||||
|
|
||||||
|
write := func(w http.ResponseWriter, body string) {
|
||||||
|
if _, err := w.Write([]byte(body)); err != nil {
|
||||||
|
t.Errorf("подставной провайдер не ответил: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
provider.tokenHits++
|
||||||
|
if provider.failToken {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
write(w, `{"error":"invalid_grant"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
write(w, `{"access_token":"provider-access-token","token_type":"bearer","expires_in":3600}`)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
write(w, `{"sub":"`+provider.subject+`","email":"`+provider.emailValue+`","name":"Person","email_verified":true}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
provider.server = httptest.NewServer(mux)
|
||||||
|
t.Cleanup(provider.server.Close)
|
||||||
|
|
||||||
|
return provider
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginEnv — окружение проверки входа: хранилище с настроенным подставным
|
||||||
|
// провайдером и собранный роутер со всеми слоями.
|
||||||
|
type loginEnv struct {
|
||||||
|
app core.App
|
||||||
|
mux http.Handler
|
||||||
|
handler *AuthHandler
|
||||||
|
provider *fakeProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupLoginEnv(t *testing.T) *loginEnv {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
app := newTestStorage(t)
|
||||||
|
provider := newFakeProvider(t)
|
||||||
|
|
||||||
|
require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
|
||||||
|
AuthURL: provider.server.URL + "/authorize",
|
||||||
|
TokenURL: provider.server.URL + "/token",
|
||||||
|
UserInfoURL: provider.server.URL + "/userinfo",
|
||||||
|
ClientID: "transcriber",
|
||||||
|
ClientSecret: "local-test-secret",
|
||||||
|
}))
|
||||||
|
|
||||||
|
handler := NewAuthHandler(app, AuthHandlerConfig{
|
||||||
|
AuthURL: provider.server.URL + "/authorize",
|
||||||
|
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||||
|
ClientID: "transcriber",
|
||||||
|
SecureCookie: true,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
r, err := apis.NewRouter(app)
|
||||||
|
require.NoError(t, err)
|
||||||
|
handler.Register(r)
|
||||||
|
|
||||||
|
mux, err := r.BuildMux()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return &loginEnv{app: app, mux: mux, handler: handler, provider: provider}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startLogin проходит первый шаг входа и отдаёт носитель состояния вместе с
|
||||||
|
// выданным состоянием — тем, что сервис ждёт обратно.
|
||||||
|
func (e *loginEnv) startLogin(t *testing.T) (cookie *http.Cookie, state string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
e.mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
|
||||||
|
require.Equal(t, http.StatusFound, w.Code)
|
||||||
|
|
||||||
|
for _, c := range w.Result().Cookies() {
|
||||||
|
if c.Name == stateCookieName {
|
||||||
|
cookie = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, cookie, "носитель состояния не поставлен")
|
||||||
|
|
||||||
|
location, err := url.Parse(w.Result().Header.Get("Location"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
state = location.Query().Get("state")
|
||||||
|
require.NotEmpty(t, state)
|
||||||
|
|
||||||
|
return cookie, state
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginCreatesAccountAndSession — вход целиком: человека заводят по слову
|
||||||
|
// провайдера, и он получает сессию.
|
||||||
|
//
|
||||||
|
// Без этой проверки закрытое создание записи в коллекции пользователей выглядит
|
||||||
|
// работающим: прочие проверки заводят запись мимо входа.
|
||||||
|
func TestLoginCreatesAccountAndSession(t *testing.T) {
|
||||||
|
env := setupLoginEnv(t)
|
||||||
|
|
||||||
|
before, err := env.app.FindAllRecords("users")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, before, "учётных записей быть не должно: шаг схемы их не заводит")
|
||||||
|
|
||||||
|
cookie, state := env.startLogin(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
env.mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusFound, w.Code, "вход не прошёл: тело %s", w.Body.String())
|
||||||
|
assert.Equal(t, 1, env.provider.tokenHits, "обмен до провайдера не дошёл")
|
||||||
|
|
||||||
|
after, err := env.app.FindAllRecords("users")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, after, 1, "учётная запись не заведена — войти не может никто")
|
||||||
|
|
||||||
|
var session *http.Cookie
|
||||||
|
for _, c := range w.Result().Cookies() {
|
||||||
|
if c.Name == SessionCookieName {
|
||||||
|
session = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, session, "кука сессии не поставлена")
|
||||||
|
|
||||||
|
// Признаки куки нормативны: их потеря делает сессию доступной скриптам либо
|
||||||
|
// уносит её по незашифрованному соединению.
|
||||||
|
assert.True(t, session.HttpOnly)
|
||||||
|
assert.True(t, session.Secure)
|
||||||
|
assert.Equal(t, http.SameSiteLaxMode, session.SameSite)
|
||||||
|
assert.Equal(t, pbrepo.SessionDuration, session.MaxAge)
|
||||||
|
assert.NotEmpty(t, session.Value)
|
||||||
|
|
||||||
|
// Носитель состояния убран — и убран так, что это видно готовому ответу, а
|
||||||
|
// не только живой карте заголовков.
|
||||||
|
var cleared bool
|
||||||
|
for _, c := range w.Result().Cookies() {
|
||||||
|
if c.Name == stateCookieName && c.MaxAge < 0 {
|
||||||
|
cleared = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, cleared, "носитель состояния пережил возврат")
|
||||||
|
|
||||||
|
// Выданная сессия открывает доступ к закрытым адресам.
|
||||||
|
check := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
|
||||||
|
check.AddCookie(session)
|
||||||
|
checkResponse := httptest.NewRecorder()
|
||||||
|
|
||||||
|
r, err := apis.NewRouter(env.app)
|
||||||
|
require.NoError(t, err)
|
||||||
|
NewTranscribeHandler(pbrepo.NewTranscriptJobRepository(env.app), nil, nil).Register(r)
|
||||||
|
checkMux, err := r.BuildMux()
|
||||||
|
require.NoError(t, err)
|
||||||
|
checkMux.ServeHTTP(checkResponse, check)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, checkResponse.Code,
|
||||||
|
"сессия не открыла доступ: получен %d", checkResponse.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSelfServiceRegistrationStaysClosed: правило создания пускает обмен и не
|
||||||
|
// пускает постороннего.
|
||||||
|
func TestSelfServiceRegistrationStaysClosed(t *testing.T) {
|
||||||
|
env := setupLoginEnv(t)
|
||||||
|
|
||||||
|
body := strings.NewReader(`{"email":"intruder@example.com","password":"12345678901","passwordConfirm":"12345678901"}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/collections/users/records", body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
env.mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest,
|
||||||
|
"посторонний завёл себе учётную запись: %s", w.Body.String())
|
||||||
|
|
||||||
|
accounts, err := env.app.FindAllRecords("users")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, accounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCallbackDoesNotLeakHooks: обмен не копит обработчики приложения.
|
||||||
|
//
|
||||||
|
// Сборка роутера хранилища вешает обработчики на само приложение и без
|
||||||
|
// идентификатора, поэтому повторная не заменяет прежние. Собранный на каждый
|
||||||
|
// вход, роутер копил бы их без предела — и копил бы по запросу анонима, потому
|
||||||
|
// что обмен исполняется раньше обращения к провайдеру.
|
||||||
|
func TestCallbackDoesNotLeakHooks(t *testing.T) {
|
||||||
|
env := setupLoginEnv(t)
|
||||||
|
|
||||||
|
count := func() int {
|
||||||
|
hook := reflect.ValueOf(env.app.OnModelAfterCreateSuccess()).Elem().FieldByName("handlers")
|
||||||
|
return hook.Len()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Первый вход собирает роутер — с него и считаем.
|
||||||
|
cookie, state := env.startLogin(t)
|
||||||
|
first := httptest.NewRequest(http.MethodGet, "/auth/callback?code=c&state="+state, nil)
|
||||||
|
first.AddCookie(cookie)
|
||||||
|
env.mux.ServeHTTP(httptest.NewRecorder(), first)
|
||||||
|
|
||||||
|
before := count()
|
||||||
|
|
||||||
|
for range 20 {
|
||||||
|
cookie, state := env.startLogin(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=c&state="+state, nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
env.mux.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, before, count(),
|
||||||
|
"обработчики копятся: 20 входов добавили %d", count()-before)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSessionRefreshIsClosed: сессия не продлевает саму себя.
|
||||||
|
//
|
||||||
|
// При живом продлении срок её жизни ничего не значит, а вместе с ним перестаёт
|
||||||
|
// работать единственный канал, которым отзыв доступа у провайдера доходит до
|
||||||
|
// сервиса.
|
||||||
|
func TestSessionRefreshIsClosed(t *testing.T) {
|
||||||
|
env := setupLoginEnv(t)
|
||||||
|
|
||||||
|
cookie, state := env.startLogin(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
env.mux.ServeHTTP(w, req)
|
||||||
|
require.Equal(t, http.StatusFound, w.Code)
|
||||||
|
|
||||||
|
var session *http.Cookie
|
||||||
|
for _, c := range w.Result().Cookies() {
|
||||||
|
if c.Name == SessionCookieName {
|
||||||
|
session = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, session)
|
||||||
|
|
||||||
|
refresh := httptest.NewRequest(http.MethodPost, RefreshPath, nil)
|
||||||
|
refresh.Header.Set("Authorization", session.Value)
|
||||||
|
refreshResponse := httptest.NewRecorder()
|
||||||
|
env.mux.ServeHTTP(refreshResponse, refresh)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, refreshResponse.Code,
|
||||||
|
"сессия продлилась: %s", refreshResponse.Body.String())
|
||||||
|
|
||||||
|
// И нового значения в ответе нет — продлевать нечем.
|
||||||
|
assert.NotContains(t, refreshResponse.Body.String(), `"token"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCallbackRejectsProviderFailure: отказ обмена не открывает сессию.
|
||||||
|
func TestCallbackRejectsProviderFailure(t *testing.T) {
|
||||||
|
env := setupLoginEnv(t)
|
||||||
|
env.provider.failToken = true
|
||||||
|
|
||||||
|
cookie, state := env.startLogin(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
env.mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||||
|
|
||||||
|
for _, c := range w.Result().Cookies() {
|
||||||
|
assert.NotEqual(t, SessionCookieName, c.Name, "сессия открыта на отказе обмена")
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts, err := env.app.FindAllRecords("users")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, accounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecordFileNeedsSessionAndToken: файл записи отдаётся вошедшему и не
|
||||||
|
// отдаётся анониму.
|
||||||
|
func TestRecordFileNeedsSessionAndToken(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
created := httptest.NewRecorder()
|
||||||
|
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio content")))
|
||||||
|
require.Equal(t, http.StatusCreated, created.Code)
|
||||||
|
|
||||||
|
files, err := env.app.FindAllRecords(migrations.FilesCollection)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, files, 1)
|
||||||
|
|
||||||
|
names := files[0].GetStringSlice("file")
|
||||||
|
require.Len(t, names, 1)
|
||||||
|
link := "/api/files/" + migrations.FilesCollection + "/" + files[0].Id + "/" + names[0]
|
||||||
|
|
||||||
|
// Аноним не проходит.
|
||||||
|
anonymous := httptest.NewRecorder()
|
||||||
|
env.mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, link, nil))
|
||||||
|
assert.GreaterOrEqual(t, anonymous.Code, http.StatusBadRequest)
|
||||||
|
assert.NotContains(t, anonymous.Body.String(), "audio content")
|
||||||
|
|
||||||
|
// Вошедший берёт короткоживущий токен файла и проходит по ссылке с ним:
|
||||||
|
// защищённый файл судится этим токеном, а не сессионной кукой.
|
||||||
|
tokenRequest := httptest.NewRequest(http.MethodPost, "/api/files/token", nil)
|
||||||
|
tokenRequest.Header.Set("Authorization", env.session)
|
||||||
|
tokenResponse := httptest.NewRecorder()
|
||||||
|
env.mux.ServeHTTP(tokenResponse, tokenRequest)
|
||||||
|
require.Equal(t, http.StatusOK, tokenResponse.Code, "токен файла не выдан: %s", tokenResponse.Body.String())
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(tokenResponse.Body.Bytes(), &payload))
|
||||||
|
require.NotEmpty(t, payload.Token)
|
||||||
|
|
||||||
|
withToken := httptest.NewRecorder()
|
||||||
|
env.mux.ServeHTTP(withToken, httptest.NewRequest(http.MethodGet, link+"?token="+payload.Token, nil))
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, withToken.Code,
|
||||||
|
"вошедший не получил файл: %d", withToken.Code)
|
||||||
|
assert.Contains(t, withToken.Body.String(), "audio content")
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/hook"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RefreshPath — адрес хранилища, которым сессия продлевает саму себя.
|
||||||
|
const RefreshPath = "/api/collections/users/auth-refresh"
|
||||||
|
|
||||||
|
// BlockSessionRefresh закрывает продление сессии.
|
||||||
|
//
|
||||||
|
// Хранилище выдаёт сессию продлеваемой: предъявитель значения меняет его на
|
||||||
|
// новое, с новым сроком, и делает это сколько угодно раз, никуда не входя. При
|
||||||
|
// живом продлении срок жизни сессии перестаёт что-либо значить, а вместе с ним
|
||||||
|
// перестаёт работать единственный канал, которым отзыв доступа у провайдера
|
||||||
|
// доходит до сервиса, — сервис после входа к провайдеру не обращается.
|
||||||
|
//
|
||||||
|
// Решение владельца от 2026-08-12: продление выключено, цена — вход раз в
|
||||||
|
// семь суток.
|
||||||
|
func BlockSessionRefresh() *hook.Handler[*core.RequestEvent] {
|
||||||
|
return &hook.Handler[*core.RequestEvent]{
|
||||||
|
Id: "transcriberBlockSessionRefresh",
|
||||||
|
Priority: apis.DefaultLoadAuthTokenMiddlewarePriority - 2,
|
||||||
|
Func: func(e *core.RequestEvent) error {
|
||||||
|
if e.Request.URL.Path == RefreshPath {
|
||||||
|
return e.JSON(http.StatusNotFound, map[string]string{
|
||||||
|
"error": "Продление сессии выключено",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.Next()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionFromCookie перекладывает значение куки сессии в заголовок, которым
|
||||||
|
// хранилище читает предъявленную сессию.
|
||||||
|
//
|
||||||
|
// Куки хранилище не читает вовсе — только заголовок `Authorization`. Браузер же
|
||||||
|
// сам заголовка не шлёт, а своей страницы со скриптом у сервиса нет, поэтому
|
||||||
|
// сессия предъявляется кукой, а способ проверки остаётся один.
|
||||||
|
//
|
||||||
|
// Предъявленный заголовок побеждает: иначе браузер с сессионной кукой получал
|
||||||
|
// бы на собственных адресах хранилища не то, что предъявил.
|
||||||
|
//
|
||||||
|
// Слой стоит раньше проверки токена: тот идёт с приоритетом
|
||||||
|
// DefaultLoadAuthTokenMiddlewarePriority и к этому моменту заголовок должен
|
||||||
|
// быть на месте.
|
||||||
|
func SessionFromCookie() *hook.Handler[*core.RequestEvent] {
|
||||||
|
return &hook.Handler[*core.RequestEvent]{
|
||||||
|
Id: "transcriberSessionFromCookie",
|
||||||
|
Priority: apis.DefaultLoadAuthTokenMiddlewarePriority - 1,
|
||||||
|
Func: func(e *core.RequestEvent) error {
|
||||||
|
if e.Request.Header.Get("Authorization") != "" {
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie, err := e.Request.Cookie(SessionCookieName)
|
||||||
|
if err != nil || cookie.Value == "" {
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Request.Header.Set("Authorization", cookie.Value)
|
||||||
|
|
||||||
|
return e.Next()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
@@ -44,6 +45,14 @@ type GetTranscribeJobResponse struct {
|
|||||||
// сохранены — публичный контракт API объявлен необратимым.
|
// сохранены — публичный контракт API объявлен необратимым.
|
||||||
func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) {
|
func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) {
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
|
|
||||||
|
// Оба адреса уходят за аутентификацию. Слой предъявления стоит перед
|
||||||
|
// проверкой и действует только здесь: собственная поверхность хранилища под
|
||||||
|
// него не подпадает, часть её защищена ровно тем, что браузер заголовка сам
|
||||||
|
// не шлёт.
|
||||||
|
api.Bind(SessionFromCookie())
|
||||||
|
api.Bind(apis.RequireAuth())
|
||||||
|
|
||||||
// Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись
|
// Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись
|
||||||
// раньше обработчика, без строки в журнале приёма. Приём размеру не судья,
|
// раньше обработчика, без строки в журнале приёма. Приём размеру не судья,
|
||||||
// поэтому предел тела равен потолку самой записи.
|
// поэтому предел тела равен потолку самой записи.
|
||||||
@@ -63,7 +72,14 @@ func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
job, err := h.trsService.CreateJobFromApi(file, header.Filename)
|
// Запись доехала целиком, поэтому задача заводится независимо от того,
|
||||||
|
// дождётся ли отправитель ответа: на контексте запроса приём терял бы
|
||||||
|
// полностью загруженную запись от одного обрыва соединения, а забрать
|
||||||
|
// результат он может и позже — по `GET /status/{id}`. Значения контекста
|
||||||
|
// (журнал запроса, сессия) при этом сохраняются, теряется только отмена.
|
||||||
|
ctx := context.WithoutCancel(e.Request.Context())
|
||||||
|
|
||||||
|
job, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Второй раз отказ не логируем: приём назван конвенцией логирующей
|
// Второй раз отказ не логируем: приём назван конвенцией логирующей
|
||||||
// границей и уже написал о нём. Транспорт переводит ошибку в ответ.
|
// границей и уже написал о нём. Транспорт переводит ошибку в ответ.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package http
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -22,6 +23,7 @@ import (
|
|||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
|
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
|
||||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||||
@@ -38,7 +40,12 @@ type stubMetaViewer struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *stubMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
|
func (m *stubMetaViewer) GetInfo(ctx context.Context, _ string) (*contract.AudioInfo, error) {
|
||||||
|
// Настоящий `ffprobe` заведён с контекстом и по отмене умирает; стаб,
|
||||||
|
// который контекст игнорирует, сделал бы проверку приёма неспособной упасть.
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
return nil, m.err
|
return nil, m.err
|
||||||
}
|
}
|
||||||
@@ -49,7 +56,7 @@ func (m *stubMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
|
|||||||
// этого файла её не зовёт.
|
// этого файла её не зовёт.
|
||||||
type stubConverter struct{}
|
type stubConverter struct{}
|
||||||
|
|
||||||
func (c *stubConverter) Convert(string, string) error { return nil }
|
func (c *stubConverter) Convert(context.Context, string, string) error { return nil }
|
||||||
|
|
||||||
// TestTgSender: приём по HTTP в Telegram не отвечает, но сервису отправитель нужен.
|
// TestTgSender: приём по HTTP в Telegram не отвечает, но сервису отправитель нужен.
|
||||||
type TestTgSender struct{}
|
type TestTgSender struct{}
|
||||||
@@ -70,6 +77,50 @@ type testEnv struct {
|
|||||||
handler *TranscribeHandler
|
handler *TranscribeHandler
|
||||||
app core.App
|
app core.App
|
||||||
journal *journalBuffer
|
journal *journalBuffer
|
||||||
|
// session — значение сессии вошедшего. Приём и опрос закрыты за
|
||||||
|
// аутентификацией, и проверка, судящая их по существу, обязана предъявить
|
||||||
|
// сессию ровно так же, как это делает браузер.
|
||||||
|
session string
|
||||||
|
// account — учётная запись, которой выдана сессия. Нужна проверкам выхода.
|
||||||
|
account *core.Record
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve шлёт запрос от имени вошедшего: сессия предъявляется кукой — тем же
|
||||||
|
// способом, каким её предъявляет браузер. Заголовок проверки не ставят: куку в
|
||||||
|
// него перекладывает слой предъявления, и подмена его здесь означала бы проверку
|
||||||
|
// не той цепочки.
|
||||||
|
//
|
||||||
|
// Проверки, судящие отказ без сессии, зовут `mux` напрямую.
|
||||||
|
func (e *testEnv) serve(w http.ResponseWriter, req *http.Request) {
|
||||||
|
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: e.session})
|
||||||
|
e.mux.ServeHTTP(w, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestAccount заводит учётную запись и выдаёт ей сессию.
|
||||||
|
//
|
||||||
|
// Запись создаётся прямым сохранением, а не запросом к API: заводить её
|
||||||
|
// запросом больше нельзя — создание закрыто шагом схемы, и в этом весь смысл
|
||||||
|
// изменения. Прямое сохранение идёт мимо правил доступа так же, как идёт вход,
|
||||||
|
// когда учётную запись заводит само хранилище.
|
||||||
|
func newTestAccount(t *testing.T, app core.App) (*core.Record, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
users, err := app.FindCollectionByNameOrId("users")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
record := core.NewRecord(users)
|
||||||
|
record.Set("email", "person@example.com")
|
||||||
|
record.Set("verified", true)
|
||||||
|
// Случайный пароль ставит и само хранилище, когда заводит запись по входу у
|
||||||
|
// провайдера: запись auth-коллекции без пароля не сохраняется, а войти по
|
||||||
|
// нему всё равно нельзя — парольный вход выключен шагом схемы.
|
||||||
|
record.SetRandomPassword()
|
||||||
|
require.NoError(t, app.Save(record))
|
||||||
|
|
||||||
|
token, err := record.NewAuthToken()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return record, token
|
||||||
}
|
}
|
||||||
|
|
||||||
// journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на
|
// journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на
|
||||||
@@ -147,7 +198,16 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
|||||||
mux, err := r.BuildMux()
|
mux, err := r.BuildMux()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
return &testEnv{mux: mux, handler: handler, app: app, journal: journal}
|
account, session := newTestAccount(t, app)
|
||||||
|
|
||||||
|
return &testEnv{
|
||||||
|
mux: mux,
|
||||||
|
handler: handler,
|
||||||
|
app: app,
|
||||||
|
journal: journal,
|
||||||
|
session: session,
|
||||||
|
account: account,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// createMultipartRequest собирает запрос из имени и содержимого. Файла на диске
|
// createMultipartRequest собирает запрос из имени и содержимого. Файла на диске
|
||||||
@@ -179,7 +239,7 @@ func createMultipartRequestWithField(t *testing.T, field, fileName string, conte
|
|||||||
|
|
||||||
// storedFileNames отдаёт имена, под которыми файлы легли в хранилище.
|
// storedFileNames отдаёт имена, под которыми файлы легли в хранилище.
|
||||||
func storedFileNames(t *testing.T, env *testEnv) []string {
|
func storedFileNames(t *testing.T, env *testEnv) []string {
|
||||||
records, err := env.app.FindAllRecords(pbrepo.FilesCollection)
|
records, err := env.app.FindAllRecords(migrations.FilesCollection)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var names []string
|
var names []string
|
||||||
@@ -191,14 +251,14 @@ func storedFileNames(t *testing.T, env *testEnv) []string {
|
|||||||
|
|
||||||
// countFiles считает записи о файлах.
|
// countFiles считает записи о файлах.
|
||||||
func countFiles(t *testing.T, env *testEnv) int {
|
func countFiles(t *testing.T, env *testEnv) int {
|
||||||
records, err := env.app.FindAllRecords(pbrepo.FilesCollection)
|
records, err := env.app.FindAllRecords(migrations.FilesCollection)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
return len(records)
|
return len(records)
|
||||||
}
|
}
|
||||||
|
|
||||||
// countJobs считает заведённые задачи расшифровки.
|
// countJobs считает заведённые задачи расшифровки.
|
||||||
func countJobs(t *testing.T, env *testEnv) int {
|
func countJobs(t *testing.T, env *testEnv) int {
|
||||||
records, err := env.app.FindAllRecords(pbrepo.JobsCollection)
|
records, err := env.app.FindAllRecords(migrations.JobsCollection)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
return len(records)
|
return len(records)
|
||||||
}
|
}
|
||||||
@@ -241,7 +301,7 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
|
|||||||
req := createMultipartRequest(t, "sample.m4a", content)
|
req := createMultipartRequest(t, "sample.m4a", content)
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
env.mux.ServeHTTP(w, req)
|
env.serve(w, req)
|
||||||
|
|
||||||
require.Equal(t, http.StatusCreated, w.Code)
|
require.Equal(t, http.StatusCreated, w.Code)
|
||||||
|
|
||||||
@@ -304,7 +364,7 @@ func TestCreateTranscribeJob_NoFile(t *testing.T) {
|
|||||||
env := setupTestEnv(t, readableMetaViewer())
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
env.mux.ServeHTTP(w, tc.req(t))
|
env.serve(w, tc.req(t))
|
||||||
|
|
||||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
|
||||||
@@ -327,7 +387,7 @@ func TestCreateTranscribeJob_EmptyFile(t *testing.T) {
|
|||||||
req := createMultipartRequest(t, "empty.m4a", nil)
|
req := createMultipartRequest(t, "empty.m4a", nil)
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
env.mux.ServeHTTP(w, req)
|
env.serve(w, req)
|
||||||
|
|
||||||
require.Equal(t, http.StatusCreated, w.Code)
|
require.Equal(t, http.StatusCreated, w.Code)
|
||||||
|
|
||||||
@@ -374,7 +434,7 @@ func TestCreateTranscribeJob_DifferentFileExtensions(t *testing.T) {
|
|||||||
req := createMultipartRequest(t, tc.fileName, []byte("запись"))
|
req := createMultipartRequest(t, tc.fileName, []byte("запись"))
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
env.mux.ServeHTTP(w, req)
|
env.serve(w, req)
|
||||||
|
|
||||||
require.Equal(t, http.StatusCreated, w.Code)
|
require.Equal(t, http.StatusCreated, w.Code)
|
||||||
|
|
||||||
@@ -400,7 +460,7 @@ func TestCreateTranscribeJob_SenderFileNameNotStored(t *testing.T) {
|
|||||||
req := createMultipartRequest(t, "секретное-слово.mp3", []byte("запись"))
|
req := createMultipartRequest(t, "секретное-слово.mp3", []byte("запись"))
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
env.mux.ServeHTTP(w, req)
|
env.serve(w, req)
|
||||||
|
|
||||||
require.Equal(t, http.StatusCreated, w.Code)
|
require.Equal(t, http.StatusCreated, w.Code)
|
||||||
|
|
||||||
@@ -419,7 +479,7 @@ func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) {
|
|||||||
req := createMultipartRequest(t, "broken.m4a", []byte("не запись вовсе"))
|
req := createMultipartRequest(t, "broken.m4a", []byte("не запись вовсе"))
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
env.mux.ServeHTTP(w, req)
|
env.serve(w, req)
|
||||||
|
|
||||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
|
||||||
@@ -459,7 +519,7 @@ func TestCreateTranscribeJob_SenderFileNameNotLogged(t *testing.T) {
|
|||||||
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("запись"))
|
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("запись"))
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
env.mux.ServeHTTP(w, req)
|
env.serve(w, req)
|
||||||
|
|
||||||
require.Equal(t, http.StatusCreated, w.Code)
|
require.Equal(t, http.StatusCreated, w.Code)
|
||||||
|
|
||||||
@@ -482,7 +542,7 @@ func TestCreateTranscribeJob_SenderFileNameNotLoggedOnFailure(t *testing.T) {
|
|||||||
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("не запись вовсе"))
|
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("не запись вовсе"))
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
env.mux.ServeHTTP(w, req)
|
env.serve(w, req)
|
||||||
|
|
||||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
|
||||||
@@ -507,7 +567,7 @@ func TestCreateTranscribeJob_StorageFileNameNotLogged(t *testing.T) {
|
|||||||
req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
|
req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
env.mux.ServeHTTP(w, req)
|
env.serve(w, req)
|
||||||
|
|
||||||
require.Equal(t, http.StatusCreated, w.Code)
|
require.Equal(t, http.StatusCreated, w.Code)
|
||||||
|
|
||||||
@@ -528,7 +588,7 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
|
|||||||
req := createMultipartRequest(t, "sample.mp3", content)
|
req := createMultipartRequest(t, "sample.mp3", content)
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
env.mux.ServeHTTP(w, req)
|
env.serve(w, req)
|
||||||
|
|
||||||
require.Equal(t, http.StatusCreated, w.Code)
|
require.Equal(t, http.StatusCreated, w.Code)
|
||||||
|
|
||||||
@@ -593,7 +653,7 @@ func TestCreateTranscribeJob_MetricLabelCarriesNoSenderName(t *testing.T) {
|
|||||||
req := createMultipartRequest(t, "sample."+senderNameMarker, []byte("запись"))
|
req := createMultipartRequest(t, "sample."+senderNameMarker, []byte("запись"))
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
env.mux.ServeHTTP(w, req)
|
env.serve(w, req)
|
||||||
|
|
||||||
require.Equal(t, http.StatusCreated, w.Code)
|
require.Equal(t, http.StatusCreated, w.Code)
|
||||||
|
|
||||||
@@ -622,7 +682,7 @@ func TestGetTranscribeJobStatus_Success(t *testing.T) {
|
|||||||
req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)
|
req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
env.mux.ServeHTTP(w, req)
|
env.serve(w, req)
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
@@ -642,7 +702,7 @@ func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) {
|
|||||||
req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)
|
req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
env.mux.ServeHTTP(w, req)
|
env.serve(w, req)
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
@@ -664,7 +724,7 @@ func TestGetTranscribeJobStatus_NotFound(t *testing.T) {
|
|||||||
req := httptest.NewRequest("GET", "/api/status/non-existent-id", http.NoBody)
|
req := httptest.NewRequest("GET", "/api/status/non-existent-id", http.NoBody)
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
env.mux.ServeHTTP(w, req)
|
env.serve(w, req)
|
||||||
|
|
||||||
require.Equal(t, http.StatusNotFound, w.Code)
|
require.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
|
||||||
@@ -673,3 +733,27 @@ func TestGetTranscribeJobStatus_NotFound(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, "Job not found", response["error"])
|
assert.Equal(t, "Job not found", response["error"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Отправитель, у которого соединение оборвалось после полной загрузки, задачу
|
||||||
|
// всё равно получает: запись доехала целиком, а результат он заберёт позже по
|
||||||
|
// `GET /status/{id}`. Приём на контексте запроса терял бы такую запись молча —
|
||||||
|
// решение владельца от 2026-08-13.
|
||||||
|
func TestAcceptedRecordSurvivesSenderDisconnect(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
req := createMultipartRequest(t, "sample.m4a", []byte("аудио"))
|
||||||
|
// Так выглядит ушедший отправитель: контекст запроса отменяется сервером,
|
||||||
|
// когда соединение закрылось.
|
||||||
|
ctx, cancel := context.WithCancel(req.Context())
|
||||||
|
cancel()
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
env.serve(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusCreated, w.Result().StatusCode, "тело ответа: %s", w.Body.String())
|
||||||
|
|
||||||
|
jobs, err := env.app.FindAllRecords(migrations.JobsCollection)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, jobs, 1, "задача заведена, несмотря на ушедшего отправителя")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package tg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// probeClient подменяет клиента бота и запоминает, кого спрашивали. Через него
|
||||||
|
// проверяется стык: скачивание обязано идти клиентом бота, а не общим
|
||||||
|
// `http.DefaultClient` — чистку отказа от адреса с токеном несёт именно клиент
|
||||||
|
// (`internal/adapter/telegram`). Подмена на общий клиент правил гейта не
|
||||||
|
// нарушает, поэтому сторожить стык может только проверка.
|
||||||
|
type probeClient struct {
|
||||||
|
seen []string
|
||||||
|
download func(w *probeResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
type probeResponse struct {
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *probeClient) Do(req *http.Request) (*http.Response, error) {
|
||||||
|
c.seen = append(c.seen, req.URL.Path)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.Contains(req.URL.Path, "/getMe"):
|
||||||
|
return jsonResponse(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"probe_bot"}}`), nil
|
||||||
|
case strings.Contains(req.URL.Path, "/getFile"):
|
||||||
|
return jsonResponse(`{"ok":true,"result":{"file_id":"x","file_path":"voice/file_1.ogg"}}`), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
answer := &probeResponse{status: http.StatusOK, body: "аудио"}
|
||||||
|
if c.download != nil {
|
||||||
|
c.download(answer)
|
||||||
|
}
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: answer.status,
|
||||||
|
Body: io.NopCloser(strings.NewReader(answer.body)),
|
||||||
|
Header: make(http.Header),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonResponse(body string) *http.Response {
|
||||||
|
header := make(http.Header)
|
||||||
|
header.Set("Content-Type", "application/json")
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
Header: header,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProbeController(t *testing.T, client *probeClient) *TelegramController {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Клиент подставной, поэтому адрес значения не имеет — важно лишь, что
|
||||||
|
// библиотека соберёт из него разбираемый URL.
|
||||||
|
bot, err := tgbotapi.NewBotAPIWithClient(
|
||||||
|
"7654321:AAHsecretBOTtokenVALUE",
|
||||||
|
"http://telegram.probe/bot%s/%s",
|
||||||
|
client,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return &TelegramController{
|
||||||
|
bot: bot,
|
||||||
|
logger: slog.New(slog.DiscardHandler),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Скачивание идёт клиентом бота: иначе отказ пойдёт мимо чистки и унесёт токен.
|
||||||
|
func TestDownloadGoesThroughBotClient(t *testing.T) {
|
||||||
|
client := &probeClient{}
|
||||||
|
controller := newProbeController(t, client)
|
||||||
|
|
||||||
|
body, name, err := controller.downloadAudioFile(t.Context(), "file-id")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := body.Close(); err != nil {
|
||||||
|
t.Errorf("не удалось закрыть тело: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, "voice/file_1.ogg", name)
|
||||||
|
require.Len(t, client.seen, 3, "клиент бота видел все обращения: getMe, getFile и скачивание")
|
||||||
|
assert.Contains(t, client.seen[2], "voice/file_1.ogg", "скачивание ушло мимо клиента бота")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отказ выдачи файла — это не запись: тело такого ответа не должно доехать до
|
||||||
|
// хранилища и умереть на `ffprobe`, уведя диагностику к чужой причине.
|
||||||
|
func TestDownloadRejectsNonOKStatus(t *testing.T) {
|
||||||
|
client := &probeClient{download: func(w *probeResponse) {
|
||||||
|
w.status = http.StatusUnauthorized
|
||||||
|
w.body = `{"ok":false,"error_code":401,"description":"Unauthorized"}`
|
||||||
|
}}
|
||||||
|
controller := newProbeController(t, client)
|
||||||
|
|
||||||
|
body, _, err := controller.downloadAudioFile(t.Context(), "file-id")
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, body, "тело отказа наружу не отдают")
|
||||||
|
assert.Contains(t, err.Error(), "401", "код ответа назван — по нему видно, что отказал Telegram")
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package tg
|
package tg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -8,6 +10,12 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
// Транспорт знает адаптер Telegram ровно ради единой точки чистки отказа:
|
||||||
|
// второй экземпляр той же функции здесь был бы вторым способом делать одно
|
||||||
|
// и то же, а секрет в журнале — необратим. Направление «транспорт не знает
|
||||||
|
// адаптера» правилом не держится и уже нарушено HTTP-поверхностью
|
||||||
|
// (docs/conventions/go-linters.md, «Что остаётся прозой»).
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
@@ -25,25 +33,22 @@ type TelegramController struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TelegramConfig struct {
|
type TelegramConfig struct {
|
||||||
BotToken string
|
|
||||||
UpdateTimeout int
|
UpdateTimeout int
|
||||||
UserWhiteList []string
|
UserWhiteList []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewTelegramController принимает готового клиента, а не токен: клиента заводит
|
||||||
|
// единая точка `internal/adapter/telegram`, и только её отказ не несёт секрета.
|
||||||
|
// Токен сюда не приезжает вовсе — значит, и утечь отсюда ему неоткуда.
|
||||||
func NewTelegramController(
|
func NewTelegramController(
|
||||||
config TelegramConfig,
|
config TelegramConfig,
|
||||||
|
bot *tgbotapi.BotAPI,
|
||||||
transcribeService *service.TranscribeService,
|
transcribeService *service.TranscribeService,
|
||||||
jobRepo contract.TranscriptJobRepository,
|
jobRepo contract.TranscriptJobRepository,
|
||||||
logger *slog.Logger,
|
logger *slog.Logger,
|
||||||
) (*TelegramController, error) {
|
) (*TelegramController, error) {
|
||||||
botToken := config.BotToken
|
if bot == nil {
|
||||||
if botToken == "" {
|
return nil, errors.New("telegram bot is not created")
|
||||||
return nil, &EmptyBotTokenError{}
|
|
||||||
}
|
|
||||||
|
|
||||||
bot, err := tgbotapi.NewBotAPI(botToken)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
controller := &TelegramController{
|
controller := &TelegramController{
|
||||||
@@ -58,7 +63,11 @@ func NewTelegramController(
|
|||||||
return controller, nil
|
return controller, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TelegramController) Start() {
|
// Start принимает контекст жизни процесса и отдаёт его каждому обработчику:
|
||||||
|
// скачивание записи и разбор её метаданных — работа с внешним собеседником, и
|
||||||
|
// остановка сервиса обязана до неё доходить. Приём обновлений контекстом не
|
||||||
|
// правится: его прекращает Stop.
|
||||||
|
func (c *TelegramController) Start(ctx context.Context) {
|
||||||
c.logger.Info("Telegram bot started", "username", c.bot.Self.UserName)
|
c.logger.Info("Telegram bot started", "username", c.bot.Self.UserName)
|
||||||
|
|
||||||
u := tgbotapi.NewUpdate(0)
|
u := tgbotapi.NewUpdate(0)
|
||||||
@@ -94,11 +103,11 @@ func (c *TelegramController) Start() {
|
|||||||
|
|
||||||
// Handle audio messages and files
|
// Handle audio messages and files
|
||||||
if update.Message.Audio != nil {
|
if update.Message.Audio != nil {
|
||||||
c.handleAudioMessage(update.Message)
|
c.handleAudioMessage(ctx, update.Message)
|
||||||
} else if update.Message.Voice != nil {
|
} else if update.Message.Voice != nil {
|
||||||
c.handleVoiceMessage(update.Message)
|
c.handleVoiceMessage(ctx, update.Message)
|
||||||
} else if update.Message.Document != nil {
|
} else if update.Message.Document != nil {
|
||||||
c.handleDocumentMessage(update.Message)
|
c.handleDocumentMessage(ctx, update.Message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,7 +157,7 @@ func (c *TelegramController) handleHelpCommand(message *tgbotapi.Message) {
|
|||||||
c.send(msg)
|
c.send(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TelegramController) handleAudioMessage(message *tgbotapi.Message) {
|
func (c *TelegramController) handleAudioMessage(ctx context.Context, message *tgbotapi.Message) {
|
||||||
// Отправляем сообщение о начале обработки
|
// Отправляем сообщение о начале обработки
|
||||||
progressMsg := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю аудиофайл...")
|
progressMsg := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю аудиофайл...")
|
||||||
progressMsg.ReplyToMessageID = message.MessageID
|
progressMsg.ReplyToMessageID = message.MessageID
|
||||||
@@ -159,7 +168,7 @@ func (c *TelegramController) handleAudioMessage(message *tgbotapi.Message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Скачиваем файл
|
// Скачиваем файл
|
||||||
fileReader, fileName, err := c.downloadAudioFile(message.Audio.FileID)
|
fileReader, fileName, err := c.downloadAudioFile(ctx, message.Audio.FileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logger.Error("Failed to download audio file", "error", err)
|
c.logger.Error("Failed to download audio file", "error", err)
|
||||||
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании аудиофайла. Попробуйте еще раз.")
|
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании аудиофайла. Попробуйте еще раз.")
|
||||||
@@ -169,7 +178,7 @@ func (c *TelegramController) handleAudioMessage(message *tgbotapi.Message) {
|
|||||||
defer fileReader.Close()
|
defer fileReader.Close()
|
||||||
|
|
||||||
// Обрабатываем файл
|
// Обрабатываем файл
|
||||||
job, err := c.transcribeService.CreateJobFromTelegram(fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
|
job, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logger.Error("Failed to create transcribe job", "error", err)
|
c.logger.Error("Failed to create transcribe job", "error", err)
|
||||||
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
|
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
|
||||||
@@ -183,7 +192,7 @@ func (c *TelegramController) handleAudioMessage(message *tgbotapi.Message) {
|
|||||||
c.send(successMsg)
|
c.send(successMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TelegramController) handleVoiceMessage(message *tgbotapi.Message) {
|
func (c *TelegramController) handleVoiceMessage(ctx context.Context, message *tgbotapi.Message) {
|
||||||
// Отправляем сообщение о начале обработки
|
// Отправляем сообщение о начале обработки
|
||||||
progressMsg := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю голосовое сообщение...")
|
progressMsg := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю голосовое сообщение...")
|
||||||
progressMsg.ReplyToMessageID = message.MessageID
|
progressMsg.ReplyToMessageID = message.MessageID
|
||||||
@@ -194,7 +203,7 @@ func (c *TelegramController) handleVoiceMessage(message *tgbotapi.Message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Скачиваем файл
|
// Скачиваем файл
|
||||||
fileReader, fileName, err := c.downloadAudioFile(message.Voice.FileID)
|
fileReader, fileName, err := c.downloadAudioFile(ctx, message.Voice.FileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logger.Error("Failed to download voice file", "error", err)
|
c.logger.Error("Failed to download voice file", "error", err)
|
||||||
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании голосового сообщения. Попробуйте еще раз.")
|
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании голосового сообщения. Попробуйте еще раз.")
|
||||||
@@ -204,7 +213,7 @@ func (c *TelegramController) handleVoiceMessage(message *tgbotapi.Message) {
|
|||||||
defer fileReader.Close()
|
defer fileReader.Close()
|
||||||
|
|
||||||
// Обрабатываем файл
|
// Обрабатываем файл
|
||||||
job, err := c.transcribeService.CreateJobFromTelegram(fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
|
job, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logger.Error("Failed to create transcribe job", "error", err)
|
c.logger.Error("Failed to create transcribe job", "error", err)
|
||||||
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
|
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
|
||||||
@@ -218,7 +227,7 @@ func (c *TelegramController) handleVoiceMessage(message *tgbotapi.Message) {
|
|||||||
c.send(successMsg)
|
c.send(successMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TelegramController) handleDocumentMessage(message *tgbotapi.Message) {
|
func (c *TelegramController) handleDocumentMessage(ctx context.Context, message *tgbotapi.Message) {
|
||||||
// Проверяем, является ли документ аудиофайлом
|
// Проверяем, является ли документ аудиофайлом
|
||||||
if !c.isAudioDocument(message.Document) {
|
if !c.isAudioDocument(message.Document) {
|
||||||
return
|
return
|
||||||
@@ -234,7 +243,7 @@ func (c *TelegramController) handleDocumentMessage(message *tgbotapi.Message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Скачиваем файл
|
// Скачиваем файл
|
||||||
fileReader, fileName, err := c.downloadAudioFile(message.Document.FileID)
|
fileReader, fileName, err := c.downloadAudioFile(ctx, message.Document.FileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logger.Error("Failed to download document file", "error", err)
|
c.logger.Error("Failed to download document file", "error", err)
|
||||||
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании аудиофайла. Попробуйте еще раз.")
|
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании аудиофайла. Попробуйте еще раз.")
|
||||||
@@ -244,7 +253,7 @@ func (c *TelegramController) handleDocumentMessage(message *tgbotapi.Message) {
|
|||||||
defer fileReader.Close()
|
defer fileReader.Close()
|
||||||
|
|
||||||
// Обрабатываем файл
|
// Обрабатываем файл
|
||||||
job, err := c.transcribeService.CreateJobFromTelegram(fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
|
job, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logger.Error("Failed to create transcribe job", "error", err)
|
c.logger.Error("Failed to create transcribe job", "error", err)
|
||||||
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
|
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
|
||||||
@@ -258,20 +267,41 @@ func (c *TelegramController) handleDocumentMessage(message *tgbotapi.Message) {
|
|||||||
c.send(successMsg)
|
c.send(successMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TelegramController) downloadAudioFile(fileID string) (io.ReadCloser, string, error) {
|
func (c *TelegramController) downloadAudioFile(ctx context.Context, fileID string) (io.ReadCloser, string, error) {
|
||||||
// Получаем информацию о файле
|
// Получаем информацию о файле
|
||||||
file, err := c.bot.GetFile(tgbotapi.FileConfig{FileID: fileID})
|
file, err := c.bot.GetFile(tgbotapi.FileConfig{FileID: fileID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("failed to get file info: %w", err)
|
return nil, "", fmt.Errorf("failed to get file info: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Скачиваем файл
|
// Скачиваем файл. Запрос заводится с контекстом: скачивание шестичасовой
|
||||||
|
// записи иначе продолжается и после остановки сервиса, а ссылка на файл
|
||||||
|
// несёт токен бота — держать её живой дольше нужного незачем.
|
||||||
|
//
|
||||||
|
// Клиент берётся у бота, а не `http.DefaultClient`: у бота он свой, и его
|
||||||
|
// отказ уже не несёт адреса (`internal/adapter/telegram`, единая точка).
|
||||||
fileURL := file.Link(c.bot.Token)
|
fileURL := file.Link(c.bot.Token)
|
||||||
resp, err := http.Get(fileURL)
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("failed to build download request: %w", telegram.WithoutURL(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.bot.Client.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("failed to download file: %w", err)
|
return nil, "", fmt.Errorf("failed to download file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Отказ выдачи файла — это не запись. Без проверки телом «записи» станет
|
||||||
|
// JSON вида `{"ok":false,…}`: он доедет до хранилища, ляжет рабочей копией
|
||||||
|
// и умрёт на `ffprobe`, а отправитель получит жалобу на свой файл вместо
|
||||||
|
// правды о протухшей ссылке.
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
if err := resp.Body.Close(); err != nil {
|
||||||
|
c.logger.Error("Failed to close download response", "error", err)
|
||||||
|
}
|
||||||
|
return nil, "", fmt.Errorf("failed to download file: unexpected status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
// Получаем имя файла из URL
|
// Получаем имя файла из URL
|
||||||
fileName := file.FilePath
|
fileName := file.FilePath
|
||||||
if fileName == "" {
|
if fileName == "" {
|
||||||
|
|||||||
@@ -17,21 +17,31 @@ type Worker interface {
|
|||||||
Name() string
|
Name() string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pollInterval — пауза между прогонами шага. Полем, а не константой по месту:
|
||||||
|
// проверке нужен второй прогон, чтобы остановить воркер **после** того, как он
|
||||||
|
// рассудил об исходе первого. Отменять контекст изнутри шага она не может —
|
||||||
|
// отменённый контекст теперь и значит «нас остановили».
|
||||||
|
const pollInterval = time.Second
|
||||||
|
|
||||||
type CallbackWorker struct {
|
type CallbackWorker struct {
|
||||||
name string
|
name string
|
||||||
f func() error
|
// Шаг принимает контекст воркера: остановка обязана доходить до чужой
|
||||||
logger *slog.Logger
|
// работы, которую шаг завёл, а не только прерывать цикл между шагами.
|
||||||
|
f func(ctx context.Context) error
|
||||||
|
logger *slog.Logger
|
||||||
|
interval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCallbackWorker(name string, f func() error, logger *slog.Logger) *CallbackWorker {
|
func NewCallbackWorker(name string, f func(ctx context.Context) error, logger *slog.Logger) *CallbackWorker {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
logger = slog.Default()
|
logger = slog.Default()
|
||||||
}
|
}
|
||||||
|
|
||||||
return &CallbackWorker{
|
return &CallbackWorker{
|
||||||
name: name,
|
name: name,
|
||||||
f: f,
|
f: f,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
|
interval: pollInterval,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,25 +58,34 @@ func (w *CallbackWorker) Start(ctx context.Context) {
|
|||||||
w.logger.Info("Worker received shutdown signal", "worker", w.Name())
|
w.logger.Info("Worker received shutdown signal", "worker", w.Name())
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
err := w.f()
|
err := w.f(ctx)
|
||||||
// Признак узнаётся по смыслу, а не по точной форме значения:
|
// Признак узнаётся по смыслу, а не по точной форме значения:
|
||||||
// приведение типа видело только вершину цепочки и сломалось бы от
|
// приведение типа видело только вершину цепочки и сломалось бы от
|
||||||
// первой же обёртки `%w`, которая в проекте — умолчание.
|
// первой же обёртки `%w`, которая в проекте — умолчание.
|
||||||
var noop *contract.NoopJobError
|
var noop *contract.NoopJobError
|
||||||
isNoop := errors.As(err, &noop)
|
isNoop := errors.As(err, &noop)
|
||||||
if !isNoop {
|
// Остановка — не отказ шага: контекст отменили мы сами. Считать её
|
||||||
|
// в метрику и писать владельцу «Worker error» значит красить каждую
|
||||||
|
// выкладку как поломку — по тому же доводу, по которому не считается
|
||||||
|
// `NoopJobError`. Судит контекст, а не текст ошибки: убитый процесс
|
||||||
|
// отдаёт «signal: killed», и `errors.Is` его с отменой не свяжет.
|
||||||
|
stopped := err != nil && !isNoop && ctx.Err() != nil
|
||||||
|
if !isNoop && !stopped {
|
||||||
metrics.WorkerJobCounter.WithLabelValues(w.Name(), strconv.FormatBool(err != nil)).Inc()
|
metrics.WorkerJobCounter.WithLabelValues(w.Name(), strconv.FormatBool(err != nil)).Inc()
|
||||||
}
|
}
|
||||||
if err != nil && !isNoop {
|
if err != nil && !isNoop && !stopped {
|
||||||
w.logger.Error("Worker error", "worker", w.Name(), "error", err)
|
w.logger.Error("Worker error", "worker", w.Name(), "error", err)
|
||||||
}
|
}
|
||||||
|
if stopped {
|
||||||
|
w.logger.Info("Worker step interrupted by shutdown", "worker", w.Name())
|
||||||
|
}
|
||||||
|
|
||||||
// Ждем 1 секунду перед следующей итерацией
|
// Ждем перед следующей итерацией
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
w.logger.Info("Worker received shutdown signal during sleep", "worker", w.Name())
|
w.logger.Info("Worker received shutdown signal during sleep", "worker", w.Name())
|
||||||
return
|
return
|
||||||
case <-time.After(1 * time.Second):
|
case <-time.After(w.interval):
|
||||||
// Продолжаем работу
|
// Продолжаем работу
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,10 +40,13 @@ func (b *journalBuffer) String() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runOnce прогоняет воркер ровно один раз и возвращает журнал этого прогона.
|
// runOnce прогоняет воркер ровно один раз и возвращает журнал этого прогона.
|
||||||
// Цикл воркера бесконечен и спит секунду между прогонами, поэтому контекст
|
//
|
||||||
// отменяется сразу после первого вызова работы: ждать второго прогона нечего, а
|
// Воркер останавливает **второй** прогон, а не первый: отменённый контекст
|
||||||
// секунда сна на проверку — цена ни за что.
|
// теперь и значит «нас остановили», и отмена изнутри первого шага сделала бы
|
||||||
func runOnce(t *testing.T, name string, work func() error) string {
|
// его исход неотличимым от остановки — проверка судила бы не то, что заявляет.
|
||||||
|
// Пауза между прогонами на время проверки укорочена до миллисекунды: ждать
|
||||||
|
// секунду ради второго вызова незачем.
|
||||||
|
func runOnce(t *testing.T, name string, work func(ctx context.Context) error) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
journal := &journalBuffer{}
|
journal := &journalBuffer{}
|
||||||
@@ -54,15 +57,21 @@ func runOnce(t *testing.T, name string, work func() error) string {
|
|||||||
|
|
||||||
var once sync.Once
|
var once sync.Once
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
|
calls := 0
|
||||||
|
|
||||||
w := NewCallbackWorker(name, func() error {
|
w := NewCallbackWorker(name, func(ctx context.Context) error {
|
||||||
err := work()
|
calls++
|
||||||
once.Do(func() {
|
if calls > 1 {
|
||||||
cancel()
|
// Первый прогон уже рассужен: журнал написан, счётчик сдвинут.
|
||||||
close(done)
|
once.Do(func() {
|
||||||
})
|
cancel()
|
||||||
return err
|
close(done)
|
||||||
|
})
|
||||||
|
return &contract.NoopJobError{State: "stopping"}
|
||||||
|
}
|
||||||
|
return work(ctx)
|
||||||
}, logger)
|
}, logger)
|
||||||
|
w.interval = time.Millisecond
|
||||||
|
|
||||||
finished := make(chan struct{})
|
finished := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
@@ -147,7 +156,7 @@ func TestWrappedNoopIsNotAFailure(t *testing.T) {
|
|||||||
before := jobCount(t, name, "false")
|
before := jobCount(t, name, "false")
|
||||||
beforeErr := jobCount(t, name, "true")
|
beforeErr := jobCount(t, name, "true")
|
||||||
|
|
||||||
journal := runOnce(t, name, func() error {
|
journal := runOnce(t, name, func(context.Context) error {
|
||||||
return fmt.Errorf("find and acquire job: %w", &contract.NoopJobError{State: "created"})
|
return fmt.Errorf("find and acquire job: %w", &contract.NoopJobError{State: "created"})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -171,7 +180,7 @@ func TestFailureIsLoggedAndCounted(t *testing.T) {
|
|||||||
|
|
||||||
before := jobCount(t, name, "true")
|
before := jobCount(t, name, "true")
|
||||||
|
|
||||||
journal := runOnce(t, name, func() error {
|
journal := runOnce(t, name, func(context.Context) error {
|
||||||
return errors.New("database is gone")
|
return errors.New("database is gone")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -191,7 +200,7 @@ func TestSuccessIsCounted(t *testing.T) {
|
|||||||
|
|
||||||
before := jobCount(t, name, "false")
|
before := jobCount(t, name, "false")
|
||||||
|
|
||||||
journal := runOnce(t, name, func() error {
|
journal := runOnce(t, name, func(context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -202,3 +211,50 @@ func TestSuccessIsCounted(t *testing.T) {
|
|||||||
t.Errorf("успешный прогон записан отказом: журнал %q", journal)
|
t.Errorf("успешный прогон записан отказом: журнал %q", journal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Остановка сервиса — не отказ шага: контекст отменили мы сами. Без этой
|
||||||
|
// развилки каждая выкладка красит журнал владельца отказами и накручивает
|
||||||
|
// счётчик сбоев, которых не было, — тот же довод, по которому не считается
|
||||||
|
// `NoopJobError`. Судит контекст, а не текст ошибки: убитый по контексту
|
||||||
|
// процесс отдаёт «signal: killed», и `errors.Is` его с отменой не свяжет.
|
||||||
|
func TestShutdownIsNotAFailure(t *testing.T) {
|
||||||
|
const name = "stopped_worker"
|
||||||
|
|
||||||
|
beforeErr := jobCount(t, name, "true")
|
||||||
|
beforeOk := jobCount(t, name, "false")
|
||||||
|
|
||||||
|
journal := &journalBuffer{}
|
||||||
|
logger := slog.New(slog.NewTextHandler(journal, nil))
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
w := NewCallbackWorker(name, func(context.Context) error {
|
||||||
|
// Так выглядит шаг, которого застала остановка.
|
||||||
|
cancel()
|
||||||
|
return errors.New("ffmpeg conversion failed: signal: killed")
|
||||||
|
}, logger)
|
||||||
|
w.interval = time.Millisecond
|
||||||
|
|
||||||
|
finished := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
w.Start(ctx)
|
||||||
|
close(finished)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-finished:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("воркер не остановился по отмене контекста")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := journal.String(); strings.Contains(got, "Worker error") {
|
||||||
|
t.Errorf("остановка записана отказом: журнал %q", got)
|
||||||
|
}
|
||||||
|
if got := jobCount(t, name, "true"); got != beforeErr {
|
||||||
|
t.Errorf("остановка засчитана отказом: было %v, стало %v", beforeErr, got)
|
||||||
|
}
|
||||||
|
if got := jobCount(t, name, "false"); got != beforeOk {
|
||||||
|
t.Errorf("остановка засчитана успешным прогоном: было %v, стало %v", beforeOk, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package entity
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TranscribeJob struct {
|
type TranscribeJob struct {
|
||||||
@@ -52,13 +54,13 @@ func (j *TranscribeJob) MoveToState(state string) {
|
|||||||
// именно отказавшие: иначе задача, прошедшая конвейер целиком, накопила бы
|
// именно отказавшие: иначе задача, прошедшая конвейер целиком, накопила бы
|
||||||
// их поштучно и умерла бы здоровой.
|
// их поштучно и умерла бы здоровой.
|
||||||
j.Attempts = 0
|
j.Attempts = 0
|
||||||
j.UpdatedAt = time.Now()
|
j.UpdatedAt = clock.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *TranscribeJob) MoveToStateAndDelay(state string, delay *time.Time) {
|
func (j *TranscribeJob) MoveToStateAndDelay(state string, delay *time.Time) {
|
||||||
j.MoveToState(state)
|
j.MoveToState(state)
|
||||||
j.DelayTime = delay
|
j.DelayTime = delay
|
||||||
j.UpdatedAt = time.Now()
|
j.UpdatedAt = clock.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *TranscribeJob) Done(transcriptionText string) {
|
func (j *TranscribeJob) Done(transcriptionText string) {
|
||||||
@@ -78,7 +80,7 @@ func (j *TranscribeJob) RetryAfter(delay time.Time) {
|
|||||||
j.AcquisitionID = nil
|
j.AcquisitionID = nil
|
||||||
j.AcquireTime = nil
|
j.AcquireTime = nil
|
||||||
j.DelayTime = &delay
|
j.DelayTime = &delay
|
||||||
j.UpdatedAt = time.Now()
|
j.UpdatedAt = clock.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Die переводит задачу, исчерпавшую попытки, в состояние «мертва». Число
|
// Die переводит задачу, исчерпавшую попытки, в состояние «мертва». Число
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package service
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -36,7 +35,7 @@ func (r *stubJobRepo) FindAndAcquire(string, string, time.Time) (*entity.Transcr
|
|||||||
}
|
}
|
||||||
|
|
||||||
func serviceWithRepo(repo contract.TranscriptJobRepository) *TranscribeService {
|
func serviceWithRepo(repo contract.TranscriptJobRepository) *TranscribeService {
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.DiscardHandler)
|
||||||
return NewTranscribeService(repo, nil, nil, nil, nil, nil, logger)
|
return NewTranscribeService(repo, nil, nil, nil, nil, nil, logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
|
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
|
||||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
)
|
)
|
||||||
@@ -28,19 +30,19 @@ import (
|
|||||||
// failingConverter отказывает на каждой попытке.
|
// failingConverter отказывает на каждой попытке.
|
||||||
type failingConverter struct{}
|
type failingConverter struct{}
|
||||||
|
|
||||||
func (c *failingConverter) Convert(string, string) error {
|
func (c *failingConverter) Convert(context.Context, string, string) error {
|
||||||
return errors.New("конвертация не удалась")
|
return errors.New("конвертация не удалась")
|
||||||
}
|
}
|
||||||
|
|
||||||
type okMetaViewer struct{}
|
type okMetaViewer struct{}
|
||||||
|
|
||||||
func (m *okMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
|
func (m *okMetaViewer) GetInfo(context.Context, string) (*contract.AudioInfo, error) {
|
||||||
return &contract.AudioInfo{Seconds: 1}, nil
|
return &contract.AudioInfo{Seconds: 1}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type failingMetaViewer struct{}
|
type failingMetaViewer struct{}
|
||||||
|
|
||||||
func (m *failingMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
|
func (m *failingMetaViewer) GetInfo(context.Context, string) (*contract.AudioInfo, error) {
|
||||||
return nil, errors.New("запись не читается")
|
return nil, errors.New("запись не читается")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +91,7 @@ func newPipelineEnv(t *testing.T, metaviewer contract.AudioMetaViewer, converter
|
|||||||
converter,
|
converter,
|
||||||
&recognizer.MemoryAudioRecognizer{},
|
&recognizer.MemoryAudioRecognizer{},
|
||||||
sender,
|
sender,
|
||||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
slog.New(slog.DiscardHandler),
|
||||||
)
|
)
|
||||||
|
|
||||||
return &pipelineEnv{app: app, service: svc, jobRepo: jobRepo, fileRepo: fileRepo, sender: sender}
|
return &pipelineEnv{app: app, service: svc, jobRepo: jobRepo, fileRepo: fileRepo, sender: sender}
|
||||||
@@ -100,7 +102,7 @@ func newTelegramJob(t *testing.T, env *pipelineEnv) *entity.TranscribeJob {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
chatId := int64(100)
|
chatId := int64(100)
|
||||||
job, err := env.service.CreateJobFromTelegram(strings.NewReader("запись"), "voice.ogg", chatId, 1)
|
job, err := env.service.CreateJobFromTelegram(t.Context(), strings.NewReader("запись"), "voice.ogg", chatId, 1)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
return job
|
return job
|
||||||
}
|
}
|
||||||
@@ -110,7 +112,7 @@ func newTelegramJob(t *testing.T, env *pipelineEnv) *entity.TranscribeJob {
|
|||||||
func clearDelay(t *testing.T, env *pipelineEnv, jobID string) {
|
func clearDelay(t *testing.T, env *pipelineEnv, jobID string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
record, err := env.app.FindRecordById(pbrepo.JobsCollection, jobID)
|
record, err := env.app.FindRecordById(migrations.JobsCollection, jobID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
record.Set("delay_time", "")
|
record.Set("delay_time", "")
|
||||||
require.NoError(t, env.app.Save(record))
|
require.NoError(t, env.app.Save(record))
|
||||||
@@ -121,7 +123,7 @@ func clearDelay(t *testing.T, env *pipelineEnv, jobID string) {
|
|||||||
func rotAcquisition(t *testing.T, env *pipelineEnv, jobID string) {
|
func rotAcquisition(t *testing.T, env *pipelineEnv, jobID string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
record, err := env.app.FindRecordById(pbrepo.JobsCollection, jobID)
|
record, err := env.app.FindRecordById(migrations.JobsCollection, jobID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
record.Set("acquire_time", types.NowDateTime().Add(-24*time.Hour))
|
record.Set("acquire_time", types.NowDateTime().Add(-24*time.Hour))
|
||||||
require.NoError(t, env.app.Save(record))
|
require.NoError(t, env.app.Save(record))
|
||||||
@@ -148,7 +150,7 @@ func TestJobDiesAfterAttemptLimit(t *testing.T) {
|
|||||||
rotAcquisition(t, env, job.Id)
|
rotAcquisition(t, env, job.Id)
|
||||||
|
|
||||||
// Следующий захват видит перебор и хоронит задачу.
|
// Следующий захват видит перебор и хоронит задачу.
|
||||||
err := env.service.FindAndRunConversionJob()
|
err := env.service.FindAndRunConversionJob(t.Context())
|
||||||
|
|
||||||
var noop *contract.NoopJobError
|
var noop *contract.NoopJobError
|
||||||
require.ErrorAs(t, err, &noop, "мёртвая задача шагу не отдаётся")
|
require.ErrorAs(t, err, &noop, "мёртвая задача шагу не отдаётся")
|
||||||
@@ -177,9 +179,9 @@ func TestDeadJobReturnsAfterStateEdit(t *testing.T) {
|
|||||||
_, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour))
|
_, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
require.Error(t, env.service.FindAndRunConversionJob())
|
require.Error(t, env.service.FindAndRunConversionJob(t.Context()))
|
||||||
|
|
||||||
record, err := env.app.FindRecordById(pbrepo.JobsCollection, job.Id)
|
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
record.Set("state", entity.StateCreated)
|
record.Set("state", entity.StateCreated)
|
||||||
require.NoError(t, env.app.Save(record))
|
require.NoError(t, env.app.Save(record))
|
||||||
@@ -202,13 +204,13 @@ func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
|
|||||||
empty, err := env.fileRepo.CreateRemote("object-key", 1)
|
empty, err := env.fileRepo.CreateRemote("object-key", 1)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
record, err := env.app.FindRecordById(pbrepo.JobsCollection, job.Id)
|
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
record.Set("file", empty.Id)
|
record.Set("file", empty.Id)
|
||||||
require.NoError(t, env.app.Save(record))
|
require.NoError(t, env.app.Save(record))
|
||||||
|
|
||||||
// Первый отказ.
|
// Первый отказ.
|
||||||
require.Error(t, env.service.FindAndRunConversionJob())
|
require.Error(t, env.service.FindAndRunConversionJob(t.Context()))
|
||||||
|
|
||||||
after, err := env.jobRepo.GetByID(job.Id)
|
after, err := env.jobRepo.GetByID(job.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -219,7 +221,7 @@ func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
|
|||||||
|
|
||||||
// Второй отказ — с той же задачи, пауза снята вручную.
|
// Второй отказ — с той же задачи, пауза снята вручную.
|
||||||
clearDelay(t, env, job.Id)
|
clearDelay(t, env, job.Id)
|
||||||
require.Error(t, env.service.FindAndRunConversionJob())
|
require.Error(t, env.service.FindAndRunConversionJob(t.Context()))
|
||||||
|
|
||||||
after, err = env.jobRepo.GetByID(job.Id)
|
after, err = env.jobRepo.GetByID(job.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -246,7 +248,7 @@ func TestWorkFileRemovedAfterIntakeFailure(t *testing.T) {
|
|||||||
|
|
||||||
env := newPipelineEnv(t, &failingMetaViewer{}, &failingConverter{})
|
env := newPipelineEnv(t, &failingMetaViewer{}, &failingConverter{})
|
||||||
|
|
||||||
_, err := env.service.CreateJobFromApi(strings.NewReader("запись"), "sample.mp3")
|
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3")
|
||||||
require.Error(t, err, "отказ источника метаданных роняет приём")
|
require.Error(t, err, "отказ источника метаданных роняет приём")
|
||||||
|
|
||||||
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
||||||
@@ -262,7 +264,7 @@ func TestWorkFileRemovedAfterSuccessfulIntake(t *testing.T) {
|
|||||||
|
|
||||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||||
|
|
||||||
_, err := env.service.CreateJobFromApi(strings.NewReader("запись"), "sample.mp3")
|
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
||||||
@@ -279,7 +281,7 @@ func TestJobNeverPointsToMissingFile(t *testing.T) {
|
|||||||
|
|
||||||
// Конвертация отказывает — задача уходит в `failed`, но ссылка остаётся на
|
// Конвертация отказывает — задача уходит в `failed`, но ссылка остаётся на
|
||||||
// исходную запись, а не на несозданный результат.
|
// исходную запись, а не на несозданный результат.
|
||||||
require.NoError(t, env.service.FindAndRunConversionJob())
|
require.NoError(t, env.service.FindAndRunConversionJob(t.Context()))
|
||||||
|
|
||||||
after, err := env.jobRepo.GetByID(job.Id)
|
after, err := env.jobRepo.GetByID(job.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -297,7 +299,7 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
|
|||||||
|
|
||||||
content := strings.Repeat("запись ", 1000)
|
content := strings.Repeat("запись ", 1000)
|
||||||
|
|
||||||
job, err := env.service.CreateJobFromApi(strings.NewReader(content), "sample.mp3")
|
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader(content), "sample.mp3")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, job.FileID)
|
require.NotNil(t, job.FileID)
|
||||||
|
|
||||||
@@ -320,7 +322,7 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
|
|||||||
func TestLocalizeGivesReadableCopy(t *testing.T) {
|
func TestLocalizeGivesReadableCopy(t *testing.T) {
|
||||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||||
|
|
||||||
job, err := env.service.CreateJobFromApi(strings.NewReader("содержимое"), "sample.mp3")
|
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("содержимое"), "sample.mp3")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, job.FileID)
|
require.NotNil(t, job.FileID)
|
||||||
|
|
||||||
@@ -354,7 +356,7 @@ func TestWorkFilesRemovedAfterConversionFailure(t *testing.T) {
|
|||||||
require.Empty(t, leftovers, "приём убрал свою рабочую копию")
|
require.Empty(t, leftovers, "приём убрал свою рабочую копию")
|
||||||
|
|
||||||
// Конвертация отказывает — задача уходит в `failed`, копии убраны.
|
// Конвертация отказывает — задача уходит в `failed`, копии убраны.
|
||||||
require.NoError(t, env.service.FindAndRunConversionJob())
|
require.NoError(t, env.service.FindAndRunConversionJob(t.Context()))
|
||||||
|
|
||||||
leftovers, err = filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
leftovers, err = filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -10,7 +11,7 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
)
|
)
|
||||||
@@ -32,7 +33,7 @@ type scriptedRecognizer struct {
|
|||||||
lastObjectKey string
|
lastObjectKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *scriptedRecognizer) Recognize(file io.Reader, fileName string) (string, error) {
|
func (r *scriptedRecognizer) Recognize(_ context.Context, file io.Reader, fileName string) (string, error) {
|
||||||
r.recognizeCalls++
|
r.recognizeCalls++
|
||||||
r.lastObjectKey = fileName
|
r.lastObjectKey = fileName
|
||||||
if r.recognizeErr != nil {
|
if r.recognizeErr != nil {
|
||||||
@@ -45,11 +46,11 @@ func (r *scriptedRecognizer) Recognize(file io.Reader, fileName string) (string,
|
|||||||
return "operation-id", nil
|
return "operation-id", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *scriptedRecognizer) GetRecognitionText(string) (string, error) {
|
func (r *scriptedRecognizer) GetRecognitionText(context.Context, string) (string, error) {
|
||||||
return r.text, nil
|
return r.text, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *scriptedRecognizer) CheckRecognitionStatus(string) (*entity.RecognitionResult, error) {
|
func (r *scriptedRecognizer) CheckRecognitionStatus(context.Context, string) (*entity.RecognitionResult, error) {
|
||||||
return r.result, nil
|
return r.result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +93,7 @@ func TestTranscribeJobHandsRecordOverAndMovesOn(t *testing.T) {
|
|||||||
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
|
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
|
||||||
svc := withRecognizer(env, rec)
|
svc := withRecognizer(env, rec)
|
||||||
|
|
||||||
require.NoError(t, svc.FindAndRunTranscribeJob())
|
require.NoError(t, svc.FindAndRunTranscribeJob(t.Context()))
|
||||||
|
|
||||||
assert.Equal(t, 1, rec.recognizeCalls, "содержимое отдано распознавателю")
|
assert.Equal(t, 1, rec.recognizeCalls, "содержимое отдано распознавателю")
|
||||||
assert.NotEmpty(t, rec.lastObjectKey, "ключ объекта назван")
|
assert.NotEmpty(t, rec.lastObjectKey, "ключ объекта назван")
|
||||||
@@ -119,7 +120,7 @@ func TestTranscribeJobKeepsJobRetryableOnRecognizerFailure(t *testing.T) {
|
|||||||
rec := &scriptedRecognizer{recognizeErr: errors.New("распознаватель недоступен")}
|
rec := &scriptedRecognizer{recognizeErr: errors.New("распознаватель недоступен")}
|
||||||
svc := withRecognizer(env, rec)
|
svc := withRecognizer(env, rec)
|
||||||
|
|
||||||
require.Error(t, svc.FindAndRunTranscribeJob())
|
require.Error(t, svc.FindAndRunTranscribeJob(t.Context()))
|
||||||
|
|
||||||
after, err := env.jobRepo.GetByID(job.Id)
|
after, err := env.jobRepo.GetByID(job.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -134,7 +135,7 @@ func transcribingJob(t *testing.T, env *pipelineEnv, rec contract.AudioRecognize
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
job := convertedJob(t, env)
|
job := convertedJob(t, env)
|
||||||
require.NoError(t, withRecognizer(env, rec).FindAndRunTranscribeJob())
|
require.NoError(t, withRecognizer(env, rec).FindAndRunTranscribeJob(t.Context()))
|
||||||
|
|
||||||
clearDelay(t, env, job.Id)
|
clearDelay(t, env, job.Id)
|
||||||
return job
|
return job
|
||||||
@@ -150,7 +151,7 @@ func TestCheckJobWaitsWithoutSpendingAttempts(t *testing.T) {
|
|||||||
svc := withRecognizer(env, rec)
|
svc := withRecognizer(env, rec)
|
||||||
|
|
||||||
for i := 0; i < 3; i++ {
|
for i := 0; i < 3; i++ {
|
||||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob())
|
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
|
||||||
|
|
||||||
after, err := env.jobRepo.GetByID(job.Id)
|
after, err := env.jobRepo.GetByID(job.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -174,7 +175,7 @@ func TestCheckJobFailsJobAndTellsSender(t *testing.T) {
|
|||||||
rec.result = entity.NewFailedResult("операция отклонена")
|
rec.result = entity.NewFailedResult("операция отклонена")
|
||||||
svc := withRecognizer(env, rec)
|
svc := withRecognizer(env, rec)
|
||||||
|
|
||||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob())
|
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
|
||||||
|
|
||||||
after, err := env.jobRepo.GetByID(job.Id)
|
after, err := env.jobRepo.GetByID(job.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -196,7 +197,7 @@ func TestCheckJobCompletesAndAnswersOnce(t *testing.T) {
|
|||||||
rec.text = "расшифровка записи"
|
rec.text = "расшифровка записи"
|
||||||
svc := withRecognizer(env, rec)
|
svc := withRecognizer(env, rec)
|
||||||
|
|
||||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob())
|
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
|
||||||
|
|
||||||
after, err := env.jobRepo.GetByID(job.Id)
|
after, err := env.jobRepo.GetByID(job.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -224,7 +225,7 @@ func TestCheckJobCompletesEmptyTextWithExplanation(t *testing.T) {
|
|||||||
rec.text = ""
|
rec.text = ""
|
||||||
svc := withRecognizer(env, rec)
|
svc := withRecognizer(env, rec)
|
||||||
|
|
||||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob())
|
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
|
||||||
|
|
||||||
after, err := env.jobRepo.GetByID(job.Id)
|
after, err := env.jobRepo.GetByID(job.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -249,13 +250,13 @@ func TestCheckJobWritesNothingWhenAcquisitionLost(t *testing.T) {
|
|||||||
acquired, err := env.jobRepo.FindAndAcquire(entity.StateTranscribe, "mine", time.Now().Add(-time.Hour))
|
acquired, err := env.jobRepo.FindAndAcquire(entity.StateTranscribe, "mine", time.Now().Add(-time.Hour))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
record, err := env.app.FindRecordById(pocketbase.JobsCollection, job.Id)
|
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
record.Set("acquisition_id", "someone-else")
|
record.Set("acquisition_id", "someone-else")
|
||||||
require.NoError(t, env.app.Save(record))
|
require.NoError(t, env.app.Save(record))
|
||||||
|
|
||||||
svc := withRecognizer(env, rec)
|
svc := withRecognizer(env, rec)
|
||||||
err = svc.checkTranscribeJob(acquired, "mine")
|
err = svc.checkTranscribeJob(t.Context(), acquired, "mine")
|
||||||
|
|
||||||
var lost *contract.LostAcquisitionError
|
var lost *contract.LostAcquisitionError
|
||||||
require.ErrorAs(t, err, &lost)
|
require.ErrorAs(t, err, &lost)
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// killedConverter ведёт себя как настоящий `ffmpeg`, убитый по контексту:
|
||||||
|
// дожидается отмены и отдаёт отказ, который **не** несёт `context.Canceled`.
|
||||||
|
// Это не упрощение, а суть проверки: `exec` отдаёт `*exec.ExitError` с текстом
|
||||||
|
// «signal: killed», и отличить остановку от негодной записи по самой ошибке
|
||||||
|
// нельзя — только по контексту шага.
|
||||||
|
type killedConverter struct {
|
||||||
|
cancel func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *killedConverter) Convert(ctx context.Context, _, _ string) error {
|
||||||
|
c.cancel()
|
||||||
|
<-ctx.Done()
|
||||||
|
return errors.New("ffmpeg conversion failed: signal: killed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Остановка сервиса посреди конвертации не выносит записи приговора: задача
|
||||||
|
// остаётся пригодной к повтору, попытку не тратит и отправителю о сбое,
|
||||||
|
// которого не было, не сообщает. Прежде любой отказ `Convert` уводил задачу в
|
||||||
|
// терминальное `failed`, откуда её возвращает только владелец правкой в панели.
|
||||||
|
func TestShutdownDuringConversionKeepsJobRetryable(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
converter := &killedConverter{cancel: cancel}
|
||||||
|
env := newPipelineEnv(t, &okMetaViewer{}, converter)
|
||||||
|
job := newTelegramJob(t, env)
|
||||||
|
|
||||||
|
err := env.service.FindAndRunConversionJob(ctx)
|
||||||
|
|
||||||
|
require.Error(t, err, "шаг обязан сообщить об обрыве наверх")
|
||||||
|
require.ErrorIs(t, err, context.Canceled, "обрыв узнаётся по смыслу, а не по тексту")
|
||||||
|
|
||||||
|
after, err := env.jobRepo.GetByID(job.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, entity.StateCreated, after.State, "задача осталась на повтор, а не похоронена")
|
||||||
|
assert.Nil(t, after.AcquisitionID, "захват снят: задачу возьмёт следующий прогон")
|
||||||
|
assert.Equal(t, 0, after.Attempts, "остановка попытки не тратит")
|
||||||
|
assert.Nil(t, after.ErrorText, "приговора не выносили")
|
||||||
|
assert.Empty(t, env.sender.messages, "отправителю о несуществующем сбое не сообщают")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Задача, которую шаг не успел взять, потому что нас уже остановили, остаётся
|
||||||
|
// нетронутой: захват не случился, попытка не потрачена.
|
||||||
|
func TestShutdownBeforeStepLeavesJobUntouched(t *testing.T) {
|
||||||
|
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||||
|
job := newTelegramJob(t, env)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
err := env.service.FindAndRunConversionJob(ctx)
|
||||||
|
|
||||||
|
// Исход «шаг не сделал ничего» — это `NoopJobError`: воркер не пишет о нём
|
||||||
|
// владельцу и не считает его в метрику.
|
||||||
|
require.Error(t, err)
|
||||||
|
var noop *contract.NoopJobError
|
||||||
|
require.ErrorAs(t, err, &noop)
|
||||||
|
|
||||||
|
after, err := env.jobRepo.GetByID(job.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, entity.StateCreated, after.State)
|
||||||
|
assert.Equal(t, 0, after.Attempts, "захвата не было — попытке взяться неоткуда")
|
||||||
|
|
||||||
|
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, record.GetString("acquisition_id"))
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -14,6 +15,8 @@ import (
|
|||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -73,7 +76,7 @@ func NewTranscribeService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TranscribeService) CreateJobFromTelegram(file io.Reader, fileName string, chatId int64, replyMsgId int) (*entity.TranscribeJob, error) {
|
func (s *TranscribeService) CreateJobFromTelegram(ctx context.Context, file io.Reader, fileName string, chatId int64, replyMsgId int) (*entity.TranscribeJob, error) {
|
||||||
job := &entity.TranscribeJob{
|
job := &entity.TranscribeJob{
|
||||||
State: entity.StateCreated,
|
State: entity.StateCreated,
|
||||||
Source: entity.SourceTelegram,
|
Source: entity.SourceTelegram,
|
||||||
@@ -81,19 +84,19 @@ func (s *TranscribeService) CreateJobFromTelegram(file io.Reader, fileName strin
|
|||||||
TgReplyMessageId: &replyMsgId,
|
TgReplyMessageId: &replyMsgId,
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.createTranscribeJob(job, file, fileName)
|
return s.createTranscribeJob(ctx, job, file, fileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TranscribeService) CreateJobFromApi(file io.Reader, fileName string) (*entity.TranscribeJob, error) {
|
func (s *TranscribeService) CreateJobFromApi(ctx context.Context, file io.Reader, fileName string) (*entity.TranscribeJob, error) {
|
||||||
job := &entity.TranscribeJob{
|
job := &entity.TranscribeJob{
|
||||||
State: entity.StateCreated,
|
State: entity.StateCreated,
|
||||||
Source: entity.SourceApi,
|
Source: entity.SourceApi,
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.createTranscribeJob(job, file, fileName)
|
return s.createTranscribeJob(ctx, job, file, fileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TranscribeService) createTranscribeJob(job *entity.TranscribeJob, file io.Reader, fileName string) (*entity.TranscribeJob, error) {
|
func (s *TranscribeService) createTranscribeJob(ctx context.Context, job *entity.TranscribeJob, file io.Reader, fileName string) (*entity.TranscribeJob, error) {
|
||||||
// Определяем расширение файла
|
// Определяем расширение файла
|
||||||
ext := filepath.Ext(fileName)
|
ext := filepath.Ext(fileName)
|
||||||
if ext == "" {
|
if ext == "" {
|
||||||
@@ -120,7 +123,7 @@ func (s *TranscribeService) createTranscribeJob(job *entity.TranscribeJob, file
|
|||||||
// строка журнала вместе с идентификатором записи собрала бы её целиком.
|
// строка журнала вместе с идентификатором записи собрала бы её целиком.
|
||||||
s.logger.Info("Creating transcribe job", "file_ext", ext)
|
s.logger.Info("Creating transcribe job", "file_ext", ext)
|
||||||
|
|
||||||
info, err := s.metaviewer.GetInfo(work.Path())
|
info, err := s.metaviewer.GetInfo(ctx, work.Path())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to get file info", "error", err, "file_ext", ext)
|
s.logger.Error("Failed to get file info", "error", err, "file_ext", ext)
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -158,28 +161,41 @@ func (s *TranscribeService) createTranscribeJob(job *entity.TranscribeJob, file
|
|||||||
return job, nil
|
return job, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TranscribeService) FindAndRunConversionJob() error {
|
func (s *TranscribeService) FindAndRunConversionJob(ctx context.Context) error {
|
||||||
return s.runStep(entity.StateCreated, conversionAcquireTimeout, s.convertJob)
|
return s.runStep(ctx, entity.StateCreated, conversionAcquireTimeout, s.convertJob)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TranscribeService) FindAndRunTranscribeJob() error {
|
func (s *TranscribeService) FindAndRunTranscribeJob(ctx context.Context) error {
|
||||||
return s.runStep(entity.StateConverted, transcribeAcquireTimeout, s.transcribeJob)
|
return s.runStep(ctx, entity.StateConverted, transcribeAcquireTimeout, s.transcribeJob)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TranscribeService) FindAndRunTranscribeCheckJob() error {
|
func (s *TranscribeService) FindAndRunTranscribeCheckJob(ctx context.Context) error {
|
||||||
return s.runStep(entity.StateTranscribe, checkAcquireTimeout, s.checkTranscribeJob)
|
return s.runStep(ctx, entity.StateTranscribe, checkAcquireTimeout, s.checkTranscribeJob)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runStep забирает задачу и отдаёт её шагу. Отказ шага не оставляет задачу
|
// runStep забирает задачу и отдаёт её шагу. Отказ шага не оставляет задачу
|
||||||
// захваченной до конца срока: захват снимается, и задача ждёт нарастающую паузу
|
// захваченной до конца срока: захват снимается, и задача ждёт нарастающую паузу
|
||||||
// — иначе повтор наступал бы через восемь часов, а не через секунду.
|
// — иначе повтор наступал бы через восемь часов, а не через секунду.
|
||||||
func (s *TranscribeService) runStep(state string, expiration time.Duration, step func(job *entity.TranscribeJob, holder string) error) error {
|
//
|
||||||
|
// Контекст доходит до шага, а через него — до внешнего собеседника: остановка
|
||||||
|
// сервиса убивает `ffmpeg` и обрывает запрос к распознаванию. Прерванный шаг
|
||||||
|
// приговора не выносит: задача остаётся пригодной к повтору, попытки не тратит
|
||||||
|
// и отправителю о несуществующем сбое не сообщает — исход остановки отличается
|
||||||
|
// от исхода отказа на каждом шаге.
|
||||||
|
func (s *TranscribeService) runStep(ctx context.Context, state string, expiration time.Duration, step func(ctx context.Context, job *entity.TranscribeJob, holder string) error) error {
|
||||||
|
// Нас уже остановили — задачу не забираем: захват стоил бы ей попытки, а
|
||||||
|
// работы всё равно не будет. Исход «шаг не сделал ничего» — это `NoopJobError`
|
||||||
|
// по смыслу, и он же не поднимает уровень и не считается в метрику.
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return &contract.NoopJobError{State: state}
|
||||||
|
}
|
||||||
|
|
||||||
job, holder, err := s.findJob(state, expiration)
|
job, holder, err := s.findJob(state, expiration)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := step(job, holder); err != nil {
|
if err := step(ctx, job, holder); err != nil {
|
||||||
s.scheduleRetry(job, holder, err)
|
s.scheduleRetry(job, holder, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -187,7 +203,7 @@ func (s *TranscribeService) runStep(state string, expiration time.Duration, step
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TranscribeService) convertJob(job *entity.TranscribeJob, holder string) error {
|
func (s *TranscribeService) convertJob(ctx context.Context, job *entity.TranscribeJob, holder string) error {
|
||||||
s.logger.Info("Starting conversion job", "job_id", job.Id)
|
s.logger.Info("Starting conversion job", "job_id", job.Id)
|
||||||
|
|
||||||
if job.FileID == nil {
|
if job.FileID == nil {
|
||||||
@@ -224,14 +240,28 @@ func (s *TranscribeService) convertJob(job *entity.TranscribeJob, holder string)
|
|||||||
s.logger.Info("Converting file", "job_id", job.Id, "src_format", srcExt)
|
s.logger.Info("Converting file", "job_id", job.Id, "src_format", srcExt)
|
||||||
|
|
||||||
// Измеряем время конвертации
|
// Измеряем время конвертации
|
||||||
startTime := time.Now()
|
startTime := clock.Start()
|
||||||
err = s.converter.Convert(src.Path(), dest.Path())
|
err = s.converter.Convert(ctx, src.Path(), dest.Path())
|
||||||
conversionDuration := time.Since(startTime)
|
conversionDuration := time.Since(startTime)
|
||||||
|
|
||||||
// Записываем метрику времени конвертации
|
// Записываем метрику времени конвертации
|
||||||
metrics.ObserveConversionDuration(srcExt, "ogg", err != nil, conversionDuration.Seconds())
|
metrics.ObserveConversionDuration(srcExt, "ogg", err != nil, conversionDuration.Seconds())
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Остановка сервиса — не приговор записи. Убитый по контексту `ffmpeg`
|
||||||
|
// отдаёт `signal: killed`, и от настоящего отказа конвертации
|
||||||
|
// (`exit status N`) эта ошибка неотличима ни типом, ни `errors.Is`:
|
||||||
|
// различает их только контекст шага. Без этой развилки каждый деплой
|
||||||
|
// хоронил бы конвертируемую запись в `failed` — состояние терминальное,
|
||||||
|
// и вернуть её оттуда может только владелец правкой в панели, — да ещё
|
||||||
|
// и сообщал бы отправителю о сбое, которого не было.
|
||||||
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||||
|
s.logger.Info("File conversion interrupted by shutdown",
|
||||||
|
"job_id", job.Id,
|
||||||
|
"duration", conversionDuration)
|
||||||
|
return fmt.Errorf("conversion interrupted: %w", ctxErr)
|
||||||
|
}
|
||||||
|
|
||||||
s.logger.Error("File conversion failed",
|
s.logger.Error("File conversion failed",
|
||||||
"error", err,
|
"error", err,
|
||||||
"job_id", job.Id,
|
"job_id", job.Id,
|
||||||
@@ -274,7 +304,7 @@ func (s *TranscribeService) convertJob(job *entity.TranscribeJob, holder string)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TranscribeService) transcribeJob(job *entity.TranscribeJob, holder string) error {
|
func (s *TranscribeService) transcribeJob(ctx context.Context, job *entity.TranscribeJob, holder string) error {
|
||||||
s.logger.Info("Starting transcribe job", "job_id", job.Id)
|
s.logger.Info("Starting transcribe job", "job_id", job.Id)
|
||||||
|
|
||||||
if job.FileID == nil {
|
if job.FileID == nil {
|
||||||
@@ -302,8 +332,12 @@ func (s *TranscribeService) transcribeJob(job *entity.TranscribeJob, holder stri
|
|||||||
s.logger.Info("Starting recognition", "job_id", job.Id, "file_id", *job.FileID)
|
s.logger.Info("Starting recognition", "job_id", job.Id, "file_id", *job.FileID)
|
||||||
|
|
||||||
// Запускаем асинхронное распознавание
|
// Запускаем асинхронное распознавание
|
||||||
operationID, err := s.recognizer.Recognize(content, fileRecord.FileName)
|
operationID, err := s.recognizer.Recognize(ctx, content, fileRecord.FileName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||||
|
s.logger.Info("Recognition interrupted by shutdown", "job_id", job.Id)
|
||||||
|
return fmt.Errorf("recognition interrupted: %w", ctxErr)
|
||||||
|
}
|
||||||
s.logger.Error("Failed to start recognition", "error", err, "job_id", job.Id)
|
s.logger.Error("Failed to start recognition", "error", err, "job_id", job.Id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -321,7 +355,7 @@ func (s *TranscribeService) transcribeJob(job *entity.TranscribeJob, holder stri
|
|||||||
// Обновляем задачу с ID операции распознавания
|
// Обновляем задачу с ID операции распознавания
|
||||||
job.FileID = &destFileRecord.Id
|
job.FileID = &destFileRecord.Id
|
||||||
job.RecognitionOpID = &operationID
|
job.RecognitionOpID = &operationID
|
||||||
delayTime := time.Now().Add(firstCheckDelay)
|
delayTime := clock.Now().Add(firstCheckDelay)
|
||||||
job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime)
|
job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime)
|
||||||
|
|
||||||
if err := s.jobRepo.Save(job, holder); err != nil {
|
if err := s.jobRepo.Save(job, holder); err != nil {
|
||||||
@@ -333,18 +367,22 @@ func (s *TranscribeService) transcribeJob(job *entity.TranscribeJob, holder stri
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TranscribeService) checkTranscribeJob(job *entity.TranscribeJob, holder string) error {
|
func (s *TranscribeService) checkTranscribeJob(ctx context.Context, job *entity.TranscribeJob, holder string) error {
|
||||||
if job.RecognitionOpID == nil {
|
if job.RecognitionOpID == nil {
|
||||||
s.logger.Error("Recognition operation ID not found", "job_id", job.Id)
|
s.logger.Error("Recognition operation ID not found", "job_id", job.Id)
|
||||||
return fmt.Errorf("recogniton opId not found for job: %s", job.Id)
|
return fmt.Errorf("recognition opId not found for job: %s", job.Id)
|
||||||
}
|
}
|
||||||
|
|
||||||
opId := *job.RecognitionOpID
|
opId := *job.RecognitionOpID
|
||||||
|
|
||||||
// Проверяем статус операции
|
// Проверяем статус операции
|
||||||
s.logger.Info("Checking operation status", "job_id", job.Id, "operation_id", opId)
|
s.logger.Info("Checking operation status", "job_id", job.Id, "operation_id", opId)
|
||||||
recResult, err := s.recognizer.CheckRecognitionStatus(opId)
|
recResult, err := s.recognizer.CheckRecognitionStatus(ctx, opId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||||
|
s.logger.Info("Status check interrupted by shutdown", "job_id", job.Id)
|
||||||
|
return fmt.Errorf("status check interrupted: %w", ctxErr)
|
||||||
|
}
|
||||||
s.logger.Error("Failed to check recognition status", "error", err, "operation_id", opId)
|
s.logger.Error("Failed to check recognition status", "error", err, "operation_id", opId)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -354,7 +392,7 @@ func (s *TranscribeService) checkTranscribeJob(job *entity.TranscribeJob, holder
|
|||||||
// здесь своя, числом, а число попыток обнуляется переходом: ожидание
|
// здесь своя, числом, а число попыток обнуляется переходом: ожидание
|
||||||
// чужой операции попытку не тратит.
|
// чужой операции попытку не тратит.
|
||||||
s.logger.Info("Operation in progress", "job_id", job.Id, "operation_id", opId)
|
s.logger.Info("Operation in progress", "job_id", job.Id, "operation_id", opId)
|
||||||
delayTime := time.Now().Add(nextCheckDelay)
|
delayTime := clock.Now().Add(nextCheckDelay)
|
||||||
job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime)
|
job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime)
|
||||||
if err := s.jobRepo.Save(job, holder); err != nil {
|
if err := s.jobRepo.Save(job, holder); err != nil {
|
||||||
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
|
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
|
||||||
@@ -373,8 +411,12 @@ func (s *TranscribeService) checkTranscribeJob(job *entity.TranscribeJob, holder
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Операция завершена, получаем результат
|
// Операция завершена, получаем результат
|
||||||
transcriptionText, err := s.recognizer.GetRecognitionText(opId)
|
transcriptionText, err := s.recognizer.GetRecognitionText(ctx, opId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||||
|
s.logger.Info("Text fetch interrupted by shutdown", "job_id", job.Id)
|
||||||
|
return fmt.Errorf("text fetch interrupted: %w", ctxErr)
|
||||||
|
}
|
||||||
s.logger.Error("Failed to get recognition text", "error", err, "operation_id", opId)
|
s.logger.Error("Failed to get recognition text", "error", err, "operation_id", opId)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -397,7 +439,7 @@ func (s *TranscribeService) checkTranscribeJob(job *entity.TranscribeJob, holder
|
|||||||
// переводят в «мертва» и сообщают об этом отправителю.
|
// переводят в «мертва» и сообщают об этом отправителю.
|
||||||
func (s *TranscribeService) findJob(state string, expiration time.Duration) (*entity.TranscribeJob, string, error) {
|
func (s *TranscribeService) findJob(state string, expiration time.Duration) (*entity.TranscribeJob, string, error) {
|
||||||
acquisitionId := uuid.NewString()
|
acquisitionId := uuid.NewString()
|
||||||
rottingTime := time.Now().Add(-1 * expiration)
|
rottingTime := clock.Now().Add(-1 * expiration)
|
||||||
|
|
||||||
job, err := s.jobRepo.FindAndAcquire(state, acquisitionId, rottingTime)
|
job, err := s.jobRepo.FindAndAcquire(state, acquisitionId, rottingTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -448,7 +490,17 @@ func (s *TranscribeService) scheduleRetry(job *entity.TranscribeJob, holder stri
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
job.RetryAfter(time.Now().Add(retryDelay(job.Attempts)))
|
// Остановка попытки не тратит: задача не виновата в том, что нас
|
||||||
|
// перезапустили. Счётчик растёт при захвате, поэтому здесь его возвращают
|
||||||
|
// назад — иначе пять выкладок подряд уводят живую запись в «мертва» с
|
||||||
|
// приговором «попытки исчерпаны».
|
||||||
|
if errors.Is(stepErr, context.Canceled) || errors.Is(stepErr, context.DeadlineExceeded) {
|
||||||
|
if job.Attempts > 0 {
|
||||||
|
job.Attempts--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
job.RetryAfter(clock.Now().Add(retryDelay(job.Attempts)))
|
||||||
|
|
||||||
if err := s.jobRepo.Save(job, holder); err != nil {
|
if err := s.jobRepo.Save(job, holder); err != nil {
|
||||||
var lostOnSave *contract.LostAcquisitionError
|
var lostOnSave *contract.LostAcquisitionError
|
||||||
|
|||||||
@@ -1,11 +1,41 @@
|
|||||||
# Refer for explanation to following link:
|
# Refer for explanation to following link:
|
||||||
# https://lefthook.dev/configuration/
|
# https://lefthook.dev/configuration/
|
||||||
|
#
|
||||||
|
# Предкоммитные проверки — дешёвая часть гейта на **затронутых файлах**. Полный
|
||||||
|
# набор здесь не гоняется намеренно: он идёт минуты, а pre-commit обязан быть
|
||||||
|
# быстрым. Что ловит pre-commit и что остаётся только гейту — CLAUDE.md,
|
||||||
|
# раздел «Гейт»; перечень правил и их дома — docs/conventions/go-linters.md.
|
||||||
|
|
||||||
templates:
|
templates:
|
||||||
av-hooks-dir: "/home/av/projects/private/git-hooks"
|
av-hooks-dir: "/home/av/projects/private/git-hooks"
|
||||||
|
|
||||||
pre-commit:
|
pre-commit:
|
||||||
jobs:
|
jobs:
|
||||||
|
# Форматирование правится на месте и добавляется в коммит: спорить тут не о
|
||||||
|
# чем, а гейт на неотформатированном файле краснеет.
|
||||||
|
- name: "gofmt"
|
||||||
|
glob: "*.go"
|
||||||
|
run: "gofmt -w {staged_files}"
|
||||||
|
stage_fixed: true
|
||||||
|
|
||||||
|
# Линтеры гоняются по пакетам затронутых файлов, а не по всему дереву:
|
||||||
|
# golangci-lint принимает файлы только из одного каталога, поэтому на вход
|
||||||
|
# идут каталоги.
|
||||||
|
- name: "golangci-lint"
|
||||||
|
glob: "*.go"
|
||||||
|
run: |
|
||||||
|
dirs=$(printf '%s\n' {staged_files} | xargs -r -n1 dirname | sort -u)
|
||||||
|
golangci-lint run $dirs
|
||||||
|
|
||||||
|
- name: "shellcheck"
|
||||||
|
glob: "*.sh"
|
||||||
|
run: "shellcheck {staged_files}"
|
||||||
|
|
||||||
|
# Подавления те же, что у шага гейта, и по тем же причинам — Taskfile.yml,
|
||||||
|
# задача `dockerfile`.
|
||||||
|
- name: "hadolint"
|
||||||
|
glob: "Dockerfile"
|
||||||
|
run: "hadolint --ignore DL3007 --ignore DL3018 {staged_files}"
|
||||||
|
|
||||||
- name: "gitleaks"
|
- name: "gitleaks"
|
||||||
run: "gitleaks git --staged"
|
run: "gitleaks git --staged"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
|
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/config"
|
"git.vakhrushev.me/av/transcriber/internal/config"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
|
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
|
||||||
tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg"
|
tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/controller/worker"
|
"git.vakhrushev.me/av/transcriber/internal/controller/worker"
|
||||||
@@ -27,6 +28,8 @@ import (
|
|||||||
"github.com/pocketbase/pocketbase/apis"
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -50,6 +53,13 @@ func main() {
|
|||||||
logger.Info("Configuration loaded successfully", "config_path", *configPath)
|
logger.Info("Configuration loaded successfully", "config_path", *configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Незаполненный вход роняет старт: подняться с молча выключенным входом
|
||||||
|
// значит остаться открытым наружу, и узнать об этом было бы неоткуда.
|
||||||
|
if err := cfg.Auth.Validate(); err != nil {
|
||||||
|
logger.Error("Unable to start with incomplete login settings", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
// Загружаем переменные окружения из .env файла
|
// Загружаем переменные окружения из .env файла
|
||||||
if err := godotenv.Load(); err != nil {
|
if err := godotenv.Load(); err != nil {
|
||||||
logger.Warn("Warning: .env file not found, using system environment variables")
|
logger.Warn("Warning: .env file not found, using system environment variables")
|
||||||
@@ -127,13 +137,13 @@ func main() {
|
|||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
tgConfig := tgcontroller.TelegramConfig{
|
tgConfig := tgcontroller.TelegramConfig{
|
||||||
BotToken: cfg.Telegram.BotToken,
|
|
||||||
UpdateTimeout: cfg.Telegram.UpdateTimeout,
|
UpdateTimeout: cfg.Telegram.UpdateTimeout,
|
||||||
UserWhiteList: cfg.Server.UsersWhiteList,
|
UserWhiteList: cfg.Server.UsersWhiteList,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаем Telegram бот
|
// Клиента бота заводит единая точка: её отказ не несёт токена, тогда как
|
||||||
tgController, err := tgcontroller.NewTelegramController(tgConfig, transcribeService, jobRepo, logger)
|
// отказ `NewBotAPI` несёт — он ходит за `getMe`.
|
||||||
|
tgController, err := newTelegramController(cfg.Telegram.BotToken, tgConfig, transcribeService, jobRepo, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Failed to create Telegram controller", "error", err)
|
logger.Error("Failed to create Telegram controller", "error", err)
|
||||||
// Не останавливаем приложение, если Telegram бот не создан
|
// Не останавливаем приложение, если Telegram бот не создан
|
||||||
@@ -143,7 +153,7 @@ func main() {
|
|||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
logger.Info("Starting Telegram bot")
|
logger.Info("Starting Telegram bot")
|
||||||
tgController.Start()
|
tgController.Start(ctx)
|
||||||
logger.Info("Telegram bot stopped gracefully")
|
logger.Info("Telegram bot stopped gracefully")
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -172,6 +182,12 @@ func main() {
|
|||||||
// Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом,
|
// Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом,
|
||||||
// и второму серверу на нём взяться неоткуда.
|
// и второму серверу на нём взяться неоткуда.
|
||||||
transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService, logger)
|
transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService, logger)
|
||||||
|
authHandler := httpcontroller.NewAuthHandler(storage, httpcontroller.AuthHandlerConfig{
|
||||||
|
AuthURL: cfg.Auth.AuthURL,
|
||||||
|
RedirectURL: cfg.Auth.RedirectURL,
|
||||||
|
ClientID: cfg.Auth.ClientID,
|
||||||
|
SecureCookie: cfg.Auth.SecureCookie,
|
||||||
|
}, logger)
|
||||||
|
|
||||||
// Сервер приезжает каналом, а не общей переменной: хук исполняется в
|
// Сервер приезжает каналом, а не общей переменной: хук исполняется в
|
||||||
// горутине сервера, а читает его горутина остановки, и связи «произошло
|
// горутине сервера, а читает его горутина остановки, и связи «произошло
|
||||||
@@ -188,7 +204,7 @@ func main() {
|
|||||||
// `sloggin`, а хранилище пишет запросы в свою таблицу, которой в
|
// `sloggin`, а хранилище пишет запросы в свою таблицу, которой в
|
||||||
// журнале контейнера не видно. Поля — те, что просит конвенция.
|
// журнале контейнера не видно. Поля — те, что просит конвенция.
|
||||||
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
||||||
start := time.Now()
|
start := clock.Start()
|
||||||
err := e.Next()
|
err := e.Next()
|
||||||
|
|
||||||
level := slog.LevelInfo
|
level := slog.LevelInfo
|
||||||
@@ -207,6 +223,20 @@ func main() {
|
|||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Настройки провайдера приводятся к конфигу при каждом подъёме:
|
||||||
|
// применённый шаг схемы не переписывается, и секрет, положенный
|
||||||
|
// однажды шагом, не пережил бы ротации.
|
||||||
|
if err := pbrepo.ApplyProviderSettings(storage, pbrepo.ProviderSettings{
|
||||||
|
AuthURL: cfg.Auth.AuthURL,
|
||||||
|
TokenURL: cfg.Auth.TokenURL,
|
||||||
|
UserInfoURL: cfg.Auth.UserInfoURL,
|
||||||
|
ClientID: cfg.Auth.ClientID,
|
||||||
|
ClientSecret: cfg.Auth.ClientSecret,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("failed to apply provider settings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authHandler.Register(se.Router)
|
||||||
transcribeHandler.Register(se.Router)
|
transcribeHandler.Register(se.Router)
|
||||||
|
|
||||||
se.Router.GET("/health", func(e *core.RequestEvent) error {
|
se.Router.GET("/health", func(e *core.RequestEvent) error {
|
||||||
@@ -298,3 +328,21 @@ func main() {
|
|||||||
|
|
||||||
logger.Info("Transcriber service stopped")
|
logger.Info("Transcriber service stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newTelegramController собирает бота и транспорт вокруг него. Токен доходит
|
||||||
|
// до единой точки `internal/adapter/telegram` и дальше не идёт: транспорт его
|
||||||
|
// не видит вовсе, а отказ, который увидит журнал, адреса с токеном не несёт.
|
||||||
|
func newTelegramController(
|
||||||
|
botToken string,
|
||||||
|
cfg tgcontroller.TelegramConfig,
|
||||||
|
transcribeService *service.TranscribeService,
|
||||||
|
jobRepo contract.TranscriptJobRepository,
|
||||||
|
logger *slog.Logger,
|
||||||
|
) (*tgcontroller.TelegramController, error) {
|
||||||
|
bot, err := telegram.NewBot(botToken, logger)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tgcontroller.NewTelegramController(cfg, bot, transcribeService, jobRepo, logger)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-12
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Версия Go названа в проекте четырежды, и сегодня четыре места расходятся:
|
||||||
|
`go.mod` требует `go 1.25.0`, `Dockerfile` собирает на `golang:1.25-alpine`,
|
||||||
|
`CLAUDE.md` обещает «Go 1.25», а `README.md` не называет версию вовсе. На машине
|
||||||
|
разработки стоит `go1.26.5`.
|
||||||
|
|
||||||
|
Число 1.25 никем не назначалось: `go mod tidy` поднял требование модуля,
|
||||||
|
следуя за PocketBase, а образ подтянули следом. Ровно этот же механизм 2026-08-12
|
||||||
|
породил дефект — требование модуля уехало на 1.25, `Dockerfile` остался на
|
||||||
|
`golang:1.24-alpine` с `GOTOOLCHAIN=local`, и образ перестал собираться. Восемь
|
||||||
|
шагов набора проверок и шесть проходов ревью показали зелёное: `go build ./...`
|
||||||
|
идёт на хостовом Go, а образ не собирает ни один шаг. Случай записан в
|
||||||
|
`docs/review.md` за 2026-08-12 и там же назван способ починки — сравнение строк
|
||||||
|
вместо сборки образа.
|
||||||
|
|
||||||
|
Ограничения, в которых работаем: набор проверок обязан оставаться дешёвым и
|
||||||
|
работать без сети и без docker; выкладку это изменение не запускает; сборку
|
||||||
|
образа в набор проверок не заводим — отказ записан.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- одно число версии Go во всех четырёх местах;
|
||||||
|
- шаг набора проверок, который краснеет на расхождении и называет оба числа;
|
||||||
|
- шаг стоит доли секунды и не зависит ни от docker, ни от сети.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- сборка образа шагом набора проверок — дорого, отказ записан в
|
||||||
|
`docs/review.md`;
|
||||||
|
- проверка того, что объявленная версия вообще существует в реестре образов, —
|
||||||
|
это требует сети;
|
||||||
|
- сверка версий прочих инструментов (`golangci-lint`, `task`, `ffmpeg`) — их
|
||||||
|
расхождение так не ломает, и заводить перечень впрок незачем;
|
||||||
|
- `shellcheck` шагом набора проверок. Замер: shell-скриптов в гейте один, три
|
||||||
|
соседних шага — Python в плагинах, линтер к ним неприменим; цена шага — одна
|
||||||
|
строка плюс подавление ложного `SC1007` на идиому `CDPATH= cd`. Отказ всё равно
|
||||||
|
осознанный, но цену называем настоящую, а не «их четыре»;
|
||||||
|
- автоматическая правка разошедшихся мест — шаг набора проверок судит, а не
|
||||||
|
чинит.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Решение 1: версия 1.26, а число выбирает человек
|
||||||
|
|
||||||
|
Берём 1.26 — она стоит на машине разработки (`go1.26.5`), образ
|
||||||
|
`golang:1.26-alpine` в реестре есть, последний релиз тоже `go1.26.5`, а
|
||||||
|
`CGO_ENABLED=0 go build ./...` на ней уже проходит. Требование PocketBase v0.39.10
|
||||||
|
(`go 1.25.0`) она выполняет.
|
||||||
|
|
||||||
|
В `go.mod` пишем `go 1.26.0`, а не `1.26.5`: требование модуля — это нижняя
|
||||||
|
граница, и привязывать её к патчу значит без нужды отсекать сборку на более
|
||||||
|
раннем патче той же минорной версии.
|
||||||
|
|
||||||
|
**Число называет человек, и нормой это не записано намеренно.** `go mod tidy`
|
||||||
|
поднимает требование модуля сам, следуя за зависимостью, и подъём, никем не
|
||||||
|
назначенный, дал сегодняшнее расхождение. Но «выбрал человек» ненаблюдаемо:
|
||||||
|
директива, поднятая инструментом, и директива, назначенная решением, выглядят
|
||||||
|
одинаково, а норма, которую нечем уронить, расходится с кодом молча. Поэтому
|
||||||
|
здесь мотив, а в спеке — то, что проверяется: сборка и тесты на объявленном
|
||||||
|
числе до мерджа.
|
||||||
|
|
||||||
|
**Отвергнуто: остаться на 1.25 и завести только сверку.** Сверка — половина
|
||||||
|
задачи, и она бы прижилась; но тогда сегодняшнее число остаётся тем, которое
|
||||||
|
никто не назначал, и первый же `go mod tidy` следующей зависимости повторит
|
||||||
|
подъём вслепую. Задача собрана из двух половин именно поэтому.
|
||||||
|
|
||||||
|
**Отвергнуто: `toolchain` в `go.mod` вместо подъёма `go`.** Директива
|
||||||
|
`toolchain` заставила бы Go скачивать нужный тулчейн сам, и расхождение с
|
||||||
|
образом перестало бы ломать сборку. Но она же превращает сборку образа в
|
||||||
|
сетевую операцию, а сборочный слой качает тулчейн при каждой сборке. Дороже и
|
||||||
|
менее предсказуемо, чем строка сравнения.
|
||||||
|
|
||||||
|
### Решение 2: новая capability `toolchain`
|
||||||
|
|
||||||
|
Дельта-спека ложится в новую capability `toolchain` — «каким инструментом и какой
|
||||||
|
его версии собирается сервис, и что об этом проверяется до выкладки».
|
||||||
|
|
||||||
|
**Отвергнуто: дописать в `pipeline`.** `pipeline` нормирует прогон воркера и
|
||||||
|
захват задачи — поведение работающего сервиса. Версия сборщика с ним не меняется
|
||||||
|
вместе, а правило гранулярности в `openspec/config.yaml` именно про это: «дробить,
|
||||||
|
когда в одной спеке смешиваются разные заботы».
|
||||||
|
|
||||||
|
**Отвергнуто: обойтись без дельта-спеки.** Изменение вводит проверяемое
|
||||||
|
требование — «расхождение роняет набор проверок», — и требование без дома
|
||||||
|
проверяется только памятью того, кто его завёл. Обещание «образ собирается» уже
|
||||||
|
один раз жило в трёх документах и во всех трёх было неверным.
|
||||||
|
|
||||||
|
**Отвергнуто имя `build`.** Первая редакция называла capability `build`, и на
|
||||||
|
разметке выяснилось, что читать её нельзя: настройка среды разработчика
|
||||||
|
запрещает чтение любого каталога с этим именем. Спека, недоступная проходам
|
||||||
|
ревью, не проверяется ни одним из них, а после архивации осталась бы слепым
|
||||||
|
пятном насовсем. Имя `toolchain` точнее и по существу: предмет здесь —
|
||||||
|
инструмент сборки и его версия, а не сборка как процесс.
|
||||||
|
|
||||||
|
Признаём натяжение: три существующие capability описывают поведение сервиса для
|
||||||
|
его потребителей, а `toolchain` описывает поведение инструмента разработки.
|
||||||
|
Потребитель у него другой — тот, кто собирает сервис. Правило `config.yaml`
|
||||||
|
говорит «поведение **или домен** системы»; инструмент сборки — домен, и именно
|
||||||
|
как домен он здесь и назван. Если capability так и останется с одним
|
||||||
|
требованием, дешевле будет переименовать её, чем расщепить
|
||||||
|
(`RENAMED Requirements`).
|
||||||
|
|
||||||
|
### Решение 3: шаг сверяет все четыре места, а не два
|
||||||
|
|
||||||
|
Минимум по критерию приёмки — `go.mod` против `Dockerfile`. Берём шире: плюс
|
||||||
|
`CLAUDE.md` и `README.md`.
|
||||||
|
|
||||||
|
Причина прямо из дефекта 2026-08-12: **три места из четырёх говорили одно и то
|
||||||
|
же, и неверными были именно они.** Пару `go.mod`↔`Dockerfile` парная сверка
|
||||||
|
тогда поймала бы — та пара как раз разошлась. Чего она не ловит, так это
|
||||||
|
документа, разошедшегося с **согласованным** кодом: сойдись тогда `go.mod` с
|
||||||
|
образом на 1.24, и памятка с README продолжали бы врать молча, а гейт оставался
|
||||||
|
бы зелёным. Сегодня проект ровно в этом состоянии наполовину: `README.md` не
|
||||||
|
называет версию вовсе, а `CLAUDE.md` проверяется только тем, что кто-то её
|
||||||
|
прочтёт.
|
||||||
|
|
||||||
|
Цена: строку о версии придётся держать в форме, которую находит машина. Это же и
|
||||||
|
польза — документ, чья строка перестала находиться, краснеет вместо того, чтобы
|
||||||
|
молча протухнуть.
|
||||||
|
|
||||||
|
**Отвергнуто: сверять только `go.mod` и `Dockerfile`.** Дешевле на три строки
|
||||||
|
скрипта и не ловит половину прошлого дефекта.
|
||||||
|
|
||||||
|
**Отвергнуто: сверять ещё и `config.dist.toml`, `docker/entrypoint.sh` и
|
||||||
|
Ansible-роль в `pet-project-server`.** Версии Go там нет; чужой репозиторий этому
|
||||||
|
набору проверок недоступен.
|
||||||
|
|
||||||
|
### Решение 4: отдельный скрипт в репозитории, а не строка в `Taskfile.yml`
|
||||||
|
|
||||||
|
Шаг живёт файлом `scripts/check-go-version.sh`, а `Taskfile.yml` его зовёт.
|
||||||
|
|
||||||
|
Сверка четырёх мест — это четыре разных способа достать число (директива
|
||||||
|
модуля, тег образа, проза памятки, проза README), сравнение и внятное сообщение
|
||||||
|
со всеми четырьмя. В `Taskfile.yml` это легло бы двадцатью строками shell внутри
|
||||||
|
YAML, где их не читает ни редактор, ни `shellcheck`, а кавычки экранируются
|
||||||
|
дважды. Соседние шаги (`docs`, `tasks`, `openspec`) уже зовут скрипты, и эта
|
||||||
|
форма для набора проверок родная.
|
||||||
|
|
||||||
|
Скрипт лежит в репозитории, а не в плагине: он про этот проект, а не про метод
|
||||||
|
работы.
|
||||||
|
|
||||||
|
Оговорка о выигрыше: `shellcheck` шагом набора проверок этим изменением **не**
|
||||||
|
заводится, и обоснование выше стоит на том, что файл хотя бы **можно** проверить
|
||||||
|
и прочитать глазами, а не на том, что его кто-то проверяет машиной. Заводить
|
||||||
|
линтер оболочки — отдельная работа, и она уезжает урожаем.
|
||||||
|
|
||||||
|
**Отвергнуто: строка shell прямо в `Taskfile.yml`.** Дешевле на один файл,
|
||||||
|
дороже при первом же изменении: правка регулярного выражения в YAML-скаляре
|
||||||
|
ошибается молча.
|
||||||
|
|
||||||
|
**Отвергнуто: написать проверку на Go отдельной командой.** Тогда она попадает в
|
||||||
|
`go build ./...` и `go vet ./...`, а вместе с ней — разбор `Dockerfile` в коде
|
||||||
|
сервиса. Проверка о проекте не должна ехать в бинарник сервиса.
|
||||||
|
|
||||||
|
### Решение 5: форма строки, которую ищет машина
|
||||||
|
|
||||||
|
| Место | Что ищем | Правило множественности |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `go.mod` | единственная строка, начинающаяся с `go ` — первые два числа | директива `toolchain` запрещена: она пятое место |
|
||||||
|
| `Dockerfile` | тег `golang:<мажор>.<минор>[.<патч>][-<база>]`, берём первые два числа | все вхождения `FROM golang:` обязаны давать одно число |
|
||||||
|
| `CLAUDE.md` | образец `Go <мажор>.<минор>` в разделе `## Стек` | ровно одно вхождение в разделе; вне раздела число не читается |
|
||||||
|
| `README.md` | образец `Go <мажор>.<минор>` в разделе `## Технологии` | ровно одно вхождение в разделе; вне раздела число не читается |
|
||||||
|
|
||||||
|
Патч и база образа из тега отбрасываются: спека объявила их свободными, и
|
||||||
|
образец обязан это допускать — иначе `golang:1.26.5-alpine` уронил бы набор
|
||||||
|
проверок на дереве, которое та же спека называет верным.
|
||||||
|
|
||||||
|
Правило множественности заведено не впрок: реализация, молча берущая первое
|
||||||
|
совпадение, судила бы по обновлённой строке и не видела протухшей соседней.
|
||||||
|
|
||||||
|
**Рамка у него — раздел, а не файл, и это правка по находке ревью.** Первая
|
||||||
|
редакция считала вхождения по всему файлу, и на памятке это давало
|
||||||
|
гарантированный ложный красный: она по устройству ведёт историю закрытых долгов
|
||||||
|
(«Два прежних долга закрыты и здесь названы»), и первая же правдивая строка о
|
||||||
|
прошлой версии уронила бы шаг — сообщением, которое толкает чинить не шаг, а
|
||||||
|
исторический документ. Тем же ловился бы любой пример команды в README. Решение
|
||||||
|
человека на чекпоинте: считать по разделу стека, за его пределами число не
|
||||||
|
читать.
|
||||||
|
|
||||||
|
Граница раздела при этом определена явно — следующий заголовок того же или более
|
||||||
|
высокого уровня, и заголовок внутри блока кода за заголовок не считается.
|
||||||
|
Неопределённая граница давала бы **ложное зелёное**: пример в чужом разделе
|
||||||
|
открывал бы раздел стека на пустом месте, и число доставалось бы оттуда, откуда
|
||||||
|
его читать не велено.
|
||||||
|
|
||||||
|
Число не нашлось — это отказ с названным местом, а не «нечего сравнивать»:
|
||||||
|
пропавшая строка иначе выглядела бы как совпадение.
|
||||||
|
|
||||||
|
### Решение 6: шаг судит по репозиторию, а не по машине
|
||||||
|
|
||||||
|
Число берётся чтением файлов. `go` шаг не зовёт вовсе — ни `go mod edit -json`,
|
||||||
|
ни `go list -m`, ни `go env`.
|
||||||
|
|
||||||
|
Способ это не самый удобный: разбор директивы через `go mod edit -json` короче и
|
||||||
|
надёжнее регулярного выражения. Он же и опасный: вызов `go` тянет за собой
|
||||||
|
`GOTOOLCHAIN`, `$PATH` и установленный тулчейн, а при непустом `GOTOOLCHAIN` `go`
|
||||||
|
вправе полезть в сеть за нужной версией — то есть требование «без сети»
|
||||||
|
перестало бы выполняться. Хуже того, исход шага стал бы зависеть от машины, а не
|
||||||
|
от коммита, — ровно та подмена, которая держала дефект 2026-08-12 невидимым:
|
||||||
|
`go build ./...` шёл на хостовом Go и потому был зелёным, пока образ не
|
||||||
|
собирался.
|
||||||
|
|
||||||
|
Отсюда же и словарь кодов выхода: 0 сошлось, 1 расхождение, 2 ошибка
|
||||||
|
употребления, 3 окружение. Свой словарь заводить нельзя — раздел «Гейт» в
|
||||||
|
`CLAUDE.md` объявляет его общим для проверочных шагов, и четвёртый шаг с
|
||||||
|
собственной семантикой сделал бы это утверждение неверным.
|
||||||
|
|
||||||
|
Оболочка — POSIX `sh`, без GNU-only флагов (`grep -P`, `sed -E` с
|
||||||
|
расширениями, `mapfile`). Пути шаг строит от корня репозитория, а не от текущего
|
||||||
|
каталога: иначе его исход зависел бы от того, откуда он запущен.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **Скрипт ищет число прозой документа, и переписанная строка сломает шаг** →
|
||||||
|
сообщение отказа называет место, где число не нашлось, поэтому чинится
|
||||||
|
однозначно и сразу. Ложное зелёное здесь невозможно по построению: не нашлось
|
||||||
|
— отказ.
|
||||||
|
- **Четыре места вместо двух — четыре места, которые надо править при подъёме
|
||||||
|
версии** → это цена решения 3, и она осознанная: молчаливо врущий документ
|
||||||
|
дороже одной лишней правки.
|
||||||
|
- **1.26 может оказаться несовместимой с зависимостью, которую мы ещё не
|
||||||
|
трогали** → проверяется до мерджа: `task image` собирает образ на объявленной
|
||||||
|
версии, а `go test ./...` идёт на хостовом go1.26.5. Обе проверки в критериях
|
||||||
|
приёмки.
|
||||||
|
- **Шаг проверяет согласованность чисел, но не то, что образ собирается** →
|
||||||
|
осознанный остаток. Собранный образ по-прежнему видит только тот, кто позвал
|
||||||
|
`task image` руками; сверка ловит класс расхождений, а не все отказы сборки.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Сервис собирается тремя разными числами версии Go сразу: модуль требует одно,
|
||||||
|
сборочный образ берёт другое, памятка обещает третье, а README не называет
|
||||||
|
никакого. Расхождение этих чисел никто не проверяет, и один раз оно уже уехало в
|
||||||
|
работу: 2026-08-12 образ не собирался вовсе, а полный набор проверок и шесть
|
||||||
|
проходов ревью показали зелёное. Поймали случайно, руками. Пока сравнения нет,
|
||||||
|
то же самое повторится на следующем подъёме версии — а замечено будет в момент
|
||||||
|
выкладки, когда чинить дороже всего.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Версия Go, на которой собирается сервис, поднимается до 1.26 и называется
|
||||||
|
**одним и тем же числом** в четырёх местах: требование модуля, сборочный
|
||||||
|
образ, памятка разработчику и README. Сегодня README не называет его вовсе —
|
||||||
|
строка заводится.
|
||||||
|
- В набор проверок добавляется шаг, который сравнивает объявленные числа между
|
||||||
|
собой и краснеет, называя все четыре места и число каждого. Шаг сравнивает
|
||||||
|
строки: он не собирает образ, не ходит в сеть, не требует docker и не
|
||||||
|
спрашивает, что за Go установлен на машине, — судит он по тому, что лежит в
|
||||||
|
репозитории.
|
||||||
|
- Собирать образ проверкой мы по-прежнему **не** будем — это дорого, и отказ
|
||||||
|
осознанный. Сравнение строк ловит тот же класс расхождений за доли секунды.
|
||||||
|
- Разница в третьем числе версии между модулем и образом остаётся законной:
|
||||||
|
сравниваются только первые два.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `toolchain`: каким инструментом и какой его версии собирается сервис, и что об
|
||||||
|
этом проверяется до выкладки. Первое требование capability — согласованность
|
||||||
|
объявленной версии инструмента сборки и её проверка набором проверок.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
Нет. Поведение сервиса для его потребителей не меняется: запись принимается,
|
||||||
|
расшифровывается и хранится ровно как прежде.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- требование версии в `go.mod`;
|
||||||
|
- сборочный слой `Dockerfile`;
|
||||||
|
- строка о версии в `CLAUDE.md` и в `README.md`;
|
||||||
|
- набор шагов `task gate` в `Taskfile.yml` и описание семантики набора проверок
|
||||||
|
в `CLAUDE.md`;
|
||||||
|
- запись журнала дефектов `docs/review.md` за 2026-08-12 получает починку,
|
||||||
|
которую обещала.
|
||||||
|
|
||||||
|
Выкладка этим изменением не запускается. Внешних зависимостей изменение не
|
||||||
|
трогает: PocketBase требует не ниже 1.25, и 1.26 это требование выполняет.
|
||||||
@@ -0,0 +1,620 @@
|
|||||||
|
# Ревью кода: go-1-26-upgrade — триаж
|
||||||
|
|
||||||
|
База диффа: `HEAD` (коммит `aa20b22`), изменение целиком в рабочем дереве. Дата
|
||||||
|
прогона: 2026-08-12.
|
||||||
|
|
||||||
|
## Сводка
|
||||||
|
|
||||||
|
**Размер, сложность, метка.** Размер — среднее: `tasks.md` 20 шагов в 4 разделах,
|
||||||
|
8 файлов; `internal/` не тронут ни строкой. Сложность — знакомое: все узлы
|
||||||
|
названы поимённо до работы, шагов формы «разобраться/выяснить» нет. **Метка
|
||||||
|
`medium`** (максимум по осям), **режим — по графу**. Триггеры `docs/review.md`
|
||||||
|
проверены все три группы, ни один пункт не совпал.
|
||||||
|
|
||||||
|
**Состояние гейта: ЗЕЛЁНЫЙ.** Подтверждено собственным прогоном триажа, а не
|
||||||
|
только отчётом прохода: `task gate BASE=HEAD` → exit 0. Все девять шагов зелёные,
|
||||||
|
включая новый `go-version`. Одно унаследованное замечание `tasks.py`
|
||||||
|
(`any-audio-source`: цель без задач и без тега `decomposed`) шаг не роняет и к
|
||||||
|
диффу отношения не имеет.
|
||||||
|
|
||||||
|
### План разметки задачи с исходом по каждой теме
|
||||||
|
|
||||||
|
| тема | дом | глубина | кто закрывает | исход |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| requirements | `openspec/specs/` + дельта `specs/toolchain/spec.md` | разбор | specs | **закрыта**, 4 находки (S1–S4) + 2 блока наблюдений |
|
||||||
|
| autotests | `CLAUDE.md`, раздел «Гейт» | — | autotests | **закрыта**, 3 находки (A1–A3) + отчёт гейта |
|
||||||
|
| conventions | `docs/conventions/` | разбор | code | **закрыта**, 3 находки (C1–C3) + 3 наблюдения ниже порога |
|
||||||
|
| architecture | `docs/architecture.md` + источник `docs/passport.md` | разбор | basics | **закрыта**, 1 находка (B1) + 4 пункта «дешевле переделать» |
|
||||||
|
| security | `docs/security.md` | разбор | basics | **закрыта**, находок нет: тема неприменима к диффу целиком |
|
||||||
|
| operations | `docs/architecture.md` §Эксплуатация + источник `docs/database.md` | разбор | basics | **закрыта**, 1 находка (B3); 4 вопроса из 6 неприменимы |
|
||||||
|
|
||||||
|
**Тем без отчёта нет.** Все шесть тем ядра вернули отчёты; своих тем сверх ядра у
|
||||||
|
проекта нет. Ни одна тема не осталась непроверенной по причине «проход не
|
||||||
|
запускался».
|
||||||
|
|
||||||
|
**Находок на входе:** 13 нумерованных (A1–A3, S1–S4, C1–C3, B1–B3) плюс 15
|
||||||
|
ненумерованных содержательных пунктов (3 «поведение вне спеки», 5 «границы
|
||||||
|
спеки», 3 наблюдения ниже порога, 4 «дешевле переделать до мерджа») = **28
|
||||||
|
позиций**. **Осталось в основных секциях: 6** — 2 блокирующие и 4 «стоит
|
||||||
|
исправить сейчас». Слито по причине 4 пары, понижено до гипотез 3, уехало в
|
||||||
|
promote 5, выброшено 6 (все названы поимённо).
|
||||||
|
|
||||||
|
### Сигнал о заниженной метке
|
||||||
|
|
||||||
|
**Сигнал подан двумя проходами независимо — `code` и `basics`.** Оба назвали одно
|
||||||
|
и то же: изменение вводит новое понятие (capability `toolchain` — первая, что
|
||||||
|
описывает не поведение сервиса, а инструмент разработки), новый каталог верхнего
|
||||||
|
уровня `scripts/` и новый шаг гейта; `design.md` сам записывает нерешённое
|
||||||
|
натяжение в размещении capability. Оба сказали, что метка `large` дала бы
|
||||||
|
отдельный проход `review-architecture`, и оба отказались решать за конвейер.
|
||||||
|
|
||||||
|
**Провенанс один, приоритет два.** Согласие двух проходов — это одна модель,
|
||||||
|
высказавшаяся дважды: `confidence` оно не повышает, приоритет повышает. Одна
|
||||||
|
строка с двумя провенансами, а не два пункта.
|
||||||
|
|
||||||
|
**Суждение триажа — факт для человека, не команда конвейеру:**
|
||||||
|
|
||||||
|
1. **По записанному правилу разметка верна.** `docs/review.md`, «Триггеры метки»:
|
||||||
|
в группе «Крупное здесь» ближайший пункт — «каркас приложения: сборка
|
||||||
|
фронтенда, раздача статики и шаг гейта разом» — требует трёх вещей сразу,
|
||||||
|
здесь только шаг гейта. В «Незнакомое здесь» не совпал ни один из шести: форма
|
||||||
|
решения (сравнение строк `sed`/`awk`, без `go` и без docker) была названа до
|
||||||
|
работы. Отрицательный тест пройден: миграции, формата файла, контракта API и
|
||||||
|
имени ключа конфига изменение не трогает. `review-scope` не ошибся против
|
||||||
|
правила, которое у него было.
|
||||||
|
2. **Ось, на которую указали проходы, в правиле отсутствует.** Ни один триггер не
|
||||||
|
говорит о заведении новой capability и о новом каталоге верхнего уровня. А
|
||||||
|
именно эта ось дала **обе блокирующие находки прогона** — обе про канон, а не
|
||||||
|
про код. Сигнал верен по существу: правило разметки не видит того, что в этом
|
||||||
|
изменении оказалось самым дорогим.
|
||||||
|
3. **Перезапуска это не требует, метку задним числом не пересматривают.** Тема
|
||||||
|
`architecture` дома не лишилась и без отчёта не осталась — её закрыл `basics`
|
||||||
|
на глубине «разбор». Честный остаток: тему смотрел проход широкого профиля, а
|
||||||
|
не специализированный, и вопрос «правильно ли выбрано имя и дом capability»
|
||||||
|
остался без независимого разбора (H-1).
|
||||||
|
4. **Что с этим делать — не здесь.** Кандидат в правило вынесен в promote (P-5).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Блокирует мердж (2 из 3)
|
||||||
|
|
||||||
|
### B-1. Требование «ровно одно вхождение» станет нормой в форме, которая про `Dockerfile` уже неверна, а на `CLAUDE.md` уронит гейт на правдивой строке
|
||||||
|
|
||||||
|
- Файл: `openspec/changes/go-1-26-upgrade/specs/toolchain/spec.md:16-18` против
|
||||||
|
`scripts/check-go-version.sh:127-131` и `:147-153`; правильные слова уже лежат в
|
||||||
|
`openspec/changes/go-1-26-upgrade/design.md:158-163`
|
||||||
|
- Severity: **major** | Confidence: **high**
|
||||||
|
- Действие: **развилка**
|
||||||
|
- Найдено проходами: `specs` (S1 и пункт «Границы спеки»), `basics` (пункт «Рамка
|
||||||
|
правила „ровно одно вхождение“»). **Слито триажем по причине:** причина одна —
|
||||||
|
требование написано пофайлово единым правилом, а четыре места устроены
|
||||||
|
по-разному. Правится одним абзацем.
|
||||||
|
- **Оракул — три прогона на копии дерева** (копии в scratchpad; рабочее дерево не
|
||||||
|
тронуто, `git status --porcelain` до и после совпадает):
|
||||||
|
1. второй сборочный слой `FROM docker.io/library/golang:1.26-alpine AS second` →
|
||||||
|
**exit 0**. Норма гласит: «Каждое место MUST называть версию ровно один раз.
|
||||||
|
Второе вхождение числа в том же месте MUST считаться отказом», и перечень
|
||||||
|
мест включает `Dockerfile`. Код нормы не исполняет и исполнять не должен:
|
||||||
|
`collect Dockerfile "$(read_dockerfile)" 0` передаёт `strict=0` намеренно.
|
||||||
|
Контроль: тот же второй слой с `golang:1.25-alpine` → exit 1, «Dockerfile
|
||||||
|
называет несколько разных версий» — то есть совпадение слоёв проверяется,
|
||||||
|
единственность нет;
|
||||||
|
2. в `CLAUDE.md` дописана правдивая строка `- прежде собирались на Go 1.25; долг
|
||||||
|
закрыт задачей go-1-26-upgrade` → **exit 1**, «CLAUDE.md называет несколько
|
||||||
|
разных версий»;
|
||||||
|
3. в `README.md` дописан блок кода с `# нужен Go 1.26` → **exit 1**, «README.md
|
||||||
|
называет версию больше одного раза».
|
||||||
|
- Последствие. **Со стороны `Dockerfile`** — молчаливое расхождение нормы и кода:
|
||||||
|
после архивации нормой станет спека, а не `design.md`. Многослойная сборка с
|
||||||
|
двумя `FROM golang:` — законная форма. Ревьюер следующего изменения увидит
|
||||||
|
`strict=0`, прочтёт MUST и «починит» скрипт, уронив гейт на рабочем
|
||||||
|
`Dockerfile`; обратный исход не лучше — норма останется ложью, на которую
|
||||||
|
сошлются. **Со стороны документов** — гарантированный ложный красный:
|
||||||
|
`CLAUDE.md` по устройству ведёт историю (раздел «Гейт» прямо говорит «Два
|
||||||
|
прежних долга закрыты и здесь названы»), и первая же правдивая запись о прошлой
|
||||||
|
версии роняет шаг. По правилу проекта «Что считается сломанным — новый красный
|
||||||
|
шаг гейта… чинится прежде любой другой работы» это остановит работу, а
|
||||||
|
сообщение «называет несколько разных версий» толкает чинить не скрипт, а
|
||||||
|
исторический документ, то есть подделывать запись. `README.md` ловится тем же на
|
||||||
|
любом блоке кода с командой установки.
|
||||||
|
- Почему до мерджа: спека замерзает архивацией, после неё правка MUST — отдельное
|
||||||
|
изменение. Сегодня это один абзац.
|
||||||
|
- **Вопрос человеку:**
|
||||||
|
- **Вариант А (дешёвый, ожидаемый).** Развести правило по местам прямо в
|
||||||
|
требовании, дословно как уже написано в `design.md:158-163`: единственности
|
||||||
|
требовать от `go.mod`, `CLAUDE.md` и `README.md`, а от `Dockerfile` —
|
||||||
|
совпадения всех вхождений `FROM golang:`. Плюс сузить рамку для документов:
|
||||||
|
правило считает не файл целиком, а помеченную строку стека (или раздел
|
||||||
|
«Стек»/«Технологии»). Цена: абзац спеки + `read_doc` начинает читать раздел, а
|
||||||
|
не файл — несколько строк скрипта. Сценарий «Одно место называет два разных
|
||||||
|
числа» остаётся верным и правки не требует.
|
||||||
|
- **Вариант Б (дешевле сейчас, дороже потом).** Развести только `Dockerfile`
|
||||||
|
(правка чисто текстовая, кода не трогает), а цену «файл целиком» для
|
||||||
|
документов принять осознанно и записать остатком в спеке: «`CLAUDE.md` не
|
||||||
|
ведёт истории версий Go; запись о прошлой версии живёт в `docs/review.md`».
|
||||||
|
Тогда красный на истории — не сюрприз, а объявленный запрет.
|
||||||
|
|
||||||
|
### B-2. Канонический перечень capability назовёт три из четырёх ровно в момент архивации, и промолчит именно о новой
|
||||||
|
|
||||||
|
- Файл: `docs/architecture.md:11-26`; `openspec/changes/go-1-26-upgrade/tasks.md`,
|
||||||
|
раздел «3. Документы» (в нём `docs/architecture.md` нет)
|
||||||
|
- Severity: **minor** | Confidence: **high**
|
||||||
|
- Действие: **инлайн**
|
||||||
|
- Найдено проходами: `specs` (S3), `basics` (B1). **Дубль по причине, слит;**
|
||||||
|
предложение взято более широкое, от `basics`.
|
||||||
|
- Оракул — поимённые положения, все перепроверены триажем:
|
||||||
|
- `openspec/config.yaml:25-26` дословно: «Состояние спек и правило „первая
|
||||||
|
задача, трогающая поведение, заводит спеку своей capability“ —
|
||||||
|
docs/architecture.md, преамбула». Дом назначен, и он один;
|
||||||
|
- `docs/architecture.md:11` дословно: «Заведены три capability:»; `proposal.md`
|
||||||
|
заводит четвёртую;
|
||||||
|
- прецедент: прошлое изменение правило этот список **тем же коммитом**, что и
|
||||||
|
реализацию — `git show 01cc31d -- docs/architecture.md` даёт `-Заведены две
|
||||||
|
capability, и каждая описана частично:` / `+Заведены три capability:`;
|
||||||
|
- `tasks.md`, раздел 3, содержит ровно два пункта — `CLAUDE.md` и
|
||||||
|
`docs/review.md`; обзора архитектуры в нём нет;
|
||||||
|
- машинного оракула нет и быть не может: `CLAUDE.md`, «Гейт» — «согласованность
|
||||||
|
документов между собой и с кодом — её судят агенты, зовёт их скилл
|
||||||
|
`av-dev-docs:healthcheck`, и звать его надо руками».
|
||||||
|
- Последствие. После архивации в `openspec/specs/` появится четвёртая capability,
|
||||||
|
о которой единственный назначенный обзор молчит. Следующий, кто возьмётся за
|
||||||
|
версию инструмента сборки, пойдёт по указанному дому, четвёртой строки не найдёт
|
||||||
|
и либо заведёт вторую спеку на ту же тему, либо припишет требование в `pipeline`
|
||||||
|
— ровно то, от чего `design.md:79-82` отказался. Отказ молчаливый: гейт этого
|
||||||
|
класса не ловит по устройству.
|
||||||
|
- Предложение (инлайн, три правки):
|
||||||
|
1. четвёртая строка перечня в `docs/architecture.md` — про `toolchain`, со
|
||||||
|
ссылкой на спеку и задачу-источник;
|
||||||
|
2. оговорка к преамбуле: сегодня она читается «поведение системы здесь не
|
||||||
|
описывается — нормативно оно живёт в `openspec/specs/`», а `toolchain`
|
||||||
|
описывает **не** поведение сервиса; без оговорки преамбула становится
|
||||||
|
неверной в момент архивации;
|
||||||
|
3. пункт в `tasks.md`, раздел «3. Документы», чтобы правка не потерялась.
|
||||||
|
|
||||||
|
**Третий слот блокирующих не занят** — кандидатов нет: всё прочее либо не
|
||||||
|
замерзает мерджем, либо не имеет оракула.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Стоит исправить сейчас (4 из 4)
|
||||||
|
|
||||||
|
### N-1. Единственный новый страж проекта не покрыт ничем: следующая правка его регулярных выражений перестанет ловить случай молча
|
||||||
|
|
||||||
|
- Файл: `scripts/check-go-version.sh` (весь); `tasks.md:96-111`
|
||||||
|
- Severity: **major** | Confidence: **high**
|
||||||
|
- Действие: **развилка** (новая работа, за границей объявленного scope)
|
||||||
|
- Найдено проходами: `autotests` (A1), `specs` (S4). **Дубль по причине, слит.**
|
||||||
|
- Оракул (перепроверено триажем):
|
||||||
|
- `find . -iname '*check-go-version*'` → ровно один файл, сам скрипт; тестов нет;
|
||||||
|
- `grep -rln 'check-go-version' --include='*_test.go' --include='*.bats'
|
||||||
|
--include='*test*.sh' .` → пусто;
|
||||||
|
- `task gate` прогоняет скрипт ровно на согласованном дереве, то есть проверяет
|
||||||
|
один сценарий дельты из четырнадцати — «Версии совпадают»;
|
||||||
|
- раздел «4. Проверка» в `tasks.md` перечисляет 11 сценариев, все `[x]`, но ни
|
||||||
|
один не зафиксирован ничем, кроме прозы: это разовый ручной прогон, а не
|
||||||
|
оракул;
|
||||||
|
- положение проекта, которое здесь нарушено, записано: `docs/review.md»,
|
||||||
|
«Типовые узлы» → «Любой узел»: «изменённое место покрыто хоть одним
|
||||||
|
**проходящим** тестом».
|
||||||
|
- Последствие. POSIX-sh с разбором четырёх разных форм через `sed`/`awk` — класс
|
||||||
|
кода, где правка одного образца ломает смежный случай беззвучно. `go vet`,
|
||||||
|
`golangci-lint` и `gofmt` shell не видят; `shellcheck` в гейт сознательно не
|
||||||
|
введён. Правка третьего аргумента `collect` или образца `read_doc` снимает
|
||||||
|
проверку молча, и заметят это на следующем подъёме версии — примерно через год,
|
||||||
|
и ровно тем способом, каким был найден дефект 2026-08-12: образ перестал
|
||||||
|
собираться, и этого не увидел никто. Класс — «молчание»: страж перестаёт
|
||||||
|
стеречь, не сообщая об этом.
|
||||||
|
- **Вопрос человеку:**
|
||||||
|
- **А. Сейчас, в этом изменении.** Тест-скрипт рядом
|
||||||
|
(`scripts/check-go-version.test.sh`) и отдельный шаг гейта: десяток
|
||||||
|
мутационных прогонов на временной копии дерева — по одному на сценарий дельты.
|
||||||
|
Цена ~100 строк shell плюс шаг Taskfile. Плюс: страж проверен ровно тем
|
||||||
|
способом, каким `docs/review.md` велит проверять оракулы.
|
||||||
|
- **Б. Задачей урожая, вместе с `shellcheck` (P-1).** Один шаг гейта, гоняющий и
|
||||||
|
линтер оболочки, и мутационные прогоны. Плюс: не раздувает изменение, scope
|
||||||
|
остаётся заявленным. Минус: между мерджем и задачей страж не проверен ничем, и
|
||||||
|
правки в этот промежуток пройдут вслепую.
|
||||||
|
- **В. Принять остаток осознанно** и записать строкой в `docs/review.md`,
|
||||||
|
«Недоступно проверке» → «Перестали проверять сознательно», с ценой. Плюс:
|
||||||
|
честно и бесплатно. Минус: следующий промах этого класса будет уже вторым.
|
||||||
|
|
||||||
|
### N-2. Новое машинное правило не попало в единственный индекс механизированного, и следующий проход конвенций будет сверять версии руками
|
||||||
|
|
||||||
|
- Файл: `docs/conventions/README.md:50-65`
|
||||||
|
- Severity: **minor** | Confidence: **medium**
|
||||||
|
- Действие: **инлайн**
|
||||||
|
- Найдено проходом: `code` (C3)
|
||||||
|
- Оракул — поимённое положение конвенций: `docs/conventions/README.md:52-53`
|
||||||
|
(«Проверяется командами из CLAUDE.md; прозой не дублируется») и `:64-65` («Не
|
||||||
|
названное здесь место механизации означает, что проход по конвенциям будет
|
||||||
|
добросовестно проверять уже проверенное»). Строка про `docs.py check` в той же
|
||||||
|
таблице — прямой прецедент внесения шагов гейта.
|
||||||
|
- Последствие. Дифф заводит новое машинное правило и не вносит его в индекс,
|
||||||
|
объявленный исчерпывающим. Следующий проход по конвенциям и следующий человек
|
||||||
|
будут считать согласованность версий непроверенной и сверять её руками. Заодно
|
||||||
|
это единственное место в `docs/conventions/`, откуда новый скрипт вообще был бы
|
||||||
|
виден: сегодня из дома конвенций он не виден никак.
|
||||||
|
- Предложение: строка в таблицу — `| Одно число версии Go в go.mod, Dockerfile,
|
||||||
|
CLAUDE.md и README.md | Taskfile.yml → шаг go-version
|
||||||
|
(scripts/check-go-version.sh) |`.
|
||||||
|
|
||||||
|
### N-3. Сломанное окружение шаг объявит расхождением версий и назовёт невиновный файл
|
||||||
|
|
||||||
|
- Файл: `scripts/check-go-version.sh:105-119`, `:147-153`
|
||||||
|
- Severity: **minor** | Confidence: **high** (проход давал `medium`; поднято
|
||||||
|
прогоном)
|
||||||
|
- Действие: **инлайн**
|
||||||
|
- Найдено проходом: `code` (C1)
|
||||||
|
- **Оракул — два прогона на копии дерева:**
|
||||||
|
1. `chmod a-r README.md && sh scripts/check-go-version.sh` →
|
||||||
|
```
|
||||||
|
sed: can't read .../README.md: Permission denied
|
||||||
|
check-go-version: README.md не называет версию Go
|
||||||
|
Объявленная версия Go по местам:
|
||||||
|
go.mod 1.26
|
||||||
|
Dockerfile 1.26
|
||||||
|
CLAUDE.md 1.26
|
||||||
|
README.md версия не названа
|
||||||
|
exit=1
|
||||||
|
```
|
||||||
|
2. прогон с `PATH`, где нет `awk` → **exit 127**, код вне словаря вовсе.
|
||||||
|
|
||||||
|
Для сравнения, штатные пути проверены и корректны: нет файла места → 3, лишний
|
||||||
|
аргумент → 2, директива `toolchain` → 1, согласованное дерево → 0.
|
||||||
|
- Последствие. Значения добываются подстановкой команд в **аргументе** (`collect
|
||||||
|
README.md "$(read_doc "$readmemd")" 1`), а POSIX теряет код возврата подстановки,
|
||||||
|
стоящей в аргументе простой команды. Любой отказ чтения — файл есть, но
|
||||||
|
нечитаем; урезанный `PATH`; сломанный апплет busybox — даёт пустой вход,
|
||||||
|
`collect` видит `total -eq 0` и печатает утверждение **о содержимом файла** там,
|
||||||
|
где сломалось окружение. По словарю, который этот же дифф и расширил, 1 значит
|
||||||
|
«дрейф», 3 — «окружение», 4 — «внутренний сбой»; кода 4 скрипт не возвращает ни
|
||||||
|
на одном пути.
|
||||||
|
- **Честная оценка веса.** Ущерб невелик: `sed` печатает свою причину строкой
|
||||||
|
выше, так что человек у терминала подсказку видит, а `task gate` различает
|
||||||
|
только ноль и не-ноль. Вероятность низкая — CI у проекта нет, гейт гоняет
|
||||||
|
владелец на своей машине. Находка остаётся потому, что шаг гейта — источник, на
|
||||||
|
который смотрят как на истину, и ложное утверждение о конкретном файле из такого
|
||||||
|
источника дороже своей вероятности.
|
||||||
|
- Предложение: добывать значения через промежуточную переменную с проверкой кода,
|
||||||
|
а не в аргументе — четыре места, по одному на источник.
|
||||||
|
|
||||||
|
### N-4. Цена отказа от `shellcheck` названа в памятке вчетверо, и вопрос от этого отложится снова
|
||||||
|
|
||||||
|
- Файл: `CLAUDE.md:122-123`; то же в `design.md:37-38`
|
||||||
|
- Severity: **minor** | Confidence: **high**
|
||||||
|
- Действие: **инлайн**
|
||||||
|
- Найдено проходом: `code` (C2); сюда же ушла находка `autotests` A3 как замер
|
||||||
|
цены.
|
||||||
|
- Оракул — замер, снятый триажем на этом прогоне:
|
||||||
|
- `find . -path ./.git -prune -o -name '*.sh' -print` → ровно два файла:
|
||||||
|
`./scripts/check-go-version.sh` и `./docker/entrypoint.sh`; в гейте из них
|
||||||
|
один. Остальные три шага гейта — `docs.py`, `tasks.py`, `openspec.py` — Python
|
||||||
|
и лежат в плагинах вне репозитория;
|
||||||
|
- `shellcheck scripts/check-go-version.sh` → **одно** замечание, SC1007 на
|
||||||
|
`root=$(CDPATH= cd -- ... )`, и оно ложное: `CDPATH= ` — идиома очистки
|
||||||
|
переменной перед `cd`. Значит заведение линтера стоит одной строки шага плюс
|
||||||
|
одной директивы подавления.
|
||||||
|
- Последствие. Строка «`shellcheck` для скриптов гейта — их четыре, и заводить им
|
||||||
|
линтер надо разом» смешивает «скриптов в гейте четыре» с «`shellcheck` применим
|
||||||
|
к четырём». Применим он к одному. Следующий прочтёт в памятке цену «надо разом,
|
||||||
|
четыре штуки» и отложит вопрос снова — при том что нелинтуемым остаётся ровно
|
||||||
|
тот файл, который проход `code` был вынужден разбирать глазами построчно, а
|
||||||
|
триаж — прогонять руками.
|
||||||
|
- Предложение: переписать пункт по факту — «shell-скрипт в гейте один; три
|
||||||
|
соседних шага — Python в плагинах. Линтер не заведён; цена ему одна строка шага
|
||||||
|
и одно подавление SC1007». Ту же правку в `design.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Гипотезы без доказательства
|
||||||
|
|
||||||
|
### H-1. Имя и дом capability `toolchain` замерзают мерджем (понижено: оракула нет)
|
||||||
|
|
||||||
|
Из прохода `basics`, «Дешевле переделать до мерджа». После архивации спека уезжает
|
||||||
|
в `openspec/specs/` насовсем; `design.md` сам признаёт, что при одном требовании
|
||||||
|
переименовать дешевле, чем расщепить. Вопрос дома шире: четыре однородных шага
|
||||||
|
набора проверок живут в двух разных домах — у трёх плагинных дома нет вовсе, у
|
||||||
|
четвёртого есть нормативная спека, и эта асимметрия становится постоянной.
|
||||||
|
|
||||||
|
**Почему понижено.** Оракула нет и построить его нечем: это суждение о
|
||||||
|
правильности имени, а не о поведении. Плюс два смягчающих факта: стадия ревью
|
||||||
|
дизайна (`specs` + rubric) прошла до кода и её замечания отработаны — вопрос уже
|
||||||
|
был на столе; `CLAUDE.md`, «Необратимое», имени capability не перечисляет.
|
||||||
|
Severity снята.
|
||||||
|
|
||||||
|
**Остаток честный:** независимого архитектурного разбора у этого вопроса не было —
|
||||||
|
на метке `medium` отдельный проход не запускается, тему закрывал `basics` широким
|
||||||
|
профилем. Это и есть содержание сигнала о заниженной метке.
|
||||||
|
|
||||||
|
### H-2. Пересборка того же коммита через месяц даст другой `ffmpeg` (понижено: замера нет, строка не из этого диффа)
|
||||||
|
|
||||||
|
Из прохода `basics` (B3). `Dockerfile:26` — рантайм-слой `alpine:latest`, а
|
||||||
|
`Taskfile.yml:93` собирает с `--pull`: два образа из одного коммита с разницей в
|
||||||
|
неделю несут разные `ffmpeg`. Регрессия конвертации после такой пересборки
|
||||||
|
выглядит как задачи в `failed` при пустом диффе репозитория, и откат на прежний
|
||||||
|
коммит её не чинит. Класс тот же, ради которого написан весь новый шаг:
|
||||||
|
объявленное и собранное расходятся, и сверять некому.
|
||||||
|
|
||||||
|
**Почему понижено.** Замера нет — ни одного числа о том, как часто и насколько
|
||||||
|
меняется `ffmpeg` в `alpine:latest`; снять на этом прогоне нечем. Строка внесена
|
||||||
|
не этим изменением, только активирована им. `Confidence: medium`.
|
||||||
|
|
||||||
|
### H-3. «Два дома у семантики шага» — проверено и не подтвердилось
|
||||||
|
|
||||||
|
Из прохода `basics`. Утверждение: семантика шага описана и в `CLAUDE.md` «Гейт», и
|
||||||
|
в спеке `toolchain`, а `openspec/config.yaml` предупреждает, что «второй дом факта
|
||||||
|
расходится с первым молча».
|
||||||
|
|
||||||
|
**Проверено триажем:** спека `spec.md:93-96` не пересказывает словарь, а
|
||||||
|
**ссылается** на него — «раздел „Гейт“ в `CLAUDE.md` объявляет словарь общим». Это
|
||||||
|
уже правильная форма: один дом факта, вторая точка — ссылка. Последствия не
|
||||||
|
построено, находкой не выводится.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Promote candidates
|
||||||
|
|
||||||
|
- **P-1. `shellcheck` шагом набора проверок.** Цена замерена на этом прогоне:
|
||||||
|
файлов `.sh` два, в гейте один, единственное сегодняшнее замечание — ложный
|
||||||
|
SC1007 на идиому `CDPATH= cd`. Шаг стоит одной строки плюс одной директивы
|
||||||
|
подавления.
|
||||||
|
- **P-2. Род узла «скрипт набора проверок» в `docs/review.md`, «Типовые узлы».**
|
||||||
|
Сегодня перечень родов покрывает только рантайм. Скрипт гейта — новый род с
|
||||||
|
собственными проверяемыми свойствами: отличает «расхождение» от «сломанного
|
||||||
|
окружения», исход есть функция коммита, покрыт мутационным прогоном. Без этого
|
||||||
|
рода свойство «изменённое место покрыто хоть одним проходящим тестом» к shell не
|
||||||
|
приложено ничем, и находка N-1 в следующий раз опять будет добываться с нуля.
|
||||||
|
- **P-3. Обёртки `Taskfile` отдают 1, когда скрипта нет, а словарь велит 3.** Из
|
||||||
|
S2/B2, слитых по причине. **Но так делают все четыре обёртки** — `docs`,
|
||||||
|
`tasks`, `openspec` и новая `go-version`: новый шаг лишь повторил существующий
|
||||||
|
рисунок, и дефектом **этого** диффа это не является. Сам скрипт при отсутствующем
|
||||||
|
месте выходит корректно — 3 (проверено). Правило, а не правка: привести все
|
||||||
|
четыре обёртки к 3 разом либо убрать «или файл не найден» из описания кода 3 и
|
||||||
|
объявить `exit 1` нормой для «скрипта шага нет».
|
||||||
|
- **P-4. Норму «версия внешнего инструмента объявлена числом и сверяется»
|
||||||
|
распространить на рантайм-базу образа.** Из H-2. Non-Goal этого изменения
|
||||||
|
записан; кандидат в отдельную задачу.
|
||||||
|
- **P-5. Триггер метки: изменение, заводящее новую capability или новый каталог
|
||||||
|
верхнего уровня.** Из сигнала о заниженной метке. Сегодня «Триггеры метки» видят
|
||||||
|
только объём и незнакомость формы решения; ось «изменение трогает канон» в них
|
||||||
|
отсутствует, а на этом прогоне именно она дала обе блокирующие находки.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Границы покрытия
|
||||||
|
|
||||||
|
Секция не сокращается. Без неё формулировка «критичных проблем не обнаружено»
|
||||||
|
запрещена — и здесь она не употребляется.
|
||||||
|
|
||||||
|
### План: темы, дома, глубины
|
||||||
|
|
||||||
|
Все шесть тем ядра размечены, у всех есть дом, все закрыты — таблица в сводке.
|
||||||
|
**Тем без дома нет. Тем без отчёта нет. Своих тем сверх ядра проект не
|
||||||
|
объявляет.** Глубина «разбор» у пяти тем, у `autotests` глубина не назначалась.
|
||||||
|
|
||||||
|
### Какие проходы запускались
|
||||||
|
|
||||||
|
На метке `medium`, в режиме «по графу», запускались четыре: `autotests`, `specs`,
|
||||||
|
`code`, `basics`. Стадия ревью дизайна (`specs` + `rubric`) прошла раньше, до
|
||||||
|
кода, и её замечания отработаны — на этом прогоне она не повторялась.
|
||||||
|
|
||||||
|
### Какие проходы не запускались и почему
|
||||||
|
|
||||||
|
- **`review-architecture`** — не запускается на метке `medium` по устройству
|
||||||
|
графа; тема `architecture` отдана `basics`. Именно об этом сигнал двух проходов.
|
||||||
|
- **Проход независимой реализации** — снят из конвейера по стоимости.
|
||||||
|
- **Проход про идиоматичность языка** — упразднён.
|
||||||
|
|
||||||
|
### Что каждый запущенный проход не мог проверить в принципе
|
||||||
|
|
||||||
|
Оговорка о происхождении: **сырые выводы, поданные триажу, несут границы прозой, а
|
||||||
|
не блоком `Coverage of this pass` из контракта.** Перечень ниже восстановлен по
|
||||||
|
тому, что проходы написали, а не по их charter'ам, — и может быть неполон. Это
|
||||||
|
отдельная строка деградации.
|
||||||
|
|
||||||
|
- `autotests` — не судит содержание кода; видит зелёное/красное и наличие тестов.
|
||||||
|
Прогон гейта не собирает образ (намеренно) и не считает покрытие изменённых строк.
|
||||||
|
- `specs` — судит соответствие кода дельта-спеке и обратно; не судит качество кода
|
||||||
|
вне нормы и не проверяет, нужна ли норма вообще.
|
||||||
|
- `code` — темы `conventions` плюс технический разбор; Go-кода дифф не содержит,
|
||||||
|
поэтому `logging.md`, `errors.md`, `config.md` неприменимы поимённо, и разбор
|
||||||
|
свёлся к shell, который ни один линтер проекта не видит.
|
||||||
|
- `basics` — темы `security`, `operations`, `architecture` широким профилем; на
|
||||||
|
этой метке заменяет специализированные проходы, а не дополняет их.
|
||||||
|
- **Триаж не находит ничего нового по определению**: работает с чужими выводами и
|
||||||
|
своими прогонами-оракулами. Пропуск любого прохода — его пропуск тоже.
|
||||||
|
|
||||||
|
### Неприменимые темы и вопросы — названы, а не пропущены
|
||||||
|
|
||||||
|
- **`security`: тема неприменима к диффу целиком.** `internal/` не тронут ни
|
||||||
|
строкой. Три вопроса темы из `docs/review.md` адресованы
|
||||||
|
`internal/service/transcribe.go` и `internal/metrics` — они не менялись, вопросы
|
||||||
|
остаются открытыми и после этого прогона. Периметр сборки дом объявляет вне
|
||||||
|
модели: `docs/security.md:233-235`.
|
||||||
|
- **`operations`: применимы 2 вопроса из 6.** Отказ соседа, повтор и
|
||||||
|
одновременность, остановка на середине, наблюдаемость, рост объёма —
|
||||||
|
неприменимы: рантайм не меняется. Три вопроса темы из `docs/review.md` к диффу
|
||||||
|
неприменимы и остаются открытыми.
|
||||||
|
- **`autotests`: вопрос темы из `docs/review.md:133-134`** адресован
|
||||||
|
job-конвейеру в `internal/`, которого дифф не трогает.
|
||||||
|
- **`conventions`: вопрос темы** («новая колонка правится во всех четырёх местах»)
|
||||||
|
— **колонок изменение не трогает вовсе**; `internal/adapter/repo/pocketbase` не
|
||||||
|
изменён ни строкой.
|
||||||
|
|
||||||
|
### Что осталось целиком на человеке
|
||||||
|
|
||||||
|
**Не проверит ни один проход:**
|
||||||
|
- `operations`: поведение внешних сервисов под нагрузкой и на границах;
|
||||||
|
- `operations`: реальный профиль нагрузки;
|
||||||
|
- `security`: стойкость `ffmpeg` к вредоносному входу.
|
||||||
|
|
||||||
|
**Перестали проверять сознательно:**
|
||||||
|
- `autotests`: разбор вывода настоящего `ffprobe` — решение и цена в
|
||||||
|
`docs/adr/ADR-2026-08-11-stub-adapters-in-tests.md`.
|
||||||
|
|
||||||
|
**Своё, для этого изменения:** **собираемость образа на объявленной версии** —
|
||||||
|
требование «Объявленное число — то, на котором проект собирается» прямо оставляет
|
||||||
|
проверку человеку и запрещает вводить её в набор проверок. Косвенные свидетельства
|
||||||
|
положительные (`go version` → `go1.26.5`; гейт зелёный; образ `transcriber:dev` в
|
||||||
|
наличии), но **самой сборки триаж не запускал** — она объявлена сделанной пунктом
|
||||||
|
`tasks.md` 4.2.
|
||||||
|
|
||||||
|
### Каких документов проекта не хватило
|
||||||
|
|
||||||
|
- **`docs/review.md`, «Типовые ложноположительные» — есть и непуст (4 пункта), но
|
||||||
|
все четыре про рантайм `internal/`.** Дифф его не трогает, поэтому **проектных
|
||||||
|
ложноположительных для него не существует вовсе**: отсев вкусовщины шёл по общим
|
||||||
|
критериям устава, без проектного входа.
|
||||||
|
- **`CLAUDE.md`, «Инварианты» — раздел есть, 9 пунктов, и ни один не применим к
|
||||||
|
диффу.** Следствие названо прямо: **ни одна находка этого прогона не поднята до
|
||||||
|
`critical` по основанию «нарушен инвариант проекта» — сослаться не на что.**
|
||||||
|
Ранжирование велось по обратимости, выведенной из механики openspec, и это
|
||||||
|
**предположение триажа**, а не записанное правило проекта.
|
||||||
|
- **`CLAUDE.md`, «Необратимое» — имени capability, состава `openspec/specs/` и
|
||||||
|
раскладки `scripts/` в нём нет.** Поэтому «спека замерзает мерджем» — вывод из
|
||||||
|
механики openspec, а не проектная норма.
|
||||||
|
- **`CLAUDE.md`, «Ориентир по размеру порции: не замерялся» — дословно.** Значит
|
||||||
|
суждение «объём right-size» опирается на оценку триажа, а не на проектное число.
|
||||||
|
|
||||||
|
### Сработавшие потолки
|
||||||
|
|
||||||
|
- `basics` — **сообщил сам**: 3 находки при потолке 4, за срезом ничего.
|
||||||
|
- `code` — **сообщил частично**: «Потолок конвенций 1/4 — срез не сработал». Про
|
||||||
|
потолок технической половины не сказал ничего.
|
||||||
|
- `specs` — **не сообщил потолок вовсе.** 4 находки. **Это находка о прогоне:**
|
||||||
|
проход обязан сообщать потолок сам.
|
||||||
|
- `autotests` — **не сообщил потолок вовсе.** 3 находки. То же.
|
||||||
|
- **Триаж:** 2 из 3 блокирующих, 4 из 4 «стоит исправить сейчас». Секция «сейчас»
|
||||||
|
заполнена под завязку; ничего не выброшено молча.
|
||||||
|
|
||||||
|
### Выброшено — поимённо
|
||||||
|
|
||||||
|
1. **A2**: вопрос темы про шаг конвейера — к диффу не относится.
|
||||||
|
2. **A3**: `shellcheck` SC1007 — ложное срабатывание, действующего последствия нет;
|
||||||
|
содержание уехало замером в N-4 и P-1.
|
||||||
|
3. **Порядок шагов в `gate`**: самый дешёвый шаг стоит пятым. Вкусовщина по всем
|
||||||
|
трём условиям: поведения не меняет, стоимости следующего изменения не меняет,
|
||||||
|
записанной конвенции о порядке шагов в проекте нет.
|
||||||
|
4. **`proposal.md`: «Внешних зависимостей изменение не трогает»** против переезда
|
||||||
|
`smithy-go` из `// indirect` в прямые требования. Версия `v1.27.7` не менялась,
|
||||||
|
пакет импортируется в `internal/adapter/recognizer/yandex/s3.go:15` — штатный
|
||||||
|
результат заказанного `go mod tidy`. Прозаическая неточность без последствий.
|
||||||
|
5. **`CLAUDE.md:114-115`** «краснеет с именем недостающего плагина» — новый шаг
|
||||||
|
плагином не является. Последствия не построено; чинится вместе с P-3.
|
||||||
|
6. **`docs/review.md:194-198`**: преамбула журнала не сходится с верхней записью.
|
||||||
|
Расхождение приехало предыдущим коммитом, вне диффа.
|
||||||
|
|
||||||
|
### Четыре строки, которые не принесёт ни один проход
|
||||||
|
|
||||||
|
1. **Решения проекта не сверялись.** `docs/adr/` — процессный документ, прогон его
|
||||||
|
не открывает. Для этого изменения это особенно весомо: `design.md` ссылается на
|
||||||
|
решения, но ни один проход не открывал `docs/adr/` и не проверял, не
|
||||||
|
противоречит ли новая норма уже принятому.
|
||||||
|
2. **Записанные наблюдения проекта не использовались.** `docs/research/` — тоже
|
||||||
|
процессный. Всякое число в этом отчёте снято на этом прогоне и сопровождается
|
||||||
|
командой замера.
|
||||||
|
3. **Поимённая сверка с руководствами по стилю языка не задавалась ни одним
|
||||||
|
проходом.** Для этого изменения дыра шире обычного: основной артефакт —
|
||||||
|
POSIX-shell, у которого в проекте нет ни конвенции, ни линтера, ни руководства.
|
||||||
|
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
|
||||||
|
нет.** Вопрос «а можно ли было решить это принципиально иначе — например, одним
|
||||||
|
`go.mod` как источником истины и генерацией остальных трёх мест» никто не
|
||||||
|
задавал.
|
||||||
|
|
||||||
|
### Среда прогона
|
||||||
|
|
||||||
|
- **Рабочее дерево не тронуто.** Все оракулы добыты на копиях в scratchpad. `git
|
||||||
|
status --porcelain` до и после прогона совпадает символ в символ.
|
||||||
|
- **Запреты `CLAUDE.md` соблюдены:** боевой каталог данных не трогался, боевой
|
||||||
|
токен не запускался, в Yandex Cloud не ходили, выкладка не запускалась,
|
||||||
|
`testdata` не заводилась, временное — только в scratchpad.
|
||||||
|
- **Отказов доступа не было.** `deny: Read(./build)` на путях этого изменения не
|
||||||
|
сработал (capability названа `toolchain` именно поэтому).
|
||||||
|
- **Ограничение среды:** подагентам запрещено писать файлы отчётов. Отчёт возвращён
|
||||||
|
триажем текстом и записан сюда оркестратором.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Дополнение: перепроверка после отработки B-1
|
||||||
|
|
||||||
|
Записано оркестратором после того, как находки триажа были отработаны. Отчёт без
|
||||||
|
этого раздела сообщал бы о составе прогона неверно.
|
||||||
|
|
||||||
|
## Что было сделано по находкам
|
||||||
|
|
||||||
|
| Находка | Действие | Исход |
|
||||||
|
|---|---|---|
|
||||||
|
| B-1 | развилка → человеку | Выбран вариант А: правило множественности разведено по местам, рамка в документах сужена до раздела стека. Изменило требование и код |
|
||||||
|
| B-2 | инлайн | Четвёртая capability и оговорка о её природе — в преамбуле `docs/architecture.md`. Ссылка на спеку поставлена шагом синка: до архивации файла нет и `docs.py check` краснеет битой ссылкой |
|
||||||
|
| N-1 | развилка → человеку | Выбран вариант Б: задачей урожая, вместе с `shellcheck`. Между мерджем и той задачей страж не проверен ничем — названо остатком |
|
||||||
|
| N-2 | инлайн | Строка в таблице «Механизировано» `docs/conventions/README.md` |
|
||||||
|
| N-3 | инлайн | Проверка читаемости места: код 3 и сообщение о нечитаемости вместо «версия не названа» с кодом 1 |
|
||||||
|
| N-4 | инлайн | Цена отказа от `shellcheck` названа по замеру в `CLAUDE.md` и `design.md`; ложный `SC1007` подавлен в скрипте |
|
||||||
|
|
||||||
|
## Второй прогон: только проход `specs`
|
||||||
|
|
||||||
|
**Полный прогон ревью кода не повторялся.** Правка по B-1 изменила дельта-спеку и
|
||||||
|
код, поэтому перепрогнан **целенаправленно один проход** — `specs`, владеющий
|
||||||
|
темой `requirements`, чей дом и изменился. `autotests` заменён собственным
|
||||||
|
прогоном гейта оркестратором; `code` и `basics` не перезапускались.
|
||||||
|
|
||||||
|
**Чем это ограничено, прямо:** технический разбор новых функций `section`,
|
||||||
|
`has_section` и `collect_doc` независимым проходом **не выполнялся** — их читал
|
||||||
|
только `specs` в своей оптике (соответствие норме) и оркестратор. Проход `code`
|
||||||
|
видел прежнюю редакцию скрипта. Триаж второй раз не запускался, поэтому находки
|
||||||
|
ниже не проходили дедупликации и добычи оракула независимым агентом — оракулы у
|
||||||
|
них свои, прогонами.
|
||||||
|
|
||||||
|
**Разметка не повторялась,** хотя дельта-спека менялась. Причина названа: правка
|
||||||
|
сузила формулировку одного требования внутри уже размеченной capability, не
|
||||||
|
меняя ни набора capability, ни периметра узлов, ни списка тем — план тем остался
|
||||||
|
бы тем же. Это осознанное отступление от правила «дельта-спеки изменились —
|
||||||
|
повтори разметку», а не пропуск.
|
||||||
|
|
||||||
|
## Находки перепроверки — три, все minor, все отработаны
|
||||||
|
|
||||||
|
- **S1 закрыта по существу**, а не переформулировкой. Проверено обеими сторонами:
|
||||||
|
норма больше не требует единственности от сборочного образа, и код ровно это и
|
||||||
|
делает; правдивая историческая строка о прошлой версии в памятке даёт зелёное,
|
||||||
|
а второе число внутри раздела стека — красное.
|
||||||
|
- **F-1.** Таблица `design.md` продолжала велеть «ровно одно вхождение на файл» —
|
||||||
|
правило, обратное принятой норме, — и переживала бы мердж как единственное
|
||||||
|
описание того, как машина ищет число. Абзац-мотивировка вдобавок стал
|
||||||
|
фактически неверен. Переписаны оба.
|
||||||
|
- **F-2, дороже прочих.** Норма называла начало раздела и молчала о конце.
|
||||||
|
Раздел закрывался только заголовком того же уровня, а строка, похожая на
|
||||||
|
заголовок, внутри блока кода читалась как настоящий заголовок. Худший исход —
|
||||||
|
**ложное зелёное**: если строку версии из раздела убрать, а ниже по файлу
|
||||||
|
появится заголовок первого уровня и любое «Go 1.26», шаг добрал бы число из
|
||||||
|
чужого места и промолчал. Граница определена требованием и исполнена кодом;
|
||||||
|
контрольный прогон подтверждает, что ложное зелёное исчезло — шаг теперь
|
||||||
|
честно говорит «версия не названа».
|
||||||
|
- **F-3.** Отличие «сломанного окружения» от «пропавшей строки» держалось только
|
||||||
|
на коде: ни один сценарий его не требовал, автотеста нет, гейт гоняет скрипт
|
||||||
|
ровно на согласованном дереве. Записано требованием и сценарием.
|
||||||
|
|
||||||
|
## Оракулы перепроверки
|
||||||
|
|
||||||
|
Регрессионная батарея — **21 прогон**, все совпали с ожиданием: 4 мутации по
|
||||||
|
минору и 4 удаления строки версии (по одной на место), директива `toolchain`,
|
||||||
|
патч в теге, смена базы образа, два слоя одной версии, два слоя разных версий,
|
||||||
|
дубль внутри раздела, история вне раздела, пропавший раздел, `PATH` без `go`,
|
||||||
|
запуск из подкаталога, коды выхода 0/1/2/3. Отдельно проверено, что при
|
||||||
|
нечитаемом файле строка «не называет версию» не печатается ни разу.
|
||||||
|
|
||||||
|
Сверх того: `openspec validate --strict` — valid; `task gate` — exit 0;
|
||||||
|
`task image` пересобран на `golang:1.26-alpine` — exit 0; `shellcheck` на скрипте
|
||||||
|
чист.
|
||||||
|
|
||||||
|
## Что осталось открытым после отработки
|
||||||
|
|
||||||
|
- **N-1: страж не покрыт ничем.** Решением человека уехало задачей урожая. Из
|
||||||
|
девятнадцати сценариев дельты машина гоняет один — тот, где всё сошлось;
|
||||||
|
остальные восемнадцать подтверждены разовыми прогонами. До закрытия той задачи
|
||||||
|
правки скрипта идут вслепую.
|
||||||
|
- **H-2: рантайм-база образа берётся «последней доступной».** Два образа из
|
||||||
|
одного коммита с разницей в неделю несут разные `ffmpeg`. Вне границ задачи,
|
||||||
|
уезжает урожаем.
|
||||||
|
- **Сигнал о заниженной метке** остаётся фактом для человека: ось «изменение
|
||||||
|
трогает канон» в правиле выбора метки отсутствует, а на этом прогоне именно она
|
||||||
|
дала обе блокирующие находки. Кандидат в правило — P-5.
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Версия инструмента сборки объявлена одним числом
|
||||||
|
|
||||||
|
Проект SHALL объявлять версию Go, на которой собирается сервис, одинаково во
|
||||||
|
всех местах, где она названа. Мест ровно четыре, и перечень закрыт: требование
|
||||||
|
модуля в `go.mod`, сборочный образ в `Dockerfile`, строка стека в `CLAUDE.md`,
|
||||||
|
строка стека в `README.md`.
|
||||||
|
|
||||||
|
Сравниваются мажор и минор. Третье число у сборочного образа MUST оставаться
|
||||||
|
свободным, как и база образа: образ обновляется своим темпом, и требовать от
|
||||||
|
него совпадения по патчу значило бы краснеть на каждом его обновлении. Тег
|
||||||
|
читается по форме `golang:<мажор>.<минор>[.<патч>][-<база>]`, и берутся из него
|
||||||
|
первые два числа.
|
||||||
|
|
||||||
|
Правило множественности у мест разное, потому что места устроены по-разному.
|
||||||
|
|
||||||
|
**Документы** — `CLAUDE.md` и `README.md` — MUST называть версию ровно один раз,
|
||||||
|
и считается это **не по файлу, а по разделу стека**: `## Стек` в памятке,
|
||||||
|
`## Технологии` в README. Второе вхождение числа **в этом разделе** MUST
|
||||||
|
считаться отказом: обновят одно, второе протухнет молча. За пределами раздела
|
||||||
|
число не читается вовсе — иначе памятка, которая по устройству ведёт историю
|
||||||
|
закрытых долгов, роняла бы проверку на первой же правдивой строке о прошлой
|
||||||
|
версии, а сообщение толкало бы чинить не проверку, а исторический документ.
|
||||||
|
|
||||||
|
**Сборочный образ** единственности не требует: каждый слой — настоящий вход
|
||||||
|
сборки, и многослойная сборка законна. От всех вхождений `FROM golang:` MUST
|
||||||
|
требоваться совпадение мажора и минора, а не единственность.
|
||||||
|
|
||||||
|
**Требование модуля** называется директивой `go` и по устройству файла
|
||||||
|
единственно.
|
||||||
|
|
||||||
|
Граница раздела MUST быть определена, а не подразумеваться: раздел кончается
|
||||||
|
следующим заголовком того же или более высокого уровня, заголовок третьего уровня
|
||||||
|
и ниже остаётся внутри раздела, а строка, похожая на заголовок, но лежащая внутри
|
||||||
|
блока кода, заголовком MUST не считаться. Без этого пример в чужом разделе
|
||||||
|
открывал бы раздел стека на пустом месте, и число доставалось бы оттуда, откуда
|
||||||
|
норма его читать не велит.
|
||||||
|
|
||||||
|
`go.mod` MUST не содержать директиву `toolchain`. Она называет версию **пятым**
|
||||||
|
местом, которого перечень не знает: при `toolchain go1.27.0` четыре объявленных
|
||||||
|
числа сойдутся, а собирать будет пятое — то есть вернётся тот самый класс
|
||||||
|
расхождения, ради которого требование и заведено.
|
||||||
|
|
||||||
|
#### Scenario: Все четыре места названы одинаково
|
||||||
|
|
||||||
|
- **GIVEN** дерево проекта, где `go.mod`, `Dockerfile`, `CLAUDE.md` и `README.md`
|
||||||
|
называют версию Go
|
||||||
|
- **WHEN** их читают подряд
|
||||||
|
- **THEN** мажор и минор совпадают во всех четырёх
|
||||||
|
|
||||||
|
#### Scenario: Патч сборочного образа отличается законно
|
||||||
|
|
||||||
|
- **GIVEN** `go.mod` требует `1.26.0`, а образ собирается на `golang:1.26.5-alpine`
|
||||||
|
- **WHEN** версии сравнивают
|
||||||
|
- **THEN** расхождением это не считается
|
||||||
|
|
||||||
|
#### Scenario: База сборочного образа сменилась
|
||||||
|
|
||||||
|
- **GIVEN** образ переехал с `golang:1.26-alpine` на `golang:1.26-bookworm`
|
||||||
|
- **WHEN** версии сравнивают
|
||||||
|
- **THEN** расхождением это не считается
|
||||||
|
|
||||||
|
#### Scenario: Раздел стека называет версию дважды
|
||||||
|
|
||||||
|
- **GIVEN** раздел стека в `CLAUDE.md` называет версию два раза
|
||||||
|
- **WHEN** версии сравнивают
|
||||||
|
- **THEN** это расхождение, даже если оба числа одинаковы
|
||||||
|
|
||||||
|
#### Scenario: Число за пределами раздела стека не читается
|
||||||
|
|
||||||
|
- **GIVEN** `CLAUDE.md` вне раздела стека упоминает прошлую версию Go — например
|
||||||
|
записью о закрытом долге
|
||||||
|
- **WHEN** версии сравнивают
|
||||||
|
- **THEN** расхождением это не считается
|
||||||
|
|
||||||
|
#### Scenario: Сборочный образ собран в два слоя
|
||||||
|
|
||||||
|
- **GIVEN** `Dockerfile` содержит два `FROM golang:` с одним мажором и минором
|
||||||
|
- **WHEN** версии сравнивают
|
||||||
|
- **THEN** расхождением это не считается
|
||||||
|
|
||||||
|
#### Scenario: Слои сборочного образа разошлись между собой
|
||||||
|
|
||||||
|
- **GIVEN** `Dockerfile` содержит два `FROM golang:` с разными минорами
|
||||||
|
- **WHEN** версии сравнивают
|
||||||
|
- **THEN** это расхождение
|
||||||
|
|
||||||
|
#### Scenario: Заголовок раздела встретился внутри блока кода
|
||||||
|
|
||||||
|
- **GIVEN** документ в чужом разделе показывает пример, внутри которого есть
|
||||||
|
строка, совпадающая с заголовком раздела стека, а ниже названо другое число
|
||||||
|
- **WHEN** версии сравнивают
|
||||||
|
- **THEN** число из примера не читается, и расхождением это не считается
|
||||||
|
|
||||||
|
#### Scenario: Раздел стека закрыт заголовком верхнего уровня
|
||||||
|
|
||||||
|
- **GIVEN** после раздела стека идёт заголовок первого уровня, а ниже названа
|
||||||
|
прошлая версия
|
||||||
|
- **WHEN** версии сравнивают
|
||||||
|
- **THEN** это число не читается, и расхождением не считается
|
||||||
|
|
||||||
|
#### Scenario: Раздела стека нет вовсе
|
||||||
|
|
||||||
|
- **GIVEN** в документе нет раздела, где называется версия
|
||||||
|
- **WHEN** запускают шаг сверки
|
||||||
|
- **THEN** он завершается отказом и называет недостающий раздел
|
||||||
|
|
||||||
|
#### Scenario: Модуль объявляет версию пятым местом
|
||||||
|
|
||||||
|
- **GIVEN** `go.mod` содержит директиву `toolchain`
|
||||||
|
- **WHEN** версии сравнивают
|
||||||
|
- **THEN** это расхождение
|
||||||
|
|
||||||
|
### Requirement: Объявленное число — то, на котором проект собирается
|
||||||
|
|
||||||
|
Объявленная версия SHALL быть той, на которой сервис действительно собирается и
|
||||||
|
проходит тесты. Согласованность четырёх строк между собой этого не доказывает:
|
||||||
|
четыре одинаковых числа несуществующей версии требованию о согласованности
|
||||||
|
удовлетворяют, а собрать на них нельзя.
|
||||||
|
|
||||||
|
Проверка эта MUST оставаться за человеком и MUST не входить в набор проверок:
|
||||||
|
она требует сборки образа, а сборка образа набором проверок не делается
|
||||||
|
намеренно — дорого. Подъём версии MUST не уезжать в основную ветку, пока сборка
|
||||||
|
образа и тесты на объявленном числе не прогнаны.
|
||||||
|
|
||||||
|
#### Scenario: Версию подняли
|
||||||
|
|
||||||
|
- **GIVEN** объявленную версию Go подняли во всех четырёх местах
|
||||||
|
- **WHEN** изменение готовят к мерджу
|
||||||
|
- **THEN** до мерджа на этой версии прогнаны сборка образа и тесты
|
||||||
|
|
||||||
|
### Requirement: Расхождение версий роняет набор проверок
|
||||||
|
|
||||||
|
Набор проверок `task gate` SHALL включать шаг, который сравнивает объявленные
|
||||||
|
версии между собой и MUST завершаться отказом, когда они разошлись. Сообщение
|
||||||
|
отказа MUST называть **все четыре места и прочитанное в каждом число** — не одну
|
||||||
|
разошедшуюся пару: в дефекте 2026-08-12 три места из четырёх говорили одно и то
|
||||||
|
же и неверными были именно они, а по сообщению о паре человек чинит не то место.
|
||||||
|
|
||||||
|
Шаг MUST судить по содержимому файлов репозитория и MUST не спрашивать
|
||||||
|
установленный инструмент — ни `go version`, ни `go env`, ни `GOTOOLCHAIN`. Исход
|
||||||
|
его MUST быть функцией коммита, а не машины: шаг, чей ответ зависит от того, что
|
||||||
|
стоит на хосте, воспроизводит ровно ту подмену, которая держала дефект
|
||||||
|
2026-08-12 невидимым — там `go build ./...` шёл на хостовом Go, а объявленное
|
||||||
|
число не проверял никто.
|
||||||
|
|
||||||
|
Шаг MUST работать сравнением строк — без сборки образа, без docker и без сети —
|
||||||
|
и MUST не зависеть от рабочего каталога, из которого запущен. Шаг MUST только
|
||||||
|
читать: файлов он не правит и разошедшихся мест не чинит.
|
||||||
|
|
||||||
|
Коды выхода MUST следовать словарю прочих проверочных шагов проекта: 0 сошлось,
|
||||||
|
1 расхождение, 2 ошибка употребления, 3 окружение. Своего словаря шаг MUST не
|
||||||
|
заводить: раздел «Гейт» в `CLAUDE.md` объявляет словарь общим, и четвёртый шаг с
|
||||||
|
собственной семантикой сделал бы это утверждение неверным.
|
||||||
|
|
||||||
|
Место, где числа не нашлось вовсе, MUST считаться отказом с именем этого места.
|
||||||
|
«Нечего сравнивать» исходом MUST не быть: пропавшая строка иначе выглядела бы
|
||||||
|
как совпадение.
|
||||||
|
|
||||||
|
Отказ чтения места MUST не выглядеть как отсутствие числа. Место, которое
|
||||||
|
существует, но не читается, — это отказ окружения, и сообщение MUST говорить о
|
||||||
|
нечитаемости, а не о ненайденной версии: иначе шаг отправляет чинить документ, в
|
||||||
|
котором строка на месте, а сломаны права.
|
||||||
|
|
||||||
|
#### Scenario: Разошёлся сборочный образ
|
||||||
|
|
||||||
|
- **GIVEN** `Dockerfile` называет версию, отличную от прочих трёх мест
|
||||||
|
- **WHEN** запускают `task gate`
|
||||||
|
- **THEN** шаг сверки завершается отказом
|
||||||
|
- **AND** сообщение называет все четыре места и число каждого
|
||||||
|
- **AND** весь набор проверок краснеет
|
||||||
|
|
||||||
|
#### Scenario: Разошлось требование модуля
|
||||||
|
|
||||||
|
- **GIVEN** `go.mod` называет версию, отличную от прочих трёх мест
|
||||||
|
- **WHEN** запускают шаг сверки
|
||||||
|
- **THEN** он завершается отказом и называет `go.mod` среди разошедшихся
|
||||||
|
|
||||||
|
#### Scenario: Разошлась памятка
|
||||||
|
|
||||||
|
- **GIVEN** `CLAUDE.md` называет версию, отличную от прочих трёх мест
|
||||||
|
- **WHEN** запускают шаг сверки
|
||||||
|
- **THEN** он завершается отказом и называет `CLAUDE.md` среди разошедшихся
|
||||||
|
|
||||||
|
#### Scenario: Разошёлся README
|
||||||
|
|
||||||
|
- **GIVEN** `README.md` называет версию, отличную от прочих трёх мест
|
||||||
|
- **WHEN** запускают шаг сверки
|
||||||
|
- **THEN** он завершается отказом и называет `README.md` среди разошедшихся
|
||||||
|
|
||||||
|
#### Scenario: Версии совпадают
|
||||||
|
|
||||||
|
- **GIVEN** все четыре места называют одно число
|
||||||
|
- **WHEN** запускают `task gate`
|
||||||
|
- **THEN** шаг сверки проходит с кодом 0
|
||||||
|
- **AND** остальные шаги набора идут как прежде
|
||||||
|
|
||||||
|
#### Scenario: Инструмента сборки нет на машине
|
||||||
|
|
||||||
|
- **GIVEN** в `PATH` нет `go` вовсе
|
||||||
|
- **WHEN** запускают шаг сверки
|
||||||
|
- **THEN** исход и сообщение те же, что и при установленном `go`
|
||||||
|
|
||||||
|
#### Scenario: Ни docker, ни сети нет
|
||||||
|
|
||||||
|
- **GIVEN** docker недоступен и сети нет
|
||||||
|
- **WHEN** запускают шаг сверки
|
||||||
|
- **THEN** он отрабатывает и даёт тот же исход, что и при доступном docker
|
||||||
|
|
||||||
|
#### Scenario: Шаг запущен не из корня проекта
|
||||||
|
|
||||||
|
- **GIVEN** шаг запускают из подкаталога дерева
|
||||||
|
- **WHEN** он ищет свои четыре места
|
||||||
|
- **THEN** исход тот же, что и при запуске из корня
|
||||||
|
|
||||||
|
#### Scenario: Место существует, но не читается
|
||||||
|
|
||||||
|
- **GIVEN** файл одного из мест на диске есть, но прав на чтение нет
|
||||||
|
- **WHEN** запускают шаг сверки
|
||||||
|
- **THEN** он завершается кодом окружения и говорит о нечитаемости места
|
||||||
|
- **AND** сообщения «версия не названа» не печатает
|
||||||
|
|
||||||
|
#### Scenario: Версия не названа там, где должна быть
|
||||||
|
|
||||||
|
- **GIVEN** одно из четырёх мест перестало называть версию Go
|
||||||
|
- **WHEN** запускают шаг сверки
|
||||||
|
- **THEN** он завершается отказом и называет место, где число не нашлось
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
## Критерии приёмки
|
||||||
|
|
||||||
|
### От постановки
|
||||||
|
|
||||||
|
Перенесены дословно из записи задачи `tasks/items/go-1-26-upgrade.md`: закрытие
|
||||||
|
задачи удалит файл, а критерии обязаны его пережить.
|
||||||
|
|
||||||
|
- Модуль, образ и документы называют одну версию Go. Оракул — `grep` по четырём
|
||||||
|
местам: `go.mod`, `Dockerfile`, `CLAUDE.md`, `README.md`; все дают одно число.
|
||||||
|
- Сборка на объявленной версии проходит. Оракул — `task image` и
|
||||||
|
`CGO_ENABLED=0 go build ./...` на чистом дереве.
|
||||||
|
- Расхождение версий роняет гейт. Оракул — прогон `task gate` на дереве, где
|
||||||
|
версия в `Dockerfile` понижена на минор: шаг краснеет и называет оба числа.
|
||||||
|
- Совпадение гейт не роняет, а сам шаг не требует docker и работает без сети.
|
||||||
|
Оракул — `task gate` на неизменённом дереве и прогон с
|
||||||
|
`DOCKER_HOST=/dev/null`.
|
||||||
|
- Гейт зелёный целиком. Оракул — `task gate`.
|
||||||
|
|
||||||
|
### От рубрики ревью дизайна
|
||||||
|
|
||||||
|
Проход `review-rubric`, стадия ревью дизайна. Рубрика на род узла «проверочный
|
||||||
|
шаг набора проверок, читающий разнородные источники».
|
||||||
|
|
||||||
|
- **Мутационный оракул на каждый источник.** Четыре прогона: по очереди понизить
|
||||||
|
минор в `go.mod`, `Dockerfile`, `CLAUDE.md`, `README.md`. В каждом шаг краснеет
|
||||||
|
и называет именно изменённый источник. Оракул — четыре прогона, четыре красных,
|
||||||
|
четыре разных сообщения.
|
||||||
|
- **Пустая выборка — отказ, а не согласие.** Четыре прогона: по очереди убрать
|
||||||
|
строку версии из каждого источника. Каждый даёт отказ с именем этого источника.
|
||||||
|
- **Исход — функция коммита, а не машины.** Оракул — прогон с `PATH`, из которого
|
||||||
|
убран `go`: тот же код выхода и тот же вывод, что и при установленном `go`.
|
||||||
|
- **Второе вхождение числа не проходит молча.** Оракул — дописать в `CLAUDE.md`
|
||||||
|
второе «Go 1.25» и прогнать шаг: он краснеет, а не судит по первому совпадению.
|
||||||
|
- **Патч и база образа свободны.** Оракул — два прогона: `golang:1.26.5-alpine` и
|
||||||
|
`golang:1.26-bookworm` в `Dockerfile`, оба зелёные.
|
||||||
|
- **Сообщение масштабируется на четыре места.** Оракул — прогон на дереве, где
|
||||||
|
один источник разошёлся с тремя: вывод содержит четыре пары «место: число».
|
||||||
|
- **Словарь кодов выхода объявлен.** Оракул — четыре прогона: сошлось, расхождение,
|
||||||
|
лишний аргумент, файл источника убран — дают 0, 1, 2 и 3.
|
||||||
|
- **Исход не зависит от рабочего каталога.** Оракул — прогон из корня и из
|
||||||
|
подкаталога дают один код выхода.
|
||||||
|
- **Шаг только читает.** Оракул — контрольная сумма дерева до и после прогона
|
||||||
|
совпадает, два прогона подряд дают одинаковый вывод.
|
||||||
|
- **Шаг не ходит в сеть.** Оракул — прогон под `strace -f -e
|
||||||
|
trace=socket,connect,sendto,recvfrom`: ни одного сетевого вызова.
|
||||||
|
- **Граница «чего шаг не проверяет» записана.** Оракул — раздел «Гейт» в
|
||||||
|
`CLAUDE.md` содержит строку о том, что сборка образа в набор проверок
|
||||||
|
по-прежнему не входит.
|
||||||
|
- **Директива `toolchain` не проходит незамеченной.** Оракул — дописать
|
||||||
|
`toolchain go1.27.0` в `go.mod` и прогнать шаг: он краснеет.
|
||||||
|
|
||||||
|
## 1. Шаг сверки версий
|
||||||
|
|
||||||
|
- [x] 1.1 Написать `scripts/check-go-version.sh` на POSIX `sh`: достать мажор и
|
||||||
|
минор из директивы `go` в `go.mod`, из тега `FROM golang:` в `Dockerfile`, из
|
||||||
|
строки стека в `CLAUDE.md` и из строки стека в `README.md`
|
||||||
|
- [x] 1.2 Пути строить от корня репозитория, а не от текущего каталога
|
||||||
|
- [x] 1.3 Не звать `go` ни в каком виде: ни `go mod edit`, ни `go list`, ни
|
||||||
|
`go env`. Исход обязан быть функцией содержимого файлов
|
||||||
|
- [x] 1.4 Тег образа разбирать по форме `golang:<мажор>.<минор>[.<патч>][-<база>]`
|
||||||
|
— патч и база отбрасываются
|
||||||
|
- [x] 1.5 Запретить директиву `toolchain` в `go.mod` — отказ с названием причины
|
||||||
|
- [x] 1.6 Второе вхождение числа в одном источнике — отказ; для `Dockerfile` все
|
||||||
|
`FROM golang:` обязаны давать одно число
|
||||||
|
- [x] 1.7 Отказ при расхождении: сообщение печатает все четыре места и число
|
||||||
|
каждого, а не одну пару
|
||||||
|
- [x] 1.8 Отказ при ненайденном числе: место названо поимённо, «нечего
|
||||||
|
сравнивать» исходом не считается
|
||||||
|
- [x] 1.9 Коды выхода по словарю проекта: 0 сошлось, 1 расхождение, 2 ошибка
|
||||||
|
употребления, 3 окружение
|
||||||
|
- [x] 1.10 Не использовать GNU-only флаги (`grep -P`, `sed -E` с расширениями,
|
||||||
|
`mapfile`); сделать скрипт исполняемым
|
||||||
|
- [x] 1.11 Завести шаг `go-version` в `Taskfile.yml` по образцу соседних шагов,
|
||||||
|
включая внятный отказ при отсутствии скрипта
|
||||||
|
- [x] 1.12 Включить шаг в `gate`
|
||||||
|
|
||||||
|
## 2. Подъём версии до 1.26
|
||||||
|
|
||||||
|
- [x] 2.1 `go.mod`: директива `go 1.26.0`
|
||||||
|
- [x] 2.2 `Dockerfile`: сборочный слой на `golang:1.26-alpine`
|
||||||
|
- [x] 2.3 `CLAUDE.md`, раздел «Стек»: «Go 1.26» — ровно одно вхождение числа на
|
||||||
|
файл, включая абзац из шага 3.1
|
||||||
|
- [x] 2.4 `README.md`, раздел «Технологии»: завести строку версии Go в форме,
|
||||||
|
которую находит скрипт
|
||||||
|
- [x] 2.5 `go mod tidy` и проверка, что `go.sum` не разъехался и директива
|
||||||
|
`toolchain` не появилась
|
||||||
|
|
||||||
|
## 3. Документы
|
||||||
|
|
||||||
|
- [x] 3.1 `CLAUDE.md`, раздел «Гейт»: новый шаг в перечне того, что красит
|
||||||
|
безусловно; строка о том, что сборка образа в гейт по-прежнему не входит;
|
||||||
|
словарь кодов выхода распространён на четвёртый проверочный шаг
|
||||||
|
- [x] 3.2 `docs/review.md`: запись за 2026-08-12 получает строку о том, чем
|
||||||
|
дефект закрыт
|
||||||
|
- [x] 3.3 `docs/architecture.md`, преамбула: четвёртая capability в перечне и
|
||||||
|
оговорка о том, что она нормирует не поведение сервиса (находка ревью B-2).
|
||||||
|
Ссылка на спеку ставится шагом синка, после архивации: до неё файла нет и
|
||||||
|
`docs.py check` краснеет битой ссылкой
|
||||||
|
- [x] 3.4 `docs/conventions/README.md`, таблица «Механизировано»: строка про
|
||||||
|
новое машинное правило (находка ревью N-2)
|
||||||
|
|
||||||
|
## 5. Отработка находок ревью кода
|
||||||
|
|
||||||
|
Отчёт триажа — `openspec/changes/go-1-26-upgrade/review/report.md`.
|
||||||
|
|
||||||
|
- [x] 5.1 N-3: нечитаемое место даёт код 3 с честным сообщением, а не «версия не
|
||||||
|
названа» с кодом 1. Проверка читаемости стоит рядом с проверкой существования:
|
||||||
|
подстановка команд в аргументе теряет код возврата, а в `read_doc` статус
|
||||||
|
конвейера берётся от последней команды
|
||||||
|
- [x] 5.2 N-4: цена отказа от `shellcheck` названа по замеру — один shell-скрипт,
|
||||||
|
а не четыре. Правка в `CLAUDE.md` и в `design.md`
|
||||||
|
- [x] 5.3 Подавление ложного `SC1007` в скрипте, чтобы заведение линтера потом
|
||||||
|
стоило ровно одной строки шага. `shellcheck` на скрипте чист
|
||||||
|
|
||||||
|
## 6. B-1: правило множественности разведено по местам
|
||||||
|
|
||||||
|
Находка B-1 меняла требование, поэтому прошла через чекпоинт заново. Решение
|
||||||
|
человека: развести правило по местам и сузить рамку до раздела.
|
||||||
|
|
||||||
|
- [x] 6.1 Требование: единственность — от раздела стека в документах, от
|
||||||
|
сборочного образа — совпадение всех вхождений `FROM golang:`, требование модуля
|
||||||
|
единственно по устройству файла
|
||||||
|
- [x] 6.2 Пять новых сценариев: дубль в разделе, число вне раздела, два слоя с
|
||||||
|
одной версией, два слоя с разными, пропавший раздел
|
||||||
|
- [x] 6.3 Скрипт читает раздел (`## Стек` в памятке, `## Технологии` в README), а
|
||||||
|
не файл целиком
|
||||||
|
- [x] 6.4 Пропавший раздел — свой исход с именем раздела, а не «версия не названа»
|
||||||
|
- [x] 6.5 Прогоны: история о прошлой версии вне раздела — зелено; пример команды в
|
||||||
|
README вне раздела — зелено; дубль внутри раздела — красно; два слоя одной
|
||||||
|
версии — зелено; два слоя разных — красно; раздела нет — красно с его именем
|
||||||
|
- [x] 6.6 Регрессия прежней батареи: 8 мутаций по местам, `toolchain`, патч и база
|
||||||
|
тега, `PATH` без `go`, подкаталог, коды 0/1/2/3 — все как прежде
|
||||||
|
- [x] 6.7 `openspec validate --strict` и `task gate` зелёные
|
||||||
|
|
||||||
|
## 7. Отработка перепроверки спек после правки B-1
|
||||||
|
|
||||||
|
Целевой перепрогон прохода `specs`: правка коснулась ровно его темы. Прежняя
|
||||||
|
находка S1 подтверждена закрытой по существу; три новые, все minor.
|
||||||
|
|
||||||
|
- [x] 7.1 F-2, ложное зелёное: раздел кончался только заголовком того же уровня, а
|
||||||
|
заголовок внутри блока кода читался как настоящий — число доставалось из-за
|
||||||
|
границы раздела. Граница определена в требовании и исполнена в коде: следующий
|
||||||
|
заголовок того же или более высокого уровня, блок кода заголовков не даёт,
|
||||||
|
хвостовые пробелы в заголовке не значат ничего
|
||||||
|
- [x] 7.2 F-2: два сценария — заголовок раздела внутри блока кода, раздел закрыт
|
||||||
|
заголовком верхнего уровня
|
||||||
|
- [x] 7.3 F-3: отличие «сломанного окружения» от «пропавшей строки» записано
|
||||||
|
требованием и сценарием, а не только кодом
|
||||||
|
- [x] 7.4 F-1: таблица `design.md` велела правило, обратное принятой норме
|
||||||
|
(«одно вхождение на файл»), и абзац-мотивировка стал фактически неверен —
|
||||||
|
переписаны оба
|
||||||
|
- [x] 7.5 Регрессия 21 прогоном: 8 мутаций по местам, `toolchain`, патч и база
|
||||||
|
тега, два слоя одной и разных версий, дубль в разделе, история вне раздела,
|
||||||
|
пропавший раздел, `PATH` без `go`, подкаталог, коды 0/1/2/3 — все совпали с
|
||||||
|
ожиданием
|
||||||
|
- [x] 7.6 `shellcheck` на скрипте чист
|
||||||
|
|
||||||
|
## 4. Проверка
|
||||||
|
|
||||||
|
- [x] 4.1 `CGO_ENABLED=0 go build ./...` и `go test ./...` на go1.26.5
|
||||||
|
- [x] 4.2 `task image` собирает образ на `golang:1.26-alpine`
|
||||||
|
- [x] 4.3 Четыре мутации по минору — по одной на источник; в каждой шаг краснеет
|
||||||
|
и называет изменённый источник; дерево возвращается как было
|
||||||
|
- [x] 4.4 Четыре мутации удалением строки версии — по одной на источник; в каждой
|
||||||
|
отказ называет место
|
||||||
|
- [x] 4.5 Прогон с `PATH` без `go`, прогон с `DOCKER_HOST=/dev/null`, прогон под
|
||||||
|
`strace` без сетевых вызовов — исход тот же
|
||||||
|
- [x] 4.6 Прогон из подкаталога — тот же код выхода
|
||||||
|
- [x] 4.7 Прогоны на `golang:1.26.5-alpine` и `golang:1.26-bookworm` — зелёные
|
||||||
|
- [x] 4.8 Прогон с дописанным `toolchain go1.27.0` — красный
|
||||||
|
- [x] 4.9 Четыре прогона на коды выхода: 0, 1, 2, 3
|
||||||
|
- [x] 4.10 Контрольная сумма дерева до и после прогона совпадает
|
||||||
|
- [x] 4.11 `task gate` целиком зелёный
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-12
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Сегодня HTTP API открыт наружу без проверки — так записано первой строкой модели
|
||||||
|
угроз. Приглашение второго человека упирается в это: у записей нет владельца, а
|
||||||
|
подобранный идентификатор задачи отдаёт чужую расшифровку.
|
||||||
|
|
||||||
|
Решение от 2026-08-11 (`ADR-2026-08-11-pocketbase-storage-with-admin-panel`)
|
||||||
|
назвало способ: **ответ провайдера разбирает хранилище, а не наш код**. У
|
||||||
|
коллекции пользователей включается провайдер `oidc` с адресами Authelia, учётные
|
||||||
|
записи заводятся сами, и панель их видит. Проверено на версии 0.39.10 —
|
||||||
|
`docs/research/pocketbase.md`, раздел «Пользователи — только те, кого туда
|
||||||
|
положат».
|
||||||
|
|
||||||
|
**Способ остаётся верным, но его механика уже проверена по исходникам
|
||||||
|
библиотеки, и три ожидания постановки она не подтверждает.** Проверено чтением
|
||||||
|
`pocketbase@v0.39.10`:
|
||||||
|
|
||||||
|
1. **Куки библиотека не читает вовсе.** Сессию она берёт единственным способом —
|
||||||
|
заголовком `Authorization` (`apis/middlewares.go`, `getAuthTokenFromRequest`).
|
||||||
|
Критерий приёмки задачи написан про куку.
|
||||||
|
2. **Эндпоинта выхода библиотека не приносит.** Список её адресов
|
||||||
|
аутентификации — `auth-methods`, `auth-refresh`, `auth-with-password`,
|
||||||
|
`auth-with-oauth2`, `request-otp`, `auth-with-otp`, восстановление пароля,
|
||||||
|
подтверждение почты и смена почты (`apis/record_auth.go`). Выхода среди них
|
||||||
|
нет.
|
||||||
|
3. **Браузерный вход по редиректу библиотека своим не приносит.** Она приносит
|
||||||
|
обмен уже полученного кода: `POST /api/collections/{c}/auth-with-oauth2`
|
||||||
|
требует `provider`, `code`, `codeVerifier` и `redirectURL`. Её собственный
|
||||||
|
`/api/oauth2-redirect` служит другому: он ищет клиента realtime-подписки по
|
||||||
|
параметру `state` и отдаёт код туда (`apis/record_auth_with_oauth2_redirect.go`)
|
||||||
|
— это механика её JS-клиента с всплывающим окном, а не серверный вход.
|
||||||
|
|
||||||
|
Отсюда объём: инициировать вход, принять возврат и завести куку — наш код.
|
||||||
|
Разбор ответа провайдера, заведение учётной записи и связь с внешним провайдером
|
||||||
|
остаются за хранилищем, как и решено. Решение 2026-08-11 не пересматривается.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- запрос к приёму записи и к опросу готовности без сессии получает отказ и
|
||||||
|
ничего не заводит;
|
||||||
|
- вход идёт у Authelia по OIDC, учётные записи заводятся сами;
|
||||||
|
- выход закрывает доступ немедленно, а не по истечении срока;
|
||||||
|
- сессия переживает выкладку;
|
||||||
|
- проба здоровья и метрики остаются открытыми.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- владелец у записи и сужение выборки по нему — задача `record-ownership`;
|
||||||
|
- вход для программ по личным токенам — отдельная цель роадмапа. Внешняя
|
||||||
|
программа, ходившая в API анонимно, этим изменением ломается намеренно, и
|
||||||
|
замены ей здесь не появляется;
|
||||||
|
- белый список Telegram — живёт до `telegram-account-link`;
|
||||||
|
- панель администратора — в неё провайдер не пускает, наружу её закрывает
|
||||||
|
обратный прокси;
|
||||||
|
- своя страница входа со скриптом: у сервиса нет фронтенда, и заводить его ради
|
||||||
|
входа незачем.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Сессия предъявляется кукой, а заголовок остаётся внутренним
|
||||||
|
|
||||||
|
**Выбрано:** наш обработчик возврата ставит куку `HttpOnly`, `Secure`,
|
||||||
|
`SameSite=Lax` со значением, выданным хранилищем. Промежуточный слой перед
|
||||||
|
проверкой перекладывает значение куки в заголовок `Authorization`, если заголовка
|
||||||
|
нет. Дальше работает штатная проверка библиотеки.
|
||||||
|
|
||||||
|
Человек увидит обычный вход: перешёл, авторизовался у Authelia, вернулся —
|
||||||
|
работает. Ни строки скрипта на его стороне.
|
||||||
|
|
||||||
|
Отвергнуто:
|
||||||
|
|
||||||
|
- **заголовок `Authorization` как единственный способ.** Это механика библиотеки
|
||||||
|
и путь наименьшего кода, но браузер такой заголовок сам не шлёт: понадобился
|
||||||
|
бы свой фронтенд, который держит значение и подставляет его. Фронтенда у
|
||||||
|
сервиса нет, а заводить его ради входа — работа шире задачи. Критерий приёмки
|
||||||
|
задачи вдобавок написан про куку;
|
||||||
|
- **своя таблица сессий.** Даёт полный контроль над выходом и сроком, но заводит
|
||||||
|
второй способ делать то, что хранилище уже делает, — и второй дом для факта
|
||||||
|
«кто вошёл». Отвергнуто по концептуальной целостности.
|
||||||
|
|
||||||
|
Заголовок при этом остаётся рабочим: его требуют собственные адреса
|
||||||
|
аутентификации хранилища, и глушить их значит ломать библиотеку изнутри. Это
|
||||||
|
осознанно оставленная вторая дверь, и она названа в спеке.
|
||||||
|
|
||||||
|
### Форма решения выбрана из трёх, а не из одной
|
||||||
|
|
||||||
|
Прежде трёх решений ниже — выбор самой формы. Рассматривались три.
|
||||||
|
|
||||||
|
**A — свой тонкий слой входа поверх хранилища.** Выбрана. Наш код ведёт флоу и
|
||||||
|
ставит куку, обмен кода и заведение учётной записи остаются за хранилищем.
|
||||||
|
Цена: сверка состояния и установка куки — наша ответственность, то есть ошибки
|
||||||
|
в чувствительном месте наши.
|
||||||
|
|
||||||
|
**B — вход целиком на обратном прокси.** Authelia стоит перед сервисом и не
|
||||||
|
пускает неузнанные запросы, приложение доверяет заголовку от прокси. Нашего кода
|
||||||
|
почти ноль. Отвергнуто по трём причинам сразу: при прямом обращении к порту
|
||||||
|
заголовок подделывает кто угодно в той же сети, а сервис не имеет способа
|
||||||
|
отличить прокси от постороннего; учётные записи в панели не появляются вовсе, а
|
||||||
|
решение 2026-08-11 требует обратного; вход для программ по личным токенам из
|
||||||
|
этой формы не вырастает — его пришлось бы делать заново и мимо.
|
||||||
|
|
||||||
|
**C — фронтенд и штатный клиент хранилища.** Своя страница, всплывающее окно,
|
||||||
|
подписка, значение сессии в хранилище браузера. Всё штатно для библиотеки.
|
||||||
|
Отвергнуто: у сервиса нет фронтенда, и заводить его ради входа — работа шире
|
||||||
|
задачи; значение сессии становится доступно скриптам страницы, то есть XSS
|
||||||
|
уносит сессию целиком, тогда как кука с запретом чтения скриптом этого не даёт.
|
||||||
|
|
||||||
|
### Вход и возврат ведёт наш код, разбор ответа — хранилище
|
||||||
|
|
||||||
|
**Выбрано:** три своих адреса — начало входа, возврат от провайдера, выход.
|
||||||
|
Начало входа заводит `state` и PKCE-verifier, кладёт их во временную куку и
|
||||||
|
уводит человека на `authURL` провайдера. Возврат сверяет `state`, а код отдаёт
|
||||||
|
хранилищу вызовом его же обмена — тем, что стоит за `auth-with-oauth2`.
|
||||||
|
|
||||||
|
**Обмен кода библиотека наружу не отдаёт** — он живёт неэкспортированной
|
||||||
|
функцией за собственным адресом хранилища. Решением владельца от 2026-08-12
|
||||||
|
обработчик возврата зовёт **этот адрес внутри процесса**, через роутер
|
||||||
|
хранилища, а не по сети.
|
||||||
|
|
||||||
|
Цена названа и принята: получается петля «наш обработчик → наш роутер → наш
|
||||||
|
обработчик», ответ разбирается текстом, а типизированная ошибка теряется.
|
||||||
|
Взамен решение 2026-08-11 соблюдается дословно — разбор ответа провайдера
|
||||||
|
остаётся за хранилищем, и учётные записи видны в панели.
|
||||||
|
|
||||||
|
Отвергнуто:
|
||||||
|
|
||||||
|
- **собрать обмен своими руками** из кусков, которые библиотека всё же отдаёт.
|
||||||
|
Прямой код без петли, но разбор ответа провайдера переезжает к нам — это
|
||||||
|
пересмотр решения 2026-08-11 отдельным ADR, и владелец его не выбрал;
|
||||||
|
- **всплывающее окно и realtime-подписка**, как делает JS-клиент библиотеки.
|
||||||
|
Работает без нашего кода вовсе, но требует того самого фронтенда и держит
|
||||||
|
открытым realtime-соединение ради одного входа.
|
||||||
|
|
||||||
|
### Кого пускать, решает провайдер, а не сервис
|
||||||
|
|
||||||
|
Решением владельца от 2026-08-12 сервис своей проверки допуска **не делает**:
|
||||||
|
кто допущен, определяет правило Authelia на этого клиента. Всякий, кого
|
||||||
|
провайдер пропустил, получает учётную запись и доступ.
|
||||||
|
|
||||||
|
Цена принята и обязана быть записанной: правило живёт вне репозитория, в
|
||||||
|
настройках выкладки, и сервис на него полагается так же, как полагается на
|
||||||
|
обратный прокси в части панели администратора. Настроенный слишком широко
|
||||||
|
клиент открывает сервис всем, у кого есть учётная запись в общей Authelia, — и
|
||||||
|
проверить это по коду нельзя. Строка об этом идёт в `docs/security.md`, раздел
|
||||||
|
«Что разграничивает доступ».
|
||||||
|
|
||||||
|
Отвергнуто: **проверка группы своим кодом** — защита стояла бы в сервисе и не
|
||||||
|
зависела от настройки контура, но владелец выбрал не заводить второе место, где
|
||||||
|
решается допуск.
|
||||||
|
|
||||||
|
### Выход обесценивает выданные сессии, а не только убирает куку
|
||||||
|
|
||||||
|
**Выбрано:** выход обновляет ключ токенов учётной записи
|
||||||
|
(`Record.RefreshTokenKey()` плюс сохранение) и убирает куку. Подпись сессии
|
||||||
|
считается от этого ключа, поэтому все прежние значения перестают проходить
|
||||||
|
разом.
|
||||||
|
|
||||||
|
Отвергнуто:
|
||||||
|
|
||||||
|
- **только уборка куки.** Унесённое значение продолжало бы открывать доступ до
|
||||||
|
истечения срока — то есть выход не закрывал бы доступ, а делал вид;
|
||||||
|
- **чёрный список выданных значений.** Даёт точечный выход одной сессии, но
|
||||||
|
требует своей таблицы и её чистки; при одном человеке и одном браузере это
|
||||||
|
цена без покупателя.
|
||||||
|
|
||||||
|
Цена выбранного названа прямо: выход закрывает **все** сессии учётной записи, а
|
||||||
|
не только текущую. При сегодняшнем числе пользователей это незаметно, и
|
||||||
|
переделка, когда станет заметно, — чёрный список из отвергнутого варианта.
|
||||||
|
|
||||||
|
### Сессия переживает перезапуск сама
|
||||||
|
|
||||||
|
Проверено по исходникам: подпись считается от секрета коллекции
|
||||||
|
(`Collection().AuthToken.Secret`) и ключа записи, оба лежат в базе
|
||||||
|
(`core/record_query.go`, `FindAuthRecordByToken`). Значит требование выполняется
|
||||||
|
устройством хранилища, и нашей работы здесь нет — есть проверка тестом.
|
||||||
|
|
||||||
|
### Настройки провайдера приводятся к конфигу при каждом запуске
|
||||||
|
|
||||||
|
**Выбрано:** шаг схемы включает провайдера с пустыми значениями, а адреса,
|
||||||
|
идентификатор клиента и секрет проставляются при подъёме сервиса из конфига.
|
||||||
|
|
||||||
|
Причина в инварианте: **применённый шаг схемы не переписывается**. Проставь
|
||||||
|
секрет однажды шагом — и ротация секрета в конфиге до хранилища не доедет вовсе,
|
||||||
|
вход сломается после смены ключа, а починить это можно будет только руками в
|
||||||
|
панели.
|
||||||
|
|
||||||
|
Отвергнуто:
|
||||||
|
|
||||||
|
- **секрет в шаге схемы.** Разбито инвариантом выше;
|
||||||
|
- **настройка руками в панели.** Работает, но не воспроизводится: поднятый с
|
||||||
|
нуля сервис оказывается без входа, и знание живёт в голове владельца.
|
||||||
|
|
||||||
|
### Что изменило ревью кода
|
||||||
|
|
||||||
|
Три решения приняты владельцем 2026-08-12 уже после того, как код был написан:
|
||||||
|
ревью нашло, что заявленное поведение не работает.
|
||||||
|
|
||||||
|
**Продление сессии выключено.** Хранилище выдаёт сессию продлеваемой, и
|
||||||
|
предъявитель менял своё значение на новое бессрочно, никуда не входя. При живом
|
||||||
|
продлении семисуточный срок не значил ничего — а он объявлен единственным
|
||||||
|
каналом, которым отзыв доступа у провайдера доходит до сервиса. Цена: вход раз в
|
||||||
|
семь суток. Отвергнуто: сверяться с провайдером по расписанию (новая связь с
|
||||||
|
Authelia и обработка её недоступности — работа шире задачи) и принять как есть
|
||||||
|
(тогда паспорт теряет способ остановить того, кто тратит слишком много).
|
||||||
|
|
||||||
|
**Файл записи открыт вошедшим.** Пометка поля защищённым сама по себе закрыла
|
||||||
|
файл вообще для всех, кроме владельца панели: защищённый файл судится ещё и
|
||||||
|
правилом просмотра коллекции, а незаданное правило означает «только
|
||||||
|
суперпользователь». Назначено правило для всякого узнанного. Отвергнуто:
|
||||||
|
оставить файл только панели — тогда задача про прослушивание записи начинается с
|
||||||
|
того же вопроса.
|
||||||
|
|
||||||
|
**Форма адреса провайдера проверяется на старте.** Непустая, но негодная строка
|
||||||
|
проходила проверку конфига и отвергалась хранилищем позже — из хука подъёма, до
|
||||||
|
регистрации пробы здоровья. Сервис падал целиком, вместе с ботом и воркерами, а
|
||||||
|
у владельца не было даже кода состояния. Отвергнуто: поднимать пробу здоровья
|
||||||
|
раньше настройки провайдера — это завело бы состояние «сервис жив, вход сломан»,
|
||||||
|
которого спека не описывает.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **Секрет клиента появляется в новом месте — в базе.** → Инвариант проекта
|
||||||
|
запрещает секрету попадать в git, в лог, в ответ и в `error_text`; база в этом
|
||||||
|
перечне не значится, и запрета не нарушает. Но место новое, и модель угроз
|
||||||
|
обязана его назвать: чтение файла базы теперь равносильно чтению секрета
|
||||||
|
клиента. Пишется в `docs/security.md` этой же задачей.
|
||||||
|
- **Ломается внешняя программа, ходившая в API анонимно.** → Ломка намеренная и
|
||||||
|
объявлена в предложении: это и есть предмет задачи. Замены для программ
|
||||||
|
(личные токены) в этом изменении нет — она отдельной целью.
|
||||||
|
- **Вторая дверь: заголовок `Authorization` остаётся принимаемым.** → Он
|
||||||
|
предъявляет ту же сессию и той же проверке, поэтому обхода не даёт. Но это
|
||||||
|
второй способ войти, и в спеке он назван, чтобы не был обнаружен ревью как
|
||||||
|
находка.
|
||||||
|
- **Выход закрывает все сессии учётной записи.** → Названо решением выше, цена
|
||||||
|
принята.
|
||||||
|
- **PKCE-verifier и `state` живут во временной куке.** → Кука ставится на время
|
||||||
|
входа, `HttpOnly` и `SameSite=Lax`, и убирается на возврате. Хранить их в
|
||||||
|
памяти процесса нельзя: выкладка посреди входа роняла бы вход.
|
||||||
|
- **Признак `Secure` закрывает локальный запуск.** → Браузер не сохранит такую
|
||||||
|
куку по `http://localhost`, и вход перестанет работать у того, кто поднимает
|
||||||
|
сервис командой из раздела команд. Признак берётся из конфига с умолчанием
|
||||||
|
«включено», и расхождение образца называется строкой в
|
||||||
|
`docs/conventions/config.md`.
|
||||||
|
- **Коллекция пользователей остаётся умолчательной `users`.** → Своя коллекция
|
||||||
|
означала бы задание правил и способов входа с нуля вместо подчистки
|
||||||
|
умолчаний, а переезд позже — перевязку связей с провайдером и обесценивание
|
||||||
|
всех выданных сессий. Цена умолчательной: её заводит системный шаг библиотеки
|
||||||
|
с открытым созданием записи, и закрывать это приходится нам.
|
||||||
|
- **Проверить вход целиком без живой Authelia нельзя.** → Тесты закрывают
|
||||||
|
сверку `state`, отказ без сессии, выход и сохранность сессии; живой вход у
|
||||||
|
провайдера остаётся ручной проверкой владельца на выкладке. Это граница
|
||||||
|
покрытия, и она называется в докладе.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Шаг схемы включает провайдера у коллекции пользователей и накатывается при
|
||||||
|
подъёме, как и прежние шаги. Данных он не трогает: ни одной записи не
|
||||||
|
переписывается, учётные записи заводятся сами при первом входе.
|
||||||
|
|
||||||
|
Откат — прежний образ: шаг схемы обратим своим `down`, а до первого входа в
|
||||||
|
коллекции пользователей пусто.
|
||||||
|
|
||||||
|
Порядок выкладки: сперва завести клиента в Authelia и получить секрет, потом
|
||||||
|
положить его в конфиг на сервере, потом выкладывать. Обратный порядок поднимает
|
||||||
|
сервис с провайдером без секрета — вход не работает, а API уже закрыт.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Адрес возврата должен совпадать с тем, что записан клиенту в Authelia. Значение
|
||||||
|
выбирается при заведении клиента и попадает в конфиг; здесь оно не
|
||||||
|
фиксируется.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
HTTP API открыт наружу без всякой проверки: кто угодно из интернета заводит
|
||||||
|
задачи расшифровки за наши деньги и читает чужие расшифровки, подобрав
|
||||||
|
идентификатор задачи. Сегодняшний периметр так и записан в модели угроз —
|
||||||
|
аутентификации не делает ни обратный прокси, ни само приложение.
|
||||||
|
|
||||||
|
Второго человека пригласить в сервис сейчас нельзя: это значит открыть ему всё,
|
||||||
|
что в сервисе уже лежит.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Сервис узнаёт, кто к нему пришёл. Учётные записи заводит и проверяет внешний
|
||||||
|
провайдер — Authelia по OIDC; своей регистрации и своих паролей не заводим.
|
||||||
|
- **BREAKING** Приём записи и опрос готовности задачи требуют входа: запрос без
|
||||||
|
сессии получает отказ и не заводит задачу, а текста расшифровки не отдаёт.
|
||||||
|
Внешняя программа, ходившая в API без всякого входа, перестаёт работать.
|
||||||
|
- Появляются вход и выход: вход уводит человека к провайдеру и возвращает
|
||||||
|
обратно уже узнанным, выход закрывает доступ немедленно.
|
||||||
|
- Проба здоровья и метрики остаются открытыми и сессии не требуют: ни у пробы,
|
||||||
|
ни у сборщика метрик её нет. Наружу их закрывает правило обратного прокси —
|
||||||
|
это работа выкладки.
|
||||||
|
- Разграничения записей по владельцу здесь **нет**: после входа человек видит
|
||||||
|
ровно столько же, сколько видно сейчас.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `access`: кто пришёл в сервис и пускают ли его дальше — вход через внешнего
|
||||||
|
провайдера, чем предъявляется сессия, что её прекращает и какие адреса
|
||||||
|
остаются открытыми.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `intake`: приём записи и опрос готовности задачи перестают быть доступны
|
||||||
|
анонимно — оба требуют узнанного отправителя.
|
||||||
|
- `storage`: ссылка на файл записи перестаёт быть правом пройти по ней — файл
|
||||||
|
отдаётся только узнанному отправителю.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Коллекция пользователей хранилища: включённый провайдер `oidc` с адресами
|
||||||
|
Authelia, идентификатором клиента и секретом; связь учётной записи с внешним
|
||||||
|
провайдером хранилище ведёт своей служебной коллекцией.
|
||||||
|
- `POST /api/audio` и `GET /api/status/{id}` — публичный контракт HTTP API
|
||||||
|
объявлен проектом необратимым, и здесь он меняется: у обоих появляется отказ
|
||||||
|
без входа.
|
||||||
|
- Новые адреса входа, возврата от провайдера и выхода.
|
||||||
|
- Секция конфигурации под провайдера: адрес, идентификатор клиента, секрет.
|
||||||
|
Секрет попадает в настройки коллекции хранилища — это новое место, где он
|
||||||
|
живёт, и его надо назвать в модели угроз.
|
||||||
|
- `config.dist.toml` и разбор конфига.
|
||||||
|
- `docs/security.md`: первая строка периметра перестаёт быть верной.
|
||||||
|
- Панель администратора не трогается: в неё провайдер не пускает, и закрывает её
|
||||||
|
обратный прокси.
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
# Ревью кода: oidc-login — триаж
|
||||||
|
|
||||||
|
База диффа: `origin/master`, изменение целиком в рабочем дереве. Дата прогона: 2026-08-12.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что сделано по итогам (дописано оркестратором после отработки)
|
||||||
|
|
||||||
|
Все семь пунктов закрыты; три развилки решены владельцем 2026-08-12.
|
||||||
|
|
||||||
|
| Пункт | Исход |
|
||||||
|
|---|---|
|
||||||
|
| 1. Войти не может никто | Исправлено: `CreateRule = @request.context = "oauth2"` правкой неуехавшего шага. Оракул — `TestLoginCreatesAccountAndSession`: вход целиком через подставного провайдера, учётная запись заводится, сессия выдаётся |
|
||||||
|
| 2. Утечка обработчиков | Исправлено: роутер хранилища собирается один раз через `sync.Once`. Оракул — `TestCallbackDoesNotLeakHooks`: 20 возвратов не меняют длины очереди |
|
||||||
|
| 3. Файл не отдаётся вошедшему | **Решение владельца: открыть вошедшим.** Назначено `files.ViewRule = @request.auth.id != ""`, спека дополнена порядком «сессия → токен файла → ссылка». Оракул — `TestRecordFileNeedsSessionAndToken` |
|
||||||
|
| 4. Носитель состояния не убирался | Исправлено: уборка перенесена до записи ответа; все проверки файла судят по `w.Result()`, а не по живой карте заголовков |
|
||||||
|
| 5. Опечатка в `[auth]` роняет процесс | **Решение владельца: проверять форму адреса на старте.** `Validate` разбирает адреса и требует схему и хост. Оракул — `TestAuthConfigValidateRejectsMalformedURL` |
|
||||||
|
| 6. Отзыв доступа не доходит | **Решение владельца: выключить продление.** Заведён слой `BlockSessionRefresh`; спека и модель угроз дополнены. Оракул — `TestSessionRefreshIsClosed` |
|
||||||
|
| 7. Сердцевина не покрыта | Исправлено: заведён `login_test.go` (вход целиком, отказ обмена, утечка, продление, файл) и `internal/config/config_test.go`; закрыты ветки выхода |
|
||||||
|
|
||||||
|
Сверх семи, из срезанного потолком, исправлено там же, где окно закрывается мерджем:
|
||||||
|
|
||||||
|
- **G** — срок жизни сессии перенесён из шага схемы в приведение настроек при подъёме;
|
||||||
|
- **L** — откат шага больше не падает на валидации и не возвращает открытую регистрацию;
|
||||||
|
- **O** — `docs/database.md` приведён к коду, коллекция `users` описана, три числа внесены в таблицу;
|
||||||
|
- **Q** — уровни журнала разведены по адресату, добавлено поле `capability`;
|
||||||
|
- **R** — `auth.client_secret` внесён в перечень секретов и в инвариант `CLAUDE.md` с изъятием про базу;
|
||||||
|
- **N** — причина отказа провайдера приводится к перечню известных кодов;
|
||||||
|
- язык ответов пользователю переведён на русский.
|
||||||
|
|
||||||
|
**Не сделано намеренно, ушло в урожай:** `P` (настройка `docs/.docs.json` указывает на несуществующий каталог миграций — дефект гейта, не этого изменения), `M` (код провайдера в журнале запросов хранилища), `S` (начало входа собрано руками), `T`/`U` (рантбук выкладки), `V` (проверка конфига не в норме), гипотезы без пути и остаток пункта 4 (серверный учёт употреблённых состояний).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сводка
|
||||||
|
|
||||||
|
**Размер, сложность, метка.** Размер — крупное: 24 подзадачи в 6 группах, 5 слоёв кода, 8 узлов в «Затрагивает», 2 capability (одна ADDED целиком). Сложность — незнакомое: `docs/review.md`, «Триггеры метки», «Незнакомое здесь» называет вход через OIDC дословно первой строкой. **Метка `large`** (максимум по осям), **режим — по графу**, опиниативные проходы открыты.
|
||||||
|
|
||||||
|
**Состояние гейта: ЗЕЛЁНЫЙ.** Проверено собственным прогоном триажа, а не только отчётом прохода: `task gate BASE=origin/master` → exit 0, все девять шагов. Унаследованное замечание `tasks.py` (`any-audio-source`) шаг не роняет и к диффу отношения не имеет.
|
||||||
|
|
||||||
|
**Зелёный гейт здесь — часть находки, а не свидетельство.** Два теста в `internal/controller/http/auth_test.go` утверждают проверенным то, что не работает (пункты 3 и 4), и оба зелёные. Два пункта `tasks.md` — 5.5 и 5.8 — отмечены `[x]` за проверки, которых в файле нет.
|
||||||
|
|
||||||
|
### План разметки задачи с исходом по каждой теме
|
||||||
|
|
||||||
|
| тема | дом | глубина | кто закрывает | исход |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| requirements | `openspec/changes/oidc-login/specs/{access,intake,storage}/spec.md` | разбор | specs | **закрыта**, 5 находок (C, D, E, M, V) + 3 блока наблюдений |
|
||||||
|
| autotests | `CLAUDE.md`, «Гейт» | — | autotests | **закрыта**, 3 находки (H, I, J) + отчёт гейта и `govulncheck` |
|
||||||
|
| conventions | `docs/conventions/{config,database,errors,logging}.md` | разбор | code | **закрыта**, 7 находок (A, L, O, P, Q, R + доля в B) |
|
||||||
|
| architecture | `docs/architecture.md`; источник `docs/passport.md` | доказательство | architecture | **закрыта**, 3 находки (B, G, S) + 5 пунктов «дешевле переделать» |
|
||||||
|
| security | `docs/security.md` | доказательство | adversary | **закрыта**, 6 находок (A, B, E, F, M, N) + 5 свойств без пути |
|
||||||
|
| operations | `docs/architecture.md` «Эксплуатация»; источник `docs/database.md` | доказательство | ops | **закрыта**, 3 находки (K, T, U) |
|
||||||
|
|
||||||
|
**Тем без отчёта нет.** Все шесть тем ядра вернули отчёты. `review-basics` не запускался — по решению `review-scope`: своих тем сверх ядра у проекта нет. Тем, унесённых непроверенными, нет.
|
||||||
|
|
||||||
|
**Сигнал о заниженной метке: не поступил, и провенанс у этого один.** `review-code` подал строку прямо: «метка `large` соответствует изменению, понижения не вижу». `review-basics` не запускался, поэтому второго независимого корректора метки у прогона не было — согласия двух проходов нет, есть отсутствие возражения от одного.
|
||||||
|
|
||||||
|
**Находок на входе:** 22 нумерованных (A–V) плюс 23 ненумерованных содержательных пункта (8 «поведение вне спеки», 5 «границы спеки», 5 «свойства без построенного пути», 5 «дешевле переделать») = **45 позиций**. **Осталось в основных секциях: 7** — 3 блокирующие и 4 «стоит исправить сейчас». Слито по причине 4 группы, понижено до гипотез 5, уехало в promote 6, выброшено как вкусовщина 3, срезано потолком с поимённым перечислением 13.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Блокирует мердж
|
||||||
|
|
||||||
|
### 1. После выкладки войти не может никто, включая владельца — первый вход не заводит учётной записи
|
||||||
|
|
||||||
|
- Файл: `internal/adapter/repo/pocketbase/migrations.go:149`
|
||||||
|
- Severity: `critical` · Confidence: `high`
|
||||||
|
- Найдено проходами: code, adversary (2 прохода, оракула два разных; согласие приоритет повышает, `confidence` — нет)
|
||||||
|
- **Оракул — мой собственный, добыт на этом прогоне.** Тест против настоящего PocketBase с подставным провайдером OIDC (`httptest`, token + userinfo), запущен через `go test -overlay=…` без записи в дерево проекта:
|
||||||
|
|
||||||
|
```
|
||||||
|
users.CreateRule = <nil>
|
||||||
|
ИСХОД: код=401 обращений к токен-эндпоинту=1 учётных записей=0
|
||||||
|
тело={"error":"Login failed"}
|
||||||
|
ERROR Failed to exchange provider code error="storage rejected the exchange with code 403"
|
||||||
|
```
|
||||||
|
|
||||||
|
Причина изолирована тем же прогоном: с `CreateRule = @request.context = "oauth2"` результат `код=302 учётных записей=1 Location="/"`, при этом анонимный `POST /api/collections/users/records` по-прежнему получает `400`. Подтверждение по исходникам библиотеки: `apis/record_crud.go:230-232` (`!hasSuperuserAuth && collection.CreateRule == nil` → Forbidden); внутренний запрос обмена идёт без авторизации.
|
||||||
|
- Последствие: шаг схемы закрывает создание записи для всех, кроме суперпользователя, а запись при первом входе заводит именно внутренний запрос обмена. Ни одной записи `users` шаг схемы не создаёт, приём и опрос закрыты сессией. После выкладки HTTP-вход не работает ни у кого; жив только Telegram. Лечится руками в панели — ровно то, от чего задача уходила.
|
||||||
|
- Предложение: `users.CreateRule = ptr("@request.context = \"oauth2\"")` **правкой самого шага `up202608120001`**: он ещё не уезжал на сервер, и окно закрывается мерджем — после выкладки то же изменение потребует нового шага. **Не** чинить открытием `CreateRule = ""`: публичный `auth-with-oauth2` принимает `createData`, и всякий владелец учётной записи Authelia задаст поля новой записи сам.
|
||||||
|
- Действие: **инлайн**
|
||||||
|
|
||||||
|
### 2. Всякий анонимный запрос на возврат навсегда добавляет обработчики приложению и дёргает Authelia нашим секретом
|
||||||
|
|
||||||
|
- Файл: `internal/controller/http/auth.go:224-234`
|
||||||
|
- Severity: `critical` · Confidence: `high`
|
||||||
|
- Найдено проходами: architecture, adversary (`critical`), specs и code (`minor`) — 4 прохода, оракула два
|
||||||
|
- **Оракул — мой собственный, тот же прогон:**
|
||||||
|
|
||||||
|
```
|
||||||
|
OnModelAfterCreateSuccess: до=5 после 50 возвратов=55
|
||||||
|
OnModelAfterUpdateSuccess: до=6 после=106
|
||||||
|
обращений к провайдеру за 50 анонимных возвратов: 50
|
||||||
|
провайдер недоступен: обработчиков до=5 после 10 возвратов=15
|
||||||
|
```
|
||||||
|
|
||||||
|
Последняя строка важна: утечка происходит **раньше** сетевого обращения и работает при мёртвом провайдере. Путь анонимный: атакующий сам ставит себе куку `transcriber_login=S:V` и зовёт `/auth/callback?state=S&code=x` — сверка сравнивает две его же величины. Причина: `apis.NewRouter` (`apis/base.go:47,53`) зовёт `bindRealtimeEvents` и `bindUIExtensions`, которые вешают обработчики **на приложение** без поля `Id`; `hook.Bind` (`tools/hook/hook.go:64`) дописывает, а не заменяет.
|
||||||
|
- Последствие: рост линейный и не освобождается до перезапуска; каждое сохранение задачи конвейером проходит по всем накопленным замыканиям (замер adversary: 3000 хитов → 100 сохранений 3.86ms → 59.8ms, куча +5013 КиБ). Побочно каждый анонимный запрос гонит обмен к Authelia нашими `client_id`/`client_secret`.
|
||||||
|
- Предложение: строить роутер один раз (при подъёме либо `sync.Once`), держать `http.Handler` полем `AuthHandler`. Решение владельца о петле внутри процесса не пересматривается.
|
||||||
|
- Остаток, который правка не закрывает и который я не заказываю: анонимный запрос по-прежнему вызывает исходящее обращение к провайдеру. Ограничение числа запросов в scope изменения не входит; названо, чтобы не потерялось.
|
||||||
|
- Действие: **инлайн**
|
||||||
|
|
||||||
|
### 3. Файл записи не отдаётся ни одному вошедшему — сценарий дельты не исполняется, а `docs/security.md` уже утверждает обратное
|
||||||
|
|
||||||
|
- Файл: `internal/adapter/repo/pocketbase/migrations.go:168-179`; тест `internal/controller/http/auth_test.go:405-431`; `docs/security.md:112-114`; `openspec/changes/oidc-login/tasks.md:68`
|
||||||
|
- Severity: `major` · Confidence: `high`
|
||||||
|
- Найдено проходами: specs, code, adversary
|
||||||
|
- **Оракул — мой собственный, тот же прогон:**
|
||||||
|
|
||||||
|
```
|
||||||
|
files.ViewRule = <nil>
|
||||||
|
вошедший кукой → 404
|
||||||
|
вошедший заголовком → 404
|
||||||
|
файловый токен получен: код=200 непусто=true
|
||||||
|
вошедший файловым токеном → 404
|
||||||
|
аноним → 404
|
||||||
|
```
|
||||||
|
|
||||||
|
Причина: `Protected=true` включает проверку по файловому токену **и** по `ViewRule` коллекции (`apis/file.go:109-134`); `ViewRule` у `files` не назначался ни одним шагом → `nil` → доступ только суперпользователю (`core/record_query.go:606-608`).
|
||||||
|
- Последствие: сценарий дельты `storage` «GIVEN забирающий предъявил сессию THEN приходит тот же файл» не исполняется. Пункт `tasks.md` 5.8 («без сессии отдаёт отказ, **а с сессией — тот же файл**») отмечен сделанным, а тест проверяет только отказ анониму. `docs/security.md:113` уже переписан утверждением «пройти по ссылке теперь можно только с сессией»: сегодня это ложно. Заведённая задача про прослушивание записи упрётся сюда и, вероятнее всего, «починит» снятием `Protected`, вернув «знание ссылки = доступ».
|
||||||
|
- Действие: **развилка**
|
||||||
|
|
||||||
|
**Вопрос владельцу.** Файл записи защищён так, что его не получает никто, кроме владельца панели. Что делаем:
|
||||||
|
1. назначить `files.ViewRule` для вошедших и описать в спеке шаг «сессия → файловый токен → ссылка» (цена: правка шага схемы + новый абзац нормы + тест; окно на правку неуехавшего шага закрывается мерджем);
|
||||||
|
2. переписать требование как «файл виден только владельцу в панели», снять сценарий из дельты `storage` и поправить `docs/security.md` (цена: правка нормы, задача про прослушивание записи начинается с этого же вопроса);
|
||||||
|
3. оставить как есть и записать расхождение (цена: норма и код разошлись сознательно, следующий проход найдёт то же самое).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Стоит исправить сейчас
|
||||||
|
|
||||||
|
### 4. Носитель состояния входа живёт 10 минут вместо одного входа, и стерегущий его тест зелёный ложно
|
||||||
|
|
||||||
|
- Файл: `internal/controller/http/auth.go:131` (defer), `293-303`; тест `internal/controller/http/auth_test.go:272`
|
||||||
|
- Severity: `major` · Confidence: `high`
|
||||||
|
- Найдено проходами: specs, code (сведено с находкой «одноразовость состояния не реализована ничем» — причина одна: единственным механизмом одноразовости была уборка куки)
|
||||||
|
- **Оракул — мой собственный, тот же прогон, настоящий сервер против recorder'а:**
|
||||||
|
|
||||||
|
```
|
||||||
|
НАСТОЯЩИЙ СЕРВЕР: код=401 Set-Cookie=[]
|
||||||
|
RECORDER rec.Header()=[transcriber_login=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax]
|
||||||
|
RECORDER rec.Result().Header=[]
|
||||||
|
УСПЕХ: код=302 Location="/" Set-Cookie=[transcriber_session=…; Max-Age=604800; HttpOnly; Secure; SameSite=Lax]
|
||||||
|
```
|
||||||
|
|
||||||
|
На успешной ветке уборки состояния тоже нет — в ответе только кука сессии. Причина: `e.SetCookie` правит карту заголовков, а `defer` исполняется **после** `e.JSON`/`e.Redirect`, которые уже позвали `WriteHeader`. `net/http` при `WriteHeader` фиксирует снимок; `httptest.ResponseRecorder` наоборот — `Header()` отдаёт живую карту, а снимок лежит в `snapHeader`, который читает `Result()`.
|
||||||
|
- Последствие: спека требует «MUST убираться на возврате — и на успешном, и на отказном»; носитель не убирается ни там, ни там и остаётся годным 10 минут. Повторно поданный URL возврата проходит сверку и уходит в обмен; отказ наступает только потому, что код у провайдера одноразовый — гарантия перенесена на внешнюю систему, чего спека не допускает. Тест утверждает обратное и проходит, потому что читает `w.Header()`.
|
||||||
|
- Предложение: убирать куку **до** записи ответа (как уже сделано в `Logout`); всем тестам этого файла судить по `w.Result()`.
|
||||||
|
- Остаток: `tasks.md` 5.5 обещает ещё и «повторный возврат с уже употреблённым состоянием», то есть учёт употреблённых состояний. Уборка куки закрывает переигрывание тем же браузером, но не учёт как таковой. Нужен ли учёт — вопрос владельцу, отдельно от этой правки; в код его сейчас не заказываю.
|
||||||
|
- Действие: **инлайн**
|
||||||
|
|
||||||
|
### 5. Опечатка в `[auth]` кладёт весь сервис детерминированно, и `/health` в этот момент ещё не зарегистрирован
|
||||||
|
|
||||||
|
- Файл: `main.go:220-234`; `internal/config/config.go:69-90`
|
||||||
|
- Severity: `major` · Confidence: `high`
|
||||||
|
- Найдено проходом: ops
|
||||||
|
- Оракул: эксперимент прохода — `ApplyProviderSettings` с непустым, но негодным URL → `oauth2: (providers: (0: (authURL: must be a valid URL; tokenURL: …).).)`. Проверено мной по коду: `AuthConfig.Validate` (`config.go:69-90`) сверяет **только непустоту** шести ключей; `ApplyProviderSettings` зовётся внутри хука `OnServe` **выше** регистрации `/health` и `/metrics` (`main.go:227-234` против `main.go:239+`), а ошибка хука прерывает `apis.Serve` до открытия порта (`apis/serve.go:216-269`). Далее общий shutdown останавливает бот и все три воркера.
|
||||||
|
- Последствие: пробел от шаблона или отсутствующая схема в адресе роняет сервис целиком при каждом перезапуске, и у владельца нет даже кода состояния — только текст в журнале контейнера. Это первая выкладка этой секции конфига, шесть новых ключей.
|
||||||
|
- Действие: **развилка**
|
||||||
|
|
||||||
|
**Вопрос владельцу.** Негодный адрес провайдера сегодня валит процесс молча. Что делаем:
|
||||||
|
1. проверять форму URL в `Validate()` — отказ переезжает на старт, называет ключ поимённо и виден в журнале сразу (цена: три строки, поведение «не поднимаемся с кривым входом» сохраняется);
|
||||||
|
2. регистрировать `/health` и `/metrics` **до** `ApplyProviderSettings` — сервис поднимается и честно отвечает о своём состоянии (цена: появляется состояние «сервис жив, вход сломан», которого спека не описывает);
|
||||||
|
3. и то и другое.
|
||||||
|
|
||||||
|
### 6. Отзыв доступа в Authelia не доходит до сервиса никогда: предъявитель продлевает сессию сам
|
||||||
|
|
||||||
|
- Файл: `internal/adapter/repo/pocketbase/migrations.go:126-130` (комментарий); `openspec/changes/oidc-login/specs/access/spec.md:185-188`; `docs/security.md:145`
|
||||||
|
- Severity: `major` · Confidence: `high`
|
||||||
|
- Найдено проходом: adversary
|
||||||
|
- **Оракул — мой собственный, тот же прогон:**
|
||||||
|
|
||||||
|
```
|
||||||
|
auth-refresh #1 → код=200 новый токен непуст=true
|
||||||
|
auth-refresh #2 → код=200 новый токен непуст=true
|
||||||
|
auth-refresh #3 → код=200 новый токен непуст=true
|
||||||
|
продлённое значение на /api/status → 404 (401 значило бы, что не работает)
|
||||||
|
```
|
||||||
|
|
||||||
|
Замер adversary добавляет растущий `exp` (13:32:59 → 13:33:00 → 13:33:01 → 13:33:03) и claim `refreshable=true`.
|
||||||
|
- Последствие: спека дельты `access` объявляет срок сессии «единственным, что доносит до сервиса отзыв доступа у провайдера», и на этом утверждении стоит ссылка паспорта на отзыв в Authelia как на способ остановить перерасход. Канала нет: предъявитель одного живого значения продлевает себе доступ бессрочно, никуда не входя. Аноним так не может — нужен живой токен.
|
||||||
|
- Действие: **развилка**
|
||||||
|
|
||||||
|
**Вопрос владельцу.** Что делаем с продлением сессии:
|
||||||
|
1. выключить продление (закрыть `auth-refresh` для `users`) — отзыв начинает доходить за семь суток, как обещает норма (цена: человек перевходит раз в неделю);
|
||||||
|
2. сверяться с провайдером по расписанию (цена: новая связь с Authelia, обработка её недоступности, вне текущего scope);
|
||||||
|
3. принять как есть и **сейчас же** убрать из `specs/access/spec.md`, `docs/security.md` и комментария шага схемы утверждение про канал отзыва (цена: паспорт теряет способ остановить перерасход, и это надо записать явно).
|
||||||
|
|
||||||
|
При любом варианте утверждение о канале отзыва сегодня ложно — правка нормы обязательна во всех трёх.
|
||||||
|
|
||||||
|
### 7. Сердцевина входа не исполнялась ни одним тестом: обмен кода, выдача куки сессии и проверка конфига
|
||||||
|
|
||||||
|
- Файл: `internal/controller/http/auth.go:199-252, 269-279`; `internal/config/config.go:69-90`; `internal/controller/http/auth.go:174-195`
|
||||||
|
- Severity: `major` · Confidence: `high`
|
||||||
|
- Найдено проходами: autotests, specs (сведены три находки: покрытие `exchange` и `setSessionCookie`, непокрытая `AuthConfig.Validate`, непокрытые ветки отказа `Logout` — причина одна: проверки останавливаются раньше сердцевины)
|
||||||
|
- **Оракул — мой собственный прогон покрытия:**
|
||||||
|
|
||||||
|
```
|
||||||
|
auth.go:199 exchange 0.0%
|
||||||
|
auth.go:269 setSessionCookie 0.0%
|
||||||
|
auth.go:128 Callback 54.5%
|
||||||
|
auth.go:174 Logout 63.6%
|
||||||
|
config.go:69 Validate 0.0% (у пакета internal/config нет файла тестов вовсе)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Последствие: единственный код, который меняет код провайдера на сессию, и код, который выдаёт сессию браузеру, не проверены ни на успех, ни на отказ. Спека объявляет имя `transcriber_session` нормативным именно потому, что «тест, ставящий и читающий одно и то же имя, этого не замечает» — потеря `HttpOnly`/`Secure`/`SameSite`, смена имени или срока пройдут гейт зелёными. Класс не новый: `docs/review.md`, журнал, записи 2026-08-10 («тесты http-обработчика ни разу не были зелёными») и 2026-08-11 («проверка приёма не могла упасть») — тот же род, третье появление.
|
||||||
|
- Предложение: поднять подставного провайдера `httptest` и пройти `Callback` до `302` + куки, судя по `w.Result().Cookies()`, сверяя литерал имени, флаги и `MaxAge`; завести файл тестов `internal/config`; закрыть обе ветки отказа `Logout`.
|
||||||
|
- Действие: **инлайн**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Гипотезы без доказательства
|
||||||
|
|
||||||
|
- **Чужая страница гасит сессию.** `POST /auth/logout` сессии не требует и на кросс-сайтовом запросе отвечает `200` с `Set-Cookie Max-Age=-1`. Браузера в прогоне нет, применение `SameSite=Lax` к POST не проверялось. Confidence `low` → `minor` (adversary).
|
||||||
|
- **Две учётные записи провайдера с одной почтой сливаются в одну нашу.** Обмен ищет по `sub`, не найдя — по почте. Требует, чтобы Authelia выдала двум субъектам один адрес; живого провайдера нет. Confidence `medium`, без оракула → выше `major` не поднимается и в основные секции не идёт (adversary).
|
||||||
|
- **Претензия `picture` тянет до 32 МиБ на вход.** `MappedFields.AvatarURL` заставляет библиотеку скачать URL из ответа провайдера. Confidence `low` → `minor` (adversary).
|
||||||
|
- **Анонимный `POST /api/collections/users/request-verification` заставляет сервис слать почту.** Почта не настроена, потолка запросов нет. Confidence `medium` → `minor` (adversary).
|
||||||
|
- **Вошедший читает, правит и удаляет свою запись `users`** (системные правила `id = @request.auth.id` оставлены), тогда как `docs/security.md` утверждает, что правила коллекций пусты и отдают `403`. Своим прогоном не проверял — бюджет попытки израсходован на пункты 1–4, 6, 7 (adversary).
|
||||||
|
|
||||||
|
## Promote candidates
|
||||||
|
|
||||||
|
- **Проверка ответа судит по `w.Result()`, а не по `w.Header()`.** Ровно этим различием держался ложно зелёный тест пункта 4. Место — `docs/review.md`, «Типовые узлы», абзац про способность проверки упасть: род механизируем grep'ом и уже дал дефект.
|
||||||
|
- **`docs/.docs.json` объявляет механизированную сверку миграций, которой нет.** Ключ `"migrations": "migrations"`, а `check_migrations` фильтрует изменённые файлы по префиксу `migrations/`; такого каталога в репозитории нет (`git ls-files | grep -c "^migrations/"` → `0`), шаги схемы лежат в `internal/adapter/repo/pocketbase/`. Шаг гейта зелен при изменённом `migrations.go` и нетронутом `docs/database.md`. Правило есть, механизации нет: `"migrations": "internal/adapter/repo/pocketbase"` либо снять пометку «механизировано» в `docs/conventions/README.md`.
|
||||||
|
- **Откат бинаря не откатывает шаг схемы** — назвать в `CLAUDE.md`, «Необратимое», рядом с «применённой миграцией» (эксперимент ops: два прогона `New()` разных ревизий над одним каталогом, `files.file.Protected=true` сохраняется).
|
||||||
|
- **Новый секрет `auth.client_secret` не назван ни в перечне секретных полей `docs/conventions/config.md:94-96`, ни в инварианте `CLAUDE.md`.** Перечень поимённый и закрытый — это и делает его правилом.
|
||||||
|
- **Ввод пользователя в журнале приводится к закрытому перечню, а не пишется как есть** — обобщение приёма задачи `no-user-filename-in-log`. Повод: `providerError` из query уходит в `logger.Warn` целиком (падающий тест adversary: 204806 байт запроса → 204902 байта журнала).
|
||||||
|
- **Имя провайдера `oidc` — это значение в связи учётной записи с провайдером.** Смена имени после выкладки отвяжет всех заведённых людей. `CLAUDE.md`, «Необратимое», знает имя ключа конфига, но не знает имени провайдера.
|
||||||
|
|
||||||
|
## Границы покрытия
|
||||||
|
|
||||||
|
### План: темы, глубины, дома
|
||||||
|
|
||||||
|
| тема | дом | глубина | закрыта |
|
||||||
|
|---|---|---|---|
|
||||||
|
| requirements | дельта-спеки `access`, `intake`, `storage` | разбор | specs |
|
||||||
|
| autotests | `CLAUDE.md`, «Гейт» | — | autotests |
|
||||||
|
| conventions | `docs/conventions/{config,database,errors,logging}.md` | разбор | code |
|
||||||
|
| architecture | `docs/architecture.md`; источник `docs/passport.md` | доказательство | architecture |
|
||||||
|
| security | `docs/security.md` | доказательство | adversary |
|
||||||
|
| operations | `docs/architecture.md` «Эксплуатация»; источник `docs/database.md` | доказательство | ops |
|
||||||
|
|
||||||
|
Тем без дома нет. Тем без отчёта нет.
|
||||||
|
|
||||||
|
### Что запускалось и что нет
|
||||||
|
|
||||||
|
- Запущены на метке `large`, режим «по графу»: `specs`, `code`, `architecture`, `adversary`, `ops`, `autotests`.
|
||||||
|
- `basics` не запускался: решение `review-scope` — своих тем сверх ядра нет. Это решение, а не бюджет.
|
||||||
|
- Триаж запускал сам: `task gate BASE=origin/master` (exit 0), покрытие `internal/controller/http` и `internal/config`, шесть собственных тестов-оракулов через `go test -overlay=…` (в дерево проекта не писал), чтение исходников PocketBase v0.39.10 и `docs.py`.
|
||||||
|
|
||||||
|
### Чего запущенные проходы не могли проверить в принципе
|
||||||
|
|
||||||
|
- Живой вход у настоящей Authelia не воспроизводился ни одним проходом и мной: провайдера нет, поднять нечем. Всё, что известно о протоколе, получено против подставного провайдера и исходников библиотеки.
|
||||||
|
- Поведенческая верификация живым запуском сервиса не проводилась: адаптер Telegram роняет старт при негодном токене, а боевым токеном запускаться запрещено (`CLAUDE.md`, «Запреты»).
|
||||||
|
- Поведение браузера с куками — применение `SameSite`, приём `Set-Cookie` кросс-сайтом — не проверялось: браузера в прогоне нет.
|
||||||
|
- Панель `/_/` в тестовом роутере отсутствует (её вешает `apis.Serve`); закрывает её обратный прокси, то есть выкладка, а она вне модели.
|
||||||
|
- `govulncheck` дал две уязвимости (GO-2026-6061 grpc, GO-2026-5764 aws eventstream/s3), обе унаследованы от `origin/master`, в цепочке распознавания, не в этом коде.
|
||||||
|
- Замер утечки обработчиков сделан на 50 и 3000 итерациях в тесте; поведение под настоящим потоком не замерялось ничем.
|
||||||
|
|
||||||
|
### Что осталось целиком на человеке
|
||||||
|
|
||||||
|
Из `docs/review.md`, «Недоступно проверке». Списки не сливаются: при следующем промахе первый вопрос — «не тот ли это класс, который мы перестали проверять».
|
||||||
|
|
||||||
|
**Не проверит ни один проход:**
|
||||||
|
- `operations`: поведение внешних сервисов под нагрузкой и на границах — SpeechKit и Object Storage поднять в тесте нечем;
|
||||||
|
- `operations`: реальный профиль нагрузки — проект работает на единицах записей в день, и утверждения о росте остаются условиями, а не замерами;
|
||||||
|
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата отдан внешней программе, и она вне нашей границы.
|
||||||
|
|
||||||
|
**Перестали проверять сознательно:**
|
||||||
|
- `autotests`: разбор вывода настоящего `ffprobe` — проверки приёма получают длительность от подставного источника; своего теста у `adapter/metaviewer/ffmpeg` нет (`docs/adr/ADR-2026-08-11-stub-adapters-in-tests.md`).
|
||||||
|
|
||||||
|
**Сверх проектного перечня — общее:** история инцидентов, поведение под реальным потоком, поведение внешних систем в их версиях (здесь — конкретной Authelia владельца и её правила на этого клиента), завязка потребителей на текущее поведение и вопрос «а нужна ли эта функциональность вообще».
|
||||||
|
|
||||||
|
### Каких документов проекта не хватило
|
||||||
|
|
||||||
|
- `docs/review.md`, «Типовые ложноположительные»: раздел есть и непуст (4 пункта), но **ни один не относится к области этого изменения** — все четыре про конвейер, очередь и открытый HTTP. Отсев для темы входа и веб-поверхности шёл по общим критериям, проектных ложноположительных этой области я не знал.
|
||||||
|
- `docs/review.md`, «Вопросы по темам»: вопросов по теме входа нет — раздел писан до появления этой поверхности. Вопросы `security` про журнал и метки применялись, вопросы про конвейер неприменимы.
|
||||||
|
- `docs/conventions/web-ui.md` существует (104 строки), требование к языку пользовательского текста в нём есть; конвенции по форме HTTP-ответов входа (коды, тело отказа) в нём нет — поэтому «Login failed» судилось только по требованию русского языка, а форма ответа не судилась ничем.
|
||||||
|
- Прочих пробелов проходы не заявляли; `CLAUDE.md` с разделом инвариантов, `docs/security.md`, `docs/architecture.md`, `docs/database.md`, `docs/passport.md` и четыре конвенции были на месте и использовались.
|
||||||
|
|
||||||
|
### Потолки проходов
|
||||||
|
|
||||||
|
- `code`: 4 из 4 — срез сработал. За срезом остались два рода, названы самим проходом: (1) язык пользовательского текста на новой публичной поверхности («Login failed», «Logout failed» по-английски против `web-ui.md`); (2) продолжение известных «Расхождений» новым кодом — `msg` предложением, «failed to» в обёртках, третья точка трансляции доменной ошибки в ответ.
|
||||||
|
- `architecture`: 3 из 3 — потолок выбран полностью, за срезом ничего не заявлено.
|
||||||
|
- `specs`, `adversary`, `ops`, `autotests`: **свои потолки не сообщили.** Это находка о прогоне: сколько находок каждый показал против своего лимита и что осталось за срезом, установить нечем. Из четверых пришло 5, 6, 3 и 3 находки — то есть по крайней мере `adversary` шёл близко к типичному лимиту, и молчание о срезе здесь дороже всего.
|
||||||
|
|
||||||
|
### Срезано потолком триажа — названо поимённо
|
||||||
|
|
||||||
|
Тринадцать позиций с оракулами не попали в секции 1–2 и **к правке не заказаны**. Строки ниже — не задание; они здесь потому, что ничего не выбрасывается молча.
|
||||||
|
|
||||||
|
1. **G** (`major`, architecture): `SessionDuration` питает две точки разной обратимости — `AuthToken.Duration` в применяемом однажды шаге схемы и `MaxAge` куки, перечитываемый каждый подъём. Первая же правка константы уедет только в куку. Оракул — инвариант `CLAUDE.md` «Миграция, уехавшая на сервер, не переписывается» и собственный довод `design.md`. Правка (перенести `AuthToken.Duration` в `ApplyProviderSettings`) стоит трёх строк **сейчас** и требует нового шага схемы **после** выкладки: окно закрывается мерджем. Первый кандидат на восьмое место.
|
||||||
|
2. **M** (`minor`): код провайдера оседает в журнале запросов хранилища на пять суток. Проверено мной по исходникам: `activityLogger` пишет `event.Request.URL.RequestURI()` (со строкой запроса) полем `url` (`apis/middlewares.go:391,422`), ретеншен `MaxDays: 5` (`core/settings_model.go:158`), проект его не переопределяет. Спека требует «код MUST не попадать в журнал»; наш `slog` чист, и тест смотрит только в него.
|
||||||
|
3. **N** (`minor`): аноним пишет в журнал контейнера мегабайты (`?error=<1 МиБ>` → `Warn` целиком; падающий тест adversary: 204806 → 204902 байта). Журнал — единственное место наблюдения двух инвариантов о молчаливой потере задачи.
|
||||||
|
4. **L** (`minor`): `down202608120001` падает на валидации — проверено моим прогоном: `Save(users)` с `Duration=0` → `authToken: (duration: cannot be blank.)`. Путь «шаг обратим своим down» из `design.md` не работает, снятие `Protected` не выполняется вовсе, а сам `down` возвращает `CreateRule = ""` — открытую регистрацию, то самое, что чинит `up`.
|
||||||
|
5. **O** (`minor`): `docs/database.md:98-101` противоречит коду («поле файла не помечено защищённым»), коллекция `users` не описана, три новых числа (7 суток, 10 минут, 15 секунд) не попали в таблицу «Настройки с числовым значением». Оракул — `docs/conventions/database.md`, «Прочее».
|
||||||
|
6. **Q** (`minor`): уровни журнала не по адресату — отказ человека у Authelia даёт `WARN`, возврат по старой ссылке `ERROR`. Оракул — `docs/conventions/logging.md`, «Уровни», дословно. Плюс ни одна новая запись не несёт поля `capability`.
|
||||||
|
7. **S** (`minor`): начало входа собрано руками поверх того, что библиотека экспортирует (`InitProvider`, `BuildAuthURL`, `PKCE`) — протокол разрезан пополам, `auth_url` и `client_id` получают второго потребителя мимо настроек коллекции.
|
||||||
|
8. **U** (`minor`): пустая секция `[auth]` роняет сервис целиком, тогда как пустой токен бота лишь деградировал до «работает без Telegram». Асимметрия осознанная, но в рантбуке выкладки не названа.
|
||||||
|
9. **V** (`minor`): новое безусловное условие отказа старта по шести ключам живёт в `tasks.md` и конвенции, но не в норме.
|
||||||
|
10. **Поведение вне спеки** (8 пунктов от `specs`, ни один не заказан): `stateCookieMaxAge` 10 минут; редирект успешного входа на `/`; выход без сессии отвечает `200`; отказ загрузки записи при выходе оставляет куку; состав `scope`; склейка `authURL` через `?`/`&`; `ApplyProviderSettings` перетирает список провайдеров целиком (провайдер, заведённый владельцем в панели, исчезнет при подъёме); `down` не возвращает `OTP.Enabled`.
|
||||||
|
11. **Границы спеки** (5 пунктов): два входа одновременно в двух вкладках; поведение при отказе приведения настроек провайдера; остальная поверхность аутентификации хранилища (`confirm-password-reset`, `request-verification`, `confirm-verification`, `request-email-change` — перечень «что выключено» в спеке закрыт тремя пунктами, а поверхность шире); отзыв доступа внутри срока сессии; «владелец закрывает чужие сессии немедленно» существует только как ручная правка в панели.
|
||||||
|
12. **Имя куки состояния `transcriber_login` в спеке не нормировано**, в отличие от `transcriber_session`; **комментарий шага `up202608110001`** до сих пор утверждает «Защищённым поле не помечено намеренно» — после выкладки два шага противоречат друг другу в исходнике.
|
||||||
|
13. **Литерал `"users"` живёт в четырёх местах** при существующих константах имён коллекций (`FilesCollection`, `JobsCollection`).
|
||||||
|
|
||||||
|
**Выброшено как вкусовщина (3):** имена ключей `auth.*` как таковые (`CLAUDE.md` уже относит имя ключа конфига к необратимому — повторение записанного, а не находка); замечание про JSON-404 катч-олла на `/` (поведение хранилища, не этого кода, последствие не названо); предложение обобщить сборку адреса согласия сверх пункта S (работающий частный случай, последствия сверх S нет).
|
||||||
|
|
||||||
|
### Четыре строки, которых не принесёт ни один проход
|
||||||
|
|
||||||
|
1. **Решения проекта не сверялись.** `docs/adr/` — процессный документ, прогон его не открывает. Расхождение изменения с записанным решением ловит скилл `av-dev-docs:healthcheck`, а не ревью. В этом изменении решений владельца названо минимум три (архив бессрочный, петля обмена внутри процесса, семь суток сессии) — ни одно против ADR не сверено.
|
||||||
|
2. **Записанные наблюдения проекта не использовались.** `docs/research/` прогон не открывал. Всякое число в этом отчёте снято на этом прогоне и сопровождено командой или выводом; чисел из записанных наблюдений здесь нет.
|
||||||
|
3. **Поимённая сверка с руководствами по стилю Go не задавалась ни одним проходом.** Различение «идиоматично против распространено» не спрашивает никто с тех пор, как упразднён проход про идиоматичность; к новому коду (`auth.go`, `session.go`, `provider.go`) это относится целиком.
|
||||||
|
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет.** Проход независимой реализации снят по стоимости, а не по замеру. «Не знаю, чего не знаю» про форму решения входа — а форма здесь нащупывалась по ходу, это и подняло метку до `large` — не достаёт никто.
|
||||||
|
|
||||||
|
Метка `large`, поэтому пятая строка (про `small`) не применяется — дома всех трёх тем `security`, `operations`, `architecture` открывались.
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Вход через внешнего провайдера
|
||||||
|
|
||||||
|
Сервис SHALL заводить сессию только по итогу входа у внешнего провайдера OIDC.
|
||||||
|
Своей регистрации, своей формы пароля и своего восстановления доступа сервис
|
||||||
|
MUST не заводить: учётные записи держит провайдер, и это граница домена из
|
||||||
|
паспорта.
|
||||||
|
|
||||||
|
Вход начинается собственным адресом сервиса: он уводит человека к провайдеру.
|
||||||
|
Провайдер возвращает человека на адрес возврата, и сервис MUST обменять
|
||||||
|
принесённый код на учётную запись **средствами хранилища**, а не разбором ответа
|
||||||
|
провайдера своими руками — так решено 2026-08-11. Учётная запись, которой ещё
|
||||||
|
нет, заводится сама; связь её с внешним провайдером ведёт хранилище.
|
||||||
|
|
||||||
|
Возврат от провайдера MUST быть проверен на подмену: сервис сверяет пришедшее
|
||||||
|
состояние с тем, что сам выдал, и отвергает возврат, чьё состояние он не
|
||||||
|
выдавал. Без этой сверки вход принимает чужой код.
|
||||||
|
|
||||||
|
Обмен кода MUST быть ограничен во времени: у обращения к провайдеру есть
|
||||||
|
таймаут, и по его истечении вход кончается отказом. Молчащий провайдер иначе
|
||||||
|
держит обработчик возврата открытым до упора, а «провайдер медленный»
|
||||||
|
становится неотличим от «провайдер отказал».
|
||||||
|
|
||||||
|
Ни код, принесённый от провайдера, ни секрет клиента MUST не попадать в журнал.
|
||||||
|
|
||||||
|
Адреса нормативны: вход — `GET /auth/login`, возврат — `GET /auth/callback`,
|
||||||
|
выход — `POST /auth/logout`. Они лежат вне `/api/`, потому что это пространство
|
||||||
|
поделено с собственными адресами хранилища. Выход берёт `POST` намеренно: по
|
||||||
|
`GET` его срабатывание уносится переходом по чужой ссылке.
|
||||||
|
|
||||||
|
#### Scenario: Человек входит впервые
|
||||||
|
|
||||||
|
- **GIVEN** провайдер настроен и учётной записи в сервисе ещё нет
|
||||||
|
- **WHEN** человек проходит вход и возвращается с кодом провайдера
|
||||||
|
- **THEN** учётная запись заводится, а сессия открывается
|
||||||
|
- **AND** дальнейший запрос к API от этой сессии проходит
|
||||||
|
|
||||||
|
Состояние и проверочный код PKCE сервис SHALL хранить у браузера — тем же
|
||||||
|
носителем, что и сессию, и с теми же признаками защиты. Носитель MUST жить не
|
||||||
|
дольше одного входа, MUST убираться на возврате — и на успешном, и на отказном,
|
||||||
|
— а состояние MUST быть одноразовым: возврат, чьё состояние уже употреблено,
|
||||||
|
отвергается наравне с невыданным. Проверочный код PKCE обязателен: обмен кода
|
||||||
|
средствами хранилища его требует.
|
||||||
|
|
||||||
|
Носитель без защиты соединения отменял бы то, ради чего заведён: перехваченный
|
||||||
|
проверочный код обесценивает PKCE, а подставленное состояние — сверку подмены.
|
||||||
|
|
||||||
|
#### Scenario: Признаки носителя состояния
|
||||||
|
|
||||||
|
- **WHEN** сервис уводит человека к провайдеру
|
||||||
|
- **THEN** носитель состояния и проверочного кода несёт те же признаки защиты,
|
||||||
|
что и кука сессии
|
||||||
|
|
||||||
|
#### Scenario: Возврат нельзя переиграть
|
||||||
|
|
||||||
|
- **GIVEN** человек уже вернулся от провайдера и сессия открылась
|
||||||
|
- **WHEN** тот же возврат с тем же состоянием приходит второй раз
|
||||||
|
- **THEN** сессия не открывается, а ответ несёт отказ
|
||||||
|
|
||||||
|
#### Scenario: Возврат с чужим состоянием
|
||||||
|
|
||||||
|
- **WHEN** на адрес возврата приходит код с состоянием, которого сервис не
|
||||||
|
выдавал
|
||||||
|
- **THEN** сессия не открывается, а ответ несёт отказ
|
||||||
|
- **AND** учётная запись не заводится
|
||||||
|
|
||||||
|
#### Scenario: Провайдер отказал
|
||||||
|
|
||||||
|
- **WHEN** провайдер возвращает человека с ошибкой вместо кода
|
||||||
|
- **THEN** сессия не открывается, а ответ несёт отказ
|
||||||
|
|
||||||
|
### Requirement: Иных способов открыть сессию нет
|
||||||
|
|
||||||
|
Сервис SHALL оставить вход у провайдера единственным способом завести учётную
|
||||||
|
запись и получить сессию. Собственное создание записи в коллекции пользователей,
|
||||||
|
вход по паролю, вход по одноразовому коду и восстановление доступа MUST быть
|
||||||
|
выключены настройкой коллекции.
|
||||||
|
|
||||||
|
Требование отдельно от «Вход через внешнего провайдера» намеренно: то нормирует
|
||||||
|
наш код, а это — **поверхность, которую приносит хранилище**. Умолчание
|
||||||
|
хранилища заводит коллекцию пользователей с открытым созданием записи и
|
||||||
|
включённым входом по паролю, и без этого требования закрытие приёма обходится
|
||||||
|
двумя запросами: завести себе запись, войти по паролю, предъявить полученное.
|
||||||
|
|
||||||
|
Отдельная цена у открытого создания записи — захват учётной записи. Обмен кода
|
||||||
|
ищет запись сперва по неизменяемому признаку провайдера, а не найдя — по адресу
|
||||||
|
почты; запись, заведённая посторонним на чужой адрес, достаётся первому же
|
||||||
|
настоящему входу с этим адресом.
|
||||||
|
|
||||||
|
#### Scenario: Завести учётную запись самому нельзя
|
||||||
|
|
||||||
|
- **WHEN** анонимный запрос создаёт запись в коллекции пользователей
|
||||||
|
- **THEN** ответ несёт отказ, а записи не появляется
|
||||||
|
|
||||||
|
#### Scenario: Вход паролем недоступен
|
||||||
|
|
||||||
|
- **WHEN** запрос идёт на вход по паролю к коллекции пользователей
|
||||||
|
- **THEN** ответ несёт отказ, а сессия не открывается
|
||||||
|
|
||||||
|
#### Scenario: Восстановление доступа недоступно
|
||||||
|
|
||||||
|
- **WHEN** запрос просит восстановление пароля или одноразовый код
|
||||||
|
- **THEN** ответ несёт отказ
|
||||||
|
|
||||||
|
### Requirement: Сессия предъявляется кукой
|
||||||
|
|
||||||
|
Сервис SHALL принимать сессию, предъявленную кукой, — браузер отдаёт её сам, и
|
||||||
|
своей страницы со скриптом для этого не требуется. Кука сессии MUST быть
|
||||||
|
недоступна скриптам страницы (`HttpOnly`), MUST не уходить по незашифрованному
|
||||||
|
соединению (`Secure`) и MUST не отправляться при переходе с чужого сайта
|
||||||
|
(`SameSite=Lax` или строже).
|
||||||
|
|
||||||
|
Имя куки нормативно — `transcriber_session`: смена имени молча выкидывает всех
|
||||||
|
вошедших, а тест, ставящий и читающий одно и то же имя, этого не замечает.
|
||||||
|
|
||||||
|
Хранилище читает предъявленную сессию заголовком `Authorization`, и этот способ
|
||||||
|
остаётся рабочим: его требуют собственные адреса аутентификации хранилища.
|
||||||
|
Сервис MUST перекладывать значение куки в этот заголовок **только когда
|
||||||
|
заголовка нет**: предъявленный заголовок побеждает, иначе браузер с сессионной
|
||||||
|
кукой получал бы не то, что предъявил на собственных адресах хранилища.
|
||||||
|
|
||||||
|
Область действия слоя MUST быть ограничена адресами приложения — приёмом записи
|
||||||
|
и опросом готовности. Собственная поверхность хранилища под него не подпадает:
|
||||||
|
часть её защищена сегодня ровно тем, что браузер заголовка сам не шлёт, и
|
||||||
|
расширение слоя на всё сняло бы эту защиту молча.
|
||||||
|
|
||||||
|
#### Scenario: Кука открывает доступ
|
||||||
|
|
||||||
|
- **GIVEN** человек вошёл и получил куку сессии
|
||||||
|
- **WHEN** он шлёт запрос к API с этой кукой и без заголовка
|
||||||
|
- **THEN** запрос проходит
|
||||||
|
|
||||||
|
#### Scenario: Кука защищена от чтения скриптом
|
||||||
|
|
||||||
|
- **WHEN** сервис ставит куку сессии
|
||||||
|
- **THEN** она несёт признаки `HttpOnly`, `Secure` и `SameSite`
|
||||||
|
|
||||||
|
#### Scenario: Предъявленный заголовок побеждает куку
|
||||||
|
|
||||||
|
- **WHEN** запрос несёт и куку сессии, и заголовок `Authorization`
|
||||||
|
- **THEN** проверку проходит значение заголовка, а не куки
|
||||||
|
|
||||||
|
### Requirement: Значение, дающее доступ, не печатается
|
||||||
|
|
||||||
|
Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение сессии,
|
||||||
|
ни код, принесённый от провайдера, ни секрет клиента, ни адрес почты
|
||||||
|
пользователя. Записанное значение сессии MUST читаться как ключ к чужому
|
||||||
|
доступу: оно годно до выхода или до истечения срока, и строка журнала уезжает в
|
||||||
|
собранные логи, откуда её не убрать.
|
||||||
|
|
||||||
|
Требование того же рода, что и запрет писать имя файла в хранилище: там строка
|
||||||
|
журнала собирала бы ссылку на чужую запись, здесь — предъявление чужой сессии.
|
||||||
|
Адрес почты приходит от провайдера и принадлежит человеку, а не сервису.
|
||||||
|
|
||||||
|
#### Scenario: Значения сессии нет в журнале
|
||||||
|
|
||||||
|
- **GIVEN** человек вошёл и получил куку сессии
|
||||||
|
- **WHEN** он шлёт запрос к API с этой кукой
|
||||||
|
- **THEN** значение сессии не встречается ни в одной журнальной записи
|
||||||
|
|
||||||
|
#### Scenario: Адреса почты нет в журнале
|
||||||
|
|
||||||
|
- **WHEN** человек проходит вход и учётная запись заводится
|
||||||
|
- **THEN** адрес его почты не встречается ни в одной журнальной записи
|
||||||
|
|
||||||
|
### Requirement: Сессия переживает перезапуск сервиса
|
||||||
|
|
||||||
|
Сервис SHALL держать сессию годной после своего перезапуска: подпись сессии MUST
|
||||||
|
опираться на секрет, лежащий в хранилище, а не на значение, заведённое в памяти
|
||||||
|
при старте. Иначе всякая выкладка выкидывает всех вошедших молча.
|
||||||
|
|
||||||
|
#### Scenario: Прежняя кука годна после перезапуска
|
||||||
|
|
||||||
|
- **GIVEN** человек вошёл и получил куку сессии
|
||||||
|
- **WHEN** сервис поднимается заново на том же хранилище
|
||||||
|
- **THEN** запрос с прежней кукой проходит
|
||||||
|
|
||||||
|
### Requirement: Срок жизни сессии назначен, а не достался умолчанию
|
||||||
|
|
||||||
|
Сервис SHALL назначать срок жизни сессии сам — **семь суток**, числом в настройке
|
||||||
|
коллекции и тем же числом в сроке жизни куки. Умолчание хранилища MUST не
|
||||||
|
применяться: оно даёт пять суток, и это число никем не выбрано.
|
||||||
|
|
||||||
|
Срок здесь — единственное, что доносит до сервиса **отзыв доступа у
|
||||||
|
провайдера**. Сессия выдана однажды, и к провайдеру сервис больше не ходит:
|
||||||
|
человек, которому Authelia закрыла доступ, работает до истечения своей сессии.
|
||||||
|
Паспорт опирается на отзыв в Authelia как на способ остановить того, кто
|
||||||
|
тратит слишком много, — значит срок сессии и есть цена этой остановки.
|
||||||
|
|
||||||
|
**Отсюда запрет на продление.** Хранилище выдаёт сессию продлеваемой:
|
||||||
|
предъявитель меняет своё значение на новое, с новым сроком, и делает это сколько
|
||||||
|
угодно раз, никуда не входя. Сервис SHALL закрыть продление — иначе срок жизни
|
||||||
|
сессии не значит ничего, а канал отзыва перестаёт существовать вовсе.
|
||||||
|
|
||||||
|
Владелец MUST иметь способ закрыть чужие сессии немедленно, не дожидаясь срока.
|
||||||
|
|
||||||
|
#### Scenario: Сессия не продлевает саму себя
|
||||||
|
|
||||||
|
- **GIVEN** человек вошёл и получил сессию
|
||||||
|
- **WHEN** этой же сессией он просит продлить её
|
||||||
|
- **THEN** ответ несёт отказ, а нового значения в нём нет
|
||||||
|
|
||||||
|
#### Scenario: Сессия истекает назначенным сроком
|
||||||
|
|
||||||
|
- **GIVEN** человек вошёл и получил куку сессии
|
||||||
|
- **WHEN** назначенный срок прошёл
|
||||||
|
- **THEN** запрос с этой кукой получает отказ
|
||||||
|
|
||||||
|
#### Scenario: Владелец закрывает чужую сессию
|
||||||
|
|
||||||
|
- **GIVEN** человек вошёл и получил куку сессии
|
||||||
|
- **WHEN** владелец обесценивает сессии этой учётной записи
|
||||||
|
- **THEN** запрос с прежней кукой получает отказ
|
||||||
|
|
||||||
|
### Requirement: Выход прекращает доступ
|
||||||
|
|
||||||
|
Сервис SHALL закрывать доступ по выходу немедленно: выход MUST обесценивать
|
||||||
|
выданные этой учётной записи сессии на стороне сервиса, а не только убирать куку
|
||||||
|
у браузера. Куку сервис при этом MUST убрать тоже.
|
||||||
|
|
||||||
|
Одной уборки куки мало: сессия предъявляется значением, и унесённое значение
|
||||||
|
продолжало бы открывать доступ до самого своего истечения.
|
||||||
|
|
||||||
|
Порядок обязателен: сперва обесценивание, потом уборка куки. При обратном
|
||||||
|
порядке выход, разошедшийся с одновременным входом, оставляет годную сессию, а
|
||||||
|
человек уверен, что вышел.
|
||||||
|
|
||||||
|
#### Scenario: После выхода прежняя кука не работает
|
||||||
|
|
||||||
|
- **GIVEN** человек вошёл и получил куку сессии
|
||||||
|
- **WHEN** он выходит, а затем шлёт запрос к API с прежней кукой
|
||||||
|
- **THEN** запрос получает отказ
|
||||||
|
|
||||||
|
#### Scenario: Выход убирает куку
|
||||||
|
|
||||||
|
- **WHEN** человек выходит
|
||||||
|
- **THEN** ответ убирает куку сессии у браузера
|
||||||
|
|
||||||
|
### Requirement: Кого пускать, решает провайдер
|
||||||
|
|
||||||
|
Сервис SHALL пускать всякого, кого пропустил провайдер, и своей проверки допуска
|
||||||
|
MUST не делать. Кто допущен, определяет правило провайдера на этого клиента —
|
||||||
|
настройка выкладки, лежащая вне репозитория.
|
||||||
|
|
||||||
|
Требование записано именно как решение с ценой, а не как умолчание: провайдер
|
||||||
|
общий для контура, и клиент, настроенный слишком широко, открывает сервис
|
||||||
|
всякому, у кого есть учётная запись у провайдера. Проверить это по коду нельзя,
|
||||||
|
поэтому граница названа здесь и повторена в модели угроз.
|
||||||
|
|
||||||
|
#### Scenario: Пропущенный провайдером получает доступ
|
||||||
|
|
||||||
|
- **WHEN** человек проходит вход у провайдера и возвращается с кодом
|
||||||
|
- **THEN** учётная запись заводится, а доступ открывается
|
||||||
|
- **AND** сервис не спрашивает у ответа провайдера ничего сверх того, что нужно
|
||||||
|
для заведения записи
|
||||||
|
|
||||||
|
### Requirement: Проба здоровья и метрики остаются открытыми
|
||||||
|
|
||||||
|
Сервис SHALL отдавать `GET /health` и `GET /metrics` без сессии. Ни у пробы
|
||||||
|
здоровья, ни у сборщика метрик сессии нет, и требование входа остановило бы
|
||||||
|
наблюдение за сервисом.
|
||||||
|
|
||||||
|
Наружу эти адреса закрывает правило обратного прокси — это работа выкладки, и
|
||||||
|
сервис на неё не полагается: содержимого записей и текстов расшифровок оба
|
||||||
|
адреса не несут.
|
||||||
|
|
||||||
|
#### Scenario: Проба здоровья доступна анонимно
|
||||||
|
|
||||||
|
- **WHEN** запрос приходит на `GET /health` без сессии
|
||||||
|
- **THEN** ответ имеет код `200`
|
||||||
|
|
||||||
|
#### Scenario: Метрики доступны анонимно
|
||||||
|
|
||||||
|
- **WHEN** запрос приходит на `GET /metrics` без сессии
|
||||||
|
- **THEN** ответ имеет код `200`
|
||||||
|
|
||||||
|
### Requirement: Секрет провайдера живёт в конфиге
|
||||||
|
|
||||||
|
Сервис SHALL брать адреса провайдера, идентификатор клиента и секрет клиента из
|
||||||
|
конфига. Секрет MUST не попадать ни в журнал, ни в ответ, ни в git; настройки
|
||||||
|
провайдера в хранилище MUST приводиться к значениям конфига при каждом запуске,
|
||||||
|
а не заводиться однажды шагом схемы.
|
||||||
|
|
||||||
|
Причина второго требования в необратимости шага схемы: применённый шаг не
|
||||||
|
переписывается, и смена секрета в конфиге иначе не доехала бы до хранилища
|
||||||
|
вовсе — вход сломался бы после ротации.
|
||||||
|
|
||||||
|
#### Scenario: Секрета нет в журнале
|
||||||
|
|
||||||
|
- **WHEN** сервис поднимается с настроенным провайдером
|
||||||
|
- **THEN** значение секрета не встречается ни в одной журнальной записи
|
||||||
|
|
||||||
|
#### Scenario: Смена секрета доезжает до хранилища
|
||||||
|
|
||||||
|
- **GIVEN** сервис уже поднимался с прежним секретом
|
||||||
|
- **WHEN** секрет в конфиге заменён и сервис поднят заново
|
||||||
|
- **THEN** настройки провайдера в хранилище несут новое значение
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Приём записи по HTTP
|
||||||
|
|
||||||
|
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
|
||||||
|
телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**.
|
||||||
|
Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
|
||||||
|
ни задача расшифровки. Принятая запись от узнанного отправителя MUST быть
|
||||||
|
сохранена и получить заведённую под неё задачу расшифровки в состоянии
|
||||||
|
`created`; ответ MUST нести идентификатор задачи полем `job_id` и её состояние
|
||||||
|
полем `status`.
|
||||||
|
|
||||||
|
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
|
||||||
|
не заплатит узнанный отправитель, не должна попасть даже в память.
|
||||||
|
|
||||||
|
Имена полей ответа нормативны: контракт HTTP API объявлен проектом необратимым,
|
||||||
|
и переименование поля ломает внешнюю программу молча. Появление отказа без
|
||||||
|
сессии — намеренная ломка этого контракта: до неё приём стоял открытым наружу.
|
||||||
|
|
||||||
|
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
|
||||||
|
пригодность содержимого узнаёт у источника метаданных.
|
||||||
|
|
||||||
|
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
|
||||||
|
хранилище, и нормирует её capability `storage`.
|
||||||
|
|
||||||
|
Владельца у принятой записи приём не заводит: после входа видно ровно то же, что
|
||||||
|
видно было анонимно.
|
||||||
|
|
||||||
|
#### Scenario: Запись принята
|
||||||
|
|
||||||
|
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||||
|
- **AND** отправитель предъявил сессию
|
||||||
|
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
|
||||||
|
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
|
||||||
|
со значением `created`
|
||||||
|
- **AND** содержимое записи целиком лежит в хранилище одним файлом
|
||||||
|
|
||||||
|
#### Scenario: Сессии нет
|
||||||
|
|
||||||
|
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` без сессии
|
||||||
|
- **THEN** ответ имеет код `401`
|
||||||
|
- **AND** ни файла, ни задачи не заводится
|
||||||
|
- **AND** тело ответа не несёт данных задачи
|
||||||
|
|
||||||
|
#### Scenario: Поля с записью нет
|
||||||
|
|
||||||
|
- **GIVEN** отправитель предъявил сессию
|
||||||
|
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
|
||||||
|
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
|
||||||
|
- **AND** ни файла, ни задачи не заводится
|
||||||
|
|
||||||
|
#### Scenario: Размеру записи приём не судья
|
||||||
|
|
||||||
|
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||||
|
- **AND** отправитель предъявил сессию
|
||||||
|
- **WHEN** программа шлёт запись нулевой длины
|
||||||
|
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
|
||||||
|
|
||||||
|
### Requirement: Опрос готовности задачи
|
||||||
|
|
||||||
|
Сервис SHALL отдавать состояние задачи расшифровки по запросу
|
||||||
|
`GET /api/status/:id` **только узнанному отправителю**. Запрос без сессии MUST
|
||||||
|
получать код `401`, и тело такого ответа MUST не нести ни состояния задачи, ни
|
||||||
|
текста расшифровки. Ответ узнанному отправителю MUST нести идентификатор полем
|
||||||
|
`job_id`, состояние полем `status` и время заведения полем `created_at`, а текст
|
||||||
|
расшифровки полем `transcription_text`, и это поле MUST отсутствовать в ответе,
|
||||||
|
пока текста нет: пустая строка на месте отсутствующего текста читается как
|
||||||
|
«расшифровка пуста».
|
||||||
|
|
||||||
|
Отказ без сессии MUST не зависеть от того, есть такая задача или нет: иначе по
|
||||||
|
кодам ответа перебирается список заведённых задач.
|
||||||
|
|
||||||
|
Выборку по владельцу опрос не сужает: узнанный отправитель видит любую задачу по
|
||||||
|
её идентификатору ровно как прежде. Сужение придёт отдельной задачей.
|
||||||
|
|
||||||
|
#### Scenario: Задача найдена
|
||||||
|
|
||||||
|
- **GIVEN** отправитель предъявил сессию
|
||||||
|
- **WHEN** программа спрашивает состояние заведённой задачи
|
||||||
|
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
|
||||||
|
|
||||||
|
#### Scenario: Сессии нет
|
||||||
|
|
||||||
|
- **WHEN** программа спрашивает состояние заведённой задачи без сессии
|
||||||
|
- **THEN** ответ имеет код `401`
|
||||||
|
- **AND** тело ответа не несёт ни состояния задачи, ни текста расшифровки
|
||||||
|
|
||||||
|
#### Scenario: Без сессии неизвестная задача неотличима от заведённой
|
||||||
|
|
||||||
|
- **WHEN** программа без сессии спрашивает состояние заведённой задачи, а затем
|
||||||
|
состояние по неизвестному идентификатору
|
||||||
|
- **THEN** оба ответа имеют код `401`
|
||||||
|
|
||||||
|
#### Scenario: Расшифровки ещё нет
|
||||||
|
|
||||||
|
- **GIVEN** отправитель предъявил сессию
|
||||||
|
- **WHEN** программа спрашивает состояние задачи, которая ещё не дошла до текста
|
||||||
|
- **THEN** поля `transcription_text` в ответе нет вовсе
|
||||||
|
|
||||||
|
#### Scenario: Задачи с таким идентификатором нет
|
||||||
|
|
||||||
|
- **GIVEN** отправитель предъявил сессию
|
||||||
|
- **WHEN** программа спрашивает состояние по неизвестному идентификатору
|
||||||
|
- **THEN** ответ имеет код `404` и сообщение о ненайденной задаче
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Файл отдаётся ссылкой
|
||||||
|
|
||||||
|
Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой
|
||||||
|
записи, **и только узнанному отправителю**. Поле файла MUST быть помечено
|
||||||
|
защищённым: без этого ссылка открывает запись любому, кто её знает, и знание
|
||||||
|
ссылки становится правом. Отданный файл MUST совпадать с принятым по длине.
|
||||||
|
|
||||||
|
Одной пометки мало: защищённый файл судится **коротким токеном файла**, который
|
||||||
|
узнанный отправитель берёт у хранилища, предъявив сессию, — и правилом просмотра
|
||||||
|
коллекции. Правило MUST пускать всякого узнанного: незаданное означает «только
|
||||||
|
владелец панели», и тогда файла не получит и вошедший. Сужения по владельцу
|
||||||
|
здесь нет — его заводит отдельная задача.
|
||||||
|
|
||||||
|
Отсюда порядок для потребителя: сессия → токен файла → ссылка с этим токеном.
|
||||||
|
Браузер с одной лишь кукой файла не получит, и это свойство хранилища, а не
|
||||||
|
недосмотр.
|
||||||
|
|
||||||
|
Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом.
|
||||||
|
|
||||||
|
**Ссылка сама по себе и есть право пройти по ней**, и потому она MUST не попадать
|
||||||
|
ни в журнал, ни в метку метрики, ни в ответ отправителю. Имя, под которым файл
|
||||||
|
лёг в хранилище, из журнала выводимо быть не должно: журнал уезжает в собранные
|
||||||
|
логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы
|
||||||
|
бессрочно.
|
||||||
|
|
||||||
|
Защищённое поле сужает это право, но не отменяет запрета: право пройти теперь
|
||||||
|
требует ещё и сессии, а строка журнала со ссылкой по-прежнему собирала бы
|
||||||
|
половину ключа.
|
||||||
|
|
||||||
|
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
|
||||||
|
пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла
|
||||||
|
целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и
|
||||||
|
другое кончается в журнале и собирает ссылку не хуже успешного пути.
|
||||||
|
|
||||||
|
Конвейер расшифровки этим не затронут: он читает файл из файловой системы
|
||||||
|
хранилища, а не по ссылке.
|
||||||
|
|
||||||
|
Что именно журнал приёма пишет ради прослеживаемости, нормирует capability
|
||||||
|
`intake`.
|
||||||
|
|
||||||
|
#### Scenario: Файл забирают по ссылке
|
||||||
|
|
||||||
|
- **GIVEN** запись принята и её файл лежит в хранилище
|
||||||
|
- **AND** забирающий предъявил сессию и взял по ней токен файла
|
||||||
|
- **WHEN** ссылку на файл запрашивают с этим токеном
|
||||||
|
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
|
||||||
|
|
||||||
|
#### Scenario: Без сессии файл не отдаётся
|
||||||
|
|
||||||
|
- **GIVEN** запись принята и её файл лежит в хранилище
|
||||||
|
- **WHEN** ссылку на файл запрашивают без сессии
|
||||||
|
- **THEN** приходит отказ, а содержимого записи в ответе нет
|
||||||
|
|
||||||
|
#### Scenario: Ссылка ведёт в никуда
|
||||||
|
|
||||||
|
- **WHEN** запрашивают ссылку на запись, которой нет
|
||||||
|
- **THEN** приходит отказ, а не пустой ответ
|
||||||
|
|
||||||
|
#### Scenario: По журналу ссылку не собрать
|
||||||
|
|
||||||
|
- **GIVEN** запись принята и прошла конвейер
|
||||||
|
- **WHEN** читают журнал сервиса целиком
|
||||||
|
- **THEN** имени, под которым файл лёг в хранилище, в нём нет
|
||||||
|
|
||||||
|
#### Scenario: Отказ чтения файла не называет его ключ
|
||||||
|
|
||||||
|
- **GIVEN** файл записи не читается из хранилища
|
||||||
|
- **WHEN** шаг конвейера берётся за эту запись и отказывает
|
||||||
|
- **THEN** отказ называет запись её идентификатором и не несёт имени файла
|
||||||
|
|
||||||
|
#### Scenario: Конвейер читает файл без сессии
|
||||||
|
|
||||||
|
- **GIVEN** запись принята и ждёт расшифровки
|
||||||
|
- **WHEN** шаг конвейера берётся за неё
|
||||||
|
- **THEN** файл читается из файловой системы хранилища и шаг проходит
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
## 1. Конфигурация
|
||||||
|
|
||||||
|
- [x] 1.1 Завести секцию конфига под провайдера: адрес авторизации, адрес обмена
|
||||||
|
кода, адрес сведений о пользователе, идентификатор клиента, секрет клиента,
|
||||||
|
адрес возврата
|
||||||
|
- [x] 1.2 Дописать те же ключи в `config.dist.toml` с пустыми значениями и
|
||||||
|
комментарием, откуда их брать
|
||||||
|
- [x] 1.3 Проверить, что незаполненный конфиг роняет старт с внятным
|
||||||
|
сообщением, а не поднимает сервис с молча выключенным входом
|
||||||
|
|
||||||
|
## 2. Провайдер в хранилище
|
||||||
|
|
||||||
|
- [x] 2.1 Завести шаг схемы, включающий провайдера `oidc` у коллекции
|
||||||
|
пользователей; файл шага именуется по правилу проекта и не переписывает
|
||||||
|
прежние
|
||||||
|
- [x] 2.2 Тем же шагом закрыть создание записи в коллекции пользователей и
|
||||||
|
выключить вход по паролю, одноразовый код и восстановление доступа: умолчание
|
||||||
|
библиотеки оставляет их открытыми
|
||||||
|
- [x] 2.3 Тем же шагом назначить срок жизни сессии числом вместо умолчания в
|
||||||
|
пять суток
|
||||||
|
- [x] 2.4 При подъёме сервиса приводить настройки провайдера к значениям
|
||||||
|
конфига: адреса, идентификатор клиента, секрет
|
||||||
|
- [x] 2.5 Убедиться, что секрет не попадает в журнал ни при подъёме, ни при
|
||||||
|
ошибке настройки
|
||||||
|
|
||||||
|
## 3. Вход, возврат, выход
|
||||||
|
|
||||||
|
- [x] 3.1 `GET /auth/login`: завести состояние и проверочный код PKCE, положить
|
||||||
|
во временную куку с теми же признаками, что у сессионной, увести на адрес
|
||||||
|
авторизации провайдера
|
||||||
|
- [x] 3.2 `GET /auth/callback`: сверить состояние с выданным, отвергнуть
|
||||||
|
несовпавшее и уже употреблённое, обменять код средствами хранилища с
|
||||||
|
таймаутом, поставить куку сессии, убрать временную
|
||||||
|
- [x] 3.3 Кука сессии зовётся `transcriber_session` и несёт `HttpOnly`,
|
||||||
|
`SameSite` и `Secure`; последний берётся из конфига с умолчанием «включено»
|
||||||
|
- [x] 3.4 `POST /auth/logout`: сперва обесценить ключ токенов учётной записи,
|
||||||
|
затем убрать куку сессии
|
||||||
|
- [x] 3.5 Промежуточный слой перекладывает значение куки в заголовок
|
||||||
|
`Authorization`, только когда заголовка нет, и только на адресах приложения
|
||||||
|
|
||||||
|
## 4. Закрытие API
|
||||||
|
|
||||||
|
- [x] 4.1 `POST /api/audio` и `GET /api/status/{id}` требуют узнанного
|
||||||
|
отправителя; отказ — код `401`
|
||||||
|
- [x] 4.2 Отказ по отсутствию сессии наступает раньше чтения тела запроса
|
||||||
|
- [x] 4.3 `GET /health` и `GET /metrics` остаются доступны без сессии
|
||||||
|
- [x] 4.4 Отказ без сессии одинаков для заведённой и неизвестной задачи
|
||||||
|
- [x] 4.5 Пометить поле файла защищённым тем же шагом схемы: ссылка на файл
|
||||||
|
перестаёт быть правом пройти по ней и требует сессии
|
||||||
|
- [x] 4.6 Убедиться, что конвейер по-прежнему читает файл из файловой системы, а
|
||||||
|
панель администратора его по-прежнему скачивает
|
||||||
|
|
||||||
|
## 5. Проверки
|
||||||
|
|
||||||
|
- [x] 5.1 Тест: оба эндпоинта API без куки отдают `401` и не заводят задачу;
|
||||||
|
`/health` и `/metrics` без куки отдают `200`
|
||||||
|
- [x] 5.2 Тест: запрос с прежней кукой проходит после пересоздания сервера
|
||||||
|
- [x] 5.3 Тест: после выхода запрос с прежней кукой получает отказ
|
||||||
|
- [x] 5.4 Тест: ни значение секрета, ни значение сессии, ни адрес почты не
|
||||||
|
встречаются в записанном выводе логгера
|
||||||
|
- [x] 5.5 Тест: возврат с невыданным состоянием не открывает сессию и не заводит
|
||||||
|
учётную запись; повторный возврат с уже употреблённым — тоже
|
||||||
|
- [x] 5.6 Тест: анонимное создание записи в коллекции пользователей и вход по
|
||||||
|
паролю получают отказ
|
||||||
|
- [x] 5.7 Тест: запрос с кукой и заголовком разом проходит по заголовку
|
||||||
|
- [x] 5.8 Тест: ссылка на файл записи без сессии отдаёт отказ, а с сессией —
|
||||||
|
тот же файл
|
||||||
|
- [x] 5.9 `task gate` зелёный целиком
|
||||||
|
|
||||||
|
## 6. Документация
|
||||||
|
|
||||||
|
- [x] 6.1 `docs/security.md`: первая строка периметра переписана под новый
|
||||||
|
периметр; названо новое место жизни секрета клиента — база; в разделе «Что
|
||||||
|
разграничивает доступ» записано, что допуск держит правило провайдера вне
|
||||||
|
репозитория, а сервис своей проверки не делает
|
||||||
|
- [x] 6.2 `docs/architecture.md`: capability `access` внесена в перечень
|
||||||
|
- [x] 6.3 `docs/conventions/config.md`: новые ключи конфига и расхождения
|
||||||
|
образца, если появились
|
||||||
|
|
||||||
|
## Критерии приёмки
|
||||||
|
|
||||||
|
Перенесены из записи задачи `oidc-login` дословно. Файл задачи закрытие удалит —
|
||||||
|
критерии обязаны его пережить.
|
||||||
|
|
||||||
|
- Запрос к `POST /api/audio` и `GET /api/status/:id` без сессии получает отказ, а
|
||||||
|
не заводит задачу и не отдаёт текст. Оракул — тест на обоих эндпоинтах без
|
||||||
|
куки: код ответа 401 либо 302 на вход, тело без данных задачи. Тот же тест
|
||||||
|
проверяет вторую сторону границы: `GET /health` и `GET /metrics` без куки
|
||||||
|
отвечают 200.
|
||||||
|
- Сессия переживает перезапуск приложения. Оракул — тест: запрос с прежней кукой
|
||||||
|
после пересоздания сервера проходит.
|
||||||
|
- Выход из сессии закрывает доступ. Оракул — тест: после выхода тот же запрос
|
||||||
|
получает отказ.
|
||||||
|
- Секрет провайдера не попадает ни в лог, ни в ответ. Оракул — тест на отсутствие
|
||||||
|
значения секрета в записанном выводе логгера.
|
||||||
|
- Первая строка `docs/security.md` описывает новый периметр. Оракул — `task
|
||||||
|
gate`, шаг `docs.py check`.
|
||||||
|
|
||||||
|
**Сужение против исходного критерия, объявленное ревью дизайна:** код отказа —
|
||||||
|
`401`, без допуска `302`. Оба адреса судят внешнюю программу, а не браузер, и
|
||||||
|
`302` для программы означает «получил 200 со страницей входа»; `curl -L` при нём
|
||||||
|
уходит постить тело на страницу входа провайдера. Дельта-спека `intake`
|
||||||
|
нормирует `401` двумя сценариями.
|
||||||
|
|
||||||
|
## Рубрика ревью дизайна
|
||||||
|
|
||||||
|
Порождена проходом `rubric` до чтения артефактов; сюда переносятся пункты,
|
||||||
|
ставшие приёмочными сверх критериев задачи.
|
||||||
|
|
||||||
|
- Отказ без сессии наступает раньше чтения тела и раньше обращения к хранилищу.
|
||||||
|
- Форма отказа одна и та же у существующего и несуществующего ресурса.
|
||||||
|
- Ни одно значение, дающее доступ, не печатается: код провайдера, секрет
|
||||||
|
клиента, значение сессии, адрес почты.
|
||||||
|
- Правило доступа читается как «всё требует сессии, кроме перечня», а перечень
|
||||||
|
открытого живёт в одном месте.
|
||||||
|
- Все прочие способы получить сессию к тому же субъекту выключены либо названы
|
||||||
|
поимённо с обоснованием, почему они не обход.
|
||||||
|
- Возврат от провайдера отвергается без состояния, с чужим, с истёкшим и с уже
|
||||||
|
употреблённым — до обмена кода.
|
||||||
|
- У обращения к провайдеру есть таймаут, и «медленный» отличается от «отказал».
|
||||||
|
- Исход входа и выхода не зависит от порядка параллельных операций.
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
# access Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Кто пришёл в сервис и пускают ли его дальше: вход через внешнего провайдера
|
||||||
|
OIDC, чем предъявляется сессия, что её прекращает и какие адреса остаются
|
||||||
|
открытыми.
|
||||||
|
|
||||||
|
Разграничения записей по владельцу здесь **нет**: всякий вошедший видит ровно
|
||||||
|
то же, что видел прежде аноним. Его заводит отдельная задача, и до неё сессия
|
||||||
|
отвечает только на вопрос «узнан ли пришедший», а не «чьё он смотрит».
|
||||||
|
|
||||||
|
Вход из Telegram эта capability не нормирует: бот проверяет отправителя своим
|
||||||
|
белым списком, и с учётной записью приложения тот список не связан.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Вход через внешнего провайдера
|
||||||
|
|
||||||
|
Сервис SHALL заводить сессию только по итогу входа у внешнего провайдера OIDC.
|
||||||
|
Своей регистрации, своей формы пароля и своего восстановления доступа сервис
|
||||||
|
MUST не заводить: учётные записи держит провайдер, и это граница домена из
|
||||||
|
паспорта.
|
||||||
|
|
||||||
|
Вход начинается собственным адресом сервиса: он уводит человека к провайдеру.
|
||||||
|
Провайдер возвращает человека на адрес возврата, и сервис MUST обменять
|
||||||
|
принесённый код на учётную запись **средствами хранилища**, а не разбором ответа
|
||||||
|
провайдера своими руками — так решено 2026-08-11. Учётная запись, которой ещё
|
||||||
|
нет, заводится сама; связь её с внешним провайдером ведёт хранилище.
|
||||||
|
|
||||||
|
Возврат от провайдера MUST быть проверен на подмену: сервис сверяет пришедшее
|
||||||
|
состояние с тем, что сам выдал, и отвергает возврат, чьё состояние он не
|
||||||
|
выдавал. Без этой сверки вход принимает чужой код.
|
||||||
|
|
||||||
|
Состояние и проверочный код PKCE сервис SHALL хранить у браузера — тем же
|
||||||
|
носителем, что и сессию, и с теми же признаками защиты. Носитель MUST жить не
|
||||||
|
дольше одного входа, MUST убираться на возврате — и на успешном, и на отказном,
|
||||||
|
— а состояние MUST быть одноразовым: возврат, чьё состояние уже употреблено,
|
||||||
|
отвергается наравне с невыданным. Проверочный код PKCE обязателен: обмен кода
|
||||||
|
средствами хранилища его требует.
|
||||||
|
|
||||||
|
Носитель без защиты соединения отменял бы то, ради чего заведён: перехваченный
|
||||||
|
проверочный код обесценивает PKCE, а подставленное состояние — сверку подмены.
|
||||||
|
|
||||||
|
Уборка носителя MUST происходить до записи ответа. Отложенная не работает вовсе:
|
||||||
|
заголовки фиксируются в момент, когда ответ начинают писать, и позднейшая правка
|
||||||
|
до браузера не доезжает.
|
||||||
|
|
||||||
|
Обмен кода MUST быть ограничен во времени: у обращения к провайдеру есть
|
||||||
|
таймаут, и по его истечении вход кончается отказом. Молчащий провайдер иначе
|
||||||
|
держит обработчик возврата открытым неограниченно долго, а «провайдер медленный»
|
||||||
|
становится неотличим от «провайдер отказал».
|
||||||
|
|
||||||
|
Ни код, принесённый от провайдера, ни секрет клиента MUST не попадать в журнал.
|
||||||
|
|
||||||
|
Адреса нормативны: вход — `GET /auth/login`, возврат — `GET /auth/callback`,
|
||||||
|
выход — `POST /auth/logout`. Они лежат вне `/api/`, потому что это пространство
|
||||||
|
поделено с собственными адресами хранилища. Выход берёт `POST` намеренно: по
|
||||||
|
`GET` его срабатывание уносится переходом по чужой ссылке.
|
||||||
|
|
||||||
|
#### Scenario: Человек входит впервые
|
||||||
|
|
||||||
|
- **GIVEN** провайдер настроен и учётной записи в сервисе ещё нет
|
||||||
|
- **WHEN** человек проходит вход и возвращается с кодом провайдера
|
||||||
|
- **THEN** учётная запись заводится, а сессия открывается
|
||||||
|
- **AND** дальнейший запрос к API от этой сессии проходит
|
||||||
|
|
||||||
|
#### Scenario: Признаки носителя состояния
|
||||||
|
|
||||||
|
- **WHEN** сервис уводит человека к провайдеру
|
||||||
|
- **THEN** носитель состояния и проверочного кода несёт те же признаки защиты,
|
||||||
|
что и кука сессии
|
||||||
|
|
||||||
|
#### Scenario: Возврат нельзя переиграть
|
||||||
|
|
||||||
|
- **GIVEN** человек уже вернулся от провайдера и сессия открылась
|
||||||
|
- **WHEN** тот же возврат с тем же состоянием приходит второй раз
|
||||||
|
- **THEN** сессия не открывается, а ответ несёт отказ
|
||||||
|
|
||||||
|
#### Scenario: Возврат с чужим состоянием
|
||||||
|
|
||||||
|
- **WHEN** на адрес возврата приходит код с состоянием, которого сервис не
|
||||||
|
выдавал
|
||||||
|
- **THEN** сессия не открывается, а ответ несёт отказ
|
||||||
|
- **AND** учётная запись не заводится
|
||||||
|
|
||||||
|
#### Scenario: Провайдер отказал
|
||||||
|
|
||||||
|
- **WHEN** провайдер возвращает человека с ошибкой вместо кода
|
||||||
|
- **THEN** сессия не открывается, а ответ несёт отказ
|
||||||
|
|
||||||
|
### Requirement: Иных способов открыть сессию нет
|
||||||
|
|
||||||
|
Сервис SHALL оставить вход у провайдера единственным способом завести учётную
|
||||||
|
запись и получить сессию. Собственное создание записи в коллекции пользователей,
|
||||||
|
вход по паролю, вход по одноразовому коду и восстановление доступа MUST быть
|
||||||
|
выключены настройкой коллекции.
|
||||||
|
|
||||||
|
Требование отдельно от «Вход через внешнего провайдера» намеренно: то нормирует
|
||||||
|
наш код, а это — **поверхность, которую приносит хранилище**. Умолчание
|
||||||
|
хранилища заводит коллекцию пользователей с открытым созданием записи и
|
||||||
|
включённым входом по паролю, и без этого требования закрытие приёма обходится
|
||||||
|
двумя запросами: завести себе запись, войти по паролю, предъявить полученное.
|
||||||
|
|
||||||
|
Отдельная цена у открытого создания записи — захват учётной записи. Обмен кода
|
||||||
|
ищет запись сперва по неизменяемому признаку провайдера, а не найдя — по адресу
|
||||||
|
почты; запись, заведённая посторонним на чужой адрес, достаётся первому же
|
||||||
|
настоящему входу с этим адресом.
|
||||||
|
|
||||||
|
Закрытие MUST не отменять заведения записи самим входом: запись при первом входе
|
||||||
|
заводит внутренний запрос обмена, и правило, отвергающее его наравне с
|
||||||
|
посторонним, оставляет сервис без единого способа войти.
|
||||||
|
|
||||||
|
#### Scenario: Завести учётную запись самому нельзя
|
||||||
|
|
||||||
|
- **WHEN** анонимный запрос создаёт запись в коллекции пользователей
|
||||||
|
- **THEN** ответ несёт отказ, а записи не появляется
|
||||||
|
|
||||||
|
#### Scenario: Вход у провайдера запись заводит
|
||||||
|
|
||||||
|
- **GIVEN** учётной записи в сервисе ещё нет
|
||||||
|
- **WHEN** человек проходит вход у провайдера
|
||||||
|
- **THEN** учётная запись появляется
|
||||||
|
|
||||||
|
#### Scenario: Вход паролем недоступен
|
||||||
|
|
||||||
|
- **WHEN** запрос идёт на вход по паролю к коллекции пользователей
|
||||||
|
- **THEN** ответ несёт отказ, а сессия не открывается
|
||||||
|
|
||||||
|
#### Scenario: Восстановление доступа недоступно
|
||||||
|
|
||||||
|
- **WHEN** запрос просит восстановление пароля или одноразовый код
|
||||||
|
- **THEN** ответ несёт отказ
|
||||||
|
|
||||||
|
### Requirement: Сессия предъявляется кукой
|
||||||
|
|
||||||
|
Сервис SHALL принимать сессию, предъявленную кукой, — браузер отдаёт её сам, и
|
||||||
|
своей страницы со скриптом для этого не требуется. Кука сессии MUST быть
|
||||||
|
недоступна скриптам страницы (`HttpOnly`), MUST не уходить по незашифрованному
|
||||||
|
соединению (`Secure`) и MUST не отправляться при переходе с чужого сайта
|
||||||
|
(`SameSite=Lax` или строже).
|
||||||
|
|
||||||
|
Имя куки нормативно — `transcriber_session`: смена имени молча выкидывает всех
|
||||||
|
вошедших, а тест, ставящий и читающий одно и то же имя, этого не замечает.
|
||||||
|
|
||||||
|
Хранилище читает предъявленную сессию заголовком `Authorization`, и этот способ
|
||||||
|
остаётся рабочим: его требуют собственные адреса аутентификации хранилища.
|
||||||
|
Сервис MUST перекладывать значение куки в этот заголовок **только когда
|
||||||
|
заголовка нет**: предъявленный заголовок побеждает, иначе браузер с сессионной
|
||||||
|
кукой получал бы не то, что предъявил на собственных адресах хранилища.
|
||||||
|
|
||||||
|
Область действия слоя MUST быть ограничена адресами приложения — приёмом записи
|
||||||
|
и опросом готовности. Собственная поверхность хранилища под него не подпадает:
|
||||||
|
часть её защищена сегодня ровно тем, что браузер заголовка сам не шлёт, и
|
||||||
|
расширение слоя на всё сняло бы эту защиту молча.
|
||||||
|
|
||||||
|
#### Scenario: Кука открывает доступ
|
||||||
|
|
||||||
|
- **GIVEN** человек вошёл и получил куку сессии
|
||||||
|
- **WHEN** он шлёт запрос к API с этой кукой и без заголовка
|
||||||
|
- **THEN** запрос проходит
|
||||||
|
|
||||||
|
#### Scenario: Кука защищена от чтения скриптом
|
||||||
|
|
||||||
|
- **WHEN** сервис ставит куку сессии
|
||||||
|
- **THEN** она несёт признаки `HttpOnly`, `Secure` и `SameSite`
|
||||||
|
|
||||||
|
#### Scenario: Предъявленный заголовок побеждает куку
|
||||||
|
|
||||||
|
- **WHEN** запрос несёт и куку сессии, и заголовок `Authorization`
|
||||||
|
- **THEN** проверку проходит значение заголовка, а не куки
|
||||||
|
|
||||||
|
### Requirement: Значение, дающее доступ, не печатается
|
||||||
|
|
||||||
|
Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение сессии,
|
||||||
|
ни код, принесённый от провайдера, ни секрет клиента, ни адрес почты
|
||||||
|
пользователя. Записанное значение сессии MUST читаться как ключ к чужому
|
||||||
|
доступу: оно годно до выхода или до истечения срока, и строка журнала уезжает в
|
||||||
|
собранные логи, откуда её не убрать.
|
||||||
|
|
||||||
|
Требование того же рода, что и запрет писать имя файла в хранилище: там строка
|
||||||
|
журнала собирала бы ссылку на чужую запись, здесь — предъявление чужой сессии.
|
||||||
|
Адрес почты приходит от провайдера и принадлежит человеку, а не сервису.
|
||||||
|
|
||||||
|
Причина отказа, пришедшая от провайдера строкой запроса, MUST приводиться к
|
||||||
|
перечню известных: значение целиком задаёт тот, кто шлёт запрос, и без
|
||||||
|
приведения аноним пишет в журнал что угодно и сколько угодно.
|
||||||
|
|
||||||
|
#### Scenario: Значения сессии нет в журнале
|
||||||
|
|
||||||
|
- **GIVEN** человек вошёл и получил куку сессии
|
||||||
|
- **WHEN** он шлёт запрос к API с этой кукой
|
||||||
|
- **THEN** значение сессии не встречается ни в одной журнальной записи
|
||||||
|
|
||||||
|
#### Scenario: Адреса почты нет в журнале
|
||||||
|
|
||||||
|
- **WHEN** человек проходит вход и учётная запись заводится
|
||||||
|
- **THEN** адрес его почты не встречается ни в одной журнальной записи
|
||||||
|
|
||||||
|
### Requirement: Сессия переживает перезапуск сервиса
|
||||||
|
|
||||||
|
Сервис SHALL держать сессию годной после своего перезапуска: подпись сессии MUST
|
||||||
|
опираться на секрет, лежащий в хранилище, а не на значение, заведённое в памяти
|
||||||
|
при старте. Иначе всякая выкладка выкидывает всех вошедших молча.
|
||||||
|
|
||||||
|
#### Scenario: Прежняя кука годна после перезапуска
|
||||||
|
|
||||||
|
- **GIVEN** человек вошёл и получил куку сессии
|
||||||
|
- **WHEN** сервис поднимается заново на том же хранилище
|
||||||
|
- **THEN** запрос с прежней кукой проходит
|
||||||
|
|
||||||
|
### Requirement: Срок жизни сессии назначен, а не достался умолчанию
|
||||||
|
|
||||||
|
Сервис SHALL назначать срок жизни сессии сам — **семь суток**, и тем же числом
|
||||||
|
задавать срок жизни куки. Умолчание хранилища MUST не применяться: оно даёт пять
|
||||||
|
суток, и это число никем не выбрано.
|
||||||
|
|
||||||
|
Назначаться срок MUST при каждом подъёме, а не шагом схемы: применённый шаг не
|
||||||
|
переписывается, и число, положенное туда, разошлось бы со сроком жизни куки при
|
||||||
|
первой же правке — браузер получил бы новый срок, а хранилище продолжило выдавать
|
||||||
|
прежний.
|
||||||
|
|
||||||
|
Срок здесь — единственное, что доносит до сервиса **отзыв доступа у
|
||||||
|
провайдера**. Сессия выдана однажды, и к провайдеру сервис больше не ходит:
|
||||||
|
человек, которому провайдер закрыл доступ, работает до истечения своей сессии.
|
||||||
|
Паспорт опирается на отзыв у провайдера как на способ остановить того, кто
|
||||||
|
тратит слишком много, — значит срок сессии и есть цена этой остановки.
|
||||||
|
|
||||||
|
**Отсюда запрет на продление.** Хранилище выдаёт сессию продлеваемой:
|
||||||
|
предъявитель меняет своё значение на новое, с новым сроком, и делает это сколько
|
||||||
|
угодно раз, никуда не входя. Сервис SHALL закрыть продление — иначе срок жизни
|
||||||
|
сессии не значит ничего, а канал отзыва перестаёт существовать вовсе.
|
||||||
|
|
||||||
|
Владелец MUST иметь способ закрыть чужие сессии немедленно, не дожидаясь срока.
|
||||||
|
|
||||||
|
#### Scenario: Сессия не продлевает саму себя
|
||||||
|
|
||||||
|
- **GIVEN** человек вошёл и получил сессию
|
||||||
|
- **WHEN** этой же сессией он просит продлить её
|
||||||
|
- **THEN** ответ несёт отказ, а нового значения в нём нет
|
||||||
|
|
||||||
|
#### Scenario: Сессия истекает назначенным сроком
|
||||||
|
|
||||||
|
- **GIVEN** человек вошёл и получил куку сессии
|
||||||
|
- **WHEN** назначенный срок прошёл
|
||||||
|
- **THEN** запрос с этой кукой получает отказ
|
||||||
|
|
||||||
|
#### Scenario: Владелец закрывает чужую сессию
|
||||||
|
|
||||||
|
- **GIVEN** человек вошёл и получил куку сессии
|
||||||
|
- **WHEN** владелец обесценивает сессии этой учётной записи
|
||||||
|
- **THEN** запрос с прежней кукой получает отказ
|
||||||
|
|
||||||
|
### Requirement: Выход прекращает доступ
|
||||||
|
|
||||||
|
Сервис SHALL закрывать доступ по выходу немедленно: выход MUST обесценивать
|
||||||
|
выданные этой учётной записи сессии на стороне сервиса, а не только убирать куку
|
||||||
|
у браузера. Куку сервис при этом MUST убрать тоже.
|
||||||
|
|
||||||
|
Одной уборки куки мало: сессия предъявляется значением, и унесённое значение
|
||||||
|
продолжало бы открывать доступ до самого своего истечения.
|
||||||
|
|
||||||
|
Порядок обязателен: сперва обесценивание, потом уборка куки. При обратном
|
||||||
|
порядке выход, разошедшийся с одновременным входом, оставляет годную сессию, а
|
||||||
|
человек уверен, что вышел.
|
||||||
|
|
||||||
|
#### Scenario: После выхода прежняя кука не работает
|
||||||
|
|
||||||
|
- **GIVEN** человек вошёл и получил куку сессии
|
||||||
|
- **WHEN** он выходит, а затем шлёт запрос к API с прежней кукой
|
||||||
|
- **THEN** запрос получает отказ
|
||||||
|
|
||||||
|
#### Scenario: Выход убирает куку
|
||||||
|
|
||||||
|
- **WHEN** человек выходит
|
||||||
|
- **THEN** ответ убирает куку сессии у браузера
|
||||||
|
|
||||||
|
### Requirement: Кого пускать, решает провайдер
|
||||||
|
|
||||||
|
Сервис SHALL пускать всякого, кого пропустил провайдер, и своей проверки допуска
|
||||||
|
MUST не делать. Кто допущен, определяет правило провайдера на этого клиента —
|
||||||
|
настройка выкладки, лежащая вне репозитория.
|
||||||
|
|
||||||
|
Требование записано именно как решение с ценой, а не как умолчание: провайдер
|
||||||
|
общий для контура, и клиент, настроенный слишком широко, открывает сервис
|
||||||
|
всякому, у кого есть учётная запись у провайдера. Проверить это по коду нельзя,
|
||||||
|
поэтому граница названа здесь и повторена в модели угроз.
|
||||||
|
|
||||||
|
#### Scenario: Пропущенный провайдером получает доступ
|
||||||
|
|
||||||
|
- **WHEN** человек проходит вход у провайдера и возвращается с кодом
|
||||||
|
- **THEN** учётная запись заводится, а доступ открывается
|
||||||
|
- **AND** сервис не спрашивает у ответа провайдера ничего сверх того, что нужно
|
||||||
|
для заведения записи
|
||||||
|
|
||||||
|
### Requirement: Проба здоровья и метрики остаются открытыми
|
||||||
|
|
||||||
|
Сервис SHALL отдавать `GET /health` и `GET /metrics` без сессии. Ни у пробы
|
||||||
|
здоровья, ни у сборщика метрик сессии нет, и требование входа остановило бы
|
||||||
|
наблюдение за сервисом.
|
||||||
|
|
||||||
|
Наружу эти адреса закрывает правило обратного прокси — это работа выкладки, и
|
||||||
|
сервис на неё не полагается: содержимого записей и текстов расшифровок оба
|
||||||
|
адреса не несут.
|
||||||
|
|
||||||
|
#### Scenario: Проба здоровья доступна анонимно
|
||||||
|
|
||||||
|
- **WHEN** запрос приходит на `GET /health` без сессии
|
||||||
|
- **THEN** ответ имеет код `200`
|
||||||
|
|
||||||
|
#### Scenario: Метрики доступны анонимно
|
||||||
|
|
||||||
|
- **WHEN** запрос приходит на `GET /metrics` без сессии
|
||||||
|
- **THEN** ответ имеет код `200`
|
||||||
|
|
||||||
|
### Requirement: Секрет провайдера живёт в конфиге
|
||||||
|
|
||||||
|
Сервис SHALL брать адреса провайдера, идентификатор клиента и секрет клиента из
|
||||||
|
конфига. Секрет MUST не попадать ни в журнал, ни в ответ, ни в git; настройки
|
||||||
|
провайдера в хранилище MUST приводиться к значениям конфига при каждом запуске,
|
||||||
|
а не заводиться однажды шагом схемы.
|
||||||
|
|
||||||
|
Причина второго требования в необратимости шага схемы: применённый шаг не
|
||||||
|
переписывается, и смена секрета в конфиге иначе не доехала бы до хранилища
|
||||||
|
вовсе — вход сломался бы после ротации.
|
||||||
|
|
||||||
|
Незаполненная или негодная настройка входа MUST ронять старт с перечнем ключей и
|
||||||
|
без их значений. Форма адресов проверяется там же: непустая, но негодная строка
|
||||||
|
иначе отвергается хранилищем позже — из хука подъёма, до регистрации пробы
|
||||||
|
здоровья, — и сервис падает целиком, не оставив владельцу даже кода состояния.
|
||||||
|
|
||||||
|
#### Scenario: Секрета нет в журнале
|
||||||
|
|
||||||
|
- **WHEN** сервис поднимается с настроенным провайдером
|
||||||
|
- **THEN** значение секрета не встречается ни в одной журнальной записи
|
||||||
|
|
||||||
|
#### Scenario: Смена секрета доезжает до хранилища
|
||||||
|
|
||||||
|
- **GIVEN** сервис уже поднимался с прежним секретом
|
||||||
|
- **WHEN** секрет в конфиге заменён и сервис поднят заново
|
||||||
|
- **THEN** настройки провайдера в хранилище несут новое значение
|
||||||
|
|
||||||
|
#### Scenario: Негодная настройка роняет старт
|
||||||
|
|
||||||
|
- **WHEN** сервис поднимается с пустым или негодным ключом секции входа
|
||||||
|
- **THEN** старт кончается отказом, а отказ называет имена ключей
|
||||||
|
- **AND** значений этих ключей в отказе нет
|
||||||
@@ -14,12 +14,19 @@ Telegram делит с ним общий шаг заведения задачи,
|
|||||||
### Requirement: Приём записи по HTTP
|
### Requirement: Приём записи по HTTP
|
||||||
|
|
||||||
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
|
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
|
||||||
телом `multipart/form-data` и полем `audio`. Принятая запись MUST быть сохранена
|
телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**.
|
||||||
и получить заведённую под неё задачу расшифровки в состоянии `created`; ответ
|
Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
|
||||||
MUST нести идентификатор задачи полем `job_id` и её состояние полем `status`.
|
ни задача расшифровки. Принятая запись от узнанного отправителя MUST быть
|
||||||
|
сохранена и получить заведённую под неё задачу расшифровки в состоянии
|
||||||
|
`created`; ответ MUST нести идентификатор задачи полем `job_id` и её состояние
|
||||||
|
полем `status`.
|
||||||
|
|
||||||
|
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
|
||||||
|
не заплатит узнанный отправитель, не должна попасть даже в память.
|
||||||
|
|
||||||
Имена полей ответа нормативны: контракт HTTP API объявлен проектом необратимым,
|
Имена полей ответа нормативны: контракт HTTP API объявлен проектом необратимым,
|
||||||
и переименование поля ломает внешнюю программу молча.
|
и переименование поля ломает внешнюю программу молча. Появление отказа без
|
||||||
|
сессии — намеренная ломка этого контракта: до неё приём стоял открытым наружу.
|
||||||
|
|
||||||
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
|
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
|
||||||
пригодность содержимого узнаёт у источника метаданных.
|
пригодность содержимого узнаёт у источника метаданных.
|
||||||
@@ -27,16 +34,28 @@ MUST нести идентификатор задачи полем `job_id` и
|
|||||||
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
|
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
|
||||||
хранилище, и нормирует её capability `storage`.
|
хранилище, и нормирует её capability `storage`.
|
||||||
|
|
||||||
|
Владельца у принятой записи приём не заводит: после входа видно ровно то же, что
|
||||||
|
видно было анонимно.
|
||||||
|
|
||||||
#### Scenario: Запись принята
|
#### Scenario: Запись принята
|
||||||
|
|
||||||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||||
|
- **AND** отправитель предъявил сессию
|
||||||
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
|
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
|
||||||
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
|
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
|
||||||
со значением `created`
|
со значением `created`
|
||||||
- **AND** содержимое записи целиком лежит в хранилище одним файлом
|
- **AND** содержимое записи целиком лежит в хранилище одним файлом
|
||||||
|
|
||||||
|
#### Scenario: Сессии нет
|
||||||
|
|
||||||
|
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` без сессии
|
||||||
|
- **THEN** ответ имеет код `401`
|
||||||
|
- **AND** ни файла, ни задачи не заводится
|
||||||
|
- **AND** тело ответа не несёт данных задачи
|
||||||
|
|
||||||
#### Scenario: Поля с записью нет
|
#### Scenario: Поля с записью нет
|
||||||
|
|
||||||
|
- **GIVEN** отправитель предъявил сессию
|
||||||
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
|
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
|
||||||
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
|
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
|
||||||
- **AND** ни файла, ни задачи не заводится
|
- **AND** ни файла, ни задачи не заводится
|
||||||
@@ -44,6 +63,7 @@ MUST нести идентификатор задачи полем `job_id` и
|
|||||||
#### Scenario: Размеру записи приём не судья
|
#### Scenario: Размеру записи приём не судья
|
||||||
|
|
||||||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||||
|
- **AND** отправитель предъявил сессию
|
||||||
- **WHEN** программа шлёт запись нулевой длины
|
- **WHEN** программа шлёт запись нулевой длины
|
||||||
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
|
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
|
||||||
|
|
||||||
@@ -179,7 +199,7 @@ MUST нести идентификатор задачи полем `job_id` и
|
|||||||
|
|
||||||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||||
- **WHEN** программа шлёт запись с именем, чей хвост после последней точки не
|
- **WHEN** программа шлёт запись с именем, чей хвост после последней точки не
|
||||||
принадлежит перечню known-форматов
|
принадлежит перечню известных форматов
|
||||||
- **THEN** метка метрики принимает значение `other`
|
- **THEN** метка метрики принимает значение `other`
|
||||||
- **AND** имя файла в хранилище сохраняет пришедшее расширение
|
- **AND** имя файла в хранилище сохраняет пришедшее расширение
|
||||||
|
|
||||||
@@ -192,23 +212,47 @@ MUST нести идентификатор задачи полем `job_id` и
|
|||||||
### Requirement: Опрос готовности задачи
|
### Requirement: Опрос готовности задачи
|
||||||
|
|
||||||
Сервис SHALL отдавать состояние задачи расшифровки по запросу
|
Сервис SHALL отдавать состояние задачи расшифровки по запросу
|
||||||
`GET /api/status/:id`. Ответ MUST нести идентификатор полем `job_id`, состояние
|
`GET /api/status/:id` **только узнанному отправителю**. Запрос без сессии MUST
|
||||||
полем `status` и время заведения полем `created_at`, а текст расшифровки полем
|
получать код `401`, и тело такого ответа MUST не нести ни состояния задачи, ни
|
||||||
`transcription_text`, и это поле MUST отсутствовать в ответе, пока текста нет:
|
текста расшифровки. Ответ узнанному отправителю MUST нести идентификатор полем
|
||||||
пустая строка на месте отсутствующего текста читается как «расшифровка пуста».
|
`job_id`, состояние полем `status` и время заведения полем `created_at`, а текст
|
||||||
|
расшифровки полем `transcription_text`, и это поле MUST отсутствовать в ответе,
|
||||||
|
пока текста нет: пустая строка на месте отсутствующего текста читается как
|
||||||
|
«расшифровка пуста».
|
||||||
|
|
||||||
|
Отказ без сессии MUST не зависеть от того, есть такая задача или нет: иначе по
|
||||||
|
кодам ответа перебирается список заведённых задач.
|
||||||
|
|
||||||
|
Выборку по владельцу опрос не сужает: узнанный отправитель видит любую задачу по
|
||||||
|
её идентификатору ровно как прежде. Сужение придёт отдельной задачей.
|
||||||
|
|
||||||
#### Scenario: Задача найдена
|
#### Scenario: Задача найдена
|
||||||
|
|
||||||
|
- **GIVEN** отправитель предъявил сессию
|
||||||
- **WHEN** программа спрашивает состояние заведённой задачи
|
- **WHEN** программа спрашивает состояние заведённой задачи
|
||||||
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
|
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
|
||||||
|
|
||||||
|
#### Scenario: Сессии нет
|
||||||
|
|
||||||
|
- **WHEN** программа спрашивает состояние заведённой задачи без сессии
|
||||||
|
- **THEN** ответ имеет код `401`
|
||||||
|
- **AND** тело ответа не несёт ни состояния задачи, ни текста расшифровки
|
||||||
|
|
||||||
|
#### Scenario: Без сессии неизвестная задача неотличима от заведённой
|
||||||
|
|
||||||
|
- **WHEN** программа без сессии спрашивает состояние заведённой задачи, а затем
|
||||||
|
состояние по неизвестному идентификатору
|
||||||
|
- **THEN** оба ответа имеют код `401`
|
||||||
|
|
||||||
#### Scenario: Расшифровки ещё нет
|
#### Scenario: Расшифровки ещё нет
|
||||||
|
|
||||||
|
- **GIVEN** отправитель предъявил сессию
|
||||||
- **WHEN** программа спрашивает состояние задачи, которая ещё не дошла до текста
|
- **WHEN** программа спрашивает состояние задачи, которая ещё не дошла до текста
|
||||||
- **THEN** поля `transcription_text` в ответе нет вовсе
|
- **THEN** поля `transcription_text` в ответе нет вовсе
|
||||||
|
|
||||||
#### Scenario: Задачи с таким идентификатором нет
|
#### Scenario: Задачи с таким идентификатором нет
|
||||||
|
|
||||||
|
- **GIVEN** отправитель предъявил сессию
|
||||||
- **WHEN** программа спрашивает состояние по неизвестному идентификатору
|
- **WHEN** программа спрашивает состояние по неизвестному идентификатору
|
||||||
- **THEN** ответ имеет код `404` и сообщение о ненайденной задаче
|
- **THEN** ответ имеет код `404` и сообщение о ненайденной задаче
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
# storage Specification
|
# storage Specification
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
TBD - created by archiving change pocketbase-storage. Update Purpose after archive.
|
|
||||||
|
Где живут запись, её метаданные и её файл: раскладка каталога данных, приведение
|
||||||
|
схемы при подъёме, отдача файла ссылкой по токену, собственная поверхность
|
||||||
|
хранилища и панель владельца.
|
||||||
|
|
||||||
|
Приём и опрос готовности нормирует `intake`, вход и сессию — `access`.
|
||||||
|
Сознательно не описаны: перенос прежних данных — его нет по решению задачи
|
||||||
|
`pocketbase-storage`; удаление записей и файлов — сервис объявлен архивом
|
||||||
|
2026-08-11, а удаление приносит задача `delete-record`.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
### Requirement: Сервис поднимается на чистом каталоге данных
|
### Requirement: Сервис поднимается на чистом каталоге данных
|
||||||
|
|
||||||
@@ -91,7 +100,22 @@ MUST завести свою схему и принимать записи об
|
|||||||
### Requirement: Файл отдаётся ссылкой
|
### Requirement: Файл отдаётся ссылкой
|
||||||
|
|
||||||
Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой
|
Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой
|
||||||
записи. Отданный файл MUST совпадать с принятым по длине.
|
записи, **и только узнанному отправителю**. Поле файла MUST быть помечено
|
||||||
|
защищённым: без этого ссылка открывает запись любому, кто её знает, и знание
|
||||||
|
ссылки становится правом. Отданный файл MUST совпадать с принятым по длине.
|
||||||
|
|
||||||
|
Одной пометки мало: защищённый файл судится **коротким токеном файла**, который
|
||||||
|
узнанный отправитель берёт у хранилища, предъявив сессию, — и правилом просмотра
|
||||||
|
коллекции. Правило MUST пускать всякого узнанного: незаданное означает «только
|
||||||
|
владелец панели», и тогда файла не получит и вошедший. Сужения по владельцу
|
||||||
|
здесь нет — его заводит отдельная задача.
|
||||||
|
|
||||||
|
Отсюда порядок для потребителя: сессия → токен файла → ссылка с этим токеном.
|
||||||
|
Браузер с одной лишь кукой файла не получит, и это свойство хранилища, а не
|
||||||
|
недосмотр.
|
||||||
|
|
||||||
|
Конвейер расшифровки этим не затронут: он читает файл из файловой системы
|
||||||
|
хранилища, а не по ссылке.
|
||||||
|
|
||||||
Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом.
|
Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом.
|
||||||
|
|
||||||
@@ -101,6 +125,10 @@ MUST завести свою схему и принимать записи об
|
|||||||
логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы
|
логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы
|
||||||
бессрочно.
|
бессрочно.
|
||||||
|
|
||||||
|
Защищённое поле сужает это право, но не отменяет запрета: право пройти теперь
|
||||||
|
требует ещё и сессии, а строка журнала со ссылкой по-прежнему собирала бы
|
||||||
|
половину ключа.
|
||||||
|
|
||||||
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
|
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
|
||||||
пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла
|
пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла
|
||||||
целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и
|
целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и
|
||||||
@@ -112,9 +140,22 @@ MUST завести свою схему и принимать записи об
|
|||||||
#### Scenario: Файл забирают по ссылке
|
#### Scenario: Файл забирают по ссылке
|
||||||
|
|
||||||
- **GIVEN** запись принята и её файл лежит в хранилище
|
- **GIVEN** запись принята и её файл лежит в хранилище
|
||||||
- **WHEN** ссылку на файл запрашивают
|
- **AND** забирающий предъявил сессию и взял по ней токен файла
|
||||||
|
- **WHEN** ссылку на файл запрашивают с этим токеном
|
||||||
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
|
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
|
||||||
|
|
||||||
|
#### Scenario: Без сессии файл не отдаётся
|
||||||
|
|
||||||
|
- **GIVEN** запись принята и её файл лежит в хранилище
|
||||||
|
- **WHEN** ссылку на файл запрашивают без сессии
|
||||||
|
- **THEN** приходит отказ, а содержимого записи в ответе нет
|
||||||
|
|
||||||
|
#### Scenario: Конвейер читает файл без сессии
|
||||||
|
|
||||||
|
- **GIVEN** запись принята и ждёт расшифровки
|
||||||
|
- **WHEN** шаг конвейера берётся за неё
|
||||||
|
- **THEN** файл читается из файловой системы хранилища и шаг проходит
|
||||||
|
|
||||||
#### Scenario: Ссылка ведёт в никуда
|
#### Scenario: Ссылка ведёт в никуда
|
||||||
|
|
||||||
- **WHEN** запрашивают ссылку на запись, которой нет
|
- **WHEN** запрашивают ссылку на запись, которой нет
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
# toolchain Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Каким инструментом и какой его версии собирается сервис, и что об этом
|
||||||
|
проверяется до выкладки. Заведена задачей `go-1-26-upgrade` 2026-08-12 по
|
||||||
|
дефекту, записанному в `docs/review.md` за то же число: сборочный образ разошёлся
|
||||||
|
с требованием модуля, образ перестал собираться, а восемь шагов гейта и шесть
|
||||||
|
проходов ревью показали зелёное.
|
||||||
|
|
||||||
|
Capability нормирует **не поведение сервиса** для его потребителей, а поведение
|
||||||
|
инструмента разработки; потребитель у неё другой — тот, кто собирает сервис. Это
|
||||||
|
осознанное исключение, и оно названо в преамбуле `docs/architecture.md`.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
### Requirement: Версия инструмента сборки объявлена одним числом
|
||||||
|
|
||||||
|
Проект SHALL объявлять версию Go, на которой собирается сервис, одинаково во
|
||||||
|
всех местах, где она названа. Мест ровно четыре, и перечень закрыт: требование
|
||||||
|
модуля в `go.mod`, сборочный образ в `Dockerfile`, строка стека в `CLAUDE.md`,
|
||||||
|
строка стека в `README.md`.
|
||||||
|
|
||||||
|
Сравниваются мажор и минор. Третье число у сборочного образа MUST оставаться
|
||||||
|
свободным, как и база образа: образ обновляется своим темпом, и требовать от
|
||||||
|
него совпадения по патчу значило бы краснеть на каждом его обновлении. Тег
|
||||||
|
читается по форме `golang:<мажор>.<минор>[.<патч>][-<база>]`, и берутся из него
|
||||||
|
первые два числа.
|
||||||
|
|
||||||
|
Правило множественности у мест разное, потому что места устроены по-разному.
|
||||||
|
|
||||||
|
**Документы** — `CLAUDE.md` и `README.md` — MUST называть версию ровно один раз,
|
||||||
|
и считается это **не по файлу, а по разделу стека**: `## Стек` в памятке,
|
||||||
|
`## Технологии` в README. Второе вхождение числа **в этом разделе** MUST
|
||||||
|
считаться отказом: обновят одно, второе протухнет молча. За пределами раздела
|
||||||
|
число не читается вовсе — иначе памятка, которая по устройству ведёт историю
|
||||||
|
закрытых долгов, роняла бы проверку на первой же правдивой строке о прошлой
|
||||||
|
версии, а сообщение толкало бы чинить не проверку, а исторический документ.
|
||||||
|
|
||||||
|
**Сборочный образ** единственности не требует: каждый слой — настоящий вход
|
||||||
|
сборки, и многослойная сборка законна. От всех вхождений `FROM golang:` MUST
|
||||||
|
требоваться совпадение мажора и минора, а не единственность.
|
||||||
|
|
||||||
|
**Требование модуля** называется директивой `go` и по устройству файла
|
||||||
|
единственно.
|
||||||
|
|
||||||
|
Граница раздела MUST быть определена, а не подразумеваться: раздел кончается
|
||||||
|
следующим заголовком того же или более высокого уровня, заголовок третьего уровня
|
||||||
|
и ниже остаётся внутри раздела, а строка, похожая на заголовок, но лежащая внутри
|
||||||
|
блока кода, заголовком MUST не считаться. Без этого пример в чужом разделе
|
||||||
|
открывал бы раздел стека на пустом месте, и число доставалось бы оттуда, откуда
|
||||||
|
норма его читать не велит.
|
||||||
|
|
||||||
|
`go.mod` MUST не содержать директиву `toolchain`. Она называет версию **пятым**
|
||||||
|
местом, которого перечень не знает: при `toolchain go1.27.0` четыре объявленных
|
||||||
|
числа сойдутся, а собирать будет пятое — то есть вернётся тот самый класс
|
||||||
|
расхождения, ради которого требование и заведено.
|
||||||
|
|
||||||
|
#### Scenario: Все четыре места названы одинаково
|
||||||
|
|
||||||
|
- **GIVEN** дерево проекта, где `go.mod`, `Dockerfile`, `CLAUDE.md` и `README.md`
|
||||||
|
называют версию Go
|
||||||
|
- **WHEN** их читают подряд
|
||||||
|
- **THEN** мажор и минор совпадают во всех четырёх
|
||||||
|
|
||||||
|
#### Scenario: Патч сборочного образа отличается законно
|
||||||
|
|
||||||
|
- **GIVEN** `go.mod` требует `1.26.0`, а образ собирается на `golang:1.26.5-alpine`
|
||||||
|
- **WHEN** версии сравнивают
|
||||||
|
- **THEN** расхождением это не считается
|
||||||
|
|
||||||
|
#### Scenario: База сборочного образа сменилась
|
||||||
|
|
||||||
|
- **GIVEN** образ переехал с `golang:1.26-alpine` на `golang:1.26-bookworm`
|
||||||
|
- **WHEN** версии сравнивают
|
||||||
|
- **THEN** расхождением это не считается
|
||||||
|
|
||||||
|
#### Scenario: Раздел стека называет версию дважды
|
||||||
|
|
||||||
|
- **GIVEN** раздел стека в `CLAUDE.md` называет версию два раза
|
||||||
|
- **WHEN** версии сравнивают
|
||||||
|
- **THEN** это расхождение, даже если оба числа одинаковы
|
||||||
|
|
||||||
|
#### Scenario: Число за пределами раздела стека не читается
|
||||||
|
|
||||||
|
- **GIVEN** `CLAUDE.md` вне раздела стека упоминает прошлую версию Go — например
|
||||||
|
записью о закрытом долге
|
||||||
|
- **WHEN** версии сравнивают
|
||||||
|
- **THEN** расхождением это не считается
|
||||||
|
|
||||||
|
#### Scenario: Сборочный образ собран в два слоя
|
||||||
|
|
||||||
|
- **GIVEN** `Dockerfile` содержит два `FROM golang:` с одним мажором и минором
|
||||||
|
- **WHEN** версии сравнивают
|
||||||
|
- **THEN** расхождением это не считается
|
||||||
|
|
||||||
|
#### Scenario: Слои сборочного образа разошлись между собой
|
||||||
|
|
||||||
|
- **GIVEN** `Dockerfile` содержит два `FROM golang:` с разными минорами
|
||||||
|
- **WHEN** версии сравнивают
|
||||||
|
- **THEN** это расхождение
|
||||||
|
|
||||||
|
#### Scenario: Заголовок раздела встретился внутри блока кода
|
||||||
|
|
||||||
|
- **GIVEN** документ в чужом разделе показывает пример, внутри которого есть
|
||||||
|
строка, совпадающая с заголовком раздела стека, а ниже названо другое число
|
||||||
|
- **WHEN** версии сравнивают
|
||||||
|
- **THEN** число из примера не читается, и расхождением это не считается
|
||||||
|
|
||||||
|
#### Scenario: Раздел стека закрыт заголовком верхнего уровня
|
||||||
|
|
||||||
|
- **GIVEN** после раздела стека идёт заголовок первого уровня, а ниже названа
|
||||||
|
прошлая версия
|
||||||
|
- **WHEN** версии сравнивают
|
||||||
|
- **THEN** это число не читается, и расхождением не считается
|
||||||
|
|
||||||
|
#### Scenario: Раздела стека нет вовсе
|
||||||
|
|
||||||
|
- **GIVEN** в документе нет раздела, где называется версия
|
||||||
|
- **WHEN** запускают шаг сверки
|
||||||
|
- **THEN** он завершается отказом и называет недостающий раздел
|
||||||
|
|
||||||
|
#### Scenario: Модуль объявляет версию пятым местом
|
||||||
|
|
||||||
|
- **GIVEN** `go.mod` содержит директиву `toolchain`
|
||||||
|
- **WHEN** версии сравнивают
|
||||||
|
- **THEN** это расхождение
|
||||||
|
|
||||||
|
### Requirement: Объявленное число — то, на котором проект собирается
|
||||||
|
|
||||||
|
Объявленная версия SHALL быть той, на которой сервис действительно собирается и
|
||||||
|
проходит тесты. Согласованность четырёх строк между собой этого не доказывает:
|
||||||
|
четыре одинаковых числа несуществующей версии требованию о согласованности
|
||||||
|
удовлетворяют, а собрать на них нельзя.
|
||||||
|
|
||||||
|
Проверка эта MUST оставаться за человеком и MUST не входить в набор проверок:
|
||||||
|
она требует сборки образа, а сборка образа набором проверок не делается
|
||||||
|
намеренно — дорого. Подъём версии MUST не уезжать в основную ветку, пока сборка
|
||||||
|
образа и тесты на объявленном числе не прогнаны.
|
||||||
|
|
||||||
|
#### Scenario: Версию подняли
|
||||||
|
|
||||||
|
- **GIVEN** объявленную версию Go подняли во всех четырёх местах
|
||||||
|
- **WHEN** изменение готовят к мерджу
|
||||||
|
- **THEN** до мерджа на этой версии прогнаны сборка образа и тесты
|
||||||
|
|
||||||
|
### Requirement: Расхождение версий роняет набор проверок
|
||||||
|
|
||||||
|
Набор проверок `task gate` SHALL включать шаг, который сравнивает объявленные
|
||||||
|
версии между собой и MUST завершаться отказом, когда они разошлись. Сообщение
|
||||||
|
отказа MUST называть **все четыре места и прочитанное в каждом число** — не одну
|
||||||
|
разошедшуюся пару: в дефекте 2026-08-12 три места из четырёх говорили одно и то
|
||||||
|
же и неверными были именно они, а по сообщению о паре человек чинит не то место.
|
||||||
|
|
||||||
|
Шаг MUST судить по содержимому файлов репозитория и MUST не спрашивать
|
||||||
|
установленный инструмент — ни `go version`, ни `go env`, ни `GOTOOLCHAIN`. Исход
|
||||||
|
его MUST быть функцией коммита, а не машины: шаг, чей ответ зависит от того, что
|
||||||
|
стоит на хосте, воспроизводит ровно ту подмену, которая держала дефект
|
||||||
|
2026-08-12 невидимым — там `go build ./...` шёл на хостовом Go, а объявленное
|
||||||
|
число не проверял никто.
|
||||||
|
|
||||||
|
Шаг MUST работать сравнением строк — без сборки образа, без docker и без сети —
|
||||||
|
и MUST не зависеть от рабочего каталога, из которого запущен. Шаг MUST только
|
||||||
|
читать: файлов он не правит и разошедшихся мест не чинит.
|
||||||
|
|
||||||
|
Коды выхода MUST следовать общему словарю проверочных шагов проекта; словарь
|
||||||
|
объявляет раздел «Гейт» в `CLAUDE.md`, и здесь он не повторяется. Своего словаря шаг
|
||||||
|
MUST не заводить: четвёртый шаг с собственной семантикой сделал бы это
|
||||||
|
утверждение неверным.
|
||||||
|
|
||||||
|
Место, где числа не нашлось вовсе, MUST считаться отказом с именем этого места.
|
||||||
|
«Нечего сравнивать» исходом MUST не быть: пропавшая строка иначе выглядела бы
|
||||||
|
как совпадение.
|
||||||
|
|
||||||
|
Отказ чтения места MUST не выглядеть как отсутствие числа. Место, которое
|
||||||
|
существует, но не читается, — это отказ окружения, и сообщение MUST говорить о
|
||||||
|
нечитаемости, а не о ненайденной версии: иначе шаг отправляет чинить документ, в
|
||||||
|
котором строка на месте, а сломаны права.
|
||||||
|
|
||||||
|
#### Scenario: Разошёлся сборочный образ
|
||||||
|
|
||||||
|
- **GIVEN** `Dockerfile` называет версию, отличную от прочих трёх мест
|
||||||
|
- **WHEN** запускают `task gate`
|
||||||
|
- **THEN** шаг сверки завершается отказом
|
||||||
|
- **AND** сообщение называет все четыре места и число каждого
|
||||||
|
- **AND** весь набор проверок краснеет
|
||||||
|
|
||||||
|
#### Scenario: Разошлось требование модуля
|
||||||
|
|
||||||
|
- **GIVEN** `go.mod` называет версию, отличную от прочих трёх мест
|
||||||
|
- **WHEN** запускают шаг сверки
|
||||||
|
- **THEN** он завершается отказом и называет `go.mod` среди разошедшихся
|
||||||
|
|
||||||
|
#### Scenario: Разошлась памятка
|
||||||
|
|
||||||
|
- **GIVEN** `CLAUDE.md` называет версию, отличную от прочих трёх мест
|
||||||
|
- **WHEN** запускают шаг сверки
|
||||||
|
- **THEN** он завершается отказом и называет `CLAUDE.md` среди разошедшихся
|
||||||
|
|
||||||
|
#### Scenario: Разошёлся README
|
||||||
|
|
||||||
|
- **GIVEN** `README.md` называет версию, отличную от прочих трёх мест
|
||||||
|
- **WHEN** запускают шаг сверки
|
||||||
|
- **THEN** он завершается отказом и называет `README.md` среди разошедшихся
|
||||||
|
|
||||||
|
#### Scenario: Версии совпадают
|
||||||
|
|
||||||
|
- **GIVEN** все четыре места называют одно число
|
||||||
|
- **WHEN** запускают `task gate`
|
||||||
|
- **THEN** шаг сверки проходит с кодом 0
|
||||||
|
- **AND** остальные шаги набора идут как прежде
|
||||||
|
|
||||||
|
#### Scenario: Инструмента сборки нет на машине
|
||||||
|
|
||||||
|
- **GIVEN** в `PATH` нет `go` вовсе
|
||||||
|
- **WHEN** запускают шаг сверки
|
||||||
|
- **THEN** исход и сообщение те же, что и при установленном `go`
|
||||||
|
|
||||||
|
#### Scenario: Ни docker, ни сети нет
|
||||||
|
|
||||||
|
- **GIVEN** docker недоступен и сети нет
|
||||||
|
- **WHEN** запускают шаг сверки
|
||||||
|
- **THEN** он отрабатывает и даёт тот же исход, что и при доступном docker
|
||||||
|
|
||||||
|
#### Scenario: Шаг запущен не из корня проекта
|
||||||
|
|
||||||
|
- **GIVEN** шаг запускают из подкаталога дерева
|
||||||
|
- **WHEN** он ищет свои четыре места
|
||||||
|
- **THEN** исход тот же, что и при запуске из корня
|
||||||
|
|
||||||
|
#### Scenario: Место существует, но не читается
|
||||||
|
|
||||||
|
- **GIVEN** файл одного из мест на диске есть, но прав на чтение нет
|
||||||
|
- **WHEN** запускают шаг сверки
|
||||||
|
- **THEN** он завершается кодом окружения и говорит о нечитаемости места
|
||||||
|
- **AND** сообщения «версия не названа» не печатает
|
||||||
|
|
||||||
|
#### Scenario: Версия не названа там, где должна быть
|
||||||
|
|
||||||
|
- **GIVEN** одно из четырёх мест перестало называть версию Go
|
||||||
|
- **WHEN** запускают шаг сверки
|
||||||
|
- **THEN** он завершается отказом и называет место, где число не нашлось
|
||||||
Executable
+243
@@ -0,0 +1,243 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Сверяет объявленную версию Go во всех местах, где она названа: требование
|
||||||
|
# модуля, сборочный образ, памятка и README. Сравниваются мажор и минор; патч и
|
||||||
|
# база сборочного образа свободны.
|
||||||
|
#
|
||||||
|
# Шаг судит по содержимому репозитория и только по нему: команда `go` не
|
||||||
|
# зовётся вовсе. Иначе исход зависел бы от установленного тулчейна и от
|
||||||
|
# GOTOOLCHAIN, а при непустом GOTOOLCHAIN `go` вправе уйти в сеть за нужной
|
||||||
|
# версией — то есть шаг перестал бы быть функцией коммита. Ровно эта подмена
|
||||||
|
# держала дефект 2026-08-12 невидимым: `go build ./...` шёл на хостовом Go и был
|
||||||
|
# зелёным, пока образ не собирался.
|
||||||
|
#
|
||||||
|
# Коды выхода — общий словарь проверочных шагов проекта (CLAUDE.md, «Гейт»):
|
||||||
|
# 0 сошлось, 1 расхождение, 2 ошибка употребления, 3 окружение.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
me=check-go-version
|
||||||
|
|
||||||
|
if [ "$#" -ne 0 ]; then
|
||||||
|
cat >&2 <<'EOF'
|
||||||
|
Использование: check-go-version.sh
|
||||||
|
|
||||||
|
Сверяет объявленную версию Go в go.mod, Dockerfile, CLAUDE.md и README.md.
|
||||||
|
Аргументов не принимает. Коды выхода: 0 сошлось, 1 расхождение,
|
||||||
|
2 ошибка употребления, 3 окружение.
|
||||||
|
EOF
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Корень считается от самого скрипта, а не от текущего каталога: иначе исход
|
||||||
|
# зависел бы от того, откуда шаг запустили.
|
||||||
|
#
|
||||||
|
# `CDPATH= ` — очистка переменной перед `cd`, а не забытый пробел в присваивании:
|
||||||
|
# непустой CDPATH увёл бы `cd` в чужой каталог. shellcheck читает это как SC1007,
|
||||||
|
# и подавление стоит здесь, чтобы заведение линтера потом стоило ровно одной
|
||||||
|
# строки шага.
|
||||||
|
# shellcheck disable=SC1007
|
||||||
|
root=$(CDPATH= cd -- "$(dirname -- "$0")/.." 2>/dev/null && pwd) || {
|
||||||
|
echo "$me: не удалось определить корень репозитория" >&2
|
||||||
|
exit 3
|
||||||
|
}
|
||||||
|
|
||||||
|
# Перечень мест закрыт и лежит здесь одним списком. Пятое место, о котором никто
|
||||||
|
# не знает, — это и есть тот дефект, против которого написан шаг, поэтому
|
||||||
|
# перечень не расползается по коду.
|
||||||
|
gomod=$root/go.mod
|
||||||
|
dockerfile=$root/Dockerfile
|
||||||
|
claudemd=$root/CLAUDE.md
|
||||||
|
readmemd=$root/README.md
|
||||||
|
|
||||||
|
# Нечитаемое место проверяется здесь, а не при разборе, и вот почему. Значения
|
||||||
|
# добываются подстановкой команд в аргументе, а она теряет код возврата; в
|
||||||
|
# `read_doc` вдобавок конвейер, чей статус берётся от последней команды. Отказ
|
||||||
|
# чтения дал бы пустой вход, и шаг сказал бы «README.md не называет версию» —
|
||||||
|
# то есть отправил бы чинить документ, в котором строка на месте, а сломаны
|
||||||
|
# права. Отказ окружения обязан звучать как отказ окружения.
|
||||||
|
for f in "$gomod" "$dockerfile" "$claudemd" "$readmemd"; do
|
||||||
|
if [ ! -f "$f" ]; then
|
||||||
|
echo "$me: нет файла ${f#"$root"/}" >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
if [ ! -r "$f" ]; then
|
||||||
|
echo "$me: файл ${f#"$root"/} нечитаем" >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Число непустых строк на входе.
|
||||||
|
count_lines() {
|
||||||
|
awk 'NF { n = n + 1 } END { print n + 0 }'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Различные непустые значения на входе.
|
||||||
|
distinct() {
|
||||||
|
awk 'NF' | sort -u
|
||||||
|
}
|
||||||
|
|
||||||
|
# Директива `go` в go.mod: первые два числа.
|
||||||
|
read_gomod() {
|
||||||
|
sed -n 's/^go[[:space:]]\{1,\}\([0-9][0-9]*\.[0-9][0-9]*\).*$/\1/p' "$gomod"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Все теги golang в Dockerfile: golang:<мажор>.<минор>[.<патч>][-<база>].
|
||||||
|
# Патч и база отбрасываются — спека объявила их свободными.
|
||||||
|
read_dockerfile() {
|
||||||
|
sed -n \
|
||||||
|
's/^[Ff][Rr][Oo][Mm][[:space:]].*golang:\([0-9][0-9]*\.[0-9][0-9]*\).*$/\1/p' \
|
||||||
|
"$dockerfile"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Тело раздела документа. Раздел кончается следующим заголовком того же или более
|
||||||
|
# высокого уровня; заголовок внутри блока кода заголовком не считается, иначе
|
||||||
|
# пример в чужом разделе открыл бы «раздел стека» на пустом месте. Хвостовые
|
||||||
|
# пробелы в заголовке markdown не рендерит, поэтому и здесь они не значат ничего.
|
||||||
|
section() {
|
||||||
|
awk -v want="$2" '
|
||||||
|
/^```/ { fence = !fence; if (inside) print; next }
|
||||||
|
!fence && (/^# / || /^## /) {
|
||||||
|
line = $0
|
||||||
|
sub(/[[:space:]]+$/, "", line)
|
||||||
|
inside = (line == want)
|
||||||
|
next
|
||||||
|
}
|
||||||
|
inside { print }
|
||||||
|
' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Есть ли в документе раздел с таким заголовком. Правила те же, что у section:
|
||||||
|
# иначе «раздел есть» и «раздел читается» разошлись бы на первом же примере.
|
||||||
|
has_section() {
|
||||||
|
awk -v want="$2" '
|
||||||
|
/^```/ { fence = !fence; next }
|
||||||
|
!fence && (/^# / || /^## /) {
|
||||||
|
line = $0
|
||||||
|
sub(/[[:space:]]+$/, "", line)
|
||||||
|
if (line == want) { found = 1 }
|
||||||
|
}
|
||||||
|
END { exit(found ? 0 : 1) }
|
||||||
|
' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Все вхождения образца `Go <мажор>.<минор>` в разделе стека документа.
|
||||||
|
#
|
||||||
|
# Читается именно раздел, а не файл целиком. `CLAUDE.md` по устройству ведёт
|
||||||
|
# историю закрытых долгов, и правдивая строка о прошлой версии уронила бы шаг
|
||||||
|
# сообщением «называет версию больше одного раза» — то есть послала бы чинить
|
||||||
|
# исторический документ вместо проверки. Тем же ловился бы любой пример команды
|
||||||
|
# в README.
|
||||||
|
#
|
||||||
|
# Первый sed ставит каждое вхождение на свою строку: два числа в одной строке
|
||||||
|
# иначе слились бы в одно, и второе, протухшее, осталось бы невидимым.
|
||||||
|
read_doc() {
|
||||||
|
section "$1" "$2" | sed -e 's/Go [0-9][0-9]*\.[0-9][0-9]*/\
|
||||||
|
&\
|
||||||
|
/g' | sed -n 's/^Go \([0-9][0-9]*\.[0-9][0-9]*\)$/\1/p'
|
||||||
|
}
|
||||||
|
|
||||||
|
failed=0
|
||||||
|
report=''
|
||||||
|
collected=''
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "$me: $1" >&2
|
||||||
|
failed=1
|
||||||
|
}
|
||||||
|
|
||||||
|
add_report() {
|
||||||
|
line=$(printf ' %-11s %s' "$1" "$2")
|
||||||
|
report="$report$line
|
||||||
|
"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Добывает число из места и кладёт его в `collected`; пусто — значит не добыто.
|
||||||
|
# Третий аргумент: 1 — место обязано называть версию ровно один раз.
|
||||||
|
#
|
||||||
|
# Функция не зовётся в подстановке команд намеренно: подоболочка потеряла бы и
|
||||||
|
# флаг отказа, и отчёт.
|
||||||
|
collect() {
|
||||||
|
place=$1
|
||||||
|
values=$2
|
||||||
|
strict=$3
|
||||||
|
|
||||||
|
collected=''
|
||||||
|
total=$(printf '%s\n' "$values" | count_lines)
|
||||||
|
uniq=$(printf '%s\n' "$values" | distinct)
|
||||||
|
uniq_total=$(printf '%s\n' "$uniq" | count_lines)
|
||||||
|
|
||||||
|
if [ "$total" -eq 0 ]; then
|
||||||
|
add_report "$place" 'версия не названа'
|
||||||
|
fail "$place не называет версию Go"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$uniq_total" -gt 1 ]; then
|
||||||
|
add_report "$place" "$(printf '%s' "$uniq" | tr '\n' '/') — разные числа"
|
||||||
|
fail "$place называет несколько разных версий"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$strict" -eq 1 ] && [ "$total" -gt 1 ]; then
|
||||||
|
add_report "$place" "$uniq — названа $total раз(а)"
|
||||||
|
fail "$place называет версию больше одного раза"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
collected=$uniq
|
||||||
|
add_report "$place" "$uniq"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Директива toolchain — пятое место, которого перечень не знает: четыре числа
|
||||||
|
# сойдутся, а собирать будет пятое.
|
||||||
|
if grep '^toolchain[[:space:]]' "$gomod" >/dev/null 2>&1; then
|
||||||
|
fail 'go.mod содержит директиву toolchain — она называет версию пятым местом'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Раздел, в котором документ называет версию. За его пределами число не читается.
|
||||||
|
claude_section='## Стек'
|
||||||
|
readme_section='## Технологии'
|
||||||
|
|
||||||
|
# Пропавший раздел — отдельный исход: он говорит «искать негде», а не «версия не
|
||||||
|
# названа», и чинится другим движением.
|
||||||
|
collect_doc() {
|
||||||
|
place=$1
|
||||||
|
path=$2
|
||||||
|
heading=$3
|
||||||
|
|
||||||
|
collected=''
|
||||||
|
if ! has_section "$path" "$heading"; then
|
||||||
|
add_report "$place" "нет раздела «$heading»"
|
||||||
|
fail "в $place нет раздела «$heading», где называется версия"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
collect "$place" "$(read_doc "$path" "$heading")" 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Документы называют версию прозой, и второе вхождение в разделе стека — не
|
||||||
|
# дубликат, а второе утверждение: обновят одно, второе протухнет молча. У
|
||||||
|
# Dockerfile иначе: каждый сборочный слой — настоящий вход сборки, и от них
|
||||||
|
# требуется совпадение, а не единственность.
|
||||||
|
collect go.mod "$(read_gomod)" 1
|
||||||
|
v_gomod=$collected
|
||||||
|
collect Dockerfile "$(read_dockerfile)" 0
|
||||||
|
v_docker=$collected
|
||||||
|
collect_doc CLAUDE.md "$claudemd" "$claude_section"
|
||||||
|
v_claude=$collected
|
||||||
|
collect_doc README.md "$readmemd" "$readme_section"
|
||||||
|
v_readme=$collected
|
||||||
|
|
||||||
|
if [ "$failed" -eq 0 ]; then
|
||||||
|
found=$(printf '%s\n%s\n%s\n%s\n' \
|
||||||
|
"$v_gomod" "$v_docker" "$v_claude" "$v_readme" | distinct | count_lines)
|
||||||
|
if [ "$found" -ne 1 ]; then
|
||||||
|
fail 'объявленные версии Go разошлись'
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$failed" -ne 0 ]; then
|
||||||
|
printf 'Объявленная версия Go по местам:\n%s' "$report" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
// Package scripts — проверки скриптов репозитория. Рабочего кода на Go в нём
|
||||||
|
// нет: пакет существует ради того, чтобы `go test ./...` гонял и shell.
|
||||||
|
//
|
||||||
|
// Норма шага сверки версий — openspec/specs/toolchain/spec.md. Каждый её
|
||||||
|
// сценарий проверяется здесь мутацией: дерево-образец собирается во временном
|
||||||
|
// каталоге, портится ровно одним способом, и от скрипта требуется объявленный
|
||||||
|
// исход. Прежде сценарии подтверждались разовыми ручными прогонами — после
|
||||||
|
// первой правки образца они перестали бы выполняться молча.
|
||||||
|
package scripts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Дерево-образец: все четыре места называют одну версию.
|
||||||
|
//
|
||||||
|
// `CLAUDE.md` держит второе число **за** разделом стека намеренно: так выглядит
|
||||||
|
// правдивая строка о закрытом долге, и норма велит её не читать.
|
||||||
|
var fixture = map[string]string{
|
||||||
|
"go.mod": "module example\n\ngo 1.26.0\n",
|
||||||
|
"Dockerfile": "FROM docker.io/library/golang:1.26-alpine AS builder\n" +
|
||||||
|
"RUN true\n\n" +
|
||||||
|
"FROM docker.io/library/alpine:3.22\n",
|
||||||
|
"CLAUDE.md": "# CLAUDE.md\n\n" +
|
||||||
|
"## Стек\n\nGo 1.26, встроенная PocketBase.\n\n" +
|
||||||
|
"## Гейт\n\nПрежде проект собирался на Go 1.24 — долг закрыт.\n",
|
||||||
|
"README.md": "# transcriber\n\n## Технологии\n\nGo 1.26 и ffmpeg.\n",
|
||||||
|
}
|
||||||
|
|
||||||
|
// allPlaces — все четыре места и число каждого: этого требует норма от
|
||||||
|
// сообщения о расхождении. Числа два, потому что разошедшееся место называет
|
||||||
|
// своё.
|
||||||
|
var allPlaces = []string{"go.mod", "Dockerfile", "CLAUDE.md", "README.md", "1.26", "1.25"}
|
||||||
|
|
||||||
|
const (
|
||||||
|
exitOK = 0
|
||||||
|
exitDrift = 1
|
||||||
|
exitUsage = 2
|
||||||
|
exitEnviron = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestСверкаВерсийПоСценариямНормы(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
// mutate портит дерево-образец; nil — дерево не портится.
|
||||||
|
mutate func(t *testing.T, root string)
|
||||||
|
// args — аргументы скрипта.
|
||||||
|
args []string
|
||||||
|
// dir — рабочий каталог прогона относительно корня дерева.
|
||||||
|
dir string
|
||||||
|
want int
|
||||||
|
// says — что обязано прозвучать в сообщении.
|
||||||
|
says string
|
||||||
|
// saysAll — что обязано прозвучать всё разом. Норма требует от сообщения
|
||||||
|
// о расхождении **все четыре места и число каждого**: в дефекте
|
||||||
|
// 2026-08-12 три места из четырёх говорили одно и то же, и неверными
|
||||||
|
// были именно они — по сообщению о паре человек чинит не то место.
|
||||||
|
saysAll []string
|
||||||
|
// saysNot — чего в сообщении быть не должно.
|
||||||
|
saysNot string
|
||||||
|
}{
|
||||||
|
{name: "все четыре места названы одинаково", want: exitOK},
|
||||||
|
{
|
||||||
|
name: "патч сборочного образа отличается законно",
|
||||||
|
mutate: replace("Dockerfile", "golang:1.26-alpine", "golang:1.26.5-alpine"),
|
||||||
|
want: exitOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "база сборочного образа сменилась",
|
||||||
|
mutate: replace("Dockerfile", "golang:1.26-alpine", "golang:1.26-bookworm"),
|
||||||
|
want: exitOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "сборочный образ собран в два слоя",
|
||||||
|
mutate: replace("Dockerfile", "RUN true", "FROM docker.io/library/golang:1.26-alpine AS tools"),
|
||||||
|
want: exitOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "слои сборочного образа разошлись между собой",
|
||||||
|
mutate: replace("Dockerfile", "RUN true", "FROM docker.io/library/golang:1.25-alpine AS tools"),
|
||||||
|
want: exitDrift,
|
||||||
|
says: "Dockerfile",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "разошёлся сборочный образ",
|
||||||
|
mutate: replace("Dockerfile", "golang:1.26-alpine", "golang:1.25-alpine"),
|
||||||
|
want: exitDrift,
|
||||||
|
says: "разошлись",
|
||||||
|
saysAll: allPlaces,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "разошлось требование модуля",
|
||||||
|
mutate: replace("go.mod", "go 1.26.0", "go 1.25.0"),
|
||||||
|
want: exitDrift,
|
||||||
|
says: "разошлись",
|
||||||
|
saysAll: allPlaces,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "разошлась памятка",
|
||||||
|
mutate: replace("CLAUDE.md", "Go 1.26, встроенная", "Go 1.25, встроенная"),
|
||||||
|
want: exitDrift,
|
||||||
|
says: "разошлись",
|
||||||
|
saysAll: allPlaces,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "разошёлся README",
|
||||||
|
mutate: replace("README.md", "Go 1.26 и ffmpeg", "Go 1.25 и ffmpeg"),
|
||||||
|
want: exitDrift,
|
||||||
|
says: "разошлись",
|
||||||
|
saysAll: allPlaces,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "раздел стека называет версию дважды",
|
||||||
|
mutate: replace("CLAUDE.md", "встроенная PocketBase.", "встроенная PocketBase, всё та же Go 1.26."),
|
||||||
|
want: exitDrift,
|
||||||
|
says: "больше одного раза",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "число за пределами раздела стека не читается",
|
||||||
|
mutate: replace("CLAUDE.md", "Go 1.24 — долг закрыт.", "Go 1.24 и Go 1.23 — долги закрыты."),
|
||||||
|
want: exitOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "заголовок раздела встретился внутри блока кода",
|
||||||
|
mutate: replace("README.md", "## Технологии\n\nGo 1.26 и ffmpeg.\n",
|
||||||
|
"## Пример\n\n```md\n## Технологии\n\nGo 1.19 из примера.\n```\n\n## Технологии\n\nGo 1.26 и ffmpeg.\n"),
|
||||||
|
want: exitOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "раздел стека закрыт заголовком верхнего уровня",
|
||||||
|
mutate: replace("CLAUDE.md", "## Гейт\n\nПрежде проект собирался на Go 1.24 — долг закрыт.\n",
|
||||||
|
"# Приложение\n\nПрежде проект собирался на Go 1.24 — долг закрыт.\n"),
|
||||||
|
want: exitOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "раздела стека нет вовсе",
|
||||||
|
mutate: replace("CLAUDE.md", "## Стек", "## Инструменты"),
|
||||||
|
want: exitDrift,
|
||||||
|
says: "нет раздела",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "версия не названа там, где должна быть",
|
||||||
|
mutate: replace("README.md", "Go 1.26 и ffmpeg.", "ffmpeg и всё остальное."),
|
||||||
|
want: exitDrift,
|
||||||
|
says: "не называет версию",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "модуль объявляет версию пятым местом",
|
||||||
|
mutate: replace("go.mod", "go 1.26.0", "go 1.26.0\n\ntoolchain go1.27.0"),
|
||||||
|
want: exitDrift,
|
||||||
|
says: "toolchain",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "места нет вовсе",
|
||||||
|
mutate: remove("README.md"),
|
||||||
|
want: exitEnviron,
|
||||||
|
says: "нет файла",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "место существует, но не читается",
|
||||||
|
mutate: unreadable("README.md"),
|
||||||
|
want: exitEnviron,
|
||||||
|
says: "нечитаем",
|
||||||
|
saysNot: "не называет версию",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "шаг запущен не из корня проекта",
|
||||||
|
dir: "scripts",
|
||||||
|
want: exitOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "шагу переданы аргументы",
|
||||||
|
args: []string{"--base", "origin/master"},
|
||||||
|
want: exitUsage,
|
||||||
|
says: "Использование",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
root := treeWithScript(t)
|
||||||
|
if c.mutate != nil {
|
||||||
|
c.mutate(t, root)
|
||||||
|
}
|
||||||
|
code, out := runScript(t, root, c.dir, c.args)
|
||||||
|
if code != c.want {
|
||||||
|
t.Errorf("код возврата %d, ожидался %d\nвывод:\n%s", code, c.want, out)
|
||||||
|
}
|
||||||
|
if c.says != "" && !strings.Contains(out, c.says) {
|
||||||
|
t.Errorf("в сообщении нет %q\nвывод:\n%s", c.says, out)
|
||||||
|
}
|
||||||
|
for _, want := range c.saysAll {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("сообщение не называет %q\nвывод:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.saysNot != "" && strings.Contains(out, c.saysNot) {
|
||||||
|
t.Errorf("в сообщении есть лишнее %q\nвывод:\n%s", c.saysNot, out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Норма требует, чтобы исход был функцией коммита, а не машины: скрипт не
|
||||||
|
// спрашивает установленный инструмент. Проверяется это прогоном без `go` в
|
||||||
|
// `PATH` — исход обязан не измениться.
|
||||||
|
func TestИсходНеЗависитОтУстановленногоGo(t *testing.T) {
|
||||||
|
root := treeWithScript(t)
|
||||||
|
|
||||||
|
withGo, outWith := runScript(t, root, "", nil)
|
||||||
|
if withGo != exitOK {
|
||||||
|
t.Fatalf("дерево-образец обязано сходиться, а код %d:\n%s", withGo, outWith)
|
||||||
|
}
|
||||||
|
|
||||||
|
goBin, err := exec.LookPath("go")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("go не найден в PATH — проверять нечего")
|
||||||
|
}
|
||||||
|
var kept []string
|
||||||
|
for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
|
||||||
|
if dir != filepath.Dir(goBin) {
|
||||||
|
kept = append(kept, dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
withoutGo, outWithout := runScript(t, root, "", nil, "PATH="+strings.Join(kept, string(os.PathListSeparator)))
|
||||||
|
if withoutGo != withGo {
|
||||||
|
t.Errorf("без go в PATH код %d, с ним %d\nвывод:\n%s", withoutGo, withGo, outWithout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Скрипт не зовёт ни `go`, ни `docker`, ни сеть — это читается из его текста, и
|
||||||
|
// правило держит именно текст: прогон без сети в наборе проверок недоступен.
|
||||||
|
func TestСкриптНеЗоветНиGoНиDocker(t *testing.T) {
|
||||||
|
body, err := os.ReadFile("check-go-version.sh")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("читаю скрипт: %v", err)
|
||||||
|
}
|
||||||
|
// Границы слова обязательны: имя `read_dockerfile` и переменная `dockerfile`
|
||||||
|
// законны — читается файл, а не зовётся демон.
|
||||||
|
code := withoutComments(string(body))
|
||||||
|
for _, forbidden := range []string{`go\s+version`, `go\s+env`, `GOTOOLCHAIN`, `\bdocker\b`, `\bcurl\b`, `\bwget\b`} {
|
||||||
|
if regexp.MustCompile(forbidden).FindString(code) != "" {
|
||||||
|
t.Errorf("скрипт зовёт %s вне комментария: исход перестаёт быть функцией коммита", forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Помощники --------------------------------------------------------------
|
||||||
|
|
||||||
|
// treeWithScript собирает дерево-образец и кладёт в него сам скрипт: корень он
|
||||||
|
// считает от своего расположения, поэтому проверяется копия внутри дерева.
|
||||||
|
func treeWithScript(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
for name, body := range fixture {
|
||||||
|
write(t, filepath.Join(root, name), body, 0o644)
|
||||||
|
}
|
||||||
|
script, err := os.ReadFile("check-go-version.sh")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("читаю скрипт: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(filepath.Join(root, "scripts"), 0o755); err != nil {
|
||||||
|
t.Fatalf("завожу каталог scripts: %v", err)
|
||||||
|
}
|
||||||
|
write(t, filepath.Join(root, "scripts", "check-go-version.sh"), string(script), 0o755)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
// runScript гоняет скрипт и отдаёт код возврата с объединённым выводом.
|
||||||
|
// `env` — добавка к окружению прогона, `args` — аргументы скрипта.
|
||||||
|
func runScript(t *testing.T, root, dir string, args []string, env ...string) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
// Контекст проверки: зависший скрипт умирает вместе с ней, а не переживает
|
||||||
|
// прогон осиротевшим процессом.
|
||||||
|
cmd := exec.CommandContext(t.Context(), "sh", append([]string{filepath.Join(root, "scripts", "check-go-version.sh")}, args...)...)
|
||||||
|
cmd.Dir = filepath.Join(root, dir)
|
||||||
|
if len(env) > 0 {
|
||||||
|
cmd.Env = append(os.Environ(), env...)
|
||||||
|
}
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
code := 0
|
||||||
|
if err != nil {
|
||||||
|
var exit *exec.ExitError
|
||||||
|
if !errors.As(err, &exit) {
|
||||||
|
t.Fatalf("прогон скрипта: %v", err)
|
||||||
|
}
|
||||||
|
code = exit.ExitCode()
|
||||||
|
}
|
||||||
|
return code, string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func replace(file, old, new string) func(*testing.T, string) {
|
||||||
|
return func(t *testing.T, root string) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(root, file)
|
||||||
|
body, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("читаю %s: %v", file, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), old) {
|
||||||
|
t.Fatalf("в образце %s нет %q: мутация потеряла предмет", file, old)
|
||||||
|
}
|
||||||
|
write(t, path, strings.Replace(string(body), old, new, 1), 0o644)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(file string) func(*testing.T, string) {
|
||||||
|
return func(t *testing.T, root string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.Remove(filepath.Join(root, file)); err != nil {
|
||||||
|
t.Fatalf("убираю %s: %v", file, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unreadable(file string) func(*testing.T, string) {
|
||||||
|
return func(t *testing.T, root string) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(root, file)
|
||||||
|
if err := os.Chmod(path, 0o000); err != nil {
|
||||||
|
t.Fatalf("снимаю права с %s: %v", file, err)
|
||||||
|
}
|
||||||
|
// Права возвращаются, иначе уборка временного каталога отказала бы.
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := os.Chmod(path, 0o644); err != nil {
|
||||||
|
t.Errorf("возвращаю права %s: %v", file, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if body, err := os.ReadFile(path); err == nil {
|
||||||
|
t.Skipf("файл читается и без прав (%d байт) — прогон под root?", len(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func write(t *testing.T, path, body string, perm os.FileMode) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(path, []byte(body), perm); err != nil {
|
||||||
|
t.Fatalf("пишу %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// withoutComments снимает строки-комментарии: слово в объяснении вызовом не
|
||||||
|
// является, а объяснения в этом скрипте длиннее самого кода.
|
||||||
|
func withoutComments(body string) string {
|
||||||
|
var kept []string
|
||||||
|
for line := range strings.SplitSeq(body, "\n") {
|
||||||
|
if !strings.HasPrefix(strings.TrimSpace(line), "#") {
|
||||||
|
kept = append(kept, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(kept, "\n")
|
||||||
|
}
|
||||||
+57
-20
@@ -11,6 +11,25 @@
|
|||||||
Секция одна — полок домена у проекта нет, и делить очередь на две
|
Секция одна — полок домена у проекта нет, и делить очередь на две
|
||||||
значило бы держать два порядка вместо одного.
|
значило бы держать два порядка вместо одного.
|
||||||
|
|
||||||
|
**Чем очередь упорядочена на этом этапе — от базы к деталям.**
|
||||||
|
Сначала то, на чём стоит остальное: проверки, которым можно верить,
|
||||||
|
владелец записи, единый контракт API, покрытый тестами конвейер, — и
|
||||||
|
только потом экраны и возможности поверх них. Порядок расставлен на
|
||||||
|
груминге 2026-08-12 и держится, пока сервис не собран целиком:
|
||||||
|
задача, взятая раньше своего основания, стоит дважды — сперва её
|
||||||
|
пишут, потом переписывают под появившееся основание.
|
||||||
|
|
||||||
|
Отсюда правило для **новых** записей. Заведённая по ходу работы —
|
||||||
|
интейком, урожаем ревью, разбором находок — задача встаёт в конец
|
||||||
|
очереди машинально, и это **не** её место, а отсутствие места.
|
||||||
|
Слой ей назначает человек на ближайшем груминге: ниже того, чего она
|
||||||
|
требует, и выше того, что требует её. Причина уезжает в запись
|
||||||
|
(`move --after <слаг> --reason`).
|
||||||
|
|
||||||
|
Разведки это касается вдвойне: её исход — новые задачи, и слой они
|
||||||
|
наследуют не от разведки, а от того, что трогают. Разведка о конвейере
|
||||||
|
может принести задачу основания, которой место в голове очереди.
|
||||||
|
|
||||||
Тип записи стоит первым полем меты и решает, что у неё может быть:
|
Тип записи стоит первым полем меты и решает, что у неё может быть:
|
||||||
✨ `feature` 🐞 `fix` 🧹 `chore` 🔬 `research`
|
✨ `feature` 🐞 `fix` 🧹 `chore` 🔬 `research`
|
||||||
|
|
||||||
@@ -20,45 +39,63 @@
|
|||||||
|
|
||||||
## Очередь
|
## Очередь
|
||||||
|
|
||||||
- [🐞 Убирать записанный файл, когда приём отказал на середине](items/orphan-file-on-failed-intake.md) — Отказ чтения метаданных и отказ записи на диск оставляют файл в каталоге хранения без задачи и без учёта: сопоставить его не с чем, удалять приходится руками.
|
- [🧹 Ронять гейт на изменённой функции, которую не выполняет ни один тест](items/gate-changed-lines-coverage.md) — Свойство «изменённое место покрыто хоть одним тестом» записано в docs/review.md, но не механизировано: за две задачи подряд непокрытые шаги ловили руками.
|
||||||
- [🧹 Задать таймауты обращениям к внешним сервисам](items/external-call-timeouts.md) — Ни у Telegram, ни у Object Storage, ни у SpeechKit нет таймаута: молчащий собеседник держит шаг конвейера до истечения часового захвата.
|
- [🧹 Поднимать сервис локально без действующего токена бота](items/local-run-without-telegram-token.md) — Адаптер Telegram проверяет токен обращением к Telegram и роняет старт, а боевым токеном запускаться запрещено: проверить поведение живым прогоном не может ни одна задача.
|
||||||
- [✨ Пускать в приложение только после входа через OIDC](items/oidc-login.md) — HTTP API открыт наружу без аутентификации: любой из интернета заводит задачи за наши деньги и читает чужие расшифровки по идентификатору.
|
- [🐞 Убрать код провайдера из журнала запросов хранилища](items/provider-code-out-of-storage-log.md) — Строка запроса с кодом входа целиком уезжает в таблицу _logs и лежит там пять суток, хотя спека access требует, чтобы код в журнал не попадал.
|
||||||
|
- [🐞 Вести учёт употреблённых состояний входа на сервере](items/server-side-login-state.md) — Одноразовость возврата держится на уборке куки, то есть на браузере: сервер не помнит, какие состояния уже потрачены.
|
||||||
|
- [🧹 Строить адрес входа из настроек коллекции, а не из конфига](items/login-url-from-collection-settings.md) — Первая половина входа собрана руками из конфига и на настройки провайдера не смотрит, вторая берётся из коллекции: обновление библиотеки изменит только вторую половину.
|
||||||
|
- [🔬 Четыре недоказанные гипотезы о поверхности входа](items/login-surface-hypotheses.md) — Ревью назвало четыре пути, которых не смогло ни подтвердить, ни опровергнуть: браузера и живого провайдера в прогоне не было.
|
||||||
|
- [🧹 Назвать в необратимом, что откат кода не откатывает шаг схемы](items/rollback-does-not-undo-schema-step.md) — Откат бинаря оставляет применённый шаг схемы в силе, и на этом строятся решения о выкладке: сегодня об этом не сказано нигде.
|
||||||
|
- [🔬 Адрес объекта в тексте отказа SpeechKit](items/speechkit-error-text-leak.md) — Текст отказа операции приходит от Yandex и уезжает в журнал и в колонку error_text: если он несёт URI объекта, из журнала снова собирается ссылка на чужую запись.
|
||||||
|
- [🧹 Разобрать мелочи http-транспорта](items/http-transport-nits.md) — Маршруты зарегистрированы дважды, и переименование пути в main.go проходит проверки зелёным; обработчик пишет в журнал через стандартный log и дублирует запись, уже сделанную сервисом.
|
||||||
|
- [🧹 Переименовать образец конфига в config.example.toml](items/config-example-toml.md) — Конвенция называет config.dist.toml объявленным расхождением, но тут же пишет это имя как правило — документ противоречит сам себе, а образец расходится с конвенцией.
|
||||||
- [✨ Привязать запись к владельцу и отдавать только свои](items/record-ownership.md) — У задачи и файла нет владельца, поэтому знание UUID задачи и есть право её читать.
|
- [✨ Привязать запись к владельцу и отдавать только свои](items/record-ownership.md) — У задачи и файла нет владельца, поэтому знание UUID задачи и есть право её читать.
|
||||||
- [✨ Сопоставить пользователя Telegram с учётной записью](items/telegram-account-link.md) — Белый список сверяется с именем пользователя Telegram, которое владелец меняет в любой момент, а записи из бота ни с кем не связаны.
|
|
||||||
- [✨ Свести приём и чтение записей к одному контракту для приложения](items/json-api-for-spa.md) — Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем.
|
- [✨ Свести приём и чтение записей к одному контракту для приложения](items/json-api-for-spa.md) — Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем.
|
||||||
|
- [✨ Сопоставить пользователя Telegram с учётной записью](items/telegram-account-link.md) — Белый список сверяется с именем пользователя Telegram, которое владелец меняет в любой момент, а записи из бота ни с кем не связаны.
|
||||||
- [✨ Пускать скрипты в API по личным токенам](items/api-tokens.md) — Вход через OIDC закрывает API целиком, а скрипту браузерная сессия недоступна: автоматизировать загрузку станет нечем.
|
- [✨ Пускать скрипты в API по личным токенам](items/api-tokens.md) — Вход через OIDC закрывает API целиком, а скрипту браузерная сессия недоступна: автоматизировать загрузку станет нечем.
|
||||||
|
- [🧹 Покрыть тестами шаги конвейера и захват задачи](items/pipeline-step-tests.md) — Тестовых файлов в проекте два, и оба мимо конвейера: потеря ссылки на файл, двойной ответ пользователю и гонка при захвате не поймаются ничем.
|
||||||
|
- [🧹 Покрыть тестами разбор вывода ffprobe](items/metaviewer-adapter-tests.md) — Проверки приёма перестали звать настоящий ffprobe 2026-08-11, а своего теста у адаптера метаданных нет: разбор JSON и отличие «программы нет в PATH» от «обработка отказала» не проверяет ничто.
|
||||||
|
- [🧹 Задать таймауты обращениям к внешним сервисам](items/external-call-timeouts.md) — Ни у Telegram, ни у Object Storage, ни у SpeechKit нет таймаута: молчащий собеседник держит шаг конвейера до истечения часового захвата.
|
||||||
|
- [🧹 Прервать шаг конвейера отменой контекста](items/context-cancel-in-pipeline.md) — Половина сделана 2026-08-13 — контекст доходит до внешних вызовов, а прерванный шаг оставляет задачу на повтор и не тратит попытку, — но осталось то, ради чего задача заводилась: хранилище контекста не принимает ни одним методом, и бюджет мягкой остановки не замерен.
|
||||||
|
- [🐞 Убирать записанный файл, когда приём отказал на середине](items/orphan-file-on-failed-intake.md) — Отказ чтения метаданных и отказ записи на диск оставляют файл в каталоге хранения без задачи и без учёта: сопоставить его не с чем, удалять приходится руками.
|
||||||
|
- [🧹 Разобрать мелочи слоя хранилища](items/storage-layer-nits.md) — Три мелочи ниже потолка триажа: цикл воркера пишет потерю захвата уровнем ERROR и считает её отказом, тип ошибки заведён там, где конвенция просит sentinel, а FileName несёт два разных смысла.
|
||||||
- [✨ Собрать каркас приложения и раздать его из бинарника](items/spa-skeleton.md) — Экранов нет и собирать их нечем: ни сборки фронтенда, ни раздачи статики в проекте не существует.
|
- [✨ Собрать каркас приложения и раздать его из бинарника](items/spa-skeleton.md) — Экранов нет и собирать их нечем: ни сборки фронтенда, ни раздачи статики в проекте не существует.
|
||||||
- [✨ Сделать экран загрузки записи и её состояния](items/upload-and-status-screen.md) — Первое, ради чего приложение открывают: отдать файл и увидеть, что с ним происходит.
|
- [✨ Сделать экран загрузки записи и её состояния](items/upload-and-status-screen.md) — Первое, ради чего приложение открывают: отдать файл и увидеть, что с ним происходит.
|
||||||
- [✨ Сделать экран списка своих записей и чтения текста](items/records-list-screen.md) — Расшифровка сегодня доходит одним сообщением и теряется в переписке; вернуться к ней через неделю нечем.
|
- [✨ Сделать экран списка своих записей и чтения текста](items/records-list-screen.md) — Расшифровка сегодня доходит одним сообщением и теряется в переписке; вернуться к ней через неделю нечем.
|
||||||
- [✨ Удалять запись со всеми уровнями текста по требованию владельца](items/delete-record.md) — Ни файлы, ни расшифровки не удаляются вовсе: убрать запись сегодня можно только руками в базе и в каталоге на сервере.
|
- [✨ Проигрывать загруженную запись на экране записи](items/play-recording-in-app.md) — Послушать загруженное приложение не даёт, а самой копии для этого у задачи нет: указатель на файл перезаписывается на каждом шаге конвейера и у готовой задачи ведёт на объект в Object Storage.
|
||||||
|
- [✨ Сделать приложение устанавливаемым на телефон](items/installable-pwa.md) — Приложение, живущее вкладкой браузера, теряется среди прочих: ярлыка на экране у него нет.
|
||||||
- [✨ Узнавать уже загруженный файл по хеш-сумме](items/dedup-by-content-hash.md) — Один и тот же файл, отправленный дважды, распознаётся дважды и оплачивается дважды: приём не смотрит на содержимое вовсе.
|
- [✨ Узнавать уже загруженный файл по хеш-сумме](items/dedup-by-content-hash.md) — Один и тот же файл, отправленный дважды, распознаётся дважды и оплачивается дважды: приём не смотрит на содержимое вовсе.
|
||||||
- [✨ Принимать до десяти файлов одной загрузкой](items/multi-file-upload.md) — Приём берёт один файл в запросе, а с телефона выбирают пачку сразу: десять записей значат десять заходов на экран загрузки.
|
- [✨ Принимать до десяти файлов одной загрузкой](items/multi-file-upload.md) — Приём берёт один файл в запросе, а с телефона выбирают пачку сразу: десять записей значат десять заходов на экран загрузки.
|
||||||
- [✨ Показывать ход загрузки записи на экране](items/upload-progress.md) — Гигабайтный файл уходит на сервер молча: до ответа сервера экран не отличает идущую загрузку от зависшей.
|
- [✨ Показывать ход загрузки записи на экране](items/upload-progress.md) — Гигабайтный файл уходит на сервер молча: до ответа сервера экран не отличает идущую загрузку от зависшей.
|
||||||
- [✨ Сделать приложение устанавливаемым на телефон](items/installable-pwa.md) — Приложение, живущее вкладкой браузера, теряется среди прочих: ярлыка на экране у него нет.
|
- [🔬 Загрузка большого файла частями](items/chunked-upload-choice.md) — Гигабайтный файл едет одним запросом, и обрыв на девяноста процентах начинает его заново.
|
||||||
|
- [✨ Удалять запись со всеми уровнями текста по требованию владельца](items/delete-record.md) — Ни файлы, ни расшифровки не удаляются вовсе: убрать запись сегодня можно только руками в базе и в каталоге на сервере.
|
||||||
- [✨ Сделать экран настроек и хранить настройки по пользователю](items/settings-screen.md) — Настроек у пользователя нет вовсе: уровни текста и канал уведомлений задаются общим конфигом сервиса.
|
- [✨ Сделать экран настроек и хранить настройки по пользователю](items/settings-screen.md) — Настроек у пользователя нет вовсе: уровни текста и канал уведомлений задаются общим конфигом сервиса.
|
||||||
- [✨ Считать заголовок, темы и пересказ внешней моделью](items/llm-insights-adapter.md) — Расшифровка доходит стеной текста: ни заголовка, ни тем, ни пересказа сервис не считает, и клиента языковой модели в нём нет.
|
- [✨ Считать заголовок, темы и пересказ внешней моделью](items/llm-insights-adapter.md) — Расшифровка доходит стеной текста: ни заголовка, ни тем, ни пересказа сервис не считает, и клиента языковой модели в нём нет.
|
||||||
- [✨ Отдавать вычитанный текст рядом с сырым](items/literary-text-level.md) — Сырая расшифровка идёт без знаков препинания, с повторами и словами-паразитами: читать её подряд тяжело, а другого уровня текста нет.
|
- [✨ Отдавать вычитанный текст рядом с сырым](items/literary-text-level.md) — Сырая расшифровка идёт без знаков препинания, с повторами и словами-паразитами: читать её подряд тяжело, а другого уровня текста нет.
|
||||||
|
- [✨ Показывать заголовок в списке, отбирать список по темам и считать токены](items/insights-visible-in-list.md) — Заголовок, темы и пересказ считаются, но список по-прежнему показывает первые слова расшифровки и не отбирается ничем, а расход на модель не виден числом.
|
||||||
|
- [✨ Считать только те уровни текста, что включены у владельца записи](items/settings-applied-in-pipeline.md) — Дом настроек есть, а конвейер их не читает: выключенный уровень всё равно уходит платной модели, и настройка ничего не экономит.
|
||||||
- [✨ Отправлять готовый текст через apprise и ntfy](items/ntfy-delivery.md) — Пользователь веба узнаёт о готовности только опросом с открытого экрана.
|
- [✨ Отправлять готовый текст через apprise и ntfy](items/ntfy-delivery.md) — Пользователь веба узнаёт о готовности только опросом с открытого экрана.
|
||||||
- [✨ Слать готовый текст на почту из учётной записи](items/email-notification.md) — Адрес почты приходит вместе с входом через OIDC, но почтового отправителя в сервисе нет.
|
- [✨ Слать готовый текст на почту из учётной записи](items/email-notification.md) — Адрес почты приходит вместе с входом через OIDC, но почтового отправителя в сервисе нет.
|
||||||
- [✨ Считать объём, минуты и расход по каждому пользователю](items/usage-accounting.md) — Ни объём, ни длительность, ни обращения к платным сервисам никуда не записываются: восстановить расход задним числом не из чего.
|
|
||||||
- [✨ Сделать страницу статистики для владельца](items/admin-stats-screen.md) — Собранный учёт читается только запросом к базе руками: ни страницы, ни признака владельца в приложении нет.
|
|
||||||
- [🔬 Потолки SpeechKit по длине записи и по формату](items/speechkit-limits.md) — Потолок длины записи и перечень принимаемых форматов неизвестны, а цель про долгие записи без них не начинается.
|
- [🔬 Потолки SpeechKit по длине записи и по формату](items/speechkit-limits.md) — Потолок длины записи и перечень принимаемых форматов неизвестны, а цель про долгие записи без них не начинается.
|
||||||
- [🔬 Потолки приёма, конвертации и заливки по длине записи](items/intake-limits-measure.md) — Из пяти звеньев задача speechkit-limits замерила только модель распознавания: где отваливается шестичасовая запись до неё, неизвестно.
|
- [🔬 Потолки приёма, конвертации и заливки по длине записи](items/intake-limits-measure.md) — Из пяти звеньев задача speechkit-limits замерила только модель распознавания: где отваливается шестичасовая запись до неё, неизвестно.
|
||||||
- [✨ Отклонять на приёме запись сверх потолка](items/reject-oversized-recording.md) — Запись сверх потолка принимается молча и висит в конвейере до истечения часового захвата, а человек всё это время ждёт текста.
|
- [✨ Отклонять на приёме запись сверх потолка](items/reject-oversized-recording.md) — Запись сверх потолка принимается молча и висит в конвейере до истечения часового захвата, а человек всё это время ждёт текста.
|
||||||
- [✨ Резать длинную запись на фрагменты и продолжать с места остановки](items/long-audio-chunking.md) — Шаг конвейера повторяется целиком: перезапуск на пятом часу шестичасовой записи начинает распознавание заново и оплачивает его второй раз.
|
- [✨ Резать длинную запись на фрагменты и продолжать с места остановки](items/long-audio-chunking.md) — Шаг конвейера повторяется целиком: перезапуск на пятом часу шестичасовой записи начинает распознавание заново и оплачивает его второй раз.
|
||||||
- [✨ Отдавать текст в сотни килобайт файлом, а не сотней сообщений](items/long-text-delivery.md) — Отправитель Telegram режет текст по 4000 знаков: расшифровка шестичасовой записи придёт сотней сообщений подряд.
|
- [✨ Отдавать текст в сотни килобайт файлом, а не сотней сообщений](items/long-text-delivery.md) — Отправитель Telegram режет текст по 4000 знаков: расшифровка шестичасовой записи придёт сотней сообщений подряд.
|
||||||
- [🔬 Загрузка большого файла частями](items/chunked-upload-choice.md) — Гигабайтный файл едет одним запросом, и обрыв на девяноста процентах начинает его заново.
|
- [🔬 Перечень форматов, которые конвейер принимает на самом деле](items/audio-format-coverage-measure.md) — Команда ffmpeg проверена на голосовых Telegram, а что она берёт помимо них, не мерил никто: перечень выведен из документации, а не из прогона.
|
||||||
- [🧹 Покрыть тестами разбор вывода ffprobe](items/metaviewer-adapter-tests.md) — Проверки приёма перестали звать настоящий ffprobe 2026-08-11, а своего теста у адаптера метаданных нет: разбор JSON и отличие «программы нет в PATH» от «обработка отказала» не проверяет ничто.
|
- [✨ Принимать видео и брать из него звуковую дорожку](items/video-audio-track-intake.md) — Запись семейного архива приходит видеофайлом, а приём смотрит на аудио: человеку приходится доставать дорожку самому.
|
||||||
- [🧹 Покрыть тестами шаги конвейера и захват задачи](items/pipeline-step-tests.md) — Тестовых файлов в проекте два, и оба мимо конвейера: потеря ссылки на файл, двойной ответ пользователю и гонка при захвате не поймаются ничем.
|
|
||||||
- [🧹 Разобрать мелочи http-транспорта](items/http-transport-nits.md) — Маршруты зарегистрированы дважды, и переименование пути в main.go проходит проверки зелёным; обработчик пишет в журнал через стандартный log и дублирует запись, уже сделанную сервисом.
|
|
||||||
- [🧹 Переименовать образец конфига в config.example.toml](items/config-example-toml.md) — Конвенция называет config.dist.toml объявленным расхождением, но тут же пишет это имя как правило — документ противоречит сам себе, а образец расходится с конвенцией.
|
|
||||||
- [🔬 Стоит ли брать OpenTelemetry вместо голого Prometheus](items/opentelemetry-fit.md) — Метрик одиннадцать штук на пять счётчиков, трассировки нет вовсе: путь одной записи по конвейеру собирается только чтением логов глазами.
|
- [🔬 Стоит ли брать OpenTelemetry вместо голого Prometheus](items/opentelemetry-fit.md) — Метрик одиннадцать штук на пять счётчиков, трассировки нет вовсе: путь одной записи по конвейеру собирается только чтением логов глазами.
|
||||||
- [🧹 Обновить Go до 1.26 и сверять версию шагом гейта](items/go-1-26-upgrade.md) — Модуль объявляет go 1.25.0, образ собирается на golang:1.25-alpine, на машине разработки стоит 1.26.5, и расхождение этих чисел не ловит ни один шаг гейта: разъехавшийся Dockerfile прошёл весь конвейер зелёным.
|
- [✨ Собирать путь одной записи по конвейеру запросом](items/job-path-by-request.md) — Звенья пути связаны только идентификатором задачи в строках журнала: чтобы понять, где запись провела минуты, владелец читает логи контейнера глазами.
|
||||||
- [🧹 Ловить уязвимости в зависимостях шагом гейта](items/gate-dependency-vulnerabilities.md) — govulncheck находит две достижимые уязвимости в клиентах Yandex, а ни гейт, ни список «чего в гейте нет» о нём не знают: узнать о третьей будет неоткуда.
|
- [✨ Считать вызовы, отказы и длительность по каждому внешнему сервису](items/external-service-metrics.md) — Ни у Telegram, ни у Object Storage, ни у SpeechKit нет ни одной метрики: отказ внешнего сервиса виден только строкой в журнале контейнера.
|
||||||
- [🧹 Считать покрытие изменённых строк шагом гейта](items/gate-changed-lines-coverage.md) — Свойство «изменённое место покрыто хоть одним тестом» записано в docs/review.md, но не механизировано: за две задачи подряд непокрытые шаги ловили руками.
|
- [✨ Показывать метрикой задачу, застрявшую в состоянии](items/stalled-pipeline-metric.md) — Вставший конвейер неотличим от простоя: возраст задачи в состоянии не считается, и очередь без движения выглядит как отсутствие работы.
|
||||||
- [🧹 Прервать шаг конвейера отменой контекста](items/context-cancel-in-pipeline.md) — Воркер читает ctx только между итерациями: остановка контейнера ждёт конца шага, а на занятом писателе один запрос к хранилищу держится до 9,5 секунды при мягком таймауте в 5.
|
- [✨ Оповещать владельца об отказе, не дожидаясь жалобы](items/owner-alerting.md) — Об отказе владелец узнаёт от пользователя: правил оповещения нет ни на одной метрике, а метрики читают глазами.
|
||||||
- [🧹 Разобрать мелочи слоя хранилища](items/storage-layer-nits.md) — Три мелочи ниже потолка триажа: цикл воркера пишет потерю захвата уровнем ERROR и считает её отказом, тип ошибки заведён там, где конвенция просит sentinel, а FileName несёт два разных смысла.
|
|
||||||
- [🔬 Уведомление SpeechKit о готовности вместо опроса](items/speechkit-callback-fit.md) — Шаг проверки дёргает операцию раз в 5 секунд всё время распознавания: часовая запись даёт порядка 720 обращений к платному сервису вместо одного ответа.
|
- [🔬 Уведомление SpeechKit о готовности вместо опроса](items/speechkit-callback-fit.md) — Шаг проверки дёргает операцию раз в 5 секунд всё время распознавания: часовая запись даёт порядка 720 обращений к платному сервису вместо одного ответа.
|
||||||
- [🔬 Адрес объекта в тексте отказа SpeechKit](items/speechkit-error-text-leak.md) — Текст отказа операции приходит от Yandex и уезжает в журнал и в колонку error_text: если он несёт URI объекта, из журнала снова собирается ссылка на чужую запись.
|
- [✨ Считать объём, минуты и расход по каждому пользователю](items/usage-accounting.md) — Ни объём, ни длительность, ни обращения к платным сервисам никуда не записываются: восстановить расход задним числом не из чего.
|
||||||
- [✨ Проигрывать загруженную запись на экране записи](items/play-recording-in-app.md) — Послушать загруженное приложение не даёт, а самой копии для этого у задачи нет: указатель на файл перезаписывается на каждом шаге конвейера и у готовой задачи ведёт на объект в Object Storage.
|
- [✨ Сделать страницу статистики для владельца](items/admin-stats-screen.md) — Собранный учёт читается только запросом к базе руками: ни страницы, ни признака владельца в приложении нет.
|
||||||
|
- [🧹 Закрепить версию рантайм-базы образа](items/pin-runtime-image-base.md) — Финальный слой Dockerfile собирается на alpine:latest, а task image идёт с --pull, поэтому два образа из одного коммита с разницей в неделю несут разный ffmpeg — регрессия конвертации после такой пересборки выглядит как задачи в failed при пустом диффе репозитория, и откат на прежний коммит её не чинит.
|
||||||
|
- [🧹 Настроить конвейер ревью по итогам прогона go-1-26-upgrade](items/review-config-from-go-upgrade.md) — Прогон вскрыл две прорехи настройки: «Типовые узлы» знают только рантайм и не знают рода «проверочный шаг набора проверок», а «Триггеры метки» не видят оси «изменение трогает канон» — и именно она дала обе блокирующие находки.
|
||||||
|
- [🐞 Починить срок сессии, который ставит откат шага входа](items/rollback-restores-wrong-session-duration.md) — Константа defaultAuthTokenDuration в шаге 202608120001 названа умолчанием библиотеки, но 1209600 — это 14 суток, а умолчание PocketBase 432000, пять суток: откат объявляет возврат к умолчанию и ставит срок вдвое больше выбранных владельцем семи.
|
||||||
|
- [🧹 Проверить шаг гейта migrations так же, как шаг сверки версий Go](items/migrations-step-norm-and-tests.md) — Шаг охраняет critical-инвариант «применённый шаг схемы не переписывается», но своих проверок не имеет: дрейф шаблона имени, переезд каталога или потеря grep в конвейере оставят его вечно зелёным, и это не заметит ничто.
|
||||||
|
- [🧹 Запретить обращаться к Bot API мимо клиента бота](items/bot-api-only-through-bot-client.md) — Чистка отказа от адреса с токеном живёт в клиенте; свой http.Client в транспорте вернёт утечку молча — правило noctx такую подмену не ловит, а класс уже стоил одного дефекта.
|
||||||
|
- [🔬 Шаги гейта, у которых правило может потерять предмет](items/gate-steps-subject-guard.md) — У шага migrations страж предмета есть, у шагов docs, tasks и openspec неизвестно: они зовут чужие скрипты из плагинов, и правило, потерявшее файлы, зеленело бы молча.
|
||||||
|
- [🧹 Свести шесть расхождений между документами канона](items/docs-consistency-2026-08-13.md) — Сверка 2026-08-13 нашла шесть мест, где два документа отвечают на один вопрос по-разному; четыре из них в architecture.md, и по ним читатель строит решения о выкладке и о периметре.
|
||||||
- [🔬 Квота по общему размеру загруженного на пользователя](items/per-user-size-quota.md) — Паспорт и security.md запрещают отказы по квоте пользователю, а заметка владельца просит квоту по умолчанию 5 ГБ — открытое противоречие с границей домена, которое владелец решил не разбирать сейчас.
|
- [🔬 Квота по общему размеру загруженного на пользователя](items/per-user-size-quota.md) — Паспорт и security.md запрещают отказы по квоте пользователю, а заметка владельца просит квоту по умолчанию 5 ГБ — открытое противоречие с границей домена, которое владелец решил не разбирать сейчас.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Входящие
|
||||||
|
|
||||||
|
Сырые заметки до разбора. Разбирает владелец; разобранное уезжает задачами и
|
||||||
|
здесь не остаётся.
|
||||||
+3
-3
@@ -25,15 +25,15 @@
|
|||||||
- [🎯 Сервисом пользуются несколько человек, и записи одного не видны другому](items/multi-user.md) — У задачи нет владельца, а HTTP API открыт наружу без аутентификации: пригласить второго человека сейчас значит открыть ему чужие расшифровки.
|
- [🎯 Сервисом пользуются несколько человек, и записи одного не видны другому](items/multi-user.md) — У задачи нет владельца, а HTTP API открыт наружу без аутентификации: пригласить второго человека сейчас значит открыть ему чужие расшифровки.
|
||||||
- [🎯 Записи загружаются и читаются в приложении, которое ставится на телефон](items/web-access.md) — Сегодня записи принимает только бот и голый HTTP API без интерфейса: отдать сервис человеку, у которого нет Telegram, нечем.
|
- [🎯 Записи загружаются и читаются в приложении, которое ставится на телефон](items/web-access.md) — Сегодня записи принимает только бот и голый HTTP API без интерфейса: отдать сервис человеку, у которого нет Telegram, нечем.
|
||||||
- [🎯 Загрузка большого файла доходит до сервиса и не повторяется впустую](items/upload-reliability.md) — Приём рассчитан на голосовое в пару мегабайт: обрыв на середине гигабайтного файла начинает загрузку заново, а один и тот же файл распознаётся повторно за наши деньги.
|
- [🎯 Загрузка большого файла доходит до сервиса и не повторяется впустую](items/upload-reliability.md) — Приём рассчитан на голосовое в пару мегабайт: обрыв на середине гигабайтного файла начинает загрузку заново, а один и тот же файл распознаётся повторно за наши деньги.
|
||||||
|
- [🎯 Человек убирает свою запись из архива вместе со всеми текстами](items/data-ownership.md) — Хранение бессрочное, а способа убрать запись нет ни одного: ошибочно загруженный файл и разговор, который человек не хочет держать у нас, остаются навсегда.
|
||||||
- [🎯 Пользователь настраивает, что сервис делает с его записями](items/user-settings.md) — Уровни текста считает платная модель, а уведомления приходят одним общим способом: отказаться от лишнего и выбрать свой канал пользователю нечем.
|
- [🎯 Пользователь настраивает, что сервис делает с его записями](items/user-settings.md) — Уровни текста считает платная модель, а уведомления приходят одним общим способом: отказаться от лишнего и выбрать свой канал пользователю нечем.
|
||||||
|
- [🎯 Приложение показывает, о чём запись, не читая её целиком](items/text-insights.md) — Расшифровка часового разговора — это стена текста: найти в списке нужную запись и вспомнить, о чём она, сегодня нечем.
|
||||||
- [🎯 Пользователь узнаёт о готовности текста, не держа приложение открытым](items/ready-notification.md) — Расшифровка занимает минуты, и всё это время человек либо смотрит на экран с опросом статуса, либо забывает вернуться.
|
- [🎯 Пользователь узнаёт о готовности текста, не держа приложение открытым](items/ready-notification.md) — Расшифровка занимает минуты, и всё это время человек либо смотрит на экран с опросом статуса, либо забывает вернуться.
|
||||||
|
|
||||||
## Направления
|
## Направления
|
||||||
|
|
||||||
- [🎯 Принимается запись любого формата, включая дорожку из видео](items/any-audio-source.md) — Конвертер вызывается одной командой ffmpeg, проверенной на голосовых Telegram; что он берёт помимо них, никто не мерил.
|
|
||||||
- [🎯 Запись длиной до шести часов доходит до текста](items/long-recordings.md) — Потолок не замерен ни на одном звене: Telegram не отдаёт больше 20 МиБ, границы модели deferred-general неизвестны, а перезапуск на середине начинает распознавание заново.
|
- [🎯 Запись длиной до шести часов доходит до текста](items/long-recordings.md) — Потолок не замерен ни на одном звене: Telegram не отдаёт больше 20 МиБ, границы модели deferred-general неизвестны, а перезапуск на середине начинает распознавание заново.
|
||||||
- [🎯 Приложение показывает, о чём запись, не читая её целиком](items/text-insights.md) — Расшифровка часового разговора — это стена текста: найти в списке нужную запись и вспомнить, о чём она, сегодня нечем.
|
- [🎯 Принимается запись любого формата, включая дорожку из видео](items/any-audio-source.md) — Конвертер вызывается одной командой ffmpeg, проверенной на голосовых Telegram; что он берёт помимо них, никто не мерил.
|
||||||
- [🎯 Человек убирает свою запись из архива вместе со всеми текстами](items/data-ownership.md) — Хранение бессрочное, а способа убрать запись нет ни одного: ошибочно загруженный файл и разговор, который человек не хочет держать у нас, остаются навсегда.
|
|
||||||
|
|
||||||
## Сопровождение
|
## Сопровождение
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# ✨ Сделать страницу статистики для владельца
|
# ✨ Сделать страницу статистики для владельца
|
||||||
|
|
||||||
- **Тип:** feature
|
- **Тип:** feature
|
||||||
- **Категория:** Очередь
|
- **Категория:** Очередь — Страница показывает собранный учёт: без учёта показывать нечего.
|
||||||
- **Зачем:** Собранный учёт читается только запросом к базе руками: ни страницы, ни признака владельца в приложении нет.
|
- **Зачем:** Собранный учёт читается только запросом к базе руками: ни страницы, ни признака владельца в приложении нет.
|
||||||
- **Теги:** goal:usage-stats
|
- **Теги:** goal:usage-stats
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# 🎯 Принимается запись любого формата, включая дорожку из видео
|
# 🎯 Принимается запись любого формата, включая дорожку из видео
|
||||||
|
|
||||||
- **Тип:** goal
|
- **Тип:** goal
|
||||||
- **Секция:** Направления
|
- **Секция:** Направления — Перечень форматов не замерен, и потолок длины у видео тот же, что у долгих записей: тянется следом за ними.
|
||||||
- **Зачем:** Конвертер вызывается одной командой ffmpeg, проверенной на голосовых Telegram; что он берёт помимо них, никто не мерил.
|
- **Зачем:** Конвертер вызывается одной командой ffmpeg, проверенной на голосовых Telegram; что он берёт помимо них, никто не мерил.
|
||||||
|
- **Теги:** decomposed
|
||||||
|
|
||||||
Человек отдаёт файл, не думая о том, что внутри: аудио любого распространённого
|
Человек отдаёт файл, не думая о том, что внутри: аудио любого распространённого
|
||||||
контейнера или видео, из которого нужна только речь. Подготовка на стороне
|
контейнера или видео, из которого нужна только речь. Подготовка на стороне
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# ✨ Пускать скрипты в API по личным токенам
|
# ✨ Пускать скрипты в API по личным токенам
|
||||||
|
|
||||||
- **Тип:** feature
|
- **Тип:** feature
|
||||||
- **Категория:** Очередь
|
- **Категория:** Очередь — Второй способ представиться ставится на готовые владельца и контракт, иначе форма ошибки переписывается дважды.
|
||||||
- **Зачем:** Вход через OIDC закрывает API целиком, а скрипту браузерная сессия недоступна: автоматизировать загрузку станет нечем.
|
- **Зачем:** Вход через OIDC закрывает API целиком, а скрипту браузерная сессия недоступна: автоматизировать загрузку станет нечем.
|
||||||
- **Теги:** goal:multi-user
|
- **Теги:** goal:multi-user
|
||||||
|
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
- таблица токенов: владелец, имя, отпечаток, время выпуска и последнего
|
- таблица токенов: владелец, имя, отпечаток, время выпуска и последнего
|
||||||
обращения, и её миграция;
|
обращения, и её миграция;
|
||||||
- эндпоинты выпуска, перечня и отзыва токена;
|
- эндпоинты выпуска, перечня и отзыва токена;
|
||||||
- экран настроек — место, где токен выпускают и отзывают;
|
|
||||||
- `docs/security.md` — второй способ представиться и хранение отпечатка;
|
- `docs/security.md` — второй способ представиться и хранение отпечатка;
|
||||||
- `README.md` — пример вызова API скриптом.
|
- `README.md` — пример вызова API скриптом.
|
||||||
|
|
||||||
@@ -38,4 +37,6 @@
|
|||||||
|
|
||||||
Учётные записи по-прежнему заводит Authelia — свою регистрацию не делаем.
|
Учётные записи по-прежнему заводит Authelia — свою регистрацию не делаем.
|
||||||
Сроков жизни и областей действия у токена не заводим: он даёт права владельца
|
Сроков жизни и областей действия у токена не заводим: он даёт права владельца
|
||||||
целиком. Берётся после `oidc-login`: до неё представляться некому.
|
целиком. Берётся после `oidc-login`: до неё представляться некому. Экрана выпуска здесь
|
||||||
|
нет — приложения ещё не существует, токен выпускается запросом к API; место
|
||||||
|
токена на экране настроек заводит `settings-screen`.
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# 🔬 Перечень форматов, которые конвейер принимает на самом деле
|
||||||
|
|
||||||
|
- **Тип:** research
|
||||||
|
- **Категория:** Очередь — Форматы: сначала замер того, что конвейер берёт на самом деле.
|
||||||
|
- **Зачем:** Команда ffmpeg проверена на голосовых Telegram, а что она берёт помимо них, не мерил никто: перечень выведен из документации, а не из прогона.
|
||||||
|
- **Теги:** goal:any-audio-source
|
||||||
|
|
||||||
|
Двигает пункты 1 и 4 «Завершения» цели: перечень принимаемых форматов замерен и
|
||||||
|
записан, а расхождение `ogg/vorbis` против заявленного SpeechKit `OGG_OPUS`
|
||||||
|
разобрано.
|
||||||
|
|
||||||
|
## Вопрос
|
||||||
|
|
||||||
|
Какие форматы доходят до текста целиком, какие ломаются на конвертации, какие —
|
||||||
|
на распознавании, и чем на самом деле кодирует конвертер: `vorbis` или `opus`.
|
||||||
|
|
||||||
|
## Куда ляжет ответ
|
||||||
|
|
||||||
|
- `docs/research/audio-formats.md` — таблица «формат на входе → исход», с
|
||||||
|
командой замера и версией ffmpeg, на которой он сделан;
|
||||||
|
- расхождение `ogg/vorbis` против `OGG_OPUS`: строка о том, устранено оно или
|
||||||
|
проверенно безвредно, и чем это подтверждено;
|
||||||
|
- форматы, которые принять нельзя, — задачей об отказе на приёме, с провенансом
|
||||||
|
этой разведки.
|
||||||
|
|
||||||
|
## Рамки
|
||||||
|
|
||||||
|
Замер идёт на своих файлах во временном каталоге и на подставном распознавателе
|
||||||
|
`internal/adapter/recognizer/memory.go`; прогон на реальных ключах Yandex
|
||||||
|
запрещён — там, где без настоящего SpeechKit не обойтись, ответ берётся из
|
||||||
|
задачи `speechkit-limits`, а не оплачивается заново. Видеофайлы здесь только
|
||||||
|
измеряются, приём их заводит `video-audio-track-intake`.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user