diff --git a/apps/backend/src/routes/public.ts b/apps/backend/src/routes/public.ts index d00cb77e..4333b9cd 100644 --- a/apps/backend/src/routes/public.ts +++ b/apps/backend/src/routes/public.ts @@ -1,19 +1,13 @@ -import * as publicService from '../services/publicService'; +import * as publicService from '../services/publicService.js'; import { generateQRBuffer, generateQRSvg } from '../utils/qr.js'; import type { FastifyContextConfig, FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; // ── QR size bounds ──────────────────────────────────────────────────────────── -// Enforced before any DB query or image allocation. Values outside this range -// are rejected with 400 so a single unauthenticated request cannot trigger an -// unbounded memory allocation in the QR rasteriser. 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 CACHE_CONTROL_HEADER = 'public, max-age=300, stale-while-revalidate=60'; export async function publicRoutes(app: FastifyInstance): Promise { @@ -32,21 +26,23 @@ export async function publicRoutes(app: FastifyInstance): Promise { }, async (request: FastifyRequest<{ Params: { username: string } }>, reply: FastifyReply) => { const { username } = request.params; - // Try to extract viewer from Authorization header (soft auth). + // Soft auth: extract viewer id if token present. + // authenticatedUserId is used to detect self-views; viewerId is only set + // for other authenticated users so the service knows who is viewing. let viewerId: string | null = null; + let authenticatedUserId: string | null = null; try { if (request.headers.authorization) { const decoded = (await request.jwtVerify()) as { id?: string }; - viewerId = decoded?.id ?? null; - } else { - viewerId = null; + authenticatedUserId = decoded?.id ?? null; + viewerId = authenticatedUserId; } } catch { - // ignored + // ignored — treat as unauthenticated } try { - const result = await publicService.getPublicProfile(app, username, viewerId, request); + const result = await publicService.getPublicProfile(app, username, viewerId, request, authenticatedUserId); if (!result) { return reply.status(404).send({ error: 'User not found' }); } @@ -121,17 +117,19 @@ export async function publicRoutes(app: FastifyInstance): Promise { const { username, cardId } = request.params; let viewerId: string | null = null; + let authenticatedUserId: string | null = null; try { if (request.headers.authorization) { const decoded = (await request.jwtVerify()) as { id?: string }; - viewerId = decoded?.id ?? null; + authenticatedUserId = decoded?.id ?? null; + viewerId = authenticatedUserId; } } catch { // ignored } try { - const result = await publicService.getUserCard(app, username, cardId, viewerId, request); + const result = await publicService.getUserCard(app, username, cardId, viewerId, request, authenticatedUserId); if (result.notFound) { return reply.status(404).send({ error: 'User or card not found' }); } @@ -143,9 +141,6 @@ export async function publicRoutes(app: FastifyInstance): Promise { }); // ─── QR Session ────────────────────────────────────────────────────────── - // Returns a short-lived signed JWT encoding the public profile snapshot. - // Intended for native apps to generate QR codes that remain scannable when - // the device has no live network connectivity (offline QR mode, spec §5.9). app.get('/:username/qr-session', { config: { rateLimit: { @@ -157,7 +152,7 @@ export async function publicRoutes(app: FastifyInstance): Promise { const { username } = request.params; try { - const result = await publicService.getPublicProfile(app, username, null, request); + const result = await publicService.getPublicProfile(app, username, null, request, null); if (!result) { return reply.status(404).send({ error: 'User not found' }); } @@ -178,7 +173,7 @@ export async function publicRoutes(app: FastifyInstance): Promise { app.get('/:username/qr', { config: { rateLimit: { - max: 50, // Lower limit for QR generation as it's more resource intensive + max: 50, timeWindow: '1 minute' } } as FastifyContextConfig @@ -189,9 +184,6 @@ export async function publicRoutes(app: FastifyInstance): Promise { const { username } = request.params; const format = (request.query as any).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 size = rawSize !== undefined ? parseInt(rawSize, 10) : 400; @@ -201,7 +193,6 @@ export async function publicRoutes(app: FastifyInstance): Promise { }); } - // Verify user exists const user = await app.prisma.user.findUnique({ where: { username }, }); @@ -231,4 +222,4 @@ export async function publicRoutes(app: FastifyInstance): Promise { return reply.status(500).send({ error: 'QR code generation failed' }); } }); -} +} \ No newline at end of file diff --git a/apps/backend/src/services/publicService.ts b/apps/backend/src/services/publicService.ts index 758ab78f..734686bb 100644 --- a/apps/backend/src/services/publicService.ts +++ b/apps/backend/src/services/publicService.ts @@ -1,10 +1,16 @@ -import type { FastifyInstance } from 'fastify' import { getErrorMessage } from '../utils/error.util.js' +import type { FastifyInstance } from 'fastify' + const PROFILE_CACHE_TTL = 300 -const CACHE_CONTROL_HEADER = 'public, max-age=300, stale-while-revalidate=60' -export async function getPublicProfile(app: FastifyInstance, username: string, viewerId: string | null, request: any) { +export async function getPublicProfile( + app: FastifyInstance, + username: string, + viewerId: string | null, + request: any, + authenticatedUserId: string | null = null, +): Promise<{ cached: boolean; data: object; cacheKey: string } | null> { const cacheKey = `profile:${username}` if (app.redis) { @@ -12,7 +18,9 @@ export async function getPublicProfile(app: FastifyInstance, username: string, v const cached = await app.redis.get(cacheKey) if (cached) { const { _userId, ...profileData } = JSON.parse(cached) - if (viewerId && viewerId !== _userId) { + // Only record a view if the viewer is not the owner + const isSelfView = authenticatedUserId !== null && authenticatedUserId === _userId + if (viewerId && !isSelfView) { app.prisma.cardView.create({ data: { ownerId: _userId, cardId: null, viewerId, viewerIp: request.ip || null, viewerAgent: request.headers['user-agent'] || null, source: request.query?.source || 'link' } }).catch((err: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(err)}`)) } return { cached: true, data: profileData, cacheKey } @@ -23,9 +31,11 @@ export async function getPublicProfile(app: FastifyInstance, username: string, v } const user = await app.prisma.user.findUnique({ where: { username }, include: { platformLinks: { orderBy: { displayOrder: 'asc' } } } }) - if (!user) return null + if (!user) { return null } - if (viewerId && viewerId !== user.id) { + // Block self-views: don't record a cardView if the authenticated user is the owner + const isSelfView = authenticatedUserId !== null && authenticatedUserId === user.id + if (viewerId && !isSelfView) { app.prisma.cardView.create({ data: { ownerId: user.id, cardId: null, viewerId, viewerIp: request.ip || null, viewerAgent: request.headers['user-agent'] || null, source: request.query?.source || 'link' } }).catch((error: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(error)}`)) } @@ -47,21 +57,30 @@ export async function getPublicProfile(app: FastifyInstance, username: string, v return { cached: false, data: response, cacheKey } } -export async function getCardById(app: FastifyInstance, cardId: string) { +export async function getCardById(app: FastifyInstance, cardId: string): Promise { const card = await app.prisma.card.findUnique({ where: { id: cardId }, include: { user: true, cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } } }) return card } -export async function getUserCard(app: FastifyInstance, username: string, cardId: string, viewerId: string | null, request: any) { +export async function getUserCard( + app: FastifyInstance, + username: string, + cardId: string, + viewerId: string | null, + request: any, + authenticatedUserId: string | null = null, +): Promise<{ notFound: boolean; data?: object }> { const user = await app.prisma.user.findUnique({ where: { username } }) - if (!user) return { notFound: true } + if (!user) { return { notFound: true } } const card = await app.prisma.card.findFirst({ where: { id: cardId, userId: user.id }, include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } } }) - if (!card) return { notFound: true } + if (!card) { return { notFound: true } } - if (viewerId && viewerId !== user.id) { + // Block self-views: don't record a cardView if the authenticated user is the owner + const isSelfView = authenticatedUserId !== null && authenticatedUserId === user.id + if (viewerId && !isSelfView) { app.prisma.cardView.create({ data: { ownerId: user.id, cardId: card.id, viewerId, viewerIp: request.ip || null, viewerAgent: request.headers['user-agent'] || null, source: request.query?.source || 'qr' } }).catch((error: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(error)}`)) } const response = { title: card.title, owner: { username: user.username, displayName: user.displayName, bio: user.bio, pronouns: user.pronouns, role: user.role, company: user.company, avatarUrl: user.avatarUrl, accentColor: user.accentColor }, links: card.cardLinks.map((cl: any) => ({ id: cl.platformLink.id, platform: cl.platformLink.platform, username: cl.platformLink.username, url: cl.platformLink.url, displayOrder: cl.displayOrder })) } return { notFound: false, data: response } -} +} \ No newline at end of file