source: Klonkt/src/routes/account.js@ 9e27d64

main
Last change on this file since 9e27d64 was 9e27d64, checked in by roboburr <roboburr@…>, 3 months ago

auth: password admin + per-instance Google for listeners (no broker)

Robin's choice: every self-hoster has their own password admin account,
and can optionally let listeners log in to comment using their OWN Google
client. No central broker (that would tie every customer site to Robin's
Google Cloud -> systemic risk on abuse).

  • Admin = username/password (bcrypt). First-time setup via /auth/register (only when there are 0 users); closed afterwards. No public registration.
  • Forgot password: /auth/reset-request -> email (if SMTP configured) with reset link; CLI break-glass npm run reset-admin always works (no email needed).
  • Change password (logged in) restored in /account.
  • Google = per-instance own credentials, OPTIONAL, listeners only -> always role member, never admin (god/admin email is rejected; google_sub mismatch too).
  • config/google.js back to direct Google OAuth; config/mailer.js new (nodemailer).
  • jose removed from deps; nodemailer added.

Security review (workflow) incorporated:

  • Reset token no longer in production logs (dev only).
  • Reset link from PUBLIC_BASE_URL instead of X-Forwarded-Host (host poisoning).
  • Reset tokens stored SHA-256-hashed in the DB.
  • Same-origin check on all state-modifying POSTs (CSRF layer on top of sameSite-lax).
  • Login always runs one bcrypt comparison (no timing enumeration).

Co-Authored-By: Claude <noreply@…>

  • Property mode set to 100644
