Skip to content
Open
22 changes: 21 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"@netlify/edge-bundler": "14.5.4",
"@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.7",
Expand Down Expand Up @@ -112,7 +113,6 @@
"https-proxy-agent": "7.0.6",
"inquirer": "8.2.7",
"inquirer-autocomplete-prompt": "1.4.0",
"ipx": "3.1.1",
"is-docker": "3.0.0",
"is-stream": "4.0.1",
"is-wsl": "3.1.0",
Expand Down
182 changes: 20 additions & 162 deletions src/lib/images/proxy.ts
Original file line number Diff line number Diff line change
@@ -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'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can remove the ipx dep (it's used indirectly in @netlify/images now)

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<NormalizedCachedConfigConfig, 'images'>): {
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')
}
}
}
18 changes: 16 additions & 2 deletions src/utils/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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,
Expand Down
66 changes: 0 additions & 66 deletions tests/unit/lib/images/proxy.test.ts

This file was deleted.