From ece9eeac33077d2ec4e03b02e818aa803c3dcb4f Mon Sep 17 00:00:00 2001 From: Amrit Date: Fri, 29 May 2026 21:28:07 +0530 Subject: [PATCH] feat: add public profile OG previews --- apps/backend/package-lock.json | 228 ++++++++++++++++++++++ apps/backend/package.json | 1 + apps/backend/src/__tests__/public.test.ts | 147 +++++++++++++- apps/backend/src/routes/public.ts | 217 +++++++++++--------- apps/backend/src/utils/og-image.ts | 191 ++++++++++++++++++ apps/web/src/pages/ProfilePage.tsx | 99 +++++++++- 6 files changed, 785 insertions(+), 98 deletions(-) create mode 100644 apps/backend/src/utils/og-image.ts diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index 64f44440..b09fc48c 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -17,6 +17,7 @@ "@fastify/rate-limit": "^10.3.0", "@fastify/static": "^8.0.0", "@prisma/client": "^6.0.0", + "@resvg/resvg-js": "^2.6.2", "dotenv": "^16.4.0", "fastify": "^5.0.0", "fastify-plugin": "^5.0.0", @@ -1196,6 +1197,233 @@ "@prisma/debug": "6.19.3" } }, + "node_modules/@resvg/resvg-js": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", + "integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==", + "license": "MPL-2.0", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@resvg/resvg-js-android-arm-eabi": "2.6.2", + "@resvg/resvg-js-android-arm64": "2.6.2", + "@resvg/resvg-js-darwin-arm64": "2.6.2", + "@resvg/resvg-js-darwin-x64": "2.6.2", + "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", + "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", + "@resvg/resvg-js-linux-arm64-musl": "2.6.2", + "@resvg/resvg-js-linux-x64-gnu": "2.6.2", + "@resvg/resvg-js-linux-x64-musl": "2.6.2", + "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", + "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", + "@resvg/resvg-js-win32-x64-msvc": "2.6.2" + } + }, + "node_modules/@resvg/resvg-js-android-arm-eabi": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz", + "integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-android-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz", + "integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz", + "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-x64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz", + "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz", + "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz", + "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz", + "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz", + "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz", + "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-arm64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz", + "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-ia32-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz", + "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==", + "cpu": [ + "ia32" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-x64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz", + "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.61.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", diff --git a/apps/backend/package.json b/apps/backend/package.json index 995ce916..d3e2d28b 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -27,6 +27,7 @@ "@fastify/rate-limit": "^10.3.0", "@fastify/static": "^8.0.0", "@prisma/client": "^6.0.0", + "@resvg/resvg-js": "^2.6.2", "dotenv": "^16.4.0", "fastify": "^5.0.0", "fastify-plugin": "^5.0.0", diff --git a/apps/backend/src/__tests__/public.test.ts b/apps/backend/src/__tests__/public.test.ts index a767b25d..c2578295 100644 --- a/apps/backend/src/__tests__/public.test.ts +++ b/apps/backend/src/__tests__/public.test.ts @@ -1,9 +1,14 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import Fastify from 'fastify'; import jwt from '@fastify/jwt'; +import Fastify from 'fastify'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + import { publicRoutes } from '../routes/public.js'; +import { generateOgImage } from '../utils/og-image.js'; +import { generateQRBuffer, generateQRSvg } from '../utils/qr.js'; + import type { PrismaClient } from '@prisma/client'; + // ── Mock QR utilities ───────────────────────────────────────────────────────── // Prevents real QR rasterisation (and any native canvas/image deps) from running // during unit tests. The stubs return minimal valid values that satisfy the @@ -13,7 +18,12 @@ vi.mock('../utils/qr.js', () => ({ generateQRSvg: vi.fn().mockResolvedValue('fake'), })); -import { generateQRBuffer, generateQRSvg } from '../utils/qr.js'; +// ── Mock OG image utility ───────────────────────────────────────────────────── +// Prevents actual Resvg/resvg-js and external avatar-fetch calls in tests. +vi.mock('../utils/og-image.js', () => ({ + generateOgImage: vi.fn().mockResolvedValue(Buffer.from('fake-og-png')), +})); + const mockUser = { id: 'user-123', @@ -50,7 +60,7 @@ const mockRedis = { del: vi.fn().mockResolvedValue(1), }; -async function buildApp() { +async function buildApp(): Promise> { const app = Fastify(); // Register JWT so app.jwt.sign() is available for the qr-session route. // @fastify/jwt also adds request.jwtVerify(), which throws when no valid @@ -464,3 +474,132 @@ describe('GET /api/public/:username/qr-session', () => { ); }); }); + +// ─── OG image endpoint ──────────────────────────────────────────────────────── + +// The minimal user shape returned by the OG image DB query (select projection). +const mockOgUser = { + displayName: 'Test User', + bio: 'Building cool things.', + avatarUrl: null, + accentColor: '#6366f1', + _count: { platformLinks: 3 }, + platformLinks: [ + { platform: 'github' }, + { platform: 'linkedin' }, + { platform: 'twitter' }, + ], +}; + +describe('GET /api/public/:username/og-image', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Restore default mock behaviours after clearAllMocks. + (generateOgImage as ReturnType).mockResolvedValue( + Buffer.from('fake-og-png'), + ); + (generateQRBuffer as ReturnType).mockResolvedValue( + Buffer.from('fake-png'), + ); + (generateQRSvg as ReturnType).mockResolvedValue( + 'fake', + ); + mockRedis.get.mockResolvedValue(null); + mockRedis.set.mockResolvedValue('OK'); + mockPrisma.cardView.create.mockReturnValue({ catch: vi.fn() }); + }); + + it('returns 200 with image/png content-type for an existing user', async () => { + mockPrisma.user.findUnique.mockResolvedValue(mockOgUser); + const app = await buildApp(); + + const res = await app.inject({ + method: 'GET', + url: '/api/public/testuser/og-image', + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toMatch(/image\/png/); + expect(res.headers['cache-control']).toBe( + 'public, max-age=86400, stale-while-revalidate=3600', + ); + }); + + it('returns 404 for an unknown username', async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + const app = await buildApp(); + + const res = await app.inject({ + method: 'GET', + url: '/api/public/nobody/og-image', + }); + + expect(res.statusCode).toBe(404); + expect(res.json().error).toBe('User not found'); + }); + + it('returns X-Cache: MISS and calls generateOgImage on a fresh request', async () => { + mockPrisma.user.findUnique.mockResolvedValue(mockOgUser); + const app = await buildApp(); + + const res = await app.inject({ + method: 'GET', + url: '/api/public/testuser/og-image', + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['x-cache']).toBe('MISS'); + expect(generateOgImage).toHaveBeenCalledOnce(); + }); + + it('writes the generated PNG to Redis with a 24 h TTL', async () => { + mockPrisma.user.findUnique.mockResolvedValue(mockOgUser); + const app = await buildApp(); + + await app.inject({ + method: 'GET', + url: '/api/public/testuser/og-image', + }); + + expect(mockRedis.set).toHaveBeenCalledWith( + 'og-image:testuser', + expect.any(String), // base64-encoded PNG + 'EX', + 86400, + ); + }); + + it('returns X-Cache: HIT and skips DB + generateOgImage when cached', async () => { + // Simulate a warm Redis cache entry (base64-encoded PNG). + const cachedPng = Buffer.from('fake-og-png').toString('base64'); + mockRedis.get.mockResolvedValue(cachedPng); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/public/testuser/og-image', + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['x-cache']).toBe('HIT'); + // DB must not be queried and the generator must not run. + expect(mockPrisma.user.findUnique).not.toHaveBeenCalled(); + expect(generateOgImage).not.toHaveBeenCalled(); + }); + + it('returns 500 when generateOgImage throws', async () => { + mockPrisma.user.findUnique.mockResolvedValue(mockOgUser); + (generateOgImage as ReturnType).mockRejectedValueOnce( + new Error('resvg render error'), + ); + const app = await buildApp(); + + const res = await app.inject({ + method: 'GET', + url: '/api/public/testuser/og-image', + }); + + expect(res.statusCode).toBe(500); + expect(res.json().error).toBe('Failed to generate OG image'); + }); +}); diff --git a/apps/backend/src/routes/public.ts b/apps/backend/src/routes/public.ts index 27f544d8..891fcb0e 100644 --- a/apps/backend/src/routes/public.ts +++ b/apps/backend/src/routes/public.ts @@ -1,9 +1,12 @@ -import type { FastifyContextConfig, FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { generateQRBuffer, generateQRSvg } from '../utils/qr.js'; -import type { PlatformLink } from '@devcard/shared'; +import { PLATFORMS } from '@devcard/shared'; + +import * as publicService from '../services/publicService.js'; import { getErrorMessage } from '../utils/error.util.js'; -import * as publicService from '../services/publicService' +import { generateOgImage } from '../utils/og-image.js'; +import { generateQRBuffer, generateQRSvg } from '../utils/qr.js'; +import type { PlatformLink } from '@devcard/shared'; +import type { FastifyContextConfig, FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; // ── QR size bounds ──────────────────────────────────────────────────────────── // Enforced before any DB query or image allocation. Values outside this range @@ -12,70 +15,8 @@ import * as publicService from '../services/publicService' const MIN_QR_SIZE = 1; const MAX_QR_SIZE = 2048; -// ── Cache constants ─────────────────────────────────────────────────────────── -// Public profile cache TTL matches the Cache-Control max-age (5 minutes). -// The QR session JWT TTL is 10 minutes so an offline scan remains valid well -// beyond the HTTP cache window. -const PROFILE_CACHE_TTL = 300; // seconds (5 minutes) const CACHE_CONTROL_HEADER = 'public, max-age=300, stale-while-revalidate=60'; -type PublicProfileLink = { - id: string; - platform: string; - username: string; - url: string; - displayOrder: number; - followed?: boolean; -} - -type UsernamePublicProfileResponse = { - username: string; - displayName: string; - bio: string | null; - pronouns: string | null; - role: string | null; - company: string | null; - avatarUrl: string | null; - accentColor: string; - links: PublicProfileLink[] -} - -type PublicProfileCardLink = { - id: string; - platform: string; - username: string; - url: string; - followed?: boolean; -} - -type CardPublicProfileResponse = { - id: string; - title: string; - owner: { - username: string; - displayName: string; - bio: string | null; - avatarUrl: string | null; - accentColor: string; - }; - links: PublicProfileCardLink[] -} - -type UsernameCardPublicProfileResponse = { - title: string; - owner: { - username: string; - displayName: string; - bio: string | null; - pronouns: string | null; - role: string | null; - company: string | null; - avatarUrl: string | null; - accentColor: string; - }; - links: PublicProfileCardLink[] -} - // Represents a CardLink record with the joined PlatformLink relation interface CardLinkWithPlatform { id: string; @@ -83,13 +24,7 @@ interface CardLinkWithPlatform { platformLink: PlatformLink; } -// ── Internal Redis cache shape ──────────────────────────────────────────────── -// Extends the public response with the owner's DB id so that background view -// tracking can still fire on cache-HIT requests without an extra DB read. -type CachedProfileEntry = UsernamePublicProfileResponse & { _userId: string }; - - -export async function publicRoutes(app: FastifyInstance) { +export async function publicRoutes(app: FastifyInstance): Promise { // ─── Public Profile ─────────────────────────────────────────────────────── // ─── Public Profile ─── /** @@ -105,8 +40,6 @@ export async function publicRoutes(app: FastifyInstance) { }, }, async (request: FastifyRequest<{ Params: { username: string } }>, reply: FastifyReply) => { const { username } = request.params; - const cacheKey = `profile:${username}`; - // Try to extract viewer from Authorization header (soft auth). let viewerId: string | null = null try { @@ -122,10 +55,12 @@ export async function publicRoutes(app: FastifyInstance) { try { const result = await publicService.getPublicProfile(app, username, viewerId, request) - if (!result) return reply.status(404).send({ error: 'User not found' }) + if (!result) { + return reply.status(404).send({ error: 'User not found' }) + } reply.header('X-Cache', result.cached ? 'HIT' : 'MISS').header('Cache-Control', CACHE_CONTROL_HEADER) return result.data - } catch (err: any) { + } catch (err: unknown) { app.log.error({ err }, 'Failed to fetch public profile') return reply.status(500).send({ error: 'Internal server error' }) } @@ -150,10 +85,12 @@ export async function publicRoutes(app: FastifyInstance) { try { const card = await publicService.getCardById(app, cardId) - if (!card) return reply.status(404).send({ error: 'Card not found' }) - const response = { id: card.id, title: card.title, owner: { username: card.user.username, displayName: card.user.displayName, bio: card.user.bio, avatarUrl: card.user.avatarUrl, accentColor: card.user.accentColor }, links: card.cardLinks.map((cl: any) => ({ id: cl.platformLink.id, platform: cl.platformLink.platform, username: cl.platformLink.username, url: cl.platformLink.url })) } + if (!card) { + return reply.status(404).send({ error: 'Card not found' }) + } + const response = { id: card.id, title: card.title, owner: { username: card.user.username, displayName: card.user.displayName, bio: card.user.bio, avatarUrl: card.user.avatarUrl, accentColor: card.user.accentColor }, links: card.cardLinks.map((cl: CardLinkWithPlatform) => ({ id: cl.platformLink.id, platform: cl.platformLink.platform, username: cl.platformLink.username, url: cl.platformLink.url })) } return response - } catch (err: any) { + } catch (err: unknown) { app.log.error({ err }, 'Failed to fetch shared card') return reply.status(500).send({ error: 'Internal server error' }) } @@ -188,9 +125,11 @@ export async function publicRoutes(app: FastifyInstance) { try { const result = await publicService.getUserCard(app, username, cardId, viewerId, request) - if (result.notFound) return reply.status(404).send({ error: 'User or card not found' }) + if (result.notFound) { + return reply.status(404).send({ error: 'User or card not found' }) + } return result.data - } catch (err: any) { + } catch (err: unknown) { app.log.error({ err }, 'Failed to fetch user card') return reply.status(500).send({ error: 'Internal server error' }) } @@ -209,18 +148,18 @@ export async function publicRoutes(app: FastifyInstance) { } as FastifyContextConfig }, async (request: FastifyRequest<{ Params: { username: string } }>, reply: FastifyReply) => { const { username } = request.params; - const cacheKey = `profile:${username}`; - try { const result = await publicService.getPublicProfile(app, username, null, request) - if (!result) return reply.status(404).send({ error: 'User not found' }) + if (!result) { + return reply.status(404).send({ error: 'User not found' }) + } const snapshot = result.data const expiresIn = 600 const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString() const token = app.jwt.sign({ profile: snapshot, sub: username }, { expiresIn: '10m' }) reply.header('Cache-Control', CACHE_CONTROL_HEADER) return { token, tokenType: 'JWT', expiresIn, expiresAt } - } catch (err: any) { + } catch (err: unknown) { app.log.error({ err }, 'Failed to create qr-session') return reply.status(500).send({ error: 'Internal server error' }) } @@ -240,12 +179,12 @@ export async function publicRoutes(app: FastifyInstance) { Querystring: { format?: string; size?: string }; }>, reply: FastifyReply) => { const { username } = request.params; - const format = (request.query as any).format || 'png'; + const format = request.query.format || 'png'; // Parse and validate size before touching the DB or allocating any buffers. // parseInt safely handles non-numeric strings (returns NaN) and ignores any // trailing fractional part, so '400.9' → 400 which is within bounds. - const rawSize = (request.query as any).size; + const rawSize = request.query.size; const size = rawSize !== undefined ? parseInt(rawSize, 10) : 400; if (!Number.isInteger(size) || size < MIN_QR_SIZE || size > MAX_QR_SIZE) { @@ -277,4 +216,106 @@ export async function publicRoutes(app: FastifyInstance) { return reply.status(500).send({ error: 'QR code generation failed' }) } }); + + // ─── OG Image ───────────────────────────────────────────────────────────── + /** + * GET /api/u/:username/og-image + * Returns a 1200×630 PNG social-preview card for the user's public profile. + * Used as the og:image / twitter:image value so that sharing a DevCard URL + * on Slack, Twitter, Discord, or WhatsApp renders a rich link preview. + * + * Cache strategy: Redis for 24 h (86400 s) keyed by `og-image:`. + * X-Cache: HIT / MISS response header signals which path was taken. + */ + app.get('/:username/og-image', { + config: { + rateLimit: { + max: 30, + timeWindow: '1 minute', + }, + } as FastifyContextConfig, + }, async ( + request: FastifyRequest<{ Params: { username: string } }>, + reply: FastifyReply, + ) => { + const { username } = request.params; + const cacheKey = `og-image:${username}`; + const ogCacheControl = 'public, max-age=86400, stale-while-revalidate=3600'; + + // ── Redis cache HIT ────────────────────────────────────────────────────── + if (app.redis) { + try { + const cached = await app.redis.get(cacheKey); + if (cached) { + const buf = Buffer.from(cached, 'base64'); + return reply + .header('Content-Type', 'image/png') + .header('Cache-Control', ogCacheControl) + .header('X-Cache', 'HIT') + .send(buf); + } + } catch (err: unknown) { + app.log.warn(`OG image cache read failed for ${cacheKey}: ${getErrorMessage(err)}`); + } + } + + try { + // ── DB lookup ────────────────────────────────────────────────────────── + const user = await app.prisma.user.findUnique({ + where: { username }, + select: { + displayName: true, + bio: true, + avatarUrl: true, + accentColor: true, + _count: { select: { platformLinks: true } }, + platformLinks: { + select: { platform: true }, + orderBy: { displayOrder: 'asc' }, + take: 4, + }, + }, + }); + + if (!user) { + return reply.status(404).send({ error: 'User not found' }); + } + + // ── PNG generation ───────────────────────────────────────────────────── + const png = await generateOgImage({ + username, + displayName: user.displayName, + bio: user.bio, + avatarUrl: user.avatarUrl, + accentColor: user.accentColor, + platforms: user.platformLinks.map((link: { platform: string }) => { + const platform = PLATFORMS[link.platform]; + return { + name: platform?.name ?? link.platform, + color: platform?.color ?? user.accentColor, + icon: platform?.icon ?? link.platform, + }; + }), + platformCount: user._count.platformLinks, + }); + + // ── Redis cache write ────────────────────────────────────────────────── + if (app.redis) { + app.redis + .set(cacheKey, png.toString('base64'), 'EX', 86400) + .catch((err: unknown) => + app.log.warn(`OG image cache write failed for ${cacheKey}: ${getErrorMessage(err)}`), + ); + } + + return reply + .header('Content-Type', 'image/png') + .header('Cache-Control', ogCacheControl) + .header('X-Cache', 'MISS') + .send(png); + } catch (err: unknown) { + app.log.error({ err, message: getErrorMessage(err) }, 'OG image generation failed'); + return reply.status(500).send({ error: 'Failed to generate OG image' }); + } + }); } diff --git a/apps/backend/src/utils/og-image.ts b/apps/backend/src/utils/og-image.ts new file mode 100644 index 00000000..94b11a59 --- /dev/null +++ b/apps/backend/src/utils/og-image.ts @@ -0,0 +1,191 @@ +import { Resvg } from '@resvg/resvg-js'; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export type OgImageOptions = { + /** Profile page username — shown in the card footer URL. */ + username: string; + displayName: string; + bio: string | null; + avatarUrl: string | null; + /** Hex accent colour from the user's profile (e.g. "#6366f1"). */ + accentColor: string; + /** Ordered platform metadata. Only the first four are rendered as badges. */ + platforms: Array<{ + name: string; + color: string; + icon: string; + }>; + /** Total number of connected platforms (may exceed platforms.length). */ + platformCount: number; +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function escapeXml(raw: string): string { + return raw + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function truncate(str: string, maxLength: number): string { + if (str.length <= maxLength) {return str;} + return `${str.slice(0, Math.max(0, maxLength - 1))}\u2026`; +} + +function sanitizeHexColor(color: string): string { + return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#6366f1'; +} + +// ── Avatar fetch ────────────────────────────────────────────────────────────── +// Only HTTPS URLs are fetched (SSRF guard). A 3-second AbortController +// timeout prevents blocking the HTTP response. Returns null on any failure +// so the caller renders the initials fallback instead. + +async function fetchAvatarBase64( + url: string, +): Promise<{ data: string; mimeType: string } | null> { + if (!url.startsWith('https://')) {return null;} + + try { + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + }, 3000); + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timer); + + if (!response.ok) {return null;} + + const rawContentType = response.headers.get('content-type') ?? 'image/jpeg'; + const mimeType = rawContentType.split(';')[0].trim(); + if (!mimeType.startsWith('image/')) {return null;} + + const arrayBuffer = await response.arrayBuffer(); + return { data: Buffer.from(arrayBuffer).toString('base64'), mimeType }; + } catch { + return null; + } +} + +// ── SVG template ────────────────────────────────────────────────────────────── + +function buildOgSvg( + opts: OgImageOptions, + avatarEmbed: { data: string; mimeType: string } | null, +): string { + const { username, displayName, bio, accentColor, platforms, platformCount } = + opts; + const safeAccent = sanitizeHexColor(accentColor); + + const safeName = escapeXml(truncate(displayName, 26)); + const safeUser = escapeXml(username); + const initial = escapeXml(displayName.charAt(0).toUpperCase()); + + const rawBio = bio ? truncate(bio, 100) : ''; + const safeBio = escapeXml(rawBio); + const hasBio = safeBio.length > 0; + + const platformLabel = escapeXml( + platformCount === 1 ? '1 platform' : `${platformCount} platforms`, + ); + + // Vertical positions shift when a bio is present. + const countY = hasBio ? 296 : 243; + const badgeY = hasBio ? 338 : 288; + + // clipPath only required when embedding an actual avatar image. + const avatarDef = avatarEmbed + ? '' + : ''; + + const avatarSection = avatarEmbed + ? ` + ` + : ` + + ${initial}`; + + // First four platform slugs rendered as pill badges. + const platformBadges = platforms + .slice(0, 4) + .map((platform, index) => { + const bx = 300 + index * 152; + const platformColor = sanitizeHexColor(platform.color); + const safeIcon = escapeXml(platform.icon.slice(0, 2).toUpperCase()); + const safePlatform = escapeXml(truncate(platform.name, 10)); + return ` + + ${safeIcon} + ${safePlatform}`; + }) + .join('\n '); + + return ` + + + ${avatarDef} + + + + + + + + + + + + + ${avatarSection} + + + ${safeName} + + + ${hasBio ? `${safeBio}` : ''} + + + ${platformLabel} connected + + + ${platformBadges} + + + + + + + DevCard + devcard.dev/${safeUser} +`; +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** + * Generates a 1200×630 PNG social-preview card for the given profile. + * + * The avatar is fetched and embedded as a base64 data URI so that resvg + * can rasterise it without network access during rendering. If the fetch + * fails (or the URL is not HTTPS), the first letter of the display name is + * used as a coloured initial instead. + */ +export async function generateOgImage(opts: OgImageOptions): Promise { + let avatarEmbed: { data: string; mimeType: string } | null = null; + if (opts.avatarUrl) { + avatarEmbed = await fetchAvatarBase64(opts.avatarUrl); + } + + const svg = buildOgSvg(opts, avatarEmbed); + + const resvg = new Resvg(svg, { + fitTo: { mode: 'width' as const, value: 1200 }, + font: { loadSystemFonts: true }, + }); + + return resvg.render().asPng(); +} diff --git a/apps/web/src/pages/ProfilePage.tsx b/apps/web/src/pages/ProfilePage.tsx index 94a84f54..e1f1abee 100644 --- a/apps/web/src/pages/ProfilePage.tsx +++ b/apps/web/src/pages/ProfilePage.tsx @@ -13,6 +13,53 @@ const platformColors: Record = { telegram: '#26A5E4', email: '#EA4335', portfolio: '#6366F1', custom: '#8B5CF6', }; +const API_BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000'; +const APP_BASE_URL = import.meta.env.VITE_APP_URL ?? window.location.origin; +const MANAGED_META_SELECTOR = 'meta[data-devcard-profile-meta], link[data-devcard-profile-meta]'; + +function buildMetaDescription(profile: PublicProfile): string { + const platformCount = profile.links.length; + const platformSummary = platformCount === 1 ? '1 platform' : `${platformCount} platforms`; + const fallback = `${profile.displayName}'s developer profile with ${platformSummary} connected on DevCard.`; + + if (!profile.bio) return fallback; + return `${profile.bio} ${platformSummary} connected on DevCard.`; +} + +function upsertMetaTag(attribute: 'name' | 'property', key: string, content: string): void { + let element = document.head.querySelector( + `meta[data-devcard-profile-meta][${attribute}="${key}"]`, + ); + + if (!element) { + element = document.createElement('meta'); + element.setAttribute(attribute, key); + document.head.appendChild(element); + } + + element.dataset.devcardProfileMeta = 'true'; + element.content = content; +} + +function upsertCanonicalLink(href: string): void { + let element = document.head.querySelector( + 'link[data-devcard-profile-meta][rel="canonical"]', + ); + + if (!element) { + element = document.createElement('link'); + element.rel = 'canonical'; + document.head.appendChild(element); + } + + element.dataset.devcardProfileMeta = 'true'; + element.href = href; +} + +function clearManagedProfileMeta(): void { + document.head.querySelectorAll(MANAGED_META_SELECTOR).forEach((element) => element.remove()); +} + export default function ProfilePage() { const { username } = useParams<{ username: string }>(); const [profile, setProfile] = useState(null); @@ -58,14 +105,54 @@ export default function ProfilePage() { setTimeout(() => setCopyMessage(''), 3000); } - // Update document title useEffect(() => { - if (profile) { - document.title = `${profile.displayName} | DevCard`; - } else if (error) { - document.title = 'User Not Found | DevCard'; + clearManagedProfileMeta(); + + if (!username) return; + + const canonicalUrl = `${APP_BASE_URL}/u/${username}`; + const ogImageUrl = `${API_BASE_URL}/api/u/${username}/og-image`; + + if (!profile) { + if (error) { + document.title = 'User Not Found | DevCard'; + upsertMetaTag('name', 'description', 'DevCard developer profile.'); + upsertCanonicalLink(canonicalUrl); + } + return; } - }, [profile, error]); + + const title = `${profile.displayName} | DevCard`; + const description = buildMetaDescription(profile); + + document.title = title; + upsertMetaTag('name', 'description', description); + upsertCanonicalLink(canonicalUrl); + + upsertMetaTag('property', 'og:type', 'profile'); + upsertMetaTag('property', 'og:url', canonicalUrl); + upsertMetaTag('property', 'og:title', title); + upsertMetaTag('property', 'og:description', description); + upsertMetaTag('property', 'og:image', ogImageUrl); + upsertMetaTag('property', 'og:image:width', '1200'); + upsertMetaTag('property', 'og:image:height', '630'); + upsertMetaTag('property', 'og:site_name', 'DevCard'); + + upsertMetaTag('name', 'twitter:card', 'summary'); + upsertMetaTag('name', 'twitter:site', '@devcard'); + upsertMetaTag('name', 'twitter:title', title); + upsertMetaTag('name', 'twitter:description', description); + upsertMetaTag('name', 'twitter:image', ogImageUrl); + + return clearManagedProfileMeta; + }, [profile, error, username]); + + useEffect(() => { + return () => { + document.title = 'DevCard'; + clearManagedProfileMeta(); + }; + }, []); if (loading) { return (