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

feat(next): throw when twitter-image or opengraph-image file size exceeds #70353

Merged
merged 13 commits into from
Oct 7, 2024
Merged
24 changes: 24 additions & 0 deletions crates/next-core/src/next_app/metadata/route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,15 @@ async fn static_route_source(

let original_file_content_b64 = get_base64_file_content(path).await?;

let is_twitter = stem == "twitter-image";
let is_open_graph = stem == "opengraph-image";
// Twitter image file size limit is 5MB.
// General Open Graph image file size limit is 8MB.
// x-ref: https://developer.x.com/en/docs/x-for-websites/cards/overview/summary
// x-ref(facebook): https://developers.facebook.com/docs/sharing/webmasters/images
let file_size_limit = if is_twitter { 5 } else { 8 };
let img_name = if is_twitter { "Twitter" } else { "Open Graph" };

let code = formatdoc! {
r#"
import {{ NextResponse }} from 'next/server'
Expand All @@ -156,6 +165,16 @@ async fn static_route_source(
const cacheControl = {cache_control}
const buffer = Buffer.from({original_file_content_b64}, 'base64')

if ({is_twitter} || {is_open_graph}) {{
const fileSizeInMB = buffer.byteLength / 1024 / 1024
if (fileSizeInMB > {file_size_limit}) {{
throw new Error('File size for {img_name} image "{path}" exceeds {file_size_limit}MB. ' +
`(Current: ${{fileSizeInMB.toFixed(2)}}MB)\n` +
'Read more: https://nextjs.org/docs/app/api-reference/file-conventions/metadata/opengraph-image#image-files-jpg-png-gif'
)
}}
}}

export function GET() {{
return new NextResponse(buffer, {{
headers: {{
Expand All @@ -170,6 +189,11 @@ async fn static_route_source(
content_type = StringifyJs(&content_type),
cache_control = StringifyJs(cache_control),
original_file_content_b64 = StringifyJs(&original_file_content_b64),
is_twitter = is_twitter,
is_open_graph = is_open_graph,
file_size_limit = file_size_limit,
img_name = img_name,
path = path.to_string().await?,
};

let file = File::from(code);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ Next.js will evaluate the file and automatically add the appropriate tags to you
| [`opengraph-image.alt`](#opengraph-imagealttxt) | `.txt` |
| [`twitter-image.alt`](#twitter-imagealttxt) | `.txt` |

> **Good to know**:
>
> The `twitter-image` file size must not exceed [5MB](https://developer.x.com/en/docs/x-for-websites/cards/overview/summary), and the `opengraph-image` file size must not exceed [8MB](https://developers.facebook.com/docs/sharing/webmasters/images). If the image file size exceeds these limits, the build will fail.

### `opengraph-image`

Add an `opengraph-image.(jpg|jpeg|png|gif)` image file to any route segment.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,16 @@ async function getStaticAssetRouteCode(
: process.env.NODE_ENV !== 'production'
? cacheHeader.none
: cacheHeader.longCache

const isTwitter = fileBaseName === 'twitter-image'
const isOpenGraph = fileBaseName === 'opengraph-image'
// Twitter image file size limit is 5MB.
// General Open Graph image file size limit is 8MB.
// x-ref: https://developer.x.com/en/docs/x-for-websites/cards/overview/summary
// x-ref(facebook): https://developers.facebook.com/docs/sharing/webmasters/images
const fileSizeLimit = isTwitter ? 5 : 8
const imgName = isTwitter ? 'Twitter' : 'Open Graph'

const code = `\
/* static asset route */
import { NextResponse } from 'next/server'
Expand All @@ -92,6 +102,16 @@ const buffer = Buffer.from(${JSON.stringify(
)}, 'base64'
)

if (${isTwitter || isOpenGraph}) {
const fileSizeInMB = buffer.byteLength / 1024 / 1024
if (fileSizeInMB > ${fileSizeLimit}) {
throw new Error('File size for ${imgName} image "${resourcePath}" exceeds ${fileSizeLimit}MB. ' +
\`(Current: \${fileSizeInMB.toFixed(2)}MB)\n\` +
'Read more: https://nextjs.org/docs/app/api-reference/file-conventions/metadata/opengraph-image#image-files-jpg-png-gif'
)
}
}

export function GET() {
return new NextResponse(buffer, {
headers: {
Expand Down
71 changes: 71 additions & 0 deletions test/production/app-dir/metadata-img-too-large/generate-image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import zlib from 'zlib'

const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])

function createChunk(type, data) {
const length = Buffer.alloc(4)
length.writeUInt32BE(data.length, 0)
const crc = Buffer.alloc(4)
const crcValue = calculateCRC(Buffer.concat([Buffer.from(type), data])) >>> 0 // Ensure unsigned 32-bit integer
crc.writeUInt32BE(crcValue, 0)
return Buffer.concat([length, Buffer.from(type), data, crc])
}

function calculateCRC(data) {
let crc = 0xffffffff
for (const b of data) {
crc ^= b
for (let i = 0; i < 8; i++) {
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0)
}
}
return crc ^ 0xffffffff
}

export function generatePNG(targetSizeMB) {
const targetSizeBytes = targetSizeMB * 1024 * 1024

let width = 2048,
height = 1024
let pngFile: Buffer

do {
const ihdrData = Buffer.alloc(13)
ihdrData.writeUInt32BE(width, 0)
ihdrData.writeUInt32BE(height, 4)
ihdrData.writeUInt8(8, 8) // bitDepth
ihdrData.writeUInt8(6, 9) // colorType
ihdrData.writeUInt8(0, 10) // compressionMethod
ihdrData.writeUInt8(0, 11) // filterMethod
ihdrData.writeUInt8(0, 12) // interlaceMethod

const ihdrChunk = createChunk('IHDR', ihdrData)

const rowSize = width * 4 + 1
const imageData = Buffer.alloc(rowSize * height)

for (let y = 0; y < height; y++) {
imageData[y * rowSize] = 0
for (let x = 0; x < width; x++) {
const idx = y * rowSize + 1 + x * 4
imageData[idx] = (Math.random() * 256) | 0
imageData[idx + 1] = (Math.random() * 256) | 0
imageData[idx + 2] = (Math.random() * 256) | 0
imageData[idx + 3] = 255
}
}

const compressedImageData = zlib.deflateSync(imageData)
const idatChunk = createChunk('IDAT', compressedImageData)
const iendChunk = createChunk('IEND', Buffer.alloc(0))

pngFile = Buffer.concat([PNG_SIGNATURE, ihdrChunk, idatChunk, iendChunk])

if (pngFile.length < targetSizeBytes) {
width *= 2
height *= 2
}
} while (pngFile.length < targetSizeBytes)

return pngFile
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ReactNode } from 'react'
export default function Root({ children }: { children: ReactNode }) {
return (
<html>
<body>{children}</body>
</html>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <p>hello world</p>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { nextTestSetup } from 'e2e-utils'
import { generatePNG } from '../generate-image'

describe('app-dir - metadata-img-too-large opengraph-image', () => {
const { next } = nextTestSetup({
files: __dirname,
skipStart: true,
})

const pngFile = generatePNG(8)

it('should throw when opengraph-image file size exceeds 8MB', async () => {
await next.patchFile('app/opengraph-image.png', pngFile as any)

await next.build()
const { cliOutput } = next
expect(cliOutput).toMatch(
/Error: File size for Open Graph image ".*\/app\/opengraph-image\.png" exceeds 8MB/
)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {}

module.exports = nextConfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ReactNode } from 'react'
export default function Root({ children }: { children: ReactNode }) {
return (
<html>
<body>{children}</body>
</html>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <p>hello world</p>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { nextTestSetup } from 'e2e-utils'
import { generatePNG } from '../generate-image'

describe('metadata-img-too-large twitter-image', () => {
const { next } = nextTestSetup({
files: __dirname,
skipStart: true,
})

const pngFile = generatePNG(6)

it('should throw when twitter-image file size exceeds 5MB', async () => {
await next.patchFile('app/twitter-image.png', pngFile as any)

await next.build()
const { cliOutput } = next
expect(cliOutput).toMatch(
/Error: File size for Twitter image ".*\/app\/twitter-image\.png" exceeds 5MB/
)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {}

module.exports = nextConfig
Loading