changemaker.lite/api/src/modules/people/profile-public.routes.ts

255 lines
8.4 KiB
TypeScript

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 };