File size: 6.2 KB
Line 
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
15import express from 'express';
16import path from 'path';
17import fs from 'fs';
18import { fileURLToPath } from 'url';
19import bcrypt from 'bcryptjs';
20import multer from 'multer';
21import { v4 as uuid } from 'uuid';
22import db from '../config/database.js';
23import { renderPage } from '../middleware/render.js';
24import { requireAuth } from '../middleware/auth.js';
25
26const __dirname = path.dirname(fileURLToPath(import.meta.url));
27const AVATAR_DIR = path.resolve(
28 process.env.AVATAR_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'avatars')
29);
30fs.mkdirSync(AVATAR_DIR, { recursive: true });
31
32const ALLOWED_AVATAR_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
33const MAX_AVATAR_BYTES = 5 * 1024 * 1024;
34
35const avatarStorage = multer.diskStorage({
36 destination: (req, file, cb) => cb(null, AVATAR_DIR),
37 filename: (req, file, cb) => {
38 const ext = path.extname(file.originalname).toLowerCase();
39 cb(null, `${uuid()}${ext}`);
40 },
41});
42const avatarUpload = multer({
43 storage: avatarStorage,
44 limits: { fileSize: MAX_AVATAR_BYTES },
45 fileFilter: (req, file, cb) => {
46 const ext = path.extname(file.originalname).toLowerCase();
47 if (!ALLOWED_AVATAR_EXT.has(ext)) {
48 return cb(new Error('Avatar must be jpg/png/webp/gif'));
49 }
50 cb(null, true);
51 },
52});
53
54const router = express.Router();
55
56// ==================== GET account page ====================
57router.get('/', requireAuth, (req, res) => {
58 const account = db.prepare(`
59 SELECT id, username, email, role, bio, avatar_url, created_at, password_hash
60 FROM users WHERE id = ?
61 `).get(req.session.user.id);
62 const hasPassword = !!(account && account.password_hash && account.password_hash !== '!google-oauth');
63 if (account) delete account.password_hash; // niet naar de view lekken
64
65 renderPage(req, res, 'pages/account', {
66 pageTitle: 'Account',
67 bodyClass: 'on-special',
68 account,
69 hasPassword,
70 success: req.query.success || null,
71 error: req.query.error || null,
72 });
73});
74
75// ==================== UPDATE BIO ====================
76router.post('/profile', requireAuth, (req, res) => {
77 const bio = (req.body.bio || '').toString().slice(0, 500).trim();
78 db.prepare('UPDATE users SET bio = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
79 .run(bio || null, req.session.user.id);
80 res.redirect('/account?success=' + encodeURIComponent('Profile updated'));
81});
82
83// P57 — /preferences route removed. Per-user theme/palette was a multi-tenant
84// holdover that conflicts with the site-default model: visitors should see
85// the site's appearance, not whatever a user once picked. The users.theme and
86// users.palette columns stay in the schema (no migration needed) but are no
87// longer read or written.
88
89// ==================== CHANGE PASSWORD ====================
90router.post('/password', requireAuth, (req, res) => {
91 const { current, new_password, confirm } = req.body;
92 if (!current || !new_password || !confirm) {
93 return res.redirect('/account?error=' + encodeURIComponent('Alle wachtwoordvelden zijn verplicht'));
94 }
95 if (new_password.length < 8) {
96 return res.redirect('/account?error=' + encodeURIComponent('Nieuw wachtwoord moet minstens 8 tekens zijn'));
97 }
98 if (new_password !== confirm) {
99 return res.redirect('/account?error=' + encodeURIComponent('Nieuwe wachtwoorden komen niet overeen'));
100 }
101
102 const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.session.user.id);
103 // Google-only accounts (luisteraars) hebben geen echt wachtwoord.
104 if (!row || !row.password_hash || row.password_hash === '!google-oauth') {
105 return res.redirect('/account?error=' + encodeURIComponent('Dit account heeft geen wachtwoord (Google-login)'));
106 }
107 if (!bcrypt.compareSync(current, row.password_hash)) {
108 return res.redirect('/account?error=' + encodeURIComponent('Huidig wachtwoord is onjuist'));
109 }
110
111 const newHash = bcrypt.hashSync(new_password, 10);
112 db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
113 .run(newHash, req.session.user.id);
114
115 res.redirect('/account?success=' + encodeURIComponent('Wachtwoord gewijzigd'));
116});
117
118// ==================== UPLOAD AVATAR ====================
119router.post('/avatar', requireAuth, (req, res) => {
120 avatarUpload.single('avatar')(req, res, (err) => {
121 if (err) {
122 return res.redirect('/account?error=' + encodeURIComponent(err.message));
123 }
124 if (!req.file) {
125 return res.redirect('/account?error=' + encodeURIComponent('No file uploaded'));
126 }
127
128 const url = `/media/avatars/${req.file.filename}`;
129
130 // Remove the old avatar file (if it lives in our avatar dir)
131 const old = db.prepare('SELECT avatar_url FROM users WHERE id = ?').get(req.session.user.id)?.avatar_url;
132 if (old && old.startsWith('/media/avatars/')) {
133 const oldPath = path.join(AVATAR_DIR, path.basename(old));
134 try { fs.unlinkSync(oldPath); } catch {}
135 }
136
137 db.prepare('UPDATE users SET avatar_url = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
138 .run(url, req.session.user.id);
139 req.session.user.avatar_url = url;
140
141 res.redirect('/account?success=' + encodeURIComponent('Avatar updated'));
142 });
143});
144
145// ==================== REMOVE AVATAR ====================
146router.post('/avatar/remove', requireAuth, (req, res) => {
147 const old = db.prepare('SELECT avatar_url FROM users WHERE id = ?').get(req.session.user.id)?.avatar_url;
148 if (old && old.startsWith('/media/avatars/')) {
149 const oldPath = path.join(AVATAR_DIR, path.basename(old));
150 try { fs.unlinkSync(oldPath); } catch {}
151 }
152 db.prepare('UPDATE users SET avatar_url = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
153 .run(req.session.user.id);
154 req.session.user.avatar_url = null;
155 res.redirect('/account?success=' + encodeURIComponent('Avatar removed'));
156});
157
158export default router;
Note: See TracBrowser for help on using the repository browser.