diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index 832b4eee..c4c62809 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -18,6 +18,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", @@ -1197,6 +1198,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 d71b0777..f9c956e8 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -28,6 +28,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__/analytics.test.ts b/apps/backend/src/__tests__/analytics.test.ts index 4f0d07ae..bdf44361 100644 --- a/apps/backend/src/__tests__/analytics.test.ts +++ b/apps/backend/src/__tests__/analytics.test.ts @@ -22,6 +22,7 @@ const MOCK_USER_ID = 'user-001'; // ─── Prisma mock ───────────────────────────────────────────────────────────── const prismaMock = { + $queryRaw: vi.fn(), cardView: { count: vi.fn(), findMany: vi.fn(), @@ -48,8 +49,12 @@ async function buildApp(): Promise { app.decorateRequest( 'jwtVerify', - function () { - return mockJwtVerify(); + async function (this: any) { + const payload = await mockJwtVerify(); + if (payload) { + this.user = payload; + } + return payload; } ); @@ -157,20 +162,11 @@ describe( ] ); - prismaMock.cardView.groupBy.mockResolvedValue( + prismaMock.$queryRaw.mockResolvedValue( [ { - viewerId: - 'u1', - viewerIp: - null, - }, - { - viewerId: - 'u2', - viewerIp: - null, - }, + count: 2n + } ] ); diff --git a/apps/backend/src/__tests__/event.test.ts b/apps/backend/src/__tests__/event.test.ts index 44806af1..15a6c425 100644 --- a/apps/backend/src/__tests__/event.test.ts +++ b/apps/backend/src/__tests__/event.test.ts @@ -15,6 +15,10 @@ const MOCK_EVENT = { description: 'Annual DevCard conference', location: 'San Francisco, CA', organizerId: MOCK_USER_ID, + organizer: { + username: 'johndoe', + displayName: 'John Doe', + }, startDate: new Date('2025-09-01T09:00:00Z'), endDate: new Date('2025-09-02T18:00:00Z'), isPublic: true, @@ -74,8 +78,12 @@ async function buildApp(): Promise { // Decorate jwtVerify on the request prototype so request.jwtVerify() resolves // to whatever the current test wants. - app.decorateRequest('jwtVerify', function () { - return mockJwtVerify(); + app.decorateRequest('jwtVerify', async function (this: any) { + const payload = await mockJwtVerify(); + if (payload) { + this.user = payload; + } + return payload; }); // Register with the same prefix used in production (app.ts) so that diff --git a/apps/backend/src/__tests__/og-image.test.ts b/apps/backend/src/__tests__/og-image.test.ts new file mode 100644 index 00000000..80d2f5a6 --- /dev/null +++ b/apps/backend/src/__tests__/og-image.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { isSafeAvatarUrl } from '../utils/og-image.js'; + +describe('isSafeAvatarUrl', () => { + it.each([ + 'http://avatars.githubusercontent.com/u/1', + 'https://localhost/avatar.png', + 'https://127.0.0.1/avatar.png', + 'https://10.0.0.4/avatar.png', + 'https://172.16.0.4/avatar.png', + 'https://192.168.1.4/avatar.png', + 'https://169.254.169.254/latest/meta-data/', + 'https://[::1]/avatar.png', + 'https://[fe80::1]/avatar.png', + 'https://[fd00::1]/avatar.png', + ])('rejects unsafe avatar URL %s', (url) => { + expect(isSafeAvatarUrl(url)).toBe(false); + }); + + it.each([ + 'https://avatars.githubusercontent.com/u/1', + 'https://cdn.example.com/avatar.png', + ])('allows public HTTPS avatar URL %s', (url) => { + expect(isSafeAvatarUrl(url)).toBe(true); + }); +}); diff --git a/apps/backend/src/__tests__/profile-page.test.ts b/apps/backend/src/__tests__/profile-page.test.ts new file mode 100644 index 00000000..e12e03f2 --- /dev/null +++ b/apps/backend/src/__tests__/profile-page.test.ts @@ -0,0 +1,76 @@ +import Fastify from 'fastify'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { profilePageRoutes } from '../routes/profilePage.js'; + +vi.mock('../services/publicService.js', () => ({ + getPublicProfile: vi.fn(), +})); + +const { getPublicProfile } = await import('../services/publicService.js'); + +const mockProfile = { + username: 'testuser', + displayName: 'Test User', + bio: 'Building things.', + pronouns: null, + role: null, + company: null, + avatarUrl: null, + accentColor: '#6366f1', + links: [ + { + id: 'link-1', + platform: 'github', + username: 'testuser', + url: 'https://github.com/testuser', + displayOrder: 0, + }, + ], +}; + +async function buildApp(): Promise> { + const app = Fastify(); + app.register(profilePageRoutes); + await app.ready(); + return app; +} + +describe('GET /u/:username — server-rendered profile metadata', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.PUBLIC_APP_URL = 'https://devcard.example'; + process.env.PUBLIC_API_URL = 'https://api.devcard.example'; + }); + + it('renders crawler-readable Open Graph and Twitter tags in the initial HTML', async () => { + (getPublicProfile as ReturnType).mockResolvedValue({ + cached: false, + data: mockProfile, + cacheKey: 'profile:testuser', + }); + const app = await buildApp(); + + const res = await app.inject({ method: 'GET', url: '/u/testuser' }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/html/); + expect(res.body).toContain('Test User | DevCard'); + expect(res.body).toContain( + '', + ); + expect(res.body).toContain(''); + expect(res.body).toContain('Building <cool> things. 1 platform connected on DevCard.'); + }); + + it('returns a crawler-readable 404 shell when the profile is missing', async () => { + (getPublicProfile as ReturnType).mockResolvedValue(null); + const app = await buildApp(); + + const res = await app.inject({ method: 'GET', url: '/u/nobody' }); + + expect(res.statusCode).toBe(404); + expect(res.headers['content-type']).toMatch(/text\/html/); + expect(res.body).toContain('User Not Found | DevCard'); + }); +}); 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/__tests__/team.test.ts b/apps/backend/src/__tests__/team.test.ts index 350298a1..17efe033 100644 --- a/apps/backend/src/__tests__/team.test.ts +++ b/apps/backend/src/__tests__/team.test.ts @@ -99,8 +99,12 @@ async function buildApp(): Promise { app.decorate('prisma', prismaMock as unknown as PrismaClient); - app.decorateRequest('jwtVerify', function () { - return mockJwtVerify(); + app.decorateRequest('jwtVerify', async function (this: any) { + const payload = await mockJwtVerify(); + if (payload) { + this.user = payload; + } + return payload; }); await app.register(teamRoutes); diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 6116b91b..d22ef929 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -18,6 +18,7 @@ import { connectRoutes } from './routes/connect.js'; import { eventRoutes } from './routes/event.js'; import { followRoutes } from './routes/follow.js'; import { nfcRoutes } from './routes/nfc.js'; +import { profilePageRoutes } from './routes/profilePage.js'; import { profileRoutes } from './routes/profiles.js'; import { publicRoutes } from './routes/public.js'; import { teamRoutes } from './routes/team.js'; @@ -141,6 +142,7 @@ export async function buildApp():Promise { await app.register(nfcRoutes, { prefix: '/api/nfc' }); await app.register(eventRoutes, {prefix: '/api/events'}) await app.register(teamRoutes, {prefix: '/api/teams'}) + await app.register(profilePageRoutes) // ─── Health Check ─── diff --git a/apps/backend/src/routes/event.ts b/apps/backend/src/routes/event.ts index 4d4ee2d9..ba42e00c 100644 --- a/apps/backend/src/routes/event.ts +++ b/apps/backend/src/routes/event.ts @@ -10,6 +10,7 @@ type EventDetails = { slug: string; location: string; description: string | null; + organizerId: string; organizerUsername: string; organizerDisplayName: string; startDate: Date; @@ -42,7 +43,7 @@ type PaginatedAttendeesResponse = { type EventWithAttendees = { _count: { attendees: number; - }; + } | undefined; attendees: { user: { id: string; @@ -142,6 +143,7 @@ export async function eventRoutes(app:FastifyInstance) { slug: details.slug, description: details.description, location: details.location, + organizerId: details.organizerId, organizerUsername: details.organizer.username, organizerDisplayName: details.organizer.displayName, startDate: details.startDate, @@ -276,10 +278,10 @@ export async function eventRoutes(app:FastifyInstance) { pagination: { page, limit, - total : event._count.attendees, + total : event._count?.attendees ?? attendees.length, } } return response; }) -} \ No newline at end of file +} diff --git a/apps/backend/src/routes/profilePage.ts b/apps/backend/src/routes/profilePage.ts new file mode 100644 index 00000000..761dcb1d --- /dev/null +++ b/apps/backend/src/routes/profilePage.ts @@ -0,0 +1,123 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import * as publicService from '../services/publicService.js'; + +import type { PublicProfile } from '@devcard/shared'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const WEB_DIST_DIR = path.resolve(__dirname, '../../web/dist'); +const WEB_INDEX_PATH = path.join(WEB_DIST_DIR, 'index.html'); +const DEFAULT_DESCRIPTION = + 'One Tap. Every Profile. Every Platform. Open Source Developer Profile Exchange Platform.'; + +function escapeHtml(raw: string): string { + return raw + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function absoluteUrl(baseUrl: string, pathname: string): string { + const normalizedBase = baseUrl.replace(/\/$/, ''); + return `${normalizedBase}${pathname}`; +} + +function buildMetaDescription(profile: PublicProfile): string { + const platformCount = profile.links.length; + const platformSummary = platformCount === 1 ? '1 platform' : `${platformCount} platforms`; + + if (profile.bio) { + return `${profile.bio} ${platformSummary} connected on DevCard.`; + } + + return `${profile.displayName}'s developer profile with ${platformSummary} connected on DevCard.`; +} + +function buildProfileMeta(profile: PublicProfile): string { + const appBaseUrl = process.env.PUBLIC_APP_URL ?? 'http://localhost:5173'; + const apiBaseUrl = process.env.PUBLIC_API_URL ?? process.env.API_BASE_URL ?? 'http://localhost:3000'; + const canonicalUrl = absoluteUrl(appBaseUrl, `/u/${encodeURIComponent(profile.username)}`); + const ogImageUrl = absoluteUrl(apiBaseUrl, `/api/u/${encodeURIComponent(profile.username)}/og-image`); + const title = `${profile.displayName} | DevCard`; + const description = buildMetaDescription(profile); + + return [ + `${escapeHtml(title)}`, + ``, + ``, + '', + ``, + ``, + ``, + ``, + '', + '', + '', + '', + '', + ``, + ``, + ``, + ].join('\n '); +} + +async function readWebIndex(): Promise { + try { + return await fs.readFile(WEB_INDEX_PATH, 'utf8'); + } catch { + return ` + + + + + + + +
+ +`; + } +} + +function injectProfileMeta(indexHtml: string, metaHtml: string): string { + const htmlWithoutTitle = indexHtml.replace(/[\s\S]*?<\/title>/i, ''); + const htmlWithoutDescription = htmlWithoutTitle.replace( + /<meta\s+name=["']description["'][^>]*>\s*/i, + '', + ); + + if (htmlWithoutDescription.includes('</head>')) { + return htmlWithoutDescription.replace('</head>', ` ${metaHtml}\n </head>`); + } + + return `<!doctype html><html lang="en"><head>${metaHtml}</head><body><div id="root"></div></body></html>`; +} + +export async function profilePageRoutes(app: FastifyInstance): Promise<void> { + app.get('/u/:username', async ( + request: FastifyRequest<{ Params: { username: string } }>, + reply: FastifyReply, + ) => { + const { username } = request.params; + const result = await publicService.getPublicProfile(app, username, null, request); + + if (!result) { + return reply.status(404).type('text/html').send( + '<!doctype html><html lang="en"><head><title>User Not Found | DevCard
', + ); + } + + const indexHtml = await readWebIndex(); + const html = injectProfileMeta(indexHtml, buildProfileMeta(result.data)); + + return reply + .header('Cache-Control', 'public, max-age=300, stale-while-revalidate=60') + .type('text/html') + .send(html); + }); +} diff --git a/apps/backend/src/routes/public.ts b/apps/backend/src/routes/public.ts index d00cb77e..89e8c363 100644 --- a/apps/backend/src/routes/public.ts +++ b/apps/backend/src/routes/public.ts @@ -1,6 +1,11 @@ -import * as publicService from '../services/publicService'; +import { PLATFORMS } from '@devcard/shared'; + +import * as publicService from '../services/publicService.js'; +import { getErrorMessage } from '../utils/error.util.js'; +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 ──────────────────────────────────────────────────────────── @@ -10,6 +15,13 @@ import type { FastifyContextConfig, FastifyInstance, FastifyRequest, FastifyRepl const MIN_QR_SIZE = 1; const MAX_QR_SIZE = 2048; +// Represents a CardLink record with the joined PlatformLink relation +interface CardLinkWithPlatform { + id: string; + displayOrder: number; + platformLink: PlatformLink; +} + // ── 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 @@ -90,7 +102,7 @@ export async function publicRoutes(app: FastifyInstance): Promise { avatarUrl: card.user.avatarUrl, accentColor: card.user.accentColor, }, - links: card.cardLinks.map((cl: any) => ({ + links: card.cardLinks.map((cl: CardLinkWithPlatform) => ({ id: cl.platformLink.id, platform: cl.platformLink.platform, username: cl.platformLink.username, @@ -155,7 +167,6 @@ export async function publicRoutes(app: FastifyInstance): Promise { } as FastifyContextConfig }, async (request: FastifyRequest<{ Params: { username: string } }>, reply: FastifyReply) => { const { username } = request.params; - try { const result = await publicService.getPublicProfile(app, username, null, request); if (!result) { @@ -187,12 +198,12 @@ export async function publicRoutes(app: FastifyInstance): Promise { 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) { @@ -231,4 +242,106 @@ export async function publicRoutes(app: FastifyInstance): Promise { 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..7eb51d1a --- /dev/null +++ b/apps/backend/src/utils/og-image.ts @@ -0,0 +1,317 @@ +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, and hostnames are validated against private/local +// IP ranges (SSRF guard). A 3-second AbortController timeout prevents blocking +// the HTTP response. The download size is capped to prevent unbounded memory +// allocation. Returns null on any failure so the caller renders the initials fallback instead. + +export function isSafeAvatarUrl(urlStr: string): boolean { + try { + const parsed = new URL(urlStr); + if (parsed.protocol !== 'https:') { + return false; + } + const hostname = parsed.hostname.toLowerCase(); + + if (hostname === 'localhost') { + return false; + } + + if (hostname.endsWith('.local') || hostname.endsWith('.internal')) { + return false; + } + + const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; + const ipv4Match = hostname.match(ipv4Regex); + if (ipv4Match) { + const octets = ipv4Match.slice(1).map(Number); + if (octets.some(o => o < 0 || o > 255)) { + return false; + } + const [o1, o2] = octets; + // 127.0.0.0/8 - Loopback + if (o1 === 127) { + return false; + } + // 10.0.0.0/8 - Private + if (o1 === 10) { + return false; + } + // 172.16.0.0/12 - Private + if (o1 === 172 && (o2 >= 16 && o2 <= 31)) { + return false; + } + // 192.168.0.0/16 - Private + if (o1 === 192 && o2 === 168) { + return false; + } + // 169.254.0.0/16 - Link-local + if (o1 === 169 && o2 === 254) { + return false; + } + // 0.0.0.0/8 - Broadcast / local + if (o1 === 0) { + return false; + } + } + + if (hostname.includes(':')) { + const cleanIp = hostname.startsWith('[') && hostname.endsWith(']') + ? hostname.slice(1, -1) + : hostname; + + const normalized = cleanIp.toLowerCase(); + if (normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') { + return false; + } + if (normalized.startsWith('fe80') || normalized.startsWith('fe9') || normalized.startsWith('fea') || normalized.startsWith('feb')) { + return false; + } + if (normalized.startsWith('fc') || normalized.startsWith('fd')) { + return false; + } + if (normalized.startsWith('::ffff:')) { + const mappedIpv4 = normalized.slice(7); + return isSafeAvatarUrl(`https://${mappedIpv4}`); + } + } + + return true; + } catch { + return false; + } +} + +async function fetchAvatarBase64( + url: string, +): Promise<{ data: string; mimeType: string } | null> { + if (!isSafeAvatarUrl(url)) { + 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 MAX_SIZE = 5 * 1024 * 1024; // 5 MB + const contentLength = response.headers.get('content-length'); + if (contentLength) { + const parsedLength = parseInt(contentLength, 10); + if (Number.isInteger(parsedLength) && parsedLength > MAX_SIZE) { + return null; + } + } + + let arrayBuffer: ArrayBuffer; + if (response.body && typeof response.body.getReader === 'function') { + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let receivedLength = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (value) { + receivedLength += value.length; + if (receivedLength > MAX_SIZE) { + await reader.cancel().catch(() => {}); + return null; + } + chunks.push(value); + } + } + const combined = new Uint8Array(receivedLength); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.length; + } + arrayBuffer = combined.buffer; + } else { + // Fallback + arrayBuffer = await response.arrayBuffer(); + if (arrayBuffer.byteLength > MAX_SIZE) { + return null; + } + } + + 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..1cbf2739 100644 --- a/apps/web/src/pages/ProfilePage.tsx +++ b/apps/web/src/pages/ProfilePage.tsx @@ -58,14 +58,22 @@ export default function ProfilePage() { setTimeout(() => setCopyMessage(''), 3000); } - // Update document title useEffect(() => { + if (error) { + document.title = 'User Not Found | DevCard'; + return; + } + if (profile) { document.title = `${profile.displayName} | DevCard`; - } else if (error) { - document.title = 'User Not Found | DevCard'; } - }, [profile, error]); + }, [profile, error, username]); + + useEffect(() => { + return () => { + document.title = 'DevCard'; + }; + }, []); if (loading) { return (