Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 16 additions & 25 deletions apps/backend/src/routes/public.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
Expand All @@ -32,21 +26,23 @@ export async function publicRoutes(app: FastifyInstance): Promise<void> {
}, 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' });
}
Expand Down Expand Up @@ -121,17 +117,19 @@ export async function publicRoutes(app: FastifyInstance): Promise<void> {
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' });
}
Expand All @@ -143,9 +141,6 @@ export async function publicRoutes(app: FastifyInstance): Promise<void> {
});

// ─── 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: {
Expand All @@ -157,7 +152,7 @@ export async function publicRoutes(app: FastifyInstance): Promise<void> {
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' });
}
Expand All @@ -178,7 +173,7 @@ export async function publicRoutes(app: FastifyInstance): Promise<void> {
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
Expand All @@ -189,9 +184,6 @@ export async function publicRoutes(app: FastifyInstance): Promise<void> {
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;

Expand All @@ -201,7 +193,6 @@ export async function publicRoutes(app: FastifyInstance): Promise<void> {
});
}

// Verify user exists
const user = await app.prisma.user.findUnique({
where: { username },
});
Expand Down Expand Up @@ -231,4 +222,4 @@ export async function publicRoutes(app: FastifyInstance): Promise<void> {
return reply.status(500).send({ error: 'QR code generation failed' });
}
});
}
}
43 changes: 31 additions & 12 deletions apps/backend/src/services/publicService.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
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) {
try {
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 }
Expand All @@ -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)}`))
}

Expand All @@ -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<any> {
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 }
}
}