UNPKG

next-csrf

Version:

CSRF mitigation library for Next.js

326 lines (298 loc) 9.37 kB
/** * 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. */ 'use strict'; const { appendChild, createElement, hasAttribute, insertAfter, nextNode, firstChildByTag, } = require('../NodeUtils'); const {findMetaViewport, skipNodeAndChildren} = require('../HtmlDomHelper'); const {isValidImageSrcURL} = require('../URLUtils'); const {isTemplate} = require('../AmpConstants'); // Images smaller than 150px are considered tiny const TINY_IMG_THRESHOLD = 150; // Maximum number of hero images defined via data-hero const DATA_HERO_MAX = 2; /** * PreloadHeroImage - this transformers optimizes image rendering times for hero images. For hero * images it will: * * 1. Inject a preload hint (if possible) * 2. Generate an img tag enabling the browser to render the image without the AMP runtime being loaded. * * Hero images are either identified automatically or can be explicitly defined by adding an `data-hero` * attribute to the element. * * This transformer supports the following options: * * * `preloadHeroImage`: [true|false] - enables or disables hero image preloading. The default is `true`. */ class PreloadHeroImage { constructor(config) { this.log = config.log; this.enabled = config.preloadHeroImage !== false; } async transform(root, params) { if (!this.enabled || params.preloadHeroImage === false) { return; } const html = firstChildByTag(root, 'html'); const head = firstChildByTag(html, 'head'); const body = firstChildByTag(html, 'body'); if (!body || !head) return; const heroImages = this.findHeroImages(body); let referenceNode = findMetaViewport(head); let heroImageCount = heroImages.length; if (heroImageCount > DATA_HERO_MAX) { this.log.warn( `Found ${heroImageCount} hero elements on the page. AMP currently only supports a maximum of ${DATA_HERO_MAX} elements.` ); heroImageCount = DATA_HERO_MAX; } for (let i = 0; i < heroImageCount; i++) { const heroImage = heroImages[i]; this.generatePreload(heroImage, head, referenceNode); this.generateImg(heroImage.ampImg); } } generatePreload(heroImage, head, referenceNode) { if (heroImage.srcset) { this.log.debug( "Could not preload hero image as it's using srcset, which is currently only supported Chromium-based browsers (see https://web.dev/preload-responsive-images/).", heroImage.src ); return; } if (this.hasExistingImagePreload(head, heroImage.src)) { return; } const preload = createElement('link', { 'rel': 'preload', 'href': heroImage.src, 'as': 'image', 'data-hero': '', }); if (heroImage.media) { preload.attribs.media = heroImage.media; } insertAfter(head, preload, referenceNode); } hasExistingImagePreload(head, src) { return head.children.some((node) => { if (node.tagName !== 'link') { return false; } if (!hasAttribute(node, 'rel')) { return false; } if (node.attribs.rel !== 'preload') { return false; } if (node.attribs.as !== 'image') { return false; } return node.attribs.href === src; }); } findHeroImages(root) { let heroImageCandidate = null; let heroImages = []; let node = root; // Walk over all nodes in the body while (node !== null) { // Look for data-hero attribute this.addImageWithDataHero(node, heroImages); // Auto detect a hero image in case data-hero is not used if (!heroImageCandidate && heroImages.length === 0) { heroImageCandidate = this.isCandidateHeroImage(node); } if (isTemplate(root)) { // Ignore images inside templates node = skipNodeAndChildren(node); } else { node = nextNode(node); } } // Optimize data-hero element if defined if (heroImages.length > 0) { return heroImages; } // Fallback to auto detected hero image if available if (heroImageCandidate) { return [heroImageCandidate]; } // No hero images to optimize return []; } addImageWithDataHero(node, heroImages) { if (node.tagName === 'amp-img' && hasAttribute(node, 'data-hero')) { const {src, media, srcset} = node.attribs; heroImages.push({ ampImg: node, src, media, srcset, }); } else if (this.isAmpIframe(node) && hasAttribute(node, 'data-hero')) { const placeholder = this.getPlaceholderImage(node); if (placeholder) { heroImages.push(placeholder); } } } isCandidateHeroImage(node) { if (!node.tagName) { return null; } const layout = node.attribs ? node.attribs.layout : ''; if (layout === 'nodisplay') { return null; } if (node.tagName === 'amp-img') { return this.isCandidateImageForPreloading(node); } if (node.tagName === 'amp-video') { return this.isCandidateVideoPosterImage(node); } if (this.isAmpIframe(node)) { return this.isCandidateIframePlaceholderImage(node); } return null; } isAmpIframe(node) { return node.tagName === 'amp-iframe' || node.tagName === 'amp-video-iframe'; } // For a given <amp-video> node or any node that has poster attribute, and // qualifies as hero image, returns the HeroImageSrcs. isCandidateVideoPosterImage(ampVideo) { const poster = ampVideo.attribs.poster; if (!poster) return null; if (!isValidImageSrcURL(poster)) { return null; } const {layout, width, height, media} = ampVideo.attribs; if (this.isTinyNode(layout, width, height)) { return null; } return {src: poster, media, srcset: ''}; } isCandidateIframePlaceholderImage(ampIframe) { // Placeholder amp-img is required to preload image for iframe. if (!ampIframe.children || ampIframe.children.length === 0) { return null; } const {layout, width, height} = ampIframe.attribs; if (this.isTinyNode(layout, width, height)) return null; return this.getPlaceholderImage(ampIframe); } getPlaceholderImage(ampIframe) { for (const child of ampIframe.children) { if ( child.tagName === 'amp-img' && hasAttribute(child, 'placeholder') && isValidImageSrcURL(child.attribs.src) ) { return { ampImg: child, src: child.attribs.src, media: ampIframe.attribs.media, srcset: child.attribs.srcset || '', }; } } return null; } // Checks if node qualifies to be a hero image. Returns HeroImageSrcs if the // node is a hero image. The hero image here can come from one of <amp-img>, // <amp-video>, <amp-iframe>, <amp-video-iframe>. isCandidateImageForPreloading(ampImg) { const src = ampImg.attribs.src; if (!src) { return null; } if (!isValidImageSrcURL(src)) { return null; } let {width, height, srcset, layout, media} = ampImg.attribs; if (!width && !height) { if (layout === 'fill') { ({width, height} = this.nodeDimensionsFromParent(ampImg)); } else { return null; } } if (this.isTinyNode(layout, width, height)) { return null; } return {ampImg, src, srcset, media}; } // Any node with width or height less than 150 pixels and a non-responsive layout. isTinyNode(layout, width, height) { if (width <= 0 || height <= 0) return true; if (layout === 'intrinsic' || layout === 'responsive') { return false; } return (width > 0 && width < TINY_IMG_THRESHOLD) || (height > 0 && height < TINY_IMG_THRESHOLD); } nodeDimensionsFromParent(node) { while (node.parent) { node = node.parent; if (!node.attribs) { continue; } const width = node.attribs.width; const height = node.attribs.height; if (!width && !height) { continue; } return {width, height}; } return {width: 0, height: 0}; } generateImg(node) { if (!node) { return; } const imgNode = createElement('img', { class: 'i-amphtml-fill-content i-amphtml-replaced-content', decoding: 'async', }); const attributesToCopy = [ 'alt', 'attribution', 'object-fit', 'object-position', 'referrerpolicy', 'src', 'srcset', 'sizes', 'title', ]; for (const attr of attributesToCopy) { if (hasAttribute(node, attr)) { imgNode.attribs[attr] = node.attribs[attr]; } } node.attribs['i-amphtml-ssr'] = ''; node.attribs['data-hero'] = ''; appendChild(node, imgNode); } } /** @module PreloadHeroImage */ module.exports = PreloadHeroImage;