source: Klonkt/src/routes/account.js@ 7f46112b

main
Last change on this file since 7f46112b was 82079ad, checked in by Robin Genis <roboburr@…>, 3 months ago

feat: one identity — user avatar falls back to the site photo everywhere

When a user has no own account avatar, display their site's profile photo across
the account page, top nav (render.js) and comments. No DB copy → stays in sync.

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

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