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
50 changes: 40 additions & 10 deletions src/runtime/providers/cloudflare.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { encodeQueryItem, joinURL } from 'ufo'
import { encodeQueryItem, hasProtocol, joinURL } from 'ufo'
import { createOperationsGenerator } from '../utils/index'
import { defineProvider } from '../utils/provider'

Expand Down Expand Up @@ -34,22 +34,52 @@ const defaultModifiers = {}

interface CloudflareOptions {
baseURL?: string
/** Explicit app origin for cross-zone resolution (e.g. 'https://app.example.com'). */
appOrigin?: string
}

// https://developers.cloudflare.com/images/image-resizing/url-format/
function getRequestOrigin(event: unknown): string {
const headers = (event as any)?.headers
if (typeof headers?.get === 'function') {
const forwardedHost = headers.get('x-forwarded-host')
const host = (forwardedHost ? forwardedHost.split(',')[0].trim() : '') || headers.get('host')
const proto = (headers.get('x-forwarded-proto') || 'https').split(',')[0].trim()
if (host) return `${proto}://${host}`
}
if (typeof window !== 'undefined' && window.location?.origin && window.location.origin !== 'null') {
return window.location.origin
}
return ''
}

// https://developers.cloudflare.com/images/transform-images/transform-via-url/
export default defineProvider<CloudflareOptions>({
getImage: (src, {
modifiers,
baseURL = '/',
}) => {
getImage: (src, { modifiers, baseURL = '/', appOrigin }, ctx) => {
const mergeModifiers = { ...defaultModifiers, ...modifiers }
const operations = operationsGenerator(mergeModifiers as any)

// https://<ZONE>/cdn-cgi/image/<OPTIONS>/<SOURCE-IMAGE>
const url = operations ? joinURL(baseURL, 'cdn-cgi/image', operations, src) : src
const isExternal = hasProtocol(src)
const sourcePath = isExternal ? src : joinURL(ctx.options.nuxt.baseURL, src)

return {
url,
// Cross-zone: resolve relative src to absolute URL so Cloudflare fetches from the correct origin
let imageSource = sourcePath
if (!isExternal && hasProtocol(baseURL)) {
const origin = appOrigin || getRequestOrigin(ctx.options.event)
if (origin) {
imageSource = joinURL(origin, sourcePath)
}
else {
console.warn(
`[nuxt-image] Cloudflare cross-zone: could not determine app origin for source "${sourcePath}". `
+ 'Set `appOrigin` in your Cloudflare provider options to fix this.',
)
}
}

const url = operations
? joinURL(baseURL, 'cdn-cgi/image', operations, imageSource)
: sourcePath

return { url }
},
})
184 changes: 183 additions & 1 deletion test/nuxt/providers.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi } from 'vitest'

import { images } from '../providers'

Expand Down Expand Up @@ -111,6 +111,188 @@ describe('Providers', () => {
}
})

it('cloudflare with app.baseURL', () => {
const ctx = { options: { ...getEmptyContext().options, nuxt: { baseURL: '/admin/' } } } as any

expect(cloudflare().getImage('/images/test.png', {
modifiers: { width: 200 },
baseURL: '/',
}, ctx)).toMatchObject({ url: '/cdn-cgi/image/w=200/admin/images/test.png' })

expect(cloudflare().getImage('/images/test.png', {
modifiers: {},
baseURL: '/',
}, ctx)).toMatchObject({ url: '/admin/images/test.png' })
})

it('cloudflare with external image', () => {
expect(cloudflare().getImage('https://example.com/photo.jpg', {
modifiers: { width: 200 },
baseURL: '/',
}, getEmptyContext())).toMatchObject({ url: '/cdn-cgi/image/w=200/https://example.com/photo.jpg' })

expect(cloudflare().getImage('https://example.com/photo.jpg', {
modifiers: {},
baseURL: '/',
}, getEmptyContext())).toMatchObject({ url: 'https://example.com/photo.jpg' })
})

it('cloudflare cross-zone', () => {
const ctx = {
options: {
...getEmptyContext().options,
nuxt: { baseURL: '/' },
event: {
headers: new Headers({
'host': 'app.example.com',
'x-forwarded-proto': 'https',
}),
},
},
} as any

expect(cloudflare().getImage('/images/test.png', {
modifiers: { width: 200 },
baseURL: 'https://cdn.example.com',
}, ctx)).toMatchObject({ url: 'https://cdn.example.com/cdn-cgi/image/w=200/https://app.example.com/images/test.png' })

expect(cloudflare().getImage('/images/test.png', {
modifiers: {},
baseURL: 'https://cdn.example.com',
}, ctx)).toMatchObject({ url: '/images/test.png' })
})

