src/imageResponse.js
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);
}