Перейти к содержанию

Политика размера и типа файла

В первом уроке мы разделили карточку и объект. Теперь определим, какие заявки «Архив» готов принимать. Пользователь может назвать файл photo.png, указать image/png и заявить небольшой размер, хотя отправленные байты окажутся другими. Поэтому политика заявки проверяет обещания клиента, а проверка содержимого остаётся отдельным этапом. Эта разница будет сохраняться до самого выпуска.

Для лаборатории разрешим статические JPEG, PNG и WebP размером до восьми мебибайт. Архив подходит для фотографий и сканов, а не для произвольных исполняемых вложений. PDF, SVG, архивы и офисные документы здесь отвергаются: им требуется другой разбор, возможно антивирусный сервис и иной процесс выдачи. Нельзя назвать такую поддержку безопасной, просто добавив строку в список MIME-типов.

Три проверки заявки

Размер передаётся целым числом байтов. Проверяем, что это безопасное целое число JavaScript, что оно положительно и что оно не превышает 8 * 1024 * 1024. Такое выражение обозначает восемь мебибайт; десятичные восемь миллионов байтов были бы другой границей. Явное обозначение единиц предотвращает расхождение между браузерной подсказкой, API и политикой объектного хранилища.

Имя ограничим ста двадцатью символами и запретим управляющие символы, прямой и обратный слеш. Имя останется отображаемой подписью, поэтому запрет слеша не превращается в защиту пути: путь вообще не строится из имени. Эти ограничения улучшают поведение списков, выгрузок и журналов. Для интерфейса всё равно требуется обычное экранирование текста; валидное имя не становится доверенной HTML-разметкой.

