inventid/iaas

View on GitHub
src/imageResponse.js

Summary

Maintainability
D
2 days
Test Coverage
import {proxy} from "./proxy";

require("babel-polyfill");

import config from "config";
import promisify from "promisify-node";
import log from "./log";
import * as dbCache from "./dbCache";
import * as fastCache from "./fastCache";
import * as image from "./image";
import aws from "./aws";
import {futureDate} from "./helper";
import metrics, {GENERATION, ORIGINAL, REDIRECT} from './metrics';

const fs = promisify('fs');

const imagePath = (name) => `${config.get('originals_dir')}/${name}`;

const redirectTimeout = config.has('redirect_cache_timeout') ? config.get('redirect_cache_timeout') : 0;

const didTimeout = (error) => {
  return error.message === 'gm() resulted in a timeout.';
};

// Determine whether the image exists on disk
// Returns either true of false
const doesImageExist = async (name) => {
  try {
    await fs.access(imagePath(name), fs.R_OK);
    return true;
  } catch (e) {
    return false;
  }
};

// GM does not always return a nice buffer
// https://github.com/aheckmann/gm/issues/572#issuecomment-293768810
const gmToBuffer = (data) => {
  return new Promise((resolve, reject) => {
    data.stream((err, stdout, stderr) => {
      if (err) {
        reject(err);
        return;
      }
      const chunks = [];
      stdout.on('data', (chunk) => {
        chunks.push(chunk);
      });
      // these are 'once' because they can and do fire multiple times for multiple errors,
      // but this is a promise so you'll have to deal with them one at a time
      stdout.once('end', () => {
        resolve(Buffer.concat(chunks));
      });
      stderr.once('data', (data) => {
        reject(String(data));
      });
    });
  });
};

const isRequestedImageWithinBounds = (params) => {
  // Check if we are allowed to serve an image of this size and optionally redirect
  return (params.width && params.height) &&
    (params.width <= config.get('constraints.max_width') && params.height <= config.get('constraints.max_height'));
};

const calculateNewBounds = async (params) => {
  // Resize the parameters
  // If no params are set then we load them from the actual image before calculating new bounds
  if (!params.width || !params.height) {
    const fileSize = await image.imageSize(imagePath(params.name));
    params.width = fileSize.width;
    params.height = fileSize.height;
  }
  const providedRatio = params.width / params.height;
  if (params.width > config.get('constraints.max_width')) {
    params.width = config.get('constraints.max_width');
    if (params.fit === 'crop') {
      params.height = Math.round(params.width / providedRatio);
    }
  }
  if (params.height > config.get('constraints.max_height')) {
    params.height = config.get('constraints.max_height');
    if (params.fit === 'crop') {
      params.width = Math.round(params.height * providedRatio);
    }
  }
  return params;
};

const isHeadRequest = method => (method === 'HEAD');

const redirectImageToWithinBounds = (params, response) => {
  const newLocation = `/${params.name}_${params.width}_${params.height}.${params.type}` +
    `?fit=${params.fit}&blur=${Boolean(params.blur)}&quality=${params.quality}`;
  return response.status(303).set({
    'X-Redirect-Info': 'The requested image size falls outside of the allowed boundaries of this service. We are directing you to the closest available match.', //eslint-disable-line max-len
    'Location': newLocation
  }).end();
};

const redirectToCachedEntity = (cacheUrl, params, response) => {
  if (params.proxy) {
    proxy(cacheUrl, response);
    return;
  }
  // We redirect the user to the new location. We issue a temporary redirect such that the
  // request url stays the representitive url, and because temporary redirects generally
  // give less issues than permanent redirects for the odd chance that the underlying resource
  // does actually change
  const headers = {
    'Location': cacheUrl
  };
  if (redirectTimeout) {
    headers['Cache-Control'] = `max-age=${redirectTimeout}`;
  }

  response.status(303).set(headers).end();
  response.end();
};

const sendFoundHeaders = (params, response) => {
  // Get the image and resize it. We allow any intermediate servers and browsers to use this cached resource as well
  response.status(200).type(params.mime).set({
    'Expires': futureDate(),
    'Cache-Control': 'public'
  });
};

const imageKey = params =>
  `${params.name}_${params.width}x${params.height}.${params.fit}` +
  `.b-${Boolean(params.blur)}.q-${params.quality}.${params.type}`;


