const express = require("express"); const crypto = require("crypto"); const path = require("path"); const { promisify } = require("util"); const router = express.Router(); const pool = require("../../db"); const driver = require("../../graph_db"); const { strictInput } = require("../../middleware/strict_input"); const { sanitizeProfileUpdate, sanitizeAccountDelete, validatePasswordRules, } = require("../../middleware/sanitize_person_input"); const { handleProfileUpload, profileImageLinkForFile, removeImageLink, removeUploadedProfileFile, uploadDirectory, } = require("../../middleware/post_upload"); const { areFriends } = require("../relations/friend_graph"); const { authLimiter } = require("../../middleware/rate_limit"); const { deletePostsByAuthor } = require("../posts/post_graph"); const scrypt = promisify(crypto.scrypt); async function verifyStoredPassword(storedHash, password) { const [salt, storedKeyHex] = storedHash.split(":"); const storedKey = Buffer.from(storedKeyHex || "", "hex"); const derivedKey = await scrypt(password, salt, 64); return ( storedKey.length === derivedKey.length && crypto.timingSafeEqual(storedKey, derivedKey) ); } async function hashPassword(password) { const salt = crypto.randomBytes(16).toString("hex"); const derivedKey = await scrypt(password, salt, 64); return `${salt}:${derivedKey.toString("hex")}`; } function isUniqueViolation(err) { return err.code === "23505"; } function resolveProfileId(req) { const { id } = req.params; if (id === undefined || id === "me") { return req.user.id; } return id; } function isPositiveInteger(value) { const id = Number(value); return Number.isInteger(id) && id > 0; } function limitedProfile(profile) { return { id: profile.id, name: profile.name, username: profile.username, }; } async function getProfile(req, res) { const id = resolveProfileId(req); if (id !== req.user.id && !isPositiveInteger(id)) { return res.status(400).send("Invalid profile id"); } try { const { rows } = await pool.query( "SELECT id, name, username, profile_link, private FROM people WHERE id = $1", [id], ); if (rows.length === 0) return res.status(404).send("Profile not found"); const profile = rows[0]; const targetId = Number(id); if ( profile.private && targetId !== req.user.id && !(await areFriends(req.user.id, targetId)) ) { return res.status(200).json(limitedProfile(profile)); } return res.status(200).json(profile); } catch (err) { console.error("profile query failed", { message: err.message, code: err.code, detail: err.detail, hint: err.hint, }); return res.status(500).send("Profile could not be retrieved, request failed"); } } async function updateProfile(req, res) { const update = req.profileUpdate; const { remove_image: removeImageValue } = req.body; const removeImage = removeImageValue === "true"; if (removeImageValue !== undefined && !["true", "false"].includes(removeImageValue)) { await removeUploadedProfileFile(req.file); return res.status(400).send("remove_image must be true or false"); } if (req.file && removeImage) { await removeUploadedProfileFile(req.file); return res.status(400).send("Choose either an image or remove_image=true"); } try { const existing = await pool.query( "SELECT id, name, username, email, password, profile_link FROM people WHERE id = $1", [req.user.id], ); if (existing.rowCount === 0) { await removeUploadedProfileFile(req.file); return res.status(404).send("Profile not found"); } const current = existing.rows[0]; const nextName = update.name ?? current.name; const nextUsername = update.username ?? current.username; const nextEmail = update.email ?? current.email; if (update.password !== undefined) { const passwordMatches = await verifyStoredPassword( current.password, update.current_password, ); if (!passwordMatches) { await removeUploadedProfileFile(req.file); return res.status(401).send("Current password is incorrect"); } try { validatePasswordRules(update.password, { username: nextUsername, email: nextEmail, }); } catch (err) { await removeUploadedProfileFile(req.file); return res.status(400).send(err.message); } } const oldProfileLink = current.profile_link; const fields = []; const values = []; const addField = (field, value) => { fields.push(`${field} = $${values.length + 1}`); values.push(value); }; if (update.name !== undefined) addField("name", update.name); if (update.username !== undefined) addField("username", update.username); if (update.email !== undefined) addField("email", update.email); if (update.private !== undefined) addField("private", update.private); if (update.password !== undefined) { addField("password", await hashPassword(update.password)); } if (req.file) addField("profile_link", profileImageLinkForFile(req.file)); if (removeImage) addField("profile_link", null); values.push(req.user.id); let rows; try { ({ rows } = await pool.query( `UPDATE people SET ${fields.join(", ")} WHERE id = $${values.length} RETURNING id, name, username, profile_link, private`, values, )); } catch (err) { await removeUploadedProfileFile(req.file); throw err; } if (req.file || removeImage) { await removeImageLink(oldProfileLink).catch((err) => { console.error("old profile image could not be removed", { message: err.message }); }); } if (update.password !== undefined) { await pool.query("UPDATE people SET token_version = token_version + 1 WHERE id = $1", [req.user.id]); await pool.query("DELETE FROM sessions WHERE user_id = $1", [req.user.id]); } if (update.name !== undefined || update.username !== undefined) { await driver.executeQuery( "MATCH (p:Person {db_id: $db_id}) SET p.name = $name, p.username = $username", { db_id: req.user.id, name: rows[0].name, username: rows[0].username }, { database: process.env.GRAPH_DB_NAME }, ); } return res.status(200).json(rows[0]); } catch (err) { await removeUploadedProfileFile(req.file); if (isUniqueViolation(err)) { return res.status(409).send("Username or email is already in use"); } console.error("profile update failed", { message: err.message, code: err.code, detail: err.detail, hint: err.hint, }); return res.status(500).send("Profile was not updated, request failed"); } } async function deleteAccount(req, res) { try { const existing = await pool.query( "SELECT id, password, profile_link FROM people WHERE id = $1", [req.user.id], ); if (existing.rowCount === 0) { return res.status(404).send("Profile not found"); } const passwordMatches = await verifyStoredPassword( existing.rows[0].password, req.accountDelete.password, ); if (!passwordMatches) { return res.status(401).send("Invalid password"); } const posts = await pool.query( "SELECT image_link FROM posts WHERE author_id = $1", [req.user.id], ); for (const post of posts.rows) { await removeImageLink(post.image_link); } await removeImageLink(existing.rows[0].profile_link); await pool.query("DELETE FROM sessions WHERE user_id = $1", [req.user.id]); await pool.query("UPDATE posts SET active = false WHERE author_id = $1", [req.user.id]); await pool.query("DELETE FROM people WHERE id = $1", [req.user.id]); await deletePostsByAuthor(req.user.id); await driver.executeQuery( "MATCH (p:Person {db_id: $db_id}) DETACH DELETE p", { db_id: req.user.id }, { database: process.env.GRAPH_DB_NAME }, ); res.clearCookie("fc_session_token", { httpOnly: true, secure: true, sameSite: "Strict", path: "/", }); return res.status(200).json({ message: "Account deleted" }); } catch (err) { console.error("account delete failed", { message: err.message, code: err.code, detail: err.detail, hint: err.hint, }); return res.status(500).send("Account was not deleted, request failed"); } } router.get( "/image/:filename", strictInput(), async (req, res) => { const filename = path.basename(req.params.filename); if (filename !== req.params.filename) return res.status(404).end(); try { const { rows } = await pool.query( "SELECT id, private FROM people WHERE profile_link = $1", [`/profiles/image/${filename}`], ); if (rows.length === 0) return res.status(404).send("Image not found"); const profile = rows[0]; if ( profile.private && Number(profile.id) !== req.user.id && !(await areFriends(req.user.id, profile.id)) ) { return res.status(404).send("Image not found"); } res.set("Cache-Control", "private, no-store"); res.sendFile(filename, { root: uploadDirectory }, (err) => { if (err && !res.headersSent) res.status(err.statusCode === 404 ? 404 : 500).end(); }); } catch (err) { console.error("profile image query failed", { message: err.message, code: err.code, detail: err.detail, hint: err.hint, }); return res.status(500).send("Image could not be retrieved, request failed"); } }, ); router.get("/me", strictInput(), getProfile); router.get("/:id", strictInput(), getProfile); router.put( "/me", handleProfileUpload, strictInput({ body: ["name", "username", "email", "password", "current_password", "remove_image", "private"], cleanupUploadedFile: true, }), sanitizeProfileUpdate, updateProfile, ); router.delete( "/me", authLimiter, strictInput({ body: ["password"] }), sanitizeAccountDelete, deleteAccount, ); module.exports = router;