Skip to content

Commit 3a09387

Browse files
fix: resolve 404/500 error when downloading files containing hash in filename (#15799)
### What? Fixes file downloads returning 404 for filenames containing `#` when served through the REST API. ### Why? Next.js catch-all `[...slug]` params decodes URL-encoded segments before passing them for further handling. So a request for `/api/media/file/document%20%23123.pdf` arrives with slug segments `['media', 'file', 'document #123.pdf']`. These decoded segments are joined and passed to handleEndpoints, but the implementation of handleEndpoints is using `path-to-regexp` with a configuration that expectes urlencoded path segments. ### How? Re-encode each slug segment with `encodeURIComponent` before joining: handleEndpoints already uses match(endpoint.path, { decode: decodeURIComponent }), so params are decoded back to their original values after matching Also adds a test that creates a file with # in the filename and verifies it can be served via REST. Fixes #15798 --------- Co-authored-by: Paul Popus <paul@payloadcms.com>
1 parent e284ab5 commit 3a09387

3 files changed

Lines changed: 57 additions & 2 deletions

File tree

packages/next/src/routes/rest/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ const handlerBuilder =
3737
config,
3838
path: formatAdminURL({
3939
apiRoute: awaitedConfig.routes.api,
40-
path: awaitedParams ? `/${awaitedParams.slug.join('/')}` : undefined,
40+
path: awaitedParams
41+
? `/${awaitedParams.slug.map((segment) => encodeURIComponent(segment)).join('/')}`
42+
: undefined,
4143
}),
4244
request,
4345
})

test/uploads/e2e.spec.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Page } from '@playwright/test'
22

33
import { expect, test } from '@playwright/test'
4-
import { statSync } from 'fs'
4+
import { readFileSync, statSync } from 'fs'
55
import path from 'path'
66
import { wait } from 'payload/shared'
77
import { fileURLToPath } from 'url'
@@ -2240,4 +2240,34 @@ describe('Uploads', () => {
22402240

22412241
await expect(titleField).toHaveValue('Upload without file')
22422242
})
2243+
2244+
test('should upload and serve file with # and % in filename', async () => {
2245+
await page.goto(mediaURL.create)
2246+
2247+
const imageBuffer = readFileSync(path.resolve(dirname, './image.png'))
2248+
2249+
await page.setInputFiles('input[type="file"]', {
2250+
buffer: imageBuffer,
2251+
mimeType: 'image/png',
2252+
name: 'file%20#hash.png',
2253+
})
2254+
2255+
const filenameField = page.locator('.file-field__filename')
2256+
await expect(filenameField).toHaveValue('file%20#hash.png')
2257+
2258+
await saveDocAndAssert(page)
2259+
2260+
// After saving, the URL shown in the admin panel must have # and % encoded
2261+
const fileUrlLink = page.locator('.file-meta__url a')
2262+
await expect(fileUrlLink).toHaveAttribute('href')
2263+
2264+
const href = await fileUrlLink.getAttribute('href')
2265+
expect(href).toContain('%23') // # encoded
2266+
expect(href).toContain('%25') // % encoded
2267+
expect(href).not.toContain('#') // no literal #
2268+
2269+
// Navigating to the file URL must return 200
2270+
const response = await page.goto(`${serverURL}${href}`)
2271+
expect(response?.status()).toBe(200)
2272+
})
22432273
})

test/uploads/int.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,29 @@ describe('Collections - Uploads', () => {
498498
})
499499
})
500500
describe('read', () => {
501+
it('should serve files with hash characters in filename', async () => {
502+
const filePath = path.resolve(dirname, './image.png')
503+
const file = await getFileByPath(filePath)
504+
file!.name = "file #hash.png"
505+
506+
const mediaDoc = (await payload.create({
507+
collection: mediaSlug,
508+
data: {},
509+
file,
510+
}))
511+
512+
expect(mediaDoc.url).toContain('%23')
513+
expect(mediaDoc.url).not.toContain('#')
514+
515+
expect(mediaDoc.filename).toContain('#')
516+
expect(mediaDoc.filename).not.toContain('%23')
517+
518+
const response = await restClient.GET(`/${mediaSlug}/file/${mediaDoc.filename}`)
519+
520+
expect(response.status).toBe(200)
521+
expect(response.headers.get('content-type')).toContain('image/png')
522+
})
523+
501524
it('should return the media document with the correct file type', async () => {
502525
const filePath = path.resolve(dirname, './image.png')
503526
const file = await getFileByPath(filePath)

0 commit comments

Comments
 (0)