Skip to content

Commit ba56e95

Browse files
authored
fix(public): fix self-view tracking logic in profile and card view handlers (#294)
* fix(public): prevent owner self-views from inflating analytics when unauthenticated * fix(public): revert unintended changes, keep only isSelfView fix * fixed * fix(public): add missing return types to publicService functions --------- Signed-off-by: hariom888 <hariom880088@gmail.com> Co-authored-by: Hari Om <hariom888@users.noreply.github.com>
1 parent 150eb69 commit ba56e95

2 files changed

Lines changed: 46 additions & 36 deletions

File tree

apps/backend/src/routes/public.ts

Lines changed: 15 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,10 @@ import { generateQRBuffer, generateQRSvg } from '../utils/qr.js';
44
import type { FastifyContextConfig, FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
55

66
// ── QR size bounds ────────────────────────────────────────────────────────────
7-
// Enforced before any DB query or image allocation. Values outside this range
8-
// are rejected with 400 so a single unauthenticated request cannot trigger an
9-
// unbounded memory allocation in the QR rasteriser.
107
const MIN_QR_SIZE = 1;
118
const MAX_QR_SIZE = 2048;
129

1310
// ── Cache constants ───────────────────────────────────────────────────────────
14-
// Public profile cache TTL matches the Cache-Control max-age (5 minutes).
15-
// The QR session JWT TTL is 10 minutes so an offline scan remains valid well
16-
// beyond the HTTP cache window.
1711
const CACHE_CONTROL_HEADER = 'public, max-age=300, stale-while-revalidate=60';
1812

1913
export async function publicRoutes(app: FastifyInstance): Promise<void> {
@@ -32,21 +26,23 @@ export async function publicRoutes(app: FastifyInstance): Promise<void> {
3226
}, async (request: FastifyRequest<{ Params: { username: string } }>, reply: FastifyReply) => {
3327
const { username } = request.params;
3428

35-
// Try to extract viewer from Authorization header (soft auth).
29+
// Soft auth: extract viewer id if token present.
30+
// authenticatedUserId is used to detect self-views; viewerId is only set
31+
// for other authenticated users so the service knows who is viewing.
3632
let viewerId: string | null = null;
33+
let authenticatedUserId: string | null = null;
3734
try {
3835
if (request.headers.authorization) {
3936
const decoded = (await request.jwtVerify()) as { id?: string };
40-
viewerId = decoded?.id ?? null;
41-
} else {
42-
viewerId = null;
37+
authenticatedUserId = decoded?.id ?? null;
38+
viewerId = authenticatedUserId;
4339
}
4440
} catch {
45-
// ignored
41+
// ignored — treat as unauthenticated
4642
}
4743

4844
try {
49-
const result = await publicService.getPublicProfile(app, username, viewerId, request);
45+
const result = await publicService.getPublicProfile(app, username, viewerId, request, authenticatedUserId);
5046
if (!result) {
5147
return reply.status(404).send({ error: 'User not found' });
5248
}
@@ -121,17 +117,19 @@ export async function publicRoutes(app: FastifyInstance): Promise<void> {
121117
const { username, cardId } = request.params;
122118

123119
let viewerId: string | null = null;
120+
let authenticatedUserId: string | null = null;
124121
try {
125122
if (request.headers.authorization) {
126123
const decoded = (await request.jwtVerify()) as { id?: string };
127-
viewerId = decoded?.id ?? null;
124+
authenticatedUserId = decoded?.id ?? null;
125+
viewerId = authenticatedUserId;
128126
}
129127
} catch {
130128
// ignored
131129
}
132130

133131
try {
134-
const result = await publicService.getUserCard(app, username, cardId, viewerId, request);
132+
const result = await publicService.getUserCard(app, username, cardId, viewerId, request, authenticatedUserId);
135133
if (result.notFound) {
136134
return reply.status(404).send({ error: 'User or card not found' });
137135
}
@@ -143,9 +141,6 @@ export async function publicRoutes(app: FastifyInstance): Promise<void> {
143141
});
144142

145143
// ─── QR Session ──────────────────────────────────────────────────────────
146-
// Returns a short-lived signed JWT encoding the public profile snapshot.
147-
// Intended for native apps to generate QR codes that remain scannable when
148-
// the device has no live network connectivity (offline QR mode, spec §5.9).
149144
app.get('/:username/qr-session', {
150145
config: {
151146
rateLimit: {
@@ -157,7 +152,7 @@ export async function publicRoutes(app: FastifyInstance): Promise<void> {
157152
const { username } = request.params;
158153

159154
try {
160-
const result = await publicService.getPublicProfile(app, username, null, request);
155+
const result = await publicService.getPublicProfile(app, username, null, request, null);
161156
if (!result) {
162157
return reply.status(404).send({ error: 'User not found' });
163158
}
@@ -178,7 +173,7 @@ export async function publicRoutes(app: FastifyInstance): Promise<void> {
178173
app.get('/:username/qr', {
179174
config: {
180175
rateLimit: {
181-
max: 50, // Lower limit for QR generation as it's more resource intensive
176+
max: 50,
182177
timeWindow: '1 minute'
183178
}
184179
} as FastifyContextConfig
@@ -189,9 +184,6 @@ export async function publicRoutes(app: FastifyInstance): Promise<void> {
189184
const { username } = request.params;
190185
const format = (request.query as any).format || 'png';
191186

192-
// Parse and validate size before touching the DB or allocating any buffers.
193-
// parseInt safely handles non-numeric strings (returns NaN) and ignores any
194-
// trailing fractional part, so '400.9' → 400 which is within bounds.
195187
const rawSize = (request.query as any).size;
196188
const size = rawSize !== undefined ? parseInt(rawSize, 10) : 400;
197189

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

204-
// Verify user exists
205196
const user = await app.prisma.user.findUnique({
206197
where: { username },
207198
});
@@ -231,4 +222,4 @@ export async function publicRoutes(app: FastifyInstance): Promise<void> {
231222
return reply.status(500).send({ error: 'QR code generation failed' });
232223
}
233224
});
234-
}
225+
}

apps/backend/src/services/publicService.ts

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
1-
import type { FastifyInstance } from 'fastify'
21
import { getErrorMessage } from '../utils/error.util.js'
32

3+
import type { FastifyInstance } from 'fastify'
4+
45
const PROFILE_CACHE_TTL = 300
5-
const CACHE_CONTROL_HEADER = 'public, max-age=300, stale-while-revalidate=60'
66

7-
export async function getPublicProfile(app: FastifyInstance, username: string, viewerId: string | null, request: any) {
7+
export async function getPublicProfile(
8+
app: FastifyInstance,
9+
username: string,
10+
viewerId: string | null,
11+
request: any,
12+
authenticatedUserId: string | null = null,
13+
): Promise<{ cached: boolean; data: object; cacheKey: string } | null> {
814
const cacheKey = `profile:${username}`
915

1016
if (app.redis) {
1117
try {
1218
const cached = await app.redis.get(cacheKey)
1319
if (cached) {
1420
const { _userId, ...profileData } = JSON.parse(cached)
15-
if (viewerId && viewerId !== _userId) {
21+
// Only record a view if the viewer is not the owner
22+
const isSelfView = authenticatedUserId !== null && authenticatedUserId === _userId
23+
if (viewerId && !isSelfView) {
1624
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)}`))
1725
}
1826
return { cached: true, data: profileData, cacheKey }
@@ -23,9 +31,11 @@ export async function getPublicProfile(app: FastifyInstance, username: string, v
2331
}
2432

2533
const user = await app.prisma.user.findUnique({ where: { username }, include: { platformLinks: { orderBy: { displayOrder: 'asc' } } } })
26-
if (!user) return null
34+
if (!user) { return null }
2735

28-
if (viewerId && viewerId !== user.id) {
36+
// Block self-views: don't record a cardView if the authenticated user is the owner
37+
const isSelfView = authenticatedUserId !== null && authenticatedUserId === user.id
38+
if (viewerId && !isSelfView) {
2939
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)}`))
3040
}
3141

@@ -47,21 +57,30 @@ export async function getPublicProfile(app: FastifyInstance, username: string, v
4757
return { cached: false, data: response, cacheKey }
4858
}
4959

50-
export async function getCardById(app: FastifyInstance, cardId: string) {
60+
export async function getCardById(app: FastifyInstance, cardId: string): Promise<any> {
5161
const card = await app.prisma.card.findUnique({ where: { id: cardId }, include: { user: true, cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } } })
5262
return card
5363
}
5464

55-
export async function getUserCard(app: FastifyInstance, username: string, cardId: string, viewerId: string | null, request: any) {
65+
export async function getUserCard(
66+
app: FastifyInstance,
67+
username: string,
68+
cardId: string,
69+
viewerId: string | null,
70+
request: any,
71+
authenticatedUserId: string | null = null,
72+
): Promise<{ notFound: boolean; data?: object }> {
5673
const user = await app.prisma.user.findUnique({ where: { username } })
57-
if (!user) return { notFound: true }
74+
if (!user) { return { notFound: true } }
5875
const card = await app.prisma.card.findFirst({ where: { id: cardId, userId: user.id }, include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } } })
59-
if (!card) return { notFound: true }
76+
if (!card) { return { notFound: true } }
6077

61-
if (viewerId && viewerId !== user.id) {
78+
// Block self-views: don't record a cardView if the authenticated user is the owner
79+
const isSelfView = authenticatedUserId !== null && authenticatedUserId === user.id
80+
if (viewerId && !isSelfView) {
6281
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)}`))
6382
}
6483

6584
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 })) }
6685
return { notFound: false, data: response }
67-
}
86+
}

0 commit comments

Comments
 (0)