Временная ссылка на скачивание
Карточка теперь закрепляет конкретную исходную версию, но пользователю нельзя отдавать любой ключ из бакета. Добавим маршрут скачивания. Он принимает UUID, проверяет владельца и требует состояние ready. Подписывается только производная WebP-версия, которую позднее создаст наш обработчик. Исходный загруженный файл остаётся закрытым и не выдаётся через этот маршрут даже после успешного подтверждения.
В седьмом состоянии обработчика ещё нет, поэтому подтверждённая фотография ожидаемо получает file_not_ready. Это полезный промежуточный результат: мы вводим контракт выдачи до того, как появляется производная. Когда следующий урок создаст безопасный результат, тот же маршрут начнёт возвращать URL. Приведённые ответы описывают поведение исходников; сеть и хранилище при подготовке страницы не использовались для исполнения примера.
Право до подписи
Подпись — последнее действие разрешённой операции. Функция signed() сначала вызывает owned(), затем проверяет состояние и наличие версии. Только после этого вызывает адаптер S3. Такая последовательность препятствует распространённой ошибке, когда сервер подписывает строку key, полученную из query, и надеется, что закрытый бакет сам проверит пользователя приложения. Хранилище видит права серверной роли, а не Алису или Боба.
Полный файл signed.mjs состояния 07:
import { db } from './db.mjs';
import { owned } from './auth.mjs';
import { Failure } from './http.mjs';
import { download } from './store.mjs';
export async function signed(owner,id) {
const row=await owned(db,id,owner);
if(row.status!=='ready' || !row.derived_version) throw new Failure(409,'file_not_ready');
if(new Date(row.expires_at).getTime()<=Date.now()) throw new Failure(410,'retention_expired');
return {url:await download(row),expiresIn:60};
}
Клиент передаёт идентификатор карточки, а derived_key и derived_version читаются из PostgreSQL. Даже если пользователь изменит тело запроса, endpoint не использует присланный путь. Боб получает тот же 404 для чужой карточки, что и для отсутствующей. Успешно вошедший сотрудник не способен подписать исходную версию, другой бакет или произвольный файл операционной системы.
Проверка expires_at ограничивает срок хранения на уровне приложения. Она нужна даже до физической очистки. Очистка может выполняться с задержкой, однако истёкшая карточка уже не должна получать новые разрешения на чтение. Существующая подпись живёт отдельно до своего срока, поэтому изменение статуса базы не означает мгновенного прекращения всех ранее начатых чтений.
Ссылка является переносимым разрешением
Получивший подписанный URL способен скачать объект без bearer-токена «Архива». Это нормальное свойство такого разрешения. Поэтому владение ссылкой само по себе даёт доступ на установленный срок; её нельзя считать просто адресом, который каждый раз снова проверяет владельца. Если Алиса перешлёт URL Бобу, он сможет использовать его до истечения или до прекращения доступа со стороны хранилища.
В лаборатории срок составляет шестьдесят секунд. API возвращает expiresIn рядом с URL, чтобы клиент понимал необходимость нового разрешения при повторном действии позднее. Подпись не является гарантированно одноразовой: её можно использовать несколько раз, пока она действительна. Если продукту требуется строгая выдача ровно одному пользователю на каждый запрос, нужен другой путь — авторизованный proxy или иной механизм проверки, а не обещание одноразовости presigned GET.
Подписанный URL нельзя помещать в журнал, аналитический event, сообщение об ошибке или внешнюю систему поддержки. В его query находятся параметры доступа. При диагностике сохраняем UUID карточки, событие выдачи и срок, не полное значение ссылки. Referrer-Policy: no-referrer и Cache-Control: no-store в API уменьшают нежелательное распространение, но клиент тоже должен избегать сторонних скриптов и публичного кеширования страницы со ссылкой.
Передача URL в качестве href или download-навигации может попасть в историю браузера. Это влияет на выбор интерфейса и срок разрешения. Для закрытого портала полезно получать ссылку непосредственно при нажатии «Скачать», а не заранее для каждой строки списка. Такой подход сокращает число активных разрешений и делает момент делегирования права связанным с конкретным действием пользователя.
Заголовки производного файла
Адаптер использует GetObjectCommand с VersionId, ResponseContentType: image/webp, фиксированным Content-Disposition и private, no-store. VersionId сохраняет адресацию точного результата обработки. Тип установлен сервером потому, что pipeline создаёт WebP. В пользовательском имени может быть что угодно из разрешённого набора, но оно не формирует HTTP-заголовок; имя скачиваемого файла здесь всегда archive.webp.
Это намеренное упрощение, которое можно расширить отдельным кодированием filename*. Нельзя просто вставить исходное имя между кавычками: разные символы имеют значение в синтаксисе заголовка. Также нельзя использовать пользовательский Content-Type как подтверждение результата. Для нового формата выдачи должен существовать соответствующий процесс обработки и доказанный сервером тип, а не только новая строка настройки.
Граница кеширования относится к данным, а не только к JSON. Если объектные ответы попадут в общий CDN-кеш, закрытость может зависеть от его конфигурации и ключа кеша. В лаборатории CDN отсутствует, и ссылки идут прямо к закрытому S3. При добавлении ускоряющего слоя необходимо отдельно согласовать авторизацию, TTL и обработку query, вместо механического размещения бакета за публичным доменом.
Удаление и уже выданный доступ
Между чтением строки и созданием подписи может прийти DELETE. Поэтому процедура удаления позднее сначала запретит новые разрешения состоянием deleting, а физическое удаление отложит. Уже выданная ссылка на готовую версию может ещё работать короткий срок. Такая задержка должна быть отражена в поведении продукта: «удаление запрошено» и «байты окончательно удалены» являются разными событиями.
Если требуется более сильное прекращение чтения, нужно анализировать механизмы конкретного хранилища и активные соединения. Например, истечение URL обычно проверяется при начале запроса; оно не обязано оборвать уже идущую передачу. Поэтому нельзя обещать, что шестидесятая секунда гарантирует исчезновение каждой копии в браузере. Сохранённый пользователем файл вообще находится вне управления серверного TTL.
Ожидаемый контракт теперь понятен: собственный queued возвращает 409, истёкший ready — 410, чужой UUID — 404, собственный неистёкший ready — JSON с короткой ссылкой на конкретную производную версию. Для получения последнего результата нужно завершить следующий урок обработки. Разберите этот набор случаев заранее: он показывает, какие факты требуются для выдачи и почему одной авторизации недостаточно.
Предыдущий урок: Подтверждение загруженной версии · Следующий урок: Безопасные производные изображения
Оглавление серии · Исходники всех состояний.
Документация: S3: ограниченный доступ, AWS SDK: presigner.