Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
191 lines
7.9 KiB
TypeScript
191 lines
7.9 KiB
TypeScript
// Public + authenticated profile routes.
|
|
// GET /api/profile/:username — public profile
|
|
// PUT /api/auth/profile — update own profile (requires session)
|
|
// POST /api/auth/avatar — upload profile picture (multipart, requires session)
|
|
|
|
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
|
import { z } from 'zod';
|
|
import * as argon2 from '@node-rs/argon2';
|
|
import { prisma } from '../db.js';
|
|
import { config } from '../config.js';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
import { join, extname } from 'node:path';
|
|
import { requireAuth, invalidateAllUserSessions } from '../plugins/session.js';
|
|
import { isPasswordAcceptable } from '../lib/password-strength.js';
|
|
import { logSecurityEvent } from '../lib/security-audit.js';
|
|
|
|
const USERNAME_RE = /^[a-z0-9_][a-z0-9_.]{1,28}[a-z0-9_]$/;
|
|
|
|
const profileUpdateSchema = z.object({
|
|
name: z.string().trim().min(2).max(80).optional(),
|
|
username: z
|
|
.string()
|
|
.trim()
|
|
.toLowerCase()
|
|
.regex(USERNAME_RE, 'username must be 3-30 chars, lowercase letters/digits/dots/underscores')
|
|
.optional(),
|
|
bio: z.string().trim().max(280).optional(),
|
|
currentPassword: z.string().min(1).max(256).optional(),
|
|
password: z.string().min(8).max(256).optional(),
|
|
});
|
|
|
|
const ALLOWED_IMAGE_TYPES: Record<string, string> = {
|
|
'image/jpeg': '.jpg',
|
|
'image/png': '.png',
|
|
'image/webp': '.webp',
|
|
'image/gif': '.gif',
|
|
};
|
|
|
|
async function tryWebP(buf: Buffer, mime: string): Promise<{ buf: Buffer; ext: string }> {
|
|
if (mime === 'image/webp') return { buf, ext: '.webp' };
|
|
try {
|
|
const sharp = (await import('sharp')).default;
|
|
const converted = await sharp(buf).webp({ quality: 82 }).toBuffer();
|
|
return { buf: converted, ext: '.webp' };
|
|
} catch {
|
|
return { buf, ext: ALLOWED_IMAGE_TYPES[mime] ?? '.jpg' };
|
|
}
|
|
}
|
|
|
|
export default async function profileRoutes(app: FastifyInstance) {
|
|
// ── Public profile ──────────────────────────────────────────────────────────
|
|
app.get<{ Params: { username: string } }>('/api/v1/profile/:username', async (req, reply) => {
|
|
const slug = req.params.username.toLowerCase().replace(/^@/, '');
|
|
const user = await prisma.user.findUnique({
|
|
where: { username: slug },
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
name: true,
|
|
bio: true,
|
|
avatarUrl: true,
|
|
role: true,
|
|
createdAt: true,
|
|
author: {
|
|
select: {
|
|
articles: {
|
|
where: { status: 'منتشرشده' },
|
|
orderBy: { publishedAt: 'desc' },
|
|
take: 10,
|
|
select: { id: true, slug: true, title: true, summary: true, publishedAt: true, views: true, reactions: true },
|
|
},
|
|
articleCount: true,
|
|
verifiedExpert: true,
|
|
verificationNote: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
if (!user || !user.username) return reply.code(404).send({ error: 'not_found' });
|
|
return reply.send({ profile: user });
|
|
});
|
|
|
|
// ── Update own profile ──────────────────────────────────────────────────────
|
|
app.put('/api/v1/auth/profile', { preHandler: requireAuth() }, async (req, reply) => {
|
|
const parsed = profileUpdateSchema.safeParse(req.body);
|
|
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input', details: parsed.error.flatten().fieldErrors });
|
|
|
|
const { name, username, bio, currentPassword, password } = parsed.data;
|
|
const currentUser = await prisma.user.findUnique({
|
|
where: { id: req.session!.userId },
|
|
select: { id: true, role: true, passwordHash: true },
|
|
});
|
|
if (!currentUser) return reply.code(404).send({ error: 'user_not_found' });
|
|
const canChangeUsername = currentUser.role !== 'reader';
|
|
|
|
// Username uniqueness pre-check
|
|
if (username && !canChangeUsername) {
|
|
return reply.code(403).send({ error: 'username_change_forbidden' });
|
|
}
|
|
if (username && canChangeUsername) {
|
|
const taken = await prisma.user.findUnique({ where: { username } });
|
|
if (taken && taken.id !== req.session!.userId) {
|
|
return reply.code(409).send({ error: 'username_taken' });
|
|
}
|
|
}
|
|
|
|
let passwordHash: string | undefined;
|
|
if (password) {
|
|
const pwCheck = isPasswordAcceptable(password);
|
|
if (!pwCheck.ok) return reply.code(400).send({ error: 'weak_password', issues: pwCheck.issues });
|
|
if (currentUser.passwordHash) {
|
|
if (!currentPassword) return reply.code(400).send({ error: 'current_password_required' });
|
|
const ok = await argon2.verify(currentUser.passwordHash, currentPassword).catch(() => false);
|
|
if (!ok) return reply.code(403).send({ error: 'current_password_invalid' });
|
|
}
|
|
passwordHash = await argon2.hash(password, {
|
|
algorithm: argon2.Algorithm.Argon2id,
|
|
memoryCost: 19 * 1024,
|
|
timeCost: 2,
|
|
parallelism: 1,
|
|
});
|
|
}
|
|
|
|
const user = await prisma.user.update({
|
|
where: { id: req.session!.userId },
|
|
data: {
|
|
...(name !== undefined ? { name } : {}),
|
|
...(canChangeUsername && username !== undefined ? { username } : {}),
|
|
...(bio !== undefined ? { bio } : {}),
|
|
...(passwordHash ? { passwordHash, passwordChangedAt: new Date() } : {}),
|
|
},
|
|
select: { id: true, email: true, phone: true, name: true, username: true, bio: true, avatarUrl: true, role: true, status: true, emailVerifiedAt: true, phoneVerifiedAt: true, twoFactorEnabled: true },
|
|
});
|
|
if (passwordHash) await invalidateAllUserSessions(user.id).catch(() => undefined);
|
|
return reply.send({ ok: true, user: { ...user, emailVerified: !!user.emailVerifiedAt, phoneVerified: !!user.phoneVerifiedAt } });
|
|
});
|
|
|
|
// ── Upload avatar ───────────────────────────────────────────────────────────
|
|
app.post('/api/v1/auth/avatar', { preHandler: requireAuth() }, async (req: FastifyRequest, reply) => {
|
|
const body = req.body as Record<string, any>;
|
|
if (!body?.file) return reply.code(400).send({ error: 'file_required' });
|
|
|
|
const mime: string = body.mimeType ?? 'image/jpeg';
|
|
if (!ALLOWED_IMAGE_TYPES[mime]) return reply.code(400).send({ error: 'unsupported_file_type' });
|
|
|
|
let buf = Buffer.from(body.file, 'base64');
|
|
if (buf.length > 5 * 1024 * 1024) return reply.code(400).send({ error: 'file_too_large' }); // 5 MB
|
|
|
|
const doWebP: boolean = body.webp === true || body.webp === 'true';
|
|
const { buf: finalBuf, ext } = doWebP ? await tryWebP(buf, mime) : { buf, ext: ALLOWED_IMAGE_TYPES[mime] };
|
|
let width: number | undefined;
|
|
let height: number | undefined;
|
|
try {
|
|
const sharp = (await import('sharp')).default;
|
|
const meta = await sharp(finalBuf).metadata();
|
|
width = meta.width;
|
|
height = meta.height;
|
|
} catch {
|
|
// Dimension metadata is optional; uploads should not fail without sharp.
|
|
}
|
|
|
|
const now = new Date();
|
|
const yyyy = String(now.getFullYear());
|
|
const mm = String(now.getMonth() + 1).padStart(2, '0');
|
|
const dir = join(config.UPLOAD_DIR, yyyy, mm);
|
|
await mkdir(dir, { recursive: true });
|
|
|
|
const filename = `avatar-${randomUUID()}${ext}`;
|
|
const absolutePath = join(dir, filename);
|
|
await writeFile(absolutePath, finalBuf);
|
|
|
|
const url = `/uploads/${yyyy}/${mm}/${filename}`;
|
|
await prisma.user.update({ where: { id: req.session!.userId }, data: { avatarUrl: url } });
|
|
await prisma.mediaAsset.create({
|
|
data: {
|
|
url,
|
|
path: absolutePath,
|
|
filename,
|
|
mimeType: ext === '.webp' ? 'image/webp' : mime,
|
|
sizeBytes: finalBuf.length,
|
|
width,
|
|
height,
|
|
uploadedById: req.session!.userId,
|
|
},
|
|
}).catch(() => undefined);
|
|
|
|
return reply.send({ ok: true, url });
|
|
});
|
|
}
|