From 6b2e41329145f4515cab5a58deeff1f87e098adb Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 17 May 2021 15:31:43 +0100 Subject: [PATCH] fix: refactor image function --- package-lock.json | 59 ++++++++++++++++- package.json | 2 + src/lib/templates/imageFunction.js | 103 ++++++++++++++++++++--------- 3 files changed, 131 insertions(+), 33 deletions(-) diff --git a/package-lock.json b/package-lock.json index 49a914df58..117593302f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,8 @@ "find-cache-dir": "^3.3.1", "find-up": "^5.0.0", "fs-extra": "^9.1.0", + "image-type": "^4.1.0", + "is-svg": "^4.3.1", "make-dir": "^3.1.0", "mime-types": "^2.1.30", "moize": "^6.0.0", @@ -10025,6 +10027,14 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-type": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-10.11.0.tgz", + "integrity": "sha512-uzk64HRpUZyTGZtVuvrjP0FYxzQrBf4rojot6J65YMEbwBLB0CWm0CLojVpwpmFmxcE/lkvYICgfcGozbBq6rw==", + "engines": { + "node": ">=6" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -11103,6 +11113,17 @@ "node": ">=4.0" } }, + "node_modules/image-type": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/image-type/-/image-type-4.1.0.tgz", + "integrity": "sha512-CFJMJ8QK8lJvRlTCEgarL4ro6hfDQKif2HjSvYCdQZESaIPV4v9imrf7BQHK+sQeTeNeMpWciR9hyC/g8ybXEg==", + "dependencies": { + "file-type": "^10.10.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -11752,6 +11773,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-svg": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/is-svg/-/is-svg-4.3.1.tgz", + "integrity": "sha512-h2CGs+yPUyvkgTJQS9cJzo9lYK06WgRiXUqBBHtglSzVKAuH4/oWsqk7LGfbSa1hGk9QcZ0SyQtVggvBA8LZXA==", + "dependencies": { + "fast-xml-parser": "^3.19.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-symbol": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", @@ -46927,6 +46962,11 @@ "flat-cache": "^3.0.4" } }, + "file-type": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-10.11.0.tgz", + "integrity": "sha512-uzk64HRpUZyTGZtVuvrjP0FYxzQrBf4rojot6J65YMEbwBLB0CWm0CLojVpwpmFmxcE/lkvYICgfcGozbBq6rw==" + }, "file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -47726,6 +47766,14 @@ "integrity": "sha512-47xSUiQioGaB96nqtp5/q55m0aBQSQdyIloMOc/x+QVTDZLNmXE892IIDrJ0hM1A5vcNUDD5tDffkSP5lCaIIA==", "peer": true }, + "image-type": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/image-type/-/image-type-4.1.0.tgz", + "integrity": "sha512-CFJMJ8QK8lJvRlTCEgarL4ro6hfDQKif2HjSvYCdQZESaIPV4v9imrf7BQHK+sQeTeNeMpWciR9hyC/g8ybXEg==", + "requires": { + "file-type": "^10.10.0" + } + }, "import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -48171,6 +48219,14 @@ "integrity": "sha512-2gdzbKUuqtQ3lYNrUTQYoClPhm7oQu4UdpSZMp1/DGgkHBT8E2Z1l0yMdb6D4zNAxwDiMv8MdulKROJGNl0Q0w==", "dev": true }, + "is-svg": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/is-svg/-/is-svg-4.3.1.tgz", + "integrity": "sha512-h2CGs+yPUyvkgTJQS9cJzo9lYK06WgRiXUqBBHtglSzVKAuH4/oWsqk7LGfbSa1hGk9QcZ0SyQtVggvBA8LZXA==", + "requires": { + "fast-xml-parser": "^3.19.0" + } + }, "is-symbol": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", @@ -50868,7 +50924,6 @@ "integrity": "sha512-HcZ0RWQRuJfpPiaHyFQJzcym+/dDIVUPwUAXWoub/C4GkGu+mPjp8vqK6g0FxokCnnI2TK0gZTza2IDfiNNscQ==", "peer": true, "requires": { - "@babel/core": "^7.0.0", "@babel/plugin-proposal-class-properties": "^7.0.0", "@babel/plugin-proposal-export-default-from": "^7.0.0", "@babel/plugin-proposal-nullish-coalescing-operator": "^7.0.0", @@ -50923,7 +50978,6 @@ "integrity": "sha512-K1sHO3ODBFCr7uEiCQ4RvVr+cQg0EHQF8ChVPnecGh/WDD8udrTq9ECwB0dRfMjAvlsHtRUlJm6ZSI8UPgum2w==", "peer": true, "requires": { - "@babel/core": "^7.0.0", "babel-preset-fbjs": "^3.3.0", "metro-babel-transformer": "0.64.0", "metro-react-native-babel-preset": "0.64.0", @@ -54077,7 +54131,6 @@ "integrity": "sha512-5vwpq6kbvwkQwKqAoOU3L72GZ3Ta8RRrewKj9OJRolx28KLJJ8Dg9Rf7obRwt5jQA9bkYd8gqzMTrI7H3xLfaw==", "dev": true, "requires": { - "@oclif/config": "^1.15.1", "@oclif/errors": "^1.3.3", "@oclif/parser": "^3.8.3", "@oclif/plugin-help": "^3", diff --git a/package.json b/package.json index 507b78c978..29c0944dcc 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,8 @@ "find-cache-dir": "^3.3.1", "find-up": "^5.0.0", "fs-extra": "^9.1.0", + "image-type": "^4.1.0", + "is-svg": "^4.3.1", "make-dir": "^3.1.0", "mime-types": "^2.1.30", "moize": "^6.0.0", diff --git a/src/lib/templates/imageFunction.js b/src/lib/templates/imageFunction.js index cae025d072..8b56362298 100644 --- a/src/lib/templates/imageFunction.js +++ b/src/lib/templates/imageFunction.js @@ -1,52 +1,95 @@ -const path = require('path') const { builder } = require('@netlify/functions') const sharp = require('sharp') const fetch = require('node-fetch') +const imageType = require('image-type') +const isSvg = require('is-svg') -// Function used to mimic next/image and sharp +function getImageType(buffer) { + const type = imageType(buffer) + if (type) { + return type + } + if (isSvg(buffer)) { + return { ext: 'svg', mime: 'image/svg' } + } + return null +} + +const IGNORED_FORMATS = new Set(['svg', 'gif']) +const OUTPUT_FORMATS = new Set(['png', 'jpg', 'webp', 'avif']) + +// Function used to mimic next/image const handler = async (event) => { const [, , url, w = 500, q = 75] = event.path.split('/') - const parsedUrl = decodeURIComponent(url) + // Work-around a bug in redirect handling. Remove when fixed. + const parsedUrl = decodeURIComponent(url).replace('+', '%20') const width = parseInt(w) - const quality = parseInt(q) + + if (!width) { + return { + statusCode: 400, + body: 'Invalid image parameters', + } + } + + const quality = parseInt(q) || 60 const imageUrl = parsedUrl.startsWith('/') ? `${process.env.DEPLOY_URL || `http://${event.headers.host}`}${parsedUrl}` : parsedUrl + const imageData = await fetch(imageUrl) + + if (!imageData.ok) { + console.error(`Failed to download image ${imageUrl}. Status ${imageData.status} ${imageData.statusText}`) + return { + statusCode: imageData.status, + body: imageData.statusText, + } + } + const bufferData = await imageData.buffer() - const ext = path.extname(imageUrl) - const mimeType = ext === 'jpg' ? `image/jpeg` : `image/${ext}` - - let image - let imageBuffer - - if (mimeType === 'image/gif') { - image = await sharp(bufferData, { animated: true }) - // gif resizing in sharp seems unstable (https://github.com/lovell/sharp/issues/2275) - imageBuffer = await image.toBuffer() - } else { - image = await sharp(bufferData) - if (mimeType === 'image/webp') { - image = image.webp({ quality }) - } else if (mimeType === 'image/jpeg') { - image = image.jpeg({ quality }) - } else if (mimeType === 'image/png') { - image = image.png({ quality }) - } else if (mimeType === 'image/avif') { - image = image.avif({ quality }) - } else if (mimeType === 'image/tiff') { - image = image.tiff({ quality }) - } else if (mimeType === 'image/heif') { - image = image.heif({ quality }) + + const type = getImageType(bufferData) + + if (!type) { + return { statusCode: 400, body: 'Source does not appear to be an image' } + } + + let { ext } = type + + // For unsupported formats (gif, svg) we redirect to the original + if (IGNORED_FORMATS.has(ext)) { + return { + statusCode: 302, + headers: { + Location: imageUrl, + }, } - imageBuffer = await image.resize(width).toBuffer() } + if (process.env.FORCE_WEBP_OUTPUT) { + ext = 'webp' + } + + if (!OUTPUT_FORMATS.has(ext)) { + ext = 'jpg' + } + + // The format methods are just to set options: they don't + // make it return that format. + const { info, data: imageBuffer } = await sharp(bufferData) + .jpeg({ quality, force: ext === 'jpg' }) + .webp({ quality, force: ext === 'webp' }) + .png({ quality, force: ext === 'png' }) + .avif({ quality, force: ext === 'avif' }) + .resize(width) + .toBuffer({ resolveWithObject: true }) + return { statusCode: 200, headers: { - 'Content-Type': mimeType, + 'Content-Type': `image/${info.format}`, }, body: imageBuffer.toString('base64'), isBase64Encoded: true,