diff --git a/src/runtime/providers/cloudflare.ts b/src/runtime/providers/cloudflare.ts index cd715c262..d79e3adc9 100644 --- a/src/runtime/providers/cloudflare.ts +++ b/src/runtime/providers/cloudflare.ts @@ -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' @@ -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({ - getImage: (src, { - modifiers, - baseURL = '/', - }) => { + getImage: (src, { modifiers, baseURL = '/', appOrigin }, ctx) => { const mergeModifiers = { ...defaultModifiers, ...modifiers } const operations = operationsGenerator(mergeModifiers as any) - // https:///cdn-cgi/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 } }, }) diff --git a/test/nuxt/providers.test.ts b/test/nuxt/providers.test.ts index 21e7c6b25..545f407d7 100644 --- a/test/nuxt/providers.test.ts +++ b/test/nuxt/providers.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { images } from '../providers' @@ -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: '/',