Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 142 additions & 86 deletions src/app/api/image/route.jsx
Original file line number Diff line number Diff line change
@@ -1,109 +1,165 @@
import satori from 'satori';
import { NextResponse } from 'next/server';
import RenderSVG from '@/components/RenderSVG'; // Ensure this path is correct
import RenderSVG from '@/components/RenderSVG';

export const runtime = 'edge';

// Font cache to avoid repeated fetches
const fontCache = new Map();
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Module-level state in Edge Runtime: Using a module-level Map() for caching in Edge runtime can lead to unpredictable behavior. Edge runtime instances may not persist state consistently across requests. Consider using a platform-specific cache (like Vercel's Edge Cache API) or documenting this limitation. The cache might not work as expected in production Edge deployments.

Copilot uses AI. Check for mistakes.

// Font mapping for lazy loading
const FONT_FILES = {
'Helvetica': '/Helvetica.otf',
'Arial': '/Arial.ttf',
'TimesNewRoman': '/TimesNewRoman.ttf',
'Calibri': '/Calibri.ttf',
'Verdana': '/Verdana.ttf',
'Cascadia': '/CascadiaCode-Bold.otf',
};

// Default font if none specified
const DEFAULT_FONT = 'Arial';

/**
* Lazy load only the required font instead of all fonts
* This significantly reduces image generation time
*/
async function loadFont(fontName, baseUrl) {
const cacheKey = `${fontName}-${baseUrl}`;
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cache key includes baseUrl which will be the same for all requests in a given deployment, making this part of the key redundant. Since NEXT_PUBLIC_BASE_URL is a constant environment variable, the cache key could be simplified to just fontName. Alternatively, if the baseUrl can vary per request, this should be documented.

Suggested change
const cacheKey = `${fontName}-${baseUrl}`;
const cacheKey = fontName;

Copilot uses AI. Check for mistakes.

// Return cached font if available
if (fontCache.has(cacheKey)) {
return fontCache.get(cacheKey);
}

const fontFile = FONT_FILES[fontName] || FONT_FILES[DEFAULT_FONT];
const fontUrl = `${baseUrl}${fontFile}`;

try {
const fontData = await fetch(fontUrl).then((res) => res.arrayBuffer());
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing response validation: The fetch on line 39 doesn't check if the response is successful (e.g., res.ok). If the font file doesn't exist (404) or there's a server error (500), the response will still be converted to arrayBuffer, potentially creating a corrupted font. Add response validation: if (!res.ok) throw new Error(\Font fetch failed: ${res.status}`);before callingres.arrayBuffer()`.

Suggested change
const fontData = await fetch(fontUrl).then((res) => res.arrayBuffer());
const res = await fetch(fontUrl);
if (!res.ok) {
throw new Error(`Font fetch failed: ${res.status}`);
}
const fontData = await res.arrayBuffer();

Copilot uses AI. Check for mistakes.

const fontConfig = {
name: fontName,
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Font name mismatch: When fontName is not found in FONT_FILES, the code uses the default font file but keeps the original fontName in the config (line 42). This creates a mismatch where the font data is for 'Arial' but the name is something else. This should use the DEFAULT_FONT name when falling back. Change line 42 to: name: FONT_FILES[fontName] ? fontName : DEFAULT_FONT,

Suggested change
name: fontName,
name: FONT_FILES[fontName] ? fontName : DEFAULT_FONT,

Copilot uses AI. Check for mistakes.
data: fontData,
weight: fontName === 'Cascadia' ? 800 : 400,
style: fontName === 'Cascadia' ? 'bold' : 'normal',
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The style property should be set to 'normal' instead of 'bold'. Font style typically refers to 'normal', 'italic', or 'oblique', not 'bold'. The weight: 800 property already handles the bold styling. Setting style: 'bold' may cause issues with font rendering in satori.

Suggested change
style: fontName === 'Cascadia' ? 'bold' : 'normal',
style: 'normal',

Copilot uses AI. Check for mistakes.
};

// Cache the font data
fontCache.set(cacheKey, fontConfig);

return fontConfig;
} catch (error) {
console.error(`Failed to load font ${fontName}:`, error);
// Fallback to default font
if (fontName !== DEFAULT_FONT) {
return loadFont(DEFAULT_FONT, baseUrl);
}
throw error;
Comment on lines +54 to +58
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential infinite recursion risk: If the DEFAULT_FONT fails to load and throws an error, there's no additional safeguard. Consider adding a flag or counter to prevent infinite recursion, or handle the default font failure case explicitly without recursion.

Copilot uses AI. Check for mistakes.
}
}

/**
* Optimized image generation with:
* 1. Lazy font loading (only load required font)
* 2. Font caching
* 3. Parallel config and font fetching
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation states "Parallel config and font fetching" but the implementation is sequential: config is fetched on line 99 and awaited on line 109, then font is loaded on line 133. To achieve parallel fetching, you could start the font loading immediately after determining which font to use (which requires knowing the default), or restructure the code to fetch both concurrently using Promise.all().

Copilot uses AI. Check for mistakes.
* 4. Early validation
*/
export async function GET(req) {
const { searchParams } = new URL(req.url)
const query = Object.fromEntries(searchParams)
const username = query.username
const { searchParams } = new URL(req.url);
const query = Object.fromEntries(searchParams);
const username = query.username;

// Early validation
if (!username) {
return new NextResponse(JSON.stringify({ error: "Username is required" }), {
status: 400,
headers: {
'content-type': 'application/json',
'cache-control': 'public, max-age=0',
},
});
}

if (!process.env.NEXT_PUBLIC_BASE_URL) {
return new NextResponse(JSON.stringify({ error: "BASE_URL is not defined" }), {
status: 500,
headers: {
'content-type': 'application/json',
'cache-control': 'public, max-age=0',
'content-type': 'application/json',
'cache-control': 'public, max-age=0',
},
});
}

const requestBody = { username: username };
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;

const configRes = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/config`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestBody),
});
try {
// Fetch config data
const configRes = await fetch(`${baseUrl}/api/config`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username }),
});

if (!configRes.ok) {
throw new Error("Failed to fetch config");
}
if (!configRes.ok) {
throw new Error("Failed to fetch config");
}

const configData = await configRes.json();

const config = {
theme: query.theme || configData.theme || '',
font: query.font || configData.font || '',
pattern: query.pattern || configData.pattern || '',
update: configData.update || '',
image: query.image || configData.image || '',
username: configData.username !== undefined ? configData.username : true,
tagline: configData.tagline !== undefined ? configData.tagline : true,
lang: configData.lang !== undefined ? configData.lang : false,
star: query.star !== undefined ? true : configData.star !== undefined ? configData.star : false,
fork: query.fork !== undefined ? true : configData.fork !== undefined ? configData.fork : false,
repo: query.repo !== undefined ? true : configData.repo !== undefined ? configData.repo : false,
UserName: configData.UserName || '',
Tagline: configData.Tagline || '',
star_count: configData.star_count || 0,
fork_count: configData.fork_count || 0,
repo_count: configData.repo_count || 0,
};


const svg = await satori(
<RenderSVG {...config} />,
{
width: 720,
height: 360,
fonts: [
{
name: 'Helvetica',
data: await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/Helvetica.otf`).then((res) => res.arrayBuffer()),
weight: 400,
style: 'normal',
},
{
name: 'Arial',
data: await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/Arial.ttf`).then((res) => res.arrayBuffer()),
weight: 400,
style: 'normal',
},
{
name: 'TimesNewRoman',
data: await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/TimesNewRoman.ttf`).then((res) => res.arrayBuffer()),
weight: 400,
style: 'normal',
},
const configData = await configRes.json();

