приложение собрано каркасом и вшито в бинарник
- заведён каталог web/ — Vue 3, роутер пятой версии, сборка Vite; собранное вшивается через go:embed и раздаётся корневым маршрутом: разметка на неизвестном пути вне корней сервиса, отказ контракта внутри корня - перечень корней сервиса стал единой точкой и порождает регистрацию маршрутов, а не описывает её; журнал раздачи пишет исход и длину пути, но не сам путь - шаг front зовёт Node контейнером docker — Biome, юнит-тесты Vue и сборка входят в гейт, а в Dockerfile появилась ступень приложения
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
# Что не уезжает в контекст сборки образа.
|
||||||
|
#
|
||||||
|
# Файл заведён не ради веса: без него `COPY web/ ./` кладёт каталог зависимостей
|
||||||
|
# с машины собирающего **поверх** дерева, поставленного `npm ci` в контейнере, и
|
||||||
|
# ступень собирает приложение из того, что лежит у него, а не из файла замка.
|
||||||
|
# Сборка при этом зелёная — расхождение молчаливое.
|
||||||
|
#
|
||||||
|
# `.gitignore` этого не закрывает: docker его не читает.
|
||||||
|
|
||||||
|
# Зависимости и собранное приложение — ставит и собирает сама ступень образа.
|
||||||
|
web/node_modules/
|
||||||
|
web/embed/dist/
|
||||||
|
web/*.tsbuildinfo
|
||||||
|
|
||||||
|
# Боевые и локальные данные: каталог записей и база того, кто запускал сервис
|
||||||
|
# у себя. В образе им делать нечего.
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Настройки с секретами. Образ берёт конфиг на сервере, а не из дерева.
|
||||||
|
config.toml
|
||||||
|
.env
|
||||||
|
|
||||||
|
# История репозитория: в слой сборки не нужна.
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Каталоги процесса, а не сборки.
|
||||||
|
openspec/
|
||||||
|
tasks/
|
||||||
|
docs/
|
||||||
+8
-1
@@ -54,4 +54,11 @@ config.toml
|
|||||||
# Sample and test audio files
|
# Sample and test audio files
|
||||||
*.m4a
|
*.m4a
|
||||||
*.mp3
|
*.mp3
|
||||||
*.ogg
|
*.ogg
|
||||||
|
|
||||||
|
# Приложение: зависимости и собранное. Метка `web/embed/.gitkeep` остаётся в
|
||||||
|
# git — без неё `go build ./...` отказывает у того, кто приложение не собирал.
|
||||||
|
web/node_modules/
|
||||||
|
web/embed/dist/
|
||||||
|
# Слепок проверки типов: его пишет сборка, и в git он значил бы «собрано у меня».
|
||||||
|
web/*.tsbuildinfo
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ SpeechKit и отдаёт текст тому, кто запись загруз
|
|||||||
|
|
||||||
Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), встроенная PocketBase — хранилище, файлы записей и
|
Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), встроенная PocketBase — хранилище, файлы записей и
|
||||||
панель администратора, — `aws-sdk-go-v2` для Object
|
панель администратора, — `aws-sdk-go-v2` для Object
|
||||||
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка —
|
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Приложение — Vue 3
|
||||||
|
с роутером пятой версии и сборкой Vite; собранное вшито в бинарник, проверяют
|
||||||
|
его Biome и юнит-тесты Vue. Сборка —
|
||||||
Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`.
|
Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`.
|
||||||
|
|
||||||
## Инварианты
|
## Инварианты
|
||||||
@@ -118,10 +120,15 @@ go vet ./...
|
|||||||
gofmt -l .
|
gofmt -l .
|
||||||
golangci-lint run
|
golangci-lint run
|
||||||
go run . -c config.toml # флаг -c или --config, по умолчанию config.toml
|
go run . -c config.toml # флаг -c или --config, по умолчанию config.toml
|
||||||
|
task front # приложение: зависимости, Biome, юнит-тесты, сборка
|
||||||
task image # docker-образ; тег и раскладка — docs/architecture.md
|
task image # docker-образ; тег и раскладка — docs/architecture.md
|
||||||
task gate # весь набор проверок разом
|
task gate # весь набор проверок разом
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Node на машину **не ставится**: шаг сборки приложения зовёт его контейнером, а
|
||||||
|
образ берёт из ступени `Dockerfile`. Требованием к машине разработчика поэтому
|
||||||
|
становится docker — тот же, которым собирается образ.
|
||||||
|
|
||||||
Локальный запуск требует `ffmpeg` и `ffprobe` в `PATH` и своего `config.toml` —
|
Локальный запуск требует `ffmpeg` и `ffprobe` в `PATH` и своего `config.toml` —
|
||||||
скопируй `config.example.toml` и заполни; известные прорехи образца перечислены
|
скопируй `config.example.toml` и заполни; известные прорехи образца перечислены
|
||||||
в [docs/conventions/config.md](docs/conventions/config.md) строками
|
в [docs/conventions/config.md](docs/conventions/config.md) строками
|
||||||
@@ -153,7 +160,8 @@ task gate # весь набор проверок разом
|
|||||||
Недостающий скрипт — отказ окружения, код 3. Наружу все эти коды приходят одним: сам `task` на
|
Недостающий скрипт — отказ окружения, код 3. Наружу все эти коды приходят одним: сам `task` на
|
||||||
любой отказ шага выходит с 201, а код шага печатает строкой
|
любой отказ шага выходит с 201, а код шага печатает строкой
|
||||||
(«exit status 3»), поэтому словарь читается по коду скрипта.
|
(«exit status 3»), поэтому словарь читается по коду скрипта.
|
||||||
- **Что красит безусловно и почему:** отказ сборки, тестов, `go vet`,
|
- **Что красит безусловно и почему:** красная сборка приложения, находка Biome,
|
||||||
|
красный юнит-тест приложения, отказ сборки, тестов, `go vet`,
|
||||||
гонка, найденная детектором (`go test -race`), переписанный применённый шаг
|
гонка, найденная детектором (`go test -race`), переписанный применённый шаг
|
||||||
схемы,
|
схемы,
|
||||||
неотформатированный файл, находка `golangci-lint`, расхождение объявленных
|
неотформатированный файл, находка `golangci-lint`, расхождение объявленных
|
||||||
@@ -169,7 +177,14 @@ task gate # весь набор проверок разом
|
|||||||
`gitleaks` по индексу. Только гейту остаются сборка, `go vet`, тесты целиком,
|
`gitleaks` по индексу. Только гейту остаются сборка, `go vet`, тесты целиком,
|
||||||
сверка версий Go, сверки документов и `govulncheck`: они смотрят всё
|
сверка версий Go, сверки документов и `govulncheck`: они смотрят всё
|
||||||
дерево либо требуют сети, а pre-commit обязан быть быстрым.
|
дерево либо требуют сети, а pre-commit обязан быть быстрым.
|
||||||
- **Шагу `vulns` нужна сеть**, и он один такой: база уязвимостей живёт на
|
- **Сетезависимые шаги названы здесь поимённо, и перечень этот закрыт.** Один из
|
||||||
|
них — `front`: он ставит зависимости приложения из реестра пакетов, а docker до
|
||||||
|
того тянет образ сборочного окружения. Отказ сети и реестра там — отказ окружения, код 3,
|
||||||
|
отдельно от красной сборки, у которой код 1; полный кэш установщика снимает
|
||||||
|
поход в сеть вовсе. Без короткого обращения-пробы шаг **висел** бы вместо
|
||||||
|
отказа: установщик уходит в повторы с нарастающей паузой на каждом пакете, а
|
||||||
|
гейт, который висит, хуже красного.
|
||||||
|
- **Шагу `vulns` нужна сеть** тоже: база уязвимостей живёт на
|
||||||
vuln.go.dev. Без сети шаг краснеет, а не пропускается молча; сам инструмент
|
vuln.go.dev. Без сети шаг краснеет, а не пропускается молча; сам инструмент
|
||||||
ставится `go install golang.org/x/vuln/cmd/govulncheck@latest`. Судит он
|
ставится `go install golang.org/x/vuln/cmd/govulncheck@latest`. Судит он
|
||||||
достижимость из кода: находка в модуле, чей уязвимый символ мы не вызываем,
|
достижимость из кода: находка в модуле, чей уязвимый символ мы не вызываем,
|
||||||
|
|||||||
+21
@@ -1,3 +1,20 @@
|
|||||||
|
# Front build stage
|
||||||
|
#
|
||||||
|
# Приложение собирается до бинарника: вшивание требует готового каталога.
|
||||||
|
# Имя этого образа — единственное; шаг набора проверок берёт его отсюда же,
|
||||||
|
# чтобы версия сборочного окружения не жила вторым числом в Taskfile.yml.
|
||||||
|
FROM docker.io/library/node:24-alpine AS front-build
|
||||||
|
|
||||||
|
WORKDIR /web
|
||||||
|
|
||||||
|
# Зависимости ставятся из файла замка командой, которая его не правит:
|
||||||
|
# иначе собранное в образе перестаёт совпадать с собранным в наборе проверок.
|
||||||
|
COPY web/package.json web/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY web/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
# Build stage
|
# Build stage
|
||||||
FROM docker.io/library/golang:1.26-alpine AS build-env
|
FROM docker.io/library/golang:1.26-alpine AS build-env
|
||||||
|
|
||||||
@@ -16,6 +33,10 @@ RUN go mod download
|
|||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Собранное приложение приезжает ступенью выше: в дереве сборки его нет,
|
||||||
|
# а вшивание без него отдаёт бинарник, который отвечает «приложение не собрано».
|
||||||
|
COPY --from=front-build /web/embed/dist ./web/embed/dist
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
RUN CGO_ENABLED=0 go build -o transcriber .
|
RUN CGO_ENABLED=0 go build -o transcriber .
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ tasks:
|
|||||||
gate:
|
gate:
|
||||||
desc: 'Все проверки разом. База диффа: task gate BASE=<rev>'
|
desc: 'Все проверки разом. База диффа: task gate BASE=<rev>'
|
||||||
cmds:
|
cmds:
|
||||||
|
# Приложение собирается первым: вшивание требует готового каталога, и
|
||||||
|
# `go build` без него соберёт бинарник со вчерашней сборкой.
|
||||||
|
- task: front
|
||||||
- go build ./...
|
- go build ./...
|
||||||
- go vet ./...
|
- go vet ./...
|
||||||
- |
|
- |
|
||||||
@@ -122,6 +125,79 @@ tasks:
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
front:
|
||||||
|
desc: 'Приложение: зависимости, проверки, сборка'
|
||||||
|
vars:
|
||||||
|
# Образ берётся из Dockerfile: там он объявлен ступенью сборки. Второй дом
|
||||||
|
# версии сборочного окружения разошёлся бы с первым молча, а своего шага
|
||||||
|
# сверки у него, в отличие от версий Go, нет.
|
||||||
|
NODE_IMAGE:
|
||||||
|
sh: grep -oP '^FROM \K\S*node:\S*' Dockerfile | head -1
|
||||||
|
# Кэш установщика лежит вне дерева проекта: внутри контейнера он не пережил
|
||||||
|
# бы прогон, и каждый набор проверок тянул бы зависимости заново.
|
||||||
|
NPM_CACHE: '{{.NPM_CACHE | default "/tmp/transcriber-npm-cache"}}'
|
||||||
|
cmds:
|
||||||
|
# Node на машину не ставится — он зовётся контейнером, тем же образом,
|
||||||
|
# каким собирается ступень образа. Требованием к машине остаётся docker.
|
||||||
|
- |
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
echo "docker не найден в PATH"
|
||||||
|
echo "приложение собирается контейнером: https://docs.docker.com/engine/install/"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "{{.NODE_IMAGE}}" ]; then
|
||||||
|
echo "в Dockerfile не нашлось ступени с образом node"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "{{.NPM_CACHE}}"
|
||||||
|
|
||||||
|
run() {
|
||||||
|
docker run --rm \
|
||||||
|
-u "$(id -u):$(id -g)" \
|
||||||
|
-e npm_config_cache=/npmcache \
|
||||||
|
-v "{{.NPM_CACHE}}:/npmcache" \
|
||||||
|
-v "$PWD/web:/web" \
|
||||||
|
-w /web \
|
||||||
|
"{{.NODE_IMAGE}}" \
|
||||||
|
sh -c "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Зависимости ставятся из файла замка командой, которая его не правит:
|
||||||
|
# иначе набор проверок пачкал бы рабочее дерево, а собранное им
|
||||||
|
# расходилось бы с собранным в образе.
|
||||||
|
#
|
||||||
|
# Сперва — установка из кэша, без единого обращения наружу: кэш лежит
|
||||||
|
# вне дерева проекта и переживает прогоны, поэтому обычный случай сети
|
||||||
|
# не требует вовсе.
|
||||||
|
if ! run 'npm ci --offline' >/dev/null 2>&1; then
|
||||||
|
# Кэша не хватило — значит нужна сеть, и её наличие проверяется одним
|
||||||
|
# коротким обращением. Без этой проверки установщик уходит в повторы с
|
||||||
|
# нарастающей паузой и **висит на каждом пакете**: гейт, который висит,
|
||||||
|
# хуже красного — он не даёт ни исхода, ни причины.
|
||||||
|
if ! run 'npm ping --fetch-timeout=15000 --fetch-retries=0' >/dev/null 2>&1; then
|
||||||
|
echo "реестр пакетов недоступен"
|
||||||
|
echo "шагу нужна сеть: он ставит зависимости приложения и тянет образ"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Сеть здесь уже заведомо есть — проба реестра прошла. Значит всякий
|
||||||
|
# отказ установки это отказ проекта: замок разошёлся с package.json,
|
||||||
|
# пакет снят из реестра, сломался его postinstall. По словарю кодов
|
||||||
|
# это дрейф, а не окружение: код 3 отправил бы человека чинить docker
|
||||||
|
# и сеть вместо `git diff web/package-lock.json`.
|
||||||
|
if ! run 'npm ci'; then
|
||||||
|
echo "зависимости приложения не установились, а реестр доступен"
|
||||||
|
echo "смотри расхождение web/package-lock.json с web/package.json"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
run 'npm run check && npm run test && npm run build'
|
||||||
|
|
||||||
shell:
|
shell:
|
||||||
desc: 'shellcheck на скрипты оболочки'
|
desc: 'shellcheck на скрипты оболочки'
|
||||||
cmds:
|
cmds:
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Node зовётся контейнером, а не ставится на машину разработчика
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-15
|
||||||
|
- **Источник:** [../../openspec/changes/archive/2026-08-15-spa-skeleton/design.md](../../openspec/changes/archive/2026-08-15-spa-skeleton/design.md),
|
||||||
|
раздел «Node не ставится на машину, а зовётся контейнером»
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Шаг сборки приложения гоняет установщик пакетов и сборщик **внутри контейнера**,
|
||||||
|
а не вызывает их из `PATH`:
|
||||||
|
|
||||||
|
> Требованием к машине разработчика становится docker, которым и так собирается
|
||||||
|
> образ, — второго устанавливаемого окружения сверх `ffmpeg` не появляется
|
||||||
|
> вовсе.
|
||||||
|
|
||||||
|
Образ сборочного окружения берётся из ступени `Dockerfile`, а не объявляется
|
||||||
|
вторым числом в `Taskfile.yml`.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Довод в дизайне назван прямо:
|
||||||
|
|
||||||
|
> Так снимается расхождение, которое иначе завелось бы молча: версия Node на
|
||||||
|
> машине разработчика и версия в образе — два разных числа, и собранное ими
|
||||||
|
> приложение различается ровно тогда, когда различаются они.
|
||||||
|
|
||||||
|
Отвергнуты два очевидных подхода, и оба с названной ценой. **Поставить Node на
|
||||||
|
машину** — вводит второе устанавливаемое окружение и разъезжается с версией в
|
||||||
|
образе. **Дать выбор — контейнер или локальный Node** — это второй способ делать
|
||||||
|
одно и то же, и собранное ими различалось бы в зависимости от того, у кого что
|
||||||
|
стоит.
|
||||||
|
|
||||||
|
## Почему это ADR
|
||||||
|
|
||||||
|
Запись проходит триггер **намеренным отказом** от очевидного подхода: поставить
|
||||||
|
Node на машину — ровно то, что делают по умолчанию, и отказ от этого надо
|
||||||
|
объяснить один раз, а не на каждом вопросе «почему у нас нельзя просто
|
||||||
|
`npm run build`».
|
||||||
|
|
||||||
|
## Что это меняет в прежнем решении
|
||||||
|
|
||||||
|
[ADR-2026-08-11-spa-on-vue](ADR-2026-08-11-spa-on-vue.md) записал последствием,
|
||||||
|
что «машина разработчика получает второе требуемое окружение сверх `ffmpeg`», и
|
||||||
|
подразумевал под ним Node. Окружением оказался **docker**. Сам выбор фреймворка и
|
||||||
|
наличие шага сборки это не пересматривает, поэтому статуса «заменено на» у той
|
||||||
|
записи нет: заменена не она, а толкование одного её последствия.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` версия сборочного окружения живёт **одним** местом — ступенью
|
||||||
|
`Dockerfile`, — и своего шага сверки ей не нужно.
|
||||||
|
- `+` собранное в наборе проверок и собранное в образе совпадает, потому что
|
||||||
|
совпадает окружение сборки, а не потому что «обычно совпадает».
|
||||||
|
- `−` **набор проверок перестаёт работать без docker**, и отказ этот приходит
|
||||||
|
кодом окружения. Тем же кодом приходит отказ реестра пакетов: сетезависимых
|
||||||
|
шагов в наборе становится два вместо одного.
|
||||||
|
- `−` контейнер ходит под тем же пользователем, что и вызвавший, а кэш
|
||||||
|
установщика уводится наружу — обе частности обязательны: без них собранное
|
||||||
|
ляжет от `root`, а зависимости будут тянуться заново каждый прогон.
|
||||||
|
- `−` **вес и время самой ступени в образе неизвестны**: финальный образ от неё
|
||||||
|
не растёт (ступень в рабочий слой не копируется), а время сборки решением
|
||||||
|
владельца от 2026-08-15 не замеряется вовсе.
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
|
|
||||||
| Дата | Запись | Статус |
|
| Дата | Запись | Статус |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| 2026-08-15 | [Node зовётся контейнером, а не ставится на машину разработчика](ADR-2026-08-15-node-in-container-not-on-machine.md) | |
|
||||||
| 2026-08-15 | [Приложение живёт своим пространством адресов, а не общим с хранилищем](ADR-2026-08-15-app-namespace.md) | |
|
| 2026-08-15 | [Приложение живёт своим пространством адресов, а не общим с хранилищем](ADR-2026-08-15-app-namespace.md) | |
|
||||||
| 2026-08-15 | [Страница архива задаётся ключом, а не номером](ADR-2026-08-15-cursor-paging.md) | |
|
| 2026-08-15 | [Страница архива задаётся ключом, а не номером](ADR-2026-08-15-cursor-paging.md) | |
|
||||||
| 2026-08-15 | [Длительность и размер — снимок принятого колонками записи](ADR-2026-08-15-record-snapshot-columns.md) | |
|
| 2026-08-15 | [Длительность и размер — снимок принятого колонками записи](ADR-2026-08-15-record-snapshot-columns.md) | |
|
||||||
|
|||||||
+26
-3
@@ -45,6 +45,11 @@
|
|||||||
Здесь же обязанность, переехавшая с убранного опроса готовности: причину
|
Здесь же обязанность, переехавшая с убранного опроса готовности: причину
|
||||||
остановки владелец записи узнаёт карточкой. Задача `json-api-for-spa`
|
остановки владелец записи узнаёт карточкой. Задача `json-api-for-spa`
|
||||||
2026-08-15;
|
2026-08-15;
|
||||||
|
- [webapp](../openspec/specs/webapp/spec.md) — **приложение в браузере**: чем
|
||||||
|
сервис его отдаёт, каким адресом оно открывается, что делает обновление
|
||||||
|
страницы посреди него и что человек видит, открыв его. Здесь же правило
|
||||||
|
неизвестного пути — разметка вне корней сервиса, отказ внутри, — срок хранения
|
||||||
|
ответов и то, что раздача пишет в журнал. Задача `spa-skeleton` 2026-08-15;
|
||||||
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
|
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
|
||||||
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
|
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
|
||||||
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
|
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
|
||||||
@@ -97,6 +102,8 @@
|
|||||||
| Репозитории | `internal/adapter/repo/pocketbase` | Записи, файлы, тексты, структура, попытки распознавания и журнал событий — коллекциями хранилища; захват — сырым запросом |
|
| Репозитории | `internal/adapter/repo/pocketbase` | Записи, файлы, тексты, структура, попытки распознавания и журнал событий — коллекциями хранилища; захват — сырым запросом |
|
||||||
| Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций |
|
| Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций |
|
||||||
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки записи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» |
|
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки записи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» |
|
||||||
|
| Приложение | `web/` | Vue 3, роутер пятой версии, сборка Vite. Собранное лежит в `web/embed/dist` и вшивается в бинарник; в git его нет |
|
||||||
|
| Раздача приложения | `internal/controller/http`, `webapp.go` | Корневой маршрут: разметка вне корней сервиса, отказ внутри, срок хранения по каталогу сборщика |
|
||||||
|
|
||||||
Цепочка рубежей — `uploaded` → `normalized` → `submitted` → `transcribed` →
|
Цепочка рубежей — `uploaded` → `normalized` → `submitted` → `transcribed` →
|
||||||
`done`; рубеж называет достигнутое, а не предстоящее, и нормирует его
|
`done`; рубеж называет достигнутое, а не предстоящее, и нормирует его
|
||||||
@@ -115,6 +122,11 @@
|
|||||||
асинхронное: запрос возвращает идентификатор операции, готовность опрашивается
|
асинхронное: запрос возвращает идентификатор операции, готовность опрашивается
|
||||||
через `operation.api.cloud.yandex.net:443`, текст читается потоком.
|
через `operation.api.cloud.yandex.net:443`, текст читается потоком.
|
||||||
- **ffmpeg и ffprobe.** Внешние процессы, ищутся в `PATH`.
|
- **ffmpeg и ffprobe.** Внешние процессы, ищутся в `PATH`.
|
||||||
|
- **Node и его установщик пакетов.** Нужны только сборке приложения и на машину
|
||||||
|
не ставятся: шаг зовёт их контейнером, а образ берёт из ступени `Dockerfile`.
|
||||||
|
Требованием к машине разработчика поэтому становится docker. Реестр пакетов —
|
||||||
|
сетезависимый адрес набора проверок; все такие перечислены в
|
||||||
|
[CLAUDE.md](../CLAUDE.md), «Гейт».
|
||||||
|
|
||||||
## Эксплуатация
|
## Эксплуатация
|
||||||
|
|
||||||
@@ -187,6 +199,7 @@
|
|||||||
| Отображение доменной ошибки в ответ | `internal/controller/http.mapDomainError` — код, машиночитаемый код отказа и сообщение человеку; ветвь по умолчанию определена, новая ветвь заводится добавлением сюда. Отказы, рождённые слоями библиотеки (предел тела, ограничитель частоты, неизвестный путь), к той же форме приводит слой `OneErrorForm`, стоящий снаружи всех прочих |
|
| Отображение доменной ошибки в ответ | `internal/controller/http.mapDomainError` — код, машиночитаемый код отказа и сообщение человеку; ветвь по умолчанию определена, новая ветвь заводится добавлением сюда. Отказы, рождённые слоями библиотеки (предел тела, ограничитель частоты, неизвестный путь), к той же форме приводит слой `OneErrorForm`, стоящий снаружи всех прочих |
|
||||||
| Состояния отбора списка | `internal/entity.ListFilter` вместе с `WorkingStages` и `TerminalStages` — предикаты выводятся из дескриптора рубежа, а не пишутся строкой запроса |
|
| Состояния отбора списка | `internal/entity.ListFilter` вместе с `WorkingStages` и `TerminalStages` — предикаты выводятся из дескриптора рубежа, а не пишутся строкой запроса |
|
||||||
| Уборка имени файла отправителя | `internal/entity.SanitizeOriginalFilename` — режет по пределу и убирает управляющие знаки; зовёт её приём |
|
| Уборка имени файла отправителя | `internal/entity.SanitizeOriginalFilename` — режет по пределу и убирает управляющие знаки; зовёт её приём |
|
||||||
|
| Адресное пространство сервиса | `internal/controller/http.ServiceMounts` — перечень корней и адресов наблюдения. Он **порождает** регистрацию наших маршрутов, а не описывает её, и из него же выводятся правило неизвестного пути и уровень журнала |
|
||||||
|
|
||||||
Единых точек, которых **нет** и которые ожидались бы: идентификаторы
|
Единых точек, которых **нет** и которые ожидались бы: идентификаторы
|
||||||
генерируются вызовом `uuid.NewString()` по месту. Время из этого перечня ушло
|
генерируются вызовом `uuid.NewString()` по месту. Время из этого перечня ушло
|
||||||
@@ -201,8 +214,15 @@
|
|||||||
образ едет на сервер через `docker save`/`load`. Выкладку целиком запускает человек командой
|
образ едет на сервер через `docker save`/`load`. Выкладку целиком запускает человек командой
|
||||||
`inv pl -- transcriber` из `pet-project-server`.
|
`inv pl -- transcriber` из `pet-project-server`.
|
||||||
|
|
||||||
Сборка двухступенчатая, финальный слой — alpine с `ca-certificates` и `ffmpeg`,
|
Сборка трёхступенчатая: приложение, бинарник, рабочий слой. Приложение
|
||||||
процесс работает под непривилегированным пользователем `transcriber`.
|
собирается первым — вшивание требует готового каталога, — а в рабочий слой Node
|
||||||
|
не попадает. Финальный слой — alpine с `ca-certificates` и `ffmpeg`, процесс
|
||||||
|
работает под непривилегированным пользователем `transcriber`.
|
||||||
|
|
||||||
|
**По весу финальный образ от ступени приложения не растёт вовсе:** она отдаёт
|
||||||
|
следующей только собранное, а сама в рабочий слой не копируется. Вшитое
|
||||||
|
приложение прибавляет к бинарнику 86 072 байта. Время сборки образа не
|
||||||
|
замерялось и замеряться не будет — решение владельца от 2026-08-15.
|
||||||
|
|
||||||
## Открытые вопросы
|
## Открытые вопросы
|
||||||
|
|
||||||
@@ -217,7 +237,10 @@
|
|||||||
зависит возвращение убранного входа.
|
зависит возвращение убранного входа.
|
||||||
Панель администратора при этом Authelia не закрывает: у неё свой пароль
|
Панель администратора при этом Authelia не закрывает: у неё свой пароль
|
||||||
суперпользователя.
|
суперпользователя.
|
||||||
- **Приложение.** Экранов нет вовсе, есть только API. Решено делать SPA,
|
- **Приложение.** Каркас поставлен `spa-skeleton` 2026-08-15: приложение
|
||||||
|
открывается, показывает вошедшего и вшито в бинарник. Экранов загрузки и
|
||||||
|
списка нет — их делают `upload-and-status-screen` и `records-list-screen`.
|
||||||
|
Решено делать SPA,
|
||||||
устанавливаемое на телефон, а фреймворком взят Vue 3 с роутером пятой версии и
|
устанавливаемое на телефон, а фреймворком взят Vue 3 с роутером пятой версии и
|
||||||
сборкой Vite — 2026-08-11,
|
сборкой Vite — 2026-08-11,
|
||||||
[ADR](adr/ADR-2026-08-11-spa-on-vue.md), сравнение кандидатов в
|
[ADR](adr/ADR-2026-08-11-spa-on-vue.md), сравнение кандидатов в
|
||||||
|
|||||||
@@ -130,6 +130,9 @@
|
|||||||
| Скрипты оболочки | `Taskfile.yml` → шаг `shell` (`shellcheck`), он же на pre-commit |
|
| Скрипты оболочки | `Taskfile.yml` → шаг `shell` (`shellcheck`), он же на pre-commit |
|
||||||
| Форма `Dockerfile` | `Taskfile.yml` → шаг `dockerfile` (`hadolint`), он же на 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`) |
|
| Одно число версии Go в `go.mod`, `Dockerfile`, `CLAUDE.md` и `README.md` | `Taskfile.yml` → шаг `go-version` (`scripts/check-go-version.sh`) |
|
||||||
|
| Форматирование и статический анализ кода приложения | `web/biome.json` → Biome, зовётся шагом `front` командой `npm run check`. Разбирает и однофайловые компоненты; правила — набор `recommended` плюс своя форма (одинарные кавычки, точка с запятой по необходимости) |
|
||||||
|
| Типы разметки и кода приложения | `vue-tsc`, и он входит в **команду сборки**, а не стоит отдельным шагом: несобираемое приложение и непроверенные типы — один отказ |
|
||||||
|
| Поведение экранов приложения | `Taskfile.yml` → шаг `front`, юнит-тесты Vue (`npm run test`). Без них требование «приложение показывает вошедшего» не проверял бы никто, а набор проверок оставался бы зелёным на сломанном экране |
|
||||||
|
|
||||||
### Хранилище, документы, секреты, зависимости
|
### Хранилище, документы, секреты, зависимости
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ OpenSpec.
|
|||||||
|
|
||||||
- Доменные поля — плоский `snake_case`.
|
- Доменные поля — плоский `snake_case`.
|
||||||
- Системные домены — точечная иерархия (по образцу OpenTelemetry): `http.*`,
|
- Системные домены — точечная иерархия (по образцу OpenTelemetry): `http.*`,
|
||||||
`ext.*`.
|
`ext.*`, `webapp.*`.
|
||||||
- JSON плоский: все поля на верхнем уровне, без вложенности.
|
- JSON плоский: все поля на верхнем уровне, без вложенности.
|
||||||
|
|
||||||
| Когда добавляем | Поля |
|
| Когда добавляем | Поля |
|
||||||
@@ -103,6 +103,8 @@ OpenSpec.
|
|||||||
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` |
|
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` |
|
||||||
| на запись об ошибке | `error` |
|
| на запись об ошибке | `error` |
|
||||||
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
|
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
|
||||||
|
| на запрос, отданный приложению | `webapp.outcome` (`markup`, `asset`, `failure` — перечень закрыт), `http.path_length`. Самого пути в строке нет: его выбирает спрашивающий, и дословная запись сделала бы журнал местом, куда аноним пишет свой текст. Вместо пути в `http.route` стоит `<приложение>` |
|
||||||
|
| на подъёме сервиса | `webapp.build` — отпечаток вшитой сборки; им «не та сборка» отличается от «той» |
|
||||||
|
|
||||||
Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум.
|
Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум.
|
||||||
|
|
||||||
|
|||||||
@@ -10,17 +10,19 @@
|
|||||||
описывала htmx с прямым запретом на шаг сборки и реактивные фреймворки; она снята
|
описывала htmx с прямым запретом на шаг сборки и реактивные фреймворки; она снята
|
||||||
целиком вместе со сменой решения на SPA 2026-08-10.
|
целиком вместе со сменой решения на SPA 2026-08-10.
|
||||||
|
|
||||||
**Кода приложения ещё нет.** Правила ниже выведены из выбора и из замера на
|
Правила ниже применены каркасом приложения (`spa-skeleton`, 2026-08-15): до него
|
||||||
пробном экране, а не из написанного кода: первым их применяет и проверяет
|
они были выведены из выбора и из замера на пробном экране, а не из написанного
|
||||||
`spa-skeleton`. Место, где правило разойдётся с тем, что окажется удобным, —
|
кода. Место, где правило разойдётся с тем, что окажется удобным, — повод править
|
||||||
повод править эту запись, а не обходить её молча.
|
эту запись, а не обходить её молча.
|
||||||
|
|
||||||
Логирование запросов — [logging.md](logging.md). Трансляция доменных ошибок
|
Логирование запросов — [logging.md](logging.md). Трансляция доменных ошибок
|
||||||
наружу — [errors.md](errors.md).
|
наружу — [errors.md](errors.md).
|
||||||
|
|
||||||
**Механизировано:** типы разметки и кода проверяет `vue-tsc`, и он входит в
|
**Механизировано:** типы разметки и кода проверяет `vue-tsc`, и он входит в
|
||||||
команду сборки, а не стоит отдельным шагом. Правил линтера для кода приложения
|
команду сборки, а не стоит отдельным шагом. Форматирование и статический анализ
|
||||||
пока нет.
|
держит **Biome**, поведение экранов — **юнит-тесты Vue**; оба шага входят в набор
|
||||||
|
проверок наравне со сборкой. Инструменты зовутся контейнером, а не из `PATH`:
|
||||||
|
требованием к машине разработчика остаётся docker, а не установленный Node.
|
||||||
|
|
||||||
## Что решено про само приложение
|
## Что решено про само приложение
|
||||||
|
|
||||||
@@ -51,7 +53,14 @@
|
|||||||
делать то же самое.
|
делать то же самое.
|
||||||
- **Собранная статика неизменяема и адресуется хешем в имени.** Имена придумывает
|
- **Собранная статика неизменяема и адресуется хешем в имени.** Имена придумывает
|
||||||
Vite, руками их не задаём: от этого зависит обновление установленного
|
Vite, руками их не задаём: от этого зависит обновление установленного
|
||||||
приложения.
|
приложения. Сервис на это правило опирается, но проверить его не может — имён
|
||||||
|
он не выбирает, — поэтому дом правила здесь, а не в спеке: долгий срок
|
||||||
|
хранения он ставит **по каталогу** сборщика, и файл, положенный туда без
|
||||||
|
отпечатка в имени, останется в хранилище браузера навсегда.
|
||||||
|
- **Зависимости ставятся из файла замка командой, которая его не правит.** Иначе
|
||||||
|
набор проверок пачкает рабочее дерево, обновление зависимости приезжает в
|
||||||
|
коммит без чьего-либо решения, а собранное в наборе проверок перестаёт
|
||||||
|
совпадать с собранным в образе.
|
||||||
- **Шаг сборки входит в `task gate` и в сборку образа.** Красная сборка статики
|
- **Шаг сборки входит в `task gate` и в сборку образа.** Красная сборка статики
|
||||||
роняет гейт наравне с `go build`.
|
роняет гейт наравне с `go build`.
|
||||||
|
|
||||||
@@ -70,7 +79,14 @@
|
|||||||
`/_/` у панели, — плюс `/health` и `/metrics` отдельными адресами. Приложение
|
`/_/` у панели, — плюс `/health` и `/metrics` отдельными адресами. Приложение
|
||||||
уехало из общего `/api/` решением владельца 2026-08-15: пространство
|
уехало из общего `/api/` решением владельца 2026-08-15: пространство
|
||||||
принадлежит хранилищу, и обновление библиотеки вправе занять там имя рядом с
|
принадлежит хранилищу, и обновление библиотеки вправе занять там имя рядом с
|
||||||
нашим.
|
нашим. Перечень корней сервису не описывают, а из него **порождают**
|
||||||
|
регистрацию маршрутов: описанный порознь, он разошёлся бы с ними молча.
|
||||||
|
- **Несовпавший ресурс разметкой не подменяется.** Путь под каталогом сборщика,
|
||||||
|
которому не нашлось файла, отвечает `404`. Правило — вторая половина
|
||||||
|
предыдущего: разметка прежней сборки называет ресурсы прежней сборки, и
|
||||||
|
подменить их разметкой значит ответить `200` на то, чего нет. Браузер отвергнет
|
||||||
|
такой ответ по типу содержимого, человек увидит пустой экран, а в кодах
|
||||||
|
ответов сервиса не останется ничего.
|
||||||
- **Экран не знает, как он открыт.** Данные экран берёт по своему адресу, а не
|
- **Экран не знает, как он открыт.** Данные экран берёт по своему адресу, а не
|
||||||
получает от предыдущего: приложение открывают по ссылке и обновляют страницу
|
получает от предыдущего: приложение открывают по ссылке и обновляют страницу
|
||||||
посередине.
|
посередине.
|
||||||
@@ -107,6 +123,13 @@
|
|||||||
|
|
||||||
## Что не решено
|
## Что не решено
|
||||||
|
|
||||||
|
- **Инструмент статического анализа проверен наполовину.** Biome взят решением
|
||||||
|
владельца 2026-08-15 и разбирает однофайловые компоненты; замены он потребует,
|
||||||
|
если перестанет их держать. Тогда это отдельное решение, а не подстановка по
|
||||||
|
ходу.
|
||||||
|
- **Проверка типов держится на пятой линии TypeScript.** С седьмой `vue-tsc`
|
||||||
|
не работает: новый компилятор не отдаёт точку входа, которую тот зовёт.
|
||||||
|
Проверено прогоном 2026-08-15.
|
||||||
- **Набор компонентов и стили.** Своя разметка или готовый набор — не решено, а
|
- **Набор компонентов и стили.** Своя разметка или готовый набор — не решено, а
|
||||||
готовый способен удвоить собранный файл
|
готовый способен удвоить собранный файл
|
||||||
([research/spa-framework.md](../research/spa-framework.md), «Чего разведка не
|
([research/spa-framework.md](../research/spa-framework.md), «Чего разведка не
|
||||||
|
|||||||
@@ -328,6 +328,7 @@ capability, и третий смысл развёл бы одно слово п
|
|||||||
| Потолок длины имени файла отправителя | 255 знаков | `entity.MaxOriginalFilenameLen` | предел длины имени в распространённых файловых системах: длиннее системный диалог выбора файла не даёт |
|
| Потолок длины имени файла отправителя | 255 знаков | `entity.MaxOriginalFilenameLen` | предел длины имени в распространённых файловых системах: длиннее системный диалог выбора файла не даёт |
|
||||||
| Потолок длины расширения | 32 знака | `service/transcribe.go`, `maxExtLen` | сторож от патологии, а не перечень: расширения известных форматов укладываются в пять знаков, а `x.` с четырьмястами знаками роняет заведение временного файла |
|
| Потолок длины расширения | 32 знака | `service/transcribe.go`, `maxExtLen` | сторож от патологии, а не перечень: расширения известных форматов укладываются в пять знаков, а `x.` с четырьмястами знаками роняет заведение временного файла |
|
||||||
| Потолок тем на запись | 5 | `entity.MaxTopicsPerRecord` | решение владельца: без него часовой разговор даёт два десятка тем |
|
| Потолок тем на запись | 5 | `entity.MaxTopicsPerRecord` | решение владельца: без него часовой разговор даёт два десятка тем |
|
||||||
|
| Срок хранения ресурса приложения | 1 год | `controller/http.assetMaxAgeSeconds` | имена ресурсов несут отпечаток содержимого, поэтому ответ устареть не может; срок ставится только файлам из каталога сборщика, всё прочее браузер спрашивает заново |
|
||||||
| Потолок сохранённого ответа провайдера | 256 МиБ | шаг `202608140002` | ответ многословнее расшифровки: несёт альтернативы, время каждого слова и разбор говорящих |
|
| Потолок сохранённого ответа провайдера | 256 МиБ | шаг `202608140002` | ответ многословнее расшифровки: несёт альтернативы, время каждого слова и разбор говорящих |
|
||||||
| Потолок структуры реплик | 16 МиБ | там же | шестичасовой разговор даёт порядка мегабайта текста с временем |
|
| Потолок структуры реплик | 16 МиБ | там же | шестичасовой разговор даёт порядка мегабайта текста с временем |
|
||||||
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было |
|
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было |
|
||||||
|
|||||||
+3
-1
@@ -82,7 +82,9 @@ Telegram.
|
|||||||
|
|
||||||
## Типовые сценарии
|
## Типовые сценарии
|
||||||
|
|
||||||
Первые два — основные, и сегодня не работает ни один: приложения нет.
|
Первые два — основные, и сегодня не работает ни один. Приложение с 2026-08-15
|
||||||
|
есть, но экранов у него пока нет: оно открывается и показывает вошедшего, а
|
||||||
|
загрузку и список заводят `upload-and-status-screen` и `records-list-screen`.
|
||||||
|
|
||||||
1. **Семейный архив.** Человек открывает приложение на телефоне, выбирает до
|
1. **Семейный архив.** Человек открывает приложение на телефоне, выбирает до
|
||||||
десяти записей разом — диктофонные дорожки и видео, — и закрывает его.
|
десяти записей разом — диктофонные дорожки и видео, — и закрывает его.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, чт
|
|||||||
|
|
||||||
| Дата | Запись | О чём |
|
| Дата | Запись | О чём |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| 2026-08-15 | [Раздача приложения: что делают за нас библиотека и сборщик](webapp-serving.md) | Раскодированный путь у маршрутизатора, второй журнал у PocketBase, нулевое время у вшитого файла, зависание установщика без сети |
|
||||||
| 2026-08-13 | [Разбор TOML: какое семейство отказов несёт значения из файла](toml-decode-errors.md) | Значения только в `ParseError.Message`, врущее поле `Line`, отказ значением в BurntSushi/toml v1.5.0 |
|
| 2026-08-13 | [Разбор TOML: какое семейство отказов несёт значения из файла](toml-decode-errors.md) | Значения только в `ParseError.Message`, врущее поле `Line`, отказ значением в BurntSushi/toml v1.5.0 |
|
||||||
| 2026-08-12 | [PocketBase: умолчания, которые ломают штатный сценарий](pocketbase-defaults.md) | Потолок файла 5 МиБ, тело 32 МиБ, таймаут чтения, суффикс имени, хук правки |
|
| 2026-08-12 | [PocketBase: умолчания, которые ломают штатный сценарий](pocketbase-defaults.md) | Потолок файла 5 МиБ, тело 32 МиБ, таймаут чтения, суффикс имени, хук правки |
|
||||||
| 2026-08-11 | [gRPC-клиент SpeechKit: когда закрытие вообще может отказать](grpc-client-close.md) | Ленивое соединение и два исхода `Close` в grpc v1.74.2 |
|
| 2026-08-11 | [gRPC-клиент SpeechKit: когда закрытие вообще может отказать](grpc-client-close.md) | Ленивое соединение и два исхода `Close` в grpc v1.74.2 |
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Раздача приложения: что делают за нас библиотека и сборщик
|
||||||
|
|
||||||
|
Отвечает на вопросы, возникшие по ходу задачи `spa-skeleton`, — какие свойства
|
||||||
|
раздачи приходят не из нашего кода, а из стандартной библиотеки, из PocketBase и
|
||||||
|
из инструментов приложения. Наблюдения понадобились потому, что ревью нашло три
|
||||||
|
места, где записанное намерение расходилось с тем, что на деле делает чужой код.
|
||||||
|
|
||||||
|
## Как снималось
|
||||||
|
|
||||||
|
Прогонами на живом бинарнике (свой конфиг с выдуманными ключами, свой каталог
|
||||||
|
данных вне репозитория) и чтением исходников зависимостей, зафиксированных в
|
||||||
|
`go.mod`: `github.com/pocketbase/pocketbase` версии **v0.39.10** и стандартной
|
||||||
|
библиотеки Go. Отдельно — прогоны установщика и сборщика приложения в контейнере.
|
||||||
|
Числа ниже сняты 2026-08-15 на этом прогоне, а не взяты из чужих записок.
|
||||||
|
|
||||||
|
## Что выяснилось
|
||||||
|
|
||||||
|
- **Маршрутизатор стандартной библиотеки сравнивает сегменты пути после
|
||||||
|
раскодирования.** Поэтому `/%5f/` попадает туда же, куда `/_/`, а `/%68ealth`
|
||||||
|
— туда же, куда `/health`: ответы совпадают байт в байт. Исходная форма
|
||||||
|
остаётся в `URL.RawPath`, и решение, принимаемое **вне** сервиса по сырому пути
|
||||||
|
— правилом обратного прокси, — такой формы не видит. Цена записана в
|
||||||
|
[security.md](../security.md), «Периметр»: барьер перед панелью владельца
|
||||||
|
обходится подменой одного знака.
|
||||||
|
|
||||||
|
- **PocketBase пишет каждый запрос в свою таблицу журнала**, а не только в вывод
|
||||||
|
контейнера: слой `activityLogger` подключён ко всем маршрутам и кладёт путь
|
||||||
|
целиком
|
||||||
|
(до 3000 знаков), адрес отправителя, источник перехода и клиент. Умолчания —
|
||||||
|
хранить пять суток, адрес записывать. Готовая раздача статики
|
||||||
|
(`apis.Static`) первой же строкой ставит признак «успех не записывать»; своя
|
||||||
|
раздача этого признака не наследует, и его надо ставить руками. Отсюда правило
|
||||||
|
в [review.md](../review.md): журналов **два**, и говорить надо про оба.
|
||||||
|
|
||||||
|
- **Вшитая файловая система не несёт времени правки.** `embed.FS` отдаёт нулевое
|
||||||
|
время у любого файла, поэтому отдача файла стандартной библиотекой никогда не
|
||||||
|
отвечает подтверждением «не менялось» — всякая проверка приходит полным телом.
|
||||||
|
Заголовок, обещающий дешёвую проверку, без метки ответа обещает то, чего код не
|
||||||
|
делает.
|
||||||
|
|
||||||
|
- **Сборщик приложения чистит выходной каталог перед каждой сборкой.** Метка,
|
||||||
|
положенная рядом с собранным ради того, чтобы каталог существовал в git,
|
||||||
|
уезжает первым же прогоном. Живёт она только этажом выше выходного каталога.
|
||||||
|
|
||||||
|
- **Проверка типов однофайловых компонентов не работает с седьмой линией
|
||||||
|
TypeScript.** `vue-tsc` версии 3.3.10 зовёт у компилятора точку входа, которой
|
||||||
|
новый компилятор не отдаёт, и сборка падает на этапе проверки типов. Рабочая
|
||||||
|
пара — пятая линия TypeScript.
|
||||||
|
|
||||||
|
- **Установщик пакетов без сети не отказывает, а виснет.** Он уходит в повторы с
|
||||||
|
нарастающей паузой **на каждом пакете**, и набор проверок вместо кода отказа
|
||||||
|
просто стоит. Пределы у отдельных обращений положения не спасают: их сумма и
|
||||||
|
даёт зависание. Помогает короткое обращение-проба перед установкой.
|
||||||
|
|
||||||
|
- **Вес приложения в бинарнике равен весу собранного.** Замер: две сборки, с
|
||||||
|
собранным приложением и с пустым каталогом, разница — 86 072 байта, то есть
|
||||||
|
ровно `index.html` плюс единственный ресурс. Собранное приложение на четыре
|
||||||
|
экрана в разведке `spa-framework` весило того же порядка.
|
||||||
|
|
||||||
|
## Чего эта записка не узнала
|
||||||
|
|
||||||
|
- **Во что ступень сборки обходится образу по времени.** Прогон до конца не
|
||||||
|
доходит: из контейнеров этой машины нет исходящей сети при рабочем разрешении
|
||||||
|
имён. По весу вопрос закрыт иначе — ступень в рабочий слой не копируется, и
|
||||||
|
финальный образ от неё не растёт вовсе.
|
||||||
|
- **Как поведёт себя раздача под настоящим потоком.** Ограничителя частоты на
|
||||||
|
корневом маршруте нет, а профиля нагрузки у проекта нет тоже.
|
||||||
|
- **Что делает настоящий браузер** с этими заголовками: проверено кодами ответов
|
||||||
|
и заголовками, а не браузером.
|
||||||
@@ -58,6 +58,26 @@
|
|||||||
- переводит доменную ошибку в свой ответ, а не отдаёт сырой текст;
|
- переводит доменную ошибку в свой ответ, а не отдаёт сырой текст;
|
||||||
- закрывает то, что открыл, на всех ветках выхода.
|
- закрывает то, что открыл, на всех ветках выхода.
|
||||||
|
|
||||||
|
**Раздача собранного приложения и шаг его сборки** (`controller/http/webapp.go`,
|
||||||
|
шаг `front`):
|
||||||
|
|
||||||
|
- путь, принадлежащий корню сервиса, разметку не отдаёт никогда, а перечень
|
||||||
|
корней порождает регистрацию маршрутов, а не описывает её;
|
||||||
|
- несовпавший ресурс под каталогом сборщика отвечает `404`, а не разметкой с
|
||||||
|
кодом `200`;
|
||||||
|
- раздача ставит долгий неотзываемый срок хранения **только** файлу из каталога
|
||||||
|
сборщика: отозвать его у браузера сервису нечем;
|
||||||
|
- отсутствие сборки громкое — код ответа, страница и строка журнала; «сборки
|
||||||
|
нет» отличается от «файла нет»;
|
||||||
|
- вшито то, что собрано этим прогоном, а не то, что осталось от прошлого;
|
||||||
|
- шаг следует словарю кодов: отказ сети и реестра — 3, красная сборка — 1, и он
|
||||||
|
**отказывает, а не висит**;
|
||||||
|
- путь, выбранный анонимом, не уходит ни меткой метрики, ни строкой журнала — и
|
||||||
|
журналов **два**: свой, в вывод контейнера, и журнал хранилища, куда
|
||||||
|
библиотека кладёт путь целиком вместе с адресом отправителя. Второй молчит
|
||||||
|
только на успехе и только потому, что признак отказа от записи поставлен
|
||||||
|
руками: готовая раздача статики ставит его сама, своя — нет.
|
||||||
|
|
||||||
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`):
|
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`):
|
||||||
|
|
||||||
- имеет таймаут и не виснет, когда внешний сервис не отвечает;
|
- имеет таймаут и не виснет, когда внешний сервис не отвечает;
|
||||||
@@ -292,6 +312,26 @@ API и имя не откатываются обратной правкой по
|
|||||||
истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не
|
истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не
|
||||||
оракул, и выдумывать оракул задним числом нельзя.
|
оракул, и выдумывать оракул задним числом нельзя.
|
||||||
|
|
||||||
|
## 2026-08-15 — своя раздача статики потеряла отказ от записи успеха [пойман ревью]
|
||||||
|
|
||||||
|
- **Где:** `internal/controller/http/webapp.go`, регистрация корневого маршрута;
|
||||||
|
задача `spa-skeleton`
|
||||||
|
- **Симптом:** каждый успешный ответ разметкой и ресурсом клал в журнал
|
||||||
|
хранилища выбранный анонимом путь вместе с его адресом и держал строку пять
|
||||||
|
суток. При этом строка `docs/review.md`, добавленная той же задачей,
|
||||||
|
утверждала, что путь анонима в журнал не идёт
|
||||||
|
- **Причина:** готовая раздача статики библиотеки первой же строкой ставит
|
||||||
|
признак «успех не записывать». Своя написана мимо неё — и не зря, подстановка
|
||||||
|
разметки у готовой не отличает отсутствующий ресурс от неизвестного пути, — но
|
||||||
|
признак при переписывании не перенесён.
|
||||||
|
Журналов у сервиса два, а сделанная защита закрыла один
|
||||||
|
- **Почему не поймали раньше:** свойство было записано **утверждением**, а
|
||||||
|
проверялось только против журнала контейнера. Второй журнал живёт в базе, и ни
|
||||||
|
один тест туда не смотрел
|
||||||
|
- **Что меняем:** утверждение о журнале называет оба журнала поимённо. Оракул —
|
||||||
|
чтение таблицы журнала после прогона: три успешных запроса не оставляют строк,
|
||||||
|
два отказа оставляют
|
||||||
|
|
||||||
## 2026-08-15 — единая форма отказа не покрывала то, что рождается не в обработчике [пойман ревью]
|
## 2026-08-15 — единая форма отказа не покрывала то, что рождается не в обработчике [пойман ревью]
|
||||||
|
|
||||||
- **Где:** `internal/controller/http/errors.go`, слой `OneErrorForm`; задача
|
- **Где:** `internal/controller/http/errors.go`, слой `OneErrorForm`; задача
|
||||||
|
|||||||
+29
-3
@@ -4,9 +4,19 @@
|
|||||||
|
|
||||||
**Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через
|
**Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через
|
||||||
обратный прокси, а приём записи, чтение её карточки и текста и файл записи требуют входа
|
обратный прокси, а приём записи, чтение её карточки и текста и файл записи требуют входа
|
||||||
через OIDC у Authelia.** Вход развёрнут задачей `oidc-login` 2026-08-12. Открыты
|
через OIDC у Authelia.** Вход развёрнут задачей `oidc-login` 2026-08-12. Без
|
||||||
без входа только проба здоровья и метрики. Находки строятся против этого —
|
входа открыты проба здоровья, метрики и — с 2026-08-15, задачей `spa-skeleton` —
|
||||||
сегодняшнего — периметра.
|
**само приложение**: его разметка и её ресурсы, а вместе с ними всякий путь, не
|
||||||
|
принадлежащий ни одному корню сервиса. Иначе не вошедший не дошёл бы до входа
|
||||||
|
вовсе: закрытая сессией разметка отдала бы ему отказ вместо экрана. Данных
|
||||||
|
открытость не касается — всякий адрес под корнем приложения сессии по-прежнему
|
||||||
|
требует. Находки строятся против этого — сегодняшнего — периметра.
|
||||||
|
|
||||||
|
**Состав того, что отдаётся анонимно, задаёт содержимое собранного приложения**,
|
||||||
|
а каталог его лежит в `.gitignore` и не судится ничем: всё, что окажется там у
|
||||||
|
собирающего, уезжает в бинарник и раздаётся. Под каталогом ресурсов оно ещё и
|
||||||
|
отдаётся с годовым сроком хранения и пометкой «неизменяемо» — отозвать выданное
|
||||||
|
браузеру сервису нечем.
|
||||||
|
|
||||||
Целевой периметр добавляет к нему отдельный вход для программ по личным токенам
|
Целевой периметр добавляет к нему отдельный вход для программ по личным токенам
|
||||||
и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и
|
и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и
|
||||||
@@ -37,6 +47,17 @@ Telegram — связи чата с учётной записью сервис
|
|||||||
администраторов. Задачи в беклоге у этого нет — работа принадлежит выкладке, а
|
администраторов. Задачи в беклоге у этого нет — работа принадлежит выкладке, а
|
||||||
она вне модели («Что вне модели», строка про контур).
|
она вне модели («Что вне модели», строка про контур).
|
||||||
|
|
||||||
|
**И этот барьер обходится подменой одного знака.** Маршрутизатор сравнивает
|
||||||
|
сегменты пути **после** раскодирования, поэтому `/%5f/` попадает в ту же группу,
|
||||||
|
что и `/_/`, а правило прокси написано на литерал и такой формы не видит.
|
||||||
|
Проверено прогоном 2026-08-15 ревью задачи `spa-skeleton`: обе формы отвечают
|
||||||
|
байт в байт, и весь клиент панели грузится анониму. Вход в приложение при этом
|
||||||
|
не обходится — `/%61pp/me` отвечает `401`. Дефект старше задачи, которая его
|
||||||
|
нашла, и **сегодня не закрыт**: лечение — приведение пути к канонической форме на
|
||||||
|
стороне сервиса, и глухая проверка тут не годится, потому что сломает скачивание
|
||||||
|
файлов с пробелами и не-латиницей в имени. Половину пути проверить нечем: правило
|
||||||
|
прокси живёт в `pet-project-server`, вне этого репозитория.
|
||||||
|
|
||||||
**Четвёртый сдвиг — секрет клиента поселился в базе.** Задача `oidc-login`
|
**Четвёртый сдвиг — секрет клиента поселился в базе.** Задача `oidc-login`
|
||||||
2026-08-12 кладёт адреса провайдера, идентификатор клиента и его секрет в
|
2026-08-12 кладёт адреса провайдера, идентификатор клиента и его секрет в
|
||||||
настройки коллекции пользователей, приводя их к конфигу при каждом подъёме
|
настройки коллекции пользователей, приводя их к конфигу при каждом подъёме
|
||||||
@@ -186,6 +207,11 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
|||||||
её нет ни у пробы, ни у сборщика. Наружу их закрывает правило обратного
|
её нет ни у пробы, ни у сборщика. Наружу их закрывает правило обратного
|
||||||
прокси — работа выкладки, и сервис на неё не полагается: содержимого записей
|
прокси — работа выкладки, и сервис на неё не полагается: содержимого записей
|
||||||
эти адреса не несут.
|
эти адреса не несут.
|
||||||
|
- **Приложение** — его разметка и ресурсы открыты без сессии, и ограничителя
|
||||||
|
частоты на них нет: правило заведено под корень приложения, а раздача стоит
|
||||||
|
вне его. Содержимого записей ни разметка, ни ресурсы не несут: они одинаковы
|
||||||
|
для всех и собраны до всякого запроса. По ответу нельзя узнать, вошёл ли
|
||||||
|
кто-то, — вошедшему и не вошедшему отдаётся одно и то же.
|
||||||
|
|
||||||
Владение записью в модели данных появилось 2026-08-14: у задачи и у её файла
|
Владение записью в модели данных появилось 2026-08-14: у задачи и у её файла
|
||||||
есть владелец. Знание идентификатора задачи правом её читать больше не является
|
есть владелец. Знание идентификатора задачи правом её читать больше не является
|
||||||
|
|||||||
@@ -93,8 +93,14 @@ func (h *AuthHandler) Register(r *router.Router[*core.RequestEvent]) {
|
|||||||
// своим, и перехватить его можно только слоем.
|
// своим, и перехватить его можно только слоем.
|
||||||
r.Bind(BlockSessionRefresh())
|
r.Bind(BlockSessionRefresh())
|
||||||
|
|
||||||
r.GET("/auth/login", h.Login)
|
// Адреса вешаются группой корня, а не литералами: корень объявлен единой
|
||||||
r.GET("/auth/callback", h.Callback)
|
// точкой адресного пространства, и порознь записанный он разошёлся бы с ней
|
||||||
|
// молча — раздача приложения начала бы отдавать разметку на возврат от
|
||||||
|
// провайдера, без единой ошибки в журнале.
|
||||||
|
auth := r.Group(AuthRoot)
|
||||||
|
|
||||||
|
auth.GET("/login", h.Login)
|
||||||
|
auth.GET("/callback", h.Callback)
|
||||||
// Выход берёт POST намеренно: по GET его срабатывание уносится переходом по
|
// Выход берёт POST намеренно: по GET его срабатывание уносится переходом по
|
||||||
// чужой ссылке.
|
// чужой ссылке.
|
||||||
//
|
//
|
||||||
@@ -102,7 +108,7 @@ func (h *AuthHandler) Register(r *router.Router[*core.RequestEvent]) {
|
|||||||
// обесценивать, — он убрал бы куку и отчитался успехом, оставив унесённое
|
// обесценивать, — он убрал бы куку и отчитался успехом, оставив унесённое
|
||||||
// значение годным. Требования сессии при этом нет: выход без неё убирает
|
// значение годным. Требования сессии при этом нет: выход без неё убирает
|
||||||
// куку и молчит.
|
// куку и молчит.
|
||||||
r.POST("/auth/logout", h.Logout).Bind(SessionFromCookie())
|
auth.POST("/logout", h.Logout).Bind(SessionFromCookie())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login уводит человека к провайдеру, запомнив состояние и проверочный код
|
// Login уводит человека к провайдеру, запомнив состояние и проверочный код
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Корни адресного пространства и отдельные адреса наблюдения.
|
||||||
|
//
|
||||||
|
// `/api` и `/_` принадлежат хранилищу: первый — его наборам адресов, второй —
|
||||||
|
// панели владельца. Поменять их нельзя, это литералы библиотеки.
|
||||||
|
const (
|
||||||
|
StorageRoot = "/api"
|
||||||
|
PanelRoot = "/_"
|
||||||
|
AuthRoot = "/auth"
|
||||||
|
|
||||||
|
HealthPath = "/health"
|
||||||
|
MetricsPath = "/metrics"
|
||||||
|
)
|
||||||
|
|
||||||
|
// assetsDir — каталог, который наполняет сборщик приложения.
|
||||||
|
//
|
||||||
|
// Имена в нём строит он же и несёт в них отпечаток содержимого, поэтому
|
||||||
|
// изменившийся ресурс приезжает под новым именем. Отсюда два правила разом:
|
||||||
|
// такой ресурс отдаётся с долгим сроком хранения, а не совпавший с файлом путь
|
||||||
|
// под этим каталогом отвечает отсутствием, а не разметкой.
|
||||||
|
const assetsDir = "assets"
|
||||||
|
|
||||||
|
// Срок хранения ресурса сборщика — год.
|
||||||
|
const assetMaxAgeSeconds = 31536000
|
||||||
|
|
||||||
|
// Mount — часть адресного пространства, принадлежащая сервису.
|
||||||
|
//
|
||||||
|
// Перечень этих частей — **единственное** описание того, что сервису
|
||||||
|
// принадлежит, и он не описывает регистрацию, а порождает её: корень,
|
||||||
|
// заведённый мимо перечня, не получит обработчика вовсе. Прежде такой перечень
|
||||||
|
// был бы вторым описанием таблицы маршрутов, которую ведут три места, и корень,
|
||||||
|
// забытый в нём, молча отдавал бы разметку там, где программа ждёт отказ
|
||||||
|
// контракта.
|
||||||
|
type Mount struct {
|
||||||
|
// Path — корень либо точный адрес.
|
||||||
|
Path string
|
||||||
|
|
||||||
|
// Exact — путь является точным адресом, а не корнем: `/health` накрывает
|
||||||
|
// только сам себя, а `/app` — всё, что под ним.
|
||||||
|
Exact bool
|
||||||
|
|
||||||
|
// Bind вешает обработчики этой части. Пусто у того, что вешает библиотека.
|
||||||
|
Bind func(r *router.Router[*core.RequestEvent])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Covers говорит, принадлежит ли путь этой части адресного пространства.
|
||||||
|
//
|
||||||
|
// Условий два, и оба обязательны: точное совпадение либо префикс **вместе с
|
||||||
|
// косой чертой**. По одному префиксу корню `/app` достался бы посторонний
|
||||||
|
// `/apple`; по одному префиксу с косой чертой голый `/api` не достался бы
|
||||||
|
// никому и уехал бы разметкой приложения.
|
||||||
|
func (m Mount) Covers(requestPath string) bool {
|
||||||
|
if m.Exact {
|
||||||
|
return requestPath == m.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestPath == m.Path || strings.HasPrefix(requestPath, m.Path+"/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceMounts перечисляет адресное пространство сервиса целиком.
|
||||||
|
func ServiceMounts(
|
||||||
|
appHandler *AppHandler,
|
||||||
|
authHandler *AuthHandler,
|
||||||
|
metricsHandler http.Handler,
|
||||||
|
) []Mount {
|
||||||
|
return []Mount{
|
||||||
|
{Path: StorageRoot},
|
||||||
|
{Path: PanelRoot},
|
||||||
|
{Path: AppRoot, Bind: appHandler.Register},
|
||||||
|
{Path: AuthRoot, Bind: authHandler.Register},
|
||||||
|
{Path: HealthPath, Exact: true, Bind: bindHealth},
|
||||||
|
{Path: MetricsPath, Exact: true, Bind: bindMetrics(metricsHandler)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterServiceRoutes вешает всё, что сервис вешает сам.
|
||||||
|
func RegisterServiceRoutes(r *router.Router[*core.RequestEvent], mounts []Mount) {
|
||||||
|
for _, mount := range mounts {
|
||||||
|
if mount.Bind != nil {
|
||||||
|
mount.Bind(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsServiceAddress говорит, принадлежит ли путь сервису хоть какой-то частью.
|
||||||
|
func IsServiceAddress(mounts []Mount, requestPath string) bool {
|
||||||
|
for _, mount := range mounts {
|
||||||
|
if mount.Covers(requestPath) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsObservationAddress говорит, что путь — адрес наблюдения.
|
||||||
|
//
|
||||||
|
// Опрос здоровья и метрик идёт постоянно и полезного не несёт, поэтому уровень
|
||||||
|
// журнала у него свой. Перечень при этом тот же самый: второе перечисление этих
|
||||||
|
// адресов разошлось бы с первым молча.
|
||||||
|
func IsObservationAddress(mounts []Mount, requestPath string) bool {
|
||||||
|
for _, mount := range mounts {
|
||||||
|
if mount.Exact && mount.Covers(requestPath) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindHealth(r *router.Router[*core.RequestEvent]) {
|
||||||
|
r.GET(HealthPath, func(e *core.RequestEvent) error {
|
||||||
|
return e.JSON(http.StatusOK, map[string]string{
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Transcriber service is running",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindMetrics(handler http.Handler) func(r *router.Router[*core.RequestEvent]) {
|
||||||
|
return func(r *router.Router[*core.RequestEvent]) {
|
||||||
|
r.GET(MetricsPath, func(e *core.RequestEvent) error {
|
||||||
|
handler.ServeHTTP(e.Response, e.Request)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// notBuiltPage — что видит человек у бинарника без собранного приложения.
|
||||||
|
//
|
||||||
|
// Состояние возможно только у собранного мимо набора проверок: и набор
|
||||||
|
// проверок, и сборка образа собирают приложение раньше бинарника.
|
||||||
|
const notBuiltPage = `<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head><meta charset="utf-8"><title>Приложение не собрано</title></head>
|
||||||
|
<body><h1>Приложение не собрано</h1>
|
||||||
|
<p>Бинарник собран без приложения. Соберите его и соберите бинарник заново.</p>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
// Исход раздачи для журнала. Сам путь в журнал не идёт: множеством его значений
|
||||||
|
// распоряжается спрашивающий, а до появления раздачи такой путь ловил отказ
|
||||||
|
// маршрутизатора и успешным ответом не был.
|
||||||
|
const (
|
||||||
|
OutcomeMarkup = "markup"
|
||||||
|
OutcomeAsset = "asset"
|
||||||
|
OutcomeFailure = "failure"
|
||||||
|
)
|
||||||
|
|
||||||
|
// journalOutcomeKey — под каким ключом раздача оставляет исход слою журнала.
|
||||||
|
const journalOutcomeKey = "transcriberWebappOutcome"
|
||||||
|
|
||||||
|
// WebappOutcome отдаёт исход, оставленный раздачей, либо пустую строку, если
|
||||||
|
// запрос до неё не дошёл.
|
||||||
|
func WebappOutcome(e *core.RequestEvent) string {
|
||||||
|
outcome, ok := e.Get(journalOutcomeKey).(string)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebappHandler раздаёт собранное приложение и держит правило неизвестного пути.
|
||||||
|
type WebappHandler struct {
|
||||||
|
dist fs.FS
|
||||||
|
built bool
|
||||||
|
mounts []Mount
|
||||||
|
fingerprint string
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWebappHandler собирает раздачу приложения.
|
||||||
|
//
|
||||||
|
// Файловая система приходит параметром, а не тянется пакетом: так тест
|
||||||
|
// подставляет свою сборку, не собирая приложение.
|
||||||
|
func NewWebappHandler(dist fs.FS, built bool, mounts []Mount, logger *slog.Logger) *WebappHandler {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
|
||||||
|
if !built {
|
||||||
|
logger.Warn("Webapp is not built, service will answer with a placeholder page")
|
||||||
|
return &WebappHandler{dist: dist, built: built, mounts: mounts, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отпечаток вшитой сборки — единственное, чем «не та сборка» отличается от
|
||||||
|
// «той». Вне набора проверок порядок шагов ничем не задан: `go run .`
|
||||||
|
// вшивает то, что лежит с прошлого раза, а приложение при этом открывается и
|
||||||
|
// ведёт себя как прежняя версия. Он же уходит меткой ответа с разметкой.
|
||||||
|
fingerprint := buildFingerprint(dist)
|
||||||
|
logger.Info("Webapp is embedded", "webapp.build", fingerprint)
|
||||||
|
|
||||||
|
return &WebappHandler{
|
||||||
|
dist: dist,
|
||||||
|
built: built,
|
||||||
|
mounts: mounts,
|
||||||
|
fingerprint: fingerprint,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildFingerprint — короткий отпечаток разметки вшитой сборки.
|
||||||
|
//
|
||||||
|
// Считается по самой разметке, а не по файлу, который положил бы сборщик: файл
|
||||||
|
// пришлось бы заводить настройкой сборки, а разметка меняется вместе с именами
|
||||||
|
// ресурсов, то есть при всякой пересборке с изменениями.
|
||||||
|
func buildFingerprint(dist fs.FS) string {
|
||||||
|
markup, err := fs.ReadFile(dist, "index.html")
|
||||||
|
if err != nil {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := sha256.Sum256(markup)
|
||||||
|
|
||||||
|
return hex.EncodeToString(sum[:])[:12]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register вешает раздачу на корень.
|
||||||
|
//
|
||||||
|
// Маршрут стоит ровно на `/`, а не на `/{path...}`: сборка маршрутов сама
|
||||||
|
// вешает на `/` отказ «ничего не совпало», если такого маршрута ещё нет, и
|
||||||
|
// второй всепокрывающий образец рядом с ним спорил бы с ним за путь.
|
||||||
|
func (h *WebappHandler) Register(r *router.Router[*core.RequestEvent]) {
|
||||||
|
// Успешная раздача в журнал хранилища не пишется.
|
||||||
|
//
|
||||||
|
// Журнал хранилища — второй, помимо журнала контейнера, и в него библиотека
|
||||||
|
// кладёт путь запроса целиком вместе с адресом отправителя, храня строки
|
||||||
|
// пять суток. Путь здесь выбирает спрашивающий, и без этого отказа всякое
|
||||||
|
// открытие приложения оставляло бы там его текст и его адрес. Готовая
|
||||||
|
// раздача статики ставит тот же признак первой строкой; своя написана мимо
|
||||||
|
// неё, и признак перенесён руками.
|
||||||
|
//
|
||||||
|
// Отказы записываются по-прежнему: признак снимает только успех.
|
||||||
|
r.Any("/", h.Serve).Bind(apis.SkipSuccessActivityLog())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve отдаёт приложение либо отказ — по порядку, объявленному дизайном.
|
||||||
|
func (h *WebappHandler) Serve(e *core.RequestEvent) error {
|
||||||
|
requestPath := e.Request.URL.Path
|
||||||
|
|
||||||
|
// Путь принадлежит сервису — значит под этим корнем такого адреса просто
|
||||||
|
// нет. Отказ уходит формой библиотеки, то есть тем же, чем этот корень
|
||||||
|
// отвечает и сегодня: своя форма здесь была бы второй.
|
||||||
|
if IsServiceAddress(h.mounts, requestPath) {
|
||||||
|
e.Set(journalOutcomeKey, OutcomeFailure)
|
||||||
|
return router.NewNotFoundError("", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.Request.Method != http.MethodGet && e.Request.Method != http.MethodHead {
|
||||||
|
e.Set(journalOutcomeKey, OutcomeFailure)
|
||||||
|
return router.NewApiError(http.StatusMethodNotAllowed, "", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !h.built {
|
||||||
|
e.Set(journalOutcomeKey, OutcomeFailure)
|
||||||
|
return e.HTML(http.StatusServiceUnavailable, notBuiltPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimPrefix(path.Clean(requestPath), "/")
|
||||||
|
if name == "" || name == "." {
|
||||||
|
return h.serveIndex(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info, err := fs.Stat(h.dist, name); err == nil && !info.IsDir() {
|
||||||
|
e.Set(journalOutcomeKey, OutcomeAsset)
|
||||||
|
h.setCacheHeader(e, name)
|
||||||
|
return e.FileFS(h.dist, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разметка прежней сборки называет ресурсы прежней сборки. Подменить их
|
||||||
|
// разметкой значит ответить `200` на то, чего нет: браузер отвергнет её по
|
||||||
|
// типу содержимого, человек увидит пустой экран, а в кодах ответов сервиса
|
||||||
|
// не останется ничего.
|
||||||
|
if underAssets(name) {
|
||||||
|
e.Set(journalOutcomeKey, OutcomeFailure)
|
||||||
|
return router.NewNotFoundError("", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.serveIndex(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WebappHandler) serveIndex(e *core.RequestEvent) error {
|
||||||
|
e.Set(journalOutcomeKey, OutcomeMarkup)
|
||||||
|
h.setCacheHeader(e, "index.html")
|
||||||
|
|
||||||
|
// Отпечаток идёт метке ответа: `no-cache` обещает дешёвую проверку —
|
||||||
|
// «спроси заново и получи подтверждение», — а вшитый файл не несёт времени
|
||||||
|
// правки вовсе, и без метки браузеру каждый раз отдаётся полное тело вместо
|
||||||
|
// подтверждения. Отпечаток уже посчитан при подъёме, второго счёта не надо.
|
||||||
|
if h.fingerprint != "" {
|
||||||
|
e.Response.Header().Set("ETag", `"`+h.fingerprint+`"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.FileFS(h.dist, "index.html")
|
||||||
|
}
|
||||||
|
|
||||||
|
// setCacheHeader назначает срок хранения по каталогу, а не по виду файла.
|
||||||
|
//
|
||||||
|
// Вид файла признака не даёт: в сборке лежат и файлы с постоянными именами —
|
||||||
|
// иконка, манифест, — и такой файл, однажды отданный как неизменяемый, не
|
||||||
|
// отзывается со стороны сервиса ничем. Запросов он больше не увидит.
|
||||||
|
// underAssets говорит, ведёт ли путь в каталог сборщика.
|
||||||
|
//
|
||||||
|
// Условий два, и оба обязательны — та же пара, что у принадлежности корню: сам
|
||||||
|
// каталог и всё, что под ним. По одному префиксу с косой чертой голый `assets`
|
||||||
|
// не попал бы никуда и уехал бы разметкой с кодом `200` — то есть ровно тем
|
||||||
|
// ответом, который правило запрещает.
|
||||||
|
func underAssets(name string) bool {
|
||||||
|
return name == assetsDir || strings.HasPrefix(name, assetsDir+"/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WebappHandler) setCacheHeader(e *core.RequestEvent, name string) {
|
||||||
|
if underAssets(name) {
|
||||||
|
e.Response.Header().Set(
|
||||||
|
"Cache-Control",
|
||||||
|
"public, max-age="+strconv.Itoa(assetMaxAgeSeconds)+", immutable",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Response.Header().Set("Cache-Control", "no-cache")
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
|
||||||
|
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testMarkup = `<!doctype html><html lang="ru"><body>приложение</body></html>`
|
||||||
|
|
||||||
|
// builtDist — собранное приложение, каким его видит раздача.
|
||||||
|
func builtDist() fs.FS {
|
||||||
|
return fstest.MapFS{
|
||||||
|
"index.html": {Data: []byte(testMarkup)},
|
||||||
|
"assets/index-abc123.js": {Data: []byte("console.log(1)")},
|
||||||
|
"favicon.ico": {Data: []byte("значок")},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// webappEnv — окружение проверок раздачи. Роутер собирается тем же способом,
|
||||||
|
// что и боевой: маршруты порождает перечень, а не перечисление в проверке.
|
||||||
|
type webappEnv struct {
|
||||||
|
mux http.Handler
|
||||||
|
journal *journalBuffer
|
||||||
|
session string
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupWebappEnv(t *testing.T, dist fs.FS, built bool) *webappEnv {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
app := newTestStorage(t)
|
||||||
|
pbrepo.BindPanelRules(app)
|
||||||
|
|
||||||
|
recordRepo := pbrepo.NewAudioRecordRepository(app)
|
||||||
|
textRepo := pbrepo.NewTextRepository(app)
|
||||||
|
structureRepo := pbrepo.NewStructureRepository(app)
|
||||||
|
|
||||||
|
journal := &journalBuffer{}
|
||||||
|
logger := slog.New(slog.NewTextHandler(journal, nil))
|
||||||
|
|
||||||
|
trsService := service.NewTranscribeService(
|
||||||
|
service.Repositories{
|
||||||
|
Records: recordRepo,
|
||||||
|
Files: pbrepo.NewFileRepository(app),
|
||||||
|
Texts: textRepo,
|
||||||
|
Structures: structureRepo,
|
||||||
|
Recognitions: pbrepo.NewRecognitionRepository(app),
|
||||||
|
Events: pbrepo.NewRecordEventRepository(app),
|
||||||
|
},
|
||||||
|
readableMetaViewer(),
|
||||||
|
&stubConverter{},
|
||||||
|
&recognizer.MemoryAudioRecognizer{},
|
||||||
|
entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour},
|
||||||
|
logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
appHandler := NewAppHandler(recordRepo, textRepo, structureRepo, trsService, logger)
|
||||||
|
authHandler := NewAuthHandler(app, AuthHandlerConfig{
|
||||||
|
AuthURL: "https://provider.example/authorize",
|
||||||
|
RedirectURL: "https://service.example/auth/callback",
|
||||||
|
ClientID: "client",
|
||||||
|
}, logger)
|
||||||
|
|
||||||
|
mounts := ServiceMounts(appHandler, authHandler, http.NotFoundHandler())
|
||||||
|
|
||||||
|
r, err := apis.NewRouter(app)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
RegisterServiceRoutes(r, mounts)
|
||||||
|
NewWebappHandler(dist, built, mounts, logger).Register(r)
|
||||||
|
|
||||||
|
mux, err := r.BuildMux()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, session := newTestAccount(t, app)
|
||||||
|
|
||||||
|
return &webappEnv{mux: mux, journal: journal, session: session}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *webappEnv) get(path string) *httptest.ResponseRecorder {
|
||||||
|
return e.do(http.MethodGet, path, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *webappEnv) do(method, path string, withSession bool) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(method, path, nil)
|
||||||
|
if withSession {
|
||||||
|
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: e.session})
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление страницы посреди приложения открывает тот же экран: адреса у
|
||||||
|
// приложения обычные, а не после решётки, и сервер обязан отдать разметку.
|
||||||
|
func TestWebappServesMarkupOutsideServiceRoots(t *testing.T) {
|
||||||
|
env := setupWebappEnv(t, builtDist(), true)
|
||||||
|
|
||||||
|
for _, path := range []string{"/", "/records", "/records/abc123def456ghi"} {
|
||||||
|
res := env.get(path)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, res.Code, path)
|
||||||
|
assert.Contains(t, res.Body.String(), "приложение", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Путь внутри корня сервиса в приложение не проваливается никогда: иначе
|
||||||
|
// программа, ошибшаяся адресом, приняла бы разметку с кодом 200 за ответ.
|
||||||
|
func TestWebappNeverAnswersInsideServiceRoots(t *testing.T) {
|
||||||
|
env := setupWebappEnv(t, builtDist(), true)
|
||||||
|
|
||||||
|
for _, path := range []string{"/api/nope", "/_/nope", "/auth/nope"} {
|
||||||
|
res := env.get(path)
|
||||||
|
|
||||||
|
assert.NotContains(t, res.Body.String(), "приложение", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Под корнем приложения отказ уходит его собственной формой, и код отвечает
|
||||||
|
// причине: без сессии — «предъяви себя», с сессией — «такого адреса нет».
|
||||||
|
func TestWebappLeavesAppRootToItsOwnFailureForm(t *testing.T) {
|
||||||
|
env := setupWebappEnv(t, builtDist(), true)
|
||||||
|
|
||||||
|
anonymous := env.do(http.MethodGet, "/app/nope", false)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, anonymous.Code)
|
||||||
|
assert.NotContains(t, anonymous.Body.String(), "приложение")
|
||||||
|
|
||||||
|
signedIn := env.do(http.MethodGet, "/app/nope", true)
|
||||||
|
assert.Equal(t, http.StatusNotFound, signedIn.Code)
|
||||||
|
assert.Contains(t, signedIn.Body.String(), CodeNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Принадлежность корню — два условия разом. По одному префиксу корню досталось
|
||||||
|
// бы постороннее имя, по одному префиксу с косой чертой голый корень не достался
|
||||||
|
// бы никому и уехал бы разметкой.
|
||||||
|
func TestWebappRootMatchNeedsExactOrSlash(t *testing.T) {
|
||||||
|
env := setupWebappEnv(t, builtDist(), true)
|
||||||
|
|
||||||
|
bare := env.get(StorageRoot)
|
||||||
|
assert.NotContains(t, bare.Body.String(), "приложение", "голый корень разметкой не подменяется")
|
||||||
|
|
||||||
|
neighbour := env.get(AppRoot + "le")
|
||||||
|
assert.Equal(t, http.StatusOK, neighbour.Code)
|
||||||
|
assert.Contains(t, neighbour.Body.String(), "приложение", "посторонний путь остаётся приложению")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разметка прежней сборки называет ресурсы прежней сборки. Подменить их
|
||||||
|
// разметкой значит ответить 200 на то, чего нет.
|
||||||
|
func TestWebappMissingAssetAnswersNotFound(t *testing.T) {
|
||||||
|
env := setupWebappEnv(t, builtDist(), true)
|
||||||
|
|
||||||
|
res := env.get("/assets/index-obsolete.js")
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, res.Code)
|
||||||
|
assert.NotContains(t, res.Body.String(), "приложение")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сам каталог сборщика — тот же случай, что и файл под ним: правило держится
|
||||||
|
// парой условий, и по одному префиксу с косой чертой голый `assets` уехал бы
|
||||||
|
// разметкой с кодом `200`.
|
||||||
|
func TestWebappAssetsDirItselfAnswersNotFound(t *testing.T) {
|
||||||
|
env := setupWebappEnv(t, builtDist(), true)
|
||||||
|
|
||||||
|
for _, path := range []string{"/assets", "/assets/"} {
|
||||||
|
res := env.get(path)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, res.Code, path)
|
||||||
|
assert.NotContains(t, res.Body.String(), "приложение", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Метка ответа даёт `no-cache` его смысл: без неё вшитый файл не несёт времени
|
||||||
|
// правки, и браузер получает полное тело вместо подтверждения.
|
||||||
|
func TestWebappMarkupCarriesEntityTag(t *testing.T) {
|
||||||
|
env := setupWebappEnv(t, builtDist(), true)
|
||||||
|
|
||||||
|
tag := env.get("/").Result().Header.Get("ETag")
|
||||||
|
|
||||||
|
assert.NotEmpty(t, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Адреса наблюдения приложением не подменяются.
|
||||||
|
func TestWebappLeavesObservationAddresses(t *testing.T) {
|
||||||
|
env := setupWebappEnv(t, builtDist(), true)
|
||||||
|
|
||||||
|
res := env.get(HealthPath)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, res.Code)
|
||||||
|
assert.Contains(t, res.Body.String(), "ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Открывающими страницу считаются GET и HEAD, и только они.
|
||||||
|
func TestWebappMethods(t *testing.T) {
|
||||||
|
env := setupWebappEnv(t, builtDist(), true)
|
||||||
|
|
||||||
|
head := env.do(http.MethodHead, "/records", false)
|
||||||
|
assert.Equal(t, http.StatusOK, head.Code)
|
||||||
|
|
||||||
|
post := env.do(http.MethodPost, "/records", false)
|
||||||
|
assert.Equal(t, http.StatusMethodNotAllowed, post.Code)
|
||||||
|
assert.NotContains(t, post.Body.String(), "приложение")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Долгий срок хранения ставится по каталогу, а не по виду файла: отозвать его у
|
||||||
|
// браузера сервису нечем.
|
||||||
|
func TestWebappCacheHeaders(t *testing.T) {
|
||||||
|
env := setupWebappEnv(t, builtDist(), true)
|
||||||
|
|
||||||
|
asset := env.get("/assets/index-abc123.js").Result().Header.Get("Cache-Control")
|
||||||
|
assert.Contains(t, asset, "immutable")
|
||||||
|
assert.Contains(t, asset, "max-age=31536000")
|
||||||
|
|
||||||
|
markup := env.get("/").Result().Header.Get("Cache-Control")
|
||||||
|
assert.Equal(t, "no-cache", markup)
|
||||||
|
|
||||||
|
// Файл с постоянным именем лежит в сборке, но вне каталога сборщика:
|
||||||
|
// «неизменяемо» ему не достаётся.
|
||||||
|
permanent := env.get("/favicon.ico").Result().Header.Get("Cache-Control")
|
||||||
|
assert.Equal(t, "no-cache", permanent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// «Приложения нет» отличается от «адреса нет»: первое чинится сборкой, а не
|
||||||
|
// поиском опечатки в адресе. Проба здоровья при этом остаётся зелёной — сервис
|
||||||
|
// принимает записи и расшифровывает их, не работает только показ.
|
||||||
|
func TestWebappNotBuilt(t *testing.T) {
|
||||||
|
env := setupWebappEnv(t, fstest.MapFS{}, false)
|
||||||
|
|
||||||
|
res := env.get("/")
|
||||||
|
assert.Equal(t, http.StatusServiceUnavailable, res.Code)
|
||||||
|
assert.Contains(t, res.Body.String(), "не собрано")
|
||||||
|
|
||||||
|
health := env.get(HealthPath)
|
||||||
|
assert.Equal(t, http.StatusOK, health.Code)
|
||||||
|
|
||||||
|
// Владелец сервиса узнаёт об этом журналом подъёма, а не от человека,
|
||||||
|
// открывшего страницу.
|
||||||
|
assert.Contains(t, env.journal.String(), "Webapp is not built")
|
||||||
|
}
|
||||||
+34
-2
@@ -1,11 +1,27 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Перечень адресного пространства для проверок журнала. Обработчиков он здесь
|
||||||
|
// не вешает: журналу нужны только границы, а не то, что стоит за ними.
|
||||||
|
func journalMounts() []httpcontroller.Mount {
|
||||||
|
return []httpcontroller.Mount{
|
||||||
|
{Path: httpcontroller.StorageRoot},
|
||||||
|
{Path: httpcontroller.PanelRoot},
|
||||||
|
{Path: httpcontroller.AppRoot},
|
||||||
|
{Path: httpcontroller.AuthRoot},
|
||||||
|
{Path: httpcontroller.HealthPath, Exact: true},
|
||||||
|
{Path: httpcontroller.MetricsPath, Exact: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Имя файла в хранилище в журнал не идёт: оно последняя часть ссылки
|
// Имя файла в хранилище в журнал не идёт: оно последняя часть ссылки
|
||||||
// `/api/files/...`, и строка журнала вместе с идентификатором записи собрала бы
|
// `/api/files/...`, и строка журнала вместе с идентификатором записи собрала бы
|
||||||
// ссылку целиком. Инвариант проекта, critical.
|
// ссылку целиком. Инвариант проекта, critical.
|
||||||
@@ -44,7 +60,7 @@ func TestJournalRouteHidesStoredFileName(t *testing.T) {
|
|||||||
|
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
assert.Equal(t, c.want, journalRoute(c.path))
|
assert.Equal(t, c.want, journalRoute(c.path, journalMounts()))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,8 +70,24 @@ func TestJournalRouteHidesStoredFileName(t *testing.T) {
|
|||||||
func TestJournalRouteDropsNameEntirely(t *testing.T) {
|
func TestJournalRouteDropsNameEntirely(t *testing.T) {
|
||||||
const stored = "0f7b8dd3-d1cc-424c.mp3"
|
const stored = "0f7b8dd3-d1cc-424c.mp3"
|
||||||
|
|
||||||
route := journalRoute("/api/files/files/rec0000000000000/" + stored)
|
route := journalRoute("/api/files/files/rec0000000000000/"+stored, journalMounts())
|
||||||
|
|
||||||
assert.NotContains(t, route, stored, "имя файла в хранилище не доезжает до журнала")
|
assert.NotContains(t, route, stored, "имя файла в хранилище не доезжает до журнала")
|
||||||
assert.Contains(t, route, "rec0000000000000", "идентификатор записи остаётся: по нему прослеживается путь")
|
assert.Contains(t, route, "rec0000000000000", "идентификатор записи остаётся: по нему прослеживается путь")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Путь, не принадлежащий сервису, уходит приложению и в журнал дословно не
|
||||||
|
// идёт: множеством его значений распоряжается спрашивающий.
|
||||||
|
func TestJournalRouteHidesWebappPath(t *testing.T) {
|
||||||
|
cases := []string{
|
||||||
|
"/",
|
||||||
|
"/records/abc123def456ghi",
|
||||||
|
"/" + strings.Repeat("a", 1024),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range cases {
|
||||||
|
route := journalRoute(path, journalMounts())
|
||||||
|
|
||||||
|
assert.Equal(t, webappRoute, route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"git.vakhrushev.me/av/transcriber/internal/controller/worker"
|
"git.vakhrushev.me/av/transcriber/internal/controller/worker"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||||
|
"git.vakhrushev.me/av/transcriber/web"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
@@ -167,6 +168,15 @@ func main() {
|
|||||||
SecureCookie: cfg.Auth.SecureCookie,
|
SecureCookie: cfg.Auth.SecureCookie,
|
||||||
}, logger)
|
}, logger)
|
||||||
|
|
||||||
|
// Адресное пространство сервиса объявлено одним перечнем, и он порождает
|
||||||
|
// регистрацию, а не описывает её: корень, заведённый мимо перечня, не
|
||||||
|
// получит обработчика вовсе. Отсюда же уровень журнала для адресов
|
||||||
|
// наблюдения и правило неизвестного пути у раздачи приложения.
|
||||||
|
mounts := httpcontroller.ServiceMounts(appHandler, authHandler, promhttp.Handler())
|
||||||
|
|
||||||
|
dist, appBuilt := web.Dist()
|
||||||
|
webappHandler := httpcontroller.NewWebappHandler(dist, appBuilt, mounts, logger)
|
||||||
|
|
||||||
// Сервер приезжает каналом, а не общей переменной: хук исполняется в
|
// Сервер приезжает каналом, а не общей переменной: хук исполняется в
|
||||||
// горутине сервера, а читает его горутина остановки, и связи «произошло
|
// горутине сервера, а читает его горутина остановки, и связи «произошло
|
||||||
// раньше» между ними иначе нет.
|
// раньше» между ними иначе нет.
|
||||||
@@ -186,17 +196,29 @@ func main() {
|
|||||||
err := e.Next()
|
err := e.Next()
|
||||||
|
|
||||||
level := slog.LevelInfo
|
level := slog.LevelInfo
|
||||||
if e.Request.URL.Path == "/health" || e.Request.URL.Path == "/metrics" {
|
if httpcontroller.IsObservationAddress(mounts, e.Request.URL.Path) {
|
||||||
// Опрос здоровья и метрик идёт постоянно и полезного не несёт.
|
// Опрос здоровья и метрик идёт постоянно и полезного не несёт.
|
||||||
level = slog.LevelDebug
|
level = slog.LevelDebug
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Log(e.Request.Context(), level, "Incoming request",
|
attrs := []any{
|
||||||
"http.method", e.Request.Method,
|
"http.method", e.Request.Method,
|
||||||
"http.route", journalRoute(e.Request.URL.Path),
|
"http.route", journalRoute(e.Request.URL.Path, mounts),
|
||||||
"http.status_code", e.Status(),
|
"http.status_code", e.Status(),
|
||||||
"duration_ms", time.Since(start).Milliseconds(),
|
"duration_ms", time.Since(start).Milliseconds(),
|
||||||
"transport", "http")
|
"transport", "http",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Путь, отданный приложению, в журнал не идёт — вместо него исход
|
||||||
|
// и длина: по ним видно, что происходит, а множеством значений
|
||||||
|
// самого пути распоряжается спрашивающий.
|
||||||
|
if outcome := httpcontroller.WebappOutcome(e); outcome != "" {
|
||||||
|
attrs = append(attrs,
|
||||||
|
"webapp.outcome", outcome,
|
||||||
|
"http.path_length", len(e.Request.URL.Path))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Log(e.Request.Context(), level, "Incoming request", attrs...)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
@@ -222,20 +244,11 @@ func main() {
|
|||||||
return fmt.Errorf("failed to apply app rate limit: %w", err)
|
return fmt.Errorf("failed to apply app rate limit: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
authHandler.Register(se.Router)
|
httpcontroller.RegisterServiceRoutes(se.Router, mounts)
|
||||||
appHandler.Register(se.Router)
|
|
||||||
|
|
||||||
se.Router.GET("/health", func(e *core.RequestEvent) error {
|
// Раздача приложения вешается последней: она занимает корень, и всё,
|
||||||
return e.JSON(http.StatusOK, map[string]string{
|
// что не совпало ни с одним адресом сервиса, доходит до неё.
|
||||||
"status": "ok",
|
webappHandler.Register(se.Router)
|
||||||
"message": "Transcriber service is running",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
se.Router.GET("/metrics", func(e *core.RequestEvent) error {
|
|
||||||
promhttp.Handler().ServeHTTP(e.Response, e.Request)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
return se.Next()
|
return se.Next()
|
||||||
})
|
})
|
||||||
@@ -313,6 +326,9 @@ func main() {
|
|||||||
// сегмент такого пути и есть имя файла в хранилище.
|
// сегмент такого пути и есть имя файла в хранилище.
|
||||||
const filesPathPrefix = "/api/files/"
|
const filesPathPrefix = "/api/files/"
|
||||||
|
|
||||||
|
// webappRoute — чем в журнале обозначается всякий путь, отданный приложению.
|
||||||
|
const webappRoute = "<приложение>"
|
||||||
|
|
||||||
// journalRoute готовит путь запроса к записи в журнал.
|
// journalRoute готовит путь запроса к записи в журнал.
|
||||||
//
|
//
|
||||||
// Инвариант проекта запрещает имени файла в хранилище попадать в журнал: имя —
|
// Инвариант проекта запрещает имени файла в хранилище попадать в журнал: имя —
|
||||||
@@ -323,7 +339,16 @@ const filesPathPrefix = "/api/files/"
|
|||||||
//
|
//
|
||||||
// Срезается только имя: маршрут остаётся различимым, и наблюдаемость от этого не
|
// Срезается только имя: маршрут остаётся различимым, и наблюдаемость от этого не
|
||||||
// теряется.
|
// теряется.
|
||||||
func journalRoute(path string) string {
|
//
|
||||||
|
// Путь, не принадлежащий сервису, в журнал не идёт вовсе. До появления раздачи
|
||||||
|
// приложения такой путь ловил отказ маршрутизатора, а теперь получает разметку
|
||||||
|
// с кодом `200`: множеством его значений распоряжается спрашивающий, и
|
||||||
|
// дословная строка сделала бы журнал местом, куда аноним пишет свой текст.
|
||||||
|
func journalRoute(path string, mounts []httpcontroller.Mount) string {
|
||||||
|
if !httpcontroller.IsServiceAddress(mounts, path) {
|
||||||
|
return webappRoute
|
||||||
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(path, filesPathPrefix) {
|
if !strings.HasPrefix(path, filesPathPrefix) {
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-15
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
# Дизайн: каркас приложения
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Фреймворк и способ раздачи выбраны разведкой `spa-framework-choice` и записаны
|
||||||
|
решением [ADR-2026-08-11-spa-on-vue](../../../docs/adr/ADR-2026-08-11-spa-on-vue.md):
|
||||||
|
Vue 3 с роутером пятой версии, сборка Vite, статика внутри бинарника, шаг сборки
|
||||||
|
в наборе проверок и в образе. Отвергнутые кандидаты — Svelte и React — названы
|
||||||
|
там же с ценой отказа и здесь не пересматриваются. Правила кода приложения лежат
|
||||||
|
в [conventions/web-ui.md](../../../docs/conventions/web-ui.md).
|
||||||
|
|
||||||
|
Эта задача решает не «на чём писать», а «чем сервис отдаёт написанное и что он
|
||||||
|
отвечает на пути, которого не знает».
|
||||||
|
|
||||||
|
## Что человек увидит иначе
|
||||||
|
|
||||||
|
Сегодня браузер, открывший адрес сервиса, получает отказ: сервис отвечает только
|
||||||
|
на своих адресах, и ни один из них страницы не отдаёт. После изменения тот же
|
||||||
|
адрес открывает приложение, а обновление страницы посреди приложения возвращает
|
||||||
|
тот же экран, а не ошибку. Вошедший видит своё имя, не вошедший — предложение
|
||||||
|
войти.
|
||||||
|
|
||||||
|
## Решения
|
||||||
|
|
||||||
|
### Один корневой маршрут вместо готовой раздачи библиотеки
|
||||||
|
|
||||||
|
У библиотеки есть готовый обработчик статики с подстановкой `index.html` на
|
||||||
|
непопавший путь. Он **не берётся**, и причин две.
|
||||||
|
|
||||||
|
Первая — устройство роутера. Сборка маршрутов сама вешает на путь `/` отказ
|
||||||
|
«ничего не совпало», если такого маршрута ещё нет. Образец `/` в стандартном
|
||||||
|
мультиплексоре покрывает все пути, и второй всепокрывающий образец рядом с ним —
|
||||||
|
`/{path...}` — либо конфликтует с ним, либо разрешается правилом, которое мы не
|
||||||
|
задаём. Поэтому маршрут ставится ровно на `/`: тем самым он занимает место
|
||||||
|
библиотечного отказа, а не спорит с ним.
|
||||||
|
|
||||||
|
Вторая — подстановка `index.html` слепа. Она не различает «человек обновил
|
||||||
|
страницу приложения» и «программа ошиблась адресом под корнем хранилища»: оба
|
||||||
|
случая непопавшие, и оба получили бы разметку с кодом `200`. Перечень корней
|
||||||
|
знает только сервис, и проверять его обязан он.
|
||||||
|
|
||||||
|
Отвергнуто: `apis.Static(fsys, true)` на `/{path...}` — по обеим причинам выше.
|
||||||
|
|
||||||
|
### Корни сервиса перечислены одним местом
|
||||||
|
|
||||||
|
Правило неизвестного пути обязано перечислять **все** корни (`archive`, «Адреса
|
||||||
|
приложения живут своим пространством»), а корней сегодня четыре плюс два
|
||||||
|
отдельных адреса наблюдения. Перечень заводится единой точкой в пакете
|
||||||
|
транспорта: разложенный по обработчикам, он теряет корень молча, и потерянный
|
||||||
|
корень означает разметку вместо отказа — то есть ровно ту поломку, от которой
|
||||||
|
правило и поставлено.
|
||||||
|
|
||||||
|
Что делает обработчик по порядку:
|
||||||
|
|
||||||
|
1. путь принадлежит корню сервиса или совпадает с адресом наблюдения — отказ
|
||||||
|
`404` формой библиотеки, то есть **тем же, чем он отвечает сегодня**. Своей
|
||||||
|
формы отказа здесь не заводится: под корнем приложения она уже есть и её даёт
|
||||||
|
собственный перехват `/app/`, а под чужими корнями наша форма была бы второй;
|
||||||
|
2. метод не `GET` и не `HEAD` — отказ `405`;
|
||||||
|
3. путь ведёт под каталог ресурсов, а файла там нет — отказ `404`;
|
||||||
|
4. путь совпал с файлом собранного приложения — файл;
|
||||||
|
5. прочее — разметка приложения.
|
||||||
|
|
||||||
|
**Принадлежность корню — два условия, и оба обязательны:** точное совпадение либо
|
||||||
|
префикс вместе с косой чертой. По одному префиксу корню `/app` достался бы
|
||||||
|
посторонний `/apple`; по одному префиксу с косой чертой голый `/api` не достался
|
||||||
|
бы никому и уехал бы разметкой. Проверено прогоном мультиплексора на раскладке
|
||||||
|
этого дизайна.
|
||||||
|
|
||||||
|
**Шаг 3 закрывает случай, ради которого писано правило кэширования.** Разметка
|
||||||
|
прежней сборки называет ресурсы прежней сборки; после выкладки их имена другие, и
|
||||||
|
без этого шага браузер получил бы на несуществующий ресурс `200` с разметкой,
|
||||||
|
отверг бы её по типу содержимого и показал пустой экран — а в кодах ответов
|
||||||
|
сервиса не осталось бы ничего.
|
||||||
|
|
||||||
|
**Перечень не описывает регистрацию, а порождает её.** Наши маршруты — проба
|
||||||
|
здоровья, метрики, группа приложения и группа входа — вешаются из перечня;
|
||||||
|
корни библиотеки (`/api`, `/_`) остаются её литералами и держатся своим тестом.
|
||||||
|
Иначе перечень был бы вторым описанием таблицы маршрутов, которую сегодня ведут
|
||||||
|
три места, и разошлись бы они молча: корень, заведённый регистрацией и забытый в
|
||||||
|
перечне, отдаёт разметку с кодом `200` там, где программа ждёт отказ контракта, а
|
||||||
|
компилятор не видит ничего. Заодно уходит второе перечисление адресов
|
||||||
|
наблюдения — сегодня уровень журнала называет пробу здоровья и метрики отдельно.
|
||||||
|
|
||||||
|
Цена названа: задача трогает сборку маршрутов сверх первоначальных границ.
|
||||||
|
Принято решением владельца на чекпоинте 2026-08-15; отвергнуты правило-сканер
|
||||||
|
(ловит только литералы и добавляет шаг проверок) и перечень описанием
|
||||||
|
(расхождение обнаружил бы клиент).
|
||||||
|
|
||||||
|
**В журнал этот маршрут пишет исход, а не путь.** Путь здесь целиком задаёт
|
||||||
|
спрашивающий, и до изменения он ловился отказом маршрутизатора, а теперь
|
||||||
|
становится успешным ответом: дословная строка сделала бы журнал местом, куда
|
||||||
|
аноним пишет свой текст. Пишется исход — разметка, ресурс, отказ — и длина пути.
|
||||||
|
|
||||||
|
### Приложение живёт каталогом `web/`, а вшивается своим пакетом Go
|
||||||
|
|
||||||
|
Исходники, зависимости и настройка сборки — `web/`; вшиваемый каталог —
|
||||||
|
`web/embed`; собранное сборщик кладёт в `web/embed/dist`. Вшивает его файл
|
||||||
|
`web/embed.go` пакетом `web`. Транспорт получает готовую файловую систему
|
||||||
|
параметром, а не тянет пакет сам: так тест подставляет свою сборку, не собирая
|
||||||
|
приложение.
|
||||||
|
|
||||||
|
Отвергнуто: держать исходники приложения под `internal/`. Каталог означает
|
||||||
|
«внутреннее для Go», а приложение к сборке Go отношения не имеет; сборщик
|
||||||
|
фронтенда пришлось бы учить писать результат в чужое дерево.
|
||||||
|
|
||||||
|
### Собранного приложения в git нет, а сборка Go без него не ломается
|
||||||
|
|
||||||
|
Собранное не коммитится: это производное, и его версия в git разошлась бы с
|
||||||
|
исходниками молча. Но вшивание требует, чтобы каталог существовал в момент сборки
|
||||||
|
Go, иначе `go build ./...` отказывает у всякого, кто приложение не собирал.
|
||||||
|
|
||||||
|
Отсюда раскладка в два этажа. В git лежит **пустой каталог с меткой** —
|
||||||
|
`web/embed/.gitkeep`, — а сборщик пишет в `web/embed/dist`, то есть **этажом
|
||||||
|
ниже метки**. Так очистка выходного каталога, которую сборщик делает перед каждой
|
||||||
|
сборкой, метки не касается: положенная рядом с собранным, она уехала бы первым же
|
||||||
|
прогоном, а следом — удалённой в ближайший коммит, и `go build ./...` отказал бы
|
||||||
|
у всякого, кто клонировал заново.
|
||||||
|
|
||||||
|
Обработчик, не нашедший разметки, отвечает `503` и честной страницей «приложение
|
||||||
|
не собрано» — плюс строка в журнале при подъёме. Молча отдавать `404` он не
|
||||||
|
вправе: инвариант о неслучившемся молчании относится и к этому случаю.
|
||||||
|
|
||||||
|
**Что за сборка вшита, видно снаружи.** При подъёме в журнал уходит отпечаток
|
||||||
|
вшитой разметки. Иначе бинарник молча несёт вчерашнюю сборку — вне набора
|
||||||
|
проверок порядок шагов ничем не задан, и `go run .` вшивает то, что лежит с
|
||||||
|
прошлого раза, — а отличить «не та сборка» от «та» нечем: приложение открывается
|
||||||
|
и ведёт себя как прежняя версия.
|
||||||
|
|
||||||
|
Отвергнуто: **коммитить собранное** — производное в git, расходится с
|
||||||
|
исходниками. Отвергнуто: **ронять старт** без собранного приложения — сломало бы
|
||||||
|
`go test ./...` и `go run .` у того, кто правит только Go, а команды эти
|
||||||
|
объявлены в `CLAUDE.md` работающими.
|
||||||
|
|
||||||
|
### Заголовки кэширования назначаются, а не достаются умолчанию
|
||||||
|
|
||||||
|
Отдача файла в библиотеке заголовка кэширования не ставит вовсе, а вшитый файл не
|
||||||
|
несёт и времени правки: браузер остаётся с собственной догадкой о том, сколько
|
||||||
|
хранить копию. Догадка эта разная у разных браузеров, и проверить её со стороны
|
||||||
|
сервиса нечем.
|
||||||
|
|
||||||
|
Поэтому: **признаком служит каталог, а не вид файла.** Ресурс из каталога,
|
||||||
|
который наполняет сборщик, отдаётся с долгим сроком и пометкой «неизменяемо» —
|
||||||
|
имена там строит сборщик и несёт в них отпечаток содержимого, поэтому устареть
|
||||||
|
такой ответ не может. Всё прочее, включая разметку, — с требованием спрашивать
|
||||||
|
заново.
|
||||||
|
|
||||||
|
Признак назван каталогом потому, что вид файла его не даёт: в сборке лежат и
|
||||||
|
файлы с постоянными именами — иконка, манифест, а после `installable-pwa` ещё и
|
||||||
|
service worker, у которого отпечатка не бывает по устройству. Такой файл,
|
||||||
|
однажды отданный как «неизменяемый», не отзывается со стороны сервиса ничем:
|
||||||
|
запросов он больше не увидит, а у установленного на телефон приложения залипший
|
||||||
|
service worker означает, что новая сборка не доходит вовсе.
|
||||||
|
|
||||||
|
**Отпечаток в именах здесь не нормируется.** Его делает сборщик, сервис имён не
|
||||||
|
выбирает и их нарушения не заметит — значит это правило конвенции приложения, а
|
||||||
|
не спеки: спеки нормируют поведение сервиса, а инструмент, которым его собирают,
|
||||||
|
не нормируют вовсе (`architecture.md`, преамбула).
|
||||||
|
|
||||||
|
Долгий срок — год. Число живёт настройкой в [database.md](../../../docs/database.md),
|
||||||
|
«Настройки с числовым значением», как и прочие числа проекта.
|
||||||
|
|
||||||
|
### Node приходит отдельной ступенью образа
|
||||||
|
|
||||||
|
Ступеней в образе становится три: сборка приложения, сборка бинарника, рабочий
|
||||||
|
слой. Приложение собирается **до** бинарника, потому что вшивание требует
|
||||||
|
готового каталога. Рабочего слоя это не касается: Node в нём не остаётся.
|
||||||
|
|
||||||
|
Цена названа решением ADR и здесь только подтверждается: во что слой обходится
|
||||||
|
образу по времени и по весу, не замерялось — замер делает эта задача.
|
||||||
|
|
||||||
|
### Node не ставится на машину, а зовётся контейнером
|
||||||
|
|
||||||
|
Шаг сборки приложения гоняет установщик пакетов и сборщик **внутри контейнера**,
|
||||||
|
а не вызывает их из `PATH`. Требованием к машине разработчика становится docker,
|
||||||
|
которым и так собирается образ, — второго устанавливаемого окружения сверх
|
||||||
|
`ffmpeg` не появляется вовсе.
|
||||||
|
|
||||||
|
Так снимается расхождение, которое иначе завелось бы молча: версия Node на машине
|
||||||
|
разработчика и версия в образе — два разных числа, и собранное ими приложение
|
||||||
|
различается ровно тогда, когда различаются они. Обёртка берёт **тот же** образ,
|
||||||
|
которым собирает ступень `Dockerfile`.
|
||||||
|
|
||||||
|
Отсюда три частности, каждая из которых иначе кусается на первом же прогоне:
|
||||||
|
|
||||||
|
- **имя образа берётся из `Dockerfile`**, а не объявляется вторым числом в
|
||||||
|
`Taskfile.yml`: второй дом версии разошёлся бы с первым, и сверять их было бы
|
||||||
|
нечем — своего шага сверки у него, в отличие от версий Go, нет;
|
||||||
|
- **контейнер ходит под тем же пользователем, что и вызвавший**, иначе каталог
|
||||||
|
зависимостей и собранное лягут от `root`, и убрать их с машины обычными
|
||||||
|
средствами не выйдет;
|
||||||
|
- **кэш установщика уводится наружу контейнера**, в каталог под `/tmp`: под
|
||||||
|
чужим пользователем домашнего каталога у контейнера нет, и установка иначе
|
||||||
|
отказывает, а кэш внутри контейнера не пережил бы прогон — каждый набор
|
||||||
|
проверок тянул бы зависимости заново, включая задачи, приложения не
|
||||||
|
касающиеся. В git каталог не попадает по построению: он вне дерева проекта.
|
||||||
|
|
||||||
|
**Зависимости ставятся из файла замка командой, которая его не правит.** Иначе
|
||||||
|
шаг набора проверок правит отслеживаемый файл: обновление зависимости приезжает в
|
||||||
|
коммит без чьего-либо решения, а собранное в наборе проверок перестаёт совпадать
|
||||||
|
с собранным в образе. Обе половины молчаливы — набор проверок зелёный в обоих
|
||||||
|
случаях. Ту же команду зовёт и ступень `Dockerfile`.
|
||||||
|
|
||||||
|
**Шаг ходит в сеть, и это второй такой шаг в наборе проверок.** Установщик
|
||||||
|
обращается к реестру пакетов, а docker — к реестру образов; без сети шаг
|
||||||
|
краснеет. Отказ сети и реестра — **отказ окружения, код 3**, отдельно от красной
|
||||||
|
сборки, у которой код 1. Утверждение `CLAUDE.md` «шагу `vulns` нужна сеть, и он
|
||||||
|
один такой» перестаёт быть верным и правится этой же задачей: иначе следующий
|
||||||
|
читатель раздела «Гейт» получит неверную посылку.
|
||||||
|
|
||||||
|
Решение правит **последствие** [ADR-2026-08-11-spa-on-vue](../../../docs/adr/ADR-2026-08-11-spa-on-vue.md):
|
||||||
|
там записано, что Node и его установщик становятся нужны на машине
|
||||||
|
разработчика. Нужен docker; сам ADR при этом не пересматривается — выбор
|
||||||
|
фреймворка и наличие шага сборки остаются прежними.
|
||||||
|
|
||||||
|
Отвергнуто: **ставить Node на машину** — вводит второе устанавливаемое окружение
|
||||||
|
и разъезжается с версией в образе. Отвергнуто: **дать выбор — контейнер или
|
||||||
|
локальный Node** — это второй способ делать одно и то же, и собранное ими
|
||||||
|
различалось бы в зависимости от того, у кого что стоит.
|
||||||
|
|
||||||
|
### Код приложения проверяется машиной, а не глазами
|
||||||
|
|
||||||
|
Проверок у приложения две, и обе входят в набор проверок наравне со сборкой:
|
||||||
|
**Biome** — форматирование и статический анализ, **юнит-тесты Vue** — поведение
|
||||||
|
экрана, включая показ вошедшего и три ветки ответа на вопрос «кто вошёл».
|
||||||
|
Гоняются они тем же контейнером, что и сборка, — второго окружения не заводится.
|
||||||
|
|
||||||
|
Без них требование «приложение показывает вошедшего» — единственное, ради чего
|
||||||
|
задача заводится, — не проверял бы ни один шаг: набор проверок судил бы только
|
||||||
|
раздачу из Go и оставался зелёным на сломанном приложении. Ручная проверка эту
|
||||||
|
дыру не закрывает: следующая задача её не повторит.
|
||||||
|
|
||||||
|
Оговорка на Biome одна и проверяется первым же прогоном: если он не покрывает
|
||||||
|
разметку однофайловых компонентов, инструмент меняется. Решением владельца на
|
||||||
|
чекпоинте 2026-08-15 взят он, а замена — отдельное решение, не подстановка по
|
||||||
|
ходу.
|
||||||
|
|
||||||
|
### Шаг сборки приложения — первый в наборе проверок
|
||||||
|
|
||||||
|
Он стоит **перед** сборкой Go по той же причине: вшивается то, что собрано.
|
||||||
|
Недостающий docker — отказ окружения, код 3 по общему словарю (`CLAUDE.md`,
|
||||||
|
«Гейт»), наравне с недостающим компилятором C у шага тестов; красная сборка
|
||||||
|
приложения — код 1, отказ проверки наравне с `go build`.
|
||||||
|
|
||||||
|
## Границы
|
||||||
|
|
||||||
|
Экранов, кроме показа вошедшего, не делается: их заводят `upload-and-status-screen`
|
||||||
|
и `records-list-screen`. Установка на телефон, service worker и версионирование
|
||||||
|
статики — `installable-pwa`. Готовый набор компонентов не берётся: он не выбран,
|
||||||
|
а способен удвоить собранный файл.
|
||||||
|
|
||||||
|
## Открытые вопросы
|
||||||
|
|
||||||
|
- **Набор компонентов и стили** — не решено, дом вопроса
|
||||||
|
[conventions/web-ui.md](../../../docs/conventions/web-ui.md), «Что не решено».
|
||||||
|
- **Во что слой Node обходится образу** — замеряется этой задачей, число уезжает
|
||||||
|
в решение ADR последствием.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Сервис отдаёт данные, но человеку их показать нечем: экранов нет вовсе, а
|
||||||
|
браузер, открывший адрес сервиса, получает поверхность хранилища вместо
|
||||||
|
приложения. Два основных сценария паспорта — «семейный архив» и «возвращение к
|
||||||
|
записи» — не работают ни один, и упираются они в одно: приложения не существует.
|
||||||
|
|
||||||
|
Каркас сам по себе экранов не приносит. Он приносит то, на чём они стоят:
|
||||||
|
собранное приложение, которое сервис отдаёт браузером, открывается по любому
|
||||||
|
своему адресу и показывает вошедшего, спросив об этом сервис.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Сервис начинает отдавать браузеру **приложение**: разметку и её ресурсы. Они
|
||||||
|
лежат внутри самого бинарника, отдельного каталога рядом с ним не требуется, и
|
||||||
|
внешнего веб-сервера под них не заводится.
|
||||||
|
- Неизвестный адрес **вне корней сервиса** отдаёт разметку приложения, а не
|
||||||
|
отказ. Так обновление страницы посреди приложения открывает тот же экран, а не
|
||||||
|
ошибку. Корни перечислены поимённо, и адрес **внутри** корня в приложение не
|
||||||
|
проваливается никогда: отказ контракта остаётся отказом контракта.
|
||||||
|
- Разметка и её ресурсы отдаются **без сессии**: приложение само спрашивает
|
||||||
|
сервис, кто вошёл, и уводит ко входу, когда не вошёл никто. Требование сессии
|
||||||
|
на самой разметке отдавало бы отказ вместо экрана входа.
|
||||||
|
- Открытое приложение показывает **вошедшего** — то, что сервис ответил о нём, —
|
||||||
|
и это единственное, что оно сегодня показывает.
|
||||||
|
- В сборку приходит **шаг сборки приложения**: он входит в набор проверок и в
|
||||||
|
сборку образа. Спеками это не нормируется — у сборки другой потребитель, тот,
|
||||||
|
кто собирает.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `webapp`: приложение в браузере — чем сервис его отдаёт, каким адресом оно
|
||||||
|
открывается, что делает обновление страницы посреди приложения и что человек
|
||||||
|
видит, открыв его.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `access`: разметка приложения и её ресурсы пополняют перечень адресов,
|
||||||
|
открытых без сессии; сегодня в нём только проба здоровья и метрики.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- новый каталог приложения: исходники, зависимости, настройка сборки;
|
||||||
|
- `internal/controller/http` — маршрут, отдающий разметку на неизвестном пути вне
|
||||||
|
корней, и раздача ресурсов приложения из бинарника;
|
||||||
|
- `Taskfile.yml` — шаг сборки приложения, его место в наборе проверок и в сборке
|
||||||
|
образа;
|
||||||
|
- `Dockerfile` — приложение собирается внутри образа, чтобы выкладка не зависела
|
||||||
|
от машины разработчика;
|
||||||
|
- `.gitignore` — каталог зависимостей и собранное приложение;
|
||||||
|
- новая внешняя зависимость сборки: Node и его установщик пакетов. На машину
|
||||||
|
разработчика они не ставятся — шаг сборки зовёт их контейнером, поэтому
|
||||||
|
требованием к машине становится docker, которым и так собирается образ;
|
||||||
|
- `docs/conventions/web-ui.md` и `docs/architecture.md` — правила кода
|
||||||
|
приложения перестают быть выведенными из выбора, а компонент и зависимость
|
||||||
|
сборки появляются в описании устройства.
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
# Ревью кода: `spa-skeleton`
|
||||||
|
|
||||||
|
## Сводка
|
||||||
|
|
||||||
|
- **Режим прогона:** по графу, метка **`large`**.
|
||||||
|
- **Обоснование метки, размер и сложность:** до триажа **не доехали** — задание
|
||||||
|
передало только саму метку и режим. Строка деградации, а не оценка: сверить
|
||||||
|
метку с предметом мне нечем. Свой замер (не замер разметчика): изменение
|
||||||
|
рабочего дерева — 12 изменённых файлов, +270/−35 строк, плюс ~850 строк нового
|
||||||
|
кода в `internal/controller/http/webapp*.go` и `web/`.
|
||||||
|
- **Гейт:** зелёный целиком (14 шагов), унаследованных отказов нет.
|
||||||
|
- **Находок на входе:** 26 (пункты 1–19 поимённо, 4 подпункта в 20-м, 21–23).
|
||||||
|
- **Осталось в первых двух секциях:** 7 (3 + 4). Понижено — 5, в promote — 4,
|
||||||
|
срезано потолком — 6; срезанное поимённо названо в границах покрытия.
|
||||||
|
|
||||||
|
### План с исходом по каждой теме
|
||||||
|
|
||||||
|
| Тема | Дом | Глубина | Кто закрывает | Исход |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| requirements | дельта-спеки `specs/{webapp,access}` | разбор | specs | закрыта, 7 находок |
|
||||||
|
| autotests | `CLAUDE.md`, «Гейт» | — | autotests | закрыта, 2 находки |
|
||||||
|
| conventions | `docs/conventions/` | разбор | code | закрыта, 4 находки |
|
||||||
|
| architecture | `docs/architecture.md` + `passport.md` | доказательство | architecture | закрыта, 3 находки |
|
||||||
|
| security | `docs/security.md` | доказательство | adversary | закрыта, 7 находок |
|
||||||
|
| operations | `architecture.md` «Эксплуатация» + `database.md` | доказательство | ops | закрыта, 3 находки |
|
||||||
|
| темы проекта | своих нет | — | basics | **дома у темы нет**; проход не запускался, темы разобраны именными проходами |
|
||||||
|
|
||||||
|
**Тем без отчёта нет** — все шесть заявленных домов закрыты своими проходами.
|
||||||
|
|
||||||
|
**Сигнал о заниженной метке: не поступал ни от одного прохода.** `review-code`
|
||||||
|
его не подавал; `review-basics` не запускался и подать не мог. Это «возражений
|
||||||
|
нет» от одного из двух возможных источников, а не от обоих.
|
||||||
|
|
||||||
|
**Ни один проход не объявил свой потолок и не приложил блок «Coverage of this
|
||||||
|
pass».** Это находка о прогоне, а не оформление: «находок больше нет»
|
||||||
|
неотличимо от «больше не поместилось», и ровно об этом стоит запись
|
||||||
|
`docs/review.md` от 2026-08-13 («Ни один проход не сообщает свой потолок»).
|
||||||
|
Повторяется прогон в прогон, механизации нет.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Блокирует мердж
|
||||||
|
|
||||||
|
### Панель владельца со всеми записями и файлами открывается анониму из интернета по адресу `/%5f/`
|
||||||
|
|
||||||
|
- Файл: `internal/controller/http/webapp.go:17-28,100-124`; `docs/security.md:29-38`
|
||||||
|
- Severity: major
|
||||||
|
- Confidence: high
|
||||||
|
- Оракул (снят триажем на этом прогоне): проба стандартного мультиплексора,
|
||||||
|
`/tmp/claude-1000/.../scratchpad/muxprobe`, `go run .`:
|
||||||
|
|
||||||
|
```
|
||||||
|
/_/ -> 200 HIT /_/ path="/_/" raw=""
|
||||||
|
/%5f/ -> 200 HIT /_/ path="/_/" raw="/%5f/"
|
||||||
|
/%6detrics -> 200 HIT /metrics path="/metrics" raw="/%6detrics"
|
||||||
|
/%68ealth -> 200 HIT /health path="/health" raw="/%68ealth"
|
||||||
|
/%61pi/collections -> 200 HIT /api/ path="/api/collections"
|
||||||
|
/%61pp/me -> 200 HIT /app/ path="/app/me"
|
||||||
|
```
|
||||||
|
|
||||||
|
Маршрутизатор сравнивает **раскодированный** путь, исходная форма остаётся в
|
||||||
|
`URL.RawPath`. Живой прогон враждебного прохода даёт то же: `/_/` и `/%5f/`
|
||||||
|
отвечают байт в байт, весь клиент панели (633 КБ JS, 275 КБ CSS) грузится
|
||||||
|
анониму.
|
||||||
|
- Последствие: правило Authelia на обратном прокси написано на литерал `/_/`
|
||||||
|
(`docs/security.md`, «Третий сдвиг»), поэтому закодированная форма проходит
|
||||||
|
мимо него и попадает в панель суперпользователя — все записи, все файлы, все
|
||||||
|
пользователи. Вход в само приложение при этом не обходится: `/%61pp/me`
|
||||||
|
отвечает `401`, разграничение живёт в обработчике, а не в маршруте.
|
||||||
|
- **Дефект унаследованный:** так вело себя адресное пространство и до задачи,
|
||||||
|
изменение его не вносило. В блокирующие он попал по ущербу, а не по авторству.
|
||||||
|
- **Половина пути не проверена:** правило прокси лежит в `pet-project-server`,
|
||||||
|
в этом репозитории его нет. Проверена только сторона сервиса — отсюда `major`,
|
||||||
|
а не `critical`.
|
||||||
|
- Предложение: слой, приводящий путь к канонической форме (отказ `400` при
|
||||||
|
`RawPath != ""`, если раскодированный путь попадает в перечень корней), рядом с
|
||||||
|
новым перечнем `ServiceMounts`; плюс правка `docs/security.md`.
|
||||||
|
**Осторожно:** глухая проверка `RawPath != ""` сломает скачивание файлов
|
||||||
|
записей с пробелами и не-латиницей в имени — они приходят закодированными
|
||||||
|
законно.
|
||||||
|
- Найдено проходом: adversary; оракул перепроверен триажем.
|
||||||
|
- **Действие: развилка.**
|
||||||
|
> Панель `/_/` доступна анониму по `/%5f/` мимо Authelia — дефект старше этой
|
||||||
|
> задачи, но именно она заводит перечень корней, куда лечение просится.
|
||||||
|
> Варианты:
|
||||||
|
> 1. чинить здесь: слой канонизации пути на перечне `ServiceMounts`, отказ
|
||||||
|
> `400` только на закодированной форме корня, тест на `/%5f/`, `/%6detrics`
|
||||||
|
> и на законный закодированный путь файла (цена — день, растёт scope задачи);
|
||||||
|
> 2. чинить отдельной задачей, а в этой задаче только записать дыру в
|
||||||
|
> `docs/security.md` и завести задачу в беклоге (цена — час, дыра живёт до
|
||||||
|
> выкладки);
|
||||||
|
> 3. закрыть на стороне выкладки — правило прокси на регулярном выражении по
|
||||||
|
> сырому пути (цена — вне репозитория, проверить прогоном нечем).
|
||||||
|
|
||||||
|
### Образ собирается из дерева разработчика, а не из объявленных входов
|
||||||
|
|
||||||
|
- Файл: `Dockerfile:12-16,37`; отсутствующий `.dockerignore`; `.gitignore:57-61`
|
||||||
|
- Severity: major
|
||||||
|
- Confidence: high
|
||||||
|
- Оракул: `ls -la .dockerignore` → `No such file or directory`; в `Dockerfile`
|
||||||
|
ступень `front-build` делает `COPY web/package.json web/package-lock.json ./`,
|
||||||
|
`RUN npm ci`, а следом `COPY web/ ./` — то есть локальный `web/node_modules`
|
||||||
|
ложится **поверх** результата `npm ci`; ступень `build-env` делает `COPY . .`.
|
||||||
|
`Taskfile.yml:163` монтирует `$PWD/web` в контейнер, поэтому каталог
|
||||||
|
зависимостей в дереве разработчика заводится при каждом `task front`, а
|
||||||
|
`.gitignore` прячет его от git, но не от docker.
|
||||||
|
- Последствие: артефакт, который едет на сервер, собирается из того, что лежит у
|
||||||
|
собравшего, а не из `package-lock.json`, — и **молча**: сборка при этом
|
||||||
|
зелёная. В промежуточный слой `build-env` при `COPY . .` уезжают также `.git`
|
||||||
|
(12 МБ), локальный `config.toml` и каталог `data/` того, кто запускал сервис
|
||||||
|
локально; в финальный образ они не попадают, но живут в слоях сборки.
|
||||||
|
- **Поправка к исходной находке:** заявленное падение ступени из-за чужих
|
||||||
|
платформенных пакетов маловероятно — зависимости в дерево кладёт тот же
|
||||||
|
`node:24-alpine`, что и ступень образа. Замер «193 МБ» сегодня не
|
||||||
|
воспроизводится: `web/node_modules` пуст. Существо находки от этого не
|
||||||
|
меняется — вход сборки не ограничен ничем.
|
||||||
|
- Предложение: `.dockerignore` в корне с `web/node_modules/`, `web/embed/dist/`,
|
||||||
|
`data/`, `.git/`, `config.toml`, `tmp/`.
|
||||||
|
- Найдено проходом: code/техника; оракул перепроверен триажем.
|
||||||
|
- **Действие: инлайн.**
|
||||||
|
|
||||||
|
### Путь и адрес анонима уезжают в журнал хранилища, хотя записанное свойство прогона утверждает обратное
|
||||||
|
|
||||||
|
- Файл: `internal/controller/http/webapp.go:231-284`; `docs/review.md:75`
|
||||||
|
- Severity: major
|
||||||
|
- Confidence: high
|
||||||
|
- Оракул (перепроверен триажем по исходникам библиотеки,
|
||||||
|
`pocketbase@v0.39.10`): `apis/base.go:117-120` — `apis.Static` первой строкой
|
||||||
|
ставит `requestEventKeySkipSuccessActivityLog`; `apis/middlewares.go:349-445` —
|
||||||
|
`activityLogger()` навешен на **все** маршруты и при `err == nil` без этого
|
||||||
|
признака пишет `url` (`RequestURI`, до 3000 знаков), `referer`, `userAgent`, а
|
||||||
|
при `Logs.LogIP` — `userIP` и `remoteIP`; `core/settings_model.go:158-159` —
|
||||||
|
умолчания `MaxDays: 5`, `LogIP: true`; в проекте настройки журнала не
|
||||||
|
трогаются вовсе (`grep 'Settings().Logs' → пусто`). Наша раздача написана мимо
|
||||||
|
`apis.Static` и признак не ставит.
|
||||||
|
- Последствие: каждый успешный ответ разметкой и ресурсом кладёт в таблицу
|
||||||
|
`_logs` выбранный анонимом путь и его адрес, и лежит это пять суток. Строка
|
||||||
|
`docs/review.md`, добавленная этой же задачей, — «путь, выбранный анонимом, не
|
||||||
|
уходит ни строкой журнала, ни меткой метрики» — верна для журнала контейнера и
|
||||||
|
неверна для журнала хранилища. Ложное записанное свойство хуже отсутствующего:
|
||||||
|
следующий прогон возьмёт его оракулом.
|
||||||
|
Частично унаследовано: отказы (`err != nil`) писались в `_logs` и раньше;
|
||||||
|
новое — успешные ответы, то есть основной поток.
|
||||||
|
- Предложение: привязать `apis.SkipSuccessActivityLog()` к корневому маршруту в
|
||||||
|
`WebappHandler.Register` и поправить строку в `docs/review.md`, назвав оба
|
||||||
|
журнала.
|
||||||
|
- Найдено проходом: architecture.
|
||||||
|
- **Действие: инлайн.**
|
||||||
|
|
||||||
|
## Стоит исправить сейчас
|
||||||
|
|
||||||
|
### `docs/security.md` описывает периметр уже кода: новая анонимная поверхность в нём не названа
|
||||||
|
|
||||||
|
- Файл: `docs/security.md` (не тронут изменением); `internal/controller/http/webapp.go:236-284`
|
||||||
|
- Severity: major
|
||||||
|
- Confidence: high
|
||||||
|
- Оракул: `git status` — `docs/security.md` в изменениях рабочего дерева
|
||||||
|
отсутствует, тогда как раздача заведена; раздел «Периметр» перечисляет входы
|
||||||
|
без корневого маршрута, а таблица «Что разграничивает доступ» о раздаче не
|
||||||
|
знает.
|
||||||
|
- Последствие: анонимно открытым стал весь путь вне шести корней плюс всё
|
||||||
|
содержимое сборки, и документ этого не говорит. Следующие прогоны читают
|
||||||
|
периметр отсюда и не станут искать пути через раздачу вовсе — блиндаж,
|
||||||
|
который накапливается.
|
||||||
|
- Предложение: дописать в «Периметр» корневой маршрут (что отдаётся анониму,
|
||||||
|
что — нет) и строку в «Что разграничивает доступ» про раздачу.
|
||||||
|
- Найдено проходом: adversary.
|
||||||
|
- **Действие: инлайн.**
|
||||||
|
|
||||||
|
### Расхождение раскладки сборки с ожиданиями кода не ловится ничем: гейт зелёный, приложение отвечает `503` либо теряет правила `404` и срока хранения
|
||||||
|
|
||||||
|
- Файл: `web/embed.go:22-44`; `web/vite.config.ts:8-13`;
|
||||||
|
`internal/controller/http/webapp.go:36,262-275,291-301`
|
||||||
|
- Severity: minor
|
||||||
|
- Confidence: high
|
||||||
|
- Оракул: `grep` — ни один тест не зовёт `web.Dist()`, все проверки раздачи
|
||||||
|
получают `fstest.MapFS`; `outDir: 'embed/dist'` в сборщике и `distDir =
|
||||||
|
"embed/dist"` в Go связаны только совпадением строк; `build.assetsDir` в
|
||||||
|
`vite.config.ts` не задан вовсе, а Go держит `const assetsDir = "assets"` —
|
||||||
|
сегодня они совпадают по умолчанию сборщика.
|
||||||
|
- Последствие: одна причина, два исхода. Правка `outDir` даёт `built == false`
|
||||||
|
и `503` на каждом пути показа при зелёном гейте; смена умолчания `assetsDir`
|
||||||
|
сборщиком молча выключает и правило `404` под каталогом ресурсов, и годовой
|
||||||
|
срок хранения — тесты остаются зелёными, потому что подставная сборка
|
||||||
|
называет каталог сама.
|
||||||
|
- Предложение: `web/embed_test.go` на оба состояния `Dist()` (собрано / пусто),
|
||||||
|
проверяющий именно вшитое дерево — наличие `index.html` и каталога ресурсов;
|
||||||
|
плюс `build.assetsDir: 'assets'` явной строкой в `vite.config.ts`.
|
||||||
|
- Найдено проходами: autotests (п. 1), specs (пп. 3, 4), architecture (п. 14) —
|
||||||
|
одна причина в четырёх формулировках; оракула у согласия нет, приоритет
|
||||||
|
подняло само совпадение.
|
||||||
|
- **Действие: инлайн.**
|
||||||
|
|
||||||
|
### Каталог ресурсов сборщика сам по себе отвечает разметкой с кодом `200`
|
||||||
|
|
||||||
|
- Файл: `internal/controller/http/webapp.go:262-277`
|
||||||
|
- Severity: minor
|
||||||
|
- Confidence: high
|
||||||
|
- Оракул: чтение кода воспроизводит путь целиком — `fs.Stat("assets")` даёт
|
||||||
|
каталог, ветка ресурса пропускается по `!info.IsDir()`, а
|
||||||
|
`strings.HasPrefix("assets", "assets/")` — ложь, поэтому запрос уходит в
|
||||||
|
`serveIndex`. Враждебный проход подтвердил падающим тестом и живым прогоном:
|
||||||
|
`/assets` и `/assets/` → `200` с разметкой.
|
||||||
|
- Последствие: нарушено записанное свойство узла
|
||||||
|
(`docs/review.md:66-67`, «несовпавший ресурс под каталогом сборщика отвечает
|
||||||
|
`404`, а не разметкой с кодом `200`»). Ровно от этой ошибки — префикс без
|
||||||
|
точного совпадения — в соседних строках защищается `Mount.Covers`, и здесь
|
||||||
|
осталось одно условие из двух.
|
||||||
|
- Предложение: `name == assetsDir || strings.HasPrefix(name, assetsDir+"/")` в
|
||||||
|
обеих ветках — и в отказе, и в `setCacheHeader`.
|
||||||
|
- Найдено проходом: adversary.
|
||||||
|
- **Действие: инлайн.**
|
||||||
|
|
||||||
|
### Три решения раздачи не имеют дома: следующий автор снимет их, не нарушив ни одного требования
|
||||||
|
|
||||||
|
- Файл: `internal/controller/http/webapp.go:156-163,201-224`; `main.go:347-362`;
|
||||||
|
`openspec/changes/spa-skeleton/specs/webapp/spec.md`; `docs/architecture.md`;
|
||||||
|
`openspec/changes/spa-skeleton/tasks.md` (задачи 2.8, 5.2)
|
||||||
|
- Severity: minor
|
||||||
|
- Confidence: high
|
||||||
|
- Оракул: правило «путь в журнал не идёт» живёт в комментарии и тесте, но не в
|
||||||
|
дельта-спеке; у отпечатка вшитой сборки (`buildFingerprint`) нет ни
|
||||||
|
требования, ни теста, а задача 2.8 отмечена выполненной; capability `webapp`
|
||||||
|
не объявлена в перечне `docs/architecture.md`, задача 5.2 не отмечена;
|
||||||
|
сценарий «Отсутствие сборки видно в журнале» оракула не имеет — буфер журнала
|
||||||
|
в `TestWebappNotBuilt` заведён, но не читается.
|
||||||
|
- Последствие: решение без дома снимается следующей задачей бесплатно и молча.
|
||||||
|
Ближайший пример уже виден: вернуть путь анонима в журнал можно, не нарушив ни
|
||||||
|
одного записанного требования, — и это ровно то свойство, которое блокирующая
|
||||||
|
находка выше показала уже нарушенным. Capability без объявления даёт второе
|
||||||
|
описание раздачи у следующего автора.
|
||||||
|
- Предложение: дописать в дельту `webapp` требования «путь в журнал не идёт» и
|
||||||
|
«отпечаток вшитой сборки в журнале подъёма»; строку capability `webapp` в
|
||||||
|
`docs/architecture.md`; дочитать буфер журнала в `TestWebappNotBuilt`; отметить
|
||||||
|
задачи 2.8 и 5.2.
|
||||||
|
- Найдено проходами: specs (пп. 5, 6, 7, 9).
|
||||||
|
- **Действие: инлайн.**
|
||||||
|
|
||||||
|
## Гипотезы без доказательства
|
||||||
|
|
||||||
|
- **Человек, ошибшийся адресом, видит пустой экран с кодом `200`**
|
||||||
|
(`web/src/router.ts:9-12`). Поведение подтверждено чтением — в таблице один
|
||||||
|
маршрут `/`, catch-all нет, — а **дефектность не подтверждена**: требования на
|
||||||
|
этот счёт нет ни в дельте, ни в `web-ui.md`. Решает владелец: маршрут «такого
|
||||||
|
экрана нет» либо запись границей. Найдено: specs (п. 8).
|
||||||
|
- **Разметка уходит без `Content-Security-Policy`**, тогда как панель на том же
|
||||||
|
порту его получает. Пути нет: ближайший вход в разметку — ответ языковой
|
||||||
|
модели из ещё не сделанной `llm-insights-adapter`. Понижено до `minor`,
|
||||||
|
переведено в promote. Найдено: adversary (п. 20).
|
||||||
|
- **Состав вшитого никем не судится** — что именно уехало в бинарник, не
|
||||||
|
проверяет ни тест, ни шаг. Ущерб не построен, оракула нет. Найдено: adversary
|
||||||
|
(п. 20).
|
||||||
|
- **У корневого маршрута нет потолка частоты** (ограничитель заведён только на
|
||||||
|
`/app/`). Замера нагрузки нет, ущерб не построен; проект работает на единицах
|
||||||
|
записей в день (`docs/review.md`, «Недоступно проверке»). Найдено: ops (п. 23).
|
||||||
|
- **`POST /api/collections/users/request-verification` отвечает `204` анониму.**
|
||||||
|
Предмет старше изменения, пути к ущербу проход не построил. Найдено: adversary
|
||||||
|
(п. 20).
|
||||||
|
|
||||||
|
## Promote candidates
|
||||||
|
|
||||||
|
- **Зависимости приложения не сканируются на уязвимости** (`Taskfile.yml`, шаг
|
||||||
|
`front`; `web/package.json`). `govulncheck` смотрит только Go, ручной
|
||||||
|
`npm audit` сегодня чист. Это не находка о коде, а **пробел в правиле**:
|
||||||
|
`CLAUDE.md`, «Чего в гейте намеренно нет», перечисляет исключения поимённо, а
|
||||||
|
новая экосистема не попала ни в проверки, ни в этот перечень. Кандидат:
|
||||||
|
шаг `npm audit` в гейт (словарь кодов общий) **либо** строка исключения с
|
||||||
|
причиной. Решение владельца, не оркестратора. Найдено: autotests (п. 2).
|
||||||
|
- **Копия перечня корней в тесте расходится с перечнем молча**
|
||||||
|
(`journal_route_test.go:15-24` против `webapp.go:76-88`). Тест стережёт
|
||||||
|
инвариант `critical` про имя файла и сверяется с рукописной копией: новый
|
||||||
|
корень в копию не попадёт, тест останется зелёным. Кандидат: правило в
|
||||||
|
`internal/archrules` — тем же способом, каким проект уже стережёт перечень
|
||||||
|
колонок и дескриптор рубежа. Найдено: architecture (п. 16).
|
||||||
|
- **Заголовки безопасности разметки** — где объявляется `Content-Security-Policy`
|
||||||
|
и кто за него отвечает; сегодня дома у правила нет
|
||||||
|
(`docs/conventions/web-ui.md` о заголовках не говорит).
|
||||||
|
- **Поле `Exact` в `Mount` несёт два смысла** — «точный адрес» и «адрес
|
||||||
|
наблюдения». Сегодня они совпадают, завтра разойдутся. Кандидат в конвенцию
|
||||||
|
описания адресного пространства, а не правка этого кода. Найдено: architecture
|
||||||
|
(п. 16).
|
||||||
|
|
||||||
|
## Границы покрытия
|
||||||
|
|
||||||
|
### План: темы, дома, глубины
|
||||||
|
|
||||||
|
Воспроизведён таблицей в сводке выше. Тема **«темы проекта» дома не имеет** —
|
||||||
|
своих тем у проекта не заведено, поэтому `review-basics` не запускался, и это
|
||||||
|
не пропуск, а отсутствие предмета.
|
||||||
|
|
||||||
|
### Проходы
|
||||||
|
|
||||||
|
- Запускались на метке `large`, режим «по графу»: `specs`, `autotests`, `code`,
|
||||||
|
`architecture`, `adversary`, `ops`. Отчёт пришёл от каждого.
|
||||||
|
- Не запускался: `review-basics` — все темы разобраны именными проходами.
|
||||||
|
- **Что каждый проход не мог проверить в принципе — назвать нечем: ни один
|
||||||
|
проход не приложил блок «Coverage of this pass» и ни один не объявил свой
|
||||||
|
потолок.** Сколько находок осталось за срезом у `specs` (7 показанных) и у
|
||||||
|
`adversary` (7 показанных) — неизвестно. Это находка о прогоне, повторяющая
|
||||||
|
запись `docs/review.md` от 2026-08-13.
|
||||||
|
- **Потолок триажа сработал.** Срезано шесть проверенных находок, поимённо:
|
||||||
|
1. `Taskfile.yml`, шаг `front`: рассинхронизованный `package-lock.json`
|
||||||
|
роняет гейт кодом 3 после **удавшегося** `npm ping`, то есть при заведомо
|
||||||
|
живой сети, — словарь `CLAUDE.md` («Гейт») велит здесь код 1, дрейф. Правка
|
||||||
|
однострочная (code, п. 11).
|
||||||
|
2. `main.go:212-219`, `webapp.go:159-166`: два новых поля журнала заведены мимо
|
||||||
|
словаря `docs/conventions/logging.md`, домен `webapp.*` не объявлен (code,
|
||||||
|
п. 12).
|
||||||
|
3. `webapp.go:24` против `auth.go:96-105`: корень `/auth` объявлен константой
|
||||||
|
`AuthRoot` и оставлен литералом в трёх регистрациях. Правка `AuthRoot` даст
|
||||||
|
разметку на `/auth/callback` — возврат от провайдера сломается без единой
|
||||||
|
ошибки (code, п. 13).
|
||||||
|
4. `webapp.go:280-301`: разметка с `no-cache` никогда не подтверждается `304` —
|
||||||
|
`embed.FS` отдаёт нулевой `ModTime`, `ETag` не выставляется, каждая
|
||||||
|
ревалидация тянет полное тело. Готовый `buildFingerprint` под `ETag` уже
|
||||||
|
посчитан (ops, п. 22).
|
||||||
|
5. `webapp.outcome=failure` доезжает с `http.status_code=0` — предмет уже
|
||||||
|
заведён задачей `response-code-in-journal` (adversary, п. 20).
|
||||||
|
6. Задача 3.6 (`tasks.md`) закрыть на этом окружении нечем — см. ниже.
|
||||||
|
- **Задача 3.6 остаётся человеку.** `docker build --target front-build` виснет
|
||||||
|
на `npm ci` и в обычной сети, и с `--network=host`: из контейнера нет
|
||||||
|
исходящей сети вовсе при рабочем DNS. Замерено то, что измеримо: вшитое
|
||||||
|
приложение добавляет к бинарнику 86 072 байта, Node в финальный слой не
|
||||||
|
попадает структурно, `task front` без сети отвечает за 11,3 с кодом 3. Число
|
||||||
|
размера образа снимается на машине с сетью (ops, п. 21).
|
||||||
|
|
||||||
|
### Что осталось целиком на человеке
|
||||||
|
|
||||||
|
**Не проверит ни один проход** (`docs/review.md`, «Недоступно проверке»):
|
||||||
|
|
||||||
|
- поведение внешних сервисов под нагрузкой и на границах — SpeechKit и Object
|
||||||
|
Storage поднять в тесте нечем;
|
||||||
|
- реальный профиль нагрузки: проект работает на единицах записей в день;
|
||||||
|
- стойкость `ffmpeg` к вредоносному входу;
|
||||||
|
- поведение настоящей Authelia и её правило на нашего клиента — настройка
|
||||||
|
выкладки вне репозитория. **На этом прогоне это стоило половины оракула у
|
||||||
|
первой блокирующей находки**: сторона сервиса проверена, сторона прокси — нет;
|
||||||
|
- поведение браузера с куками (`SameSite`, приём `Set-Cookie` при переходе с
|
||||||
|
чужого сайта).
|
||||||
|
|
||||||
|
**Перестали проверять сознательно** (тот же раздел, отдельным списком):
|
||||||
|
|
||||||
|
- разбор вывода настоящего `ffprobe` — своего теста у `adapter/metaviewer/ffmpeg`
|
||||||
|
нет ([ADR-2026-08-11-stub-adapters-in-tests](../../../../docs/adr/ADR-2026-08-11-stub-adapters-in-tests.md));
|
||||||
|
- работа сервиса с настоящими внешними собеседниками: живой прогон доступен и
|
||||||
|
покрывает подъём, маршруты, панель, журнал, метрики и остановку, но за
|
||||||
|
настоящие SpeechKit и Object Storage не отвечает — ключи выдуманные,
|
||||||
|
распознавание подменяется в коде; вход через живого провайдера OIDC тоже
|
||||||
|
недоступен.
|
||||||
|
|
||||||
|
Общее, что не проверяет ни один прогон в принципе: история инцидентов;
|
||||||
|
поведение под реальным потоком; поведение внешних систем в их версиях; завязка
|
||||||
|
потребителей на текущее поведение; и вопрос «нужна ли эта функциональность
|
||||||
|
вообще».
|
||||||
|
|
||||||
|
### Каких документов и данных не хватило
|
||||||
|
|
||||||
|
- **Разметка не передала размер, сложность и обоснование метки** — в задании
|
||||||
|
триажу только метка и режим. Сверить `large` с предметом нечем; сигнала о
|
||||||
|
заниженной метке при этом не подавал никто.
|
||||||
|
- **Правила обратного прокси нет в репозитории** — оно живёт в
|
||||||
|
`pet-project-server`. Из-за этого путь к панели через `/%5f/` подтверждён
|
||||||
|
только на стороне сервиса, и находка держит `major`, а не `critical`.
|
||||||
|
- **`docs/conventions/web-ui.md` не говорит о заголовках безопасности** —
|
||||||
|
документ есть, предмета в нём нет; отсюда `Content-Security-Policy` уехал в
|
||||||
|
promote, а не в находки.
|
||||||
|
- **`openspec/changes/spa-skeleton/specs/webapp/spec.md` не покрывает три
|
||||||
|
решения раздачи** — дельта есть, требований на предмет в ней нет (пункт 4
|
||||||
|
второй секции).
|
||||||
|
- **`docs/review.md`, «Типовые ложноположительные», прочитан и применён**:
|
||||||
|
четыре записи, ни одна к находкам этого прогона не подошла. Отсев вкусовщины
|
||||||
|
шёл по общим критериям и по этому разделу.
|
||||||
|
|
||||||
|
### Четыре строки, которых не принесёт ни один проход
|
||||||
|
|
||||||
|
1. **Решения проекта не сверялись.** `docs/adr.*` — процессный документ, прогон
|
||||||
|
его не открывает. Расхождение изменения с записанным решением ловит
|
||||||
|
`av-dev:doc-healthcheck`, а не ревью. У этой задачи ADR есть
|
||||||
|
(`ADR-2026-08-11-spa-on-vue.md`, тронут), и сверен он не был.
|
||||||
|
2. **Записанные наблюдения проекта не использовались.** `docs/research.*` не
|
||||||
|
открывался. Все числа в отчёте сняты на этом прогоне: размер `.git` (`du`),
|
||||||
|
умолчания журнала PocketBase (исходники модуля), 86 072 байта прироста
|
||||||
|
бинарника (замер прохода `ops`), таблица ответов мультиплексора (`go run`).
|
||||||
|
3. **Поимённая сверка с руководствами по стилю Go, TypeScript и Vue не
|
||||||
|
задавалась ни одним проходом.** Различение «идиоматично против
|
||||||
|
распространено» не спрашивает никто — прохода про идиоматичность в конвейере
|
||||||
|
нет. Для этой задачи это ощутимее обычного: `web/` — новая для проекта
|
||||||
|
экосистема, и её первый код никем на идиоматичность не смотрен.
|
||||||
|
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
|
||||||
|
нет.** Проход независимой реализации снят по стоимости, а не по замеру;
|
||||||
|
«не знаю, чего не знаю» здесь не достаёт никто.
|
||||||
|
|
||||||
|
Формулировка «критичных проблем не обнаружено» к этому отчёту неприменима: три
|
||||||
|
находки блокируют мердж, и ещё шесть проверенных срезаны потолком и названы
|
||||||
|
поимённо выше.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Приложение отдаётся без сессии
|
||||||
|
|
||||||
|
Сервис SHALL отдавать разметку приложения и её ресурсы без сессии. Перечень
|
||||||
|
адресов, открытых анонимно, пополняется ими: прежде в нём стояли только проба
|
||||||
|
здоровья и метрики.
|
||||||
|
|
||||||
|
Причина в самом входе: человек, ещё не вошедший, дошёл бы до входа только через
|
||||||
|
приложение, а закрытая сессией разметка отдала бы ему отказ вместо экрана. Цена
|
||||||
|
открытости названа здесь же и невелика — ни разметка, ни ресурсы содержимого
|
||||||
|
записей не несут: они одинаковы для всех и собираются до всякого запроса.
|
||||||
|
|
||||||
|
Открытость MUST не касаться данных: всякий адрес под корнем приложения
|
||||||
|
по-прежнему требует сессии, и приложение, открытое анонимно, не получает ни
|
||||||
|
одной записи.
|
||||||
|
|
||||||
|
#### Scenario: Разметка доступна анонимно
|
||||||
|
|
||||||
|
- **WHEN** запрос приходит на корень сервиса без сессии
|
||||||
|
- **THEN** ответ имеет код `200`
|
||||||
|
- **AND** тело ответа — разметка приложения
|
||||||
|
|
||||||
|
#### Scenario: Ресурс приложения доступен анонимно
|
||||||
|
|
||||||
|
- **WHEN** запрос приходит на ресурс приложения без сессии
|
||||||
|
- **THEN** ответ имеет код `200`
|
||||||
|
|
||||||
|
#### Scenario: Данные анонимно не отдаются
|
||||||
|
|
||||||
|
- **GIVEN** приложение открыто без сессии
|
||||||
|
- **WHEN** оно спрашивает список записей
|
||||||
|
- **THEN** ответ имеет код `401`
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Приложение отдаётся самим бинарником
|
||||||
|
|
||||||
|
Сервис SHALL отдавать разметку приложения и её ресурсы из самого бинарника.
|
||||||
|
Каталога с собранными файлами рядом с бинарником MUST не требоваться, и внешнего
|
||||||
|
веб-сервера под раздачу MUST не заводиться.
|
||||||
|
|
||||||
|
Причина в выкладке: сервис едет на сервер одним образом, и второй разворачиваемый
|
||||||
|
артефакт рядом с ним завёл бы вторую точку, где выкладка расходится с собранным.
|
||||||
|
Бинарник, которому нужен каталог рядом, отдаёт пустую страницу молча — каталог
|
||||||
|
либо забыли положить, либо положили не тот, и различить это снаружи нечем.
|
||||||
|
|
||||||
|
#### Scenario: Приложение открывается у бинарника без каталога рядом
|
||||||
|
|
||||||
|
- **GIVEN** бинарник запущен в каталоге, где нет ничего, кроме его настроек
|
||||||
|
- **WHEN** браузер спрашивает корень сервиса
|
||||||
|
- **THEN** ответ имеет код `200`
|
||||||
|
- **AND** тело ответа — разметка приложения
|
||||||
|
|
||||||
|
#### Scenario: Ресурс приложения отдаётся оттуда же
|
||||||
|
|
||||||
|
- **GIVEN** разметка приложения названа своим ресурсом
|
||||||
|
- **WHEN** браузер спрашивает этот ресурс
|
||||||
|
- **THEN** ответ имеет код `200`
|
||||||
|
- **AND** тело ответа — содержимое ресурса
|
||||||
|
|
||||||
|
### Requirement: Сервис, поднятый без собранного приложения, говорит об этом
|
||||||
|
|
||||||
|
Сервис SHALL отвечать кодом `503` на всяком пути, где он отдал бы разметку, если
|
||||||
|
собранного приложения в нём нет, и MUST писать об этом строку в журнал при
|
||||||
|
подъёме. Отвечать `404` и молчать он MUST не вправе: «приложения нет» и «такого
|
||||||
|
адреса нет» — разные состояния, и первое чинится сборкой, а не поиском опечатки в
|
||||||
|
адресе.
|
||||||
|
|
||||||
|
Состояние это возможно только у собранного мимо набора проверок: и набор
|
||||||
|
проверок, и сборка образа собирают приложение раньше бинарника. Проба здоровья
|
||||||
|
при этом остаётся зелёной: она отвечает за то, работает ли сервис, а сервис в
|
||||||
|
этом состоянии принимает записи и расшифровывает их — не работает только показ.
|
||||||
|
|
||||||
|
#### Scenario: Пустая сборка отвечает отказом, а не отсутствием адреса
|
||||||
|
|
||||||
|
- **GIVEN** бинарник собран без собранного приложения
|
||||||
|
- **WHEN** браузер спрашивает корень сервиса
|
||||||
|
- **THEN** ответ имеет код `503`
|
||||||
|
|
||||||
|
#### Scenario: Отсутствие сборки видно в журнале
|
||||||
|
|
||||||
|
- **GIVEN** бинарник собран без собранного приложения
|
||||||
|
- **WHEN** сервис поднимается
|
||||||
|
- **THEN** в журнале есть строка о том, что приложение не собрано
|
||||||
|
|
||||||
|
#### Scenario: Проба здоровья остаётся зелёной
|
||||||
|
|
||||||
|
- **GIVEN** бинарник собран без собранного приложения
|
||||||
|
- **WHEN** запрос приходит на пробу здоровья
|
||||||
|
- **THEN** ответ имеет код `200`
|
||||||
|
|
||||||
|
### Requirement: Неизвестный путь вне корней открывает приложение
|
||||||
|
|
||||||
|
Сервис SHALL отдавать разметку приложения на всяком пути, который не принадлежит
|
||||||
|
ни одному корню сервиса и не совпадает с отдельным адресом наблюдения. Корни
|
||||||
|
перечислены поимённо — `/api` у хранилища, `/app` у приложения, `/auth` у входа,
|
||||||
|
`/_` у панели, — отдельными адресами стоят `/health` и `/metrics`.
|
||||||
|
|
||||||
|
Путь принадлежит корню, когда **совпадает с ним точно либо начинается им вместе с
|
||||||
|
косой чертой**. Оба условия обязательны: по одному лишь префиксу корню `/app`
|
||||||
|
достался бы посторонний `/apple`, а по одному лишь префиксу с косой чертой голый
|
||||||
|
`/api` не достался бы никому и уехал бы разметкой.
|
||||||
|
|
||||||
|
Путь, принадлежащий корню, MUST не проваливаться в приложение никогда: отказ
|
||||||
|
контракта остаётся отказом контракта и уходит той формой, которой этот корень
|
||||||
|
отвечает и сегодня. Иначе программа, ошибшаяся адресом под корнем приложения,
|
||||||
|
получила бы разметку с кодом `200` вместо отказа с машиночитаемым кодом — и
|
||||||
|
приняла бы её за ответ.
|
||||||
|
|
||||||
|
Путь **под каталогом ресурсов** — тем, который наполняет сборщик, — разметкой не
|
||||||
|
подменяется: не совпавший с файлом, он MUST отвечать `404`. Иначе разметка
|
||||||
|
прежней сборки, назвавшая ресурс, которого в новой сборке уже нет, получает на
|
||||||
|
него `200` и разметку вместо ресурса: браузер отвергнет её по типу содержимого,
|
||||||
|
человек увидит пустой экран, а в кодах ответов сервиса не останется ничего.
|
||||||
|
|
||||||
|
Открывающими страницу считаются `GET` и `HEAD`, и только они; прочие методы MUST
|
||||||
|
отвечать `405`.
|
||||||
|
|
||||||
|
#### Scenario: Обновление страницы посреди приложения открывает тот же экран
|
||||||
|
|
||||||
|
- **GIVEN** приложение открыто на своём маршруте
|
||||||
|
- **WHEN** браузер спрашивает этот путь заново
|
||||||
|
- **THEN** ответ имеет код `200`
|
||||||
|
- **AND** тело ответа — разметка приложения
|
||||||
|
|
||||||
|
#### Scenario: Голый корень разметкой не подменяется
|
||||||
|
|
||||||
|
- **WHEN** запрос приходит на путь, совпадающий с корнем сервиса точно и без
|
||||||
|
косой черты
|
||||||
|
- **THEN** тело ответа — не разметка приложения
|
||||||
|
|
||||||
|
#### Scenario: Посторонний путь, начинающийся именем корня, открывает приложение
|
||||||
|
|
||||||
|
- **WHEN** запрос приходит на путь, который начинается именем корня, но не
|
||||||
|
отделён от него косой чертой
|
||||||
|
- **THEN** ответ имеет код `200`
|
||||||
|
- **AND** тело ответа — разметка приложения
|
||||||
|
|
||||||
|
#### Scenario: Неизвестный путь под корнем приложения отвечает отказом
|
||||||
|
|
||||||
|
- **GIVEN** человек вошёл и предъявил сессию
|
||||||
|
- **WHEN** он спрашивает неизвестный путь под корнем приложения
|
||||||
|
- **THEN** ответ имеет код `404`
|
||||||
|
- **AND** тело ответа — отказ приложения с машиночитаемым кодом, а не разметка
|
||||||
|
|
||||||
|
#### Scenario: Неизвестный путь под корнем приложения без сессии отвечает как все прочие его адреса
|
||||||
|
|
||||||
|
- **WHEN** запрос приходит на неизвестный путь под корнем приложения без сессии
|
||||||
|
- **THEN** ответ имеет код `401`
|
||||||
|
- **AND** тело ответа — не разметка приложения
|
||||||
|
|
||||||
|
#### Scenario: Неизвестный путь под корнем хранилища отвечает отказом
|
||||||
|
|
||||||
|
- **WHEN** запрос приходит на неизвестный путь под корнем хранилища
|
||||||
|
- **THEN** тело ответа — не разметка приложения
|
||||||
|
|
||||||
|
#### Scenario: Несуществующий ресурс отвечает отсутствием, а не разметкой
|
||||||
|
|
||||||
|
- **WHEN** браузер спрашивает под каталогом ресурсов файл, которого в сборке нет
|
||||||
|
- **THEN** ответ имеет код `404`
|
||||||
|
- **AND** тело ответа — не разметка приложения
|
||||||
|
|
||||||
|
#### Scenario: Наблюдение приложением не подменяется
|
||||||
|
|
||||||
|
- **WHEN** запрос приходит на пробу здоровья
|
||||||
|
- **THEN** отвечает проба здоровья, а не приложение
|
||||||
|
|
||||||
|
#### Scenario: Чужой метод отвечает отказом
|
||||||
|
|
||||||
|
- **WHEN** на неизвестный путь вне корней приходит запрос методом, которым
|
||||||
|
страницу не открывают
|
||||||
|
- **THEN** ответ имеет код `405`
|
||||||
|
|
||||||
|
#### Scenario: Проверка доступности разметку получает
|
||||||
|
|
||||||
|
- **WHEN** разметку спрашивают методом `HEAD`
|
||||||
|
- **THEN** ответ имеет код `200`
|
||||||
|
|
||||||
|
### Requirement: Обновлённое приложение доходит до браузера
|
||||||
|
|
||||||
|
Сервис SHALL отдавать **ресурс из каталога, который наполняет сборщик**, с долгим
|
||||||
|
сроком хранения и пометкой «неизменяемо», а всё прочее, включая разметку, — с
|
||||||
|
требованием спрашивать заново. Признак — каталог, а не вид файла: имена в нём
|
||||||
|
строит сборщик и несёт в них отпечаток содержимого, поэтому изменившийся ресурс
|
||||||
|
приезжает под новым именем и прежний ответ устареть не может.
|
||||||
|
|
||||||
|
Долгий срок MUST не доставаться файлу вне этого каталога. Разметка, иконка,
|
||||||
|
манифест и всякий файл с постоянным именем меняются под тем же именем, и
|
||||||
|
отозвать у браузера выданное «неизменяемо» нечем: выложенное обновление не дойдёт
|
||||||
|
до того, кто уже открывал приложение, пока он не почистит хранилище браузера
|
||||||
|
руками. Заметить это со стороны сервиса нечем — запросов он больше не увидит.
|
||||||
|
|
||||||
|
Отпечаток в именах — свойство сборки, а не сервиса, и нормой его держит конвенция
|
||||||
|
приложения, а не эта спека: сервис имён не выбирает и их нарушения не заметит.
|
||||||
|
Здесь названо только то, что делает с ними сам сервис.
|
||||||
|
|
||||||
|
#### Scenario: Ресурс сборщика отдаётся с долгим сроком
|
||||||
|
|
||||||
|
- **WHEN** браузер спрашивает ресурс из каталога, который наполняет сборщик
|
||||||
|
- **THEN** ответ несёт долгий срок хранения и пометку «неизменяемо»
|
||||||
|
|
||||||
|
#### Scenario: Разметка спрашивается заново
|
||||||
|
|
||||||
|
- **WHEN** браузер спрашивает разметку приложения
|
||||||
|
- **THEN** ответ несёт требование спрашивать её заново
|
||||||
|
- **AND** пометки «неизменяемо» в ответе нет
|
||||||
|
|
||||||
|
#### Scenario: Файл с постоянным именем долгого срока не получает
|
||||||
|
|
||||||
|
- **WHEN** браузер спрашивает файл сборки, лежащий вне каталога ресурсов
|
||||||
|
- **THEN** ответ несёт требование спрашивать его заново
|
||||||
|
|
||||||
|
### Requirement: Путь, отданный приложению, в журнал не идёт
|
||||||
|
|
||||||
|
Сервис SHALL записывать о таком запросе **исход из закрытого перечня** —
|
||||||
|
разметка, ресурс, отказ — и длину пути, а самого пути MUST не записывать ни в
|
||||||
|
один свой журнал. То же относится к меткам метрик.
|
||||||
|
|
||||||
|
Причина в том, кто этот путь выбирает. До появления раздачи путь вне корней
|
||||||
|
ловил отказ маршрутизатора; теперь он успешный ответ, и множеством его значений
|
||||||
|
распоряжается спрашивающий — дословная запись сделала бы журнал местом, куда
|
||||||
|
аноним пишет свой текст произвольной длины. Правило того же рода у сервиса уже
|
||||||
|
есть: причина отказа, пришедшая от провайдера строкой запроса, приводится к
|
||||||
|
перечню известных.
|
||||||
|
|
||||||
|
Журналов при этом **два**: свой и журнал хранилища, куда библиотека кладёт путь
|
||||||
|
целиком вместе с адресом отправителя. Требование относится к обоим.
|
||||||
|
|
||||||
|
#### Scenario: Путь не доезжает до журнала
|
||||||
|
|
||||||
|
- **WHEN** приходит запрос на путь вне корней сервиса
|
||||||
|
- **THEN** записи о нём не несут этого пути
|
||||||
|
- **AND** несут исход и длину пути
|
||||||
|
|
||||||
|
#### Scenario: Длинный путь журнал не наполняет
|
||||||
|
|
||||||
|
- **WHEN** приходит запрос на путь длиной в тысячу знаков
|
||||||
|
- **THEN** записи о нём не растут вместе с длиной пути
|
||||||
|
|
||||||
|
### Requirement: Сервис объявляет, какая сборка приложения в нём вшита
|
||||||
|
|
||||||
|
Сервис SHALL писать при подъёме отпечаток вшитой сборки. Он же MUST уходить
|
||||||
|
меткой ответа с разметкой: сама разметка отдаётся с требованием спрашивать её
|
||||||
|
заново, и без метки браузер получает полное тело вместо подтверждения — вшитый
|
||||||
|
файл не несёт времени правки вовсе.
|
||||||
|
|
||||||
|
Без отпечатка «не та сборка» неотличима от «той»: вне набора проверок порядок
|
||||||
|
шагов ничем не задан, и бинарник собирается с тем, что лежало в каталоге с
|
||||||
|
прошлого раза. Приложение при этом открывается и ведёт себя как прежняя версия,
|
||||||
|
а искать причину человек идёт в код сервиса.
|
||||||
|
|
||||||
|
#### Scenario: Отпечаток виден при подъёме
|
||||||
|
|
||||||
|
- **WHEN** сервис поднимается с собранным приложением
|
||||||
|
- **THEN** в журнале есть отпечаток вшитой сборки
|
||||||
|
|
||||||
|
#### Scenario: Разметка несёт метку ответа
|
||||||
|
|
||||||
|
- **WHEN** браузер спрашивает разметку приложения
|
||||||
|
- **THEN** ответ несёт метку, по которой её можно спросить заново
|
||||||
|
|
||||||
|
### Requirement: Открытое приложение показывает вошедшего
|
||||||
|
|
||||||
|
Приложение SHALL спрашивать сервис, кто вошёл, и показывать его имя. Отказ
|
||||||
|
`401` MUST уводить ко входу: человек, ещё не вошедший, получает его на всяком
|
||||||
|
адресе данных, и это его штатное состояние, а не поломка.
|
||||||
|
|
||||||
|
Всякий **иной** отказ и сорванный запрос MUST ко входу не уводить, а показываться
|
||||||
|
строкой о неудаче. Трактовка «любой отказ значит не вошёл» замкнула бы круг:
|
||||||
|
приложение ушло бы ко входу, вход вернул бы человека в приложение, и на отказе
|
||||||
|
сервиса или ограничителя частоты круг пошёл бы заново.
|
||||||
|
|
||||||
|
Имя вошедшего берётся ответом сервиса, а не кукой сессии: кука недоступна
|
||||||
|
скриптам страницы, и другого способа узнать вошедшего у приложения нет. Имени в
|
||||||
|
ответе может не быть вовсе — тогда приложение MUST показать, что вход выполнен,
|
||||||
|
и MUST не подставлять вместо имени адрес почты: его в ответе нет по норме
|
||||||
|
`access`.
|
||||||
|
|
||||||
|
#### Scenario: Вошедший виден
|
||||||
|
|
||||||
|
- **GIVEN** человек вошёл и получил куку сессии
|
||||||
|
- **WHEN** он открывает приложение
|
||||||
|
- **THEN** приложение показывает его имя
|
||||||
|
|
||||||
|
#### Scenario: Не вошедшему предлагается вход
|
||||||
|
|
||||||
|
- **GIVEN** сессии у человека нет
|
||||||
|
- **WHEN** он открывает приложение
|
||||||
|
- **THEN** приложение ведёт его ко входу
|
||||||
|
|
||||||
|
#### Scenario: Отказ сервиса ко входу не уводит
|
||||||
|
|
||||||
|
- **GIVEN** сервис отвечает на вопрос о вошедшем отказом, который не является
|
||||||
|
отсутствием сессии
|
||||||
|
- **WHEN** человек открывает приложение
|
||||||
|
- **THEN** приложение показывает строку о неудаче
|
||||||
|
- **AND** ко входу оно не уводит
|
||||||
|
|
||||||
|
#### Scenario: Учётная запись без имени
|
||||||
|
|
||||||
|
- **GIVEN** человек вошёл, а имени у его учётной записи нет
|
||||||
|
- **WHEN** он открывает приложение
|
||||||
|
- **THEN** приложение показывает, что вход выполнен
|
||||||
|
- **AND** адреса почты на экране нет
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
## 1. Каталог приложения и его сборка
|
||||||
|
|
||||||
|
- [x] 1.1 Завести `web/` с `package.json`, файлом замка, настройкой Vite и
|
||||||
|
`index.html`; выходной каталог сборщика — `web/embed/dist`
|
||||||
|
- [x] 1.2 Команда сборки приложения гоняет проверку типов и сборку одним вызовом
|
||||||
|
(`vue-tsc` входит в команду сборки, а не стоит отдельным шагом)
|
||||||
|
- [x] 1.3 В git лежит `web/embed/.gitkeep` — этажом выше выходного каталога, чтобы
|
||||||
|
очистка перед сборкой его не уносила; собранное и каталог зависимостей внесены
|
||||||
|
в `.gitignore`
|
||||||
|
- [x] 1.4 Написан экран-заглушка: показывает имя вошедшего, на `401` ведёт ко
|
||||||
|
входу, на прочий отказ показывает строку о неудаче; одна таблица маршрутов
|
||||||
|
через `createRouter`, адреса обычные (`createWebHistory`)
|
||||||
|
- [x] 1.5 Обращение к API идёт через одну свою обёртку над `fetch`; она же
|
||||||
|
единственное место, где читается код ответа
|
||||||
|
- [x] 1.6 Заведён Biome: форматирование и статический анализ кода приложения.
|
||||||
|
Не покрывает разметку однофайловых компонентов — сказать строкой и остановиться,
|
||||||
|
замена инструмента решается человеком
|
||||||
|
- [x] 1.7 Заведены юнит-тесты Vue: показ вошедшего, увод ко входу на `401`,
|
||||||
|
строка о неудаче на прочем отказе, учётная запись без имени
|
||||||
|
|
||||||
|
## 2. Раздача приложения из бинарника
|
||||||
|
|
||||||
|
- [x] 2.1 `web/embed.go` вшивает `web/embed` целиком (`all:`), включая метку
|
||||||
|
пустого каталога
|
||||||
|
- [x] 2.2 Перечень корней сервиса заведён единой точкой в пакете транспорта:
|
||||||
|
`/api`, `/app`, `/auth`, `/_` и отдельные адреса `/health`, `/metrics`
|
||||||
|
- [x] 2.2.1 Наши маршруты вешаются **из перечня**: проба здоровья, метрики,
|
||||||
|
группа приложения, группа входа. Корни библиотеки остаются её литералами и
|
||||||
|
держатся своим тестом; второе перечисление адресов наблюдения в уровне журнала
|
||||||
|
снято
|
||||||
|
- [x] 2.3 Принадлежность корню — точное совпадение либо префикс вместе с косой
|
||||||
|
чертой; оба условия проверяются
|
||||||
|
- [x] 2.4 Обработчик приложения зарегистрирован маршрутом `/` (тем самым занимает
|
||||||
|
место библиотечного отказа) и принимает файловую систему параметром
|
||||||
|
- [x] 2.5 Порядок обработчика: корень сервиса или адрес наблюдения — отказ формой
|
||||||
|
библиотеки; метод не `GET` и не `HEAD` — `405`; несовпавший путь под каталогом
|
||||||
|
ресурсов — `404`; совпавший файл — файл; прочее — разметка
|
||||||
|
- [x] 2.6 Заголовки кэширования назначены по каталогу: ресурсы сборщика — год и
|
||||||
|
«неизменяемо», всё прочее, включая разметку, — спрашивать заново
|
||||||
|
- [x] 2.7 Отсутствие собранного приложения даёт `503` с честной страницей и
|
||||||
|
строку в журнале при подъёме, а не `404` молча; проба здоровья остаётся зелёной
|
||||||
|
- [x] 2.8 При подъёме в журнал уходит отпечаток вшитой разметки
|
||||||
|
- [x] 2.9 Строка журнала корневого маршрута несёт исход и длину пути, но не сам
|
||||||
|
путь
|
||||||
|
- [x] 2.10 Разметка и ресурсы отдаются без сессии; адреса данных под корнем
|
||||||
|
приложения сессии по-прежнему требуют
|
||||||
|
|
||||||
|
## 3. Сборка и выкладка
|
||||||
|
|
||||||
|
- [x] 3.1 Шаг сборки приложения заведён в `Taskfile.yml` и стоит **первым** в
|
||||||
|
наборе проверок, до сборки Go; рядом — шаги Biome и юнит-тестов приложения
|
||||||
|
- [x] 3.1.1 Проверки приложения гоняются тем же контейнером, что и сборка;
|
||||||
|
второго окружения не заводится
|
||||||
|
- [x] 3.2 Шаг зовёт установщик и сборщик контейнером, а не из `PATH`: имя образа
|
||||||
|
берётся из `Dockerfile`, контейнер ходит под вызвавшим пользователем, кэш
|
||||||
|
установщика уведён в каталог под `/tmp`
|
||||||
|
- [x] 3.3 Зависимости ставятся из файла замка командой, которая его не правит; ту
|
||||||
|
же команду зовёт ступень `Dockerfile`
|
||||||
|
- [x] 3.4 Недостающий docker и отказ сети или реестра — отказ окружения, код 3;
|
||||||
|
красная сборка приложения — код 1
|
||||||
|
- [x] 3.5 В `Dockerfile` добавлена ступень сборки приложения перед сборкой
|
||||||
|
бинарника; в рабочий слой Node не попадает
|
||||||
|
- [x] 3.6 Вес выяснен, время замера снято с задачи решением владельца
|
||||||
|
2026-08-15: финальный образ от ступени приложения не растёт вовсе (Node в него
|
||||||
|
не копируется), вшитое добавляет к бинарнику 86 072 байта. Время сборки
|
||||||
|
остаётся неизвестным сознательно
|
||||||
|
- [x] 3.7 Последствие ADR-2026-08-11-spa-on-vue поправлено: требованием к машине
|
||||||
|
разработчика становится docker, а не Node
|
||||||
|
|
||||||
|
## 4. Проверки
|
||||||
|
|
||||||
|
- [x] 4.1 Тест: неизвестный путь вне корней отдаёт разметку с кодом `200`
|
||||||
|
- [x] 4.2 Тест: неизвестный путь под каждым корнем сервиса разметки не отдаёт
|
||||||
|
- [x] 4.3 Тест: голый корень без косой черты разметки не отдаёт, а посторонний
|
||||||
|
путь, начинающийся именем корня без косой черты, — отдаёт
|
||||||
|
- [x] 4.4 Тест: неизвестный путь под корнем приложения отвечает `404` с сессией и
|
||||||
|
`401` без неё
|
||||||
|
- [x] 4.5 Тест: несуществующий файл под каталогом ресурсов отвечает `404`
|
||||||
|
- [x] 4.6 Тест: проба здоровья и метрики приложением не подменяются
|
||||||
|
- [x] 4.7 Тест: чужой метод отвечает `405`, а `HEAD` на разметку — `200`
|
||||||
|
- [x] 4.8 Тест: разметка и ресурс отдаются без сессии, а список записей — нет
|
||||||
|
- [x] 4.9 Тест: у разметки стоит требование спрашивать заново, у ресурса из
|
||||||
|
каталога сборщика — год и «неизменяемо»
|
||||||
|
- [x] 4.10 Тест: пустая сборка даёт `503`, а не `404`, и проба здоровья при этом
|
||||||
|
зелёная
|
||||||
|
- [x] 4.11 Юнит-тесты приложения краснеют на сломанном экране: намеренная поломка
|
||||||
|
показа вошедшего роняет набор проверок
|
||||||
|
- [x] 4.12 `task gate` зелёный целиком
|
||||||
|
|
||||||
|
## 5. Документы
|
||||||
|
|
||||||
|
- [x] 5.1 `docs/conventions/web-ui.md` — снята оговорка «кода приложения ещё
|
||||||
|
нет»; дописаны правила: отпечаток в именах ресурсов, несовпавший ресурс отвечает
|
||||||
|
`404`, зависимости ставятся из файла замка
|
||||||
|
- [x] 5.2 `docs/architecture.md` — компонент приложения, внешняя зависимость
|
||||||
|
сборки, новая capability и перечень корней в единых точках
|
||||||
|
- [x] 5.3 `docs/database.md` — срок хранения ресурсов в настройках с числовым
|
||||||
|
значением
|
||||||
|
- [x] 5.4 `CLAUDE.md` — команды и требования к машине разработчика, состав гейта
|
||||||
|
вместе с проверками приложения, второй сетезависимый шаг названный поимённо
|
||||||
|
- [x] 5.5 `docs/review.md` — заведён род узла «раздача собранной статики и шаг
|
||||||
|
сборки приложения» в типовых узлах
|
||||||
|
- [x] 5.6 `docs/conventions/go-linters.md` либо соседняя запись — чем проверяется
|
||||||
|
код приложения: Biome и юнит-тесты, что каждый ловит и где настроен
|
||||||
|
|
||||||
|
## Критерии приёмки
|
||||||
|
|
||||||
|
Дословно из записи задачи `spa-skeleton`:
|
||||||
|
|
||||||
|
- Статика собирается одной командой и вшивается в бинарник: запущенный бинарник
|
||||||
|
отдаёт приложение без каталога рядом. Оракул — сборка, запуск бинарника из
|
||||||
|
пустого каталога, запрос корня возвращает разметку приложения.
|
||||||
|
- Образ собирается на чистой машине без предустановленного окружения фронтенда.
|
||||||
|
Оракул — `task image` без локально установленных зависимостей фронтенда.
|
||||||
|
- Приложение показывает, кто вошёл, и обращается к API живого сервера. Оракул —
|
||||||
|
тест либо ручная проверка на запущенном сервере с сессией.
|
||||||
|
- Шаг сборки статики входит в `task gate` и краснеет при ошибке сборки. Оракул —
|
||||||
|
намеренно сломанный исходник роняет `task gate`.
|
||||||
|
- Обновление страницы на любом маршруте приложения открывает тот же экран, а
|
||||||
|
адрес внутри корня сервиса в приложение не проваливается. Оракул — тест на три
|
||||||
|
запроса: неизвестный путь вне корней отдаёт разметку, неизвестный путь внутри
|
||||||
|
`/api/` и внутри `/app/` отдаёт ошибку контракта.
|
||||||
|
|
||||||
|
Рубрика ревью дизайна, тем же списком:
|
||||||
|
|
||||||
|
- Перечень корней живёт одной точкой, и добавление корня не требует правки в двух
|
||||||
|
местах.
|
||||||
|
- Запрос несуществующего файла под каталогом ресурсов отвечает `404`, а не
|
||||||
|
разметкой с `200`.
|
||||||
|
- После прогона сборки приложения `git status` чист по `web/`.
|
||||||
|
- После `task gate` `git status` чист по файлу замка зависимостей.
|
||||||
|
- Отсутствие сборки громкое: код ответа, страница и строка журнала; «сборки нет»
|
||||||
|
отличается от «файла нет».
|
||||||
|
- Долгий неотзываемый срок ставится только файлу из каталога ресурсов сборщика.
|
||||||
|
- Разметка и ресурсы отдаются анонимно, данные под корнем приложения — нет; по
|
||||||
|
анонимному ответу нельзя узнать, вошёл ли кто-то.
|
||||||
|
- В бинарник попадает только собранное: ни карт исходников, ни каталога
|
||||||
|
зависимостей, ни файлов вне выходного каталога.
|
||||||
|
- Отпечаток вшитой сборки виден строкой журнала при подъёме.
|
||||||
|
- Шаг сборки следует словарю кодов: недостающий инструмент и отказ сети — 3,
|
||||||
|
красная сборка — 1; молчаливого пропуска нет.
|
||||||
|
- Ступень сборки не протекает в рабочий слой образа, а версия сборочного
|
||||||
|
окружения объявлена одним местом.
|
||||||
|
- Файловая система строится один раз, а не на каждый запрос; поведение на `HEAD`
|
||||||
|
определено.
|
||||||
|
- Запрос на путь длиной в килобайт не добавляет значения ни метке метрики, ни
|
||||||
|
строке журнала.
|
||||||
@@ -446,3 +446,34 @@ MUST отвечать отказом `401`, когда сессии нет. Св
|
|||||||
- **WHEN** приложение спрашивает, кто вошёл
|
- **WHEN** приложение спрашивает, кто вошёл
|
||||||
- **THEN** адреса почты в ответе нет
|
- **THEN** адреса почты в ответе нет
|
||||||
|
|
||||||
|
### Requirement: Приложение отдаётся без сессии
|
||||||
|
|
||||||
|
Сервис SHALL отдавать разметку приложения и её ресурсы без сессии. Перечень
|
||||||
|
адресов, открытых анонимно, пополняется ими: прежде в нём стояли только проба
|
||||||
|
здоровья и метрики.
|
||||||
|
|
||||||
|
Причина в самом входе: человек, ещё не вошедший, дошёл бы до входа только через
|
||||||
|
приложение, а закрытая сессией разметка отдала бы ему отказ вместо экрана. Цена
|
||||||
|
открытости названа здесь же и невелика — ни разметка, ни ресурсы содержимого
|
||||||
|
записей не несут: они одинаковы для всех и собираются до всякого запроса.
|
||||||
|
|
||||||
|
Открытость MUST не касаться данных: всякий адрес под корнем приложения
|
||||||
|
по-прежнему требует сессии, и приложение, открытое анонимно, не получает ни
|
||||||
|
одной записи.
|
||||||
|
|
||||||
|
#### Scenario: Разметка доступна анонимно
|
||||||
|
|
||||||
|
- **WHEN** запрос приходит на корень сервиса без сессии
|
||||||
|
- **THEN** ответ имеет код `200`
|
||||||
|
- **AND** тело ответа — разметка приложения
|
||||||
|
|
||||||
|
#### Scenario: Ресурс приложения доступен анонимно
|
||||||
|
|
||||||
|
- **WHEN** запрос приходит на ресурс приложения без сессии
|
||||||
|
- **THEN** ответ имеет код `200`
|
||||||
|
|
||||||
|
#### Scenario: Данные анонимно не отдаются
|
||||||
|
|
||||||
|
- **GIVEN** приложение открыто без сессии
|
||||||
|
- **WHEN** оно спрашивает список записей
|
||||||
|
- **THEN** ответ имеет код `401`
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
# webapp Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Приложение в браузере: чем сервис его отдаёт, каким адресом оно открывается, что
|
||||||
|
делает обновление страницы посреди него и что человек видит, открыв его.
|
||||||
|
|
||||||
|
Спека отвечает за **сервис**, а не за сборщик: правило неизвестного пути, срок
|
||||||
|
хранения ответов, поведение при несобранном приложении и то, что уходит в журнал.
|
||||||
|
Отпечаток в именах ресурсов — свойство сборки, и его дом — конвенция приложения.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Приложение отдаётся самим бинарником
|
||||||
|
|
||||||
|
Сервис SHALL отдавать разметку приложения и её ресурсы из самого бинарника.
|
||||||
|
Каталога с собранными файлами рядом с бинарником MUST не требоваться, и внешнего
|
||||||
|
веб-сервера под раздачу MUST не заводиться.
|
||||||
|
|
||||||
|
Причина в выкладке: сервис едет на сервер одним образом, и второй разворачиваемый
|
||||||
|
артефакт рядом с ним завёл бы вторую точку, где выкладка расходится с собранным.
|
||||||
|
Бинарник, которому нужен каталог рядом, отдаёт пустую страницу молча — каталог
|
||||||
|
либо забыли положить, либо положили не тот, и различить это снаружи нечем.
|
||||||
|
|
||||||
|
#### Scenario: Приложение открывается у бинарника без каталога рядом
|
||||||
|
|
||||||
|
- **GIVEN** бинарник запущен в каталоге, где нет ничего, кроме его настроек
|
||||||
|
- **WHEN** браузер спрашивает корень сервиса
|
||||||
|
- **THEN** ответ имеет код `200`
|
||||||
|
- **AND** тело ответа — разметка приложения
|
||||||
|
|
||||||
|
#### Scenario: Ресурс приложения отдаётся оттуда же
|
||||||
|
|
||||||
|
- **GIVEN** разметка приложения названа своим ресурсом
|
||||||
|
- **WHEN** браузер спрашивает этот ресурс
|
||||||
|
- **THEN** ответ имеет код `200`
|
||||||
|
- **AND** тело ответа — содержимое ресурса
|
||||||
|
|
||||||
|
### Requirement: Сервис, поднятый без собранного приложения, говорит об этом
|
||||||
|
|
||||||
|
Сервис SHALL отвечать кодом `503` на всяком пути, где он отдал бы разметку, если
|
||||||
|
собранного приложения в нём нет, и MUST писать об этом строку в журнал при
|
||||||
|
подъёме. Отвечать `404` и молчать он MUST не вправе: «приложения нет» и «такого
|
||||||
|
адреса нет» — разные состояния, и первое чинится сборкой, а не поиском опечатки в
|
||||||
|
адресе.
|
||||||
|
|
||||||
|
Состояние это возможно только у собранного мимо набора проверок: и набор
|
||||||
|
проверок, и сборка образа собирают приложение раньше бинарника. Проба здоровья
|
||||||
|
при этом остаётся зелёной: она отвечает за то, работает ли сервис, а сервис в
|
||||||
|
этом состоянии принимает записи и расшифровывает их — не работает только показ.
|
||||||
|
|
||||||
|
#### Scenario: Пустая сборка отвечает отказом, а не отсутствием адреса
|
||||||
|
|
||||||
|
- **GIVEN** бинарник собран без собранного приложения
|
||||||
|
- **WHEN** браузер спрашивает корень сервиса
|
||||||
|
- **THEN** ответ имеет код `503`
|
||||||
|
|
||||||
|
#### Scenario: Отсутствие сборки видно в журнале
|
||||||
|
|
||||||
|
- **GIVEN** бинарник собран без собранного приложения
|
||||||
|
- **WHEN** сервис поднимается
|
||||||
|
- **THEN** в журнале есть строка о том, что приложение не собрано
|
||||||
|
|
||||||
|
#### Scenario: Проба здоровья остаётся зелёной
|
||||||
|
|
||||||
|
- **GIVEN** бинарник собран без собранного приложения
|
||||||
|
- **WHEN** запрос приходит на пробу здоровья
|
||||||
|
- **THEN** ответ имеет код `200`
|
||||||
|
|
||||||
|
### Requirement: Неизвестный путь вне корней открывает приложение
|
||||||
|
|
||||||
|
Сервис SHALL отдавать разметку приложения на всяком пути, который не принадлежит
|
||||||
|
ни одному корню сервиса и не совпадает с отдельным адресом наблюдения. Корни
|
||||||
|
перечислены поимённо — `/api` у хранилища, `/app` у приложения, `/auth` у входа,
|
||||||
|
`/_` у панели, — отдельными адресами стоят `/health` и `/metrics`.
|
||||||
|
|
||||||
|
Путь принадлежит корню, когда **совпадает с ним точно либо начинается им вместе с
|
||||||
|
косой чертой**. Оба условия обязательны: по одному лишь префиксу корню `/app`
|
||||||
|
достался бы посторонний `/apple`, а по одному лишь префиксу с косой чертой голый
|
||||||
|
`/api` не достался бы никому и уехал бы разметкой.
|
||||||
|
|
||||||
|
Путь, принадлежащий корню, MUST не проваливаться в приложение никогда: отказ
|
||||||
|
контракта остаётся отказом контракта и уходит той формой, которой этот корень
|
||||||
|
отвечает и сегодня. Иначе программа, ошибшаяся адресом под корнем приложения,
|
||||||
|
получила бы разметку с кодом `200` вместо отказа с машиночитаемым кодом — и
|
||||||
|
приняла бы её за ответ.
|
||||||
|
|
||||||
|
Путь **под каталогом ресурсов** — тем, который наполняет сборщик, — разметкой не
|
||||||
|
подменяется: не совпавший с файлом, он MUST отвечать `404`. Иначе разметка
|
||||||
|
прежней сборки, назвавшая ресурс, которого в новой сборке уже нет, получает на
|
||||||
|
него `200` и разметку вместо ресурса: браузер отвергнет её по типу содержимого,
|
||||||
|
человек увидит пустой экран, а в кодах ответов сервиса не останется ничего.
|
||||||
|
|
||||||
|
Открывающими страницу считаются `GET` и `HEAD`, и только они; прочие методы MUST
|
||||||
|
отвечать `405`.
|
||||||
|
|
||||||
|
#### Scenario: Обновление страницы посреди приложения открывает тот же экран
|
||||||
|
|
||||||
|
- **GIVEN** приложение открыто на своём маршруте
|
||||||
|
- **WHEN** браузер спрашивает этот путь заново
|
||||||
|
- **THEN** ответ имеет код `200`
|
||||||
|
- **AND** тело ответа — разметка приложения
|
||||||
|
|
||||||
|
#### Scenario: Голый корень разметкой не подменяется
|
||||||
|
|
||||||
|
- **WHEN** запрос приходит на путь, совпадающий с корнем сервиса точно и без
|
||||||
|
косой черты
|
||||||
|
- **THEN** тело ответа — не разметка приложения
|
||||||
|
|
||||||
|
#### Scenario: Посторонний путь, начинающийся именем корня, открывает приложение
|
||||||
|
|
||||||
|
- **WHEN** запрос приходит на путь, который начинается именем корня, но не
|
||||||
|
отделён от него косой чертой
|
||||||
|
- **THEN** ответ имеет код `200`
|
||||||
|
- **AND** тело ответа — разметка приложения
|
||||||
|
|
||||||
|
#### Scenario: Неизвестный путь под корнем приложения отвечает отказом
|
||||||
|
|
||||||
|
- **GIVEN** человек вошёл и предъявил сессию
|
||||||
|
- **WHEN** он спрашивает неизвестный путь под корнем приложения
|
||||||
|
- **THEN** ответ имеет код `404`
|
||||||
|
- **AND** тело ответа — отказ приложения с машиночитаемым кодом, а не разметка
|
||||||
|
|
||||||
|
#### Scenario: Неизвестный путь под корнем приложения без сессии отвечает как все прочие его адреса
|
||||||
|
|
||||||
|
- **WHEN** запрос приходит на неизвестный путь под корнем приложения без сессии
|
||||||
|
- **THEN** ответ имеет код `401`
|
||||||
|
- **AND** тело ответа — не разметка приложения
|
||||||
|
|
||||||
|
#### Scenario: Неизвестный путь под корнем хранилища отвечает отказом
|
||||||
|
|
||||||
|
- **WHEN** запрос приходит на неизвестный путь под корнем хранилища
|
||||||
|
- **THEN** тело ответа — не разметка приложения
|
||||||
|
|
||||||
|
#### Scenario: Несуществующий ресурс отвечает отсутствием, а не разметкой
|
||||||
|
|
||||||
|
- **WHEN** браузер спрашивает под каталогом ресурсов файл, которого в сборке нет
|
||||||
|
- **THEN** ответ имеет код `404`
|
||||||
|
- **AND** тело ответа — не разметка приложения
|
||||||
|
|
||||||
|
#### Scenario: Наблюдение приложением не подменяется
|
||||||
|
|
||||||
|
- **WHEN** запрос приходит на пробу здоровья
|
||||||
|
- **THEN** отвечает проба здоровья, а не приложение
|
||||||
|
|
||||||
|
#### Scenario: Чужой метод отвечает отказом
|
||||||
|
|
||||||
|
- **WHEN** на неизвестный путь вне корней приходит запрос методом, которым
|
||||||
|
страницу не открывают
|
||||||
|
- **THEN** ответ имеет код `405`
|
||||||
|
|
||||||
|
#### Scenario: Проверка доступности разметку получает
|
||||||
|
|
||||||
|
- **WHEN** разметку спрашивают методом `HEAD`
|
||||||
|
- **THEN** ответ имеет код `200`
|
||||||
|
|
||||||
|
### Requirement: Обновлённое приложение доходит до браузера
|
||||||
|
|
||||||
|
Сервис SHALL отдавать **ресурс из каталога, который наполняет сборщик**, с долгим
|
||||||
|
сроком хранения и пометкой «неизменяемо», а всё прочее, включая разметку, — с
|
||||||
|
требованием спрашивать заново. Признак — каталог, а не вид файла: имена в нём
|
||||||
|
строит сборщик и несёт в них отпечаток содержимого, поэтому изменившийся ресурс
|
||||||
|
приезжает под новым именем и прежний ответ устареть не может.
|
||||||
|
|
||||||
|
Долгий срок MUST не доставаться файлу вне этого каталога. Разметка, иконка,
|
||||||
|
манифест и всякий файл с постоянным именем меняются под тем же именем, и
|
||||||
|
отозвать у браузера выданное «неизменяемо» нечем: выложенное обновление не дойдёт
|
||||||
|
до того, кто уже открывал приложение, пока он не почистит хранилище браузера
|
||||||
|
руками. Заметить это со стороны сервиса нечем — запросов он больше не увидит.
|
||||||
|
|
||||||
|
Отпечаток в именах — свойство сборки, а не сервиса, и нормой его держит конвенция
|
||||||
|
приложения, а не эта спека: сервис имён не выбирает и их нарушения не заметит.
|
||||||
|
Здесь названо только то, что делает с ними сам сервис.
|
||||||
|
|
||||||
|
#### Scenario: Ресурс сборщика отдаётся с долгим сроком
|
||||||
|
|
||||||
|
- **WHEN** браузер спрашивает ресурс из каталога, который наполняет сборщик
|
||||||
|
- **THEN** ответ несёт долгий срок хранения и пометку «неизменяемо»
|
||||||
|
|
||||||
|
#### Scenario: Разметка спрашивается заново
|
||||||
|
|
||||||
|
- **WHEN** браузер спрашивает разметку приложения
|
||||||
|
- **THEN** ответ несёт требование спрашивать её заново
|
||||||
|
- **AND** пометки «неизменяемо» в ответе нет
|
||||||
|
|
||||||
|
#### Scenario: Файл с постоянным именем долгого срока не получает
|
||||||
|
|
||||||
|
- **WHEN** браузер спрашивает файл сборки, лежащий вне каталога ресурсов
|
||||||
|
- **THEN** ответ несёт требование спрашивать его заново
|
||||||
|
|
||||||
|
### Requirement: Путь, отданный приложению, в журнал не идёт
|
||||||
|
|
||||||
|
Сервис SHALL записывать о таком запросе **исход из закрытого перечня** —
|
||||||
|
разметка, ресурс, отказ — и длину пути, а самого пути MUST не записывать ни в
|
||||||
|
один свой журнал. То же относится к меткам метрик.
|
||||||
|
|
||||||
|
Причина в том, кто этот путь выбирает. До появления раздачи путь вне корней
|
||||||
|
ловил отказ маршрутизатора; теперь он успешный ответ, и множеством его значений
|
||||||
|
распоряжается спрашивающий — дословная запись сделала бы журнал местом, куда
|
||||||
|
аноним пишет свой текст произвольной длины. Правило того же рода у сервиса уже
|
||||||
|
есть: причина отказа, пришедшая от провайдера строкой запроса, приводится к
|
||||||
|
перечню известных.
|
||||||
|
|
||||||
|
Журналов при этом **два**: свой и журнал хранилища, куда библиотека кладёт путь
|
||||||
|
целиком вместе с адресом отправителя. Требование относится к обоим.
|
||||||
|
|
||||||
|
#### Scenario: Путь не доезжает до журнала
|
||||||
|
|
||||||
|
- **WHEN** приходит запрос на путь вне корней сервиса
|
||||||
|
- **THEN** записи о нём не несут этого пути
|
||||||
|
- **AND** несут исход и длину пути
|
||||||
|
|
||||||
|
#### Scenario: Длинный путь журнал не наполняет
|
||||||
|
|
||||||
|
- **WHEN** приходит запрос на путь длиной в тысячу знаков
|
||||||
|
- **THEN** записи о нём не растут вместе с длиной пути
|
||||||
|
|
||||||
|
### Requirement: Сервис объявляет, какая сборка приложения в нём вшита
|
||||||
|
|
||||||
|
Сервис SHALL писать при подъёме отпечаток вшитой сборки. Он же MUST уходить
|
||||||
|
меткой ответа с разметкой: сама разметка отдаётся с требованием спрашивать её
|
||||||
|
заново, и без метки браузер получает полное тело вместо подтверждения — вшитый
|
||||||
|
файл не несёт времени правки вовсе.
|
||||||
|
|
||||||
|
Без отпечатка «не та сборка» неотличима от «той»: вне набора проверок порядок
|
||||||
|
шагов ничем не задан, и бинарник собирается с тем, что лежало в каталоге с
|
||||||
|
прошлого раза. Приложение при этом открывается и ведёт себя как прежняя версия,
|
||||||
|
а искать причину человек идёт в код сервиса.
|
||||||
|
|
||||||
|
#### Scenario: Отпечаток виден при подъёме
|
||||||
|
|
||||||
|
- **WHEN** сервис поднимается с собранным приложением
|
||||||
|
- **THEN** в журнале есть отпечаток вшитой сборки
|
||||||
|
|
||||||
|
#### Scenario: Разметка несёт метку ответа
|
||||||
|
|
||||||
|
- **WHEN** браузер спрашивает разметку приложения
|
||||||
|
- **THEN** ответ несёт метку, по которой её можно спросить заново
|
||||||
|
|
||||||
|
### Requirement: Открытое приложение показывает вошедшего
|
||||||
|
|
||||||
|
Приложение SHALL спрашивать сервис, кто вошёл, и показывать его имя. Отказ
|
||||||
|
`401` MUST уводить ко входу: человек, ещё не вошедший, получает его на всяком
|
||||||
|
адресе данных, и это его штатное состояние, а не поломка.
|
||||||
|
|
||||||
|
Всякий **иной** отказ и сорванный запрос MUST ко входу не уводить, а показываться
|
||||||
|
строкой о неудаче. Трактовка «любой отказ значит не вошёл» замкнула бы круг:
|
||||||
|
приложение ушло бы ко входу, вход вернул бы человека в приложение, и на отказе
|
||||||
|
сервиса или ограничителя частоты круг пошёл бы заново.
|
||||||
|
|
||||||
|
Имя вошедшего берётся ответом сервиса, а не кукой сессии: кука недоступна
|
||||||
|
скриптам страницы, и другого способа узнать вошедшего у приложения нет. Имени в
|
||||||
|
ответе может не быть вовсе — тогда приложение MUST показать, что вход выполнен,
|
||||||
|
и MUST не подставлять вместо имени адрес почты: его в ответе нет по норме
|
||||||
|
`access`.
|
||||||
|
|
||||||
|
#### Scenario: Вошедший виден
|
||||||
|
|
||||||
|
- **GIVEN** человек вошёл и получил куку сессии
|
||||||
|
- **WHEN** он открывает приложение
|
||||||
|
- **THEN** приложение показывает его имя
|
||||||
|
|
||||||
|
#### Scenario: Не вошедшему предлагается вход
|
||||||
|
|
||||||
|
- **GIVEN** сессии у человека нет
|
||||||
|
- **WHEN** он открывает приложение
|
||||||
|
- **THEN** приложение ведёт его ко входу
|
||||||
|
|
||||||
|
#### Scenario: Отказ сервиса ко входу не уводит
|
||||||
|
|
||||||
|
- **GIVEN** сервис отвечает на вопрос о вошедшем отказом, который не является
|
||||||
|
отсутствием сессии
|
||||||
|
- **WHEN** человек открывает приложение
|
||||||
|
- **THEN** приложение показывает строку о неудаче
|
||||||
|
- **AND** ко входу оно не уводит
|
||||||
|
|
||||||
|
#### Scenario: Учётная запись без имени
|
||||||
|
|
||||||
|
- **GIVEN** человек вошёл, а имени у его учётной записи нет
|
||||||
|
- **WHEN** он открывает приложение
|
||||||
|
- **THEN** приложение показывает, что вход выполнен
|
||||||
|
- **AND** адреса почты на экране нет
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.5.8/schema.json",
|
||||||
|
"files": {
|
||||||
|
"includes": ["src/**", "*.ts", "*.json"]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2,
|
||||||
|
"lineWidth": 100
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"preset": "recommended"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "single",
|
||||||
|
"semicolons": "asNeeded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Package web несёт собранное приложение внутри бинарника.
|
||||||
|
//
|
||||||
|
// Каталога рядом с бинарником не требуется, и внешнего веб-сервера под раздачу
|
||||||
|
// не заводится: сервис едет на сервер одним образом, и второй разворачиваемый
|
||||||
|
// артефакт рядом с ним завёл бы вторую точку, где выкладка расходится с
|
||||||
|
// собранным.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Вшивается каталог `embed`, а не `embed/dist`: собранное приложение лежит
|
||||||
|
// этажом ниже метки `embed/.gitkeep`, и очистка выходного каталога перед каждой
|
||||||
|
// сборкой метки не касается. Без метки каталога не было бы в git вовсе, и
|
||||||
|
// `go build ./...` отказывал бы у всякого, кто приложение ни разу не собирал.
|
||||||
|
//
|
||||||
|
// Приставка `all:` нужна ради самой метки: без неё файлы, чьё имя начинается с
|
||||||
|
// точки, не вшиваются.
|
||||||
|
//
|
||||||
|
//go:embed all:embed
|
||||||
|
var embedded embed.FS
|
||||||
|
|
||||||
|
// distDir — где внутри вшитого лежит собранное приложение.
|
||||||
|
const distDir = "embed/dist"
|
||||||
|
|
||||||
|
// Dist отдаёт собранное приложение файловой системой.
|
||||||
|
//
|
||||||
|
// Второе значение говорит, собрано ли оно вообще: пустой каталог — законное
|
||||||
|
// состояние сборки, прошедшей мимо набора проверок, и сервис обязан сказать о
|
||||||
|
// нём вслух, а не отдавать пустую страницу.
|
||||||
|
func Dist() (fs.FS, bool) {
|
||||||
|
dist, err := fs.Sub(embedded, distDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fs.Stat(dist, "index.html"); err != nil {
|
||||||
|
return dist, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return dist, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Стык вшивания с раздачей проверяется здесь, и только здесь: проверки самой
|
||||||
|
// раздачи получают файловую систему параметром и подставляют свою, поэтому
|
||||||
|
// расхождение объявленной раскладки с настоящей им недоступно по построению.
|
||||||
|
//
|
||||||
|
// Раскладка объявлена дважды — выходным каталогом сборщика и `distDir` здесь, —
|
||||||
|
// и связаны они только совпадением строк. Разойдясь, они дают `503` на каждом
|
||||||
|
// пути показа при зелёном наборе проверок.
|
||||||
|
func TestDistMatchesEmbeddedLayout(t *testing.T) {
|
||||||
|
dist, built := Dist()
|
||||||
|
|
||||||
|
if dist == nil {
|
||||||
|
t.Fatal("вшитое поддерево недоступно: distDir разошёлся с директивой go:embed")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := fs.Stat(dist, "index.html")
|
||||||
|
|
||||||
|
// Оба исхода законны и различаются одним: собрано приложение или нет.
|
||||||
|
// Незаконно третье — «собрано, но `Dist` этого не видит».
|
||||||
|
if built && err != nil {
|
||||||
|
t.Fatalf("приложение объявлено собранным, а разметки в поддереве нет: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !built && err == nil {
|
||||||
|
t.Fatal("разметка есть, а приложение объявлено несобранным")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !built {
|
||||||
|
t.Skip("приложение не собрано: прогон без шага сборки, проверять нечего")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Метка пустого каталога вшивается вместе с прочим: без неё каталога не было бы
|
||||||
|
// в git, и сборка Go отказывала бы у всякого, кто приложение ни разу не собирал.
|
||||||
|
// Приставка `all:` заведена ровно ради неё.
|
||||||
|
func TestMarkerIsEmbedded(t *testing.T) {
|
||||||
|
if _, err := fs.Stat(embedded, "embed/.gitkeep"); err != nil {
|
||||||
|
t.Fatalf("метка пустого каталога не вшита: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Расшифровка записей</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2967
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "transcriber-web",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"check": "biome check .",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.41",
|
||||||
|
"vue-router": "^5.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.5.8",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.8",
|
||||||
|
"@vue/test-utils": "^2.4.11",
|
||||||
|
"happy-dom": "^20.11.2",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^8.2.1",
|
||||||
|
"vitest": "^4.1.10",
|
||||||
|
"vue-tsc": "^3.3.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Единственное место, где приложение читает код ответа и разбирает тело отказа.
|
||||||
|
// Экран получает готовый текст, а не `Response`: второй разборщик кодов на
|
||||||
|
// клиенте разошёлся бы с первым.
|
||||||
|
|
||||||
|
const appRoot = '/app'
|
||||||
|
|
||||||
|
/** Ответ сервиса на вопрос «кто вошёл». */
|
||||||
|
export interface Me {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Единая форма тела отказа на адресах приложения. */
|
||||||
|
interface ErrorBody {
|
||||||
|
error_code?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Исход запроса. Отсутствие сессии — свой исход, а не разновидность неудачи:
|
||||||
|
* экран уводит ко входу только на нём. Всякий прочий отказ ко входу не ведёт,
|
||||||
|
* иначе отказ сервиса или ограничителя частоты замкнул бы круг «приложение →
|
||||||
|
* вход → приложение».
|
||||||
|
*/
|
||||||
|
export type ApiResult<T> =
|
||||||
|
| { status: 'ok'; value: T }
|
||||||
|
| { status: 'unauthorized' }
|
||||||
|
| { status: 'failed'; message: string }
|
||||||
|
|
||||||
|
const noConnectionMessage = 'Связи нет'
|
||||||
|
const unknownFailureMessage = 'Сервис не отвечает как надо'
|
||||||
|
|
||||||
|
async function failureMessage(response: Response): Promise<string> {
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as ErrorBody
|
||||||
|
if (body.message) {
|
||||||
|
return body.message
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Тело не разобралось — своего текста под код ответа не сочиняем.
|
||||||
|
}
|
||||||
|
return unknownFailureMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string): Promise<ApiResult<T>> {
|
||||||
|
let response: Response
|
||||||
|
try {
|
||||||
|
response = await fetch(appRoot + path, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Сорванный запрос — состояние, а не ошибка: показываем строкой.
|
||||||
|
return { status: 'failed', message: noConnectionMessage }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
return { status: 'unauthorized' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { status: 'failed', message: await failureMessage(response) }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return { status: 'ok', value: (await response.json()) as T }
|
||||||
|
} catch {
|
||||||
|
return { status: 'failed', message: unknownFailureMessage }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Спрашивает сервис, кто вошёл. Куку сессии браузер шлёт сам. */
|
||||||
|
export function fetchMe(): Promise<ApiResult<Me>> {
|
||||||
|
return request<Me>('/me')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Адрес, которым сервис уводит человека ко входу. */
|
||||||
|
export const loginPath = '/auth/login'
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import { router } from './router'
|
||||||
|
|
||||||
|
createApp(App).use(router).mount('#app')
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import HomeScreen from './screens/HomeScreen.vue'
|
||||||
|
|
||||||
|
// Одна таблица маршрутов. Маршруты по файлам не включаем: сборочная надстройка
|
||||||
|
// роутера на нашем числе экранов не окупается.
|
||||||
|
//
|
||||||
|
// Адреса обычные, а не после решётки. Отсюда требование к серверу: неизвестный
|
||||||
|
// путь вне корней сервиса отдаёт разметку, а не отказ.
|
||||||
|
export const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [{ path: '/', name: 'home', component: HomeScreen }],
|
||||||
|
})
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import HomeScreen from './HomeScreen.vue'
|
||||||
|
|
||||||
|
// Экран судится по тому, что видит человек, а не по внутреннему состоянию:
|
||||||
|
// переписанная реализация обязана остаться зелёной.
|
||||||
|
|
||||||
|
const assign = vi.fn()
|
||||||
|
|
||||||
|
vi.stubGlobal('window', { location: { assign } })
|
||||||
|
|
||||||
|
function answerWith(response: Partial<Response> & { json?: () => Promise<unknown> }) {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
function failWith(error: Error) {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
assign.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('экран показывает вошедшего', () => {
|
||||||
|
it('показывает имя, когда сессия есть', async () => {
|
||||||
|
answerWith({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve({ id: 'u1', name: 'Антон' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const screen = mount(HomeScreen)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(screen.text()).toContain('Антон')
|
||||||
|
expect(assign).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ведёт ко входу, когда сессии нет', async () => {
|
||||||
|
answerWith({ ok: false, status: 401, json: () => Promise.resolve({}) })
|
||||||
|
|
||||||
|
mount(HomeScreen)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(assign).toHaveBeenCalledWith('/auth/login')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('на прочий отказ показывает строку и ко входу не ведёт', async () => {
|
||||||
|
answerWith({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: () => Promise.resolve({ error_code: 'internal', message: 'Внутренняя ошибка сервиса' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const screen = mount(HomeScreen)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(screen.text()).toContain('Внутренняя ошибка сервиса')
|
||||||
|
expect(assign).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('на сорванный запрос показывает, что связи нет', async () => {
|
||||||
|
failWith(new Error('network down'))
|
||||||
|
|
||||||
|
const screen = mount(HomeScreen)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(screen.text()).toContain('Связи нет')
|
||||||
|
expect(assign).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('учётная запись без имени: вход выполнен, почты на экране нет', async () => {
|
||||||
|
answerWith({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve({ id: 'u1', name: '' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const screen = mount(HomeScreen)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(screen.text()).toContain('Вы вошли')
|
||||||
|
expect(screen.text()).not.toContain('@')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { fetchMe, loginPath } from '../api'
|
||||||
|
|
||||||
|
// Состояние экрана живёт в экране: общего между экранами пока нет.
|
||||||
|
const state = ref<'loading' | 'signed-in' | 'failed'>('loading')
|
||||||
|
const name = ref('')
|
||||||
|
const failure = ref('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const result = await fetchMe()
|
||||||
|
|
||||||
|
if (result.status === 'unauthorized') {
|
||||||
|
// Единственная ветка, уводящая ко входу. Прочие отказы ведут сюда же
|
||||||
|
// только через круг «приложение → вход → приложение», поэтому их здесь нет.
|
||||||
|
window.location.assign(loginPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === 'failed') {
|
||||||
|
failure.value = result.message
|
||||||
|
state.value = 'failed'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name.value = result.value.name
|
||||||
|
state.value = 'signed-in'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main>
|
||||||
|
<h1>Расшифровка записей</h1>
|
||||||
|
|
||||||
|
<p v-if="state === 'loading'">Загружается…</p>
|
||||||
|
|
||||||
|
<p v-else-if="state === 'failed'">{{ failure }}</p>
|
||||||
|
|
||||||
|
<!-- Имени у учётной записи может не быть вовсе: тогда говорим, что вход
|
||||||
|
выполнен, и не подставляем вместо имени адрес почты — его в ответе нет. -->
|
||||||
|
<p v-else-if="name">Вы вошли как {{ name }}</p>
|
||||||
|
<p v-else>Вы вошли</p>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
// Настройка сборки и настройка проверок живут одним файлом: второй разошёлся бы
|
||||||
|
// с первым в путях и в наборе подключаемого.
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
build: {
|
||||||
|
// Выходной каталог лежит этажом ниже метки `embed/.gitkeep`: очистка перед
|
||||||
|
// сборкой метки не касается, и `go build ./...` не отказывает у того, кто
|
||||||
|
// приложение ни разу не собирал.
|
||||||
|
outDir: 'embed/dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
// Имя каталога задано явно, а не оставлено умолчанию: на него опираются два
|
||||||
|
// правила раздачи — отказ вместо разметки на несовпавшем ресурсе и долгий
|
||||||
|
// срок хранения, — и смена умолчания сборщика выключила бы оба молча.
|
||||||
|
// Второй дом имени — `controller/http.assetsDir`.
|
||||||
|
assetsDir: 'assets',
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'happy-dom',
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user