Skip to content

Commit 6f8ac76

Browse files
committed
fix: broken slash decoding in some cases
Fixes #522
1 parent 6e7ff69 commit 6f8ac76

File tree

4 files changed

+126
-3
lines changed

4 files changed

+126
-3
lines changed

src/runtime/server/og-image/context.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { useNitroApp } from 'nitropack/runtime'
1616
import { hash } from 'ohash'
1717
import { parseURL, withoutLeadingSlash, withoutTrailingSlash, withQuery } from 'ufo'
1818
import { normalizeKey } from 'unstorage'
19-
import { decodeOgImageParams, separateProps } from '../../shared'
19+
import { decodeOgImageParams, extractEncodedSegment, separateProps } from '../../shared'
2020
import { autoEjectCommunityTemplate } from '../util/auto-eject'
2121
import { createNitroRouteRuleMatcher } from '../util/kit'
2222
import { normaliseOptions } from '../util/options'
@@ -59,7 +59,7 @@ export async function resolveContext(e: H3Event): Promise<H3Error | OgImageRende
5959
// Parse encoded params from URL path (Cloudinary-style)
6060
// URL format: /_og/d/w_1200,title_Hello.png
6161
// Hash mode: /_og/d/o_<hash>.png (for long URLs)
62-
const encodedSegment = (path.split('/').pop() as string).replace(new RegExp(`\\.${extension}$`), '')
62+
const encodedSegment = extractEncodedSegment(path, extension)
6363

6464
// Check for hash mode (o_<hash>)
6565
const hashMatch = encodedSegment.match(RE_HASH_MODE)

src/runtime/shared.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { defu } from 'defu'
44
import { toValue } from 'vue'
55

66
export { extractSocialPreviewTags, toBase64Image } from './pure'
7-
export { buildOgImageUrl, decodeOgImageParams, encodeOgImageParams, hashOgImageOptions, parseOgImageUrl } from './shared/urlEncoding'
7+
export { buildOgImageUrl, decodeOgImageParams, encodeOgImageParams, extractEncodedSegment, hashOgImageOptions, parseOgImageUrl } from './shared/urlEncoding'
88

99
const RE_KEBAB_CASE = /-([a-z])/g
1010