it('cloudflare cross-zone with app.baseURL', () => {
const ctx = {
options: {
...getEmptyContext().options,
nuxt: { baseURL: '/admin/' },
event: {
headers: new Headers({
'host': 'app.example.com',
'x-forwarded-proto': 'https',
}),
},
},
} as any

expect(cloudflare().getImage('/images/test.png', {
modifiers: { width: 200 },
baseURL: 'https://cdn.example.com',
}, ctx)).toMatchObject({ url: 'https://cdn.example.com/cdn-cgi/image/w=200/https://app.example.com/admin/images/test.png' })

expect(cloudflare().getImage('/images/test.png', {
modifiers: {},
baseURL: 'https://cdn.example.com',
}, ctx)).toMatchObject({ url: '/admin/images/test.png' })
})

it('cloudflare cross-zone with external src', () => {
const ctx = {
options: {
...getEmptyContext().options,
nuxt: { baseURL: '/' },
event: {
headers: new Headers({
'host': 'app.example.com',
'x-forwarded-proto': 'https',
}),
},
},
} as any

expect(cloudflare().getImage('https://other.example.com/images/test.png', {
modifiers: { width: 200 },
baseURL: 'https://cdn.example.com',
}, ctx)).toMatchObject({ url: 'https://cdn.example.com/cdn-cgi/image/w=200/https://other.example.com/images/test.png' })

expect(cloudflare().getImage('https://other.example.com/images/test.png', {
modifiers: {},
baseURL: 'https://cdn.example.com',
}, ctx)).toMatchObject({ url: 'https://other.example.com/images/test.png' })
})

it('cloudflare cross-zone with appOrigin', () => {
const ctx = {
options: {
...getEmptyContext().options,
nuxt: { baseURL: '/admin/' },
},
} as any

expect(cloudflare().getImage('/images/test.png', {
modifiers: { width: 200 },
baseURL: 'https://cdn.example.com',
appOrigin: 'https://app.example.com',
}, ctx)).toMatchObject({ url: 'https://cdn.example.com/cdn-cgi/image/w=200/https://app.example.com/admin/images/test.png' })
})

it('cloudflare cross-zone warns when origin cannot be determined', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const ctx = {
options: {
...getEmptyContext().options,
nuxt: { baseURL: '/' },
},
} as any

const origOrigin = window.location.origin
Object.defineProperty(window, 'location', { value: { origin: 'null' }, writable: true })

cloudflare().getImage('/images/test.png', {
modifiers: { width: 200 },
baseURL: 'https://cdn.example.com',
}, ctx)

expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('[nuxt-image] Cloudflare cross-zone'),
)

Object.defineProperty(window, 'location', { value: { origin: origOrigin }, writable: true })
warnSpy.mockRestore()
})

it('cloudflare cross-zone handles multi-value x-forwarded-proto', () => {
const ctx = {
options: {
...getEmptyContext().options,
nuxt: { baseURL: '/' },
event: {
headers: new Headers({
'host': 'app.example.com',
'x-forwarded-proto': 'https, http',
}),
},
},
} as any

expect(cloudflare().getImage('/images/test.png', {
modifiers: { width: 200 },
baseURL: 'https://cdn.example.com',
}, ctx)).toMatchObject({ url: 'https://cdn.example.com/cdn-cgi/image/w=200/https://app.example.com/images/test.png' })
})

it('cloudflare cross-zone appOrigin overrides headers', () => {
const ctx = {
options: {
...getEmptyContext().options,
nuxt: { baseURL: '/' },
event: {
headers: new Headers({
'host': 'injected.attacker.com',
'x-forwarded-proto': 'https',
}),
},
},
} as any

expect(cloudflare().getImage('/images/test.png', {
modifiers: { width: 200 },
baseURL: 'https://cdn.example.com',
appOrigin: 'https://app.example.com',
}, ctx)).toMatchObject({ url: 'https://cdn.example.com/cdn-cgi/image/w=200/https://app.example.com/images/test.png' })
})

it('cloudinary', () => {
const providerOptions = {
baseURL: '/',
Expand Down
Loading