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

main
Last change on this file since a66266d was e2c3d09, checked in by Robin <roboburr@…>, 6 weeks ago

Media-submappen volgen nu MEDIA_PATH

De submappen voor avatars, post-images, reply-media, hero en audio-covers hadden
elk hun eigen env-variabele met een fallback naar <app>/storage/media/<sub>.
Daardoor negeerden ze MEDIA_PATH: wie zijn data buiten de checkout zette kreeg
alsnog een storage/-map in de work-tree, en uploads landden naast de code. Een
opruimstap bij een volgende deploy kan die vervolgens weggooien.

Nu leiden ze allemaal af van MEDIA_PATH via een gedeelde helper. Een eigen
override per submap wint nog steeds, dus bestaande installaties merken niets.
Dit maakt de scheiding van gebruikersdata en programmadata mogelijk met drie
regels in .env in plaats van acht.

Changed files:
src/routes/account.js

  • AVATAR_DIR via mediaDir(); dode dirname en fileURLToPath-import weg

src/routes/posts.js

  • POST_IMAGES_DIR en REPLY_MEDIA_DIR via mediaDir(); dode declaraties weg

src/routes/admin-media.js

  • POST_IMAGES_DIR en REPLY_MEDIA_DIR via mediaDir(); dode declaraties weg

src/routes/admin-settings.js

  • HERO_DIR via mediaDir(); dode declaraties weg

src/routes/admin-playlists.js

  • COVER_DIR via mediaDir(); dode dirname weg

src/routes/admin-audio.js

  • COVER_DIR via mediaDir(); AUDIO_DIR ongewijzigd (eigen wortel)

src/routes/admin-sites.js

  • PHOTO_DIR via mediaDir(), deelt bewust de avatars-map

src/routes/activitypub.js

  • AP_MEDIA_DIR via mediaDir(); ongebruikte fileURLToPath-import weg

New file:
src/config/paths.js

  • MEDIA_ROOT afgeleid van MEDIA_PATH
  • mediaDir(envVar, sub) voor submappen, met behoud van per-map overrides

DATABASE_PATH en AUDIO_PATH zijn eigen wortels en bewust ongemoeid gelaten.
Geverifieerd: 356 tests groen, en een server met externe MEDIA_PATH maakt al
zijn mappen buiten de checkout aan zonder de work-tree te raken.

-robo
Co-Authored-By: Claude Opus 5 <noreply@…>

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