Безопасные производные изображения
В предыдущем уроке download требовал готовую производную. Теперь научим «Архив» её создавать. Исходные байты читаются по закреплённой версии, проходят decoder, проверку формата и перекодирование в WebP. Пользователь получает контролируемое изображение, а исходник остаётся закрытым. Такой процесс существенно сильнее проверки расширения или нескольких начальных байтов, но не является обещанием отсутствия любых уязвимостей в decoder.
Восьмое состояние вводит модуль derive.mjs и отдельный одноразовый обработчик process-one.mjs. Для лаборатории его можно вызвать с UUID уже подтверждённой карточки после самостоятельной подготовки среды. Здесь обработка не выполнялась: примеры описывают ожидаемые переходы. В следующем уроке одноразовую процедуру заменит worker, который сам забирает задачи, восстанавливает зависшую работу и ограничивает конфликтующие попытки.
Чтение именно принятого объекта
Модуль получает строку карточки от серверного кода. Он не принимает путь файла пользователя и не загружает внешние URL. S3 GET содержит source_key и source_version. Поэтому повторная запись по тому же ключу не изменит вход текущей обработки. После чтения ещё раз сравниваем длину буфера с ожидаемой: обработчик должен иметь ясное основание, какие байты он декодирует.
Полный файл derive.mjs состояния 08:
import sharp from 'sharp';
import { createHash, randomUUID } from 'node:crypto';
import { bytes, put } from './store.mjs';
const formats={jpeg:'image/jpeg',png:'image/png',webp:'image/webp'};
export async function derive(row) {
const input=Buffer.from(await bytes(row.source_key,row.source_version));
if(input.length!==row.expected_bytes) throw new Error('content_length_changed');
const image=sharp(input,{limitInputPixels:20000000,failOn:'warning',animated:false});
const meta=await image.metadata();
if(formats[meta.format]!==row.declared_type || (meta.pages||1)!==1)
throw new Error('unsupported_content');
const output=await image.rotate().resize({width:1600,height:1600,fit:'inside',
withoutEnlargement:true}).webp({quality:82}).toBuffer();
const key=`derived/${row.id}/${randomUUID()}.webp`;
const version=await put(key,output);
return {key,version,sha256:createHash('sha256').update(output).digest('hex')};
}
Набор допустимых форматов decoder связывает jpeg, png, webp с заявленными MIME. Это другая проверка, чем TYPES в JSON-политике: теперь фактический формат установлен библиотекой по объекту. Если Алиса назвала JPEG типом image/png, pipeline отвергнет несоответствие. Мы не исправляем его молча, поскольку это изменило бы договор между билетами, карточками и результатом принятия.
Вызов metadata() читает сведения заголовка, но не заменяет полное декодирование. Главная операция завершается только после toBuffer(): к этому моменту библиотека должна разобрать пиксели и сформировать новый файл. Повреждённое изображение может пройти чтение метаданных и отказать позже. Поэтому ready выставляется после завершения всей цепочки, а не после проверки ширины в заголовке.
Пиксели, страницы и метаданные
Ограничение limitInputPixels задано двадцатью миллионами пикселей. Оно помогает предотвратить обработку изображения с чрезмерными размерами, даже если сжатый файл укладывается в восемь мебибайт. Документация sharp отдельно поясняет зависимость этой проверки от размеров входных метаданных. Следовательно, для недоверенных файлов дополнительно нужны внешние ограничения памяти, CPU и времени работы процесса; один параметр не является полной песочницей.
Параметр animated:false ограничивает чтение кадров, а проверка pages отвергает многостраничный результат метаданных. Наш архив принимает статические изображения. Если продукту нужна анимация, нужно определить число кадров, длительность и стоимость обработки отдельно. Случайное сохранение только первого кадра при обещании поддержки GIF было бы неправильным контрактом. GIF в текущей политике вообще не разрешён.
rotate() использует ориентацию исходного изображения для преобразования пикселей, затем resize помещает результат в рамку 1600 на 1600 с сохранением пропорций. fit: inside не обрезает документ, а withoutEnlargement не увеличивает маленькую фотографию. Пользователь увидит уменьшенный скан целиком. Установка одинаковой ширины и высоты без выбора fit могла бы обрезать важную часть документа и изменить смысл результата.
Выходные метаданные по умолчанию не сохраняются sharp. Мы не вызываем keepMetadata() или withMetadata(), поэтому EXIF и другие исходные метаданные не копируются в результат. Это полезно для удаления случайных сведений о месте съёмки, но следует различать удаление метаданных и изменение самого изображения: адрес, видимый на фотографии документа, остаётся частью пикселей и никуда автоматически не исчезает.
Новая версия и публикация состояния
Производная получает новый случайный ключ под префиксом derived/<id>/. Повторная попытка обработки не перезаписывает предыдущий результат по одному неизменному ключу. Адаптер записывает WebP и требует полноценный VersionId. Затем вызывающий код сохраняет key, version и SHA-256 выходного буфера. Хеш позволяет сверять результат при восстановлении; он не заменяет владельца и не служит авторизацией.
Полный файл process-one.mjs состояния 08:
import { db } from './db.mjs';
import { derive } from './derive.mjs';
import { requireVersioning } from './store.mjs';
await requireVersioning();
const id=process.argv[2];
try {
const q=await db.query("UPDATE archive_files SET status='processing' WHERE id=$1 AND status='queued' RETURNING *",[id]);
if(!q.rowCount) throw new Error('not_queued');
const result=await derive(q.rows[0]);
await db.query(`UPDATE archive_files SET status='ready',derived_key=$2,derived_version=$3,sha256=$4
WHERE id=$1 AND status='processing'`,[id,result.key,result.version,result.sha256]);
} finally { await db.end(); }
Одноразовый обработчик предназначен для последовательного шага лаборатории: он переводит queued в processing, создаёт производную и условно фиксирует ready. У него пока нет повторов и восстановления после остановки. При исключении запись может остаться processing. Это конкретное ограничение восьмого состояния, которое следующий урок устранит с помощью lease. Нельзя запускать несколько таких процессов для одного UUID и считать их промышленной очередью.
Если S3 PUT успел, а обновление PostgreSQL не произошло, производная станет сиротой. У двух систем нет общей транзакции. Нельзя скрывать этот разрыв словами «сохранение атомарно». Надёжный процесс учитывает его: уникальные ключи предотвращают замену, условная фиксация предотвращает принятие устаревшего результата, а отложенная очистка удаляет неиспользуемые объекты после достаточного защитного интервала.
Для изображения с исходными размерами 2400 на 1200 ожидается результат, помещающийся в заданную рамку и сохраняющий пропорции; конкретный вес зависит от содержимого и encoder. Мы не приводим измеренные мегабайты экономии или время обработки, поскольку pipeline не запускался. Правильная проверка будущей лаборатории включает визуальную целостность скана, тип WebP, отсутствие исходных метаданных и возможность читать именно сохранённую версию.
Для SVG, PDF, повреждённого PNG либо анимированного WebP ожидается отказ соответствующего этапа. Не добавляйте обход catch → ready: он превращает любую ошибку decoder в признание недоверенного файла. Отказ должен оставаться видимым состоянием, а технический журнал — сохранять безопасный код причины без содержимого документа и URL доступа. В следующем уроке это правило встроим в повторяемый worker.
Преобразование в WebP может менять цветопередачу и мелкие детали скана. Поэтому выбор quality связан с задачей архива, а не только с минимальным весом. Для юридически значимого оригинала нужен отдельный авторизованный режим сохранения и получения исходника со своей проверкой; наша выдача предназначена для подготовленной визуальной копии. Скрыто подменять оригинальный документ перекодированным результатом и обещать побайтовую сохранность нельзя.
Предыдущий урок: Временная ссылка на скачивание · Следующий урок: Фоновая обработка файлов
Оглавление серии · Исходники всех состояний.
Документация: sharp: constructor, sharp: output.