From 6b07367a09ba1a14374446191e8b672f23789dce Mon Sep 17 00:00:00 2001 From: arsam Date: Fri, 18 Sep 2026 23:20:29 +0330 Subject: [PATCH] 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. --- app/backend/src/routes/profile.ts | 190 ++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 app/backend/src/routes/profile.ts diff --git a/app/backend/src/routes/profile.ts b/app/backend/src/routes/profile.ts new file mode 100644 index 0000000..2eb6e76 --- /dev/null +++ b/app/backend/src/routes/profile.ts @@ -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 = { + '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; + 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 }); + }); +}