export async function magic(params, method, response, stats = undefined, metric = undefined, shouldBeFresh = false) {
  if (params === null) {
    // Invalid, hence reject
    response.status(400).end();
    if (metric) {
      metric.addTag('status', 400);
      metrics.write(metric);
    }
    return;
  }

  const imageExists = await doesImageExist(params.name);
  if (!imageExists) {
    response.status(404).end();
    if (metric) {
      metric.addTag('status', 404);
      metrics.write(metric);
    }
    return;
  }

  // Image exists
  if (!isRequestedImageWithinBounds(params)) {
    redirectImageToWithinBounds(await calculateNewBounds(params), response);
    if (metric) {
      metric.addTag('status', 307);
      metric.addTag('withinBounds', false);
      metrics.write(metric);
    }
    return;
  }

  const imageDescription = this.description(params);
  // Image exists and is within bounds.
  // This method is mainly used by browsers to serve retina images
  if (isHeadRequest(method)) {
    response.status(200).end();
    log('debug', `HEAD request for ${imageDescription} can be served`);
    // No metrics here
    return;
  }
  log('debug', `Request for ${imageDescription}`);

  const fastCacheValue = await fastCache.getImageFromCache(params);
  if (!shouldBeFresh) {
    if (fastCacheValue) {
      log('debug', `Fast cache hit for ${imageDescription}`);
      redirectToCachedEntity(fastCacheValue, params, response);
      if (metric) {
        metric.addTag('cacheHit', true);
        metric.addTag('withinBounds', true);
        metric.addTag('status', params.proxy ? 303 : 200);
        metric.addTag('proxy', params.proxy);
        metric.stop();
        metrics.write(metric);
        metrics.write(metric.copy(REDIRECT));
      }
      return;
    }

    metric.addTag('cacheHit', false);
    const cacheValue = await dbCache.getFromCache(params);
    if (cacheValue) {
      log('debug', `Cache hit for ${imageDescription}`);
      if (stats) {
        stats.hits.incrementAndGet();
      }
      redirectToCachedEntity(cacheValue, params, response);
      if (metric) {
        metric.addFields(dbCache.stats());
        metric.addTag('withinBounds', true);
        metric.addTag('status', params.proxy ? 303 : 200);
        metric.addTag('proxy', params.proxy);
        metric.stop();
        metrics.write(metric);
        metrics.write(metric.copy(REDIRECT));
      }
      await fastCache.addImageToCache(params, cacheValue);
      return;
    }
    if (stats) {
      stats.misses.incrementAndGet();
    }
  }

  // Image is present but not in the correct setting
  log('debug', `Cache miss for ${imageDescription}`);
  sendFoundHeaders(params, response);

  const clientStartTime = new Date();
  try {
    const browserImage = await image.magic(imagePath(params.name), params);
    const browserBuffer = await gmToBuffer(browserImage);
    log('info', `Creating image took ${new Date() - clientStartTime}ms: ${imageDescription}`);

    const awsBuffer = Buffer.from(browserBuffer);
    response.end(browserBuffer);
    if (metric) {
      metric.addFields(dbCache.stats());
      metric.addTag('status', 200);
      metric.addTag('withinBounds', true);
      metric.stop();
      metrics.write(metric);
      metrics.write(metric.copy(GENERATION));
    }
    if (!shouldBeFresh) {
      await aws(imageKey(params), params, awsBuffer);
    }
  } catch (err) {
    const status = didTimeout(err) ? 504 : 500;
    response.status(status).end();
    if (metric) {
      metric.addTag('status', status);
      metric.stop();
      metrics.write(metric);
      metrics.write(metric.copy(GENERATION));
    }
    log('error', `Error occurred while creating live image ${imageDescription}: ${err}`);
  }
}

export async function original(params, method, response, metric = undefined) {
  const startTime = new Date();
  const imageExists = await doesImageExist(params.name);
  if (!imageExists) {
    response.status(404).end();
    if (metric) {
      metric.addTag('status', 404);
      metrics.write(metric);
    }
    return;
  }
  response.status(200).set({
    'Content-Type': params.mime,
    'Cache-Control': 'public',
    Etag: `${params.name}_${params.type}`,
    Expires: futureDate()
  });
  const data = await fs.readFile(imagePath(params.name));
  response.end(data);
  if (metric) {
    metric.addTag('status', 200);
    metric.stop();
    metrics.write(metric);
    metrics.write(metric.copy(ORIGINAL));
  }
  log('info', `Serving original image ${params.name} took ${new Date() - startTime}ms`);
}

export async function upload(name, path, cropParameters) {
  const destinationPath = `${config.get('originals_dir')}/${name}`;
  return await image.writeOriented(path, destinationPath, cropParameters);
}

export function description(params) {
  return `${params.name}.${params.type} (${params.width}x${params.height}px, fit: ${params.fit}, blur: ${Boolean(params.blur)}), quality: ${Number(params.quality) || 'auto'}`; //eslint-disable-line max-len
}

export async function hasAllowableImageSize(path, maxSizeInMegapixel) {
  const imageSize = await image.imageArea(path);
  return imageSize < (maxSizeInMegapixel * 1e6);
}