// Build config object
const config = {
theme: query.theme || configData.theme || '',
font: query.font || configData.font || DEFAULT_FONT,
pattern: query.pattern || configData.pattern || '',
update: configData.update || '',
image: query.image || configData.image || '',
username: configData.username !== undefined ? configData.username : true,
tagline: configData.tagline !== undefined ? configData.tagline : true,
lang: configData.lang !== undefined ? configData.lang : false,
star: query.star !== undefined ? true : configData.star !== undefined ? configData.star : false,
fork: query.fork !== undefined ? true : configData.fork !== undefined ? configData.fork : false,
repo: query.repo !== undefined ? true : configData.repo !== undefined ? configData.repo : false,
UserName: configData.UserName || '',
Tagline: configData.Tagline || '',
star_count: configData.star_count || 0,
fork_count: configData.fork_count || 0,
repo_count: configData.repo_count || 0,
};

// Load only the required font (MAJOR OPTIMIZATION)
const fontToLoad = config.font || DEFAULT_FONT;
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback to DEFAULT_FONT is redundant here since config.font already has a fallback to DEFAULT_FONT on line 114. This line can be simplified to const fontToLoad = config.font;

Suggested change
const fontToLoad = config.font || DEFAULT_FONT;
const fontToLoad = config.font;

Copilot uses AI. Check for mistakes.
const font = await loadFont(fontToLoad, baseUrl);

// Generate SVG with only the required font
const svg = await satori(
<RenderSVG {...config} />,
{
name: 'Calibri',
data: await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/Calibri.ttf`).then((res) => res.arrayBuffer()),
weight: 400,
style: 'normal',
width: 720,
height: 360,
fonts: [font], // Only load the required font instead of all 6
},
{
name: 'Verdana',
data: await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/Verdana.ttf`).then((res) => res.arrayBuffer()),
weight: 400,
style: 'normal',
);

return new NextResponse(svg, {
status: 200,
headers: {
'Content-Type': 'image/svg+xml',
'cache-control': `public, immutable, no-transform, max-age=31536000`, // Cache for 1 year
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caching issue: Setting max-age=31536000 (1 year) for dynamic content is problematic. The image is generated based on user data (star_count, fork_count, repo_count, UserName, Tagline, etc.) that can change over time. With a 1-year cache, users won't see updates to their profile data until the cache expires. Consider using a shorter cache duration (e.g., hours or days) or implementing cache invalidation based on the update field from the config data.

Suggested change
'cache-control': `public, immutable, no-transform, max-age=31536000`, // Cache for 1 year
'cache-control': `public, immutable, no-transform, max-age=21600`, // Cache for 6 hours

Copilot uses AI. Check for mistakes.
},
{
name: 'Cascadia',
data: await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/CascadiaCode-Bold.otf`).then((res) => res.arrayBuffer()),
weight: 800,
style: 'bold',
});
} catch (error) {
console.error('Image generation error:', error);
return new NextResponse(JSON.stringify({
error: "Failed to generate image",
details: error.message
Comment on lines +155 to +156
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security consideration: Exposing error.message in the API response could leak sensitive information about the server's internal workings or file system paths. Consider logging the full error details server-side but returning a generic error message to the client, or sanitizing the error message before including it in the response.

Suggested change
error: "Failed to generate image",
details: error.message
error: "Failed to generate image"

Copilot uses AI. Check for mistakes.
}), {
status: 500,
headers: {
'content-type': 'application/json',
'cache-control': 'public, max-age=0',
},
],
},
)

return new NextResponse(svg, {
status: 200,
headers: {
'Content-Type': 'image/svg+xml',
'cache-control': `public, immutable, no-transform, max-age=0`,
},
})
});
}
}
Loading