src/runtime/shared/urlEncoding.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const RE_PERCENT20 = /%20/g
2626
const RE_PLUS = /\+/g
2727
const RE_SINGLE_UNDERSCORE = /(?<!_)_(?!_)/
2828
const RE_OG_PATH_PREFIX = /^\/_og\/[ds]\//
29+
const RE_OG_ROUTE_PREFIX = /\/_og\/[ds]\//
2930
const RE_FILE_EXTENSION_WITH_CAPTURE = /\.(\w+)$/
3031
const RE_FILE_EXTENSION = /\.\w+$/
3132
const RE_HASH_SEGMENT = /^o_([a-z0-9]+)$/i
@@ -442,3 +443,17 @@ export function parseOgImageUrl(url: string): {
442443
isStatic,
443444
}
444445
}
446+
447+
/**
448+
* Extract the encoded segment from the full path, handling the case where
449+
* %2F in prop values has been decoded to / by intermediaries (#522).
450+
* Uses the full catch-all match after /_og/d/ (or /_og/s/) instead of
451+
* only the last path segment.
452+
*/
453+
export function extractEncodedSegment(path: string, extension: string): string {
454+
const match = path.match(RE_OG_ROUTE_PREFIX)
455+
if (match?.index != null) {
456+
return path.slice(match.index + match[0].length).replace(new RegExp(`\\.${extension}$`), '')
457+
}
458+
return (path.split('/').pop() as string).replace(new RegExp(`\\.${extension}$`), '')
459+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { decodeOgImageParams, extractEncodedSegment } from '../../src/runtime/shared/urlEncoding'
3+
4+
describe('extractEncodedSegment', () => {
5+
describe('normal encoded URLs (single segment)', () => {
6+
it('extracts from /_og/d/ path', () => {
7+
expect(extractEncodedSegment('/_og/d/w_1200,title_Hello.png', 'png'))
8+
.toBe('w_1200,title_Hello')
9+
})
10+
11+
it('extracts from /_og/s/ path', () => {
12+
expect(extractEncodedSegment('/_og/s/w_1200,title_Hello.png', 'png'))
13+
.toBe('w_1200,title_Hello')
14+
})
15+
16+
it('strips jpeg extension', () => {
17+
expect(extractEncodedSegment('/_og/d/w_1200.jpeg', 'jpeg'))
18+
.toBe('w_1200')
19+
})
20+
21+
it('handles hash mode segment', () => {
22+
expect(extractEncodedSegment('/_og/d/o_abc123.png', 'png'))
23+
.toBe('o_abc123')
24+
})
25+
26+
it('handles default segment', () => {
27+
expect(extractEncodedSegment('/_og/d/default.png', 'png'))
28+
.toBe('default')
29+
})
30+
})
31+
32+
describe('decoded %2F in prop values (#522)', () => {
33+
it('preserves full segment when coverSrc contains decoded slashes', () => {
34+
const path = '/_og/d/c_Blog,coverSrc_/img/blog/cover.jpg.png'
35+
expect(extractEncodedSegment(path, 'png'))
36+
.toBe('c_Blog,coverSrc_/img/blog/cover.jpg')
37+
})
38+
39+
it('preserves full segment with multiple decoded slashes', () => {
40+
const path = '/_og/d/c_Blog,coverSrc_/img/foo/bar/baz.jpg,title_Hello.png'
41+
expect(extractEncodedSegment(path, 'png'))
42+
.toBe('c_Blog,coverSrc_/img/foo/bar/baz.jpg,title_Hello')
43+
})
44+
45+
it('preserves full segment with single decoded slash', () => {
46+
const path = '/_og/d/c_Blog,coverSrc_/cover.jpg.png'
47+
expect(extractEncodedSegment(path, 'png'))
48+
.toBe('c_Blog,coverSrc_/cover.jpg')
49+
})
50+
51+
it('works end-to-end: decoded slashes produce same params as encoded', () => {
52+
// Original encoded URL segment (before %2F decode)
53+
const originalSegment = 'c_Blog,coverSrc_%2Fimg%2Fblog%2Fcover.jpg,title_Hello+World'
54+
const originalDecoded = decodeOgImageParams(originalSegment)
55+
56+
// After intermediary decodes %2F to /
57+
const decodedPath = '/_og/d/c_Blog,coverSrc_/img/blog/cover.jpg,title_Hello+World.png'
58+
const extractedSegment = extractEncodedSegment(decodedPath, 'png')
59+
const reDecoded = decodeOgImageParams(extractedSegment)
60+
61+
expect(reDecoded).toEqual(originalDecoded)
62+
expect(reDecoded.component).toBe('Blog')
63+
expect(reDecoded.props?.coverSrc).toBe('/img/blog/cover.jpg')
64+
expect(reDecoded.props?.title).toBe('Hello World')
65+
})
66+
67+
it('old behavior would fail: split("/").pop() loses props before decoded slash', () => {
68+
// Demonstrate what the old code would produce
69+
const path = '/_og/d/c_Blog,coverSrc_/img/blog/cover.jpg,title_Hello.png'
70+
const oldResult = (path.split('/').pop() as string).replace(/\.png$/, '')
71+
// Old: only gets the last segment after the last /
72+
expect(oldResult).toBe('cover.jpg,title_Hello')
73+
74+
// New: gets the full segment
75+
const newResult = extractEncodedSegment(path, 'png')
76+
expect(newResult).toBe('c_Blog,coverSrc_/img/blog/cover.jpg,title_Hello')
77+
78+
// Old decode loses component and coverSrc
79+
const oldDecoded = decodeOgImageParams(oldResult)
80+
expect(oldDecoded.component).toBeUndefined()
81+
82+
// New decode preserves everything
83+
const newDecoded = decodeOgImageParams(newResult)
84+
expect(newDecoded.component).toBe('Blog')
85+
expect(newDecoded.props?.coverSrc).toBe('/img/blog/cover.jpg')
86+
})
87+
})
88+
89+
describe('with base path prefix', () => {
90+
it('works when app has a base path', () => {
91+
expect(extractEncodedSegment('/prefix/_og/d/w_1200,title_Hello.png', 'png'))
92+
.toBe('w_1200,title_Hello')
93+
})
94+
95+
it('works with base path and decoded slashes', () => {
96+
const path = '/my-app/_og/d/c_Blog,coverSrc_/img/cover.jpg.png'
97+
expect(extractEncodedSegment(path, 'png'))
98+
.toBe('c_Blog,coverSrc_/img/cover.jpg')
99+
})
100+
})
101+
102+
describe('fallback behavior', () => {
103+
it('falls back to last segment when no /_og/ prefix found', () => {
104+
expect(extractEncodedSegment('/unknown/path/w_1200.png', 'png'))
105+
.toBe('w_1200')
106+
})
107+
})
108+
})

0 commit comments

Comments
 (0)