diff --git a/package-lock.json b/package-lock.json index 2ab9c50b8f6..b04a11fb62e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@netlify/edge-bundler": "14.5.2", "@netlify/edge-functions-bootstrap": "2.14.0", "@netlify/headers-parser": "9.0.2", + "@netlify/images": "^1.2.5", "@netlify/local-functions-proxy": "2.0.3", "@netlify/redirect-parser": "15.0.3", "@netlify/zip-it-and-ship-it": "14.1.4", @@ -2999,6 +3000,18 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/@netlify/images": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@netlify/images/-/images-1.2.5.tgz", + "integrity": "sha512-kTcM86Zpzne46RDQJO5o0rDEryYbBpRk7+8NaWLYP6ChM13MdLYwk9nLYyh4APWB2Zx9JBvBJO3Q/lKiF20zXg==", + "license": "MIT", + "dependencies": { + "ipx": "^3.1.1" + }, + "engines": { + "node": ">=20.6.1" + } + }, "node_modules/@netlify/local-functions-proxy": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@netlify/local-functions-proxy/-/local-functions-proxy-2.0.3.tgz", @@ -21149,6 +21162,14 @@ } } }, + "@netlify/images": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@netlify/images/-/images-1.2.5.tgz", + "integrity": "sha512-kTcM86Zpzne46RDQJO5o0rDEryYbBpRk7+8NaWLYP6ChM13MdLYwk9nLYyh4APWB2Zx9JBvBJO3Q/lKiF20zXg==", + "requires": { + "ipx": "^3.1.1" + } + }, "@netlify/local-functions-proxy": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@netlify/local-functions-proxy/-/local-functions-proxy-2.0.3.tgz", diff --git a/package.json b/package.json index 2dd27996173..96eba53719b 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "@netlify/edge-bundler": "14.5.2", "@netlify/edge-functions-bootstrap": "2.14.0", "@netlify/headers-parser": "9.0.2", + "@netlify/images": "^1.2.5", "@netlify/local-functions-proxy": "2.0.3", "@netlify/redirect-parser": "15.0.3", "@netlify/zip-it-and-ship-it": "14.1.4", diff --git a/src/lib/images/proxy.ts b/src/lib/images/proxy.ts index 3773b7ba1af..437bf5aa425 100644 --- a/src/lib/images/proxy.ts +++ b/src/lib/images/proxy.ts @@ -1,184 +1,42 @@ -import type { IncomingMessage } from 'http' +import type { IncomingMessage, ServerResponse } from 'http' -import express from 'express' -import { createIPX, ipxFSStorage, ipxHttpStorage, createIPXNodeServer } from 'ipx' +import { type ImageHandler } from '@netlify/images' -import { log, NETLIFYDEVERR, type NormalizedCachedConfigConfig } from '../../utils/command-helpers.js' import { getProxyUrl } from '../../utils/proxy.js' import type { ServerSettings } from '../../utils/types.d.ts' +import { fromWebResponse, toWebRequest } from '@netlify/dev-utils' export const IMAGE_URL_PATTERN = '/.netlify/images' -interface QueryParams { - w?: string - width?: string - h?: string - height?: string - q?: string - quality?: string - fm?: string - fit?: string - position?: string -} - -interface IpxParams { - w?: string | null - h?: string | null - s?: string | null - quality?: string | null - format?: string | null - fit?: string | null - position?: string | null -} - -export const parseAllRemoteImages = function (config: Pick): { - errors: ErrorObject[] - remotePatterns: RegExp[] -} { - const remotePatterns = [] as RegExp[] - const errors = [] as ErrorObject[] - const remoteImages = config?.images?.remote_images - - if (!remoteImages) { - return { errors, remotePatterns } - } - - for (const patternString of remoteImages) { - try { - const urlRegex = new RegExp(patternString) - remotePatterns.push(urlRegex) - } catch (error) { - const message = error instanceof Error ? error.message : 'An unknown error occurred' - - errors.push({ message }) - } - } - - return { errors, remotePatterns } -} - -interface ErrorObject { - message: string -} - -const getErrorMessage = function ({ message }: { message: string }): string { - return message -} - -const handleRemoteImagesErrors = function (errors: ErrorObject[]) { - if (errors.length === 0) { - return - } - - const errorMessage = errors.map(getErrorMessage).join('\n\n') - log(NETLIFYDEVERR, `Remote images syntax errors:\n${errorMessage}`) -} - -const parseRemoteImages = function ({ config }: { config: NormalizedCachedConfigConfig }) { - if (!config) { - return [] - } - - const { errors, remotePatterns } = parseAllRemoteImages(config) - handleRemoteImagesErrors(errors) - - return remotePatterns -} - export const isImageRequest = function (req: IncomingMessage): boolean { return req.url?.startsWith(IMAGE_URL_PATTERN) ?? false } -export const transformImageParams = function (query: QueryParams): string { - const params: IpxParams = {} - - const width = query.w || query.width || null - const height = query.h || query.height || null - - if (width && height) { - params.s = `${width}x${height}` - } else { - params.w = width - params.h = height - } - - params.quality = query.q || query.quality || null - params.format = query.fm || null - - const fit = query.fit || null - params.fit = fit === 'contain' ? 'inside' : fit - - params.position = query.position || null - - return Object.entries(params) - .filter(([, value]) => value !== null) - .map(([key, value]) => `${key}_${value}`) - .join(',') -} - export const initializeProxy = function ({ - config, settings, + imageHandler, }: { - config: NormalizedCachedConfigConfig settings: ServerSettings + imageHandler: ImageHandler }) { - const remoteImages = parseRemoteImages({ config }) const devServerUrl = getProxyUrl(settings) - const ipx = createIPX({ - storage: ipxFSStorage({ dir: ('publish' in config.build ? config.build.publish : undefined) ?? './public' }), - httpStorage: ipxHttpStorage({ - allowAllDomains: true, - }), - }) - - const handler = createIPXNodeServer(ipx) - const app = express() - - let lastTimeRemoteImagesConfigurationDetailsMessageWasLogged = 0 - - app.use(IMAGE_URL_PATTERN, (req, res) => { - const { url, ...query } = req.query - const sourceImagePath = url as string - const modifiers = transformImageParams(query) || `_` - if (!sourceImagePath.startsWith('http://') && !sourceImagePath.startsWith('https://')) { - // Construct the full URL for relative paths to request from development server - const sourceImagePathWithLeadingSlash = sourceImagePath.startsWith('/') ? sourceImagePath : `/${sourceImagePath}` - const fullImageUrl = `${devServerUrl}${encodeURIComponent(sourceImagePathWithLeadingSlash)}` - req.url = `/${modifiers}/${fullImageUrl}` - } else { - // If the image is remote, we first check if it's allowed by any of patterns - if (!remoteImages.some((remoteImage) => remoteImage.test(sourceImagePath))) { - const remoteImageNotAllowedLogMessage = `Remote image "${sourceImagePath}" source for Image CDN is not allowed.` - - // Contextual information about the remote image configuration is throttled - // to avoid spamming the console as it's quite verbose - // Each not allowed remote image will still be logged, just without configuration details - if (Date.now() - lastTimeRemoteImagesConfigurationDetailsMessageWasLogged > 1000 * 30) { - log( - `${remoteImageNotAllowedLogMessage}\n\n${ - remoteImages.length === 0 - ? 'Currently no remote images are allowed.' - : `Currently allowed remote images configuration details:\n${remoteImages - .map((pattern) => ` - ${pattern}`) - .join('\n')}` - }\n\nRefer to https://ntl.fyi/remote-images for information about how to configure allowed remote images.`, - ) - lastTimeRemoteImagesConfigurationDetailsMessageWasLogged = Date.now() - } else { - log(remoteImageNotAllowedLogMessage) - } - - res.status(400).end() + return async (req: IncomingMessage, res: ServerResponse) => { + try { + const webRequest = toWebRequest(req) + const match = imageHandler.match(webRequest) + if (!match) { + res.statusCode = 404 + res.end('Image not found') return } - // Construct the full URL for remote paths - req.url = `/${modifiers}/${encodeURIComponent(sourceImagePath)}` - } - - handler(req, res) - }) - return app + const response = await match.handle(devServerUrl) + await fromWebResponse(response, res) + } catch (error) { + console.error('Image proxy error:', error) + res.statusCode = 500 + res.end('Internal server error') + } + } } diff --git a/src/utils/proxy.ts b/src/utils/proxy.ts index 5aa29a27ea9..ae9d1d65051 100644 --- a/src/utils/proxy.ts +++ b/src/utils/proxy.ts @@ -14,6 +14,7 @@ import util from 'util' import zlib from 'zlib' import { renderFunctionErrorPage } from '@netlify/dev-utils' +import { ImageHandler } from '@netlify/images' import contentType from 'content-type' import cookie from 'cookie' import { getProperty } from 'dot-prop' @@ -39,7 +40,15 @@ import { getFormHandler } from '../lib/functions/form-submissions-handler.js' import { DEFAULT_FUNCTION_URL_EXPRESSION } from '../lib/functions/registry.js' import { initializeProxy as initializeImageProxy, isImageRequest } from '../lib/images/proxy.js' -import { NETLIFYDEVLOG, NETLIFYDEVWARN, type NormalizedCachedConfigConfig, chalk, log } from './command-helpers.js' +import { + NETLIFYDEVLOG, + NETLIFYDEVWARN, + type NormalizedCachedConfigConfig, + chalk, + log, + logError, + warn, +} from './command-helpers.js' import createStreamPromise from './create-stream-promise.js' import { NFFunctionName, NFFunctionRoute, NFRequestID, headersForPath, parseHeaders } from './headers.js' import { generateRequestID } from './request-id.js' @@ -971,10 +980,15 @@ export const startProxy = async function ({ }) } + const imageHandler = new ImageHandler({ + logger: { log, warn, error: logError }, + imagesConfig: config.images, + }) const imageProxy = initializeImageProxy({ - config, settings, + imageHandler, }) + const proxy = await initializeProxy({ env, host: settings.frameworkHost, diff --git a/tests/unit/lib/images/proxy.test.ts b/tests/unit/lib/images/proxy.test.ts deleted file mode 100644 index 72ec0cabf7e..00000000000 --- a/tests/unit/lib/images/proxy.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { expect, test } from 'vitest' - -import { parseAllRemoteImages, transformImageParams } from '../../../../src/lib/images/proxy.js' - -test('should parse all remote images correctly', () => { - const config = { - images: { - remote_images: ['https://example.com/*', 'https://test.com/*'], - }, - } - const { errors, remotePatterns } = parseAllRemoteImages(config) - expect(errors).toEqual([]) - expect(remotePatterns).toEqual([/https:\/\/example.com\/*/, /https:\/\/test.com\/*/]) -}) - -test('should report invalid remote images', () => { - const config = { - images: { - remote_images: ['*'], - }, - } - const { errors, remotePatterns } = parseAllRemoteImages(config) - expect(errors).toEqual([ - { - message: 'Invalid regular expression: /*/: Nothing to repeat', - }, - ]) - expect(remotePatterns).toEqual([]) -}) - -test('should transform image params correctly - without fit or position', () => { - const query = { - w: '100', - - q: '80', - fm: 'jpg', - } - const result = transformImageParams(query) - expect(result).toEqual('w_100,quality_80,format_jpg') -}) - -test('should transform image params correctly - resize', () => { - const query = { - w: '100', - h: '200', - q: '80', - fm: 'jpg', - fit: 'cover', - position: 'center', - } - const result = transformImageParams(query) - expect(result).toEqual('s_100x200,quality_80,format_jpg,fit_cover,position_center') -}) - -test('should transform image params correctly - fit is contain', () => { - const query = { - w: '100', - h: '200', - q: '80', - fm: 'jpg', - fit: 'contain', - position: 'center', - } - const result = transformImageParams(query) - expect(result).toEqual('s_100x200,quality_80,format_jpg,fit_inside,position_center') -})