source: Klonkt/src/routes/account.js@ cf7c2d9

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

feat(images): new uploads automatically converted to WebP

Shared ImageWebpService.toWebp() converts uploaded images via cwebp (q82)
and removes the original; cwebp absent/error → original preserved (graceful).
Wired up in all upload routes: post images/cover, avatar, site photo, hub
hero, audio cover. GIF stays GIF, already-webp skipped.

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

  • Property mode set to 100644
File size: 9.7 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';
28
29const __dirname = path.dirname(fileURLToPath(import.meta.url));
30const AVATAR_DIR = path.resolve(
31 process.env.AVATAR_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'avatars')
32);
33fs.mkdirSync(AVATAR_DIR, { recursive: true });
34
35const ALLOWED_AVATAR_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
36const MAX_AVATAR_BYTES = 5 * 1024 * 1024;
37
38const 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});
45const 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
57const router = express.Router();
58
59// ==================== GET account page ====================
60router.get('/', requireAuth, (req, res) => {
61 const account = db.prepare(`
62 SELECT id, username, email, role, bio, avatar_url, created_at, password_hash, google_sub
63 FROM users WHERE id = ?
64 `).get(req.session.user.id);
65 const hasPassword = !!(account && account.password_hash && account.password_hash !== '!google-oauth');
66 const googleLinked = !!(account && account.google_sub);
67 if (account) { delete account.password_hash; delete account.google_sub; } // niet naar de view lekken
68
69 renderPage(req, res, 'pages/account', {
70 pageTitle: 'Account',
71 bodyClass: 'on-special',
72 account,
73 hasPassword,
74 googleLinked,
75 googleAvailable: googleConfigured(),
76 editableSite: ownedSite(req.session.user),
77 success: req.query.success || null,
78 error: req.query.error || null,
79 });
80});
81
82// De site die deze gebruiker mag bewerken vanuit z'n account: z'n eigen site
83// (owner_id), of voor een god de primaire site. Null als er niets is.
84function ownedSite(user) {
85 if (!user) return null;
86 let site = db.prepare('SELECT id, title, tagline, slug, owner_id FROM sites WHERE owner_id = ? ORDER BY created_at LIMIT 1').get(user.id);
87 if (!site && user.role === 'god') {
88 site = getPrimarySite(); // primaire/hoofd-site als fallback
89 }
90 return site || null;
91}
92
93// ==================== UPDATE SITE-NAAM (eigenaar) ====================
94router.post('/site', requireAuth, (req, res) => {
95 const site = ownedSite(req.session.user);
96 if (!site) return res.redirect('/account?error=' + encodeURIComponent('Geen site om te bewerken.'));
97 if (site.owner_id !== req.session.user.id && req.session.user.role !== 'god') {
98 return res.redirect('/account?error=' + encodeURIComponent('Geen rechten om deze site te bewerken.'));
99 }
100 const title = (req.body.site_title || '').toString().slice(0, 200).trim();
101 if (!title) return res.redirect('/account?error=' + encodeURIComponent('Site-naam mag niet leeg zijn.'));
102 const tagline = (req.body.site_tagline || '').toString().slice(0, 200).trim();
103 db.prepare('UPDATE sites SET title = ?, tagline = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
104 .run(title, tagline || null, site.id);
105 res.redirect('/account?success=' + encodeURIComponent('Site-naam bijgewerkt'));
106});
107
108// ==================== UPDATE BIO ====================
109router.post('/profile', requireAuth, (req, res) => {
110 const bio = (req.body.bio || '').toString().slice(0, 500).trim();
111
112 // E-mail (optioneel mee te wijzigen). Validatie: geldig formaat + niet al door
113 // een ander account in gebruik. E-mail is het login-/reset-anker, dus uniek.
114 const email = (req.body.email || '').toString().trim();
115 if (email) {
116 if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {
117 return res.redirect('/account?error=' + encodeURIComponent('Voer een geldig e-mailadres in.'));
118 }
119 const taken = db.prepare('SELECT 1 FROM users WHERE LOWER(email) = LOWER(?) AND id != ?')
120 .get(email, req.session.user.id);
121 if (taken) {
122 return res.redirect('/account?error=' + encodeURIComponent('Dit e-mailadres is al in gebruik.'));
123 }
124 db.prepare('UPDATE users SET email = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
125 .run(email, req.session.user.id);
126 req.session.user.email = email; // sessie bijwerken zodat de UI klopt
127 }
128
129 db.prepare('UPDATE users SET bio = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
130 .run(bio || null, req.session.user.id);
131 res.redirect('/account?success=' + encodeURIComponent('Profiel bijgewerkt'));
132});
133
134// P57 — /preferences route removed. Per-user theme/palette was a multi-tenant
135// holdover that conflicts with the site-default model: visitors should see
136// the site's appearance, not whatever a user once picked. The users.theme and
137// users.palette columns stay in the schema (no migration needed) but are no
138// longer read or written.
139
140// ==================== CHANGE PASSWORD ====================
141router.post('/password', requireAuth, (req, res) => {
142 const { current, new_password, confirm } = req.body;
143 if (!current || !new_password || !confirm) {
144 return res.redirect('/account?error=' + encodeURIComponent('Alle wachtwoordvelden zijn verplicht'));
145 }
146 if (new_password.length < 8) {
147 return res.redirect('/account?error=' + encodeURIComponent('Nieuw wachtwoord moet minstens 8 tekens zijn'));
148 }
149 if (new_password !== confirm) {
150 return res.redirect('/account?error=' + encodeURIComponent('Nieuwe wachtwoorden komen niet overeen'));
151 }
152
153 const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.session.user.id);
154 // Google-only accounts (luisteraars) hebben geen echt wachtwoord.
155 if (!row || !row.password_hash || row.password_hash === '!google-oauth') {
156 return res.redirect('/account?error=' + encodeURIComponent('Dit account heeft geen wachtwoord (Google-login)'));
157 }
158 if (!bcrypt.compareSync(current, row.password_hash)) {
159 return res.redirect('/account?error=' + encodeURIComponent('Huidig wachtwoord is onjuist'));
160 }
161
162 const newHash = bcrypt.hashSync(new_password, 10);
163 db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
164 .run(newHash, req.session.user.id);
165
166 res.redirect('/account?success=' + encodeURIComponent('Wachtwoord gewijzigd'));
167});
168
169// Google-account ontkoppelen. Alleen toegestaan als er nog een wachtwoord is,
170// anders zou je jezelf buitensluiten (geen login-methode meer over).
171router.post('/google/unlink', requireAuth, (req, res) => {
172 const row = db.prepare('SELECT password_hash, google_sub FROM users WHERE id = ?').get(req.session.user.id);
173 if (!row || !row.google_sub) {
174 return res.redirect('/account?error=' + encodeURIComponent('Er is geen Google-account gekoppeld'));
175 }
176 if (!row.password_hash || row.password_hash === '!google-oauth') {
177 return res.redirect('/account?error=' + encodeURIComponent('Stel eerst een wachtwoord in — anders kun je niet meer inloggen.'));
178 }
179 db.prepare('UPDATE users SET google_sub = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(req.session.user.id);
180 res.redirect('/account?success=' + encodeURIComponent('Google-account ontkoppeld'));
181});
182
183// ==================== UPLOAD AVATAR ====================
184router.post('/avatar', requireAuth, (req, res) => {
185 avatarUpload.single('avatar')(req, res, (err) => {
186 if (err) {
187 return res.redirect('/account?error=' + encodeURIComponent(err.message));
188 }
189 if (!req.file) {
190 return res.redirect('/account?error=' + encodeURIComponent('No file uploaded'));
191 }
192
193 const url = `/media/avatars/${toWebp(req.file)}`;
194
195 // Remove the old avatar file (if it lives in our avatar dir)
196 const old = db.prepare('SELECT avatar_url FROM users WHERE id = ?').get(req.session.user.id)?.avatar_url;
197 if (old && old.startsWith('/media/avatars/')) {
198 const oldPath = path.join(AVATAR_DIR, path.basename(old));
199 try { fs.unlinkSync(oldPath); } catch {}
200 }
201
202 db.prepare('UPDATE users SET avatar_url = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
203 .run(url, req.session.user.id);
204 req.session.user.avatar_url = url;
205
206 res.redirect('/account?success=' + encodeURIComponent('Avatar updated'));
207 });
208});
209
210// ==================== REMOVE AVATAR ====================
211router.post('/avatar/remove', requireAuth, (req, res) => {
212 const old = db.prepare('SELECT avatar_url FROM users WHERE id = ?').get(req.session.user.id)?.avatar_url;
213 if (old && old.startsWith('/media/avatars/')) {
214 const oldPath = path.join(AVATAR_DIR, path.basename(old));
215 try { fs.unlinkSync(oldPath); } catch {}
216 }
217 db.prepare('UPDATE users SET avatar_url = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
218 .run(req.session.user.id);
219 req.session.user.avatar_url = null;
220 res.redirect('/account?success=' + encodeURIComponent('Avatar removed'));
221});
222
223export default router;
Note: See TracBrowser for help on using the repository browser.