All files / src/utils imageCompressor.js

94.23% Statements 49/52
93.75% Branches 30/32
100% Functions 5/5
94.23% Lines 49/52

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140            6x 6x   6x 6x 6x                     4x 4x         24x 1x     23x 1x     22x   22x 21x 1x       20x 1x           19x 1x       18x 2x     16x   16x   16x 2x 2x 14x 9x 5x 3x 2x 2x                   16x     16x 16x 16x 1x 1x               16x 16x 1x                   16x 16x   2x           2x   16x     14x             14x               6x  
/**
 * Image compression utility using sharp.
 * Compresses images before R2 storage. Lossless/near-lossless by format.
 * Strips EXIF metadata, auto-rotates, preserves dimensions and transparency.
 */
 
const sharp = require('sharp');
const log = require('./log');
 
const MAX_DIMENSION = 4096;
const MIN_DIMENSION = 100;
const COMPRESSION_TIMEOUT_MS = 10000;
 
/**
 * Distinguishes policy rejections (oversized image, SVG XSS risk, empty
 * buffer) from compression-engine failures (sharp threw, timeout). Callers
 * MUST re-throw ImagePolicyError as a 4xx client error rather than
 * silently storing the original buffer — that would defeat the dimension
 * check and let an oversized image become a permanent R2 object.
 */
class ImagePolicyError extends Error {
  constructor(message) {
    super(message);
    this.name = 'ImagePolicyError';
  }
}
 
async function compressImage(buffer, mimeType) {
  if (!buffer || buffer.length === 0) {
    throw new ImagePolicyError('Empty image buffer');
  }
 
  if (mimeType === 'image/svg+xml') {
    throw new ImagePolicyError('SVG format not supported — XSS risk');
  }
 
  const originalSize = buffer.length;
 
  const metadata = await sharp(buffer).metadata();
  if (metadata.width > MAX_DIMENSION || metadata.height > MAX_DIMENSION) {
    throw new ImagePolicyError(
      `Image dimensions ${metadata.width}x${metadata.height} exceed maximum ${MAX_DIMENSION}x${MAX_DIMENSION}`,
    );
  }
  if (metadata.width < MIN_DIMENSION || metadata.height < MIN_DIMENSION) {
    throw new ImagePolicyError(
      `Image dimensions ${metadata.width}x${metadata.height} below minimum ${MIN_DIMENSION}x${MIN_DIMENSION}`,
    );
  }
 
  // Animated images: pass through (can't optimise without losing frames)
  if (metadata.pages && metadata.pages > 1) {
    return { buffer, mimeType, originalSize, compressedSize: originalSize };
  }
 
  // GIF passthrough (after dimension validation)
  if (mimeType === 'image/gif') {
    return { buffer, mimeType, originalSize, compressedSize: originalSize };
  }
 
  let pipeline = sharp(buffer, { failOn: 'error' }).rotate();
 
  let outputMime = mimeType;
 
  if (mimeType === 'image/heic' || mimeType === 'image/heif') {
    pipeline = pipeline.jpeg({ quality: 95, mozjpeg: true });
    outputMime = 'image/jpeg';
  } else if (mimeType === 'image/jpeg') {
    pipeline = pipeline.jpeg({ quality: 95, mozjpeg: true });
  } else if (mimeType === 'image/png') {
    pipeline = pipeline.png({ effort: 10, compressionLevel: 9, depth: 8 });
  } else if (mimeType === 'image/webp') {
    pipeline = pipeline.webp({ quality: 95, nearLossless: true });
  } else E{
    log.warn('imageCompressor', 'Unsupported MIME type, returning original', {
      mimeType,
      originalSize,
    });
    return { buffer, mimeType, originalSize, compressedSize: originalSize };
  }
 
  // Do NOT call withMetadata() — sharp strips EXIF by default.
  pipeline = pipeline.toColorspace('srgb');
 
  let timer;
  let timedOut = false;
  const timeoutPromise = new Promise((_, reject) => {
    timer = setTimeout(() => {
      timedOut = true;
      reject(new Error('Image compression timed out'));
    }, COMPRESSION_TIMEOUT_MS);
  });
 
  // Hold a reference to the in-flight sharp promise so we can attach a
  // tail .catch() for the post-timeout case. Without this, when the
  // timeout fires first, the sharp promise's eventual rejection becomes
  // an unhandled rejection with no context back to this request.
  const sharpPromise = pipeline.toBuffer();
  sharpPromise.catch((tailErr) => {
    Iif (timedOut) {
      log.warn('imageCompressor', 'Sharp pipeline rejected after timeout (post-mortem)', {
        error: tailErr.message,
        format: outputMime,
        originalSize,
      });
    }
  });
 
  let compressed;
  try {
    compressed = await Promise.race([sharpPromise, timeoutPromise]);
  } catch (err) {
    log.warn('imageCompressor', 'Compression failed, returning original', {
      error: err.message,
      timedOut,
      format: outputMime,
      originalSize,
    });
    return { buffer, mimeType, originalSize, compressedSize: originalSize };
  } finally {
    clearTimeout(timer);
  }
 
  log.info('imageCompressor', 'Image compressed', {
    originalSize,
    compressedSize: compressed.length,
    format: outputMime,
    ratio: `${((1 - compressed.length / originalSize) * 100).toFixed(1)}%`,
  });
 
  return {
    buffer: compressed,
    mimeType: outputMime,
    originalSize,
    compressedSize: compressed.length,
  };
}
 
module.exports = { compressImage, ImagePolicyError };