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(''),
}));
-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(
+ '',
+ );
+ 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 `
+`;
+}
+
+// ── 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 (