import { Router, Request, Response, NextFunction } from 'express'; import multer from 'multer'; import { validate } from '../../middleware/validate'; import { optionalAuth } from '../../middleware/auth.middleware'; import { profileViewRateLimit, profileEditRateLimit, profilePhotoRateLimit, profilePasswordRateLimit } from '../../middleware/rate-limit'; import { profileService } from './profile.service'; import { profileSelfUpdateSchema, profileActivitySchema, profilePasswordSchema } from './profile-public.schemas'; const router = Router(); // Profile tokens are crypto.randomBytes(32).toString('hex') → exactly 64 hex chars const TOKEN_RE = /^[0-9a-f]{64}$/; // Multer for cover photo upload — memory storage, 5MB limit const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { const allowed = ['image/jpeg', 'image/png', 'image/webp']; if (!allowed.includes(file.mimetype)) { cb(new Error('Invalid file type. Allowed: JPEG, PNG, WebP')); return; } cb(null, true); }, }); // --------------------------------------------------------------------------- // GET /api/profile/:token — Get contact profile (public) // Checks expiration + password protection before returning profile // --------------------------------------------------------------------------- router.get( '/:token', profileViewRateLimit, optionalAuth, async (req: Request, res: Response, next: NextFunction) => { try { const token = req.params.token as string; if (!token || !TOKEN_RE.test(token)) { res.status(400).json({ error: { message: 'Invalid token', code: 'INVALID_TOKEN' } }); return; } // Pre-check access (expiration + password) const access = await profileService.validateProfileAccess(token); if (access.status === 'not_found') { res.status(404).json({ error: { message: 'Profile not found', code: 'NOT_FOUND' } }); return; } if (access.status === 'expired') { res.status(410).json({ error: { message: 'This profile link has expired', code: 'LINK_EXPIRED' }, expiresAt: access.expiresAt.toISOString(), }); return; } if (access.status === 'password_required') { res.status(401).json({ error: { message: 'Password required', code: 'PASSWORD_REQUIRED' }, branding: access.branding, }); return; } // Access OK — return full profile (pass viewer ID for isOwnProfile check) const profile = await profileService.getProfileByToken(token, req.user?.id); if (!profile) { res.status(404).json({ error: { message: 'Profile not found', code: 'NOT_FOUND' } }); return; } res.json(profile); } catch (err) { next(err); } }, ); // --------------------------------------------------------------------------- // POST /api/profile/:token/verify — Verify password for protected profile // --------------------------------------------------------------------------- router.post( '/:token/verify', profilePasswordRateLimit, validate(profilePasswordSchema), async (req: Request, res: Response, next: NextFunction) => { try { const token = req.params.token as string; if (!token || !TOKEN_RE.test(token)) { res.status(400).json({ error: { message: 'Invalid token', code: 'INVALID_TOKEN' } }); return; } const result = await profileService.verifyProfilePassword(token, req.body.password); switch (result.status) { case 'not_found': res.status(404).json({ error: { message: 'Profile not found', code: 'NOT_FOUND' } }); return; case 'expired': res.status(410).json({ error: { message: 'This profile link has expired', code: 'LINK_EXPIRED' }, expiresAt: result.expiresAt.toISOString(), }); return; case 'invalid_password': res.status(401).json({ error: { message: 'Incorrect password', code: 'INVALID_PASSWORD' } }); return; case 'ok': res.json(result.profile); return; } } catch (err) { next(err); } }, ); // --------------------------------------------------------------------------- // PUT /api/profile/:token — Update self-editable fields // --------------------------------------------------------------------------- router.put( '/:token', profileEditRateLimit, validate(profileSelfUpdateSchema), async (req: Request, res: Response, next: NextFunction) => { try { const token = req.params.token as string; if (!token || !TOKEN_RE.test(token)) { res.status(400).json({ error: { message: 'Invalid token', code: 'INVALID_TOKEN' } }); return; } const updated = await profileService.updateProfileSelfService(token, req.body); if (!updated) { res.status(404).json({ error: { message: 'Profile not found', code: 'NOT_FOUND' } }); return; } res.json({ success: true }); } catch (err) { next(err); } }, ); // --------------------------------------------------------------------------- // POST /api/profile/:token/photo — Upload cover photo // --------------------------------------------------------------------------- router.post( '/:token/photo', profilePhotoRateLimit, upload.single('photo'), async (req: Request, res: Response, next: NextFunction) => { try { const token = req.params.token as string; if (!token || !TOKEN_RE.test(token)) { res.status(400).json({ error: { message: 'Invalid token', code: 'INVALID_TOKEN' } }); return; } if (!req.file) { res.status(400).json({ error: { message: 'No file provided', code: 'NO_FILE' } }); return; } const result = await profileService.uploadCoverPhoto( token, req.file.buffer, req.file.mimetype, req.file.originalname, ); if (!result) { res.status(404).json({ error: { message: 'Profile not found', code: 'NOT_FOUND' } }); return; } res.json(result); } catch (err) { if (err instanceof Error && ( err.message.includes('Invalid file type') || err.message.includes('File too large') || err.message.includes('Invalid image file') )) { res.status(400).json({ error: { message: err.message, code: 'INVALID_FILE' } }); return; } next(err); } }, ); // --------------------------------------------------------------------------- // GET /api/profile/:token/photo — Serve cover photo // --------------------------------------------------------------------------- router.get( '/:token/photo', profileViewRateLimit, async (req: Request, res: Response, next: NextFunction) => { try { const token = req.params.token as string; if (!token || !TOKEN_RE.test(token)) { res.status(400).json({ error: { message: 'Invalid token', code: 'INVALID_TOKEN' } }); return; } const size = req.query.size === 'thumb' ? 'thumb' : 'cover'; const result = await profileService.serveCoverPhoto(token, size as 'cover' | 'thumb'); if (!result) { res.status(404).json({ error: { message: 'Photo not found', code: 'NOT_FOUND' } }); return; } res.setHeader('Content-Type', result.contentType); res.setHeader('Cache-Control', 'public, max-age=3600'); result.stream.pipe(res); } catch (err) { next(err); } }, ); // --------------------------------------------------------------------------- // GET /api/profile/:token/activity — Activity timeline (filtered for public) // --------------------------------------------------------------------------- router.get( '/:token/activity', profileViewRateLimit, validate(profileActivitySchema, 'query'), async (req: Request, res: Response, next: NextFunction) => { try { const token = req.params.token as string; if (!token || !TOKEN_RE.test(token)) { res.status(400).json({ error: { message: 'Invalid token', code: 'INVALID_TOKEN' } }); return; } const result = await profileService.getProfileActivity(token, req.query as any); if (!result) { res.status(404).json({ error: { message: 'Profile not found', code: 'NOT_FOUND' } }); return; } res.json(result); } catch (err) { next(err); } }, ); export { router as profilePublicRouter };