From 48408ddbd79f36a925479f0e4a6935f48ab60135 Mon Sep 17 00:00:00 2001 From: Hari Om Date: Sat, 23 May 2026 22:29:20 +0530 Subject: [PATCH 1/4] fix(public): prevent owner self-views from inflating analytics when unauthenticated --- apps/backend/src/routes/public.ts | 39 +++++++++++++++++-------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/apps/backend/src/routes/public.ts b/apps/backend/src/routes/public.ts index 737a7f0a..28f31afe 100644 --- a/apps/backend/src/routes/public.ts +++ b/apps/backend/src/routes/public.ts @@ -2,6 +2,7 @@ import type { FastifyContextConfig, FastifyInstance, FastifyRequest, FastifyRepl import { generateQRBuffer, generateQRSvg } from '../utils/qr.js'; import type { PlatformLink } from '@devcard/shared'; import { getErrorMessage } from '../utils/error.util.js'; + type PublicProfileLink = { id: string; platform: string; @@ -11,7 +12,7 @@ type PublicProfileLink = { followed?: boolean; } -type UsernamePublicProfileResponse = { +type UsernamePublicProfileResponse = { username: string; displayName: string; bio: string | null; @@ -59,7 +60,6 @@ type UsernameCardPublicProfileResponse = { links: PublicProfileCardLink[] } -// Represents a CardLink record with the joined PlatformLink relation interface CardLinkWithPlatform { id: string; displayOrder: number; @@ -94,24 +94,26 @@ export async function publicRoutes(app: FastifyInstance) { // Try to extract viewer from Authorization header (soft auth) let viewerId: string | null = null; + let isSelfView = false; try { if (request.headers.authorization) { const decoded = (await request.jwtVerify()) as { id?: string }; - viewerId = decoded?.id ?? null; - } else { - viewerId = null; // Unauthenticated viewer + if (decoded?.id === user.id) { + isSelfView = true; + } else { + viewerId = decoded?.id ?? null; + } } } catch { // Ignored if invalid token } // Don't track if the owner is viewing their own profile - if (viewerId && viewerId !== user.id) { - // Background view tracking + if (!isSelfView && viewerId !== user.id) { app.prisma.cardView.create({ data: { ownerId: user.id, - cardId: null, // this is a profile view, not a card view + cardId: null, viewerId, viewerIp: request.ip || null, viewerAgent: request.headers['user-agent'] || null, @@ -168,7 +170,6 @@ export async function publicRoutes(app: FastifyInstance) { } return response; - }); /** @@ -222,7 +223,6 @@ export async function publicRoutes(app: FastifyInstance) { } return response; - }); // ─── Public Card View ─── @@ -264,16 +264,21 @@ export async function publicRoutes(app: FastifyInstance) { } let viewerId: string | null = null; + let isSelfView = false; try { if (request.headers.authorization) { - const decoded = (await request.jwtVerify()) as { id?: string }; - viewerId = decoded?.id ?? null; + const decoded = await request.jwtVerify() as any; + if (decoded?.id === user.id) { + isSelfView = true; + } else { + viewerId = decoded.id; + } } - } catch { + } catch (e) { // Ignored if invalid token } - if (viewerId && viewerId !== user.id) { + if (!isSelfView && viewerId !== user.id) { app.prisma.cardView.create({ data: { ownerId: user.id, @@ -286,7 +291,6 @@ export async function publicRoutes(app: FastifyInstance) { }).catch((err: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(err)}`)); } - const response: UsernameCardPublicProfileResponse = { title: card.title, owner: { @@ -315,7 +319,7 @@ export async function publicRoutes(app: FastifyInstance) { 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 @@ -327,7 +331,6 @@ export async function publicRoutes(app: FastifyInstance) { const format = request.query.format || 'png'; const size = parseInt(request.query.size || '400', 10); - // Verify user exists const user = await app.prisma.user.findUnique({ where: { username }, }); @@ -352,4 +355,4 @@ export async function publicRoutes(app: FastifyInstance) { .header('Content-Disposition', `inline; filename="devcard-${username}.png"`) .send(png); }); -} +} \ No newline at end of file From 2afa258aea54473425a2650ed7367d48e7ec46e5 Mon Sep 17 00:00:00 2001 From: Hari Om Date: Fri, 29 May 2026 01:34:19 +0530 Subject: [PATCH 2/4] fix(public): revert unintended changes, keep only isSelfView fix --- apps/backend/src/routes/public.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/backend/src/routes/public.ts b/apps/backend/src/routes/public.ts index 28f31afe..ceb75ba0 100644 --- a/apps/backend/src/routes/public.ts +++ b/apps/backend/src/routes/public.ts @@ -2,7 +2,6 @@ import type { FastifyContextConfig, FastifyInstance, FastifyRequest, FastifyRepl import { generateQRBuffer, generateQRSvg } from '../utils/qr.js'; import type { PlatformLink } from '@devcard/shared'; import { getErrorMessage } from '../utils/error.util.js'; - type PublicProfileLink = { id: string; platform: string; @@ -12,7 +11,7 @@ type PublicProfileLink = { followed?: boolean; } -type UsernamePublicProfileResponse = { +type UsernamePublicProfileResponse = { username: string; displayName: string; bio: string | null; @@ -60,6 +59,7 @@ type UsernameCardPublicProfileResponse = { links: PublicProfileCardLink[] } +// Represents a CardLink record with the joined PlatformLink relation interface CardLinkWithPlatform { id: string; displayOrder: number; @@ -103,6 +103,8 @@ export async function publicRoutes(app: FastifyInstance) { } else { viewerId = decoded?.id ?? null; } + } else { + viewerId = null; // Unauthenticated viewer } } catch { // Ignored if invalid token @@ -110,10 +112,11 @@ export async function publicRoutes(app: FastifyInstance) { // Don't track if the owner is viewing their own profile if (!isSelfView && viewerId !== user.id) { + // Background view tracking app.prisma.cardView.create({ data: { ownerId: user.id, - cardId: null, + cardId: null, // this is a profile view, not a card view viewerId, viewerIp: request.ip || null, viewerAgent: request.headers['user-agent'] || null, @@ -170,6 +173,7 @@ export async function publicRoutes(app: FastifyInstance) { } return response; + }); /** @@ -223,6 +227,7 @@ export async function publicRoutes(app: FastifyInstance) { } return response; + }); // ─── Public Card View ─── @@ -267,11 +272,11 @@ export async function publicRoutes(app: FastifyInstance) { let isSelfView = false; try { if (request.headers.authorization) { - const decoded = await request.jwtVerify() as any; + const decoded = (await request.jwtVerify()) as { id?: string }; if (decoded?.id === user.id) { isSelfView = true; } else { - viewerId = decoded.id; + viewerId = decoded?.id ?? null; } } } catch (e) { @@ -291,6 +296,7 @@ export async function publicRoutes(app: FastifyInstance) { }).catch((err: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(err)}`)); } + const response: UsernameCardPublicProfileResponse = { title: card.title, owner: { @@ -319,7 +325,7 @@ export async function publicRoutes(app: FastifyInstance) { app.get('/:username/qr', { config: { rateLimit: { - max: 50, + max: 50, // Lower limit for QR generation as it's more resource intensive timeWindow: '1 minute' } } as FastifyContextConfig @@ -331,6 +337,7 @@ export async function publicRoutes(app: FastifyInstance) { const format = request.query.format || 'png'; const size = parseInt(request.query.size || '400', 10); + // Verify user exists const user = await app.prisma.user.findUnique({ where: { username }, }); @@ -355,4 +362,4 @@ export async function publicRoutes(app: FastifyInstance) { .header('Content-Disposition', `inline; filename="devcard-${username}.png"`) .send(png); }); -} \ No newline at end of file +} From cddea77ba7d6ab589497d32c53048a8f68d35c37 Mon Sep 17 00:00:00 2001 From: Hari Om Date: Thu, 18 Jun 2026 02:10:00 +0530 Subject: [PATCH 3/4] fixed --- apps/backend/src/routes/public.ts | 145 +++++++++++++++++++++--------- 1 file changed, 101 insertions(+), 44 deletions(-) diff --git a/apps/backend/src/routes/public.ts b/apps/backend/src/routes/public.ts index 4ceb0ab0..b4da3bbe 100644 --- a/apps/backend/src/routes/public.ts +++ b/apps/backend/src/routes/public.ts @@ -1,27 +1,76 @@ -import * as publicService from '../services/publicService'; +import { getErrorMessage } from '../utils/error.util.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 -// 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; +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; + 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 -// beyond the HTTP cache window. -const CACHE_CONTROL_HEADER = 'public, max-age=300, stale-while-revalidate=60'; export async function publicRoutes(app: FastifyInstance): Promise { - // ─── Public Profile ─────────────────────────────────────────────────────── - /** - * GET /api/u/:username - * Returns the public profile information for a user. - */ + // ─── Public Profile ─── app.get('/:username', { config: { rateLimit: { @@ -120,36 +169,44 @@ export async function publicRoutes(app: FastifyInstance): Promise { timeWindow: '1 minute' } } as FastifyContextConfig - }, async (request: FastifyRequest<{ Params: { cardId: string } }>, reply: FastifyReply) => { + }, async (request: FastifyRequest<{ Params: { cardId: string } }>, _reply: FastifyReply) => { const { cardId } = request.params; - 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, + const card = await app.prisma.card.findUnique({ + where: { id: cardId }, + include: { + user: true, + cardLinks: { + include: { platformLink: true }, + orderBy: { displayOrder: 'asc' }, }, - links: card.cardLinks.map((cl: any) => ({ - id: cl.platformLink.id, - platform: cl.platformLink.platform, - username: cl.platformLink.username, - url: cl.platformLink.url, - })), - }; - return response; - } catch (err: unknown) { - app.log.error({ err }, 'Failed to fetch shared card'); - return reply.status(500).send({ error: 'Internal server error' }); + }, + }); + + if (!card) { + return _reply.status(404).send({ error: 'Card not found' }); + } + + const response: CardPublicProfileResponse = { + 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; + }); // ─── Public Card View ───────────────────────────────────────────────────── @@ -179,7 +236,7 @@ export async function publicRoutes(app: FastifyInstance): Promise { viewerId = decoded?.id ?? null; } } - } catch (e) { + } catch (_e) { // Ignored if invalid token } From 7a00738ef42ebdf26fcf169260d8c80daa3d447d Mon Sep 17 00:00:00 2001 From: Hari Om Date: Thu, 18 Jun 2026 02:17:27 +0530 Subject: [PATCH 4/4] fix(public): add missing return types to publicService functions --- apps/backend/src/routes/public.ts | 245 ++++++--------------- apps/backend/src/services/publicService.ts | 43 +++- 2 files changed, 93 insertions(+), 195 deletions(-) diff --git a/apps/backend/src/routes/public.ts b/apps/backend/src/routes/public.ts index b4da3bbe..4333b9cd 100644 --- a/apps/backend/src/routes/public.ts +++ b/apps/backend/src/routes/public.ts @@ -1,76 +1,21 @@ -import { getErrorMessage } from '../utils/error.util.js'; +import * as publicService from '../services/publicService.js'; import { generateQRBuffer, generateQRSvg } from '../utils/qr.js'; -import type { PlatformLink } from '@devcard/shared'; import type { FastifyContextConfig, FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -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; - displayOrder: number; - platformLink: PlatformLink; -} +// ── QR size bounds ──────────────────────────────────────────────────────────── +const MIN_QR_SIZE = 1; +const MAX_QR_SIZE = 2048; +// ── Cache constants ─────────────────────────────────────────────────────────── +const CACHE_CONTROL_HEADER = 'public, max-age=300, stale-while-revalidate=60'; export async function publicRoutes(app: FastifyInstance): Promise { - // ─── Public Profile ─── + // ─── Public Profile ─────────────────────────────────────────────────────── + /** + * GET /api/u/:username + * Returns the public profile information for a user. + */ app.get('/:username', { config: { rateLimit: { @@ -81,69 +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 isSelfView = false; + let authenticatedUserId: string | null = null; try { if (request.headers.authorization) { const decoded = (await request.jwtVerify()) as { id?: string }; - if (decoded?.id === user.id) { - isSelfView = true; - } else { - viewerId = decoded?.id ?? null; - } - } else { - viewerId = null; + authenticatedUserId = decoded?.id ?? null; + viewerId = authenticatedUserId; } } catch { - // Ignored if invalid token - } - - // Don't track if the owner is viewing their own profile - if (!isSelfView && viewerId !== user.id) { - // Background view tracking - app.prisma.cardView.create({ - data: { - ownerId: user.id, - cardId: null, // this is a profile view, not a card view - 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)}`)); - } - - // Fetch viewer's successful follow logs for this profile's links - let followedLinkIds: string[] = []; - if (viewerId && user.platformLinks.length > 0) { - const successfulFollows = await app.prisma.followLog.findMany({ - where: { - followerId: viewerId, - status: 'success', - OR: user.platformLinks.map((link: PlatformLink) => ({ - platform: link.platform, - targetUsername: link.username, - })), - }, - select: { - platform: true, - targetUsername: true, - }, - }); - - followedLinkIds = user.platformLinks - .filter((link: PlatformLink) => - successfulFollows.some((f: { platform: string; targetUsername: string }) => - f.platform === link.platform && - f.targetUsername.toLowerCase() === link.username.toLowerCase() - ) - ) - .map((link: PlatformLink) => link.id); + // 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' }); } @@ -169,44 +68,36 @@ export async function publicRoutes(app: FastifyInstance): Promise { timeWindow: '1 minute' } } as FastifyContextConfig - }, async (request: FastifyRequest<{ Params: { cardId: string } }>, _reply: FastifyReply) => { + }, async (request: FastifyRequest<{ Params: { cardId: string } }>, reply: FastifyReply) => { const { cardId } = request.params; - const card = await app.prisma.card.findUnique({ - where: { id: cardId }, - include: { - user: true, - cardLinks: { - include: { platformLink: true }, - orderBy: { displayOrder: 'asc' }, + 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, }, - }, - }); - - if (!card) { - return _reply.status(404).send({ error: 'Card not found' }); - } - - const response: CardPublicProfileResponse = { - 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, - })), + links: card.cardLinks.map((cl: any) => ({ + id: cl.platformLink.id, + platform: cl.platformLink.platform, + username: cl.platformLink.username, + url: cl.platformLink.url, + })), + }; + return response; + } catch (err: unknown) { + app.log.error({ err }, 'Failed to fetch shared card'); + return reply.status(500).send({ error: 'Internal server error' }); } - - return response; - }); // ─── Public Card View ───────────────────────────────────────────────────── @@ -226,38 +117,30 @@ export async function publicRoutes(app: FastifyInstance): Promise { const { username, cardId } = request.params; let viewerId: string | null = null; - let isSelfView = false; + let authenticatedUserId: string | null = null; try { if (request.headers.authorization) { const decoded = (await request.jwtVerify()) as { id?: string }; - if (decoded?.id === user.id) { - isSelfView = true; - } else { - viewerId = decoded?.id ?? null; - } + authenticatedUserId = decoded?.id ?? null; + viewerId = authenticatedUserId; } - } catch (_e) { - // Ignored if invalid token + } catch { + // ignored } - if (!isSelfView && viewerId !== user.id) { - 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((err: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(err)}`)); + try { + 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' }); + } + return result.data; + } catch (err: unknown) { + app.log.error({ err }, 'Failed to fetch user card'); + return reply.status(500).send({ error: 'Internal server error' }); } }); // ─── 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: { @@ -269,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' }); } @@ -290,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 @@ -301,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; @@ -313,7 +193,6 @@ export async function publicRoutes(app: FastifyInstance): Promise { }); } - // Verify user exists const user = await app.prisma.user.findUnique({ where: { username }, }); @@ -343,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