| [7bc636b] | 1 | /**
|
|---|
| 2 | * Account routes — profile, password, avatar.
|
|---|
| 3 | *
|
|---|
| 4 | * Sections:
|
|---|
| 5 | * GET / -> render full account page (profile + password + avatar + danger)
|
|---|
| 6 | * POST /profile -> update bio
|
|---|
| 7 | * POST /password -> change password (verify current, hash new)
|
|---|
| 8 | * POST /avatar -> upload avatar image (multer)
|
|---|
| 9 | * POST /avatar/remove -> clear avatar_url
|
|---|
| 10 | *
|
|---|
| 11 | * Each form has its own POST handler. After success, redirects back to
|
|---|
| 12 | * /account?success=... so the page picks it up via query string.
|
|---|
| 13 | */
|
|---|
| 14 |
|
|---|
| 15 | import express from 'express';
|
|---|
| 16 | import path from 'path';
|
|---|
| 17 | import fs from 'fs';
|
|---|
| 18 | import { fileURLToPath } from 'url';
|
|---|
| [9e27d64] | 19 | import bcrypt from 'bcryptjs';
|
|---|
| [7bc636b] | 20 | import multer from 'multer';
|
|---|
| 21 | import { v4 as uuid } from 'uuid';
|
|---|
| 22 | import db from '../config/database.js';
|
|---|
| [7881080] | 23 | import { getPrimarySite } from '../middleware/site.js';
|
|---|
| [7bc636b] | 24 | import { renderPage } from '../middleware/render.js';
|
|---|
| 25 | import { requireAuth } from '../middleware/auth.js';
|
|---|
| [8f6225c] | 26 | import { toWebp } from '../services/ImageWebpService.js';
|
|---|
| [5e61b17] | 27 | import { SUPPORTED } from '../services/i18n.js';
|
|---|
| [7bc636b] | 28 |
|
|---|
| 29 | const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|---|
| 30 | const AVATAR_DIR = path.resolve(
|
|---|
| 31 | process.env.AVATAR_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'avatars')
|
|---|
| 32 | );
|
|---|
| 33 | fs.mkdirSync(AVATAR_DIR, { recursive: true });
|
|---|
| 34 |
|
|---|
| 35 | const ALLOWED_AVATAR_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
|
|---|
| 36 | const MAX_AVATAR_BYTES = 5 * 1024 * 1024;
|
|---|
| 37 |
|
|---|
| 38 | const avatarStorage = multer.diskStorage({
|
|---|
| 39 | destination: (req, file, cb) => cb(null, AVATAR_DIR),
|
|---|
| 40 | filename: (req, file, cb) => {
|
|---|
| 41 | const ext = path.extname(file.originalname).toLowerCase();
|
|---|
| 42 | cb(null, `${uuid()}${ext}`);
|
|---|
| 43 | },
|
|---|
| 44 | });
|
|---|
| 45 | const avatarUpload = multer({
|
|---|
| 46 | storage: avatarStorage,
|
|---|
| 47 | limits: { fileSize: MAX_AVATAR_BYTES },
|
|---|
| 48 | fileFilter: (req, file, cb) => {
|
|---|
| 49 | const ext = path.extname(file.originalname).toLowerCase();
|
|---|
| 50 | if (!ALLOWED_AVATAR_EXT.has(ext)) {
|
|---|
| 51 | return cb(new Error('Avatar must be jpg/png/webp/gif'));
|
|---|
| 52 | }
|
|---|
| 53 | cb(null, true);
|
|---|
| 54 | },
|
|---|
| 55 | });
|
|---|
| 56 |
|
|---|
| 57 | const router = express.Router();
|
|---|
| 58 |
|
|---|
| 59 | // ==================== GET account page ====================
|
|---|
| 60 | router.get('/', requireAuth, (req, res) => {
|
|---|
| 61 | const account = db.prepare(`
|
|---|
| [5e61b17] | 62 | SELECT id, username, email, role, bio, avatar_url, created_at, password_hash, google_sub, lang
|
|---|
| [7bc636b] | 63 | FROM users WHERE id = ?
|
|---|
| 64 | `).get(req.session.user.id);
|
|---|
| [9e27d64] | 65 | const hasPassword = !!(account && account.password_hash && account.password_hash !== '!google-oauth');
|
|---|
| [834bcc3] | 66 | if (account) { delete account.password_hash; delete account.google_sub; } // don't leak to the view
|
|---|
| [7bc636b] | 67 |
|
|---|
| [82079ad] | 68 | const editableSite = ownedSite(req.session.user);
|
|---|
| [7bc636b] | 69 | renderPage(req, res, 'pages/account', {
|
|---|
| 70 | pageTitle: 'Account',
|
|---|
| 71 | bodyClass: 'on-special',
|
|---|
| 72 | account,
|
|---|
| [9e27d64] | 73 | hasPassword,
|
|---|
| [82079ad] | 74 | editableSite,
|
|---|
| 75 | // Display fallback: when you have no own account avatar, show your site's photo.
|
|---|
| 76 | siteAvatar: editableSite ? editableSite.profile_photo : null,
|
|---|
| [7bc636b] | 77 | success: req.query.success || null,
|
|---|
| 78 | error: req.query.error || null,
|
|---|
| 79 | });
|
|---|
| 80 | });
|
|---|
| 81 |
|
|---|
| [834bcc3] | 82 | // ==================== PERSONAL INTERFACE LANGUAGE ====================
|
|---|
| 83 | // Saves the language choice on the account (persists across devices/sessions) and
|
|---|
| 84 | // also sets it in the session immediately so it takes effect right away.
|
|---|
| [5e61b17] | 85 | router.post('/lang', requireAuth, (req, res) => {
|
|---|
| 86 | const code = SUPPORTED.includes(req.body.lang) ? req.body.lang : null;
|
|---|
| 87 | if (code) {
|
|---|
| 88 | db.prepare('UPDATE users SET lang = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(code, req.session.user.id);
|
|---|
| 89 | req.session.user.lang = code;
|
|---|
| 90 | req.session.lang = code;
|
|---|
| 91 | }
|
|---|
| 92 | res.redirect('/account?success=' + encodeURIComponent('Taal opgeslagen'));
|
|---|
| 93 | });
|
|---|
| 94 |
|
|---|
| [834bcc3] | 95 | // The site this user may edit from their account: their own site
|
|---|
| 96 | // (owner_id), or for a god the primary site. Null if nothing found.
|
|---|
| [1be21ba] | 97 | function ownedSite(user) {
|
|---|
| 98 | if (!user) return null;
|
|---|
| [82079ad] | 99 | let site = db.prepare('SELECT id, title, tagline, slug, owner_id, profile_photo FROM sites WHERE owner_id = ? ORDER BY created_at LIMIT 1').get(user.id);
|
|---|
| [1be21ba] | 100 | if (!site && user.role === 'god') {
|
|---|
| [834bcc3] | 101 | site = getPrimarySite(); // primary/main site as fallback
|
|---|
| [1be21ba] | 102 | }
|
|---|
| 103 | return site || null;
|
|---|
| 104 | }
|
|---|
| 105 |
|
|---|
| 106 | // ==================== UPDATE SITE-NAAM (eigenaar) ====================
|
|---|
| 107 | router.post('/site', requireAuth, (req, res) => {
|
|---|
| 108 | const site = ownedSite(req.session.user);
|
|---|
| 109 | if (!site) return res.redirect('/account?error=' + encodeURIComponent('Geen site om te bewerken.'));
|
|---|
| 110 | if (site.owner_id !== req.session.user.id && req.session.user.role !== 'god') {
|
|---|
| 111 | return res.redirect('/account?error=' + encodeURIComponent('Geen rechten om deze site te bewerken.'));
|
|---|
| 112 | }
|
|---|
| 113 | const title = (req.body.site_title || '').toString().slice(0, 200).trim();
|
|---|
| 114 | if (!title) return res.redirect('/account?error=' + encodeURIComponent('Site-naam mag niet leeg zijn.'));
|
|---|
| 115 | const tagline = (req.body.site_tagline || '').toString().slice(0, 200).trim();
|
|---|
| 116 | db.prepare('UPDATE sites SET title = ?, tagline = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
|---|
| 117 | .run(title, tagline || null, site.id);
|
|---|
| 118 | res.redirect('/account?success=' + encodeURIComponent('Site-naam bijgewerkt'));
|
|---|
| 119 | });
|
|---|
| 120 |
|
|---|
| [7bc636b] | 121 | // ==================== UPDATE BIO ====================
|
|---|
| [7c44c12] | 122 | const RESERVED_USERNAMES = new Set(['admin', 'account', 'auth', 'login', 'register', 'logout', 'user', 'users', 'api', 'fediverse', 'posts', 'media', 'audio', 'assets', 'cirkel', 'authorize_interaction']);
|
|---|
| 123 |
|
|---|
| [7bc636b] | 124 | router.post('/profile', requireAuth, (req, res) => {
|
|---|
| 125 | const bio = (req.body.bio || '').toString().slice(0, 500).trim();
|
|---|
| [6d882b1] | 126 |
|
|---|
| [7c44c12] | 127 | // Username (login + display name; does NOT affect the fediverse handle, which
|
|---|
| 128 | // is the site slug). Validate: format + reserved + unique (case-insensitive).
|
|---|
| 129 | const username = (req.body.username || '').toString().trim();
|
|---|
| 130 | if (username && username !== req.session.user.username) {
|
|---|
| 131 | if (!/^[A-Za-z0-9_-]{2,30}$/.test(username)) {
|
|---|
| 132 | return res.redirect('/account?error=' + encodeURIComponent('Gebruikersnaam: 2-30 tekens; letters, cijfers, _ en - .'));
|
|---|
| 133 | }
|
|---|
| 134 | if (RESERVED_USERNAMES.has(username.toLowerCase())) {
|
|---|
| 135 | return res.redirect('/account?error=' + encodeURIComponent('Die gebruikersnaam is gereserveerd.'));
|
|---|
| 136 | }
|
|---|
| 137 | const uTaken = db.prepare('SELECT 1 FROM users WHERE LOWER(username) = LOWER(?) AND id != ?').get(username, req.session.user.id);
|
|---|
| 138 | if (uTaken) {
|
|---|
| 139 | return res.redirect('/account?error=' + encodeURIComponent('Die gebruikersnaam is al in gebruik.'));
|
|---|
| 140 | }
|
|---|
| 141 | db.prepare('UPDATE users SET username = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(username, req.session.user.id);
|
|---|
| 142 | req.session.user.username = username;
|
|---|
| 143 | }
|
|---|
| 144 |
|
|---|
| [834bcc3] | 145 | // Email (optionally also changed). Validation: valid format + not already in use
|
|---|
| 146 | // by another account. Email is the login/reset anchor, so it must be unique.
|
|---|
| [6d882b1] | 147 | const email = (req.body.email || '').toString().trim();
|
|---|
| 148 | if (email) {
|
|---|
| 149 | if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {
|
|---|
| 150 | return res.redirect('/account?error=' + encodeURIComponent('Voer een geldig e-mailadres in.'));
|
|---|
| 151 | }
|
|---|
| 152 | const taken = db.prepare('SELECT 1 FROM users WHERE LOWER(email) = LOWER(?) AND id != ?')
|
|---|
| 153 | .get(email, req.session.user.id);
|
|---|
| 154 | if (taken) {
|
|---|
| 155 | return res.redirect('/account?error=' + encodeURIComponent('Dit e-mailadres is al in gebruik.'));
|
|---|
| 156 | }
|
|---|
| 157 | db.prepare('UPDATE users SET email = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
|---|
| 158 | .run(email, req.session.user.id);
|
|---|
| [834bcc3] | 159 | req.session.user.email = email; // update session so the UI reflects the change
|
|---|
| [6d882b1] | 160 | }
|
|---|
| 161 |
|
|---|
| [7bc636b] | 162 | db.prepare('UPDATE users SET bio = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
|---|
| 163 | .run(bio || null, req.session.user.id);
|
|---|
| [6d882b1] | 164 | res.redirect('/account?success=' + encodeURIComponent('Profiel bijgewerkt'));
|
|---|
| [7bc636b] | 165 | });
|
|---|
| 166 |
|
|---|
| 167 | // P57 — /preferences route removed. Per-user theme/palette was a multi-tenant
|
|---|
| 168 | // holdover that conflicts with the site-default model: visitors should see
|
|---|
| 169 | // the site's appearance, not whatever a user once picked. The users.theme and
|
|---|
| 170 | // users.palette columns stay in the schema (no migration needed) but are no
|
|---|
| 171 | // longer read or written.
|
|---|
| 172 |
|
|---|
| [9e27d64] | 173 | // ==================== CHANGE PASSWORD ====================
|
|---|
| 174 | router.post('/password', requireAuth, (req, res) => {
|
|---|
| 175 | const { current, new_password, confirm } = req.body;
|
|---|
| 176 | if (!current || !new_password || !confirm) {
|
|---|
| 177 | return res.redirect('/account?error=' + encodeURIComponent('Alle wachtwoordvelden zijn verplicht'));
|
|---|
| 178 | }
|
|---|
| 179 | if (new_password.length < 8) {
|
|---|
| 180 | return res.redirect('/account?error=' + encodeURIComponent('Nieuw wachtwoord moet minstens 8 tekens zijn'));
|
|---|
| 181 | }
|
|---|
| 182 | if (new_password !== confirm) {
|
|---|
| 183 | return res.redirect('/account?error=' + encodeURIComponent('Nieuwe wachtwoorden komen niet overeen'));
|
|---|
| 184 | }
|
|---|
| 185 |
|
|---|
| 186 | const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.session.user.id);
|
|---|
| [834bcc3] | 187 | // Google-only accounts (listeners) have no real password.
|
|---|
| [9e27d64] | 188 | if (!row || !row.password_hash || row.password_hash === '!google-oauth') {
|
|---|
| 189 | return res.redirect('/account?error=' + encodeURIComponent('Dit account heeft geen wachtwoord (Google-login)'));
|
|---|
| 190 | }
|
|---|
| 191 | if (!bcrypt.compareSync(current, row.password_hash)) {
|
|---|
| 192 | return res.redirect('/account?error=' + encodeURIComponent('Huidig wachtwoord is onjuist'));
|
|---|
| 193 | }
|
|---|
| 194 |
|
|---|
| 195 | const newHash = bcrypt.hashSync(new_password, 10);
|
|---|
| 196 | db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
|---|
| 197 | .run(newHash, req.session.user.id);
|
|---|
| 198 |
|
|---|
| 199 | res.redirect('/account?success=' + encodeURIComponent('Wachtwoord gewijzigd'));
|
|---|
| 200 | });
|
|---|
| [7bc636b] | 201 |
|
|---|
| 202 | // ==================== UPLOAD AVATAR ====================
|
|---|
| 203 | router.post('/avatar', requireAuth, (req, res) => {
|
|---|
| 204 | avatarUpload.single('avatar')(req, res, (err) => {
|
|---|
| 205 | if (err) {
|
|---|
| 206 | return res.redirect('/account?error=' + encodeURIComponent(err.message));
|
|---|
| 207 | }
|
|---|
| 208 | if (!req.file) {
|
|---|
| 209 | return res.redirect('/account?error=' + encodeURIComponent('No file uploaded'));
|
|---|
| 210 | }
|
|---|
| 211 |
|
|---|
| [8f6225c] | 212 | const url = `/media/avatars/${toWebp(req.file)}`;
|
|---|
| [7bc636b] | 213 |
|
|---|
| 214 | // Remove the old avatar file (if it lives in our avatar dir)
|
|---|
| 215 | const old = db.prepare('SELECT avatar_url FROM users WHERE id = ?').get(req.session.user.id)?.avatar_url;
|
|---|
| 216 | if (old && old.startsWith('/media/avatars/')) {
|
|---|
| 217 | const oldPath = path.join(AVATAR_DIR, path.basename(old));
|
|---|
| 218 | try { fs.unlinkSync(oldPath); } catch {}
|
|---|
| 219 | }
|
|---|
| 220 |
|
|---|
| 221 | db.prepare('UPDATE users SET avatar_url = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
|---|
| 222 | .run(url, req.session.user.id);
|
|---|
| 223 | req.session.user.avatar_url = url;
|
|---|
| 224 |
|
|---|
| 225 | res.redirect('/account?success=' + encodeURIComponent('Avatar updated'));
|
|---|
| 226 | });
|
|---|
| 227 | });
|
|---|
| 228 |
|
|---|
| 229 | // ==================== REMOVE AVATAR ====================
|
|---|
| 230 | router.post('/avatar/remove', requireAuth, (req, res) => {
|
|---|
| 231 | const old = db.prepare('SELECT avatar_url FROM users WHERE id = ?').get(req.session.user.id)?.avatar_url;
|
|---|
| 232 | if (old && old.startsWith('/media/avatars/')) {
|
|---|
| 233 | const oldPath = path.join(AVATAR_DIR, path.basename(old));
|
|---|
| 234 | try { fs.unlinkSync(oldPath); } catch {}
|
|---|
| 235 | }
|
|---|
| 236 | db.prepare('UPDATE users SET avatar_url = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
|---|
| 237 | .run(req.session.user.id);
|
|---|
| 238 | req.session.user.avatar_url = null;
|
|---|
| 239 | res.redirect('/account?success=' + encodeURIComponent('Avatar removed'));
|
|---|
| 240 | });
|
|---|
| 241 |
|
|---|
| 242 | export default router;
|
|---|