diff --git a/src/app/api/og/[username]/route.test.ts b/src/app/api/og/[username]/route.test.ts index 76ea55a..e47ceb2 100644 --- a/src/app/api/og/[username]/route.test.ts +++ b/src/app/api/og/[username]/route.test.ts @@ -33,11 +33,11 @@ describe("OG Image Route", () => { }); it("should generate image for valid username", async () => { - const mockFetch = vi.spyOn(global, "fetch").mockResolvedValue( - new Response(JSON.stringify({ name: "Valid User" }), { status: 200 }) + const mockFetch = vi.spyOn(global, "fetch").mockImplementation(() => + Promise.resolve(new Response(JSON.stringify({ name: "Valid User" }), { status: 200 })) ); - const req = new NextRequest("http://localhost/api/og/validuser"); + const req = new NextRequest("http://localhost/api/og/validuser", { headers: { "x-forwarded-for": "test-ip-valid" } }); const res = await GET(req, { params: Promise.resolve({ username: "validuser" }) }); expect(res.status).toBe(200); @@ -47,13 +47,14 @@ describe("OG Image Route", () => { }); it("should return 429 and Retry-After header when rate limit is exceeded", async () => { - const mockFetch = vi.spyOn(global, "fetch").mockResolvedValue( - new Response(JSON.stringify({ name: "Valid User" }), { status: 200 }) + const mockFetch = vi.spyOn(global, "fetch").mockImplementation(() => + Promise.resolve(new Response(JSON.stringify({ name: "Valid User" }), { status: 200 })) ); // Generate more than 50 requests to hit the rate limit (limit is 50 per minute) + // We must use a unique IP to not be affected by the rate limit of previous tests. const req = new NextRequest("http://localhost/api/og/validuser", { - headers: { "x-forwarded-for": "test-ip" } + headers: { "x-forwarded-for": "test-ip-rate-limit" } }); // Send 50 successful requests diff --git a/src/app/api/og/[username]/route.tsx b/src/app/api/og/[username]/route.tsx index bfc09e8..58dfd8e 100644 --- a/src/app/api/og/[username]/route.tsx +++ b/src/app/api/og/[username]/route.tsx @@ -11,30 +11,7 @@ const ONE_HOUR_IN_SECONDS = 60 * 60; const ONE_DAY_IN_SECONDS = 24 * ONE_HOUR_IN_SECONDS; const OG_CACHE_CONTROL = `public, max-age=${ONE_HOUR_IN_SECONDS}, s-maxage=${ONE_DAY_IN_SECONDS}, stale-while-revalidate=${ONE_DAY_IN_SECONDS}`; -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ username: string }> } -) { - const { username } = await params; - - const forwarded = request.headers.get("x-forwarded-for"); - const ip = forwarded ? forwarded.split(",").at(-1)?.trim() ?? "unknown" : "unknown"; - const rateLimitResult = rateLimiter.check(ip); - - if (!rateLimitResult.success) { - const retryAfterSec = Math.ceil((rateLimitResult.reset - Date.now()) / 1000); - return new Response("Rate limit exceeded", { - status: 429, - headers: { "Retry-After": String(retryAfterSec > 0 ? retryAfterSec : 0) }, - }); - } - - if (!isValidGitHubUsername(username)) { - return new Response("Invalid username", { status: 400 }); - } - - - // Fetch minimal profile data for the OG image +async function fetchGitHubProfile(username: string) { let name = username; let bio = ""; let avatarUrl = ""; @@ -59,122 +36,168 @@ export async function GET( } } catch (error) { logger.error(`Failed to fetch GitHub profile for OG image: ${username}`, error); - // fallback to defaults } - return new ImageResponse( - ( + return { name, bio, avatarUrl, followers, publicRepos }; +} + +function OgImageTemplate({ + username, + name, + bio, + avatarUrl, + followers, + publicRepos, +}: { + username: string; + name: string; + bio: string; + avatarUrl: string; + followers: number; + publicRepos: number; +}) { + return ( +