Import source: app/backend/src/routes/profile
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
// 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 });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user