Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
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'
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.

Loading