Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update handling for relative files in image-optimizer #17998

Merged
merged 14 commits into from Oct 19, 2020
113 changes: 85 additions & 28 deletions packages/next/next-server/server/image-optimizer.ts
@@ -1,4 +1,4 @@
import { UrlWithParsedQuery } from 'url'
import nodeUrl, { UrlWithParsedQuery } from 'url'
import { IncomingMessage, ServerResponse } from 'http'
import { join } from 'path'
import { mediaType } from '@hapi/accept'
Expand All @@ -9,6 +9,7 @@ import { getContentType, getExtension } from './serve-static'
import { fileExists } from '../../lib/file-exists'
// @ts-ignore no types for is-animated
import isAnimated from 'next/dist/compiled/is-animated'
import Stream from 'stream'

let sharp: typeof import('sharp')
//const AVIF = 'image/avif'
Expand All @@ -30,9 +31,8 @@ export async function imageOptimizer(
const { sizes = [], domains = [] } = nextConfig?.images || {}
const { headers } = req
const { url, w, q } = parsedUrl.query
const proto = headers['x-forwarded-proto'] || 'http'
const host = headers['x-forwarded-host'] || headers.host
const mimeType = mediaType(headers.accept, MIME_TYPES) || ''
let href: string

if (!url) {
res.statusCode = 400
Expand All @@ -44,30 +44,35 @@ export async function imageOptimizer(
return { finished: true }
}

let absoluteUrl: URL
try {
absoluteUrl = new URL(url)
} catch (_error) {
// url was not absolute so assuming relative url
let isAbsolute: boolean

if (url.startsWith('/')) {
ijjk marked this conversation as resolved.
Show resolved Hide resolved
href = url
isAbsolute = false
} else {
let hrefParsed: URL

try {
absoluteUrl = new URL(url, `${proto}://${host}`)
} catch (__error) {
hrefParsed = new URL(url)
isAbsolute = true
href = hrefParsed.toString()
ijjk marked this conversation as resolved.
Show resolved Hide resolved
} catch (_error) {
res.statusCode = 400
res.end('"url" parameter is invalid')
return { finished: true }
}
}

if (!['http:', 'https:'].includes(absoluteUrl.protocol)) {
res.statusCode = 400
res.end('"url" parameter is invalid')
return { finished: true }
}
if (!['http:', 'https:'].includes(hrefParsed.protocol)) {
res.statusCode = 400
res.end('"url" parameter is invalid')
return { finished: true }
}

if (!server.renderOpts.dev && !domains.includes(absoluteUrl.hostname)) {
res.statusCode = 400
res.end('"url" parameter is not allowed')
return { finished: true }
if (!domains.includes(hrefParsed.hostname)) {
res.statusCode = 400
res.end('"url" parameter is not allowed')
return { finished: true }
}
}

if (!w) {
Expand Down Expand Up @@ -112,7 +117,6 @@ export async function imageOptimizer(
return { finished: true }
}

const { href } = absoluteUrl
const hash = getHash([CACHE_VERSION, href, width, quality, mimeType])
const imagesDir = join(distDir, 'cache', 'images')
const hashDir = join(imagesDir, hash)
Expand All @@ -136,17 +140,70 @@ export async function imageOptimizer(
}
}

const upstreamRes = await fetch(href)
let upstreamBuffer: Buffer
let upstreamType: string | null
let maxAge: number

if (isAbsolute) {
const upstreamRes = await fetch(href)
ijjk marked this conversation as resolved.
Show resolved Hide resolved

if (!upstreamRes.ok) {
res.statusCode = upstreamRes.status
res.end('"url" parameter is valid but upstream response is invalid')
return { finished: true }
}

if (!upstreamRes.ok) {
res.statusCode = upstreamRes.status
res.end('"url" parameter is valid but upstream response is invalid')
return { finished: true }
upstreamBuffer = Buffer.from(await upstreamRes.arrayBuffer())
upstreamType = upstreamRes.headers.get('Content-Type')
maxAge = getMaxAge(upstreamRes.headers.get('Cache-Control'))
} else {
try {
const _req: any = {
headers: req.headers,
method: req.method,
url: href,
}
const resBuffers: Buffer[] = []
const mockRes: any = new Stream.Writable()

mockRes.write = (chunk: Buffer | string) => {
resBuffers.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}
mockRes._write = (chunk: Buffer | string) => {
mockRes.write(chunk)
}

const mockHeaders: Record<string, string | string[]> = {}

mockRes.writeHead = (_status: any, _headers: any) =>
Object.assign(mockHeaders, _headers)
mockRes.getHeader = (name: string) => mockHeaders[name.toLowerCase()]
mockRes.getHeaders = () => mockHeaders
mockRes.getHeaderNames = () => Object.keys(mockHeaders)
mockRes.setHeader = (name: string, value: string | string[]) =>
(mockHeaders[name.toLowerCase()] = value)
mockRes._implicitHeader = () => {}
ijjk marked this conversation as resolved.
Show resolved Hide resolved
mockRes.finished = false
mockRes.statusCode = 200

await server.getRequestHandler()(
_req,
mockRes,
nodeUrl.parse(href!, true)
ijjk marked this conversation as resolved.
Show resolved Hide resolved
)
res.statusCode = mockRes.statusCode

upstreamBuffer = Buffer.concat(resBuffers)
upstreamType = mockRes.getHeader('Content-Type')
maxAge = getMaxAge(mockRes.getHeader('Cache-Control'))
} catch (err) {
res.statusCode = 500
res.end('"url" parameter is valid but upstream response is invalid')
return { finished: true }
}
}

const upstreamBuffer = Buffer.from(await upstreamRes.arrayBuffer())
const upstreamType = upstreamRes.headers.get('Content-Type')
const maxAge = getMaxAge(upstreamRes.headers.get('Cache-Control'))
const expireAt = maxAge * 1000 + now
let contentType: string

Expand Down
90 changes: 55 additions & 35 deletions test/integration/image-optimizer/test/index.test.js
Expand Up @@ -35,7 +35,7 @@ async function fsToJson(dir, output = {}) {
return output
}

function runTests({ w, isDev }) {
function runTests({ w, isDev, domains }) {
it('should return home page', async () => {
const res = await fetchViaHTTP(appPort, '/', null, {})
expect(await res.text()).toMatch(/Image Optimizer Home/m)
Expand Down Expand Up @@ -131,16 +131,14 @@ function runTests({ w, isDev }) {
)
})

if (!isDev) {
it('should fail when domain is not defined in next.config.js', async () => {
const url = `http://vercel.com/button`
const query = { url, w, q: 100 }
const opts = { headers: { accept: 'image/webp' } }
const res = await fetchViaHTTP(appPort, '/_next/image', query, opts)
expect(res.status).toBe(400)
expect(await res.text()).toBe(`"url" parameter is not allowed`)
})
}
it('should fail when domain is not defined in next.config.js', async () => {
const url = `http://vercel.com/button`
const query = { url, w, q: 100 }
const opts = { headers: { accept: 'image/webp' } }
const res = await fetchViaHTTP(appPort, '/_next/image', query, opts)
expect(res.status).toBe(400)
expect(await res.text()).toBe(`"url" parameter is not allowed`)
})

it('should fail when width is not in next.config.js', async () => {
const query = { url: '/test.png', w: 1000, q: 100 }
Expand Down Expand Up @@ -216,14 +214,16 @@ function runTests({ w, isDev }) {
expect(res.headers.get('Content-Type')).toBe('image/webp')
})

it('should resize absolute url from localhost', async () => {
const url = `http://localhost:${appPort}/test.png`
const query = { url, w, q: 80 }
const opts = { headers: { accept: 'image/webp' } }
const res = await fetchViaHTTP(appPort, '/_next/image', query, opts)
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('image/webp')
})
if (domains.includes('localhost')) {
it('should resize absolute url from localhost', async () => {
const url = `http://localhost:${appPort}/test.png`
const query = { url, w, q: 80 }
const opts = { headers: { accept: 'image/webp' } }
const res = await fetchViaHTTP(appPort, '/_next/image', query, opts)
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('image/webp')
})
}

it('should fail when url has file protocol', async () => {
const url = `file://localhost:${appPort}/test.png`
Expand All @@ -243,15 +243,17 @@ function runTests({ w, isDev }) {
expect(await res.text()).toBe(`"url" parameter is invalid`)
})

it('should fail when url fails to load an image', async () => {
const url = `http://localhost:${appPort}/not-an-image`
const query = { w, url, q: 100 }
const res = await fetchViaHTTP(appPort, '/_next/image', query, {})
expect(res.status).toBe(404)
expect(await res.text()).toBe(
`"url" parameter is valid but upstream response is invalid`
)
})
if (domains.includes('localhost')) {
it('should fail when url fails to load an image', async () => {
const url = `http://localhost:${appPort}/not-an-image`
const query = { w, url, q: 100 }
const res = await fetchViaHTTP(appPort, '/_next/image', query, {})
expect(res.status).toBe(404)
expect(await res.text()).toBe(
`"url" parameter is valid but upstream response is invalid`
)
})
}

it('should use cached image file when parameters are the same', async () => {
await fs.remove(imagesDir)
Expand Down Expand Up @@ -288,6 +290,9 @@ function runTests({ w, isDev }) {
}

describe('Image Optimizer', () => {
// domains for testing
const domains = ['localhost', 'example.com']

describe('dev support w/o next.config.js', () => {
const size = 768 // defaults defined in server/config.ts
beforeAll(async () => {
Expand All @@ -299,7 +304,7 @@ describe('Image Optimizer', () => {
await fs.remove(imagesDir)
})

runTests({ w: size, isDev: true })
runTests({ w: size, isDev: true, domains: [] })
})

describe('dev support with next.config.js', () => {
Expand All @@ -308,7 +313,7 @@ describe('Image Optimizer', () => {
const json = JSON.stringify({
images: {
sizes: [size],
domains: ['localhost', 'example.com'],
domains,
},
})
nextConfig.replace('{ /* replaceme */ }', json)
Expand All @@ -321,7 +326,22 @@ describe('Image Optimizer', () => {
await fs.remove(imagesDir)
})

runTests({ w: size, isDev: true })
runTests({ w: size, isDev: true, domains })
})

describe('Server support w/o next.config.js', () => {
const size = 768 // defaults defined in server/config.ts
beforeAll(async () => {
await nextBuild(appDir)
appPort = await findPort()
app = await nextStart(appDir, appPort)
})
afterAll(async () => {
await killApp(app)
await fs.remove(imagesDir)
})

runTests({ w: size, isDev: false, domains: [] })
})

describe('Server support with next.config.js', () => {
Expand All @@ -330,7 +350,7 @@ describe('Image Optimizer', () => {
const json = JSON.stringify({
images: {
sizes: [128],
domains: ['localhost', 'example.com'],
domains,
},
})
nextConfig.replace('{ /* replaceme */ }', json)
Expand All @@ -344,7 +364,7 @@ describe('Image Optimizer', () => {
await fs.remove(imagesDir)
})

runTests({ w: size, isDev: false })
runTests({ w: size, isDev: false, domains })
})

describe('Serverless support with next.config.js', () => {
Expand All @@ -354,7 +374,7 @@ describe('Image Optimizer', () => {
target: 'experimental-serverless-trace',
images: {
sizes: [size],
domains: ['localhost', 'example.com'],
domains,
},
})
nextConfig.replace('{ /* replaceme */ }', json)
Expand All @@ -368,6 +388,6 @@ describe('Image Optimizer', () => {
await fs.remove(imagesDir)
})

runTests({ w: size, isDev: false })
runTests({ w: size, isDev: false, domains })
})
})