Files
transcriber/docs/adr/ADR-2026-08-12-file-link-open-but-not-logged.md
T
av 01cc31d45f хранилище, файлы записей и очередь переведены на встроенную PocketBase
- записи, метаданные и файлы съехались под один каталог данных; появилась
  панель владельца, а gin, goqu, goose и требование CGO ушли
- захват задачи стал одним запросом с RETURNING; заведены число попыток,
  состояние dead и нарастающая пауза вместо признака is_error
- имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с
  идентификатором записи оно собирало бы ссылку на скачивание
2026-08-12 08:31:59 +03:00

5.4 KiB
Raw Blame History

Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале

Решение

Поле файла в хранилище не помечается защищённым: ссылка /api/files/<коллекция>/<запись>/<имя> работает без токена, и право пройти по ней даёт знание самой ссылки. Взамен имя файла в хранилище не пишется в журнал ни в каком виде — ни на успешном пути, ни в тексте отказа.

Почему

Очевидный подход к файлам, отдаваемым в интернет, — закрыть их: у хранилища для этого есть пометка «защищённое поле», и тогда файл отдаётся только по отдельному файловому токену. Мы от неё отказываемся, и цитата из источника называет причину:

Защищённое поле требует отдельного файлового токена. Не помечаем: сегодня право прочитать задачу даёт знание её идентификатора, и файл встаёт вровень с GET /api/status/:id, а не ниже.

Отказ дешёв ровно до тех пор, пока ссылку неоткуда взять. Перевод хранилища это условие сломал, и вот чем:

Изъятие выписано под путь на диске: data/files/<uuid>.ogg читателю журнала бесполезен. После перевода имя файла в хранилище — это последняя часть ссылки /api/files/..., по которой запись скачивает кто угодно; строка журнала стала бы бессрочным ключом к чужому аудио.

Путь был построен ревью и прогнан: отказ чтения из хранилища нёс ключ файла целиком, строка уходила в журнал, а анонимный запрос по собранному адресу отвечал 200 с телом записи. Второй путь шёл через отказ выгрузки в Object Storage — тот несёт полный URL объекта.

Отсюда вторая половина решения, без которой первая недопустима: отказы обрываются. Наружу идёт свой текст с идентификатором записи, а чужая цепочка %w — нет. В журнал приёма вместо имени идёт расширение собственным полем; прослеживаемость от этого не страдает.

Запись попадает в журнал как намеренный отказ от очевидного подхода: закрыть файлы токеном предложат снова, и без записанной причины предложение выглядит бесплатным.

Последствия

  • + ссылка работает без токена, и приёмка проверяется обычным запросом; будущее приложение получает файл без отдельного механизма выдачи токенов.
  • + изъятие из инварианта приватности не расширилось: в журнале по-прежнему только расширение, а не имя.
  • ссылка, единожды утёкшая, работает бессрочно: отзыва у неё нет, а файлы не удаляются вовсе. Утечка возможна не только журналом — любой будущий экран, показывающий ссылку, наследует это свойство.
  • появилась норма, которую держит не построение, а внимание: всякий новый отказ хранилища надо обрывать руками. Норму сторожат требование capability storage и проверка журнала, но компилятор — нет.
  • диагностируемость отказов упала: обрывая цепочку, мы теряем причину. У выгрузки в Object Storage это смягчено — сохраняется класс отказа SDK (AccessDenied, NoSuchBucket), в котором адреса не бывает.
  • Решение действует до разграничения доступа: задачи oidc-login и record-ownership меняют условие, и тогда пометку стоит пересмотреть новой записью.