- записи, метаданные и файлы съехались под один каталог данных; появилась панель владельца, а gin, goqu, goose и требование CGO ушли - захват задачи стал одним запросом с RETURNING; заведены число попыток, состояние dead и нарастающая пауза вместо признака is_error - имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с идентификатором записи оно собирало бы ссылку на скачивание
5.4 KiB
Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале
- Дата: 2026-08-12
- Источник: ../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md, раздел «Поле файла не помечаем защищённым, но ссылка не уезжает в журнал»
Решение
Поле файла в хранилище не помечается защищённым: ссылка
/api/files/<коллекция>/<запись>/<имя> работает без токена, и право пройти по
ней даёт знание самой ссылки. Взамен имя файла в хранилище не пишется в журнал
ни в каком виде — ни на успешном пути, ни в тексте отказа.
Почему
Очевидный подход к файлам, отдаваемым в интернет, — закрыть их: у хранилища для этого есть пометка «защищённое поле», и тогда файл отдаётся только по отдельному файловому токену. Мы от неё отказываемся, и цитата из источника называет причину:
Защищённое поле требует отдельного файлового токена. Не помечаем: сегодня право прочитать задачу даёт знание её идентификатора, и файл встаёт вровень с
GET /api/status/:id, а не ниже.
Отказ дешёв ровно до тех пор, пока ссылку неоткуда взять. Перевод хранилища это условие сломал, и вот чем:
Изъятие выписано под путь на диске:
data/files/<uuid>.oggчитателю журнала бесполезен. После перевода имя файла в хранилище — это последняя часть ссылки/api/files/..., по которой запись скачивает кто угодно; строка журнала стала бы бессрочным ключом к чужому аудио.
Путь был построен ревью и прогнан: отказ чтения из хранилища нёс ключ файла
целиком, строка уходила в журнал, а анонимный запрос по собранному адресу
отвечал 200 с телом записи. Второй путь шёл через отказ выгрузки в Object
Storage — тот несёт полный URL объекта.
Отсюда вторая половина решения, без которой первая недопустима: отказы
обрываются. Наружу идёт свой текст с идентификатором записи, а чужая цепочка
%w — нет. В журнал приёма вместо имени идёт расширение собственным полем;
прослеживаемость от этого не страдает.
Запись попадает в журнал как намеренный отказ от очевидного подхода: закрыть файлы токеном предложат снова, и без записанной причины предложение выглядит бесплатным.
Последствия
+ссылка работает без токена, и приёмка проверяется обычным запросом; будущее приложение получает файл без отдельного механизма выдачи токенов.+изъятие из инварианта приватности не расширилось: в журнале по-прежнему только расширение, а не имя.−ссылка, единожды утёкшая, работает бессрочно: отзыва у неё нет, а файлы не удаляются вовсе. Утечка возможна не только журналом — любой будущий экран, показывающий ссылку, наследует это свойство.−появилась норма, которую держит не построение, а внимание: всякий новый отказ хранилища надо обрывать руками. Норму сторожат требование capabilitystorageи проверка журнала, но компилятор — нет.−диагностируемость отказов упала: обрывая цепочку, мы теряем причину. У выгрузки в Object Storage это смягчено — сохраняется класс отказа SDK (AccessDenied,NoSuchBucket), в котором адреса не бывает.- Решение действует до разграничения доступа: задачи
oidc-loginиrecord-ownershipменяют условие, и тогда пометку стоит пересмотреть новой записью.