1- import { getErrorMessage } from '../utils/error.util .js' ;
1+ import * as publicService from '../services/publicService .js' ;
22import { generateQRBuffer , generateQRSvg } from '../utils/qr.js' ;
33
4- import type { PlatformLink } from '@devcard/shared' ;
54import type { FastifyContextConfig , FastifyInstance , FastifyRequest , FastifyReply } from 'fastify' ;
65
7- type PublicProfileLink = {
8- id : string ;
9- platform : string ;
10- username : string ;
11- url : string ;
12- displayOrder : number ;
13- followed ?: boolean ;
14- }
15-
16- type UsernamePublicProfileResponse = {
17- username : string ;
18- displayName : string ;
19- bio : string | null ;
20- pronouns : string | null ;
21- role : string | null ;
22- company : string | null ;
23- avatarUrl : string | null ;
24- accentColor : string ;
25- links : PublicProfileLink [ ]
26- }
27-
28- type PublicProfileCardLink = {
29- id : string ;
30- platform : string ;
31- username : string ;
32- url : string ;
33- followed ?: boolean ;
34- }
35-
36- type CardPublicProfileResponse = {
37- id : string ;
38- title : string ;
39- owner : {
40- username : string ;
41- displayName : string ;
42- bio : string | null ;
43- avatarUrl : string | null ;
44- accentColor : string ;
45- } ;
46- links : PublicProfileCardLink [ ]
47- }
48-
49- type UsernameCardPublicProfileResponse = {
50- title : string ;
51- owner : {
52- username : string ;
53- displayName : string ;
54- bio : string | null ;
55- pronouns : string | null ;
56- role : string | null ;
57- company : string | null ;
58- avatarUrl : string | null ;
59- accentColor : string ;
60- } ;
61- links : PublicProfileCardLink [ ]
62- }
63-
64- // Represents a CardLink record with the joined PlatformLink relation
65- interface CardLinkWithPlatform {
66- id : string ;
67- displayOrder : number ;
68- platformLink : PlatformLink ;
69- }
6+ // ── QR size bounds ────────────────────────────────────────────────────────────
7+ const MIN_QR_SIZE = 1 ;
8+ const MAX_QR_SIZE = 2048 ;
709
10+ // ── Cache constants ───────────────────────────────────────────────────────────
11+ const CACHE_CONTROL_HEADER = 'public, max-age=300, stale-while-revalidate=60' ;
7112
7213export async function publicRoutes ( app : FastifyInstance ) : Promise < void > {
73- // ─── Public Profile ───
14+ // ─── Public Profile ───────────────────────────────────────────────────────
15+ /**
16+ * GET /api/u/:username
17+ * Returns the public profile information for a user.
18+ */
7419 app . get ( '/:username' , {
7520 config : {
7621 rateLimit : {
@@ -81,69 +26,23 @@ export async function publicRoutes(app: FastifyInstance): Promise<void> {
8126 } , async ( request : FastifyRequest < { Params : { username : string } } > , reply : FastifyReply ) => {
8227 const { username } = request . params ;
8328
84- // 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.
8532 let viewerId : string | null = null ;
86- let isSelfView = false ;
33+ let authenticatedUserId : string | null = null ;
8734 try {
8835 if ( request . headers . authorization ) {
8936 const decoded = ( await request . jwtVerify ( ) ) as { id ?: string } ;
90- if ( decoded ?. id === user . id ) {
91- isSelfView = true ;
92- } else {
93- viewerId = decoded ?. id ?? null ;
94- }
95- } else {
96- viewerId = null ;
37+ authenticatedUserId = decoded ?. id ?? null ;
38+ viewerId = authenticatedUserId ;
9739 }
9840 } catch {
99- // Ignored if invalid token
100- }
101-
102- // Don't track if the owner is viewing their own profile
103- if ( ! isSelfView && viewerId !== user . id ) {
104- // Background view tracking
105- app . prisma . cardView . create ( {
106- data : {
107- ownerId : user . id ,
108- cardId : null , // this is a profile view, not a card view
109- viewerId,
110- viewerIp : request . ip || null ,
111- viewerAgent : request . headers [ 'user-agent' ] || null ,
112- source : request . query ?. source || 'link' ,
113- } ,
114- } ) . catch ( ( err : unknown ) => app . log . error ( `Failed to log view: ${ getErrorMessage ( err ) } ` ) ) ;
115- }
116-
117- // Fetch viewer's successful follow logs for this profile's links
118- let followedLinkIds : string [ ] = [ ] ;
119- if ( viewerId && user . platformLinks . length > 0 ) {
120- const successfulFollows = await app . prisma . followLog . findMany ( {
121- where : {
122- followerId : viewerId ,
123- status : 'success' ,
124- OR : user . platformLinks . map ( ( link : PlatformLink ) => ( {
125- platform : link . platform ,
126- targetUsername : link . username ,
127- } ) ) ,
128- } ,
129- select : {
130- platform : true ,
131- targetUsername : true ,
132- } ,
133- } ) ;
134-
135- followedLinkIds = user . platformLinks
136- . filter ( ( link : PlatformLink ) =>
137- successfulFollows . some ( ( f : { platform : string ; targetUsername : string } ) =>
138- f . platform === link . platform &&
139- f . targetUsername . toLowerCase ( ) === link . username . toLowerCase ( )
140- )
141- )
142- . map ( ( link : PlatformLink ) => link . id ) ;
41+ // ignored — treat as unauthenticated
14342 }
14443
14544 try {
146- const result = await publicService . getPublicProfile ( app , username , viewerId , request ) ;
45+ const result = await publicService . getPublicProfile ( app , username , viewerId , request , authenticatedUserId ) ;
14746 if ( ! result ) {
14847 return reply . status ( 404 ) . send ( { error : 'User not found' } ) ;
14948 }
@@ -169,44 +68,36 @@ export async function publicRoutes(app: FastifyInstance): Promise<void> {
16968 timeWindow : '1 minute'
17069 }
17170 } as FastifyContextConfig
172- } , async ( request : FastifyRequest < { Params : { cardId : string } } > , _reply : FastifyReply ) => {
71+ } , async ( request : FastifyRequest < { Params : { cardId : string } } > , reply : FastifyReply ) => {
17372 const { cardId } = request . params ;
17473
175- const card = await app . prisma . card . findUnique ( {
176- where : { id : cardId } ,
177- include : {
178- user : true ,
179- cardLinks : {
180- include : { platformLink : true } ,
181- orderBy : { displayOrder : 'asc' } ,
74+ try {
75+ const card = await publicService . getCardById ( app , cardId ) ;
76+ if ( ! card ) {
77+ return reply . status ( 404 ) . send ( { error : 'Card not found' } ) ;
78+ }
79+ const response = {
80+ id : card . id ,
81+ title : card . title ,
82+ owner : {
83+ username : card . user . username ,
84+ displayName : card . user . displayName ,
85+ bio : card . user . bio ,
86+ avatarUrl : card . user . avatarUrl ,
87+ accentColor : card . user . accentColor ,
18288 } ,
183- } ,
184- } ) ;
185-
186- if ( ! card ) {
187- return _reply . status ( 404 ) . send ( { error : 'Card not found' } ) ;
188- }
189-
190- const response : CardPublicProfileResponse = {
191- id : card . id ,
192- title : card . title ,
193- owner : {
194- username : card . user . username ,
195- displayName : card . user . displayName ,
196- bio : card . user . bio ,
197- avatarUrl : card . user . avatarUrl ,
198- accentColor : card . user . accentColor ,
199- } ,
200- links : card . cardLinks . map ( ( cl : CardLinkWithPlatform ) => ( {
201- id : cl . platformLink . id ,
202- platform : cl . platformLink . platform ,
203- username : cl . platformLink . username ,
204- url : cl . platformLink . url ,
205- } ) ) ,
89+ links : card . cardLinks . map ( ( cl : any ) => ( {
90+ id : cl . platformLink . id ,
91+ platform : cl . platformLink . platform ,
92+ username : cl . platformLink . username ,
93+ url : cl . platformLink . url ,
94+ } ) ) ,
95+ } ;
96+ return response ;
97+ } catch ( err : unknown ) {
98+ app . log . error ( { err } , 'Failed to fetch shared card' ) ;
99+ return reply . status ( 500 ) . send ( { error : 'Internal server error' } ) ;
206100 }
207-
208- return response ;
209-
210101 } ) ;
211102
212103 // ─── Public Card View ─────────────────────────────────────────────────────
@@ -226,38 +117,30 @@ export async function publicRoutes(app: FastifyInstance): Promise<void> {
226117 const { username, cardId } = request . params ;
227118
228119 let viewerId : string | null = null ;
229- let isSelfView = false ;
120+ let authenticatedUserId : string | null = null ;
230121 try {
231122 if ( request . headers . authorization ) {
232123 const decoded = ( await request . jwtVerify ( ) ) as { id ?: string } ;
233- if ( decoded ?. id === user . id ) {
234- isSelfView = true ;
235- } else {
236- viewerId = decoded ?. id ?? null ;
237- }
124+ authenticatedUserId = decoded ?. id ?? null ;
125+ viewerId = authenticatedUserId ;
238126 }
239- } catch ( _e ) {
240- // Ignored if invalid token
127+ } catch {
128+ // ignored
241129 }
242130
243- if ( ! isSelfView && viewerId !== user . id ) {
244- app . prisma . cardView . create ( {
245- data : {
246- ownerId : user . id ,
247- cardId : card . id ,
248- viewerId,
249- viewerIp : request . ip || null ,
250- viewerAgent : request . headers [ 'user-agent' ] || null ,
251- source : request . query ?. source || 'qr' ,
252- } ,
253- } ) . catch ( ( err : unknown ) => app . log . error ( `Failed to log view: ${ getErrorMessage ( err ) } ` ) ) ;
131+ try {
132+ const result = await publicService . getUserCard ( app , username , cardId , viewerId , request , authenticatedUserId ) ;
133+ if ( result . notFound ) {
134+ return reply . status ( 404 ) . send ( { error : 'User or card not found' } ) ;
135+ }
136+ return result . data ;
137+ } catch ( err : unknown ) {
138+ app . log . error ( { err } , 'Failed to fetch user card' ) ;
139+ return reply . status ( 500 ) . send ( { error : 'Internal server error' } ) ;
254140 }
255141 } ) ;
256142
257143 // ─── QR Session ──────────────────────────────────────────────────────────
258- // Returns a short-lived signed JWT encoding the public profile snapshot.
259- // Intended for native apps to generate QR codes that remain scannable when
260- // the device has no live network connectivity (offline QR mode, spec §5.9).
261144 app . get ( '/:username/qr-session' , {
262145 config : {
263146 rateLimit : {
@@ -269,7 +152,7 @@ export async function publicRoutes(app: FastifyInstance): Promise<void> {
269152 const { username } = request . params ;
270153
271154 try {
272- const result = await publicService . getPublicProfile ( app , username , null , request ) ;
155+ const result = await publicService . getPublicProfile ( app , username , null , request , null ) ;
273156 if ( ! result ) {
274157 return reply . status ( 404 ) . send ( { error : 'User not found' } ) ;
275158 }
@@ -290,7 +173,7 @@ export async function publicRoutes(app: FastifyInstance): Promise<void> {
290173 app . get ( '/:username/qr' , {
291174 config : {
292175 rateLimit : {
293- max : 50 , // Lower limit for QR generation as it's more resource intensive
176+ max : 50 ,
294177 timeWindow : '1 minute'
295178 }
296179 } as FastifyContextConfig
@@ -301,9 +184,6 @@ export async function publicRoutes(app: FastifyInstance): Promise<void> {
301184 const { username } = request . params ;
302185 const format = ( request . query as any ) . format || 'png' ;
303186
304- // Parse and validate size before touching the DB or allocating any buffers.
305- // parseInt safely handles non-numeric strings (returns NaN) and ignores any
306- // trailing fractional part, so '400.9' → 400 which is within bounds.
307187 const rawSize = ( request . query as any ) . size ;
308188 const size = rawSize !== undefined ? parseInt ( rawSize , 10 ) : 400 ;
309189
@@ -313,7 +193,6 @@ export async function publicRoutes(app: FastifyInstance): Promise<void> {
313193 } ) ;
314194 }
315195
316- // Verify user exists
317196 const user = await app . prisma . user . findUnique ( {
318197 where : { username } ,
319198 } ) ;
@@ -343,4 +222,4 @@ export async function publicRoutes(app: FastifyInstance): Promise<void> {
343222 return reply . status ( 500 ) . send ( { error : 'QR code generation failed' } ) ;
344223 }
345224 } ) ;
346- }
225+ }
0 commit comments