255 lines
8.4 KiB
TypeScript
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 };
|