next-csrf
Version:
CSRF mitigation library for Next.js
344 lines (317 loc) • 12.6 kB
JavaScript
/**
* Copyright 2018 The AMP HTML Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const isDependencyInstalled = require('../isDependencyInstalled');
const {createElement, appendChild, nextNode, firstChildByTag} = require('../NodeUtils');
const {URL} = require('url');
const {skipNodeAndChildren} = require('../HtmlDomHelper');
const PathResolver = require('../PathResolver');
const PIXEL_TARGET = 60;
const MAX_BLURRED_PLACEHOLDERS = 100;
const DEFAULT_CACHED_PLACEHOLDERS = 30;
const CACHE_ALL_PLACEHOLDERS = -1;
const ESCAPE_TABLE = {
'#': '%23',
'%': '%25',
':': '%3A',
'<': '%3C',
'>': '%3E',
'"': "'",
};
const ESCAPE_REGEX = new RegExp(Object.keys(ESCAPE_TABLE).join('|'), 'g');
function escaper(match) {
return ESCAPE_TABLE[match];
}
/**
* Adds placeholders for certain amp-img's and posters for amp-videos that are
* blurry versions of the corresponding original source. The blur will be
* displayed as the <amp-img> is rendering, and will fade out once the element
* is loaded. The current requirements of appending a blurry placeholder is for
* the element is to be a JPEG that is either responsive or a poster for an
* amp-video.
*
* This transformer supports the following option:
*
* * `blurredPlaceholders`: Enables blurry image placeholder generation. Default is `false`.
* * `imageBasePath`: specifies a base path used to resolve an image during build. You can
* also pass a function `(imgSrc, params) => '../img/' + imgSrc` for calculating the image path.
* * `maxBlurredPlaceholders`: Specifies the max number of blurred images. Defaults to 5.
* * `blurredPlaceholdersCacheSize`: Specifies the max number of blurred images to be cached
* to avoid expensive recalculation. Set to 0 if caching should be disabled. Set to -1 if
* all placeholders should be cached (good for static sites). Defaults to 30.
*
* Important: blurry image placeholder computation is expensive. Make sure to
* only use it for static or cached pages.
*/
class AddBlurryImagePlaceholders {
constructor(config) {
this.log_ = config.log.tag('AddBlurryImagePlaceholders');
// setup implementation only if placeholder generation is enabled
this.blurredPlaceholders_ = !!config.blurredPlaceholders;
if (!this.blurredPlaceholders_) {
this.log_.debug('disabled');
return;
}
// check whether all required dependencies are installed
if (!isDependencyInstalled('jimp') || !isDependencyInstalled('lru-cache')) {
this.log_.warn(
'jimp and lru-cache need to be installed via `npm install jimp lru-cache` ' +
'for this transformer to work'
);
// we can't generate placeholders
this.blurredPlaceholders_ = false;
return;
}
this.jimp = require('jimp');
// use provided upper placeholder limit for fallback to default
this.maxBlurredPlaceholders_ = config.maxBlurredPlaceholders || MAX_BLURRED_PLACEHOLDERS;
// used for resolving image files
this.pathResolver_ = new PathResolver(config.imageBasePath);
// setup caching
const maxCacheSize = config.blurredPlaceholdersCacheSize || DEFAULT_CACHED_PLACEHOLDERS;
// use a Map if all placeholders should be cached
if (maxCacheSize === CACHE_ALL_PLACEHOLDERS) {
this.log_.debug('caching all placeholders');
this.cache_ = new Map();
} else if (maxCacheSize > 0) {
const LRU = require('lru-cache');
this.log_.debug('using LRU cache for regularily used placeholders', maxCacheSize);
// use a LRU cache otherwise
this.cache_ = new LRU({
max: maxCacheSize,
});
} else {
this.log_.debug('caching disabled');
}
}
/**
* Parses the document to add blurred placedholders in all appropriate
* locations.
* @param {Object} runtime parameters
* @return {Array} An array of promises that all represents the resolution of
* a blurred placeholder being added in an appropriate place.
*/
transform(root, params) {
// Check if placeholders should be generated
if (!this.blurredPlaceholders_) {
return;
}
const html = firstChildByTag(root, 'html');
const body = firstChildByTag(html, 'body');
const promises = [];
let placeholders = 0;
for (let node = body; node !== null; node = nextNode(node)) {
const {tagName} = node;
let src;
if (tagName === 'template') {
node = skipNodeAndChildren(node);
continue;
}
if (tagName === 'amp-img') {
src = node.attribs.src;
}
if (tagName === 'amp-video' && node.attribs.poster) {
src = node.attribs.poster;
}
if (this.shouldAddBlurryPlaceholder_(node, src, tagName)) {
placeholders++;
const promise = this.addBlurryPlaceholder_(src, params).then((img) => {
node.attribs.noloading = '';
appendChild(node, img);
});
promises.push(promise);
if (placeholders >= this.maxBlurredPlaceholders_) {
break;
}
}
}
return Promise.all(promises);
}
/**
* Adds a child image that is a blurry placeholder.
* @param {String} src The image that the bitmap is based on.
* @return {!Promise} A promise that signifies that the img has been updated
* to have correct attributes to be a blurred placeholder along with the
* placeholder itself.
* @private
*/
async addBlurryPlaceholder_(src, params) {
const img = createElement('img', {
class: 'i-amphtml-blurry-placeholder',
placeholder: '',
src,
alt: '',
});
try {
const dataURI = await this.getCachedDataURI(src, params);
let svg = `<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 ${dataURI.width} ${dataURI.height}">
<filter id="b" color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation=".5"></feGaussianBlur>
<feComponentTransfer>
<feFuncA type="discrete" tableValues="1 1"></feFuncA>
</feComponentTransfer>
</filter>
<image filter="url(#b)" x="0" y="0"
height="100%" width="100%"
xlink:href="${dataURI.src}">
</image>
</svg>`;
// Optimizes dataURI length by deleting line breaks, and
// removing unnecessary spaces.
svg = svg.replace(/\s+/g, ' ');
svg = svg.replace(/> </g, '><');
svg = svg.replace(ESCAPE_REGEX, escaper);
img.attribs.src = 'data:image/svg+xml;charset=utf-8,' + svg;
this.log_.debug(src, '[SUCCESS]');
} catch (err) {
this.log_.debug(src, '[FAIL]');
this.log_.error(err.message);
}
return img;
}
/**
* Returns a cached dataURI if exists, otherwise creates a new one.
*/
getCachedDataURI(src, params) {
const resolvedSrc = this.pathResolver_.resolve(src, params);
if (this.cache_) {
const dataURIPromise = this.cache_.get(resolvedSrc);
if (dataURIPromise) {
this.log_.debug(src, '[CACHE HIT]');
return dataURIPromise;
}
this.log_.debug(src, '[CACHE MISS]');
}
const dataURIPromise = this.getDataURI_(resolvedSrc);
if (this.cache_) {
// we cache the promise to ensure that multiple requests for the same image
// still use the cache
this.cache_.set(resolvedSrc, dataURIPromise);
}
return dataURIPromise;
}
/**
* Creates the bitmap in a dataURI format.
* @param {string} the img src value
* placeholder.
* @return {!Promise} A promise that is resolved once the img's src is updated
* to be a dataURI of a bitmap including width and height.
* @private
*/
async getDataURI_(src) {
const image = await this.jimp.read(src);
const imgDimension = this.getBitmapDimensions_(image.bitmap.width, image.bitmap.height);
image.resize(imgDimension.width, imgDimension.height, this.jimp.RESIZE_BEZIER);
const result = {
src: await image.getBase64Async('image/png'),
width: image.bitmap.width,
height: image.bitmap.height,
};
return result;
}
/**
* Calculates the correct dimensions for the bitmap.
* @param {Node} img The DOM element that will need a bitmap.
* placeholder.
* @return {Record} The aspect ratio of the bitmap of the image.
* @private
*/
getBitmapDimensions_(imgWidth, imgHeight) {
// Aims for a bitmap of ~P pixels (w * h = ~P).
// Gets the ratio of the width to the height. (r = w0 / h0 = w / h)
const ratioWH = imgWidth / imgHeight;
// Express the width in terms of height by multiply the ratio by the
// height. (h * r = (w / h) * h)
// Plug this representation of the width into the original equation.
// (h * r * h = ~P).
// Divide the bitmap size by the ratio to get the all expressions using
// height on one side. (h * h = ~P / r)
let bitmapHeight = PIXEL_TARGET / ratioWH;
// Take the square root of the height instances to find the singular value
// for the height. (h = sqrt(~P / r))
bitmapHeight = Math.sqrt(bitmapHeight);
// Divide the goal total pixel amount by the height to get the width.
// (w = ~P / h).
const bitmapWidth = PIXEL_TARGET / bitmapHeight;
return {width: Math.round(bitmapWidth), height: Math.round(bitmapHeight)};
}
/**
* Checks if an element has a placeholder.
* @param {Node} node The DOM element that is being checked for a placeholder.
* @return {boolean} Whether or not the element already has a placeholder
* child.
* @private
*/
hasPlaceholder_(node) {
return (
node.childNodes.find((child) => {
return child.attribs && child.attribs.placeholder !== undefined;
}) !== undefined
);
}
/**
* Checks if an image should have a blurred image placeholder.
* The current criteria for determining if a blurry image placeholder should
* be appended is as follows:
* - The source for the image should be a JPEG.
* - If the element is:
* - an amp-img using a responsive layout (responsive, fill or intrinsic)
* - an amp-video with a poster
*
* This criteria was found to be the most common places where a blurry image
* placeholder would likely want to be used through manual examination of
* existing AMP pages.
* @param {Node} node The DOM element that is being checked to see if it
* should have a blurred placeholder.
* @param {string} src The image source that is being checked.
* @param {string} tagName The type of element that is being checked.
* @return {boolean} Whether or not the element should have a blurred
* placeholder child.
* @private
*/
shouldAddBlurryPlaceholder_(node, src, tagName) {
// Ensures current placeholders are not overridden.
if (!src) {
return false;
}
if (this.hasPlaceholder_(node)) {
return false;
}
// Non-JPEG images are not commonly featured in a role where blurred
// image placeholders would be wanted.
const url = new URL(src, 'https://example.com');
if (!url.pathname.endsWith('.jpg') && !url.pathname.endsWith('jpeg')) {
return false;
}
// Images or videos with noloading attributes should not have any indicators that they
// are loading.
if (node.attribs.noloading != null) {
return false;
}
// Checks if the image is a poster or a responsive image as these are the
// two most common cases where blurred placeholders would be wanted.
const isPoster = tagName == 'amp-video';
const isResponsiveImgWithLoading =
tagName == 'amp-img' &&
(node.attribs.layout == 'intrinsic' ||
node.attribs.layout == 'responsive' ||
node.attribs.layout == 'fill');
return isPoster || isResponsiveImgWithLoading;
}
}
/** @module AddBlurryImagePlaceholders */
module.exports = AddBlurryImagePlaceholders;