Тип проверяем по точному набору, а не по совпадению image/*. Универсальный префикс разрешил бы SVG и другие форматы с самостоятельными особенностями обработки. Даже для трёх разрешённых типов заявленный MIME остаётся недоверенным. В следующей части серии parser установит настоящий формат и сравнит его с заявкой. Ошибка на этом этапе приведёт к отказу принятия, а не к незаметной смене политики.

Полный файл policy.mjs состояния 02:

import { Failure } from './http.mjs';
export const MAX_BYTES=8*1024*1024;
export const TYPES=new Set(['image/jpeg','image/png','image/webp']);
export function uploadPolicy(input) {
  if(!input || typeof input !== 'object') throw new Failure(400,'invalid_request');
  const {name,type,bytes}=input;
  if(typeof name!=='string' || name.length<1 || name.length>120 || /[\x00-\x1f\x7f/\\]/.test(name))
    throw new Failure(422,'invalid_name');
  if(!TYPES.has(type)) throw new Failure(415,'type_not_allowed');
  if(!Number.isSafeInteger(bytes) || bytes<1 || bytes>MAX_BYTES)
    throw new Failure(413,'size_not_allowed');
  return {name,type,bytes};
}

Функция возвращает новый объект с тремя известными полями. Остальные свойства входного JSON не копируются в запись. Если клиент добавит owner_id, status или source_key, они не получат смысла. Владелец появится из авторизации, состояние выберет сервер, ключ создаст сервер. Это предотвращает смешение пользовательских данных и параметров, которыми распоряжается приложение.

Предположим, Алиса отправила имя receipt.png, тип image/png и размер 204800. Ожидаемый результат проверки — объект с этими же разрешёнными значениями. Для размера 9000000 ожидается size_not_allowed; для image/svg+xml — type_not_allowed; для имени с переводом строки — invalid_name. Здесь приведены примеры контракта функции, а не результаты выполнения учебного кода.

Разные лимиты защищают разные ресурсы

В API читается только небольшая JSON-заявка, поэтому функция json() прекращает приём после шестнадцати килобайт. Этот лимит не равен лимиту изображения. Сами байты позднее пойдут напрямую в S3. Серверу не нужно принимать восемь мебибайт через JSON или base64: такая передача увеличивает объём и связывает производительность приложения со скоростью загрузки клиента.

Полный файл http.mjs состояния 02:

export class Failure extends Error {
  constructor(status, code) { super(code); this.status=status; this.code=code; }
}
export function send(res, status, value) {
  res.writeHead(status, { 'Content-Type':'application/json; charset=utf-8',
    'Cache-Control':'no-store', 'Referrer-Policy':'no-referrer' });
  res.end(JSON.stringify(value));
}
export async function json(req) {
  if (!req.headers['content-type']?.startsWith('application/json'))
    throw new Failure(415,'json_required');
  let size=0; const chunks=[];
  for await (const chunk of req) {
    size+=chunk.length; if(size>16384) throw new Failure(413,'json_too_large');
    chunks.push(chunk);
  }
  try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); }
  catch { throw new Failure(400,'invalid_json'); }
}
export function fileId(path, suffix='') {
  const uuid='[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
  return new RegExp('^/v1/files/('+uuid+')'+suffix+'$').exec(path)?.[1];
}

Проверка Content-Type относится к формату управляющего запроса. Она не проверяет формат изображения. Если клиент прислал JSON с неверным синтаксисом, API возвращает 400; если прислал другое представление вместо JSON — 415. Эти ошибки происходят до резервирования карточки. Пользователь сможет исправить заявку, не оставляя в базе незавершённую загрузку и не расходуя квоту хранения.

Помимо длины сжатого файла нужен предел числа пикселей. Маленький PNG способен распаковаться в очень большой буфер. Поэтому в обработчике предусмотрим limitInputPixels, а сам worker ограничим по памяти и процессорному времени. Ни одно из этих ограничений не заменяет остальные: предел байтов защищает передачу и хранение, предел пикселей защищает декодирование, процессный лимит ограничивает последствия ошибки библиотеки.

Квота пользователя тоже является отдельным ограничением. Десять допустимых файлов могут занять слишком много места в сумме. Поэтому на этапе резервирования мы учтём все карточки, которые ещё не полностью удалены. Незавершённые попытки загрузки тоже занимают резерв. Без этого пользователь способен одновременно получить много разрешений, каждое формально проходящее отдельную проверку размера.

Что означает подтверждённый тип

Расширение имени удобно для пользователя, но не доказывает содержимое. Поле Content-Type объекта устанавливается клиентом в рамках подписанной политики и также не является доказательством. Даже проверка первых байтов показывает лишь небольшую часть структуры. Полный decoder может встретить повреждённый фрагмент позднее. Поэтому окончательная проверка изображения связана с успешным декодированием и перекодированием в контролируемый формат.

В нашем процессе не будет функции с названием scan, которая всегда возвращает true. Такая функция создаёт ложную гарантию и мешает понять, какие проверки действительно произошли. Вместо этого явно называем факты: заявка допустима, размер объекта совпал, формат разобран, производное изображение создано. Антивирусное заключение не заявляется, поскольку отдельный антивирусный движок в лабораторию не включён.

Одинаковая политика должна использоваться в API и при выдаче upload-разрешения. Если API разрешает восемь мебибайт, а POST policy разрешает больше, браузер сможет отправить лишний объём после получения билета. Позднее подтверждение всё равно отвергнет несовпадение, но объект уже займёт место. Следовательно, верхняя граница должна применяться до записи байтов, а повторная проверка нужна для согласованности принятой карточки.

Стоит отделить ошибку пользователя от технического сбоя. Неподдерживаемый MIME — предсказуемый отказ политики, его можно объяснить рядом с выбором файла. Недоступное хранилище — другое событие, пользователь не должен переименовывать фотографию, пытаясь его исправить. Устойчивые коды ошибок позволяют интерфейсу показать правильное действие, не анализируя текст исключения библиотеки.

На этом этапе policy.mjs ещё не подключён к маршруту загрузки: сам маршрут появится после авторизации и настройки бакета. Так сохраняется последовательность проекта. Вы уже можете объяснить, почему допустимая заявка не означает принятого файла, и назвать три независимых ограничения. В дальнейшем мы будем добавлять факты к карточке, сохраняя различие между обещанием клиента и наблюдением сервера.

Предыдущий урок: Модель закрытого файлового архива · Следующий урок: Владелец файла и авторизация запросов

Оглавление серии · Исходники всех состояний.

Документация: S3 POST policy, sharp: constructor.