source: Klonkt/src/routes/account.js@ 746d98a

main
Last change on this file since 746d98a was 746d98a, checked in by Robin <roboburr@…>, 2 hours ago

Wardmodus: een besloten Klonkt met alleen het profiel openbaar

Robin, 30-9: "Ward Profile mode gaat actief wanneer ward gekoppeld is, dus
wanneer de persoon guardian(s) heeft. Een besloten Klonkt met enkel het profiel
van de ward publiek. Wij denken dat dit het beste valt bij emancipation om de
functie weer naar normaal Klonkt te brengen."

AFGELEID, NIET OPGESLAGEN. Heeft de site geaccepteerde guardians, dan staat de
modus aan; is de set leeg, dan uit. listGuardians telt alleen geaccepteerde
guardianships, dus een voorstel zet niets dicht, en de set leegmaken is
emancipatie (FEP-633c 3.4) -- precies waar het ontwerp de weg terug legt. Een
schakelaar zou een tweede waarheid naast de guardianship zijn.

STANDAARD DICHT (middleware/ward-profile.js, in server.js direct na de
siteresolver en de schrijfpoort). Een bezoeker ziet op / het profiel en verder
niets: een pagina gaat terug naar het profiel, al het andere is 404, een POST
bereikt zijn handler niet, en een htmx-navigatie krijgt HX-Redirect in plaats
van een brokstuk. Wat niet op de lijst staat komt er niet langs, zodat een
nieuwe route niet vanzelf een kind openzet.

Open blijft: inloggen, account, beheer, het guardianpaneel, /lang, de bestanden
van de pagina zelf, en /audio/stream -- die bewaakt elk bestand zelf, en een
track die de ward op de fediverse opende moet daar speelbaar blijven. De
federatie (ActivityPub, WebFinger, OAuth voor Shaer, /media, /assets, /og) hangt
al VOOR de poort; de paden staan toch op de lijst, voor als de volgorde ooit
omgaat, want dan zou een ward stil van de fediverse verdwijnen.

Wie alles ziet: de ward zelf, en god, kijker en sitebeheer. Een andere
ingelogde gebruiker is gewoon een bezoeker.

DE PROFIELPAGINA (pages/ward-profile.ejs) mag verzorgd, want dit is alles wat
een bezoeker ziet: foto met een ring in de accentkleur, de naam groot, de
handle op een pil, de bio, de links als brede knoppen in hun merkkleur, een
volgknop die de bestaande fediverse-dialoog opent (een volgverzoek aan een ward
gaat eerst langs de guardians), en een neutrale regel dat berichten alleen
voor volgers zijn. Nergens "kind" of "ward": dat de account guardians heeft
staat al openbaar op de fediverse. Dezelfde bronnen als de profielkop, zodat
het profiel op geen twee plekken iets anders kan zeggen. De chrome biedt een
bezoeker niets aan wat dicht is: geen zoekknop, geen archief, geen
Lezen/Grid-schakelaar.

DE PROFIELLINKS verhuizen van Appearance naar het account (/account#links),
bij naam, bio en foto. Twee vallen onderweg, allebei getoetst:

  • Appearance schreef profile_links altijd, uit de formuliervelden. Met de velden weg zou elke keer opslaan daar de links wissen. Die pagina schrijft ze niet meer.
  • ownedSite haalde profile_links niet op, dus de accountpagina toonde nul links en opslaan wiste ze. Gevonden bij het bekijken, niet door een toets; nu wel.

buildProfileLinks staat in PlatformIcons.js, en de rijen-knoppen in een eigen
module (mod/profile-links.js).

Getoetst: gewone Klonkt zonder guardians, een voorstel zet niets dicht,
gekoppeld alleen het profiel, pagina's terug, 404 voor wat geen pagina is, geen
handler voor een POST, HX-Redirect, wat open moet blijft open, ward en beheer
zien alles, een andere ingelogde is een bezoeker, emancipatie maakt het weer
gewoon, de links bij het account en Appearance wist ze niet. Tegenproef per
zaak. Volledige suite 1325 groen.

Nagekeken in de browser op 375 en 1280 pixels: het profiel past op een
telefoonscherm, knoppen van 55 pixels, geen overloop, de volgknop opent de
dialoog; de accountpagina toont de bestaande links en voegt er een toe.

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

  • Property mode set to 100644
File size: 12.8 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 { listPlatforms, buildProfileLinks, parseProfileLinks } from '../services/PlatformIcons.js';
17import path from 'path';
18import fs from 'fs';
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 OAuth from '../services/OAuthService.js';
26import { t } from '../services/i18n.js';
27import { requireAuth } from '../middleware/auth.js';
28import { toWebp } from '../services/ImageWebpService.js';
29import { SUPPORTED } from '../services/i18n.js';
30import { mediaDir } from '../config/paths.js';
31
32const AVATAR_DIR = mediaDir('AVATAR_PATH', 'avatars');
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, lang
63 FROM users WHERE id = ?
64 `).get(req.session.user.id);
65 const hasPassword = !!(account && account.password_hash && account.password_hash !== '!google-oauth');
66 if (account) { delete account.password_hash; delete account.google_sub; } // don't leak to the view
67
68 const editableSite = ownedSite(req.session.user);
69 renderPage(req, res, 'pages/account', {
70 pageTitle: 'Account',
71 bodyClass: 'on-special',
72 // De profiellinks staan sinds 30-9 hier, bij je profiel, en niet meer op de
73 // Appearance-pagina (Robins verzoek).
74 pageJs: 'profile-links',
75 platforms: listPlatforms(),
76 profileLinks: editableSite ? parseProfileLinks(editableSite.profile_links) : [],
77 account,
78 hasPassword,
79 editableSite,
80 // Display fallback: when you have no own account avatar, show your site's photo.
81 siteAvatar: editableSite ? editableSite.profile_photo : null,
82 // OAuth apps (C2S) this user has authorized, so they can revoke them here.
83 authorizations: OAuth.listAuthorizations(req.session.user.id),
84 success: req.query.success || null,
85 error: req.query.error || null,
86 });
87});
88
89// ==================== REVOKE AN OAUTH APP AUTHORIZATION ====================
90router.post('/oauth/revoke', requireAuth, (req, res) => {
91 const lang = req.session.lang || (req.session.user && req.session.user.lang) || 'nl';
92 const ok = OAuth.revokeAuthorization(req.session.user.id, req.body.token_hash);
93 const msg = ok ? t(lang, 'acct.oauth_revoked') : t(lang, 'acct.oauth_revoke_none');
94 res.redirect('/account?' + (ok ? 'success' : 'error') + '=' + encodeURIComponent(msg));
95});
96
97// ==================== PERSONAL INTERFACE LANGUAGE ====================
98// Saves the language choice on the account (persists across devices/sessions) and
99// also sets it in the session immediately so it takes effect right away.
100router.post('/lang', requireAuth, (req, res) => {
101 const code = SUPPORTED.includes(req.body.lang) ? req.body.lang : null;
102 if (code) {
103 db.prepare('UPDATE users SET lang = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(code, req.session.user.id);
104 req.session.user.lang = code;
105 req.session.lang = code;
106 }
107 res.redirect('/account?success=' + encodeURIComponent('Taal opgeslagen'));
108});
109
110// The site this user may edit from their account: their own site
111// (owner_id), or for a god the primary site. Null if nothing found.
112function ownedSite(user) {
113 if (!user) return null;
114 // profile_links hoort erbij sinds de links hier staan (30-9). Zonder die kolom
115 // toonde de pagina nul links, en wiste opslaan ze dus.
116 let site = db.prepare('SELECT id, title, tagline, slug, owner_id, profile_photo, profile_links FROM sites WHERE owner_id = ? ORDER BY created_at LIMIT 1').get(user.id);
117 if (!site && user.role === 'god') {
118 site = getPrimarySite(); // primary/main site as fallback
119 }
120 return site || null;
121}
122
123// ==================== UPDATE SITE-NAAM (eigenaar) ====================
124router.post('/site', requireAuth, (req, res) => {
125 const site = ownedSite(req.session.user);
126 if (!site) return res.redirect('/account?error=' + encodeURIComponent('Geen site om te bewerken.'));
127 if (site.owner_id !== req.session.user.id && req.session.user.role !== 'god') {
128 return res.redirect('/account?error=' + encodeURIComponent('Geen rechten om deze site te bewerken.'));
129 }
130 const title = (req.body.site_title || '').toString().slice(0, 200).trim();
131 if (!title) return res.redirect('/account?error=' + encodeURIComponent('Site-naam mag niet leeg zijn.'));
132 const tagline = (req.body.site_tagline || '').toString().slice(0, 200).trim();
133 db.prepare('UPDATE sites SET title = ?, tagline = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
134 .run(title, tagline || null, site.id);
135 res.redirect('/account?success=' + encodeURIComponent('Site-naam bijgewerkt'));
136});
137
138// ==================== PROFIELLINKS ====================
139// Sinds 30-9 hier en niet meer op Appearance: ze horen bij je profiel, naast je
140// naam, bio en foto. Dezelfde rechten als de site-naam hierboven: de eigenaar
141// van de site, of god.
142router.post('/links', requireAuth, (req, res) => {
143 const site = ownedSite(req.session.user);
144 if (!site) return res.redirect('/account?error=' + encodeURIComponent('Geen site om te bewerken.'));
145 if (site.owner_id !== req.session.user.id && req.session.user.role !== 'god') {
146 return res.redirect('/account?error=' + encodeURIComponent('Geen rechten om deze site te bewerken.'));
147 }
148 db.prepare('UPDATE sites SET profile_links = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
149 .run(buildProfileLinks(req.body), site.id);
150 res.redirect('/account?success=' + encodeURIComponent('Links bijgewerkt') + '#links');
151});
152
153// ==================== UPDATE BIO ====================
154const RESERVED_USERNAMES = new Set(['admin', 'account', 'auth', 'login', 'register', 'logout', 'user', 'users', 'api', 'fediverse', 'posts', 'media', 'audio', 'assets', 'cirkel', 'authorize_interaction']);
155
156router.post('/profile', requireAuth, (req, res) => {
157 const bio = (req.body.bio || '').toString().slice(0, 500).trim();
158
159 // Username (login + display name; does NOT affect the fediverse handle, which
160 // is the site slug). Validate: format + reserved + unique (case-insensitive).
161 const username = (req.body.username || '').toString().trim();
162 if (username && username !== req.session.user.username) {
163 if (!/^[A-Za-z0-9_-]{2,30}$/.test(username)) {
164 return res.redirect('/account?error=' + encodeURIComponent('Gebruikersnaam: 2-30 tekens; letters, cijfers, _ en - .'));
165 }
166 if (RESERVED_USERNAMES.has(username.toLowerCase())) {
167 return res.redirect('/account?error=' + encodeURIComponent('Die gebruikersnaam is gereserveerd.'));
168 }
169 const uTaken = db.prepare('SELECT 1 FROM users WHERE LOWER(username) = LOWER(?) AND id != ?').get(username, req.session.user.id);
170 if (uTaken) {
171 return res.redirect('/account?error=' + encodeURIComponent('Die gebruikersnaam is al in gebruik.'));
172 }
173 db.prepare('UPDATE users SET username = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(username, req.session.user.id);
174 req.session.user.username = username;
175 }
176
177 // Email (optionally also changed). Validation: valid format + not already in use
178 // by another account. Email is the login/reset anchor, so it must be unique.
179 const email = (req.body.email || '').toString().trim();
180 if (email) {
181 if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {
182 return res.redirect('/account?error=' + encodeURIComponent('Voer een geldig e-mailadres in.'));
183 }
184 const taken = db.prepare('SELECT 1 FROM users WHERE LOWER(email) = LOWER(?) AND id != ?')
185 .get(email, req.session.user.id);
186 if (taken) {
187 return res.redirect('/account?error=' + encodeURIComponent('Dit e-mailadres is al in gebruik.'));
188 }
189 db.prepare('UPDATE users SET email = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
190 .run(email, req.session.user.id);
191 req.session.user.email = email; // update session so the UI reflects the change
192 }
193
194 db.prepare('UPDATE users SET bio = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
195 .run(bio || null, req.session.user.id);
196 res.redirect('/account?success=' + encodeURIComponent('Profiel bijgewerkt'));
197});
198
199// P57 — /preferences route removed. Per-user theme/palette was a multi-tenant
200// holdover that conflicts with the site-default model: visitors should see
201// the site's appearance, not whatever a user once picked. The users.theme and
202// users.palette columns stay in the schema (no migration needed) but are no
203// longer read or written.
204
205// ==================== CHANGE PASSWORD ====================
206router.post('/password', requireAuth, (req, res) => {
207 const { current, new_password, confirm } = req.body;
208 if (!current || !new_password || !confirm) {
209 return res.redirect('/account?error=' + encodeURIComponent('Alle wachtwoordvelden zijn verplicht'));
210 }
211 if (new_password.length < 8) {
212 return res.redirect('/account?error=' + encodeURIComponent('Nieuw wachtwoord moet minstens 8 tekens zijn'));
213 }
214 if (new_password !== confirm) {
215 return res.redirect('/account?error=' + encodeURIComponent('Nieuwe wachtwoorden komen niet overeen'));
216 }
217
218 const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.session.user.id);
219 // Google-only accounts (listeners) have no real password.
220 if (!row || !row.password_hash || row.password_hash === '!google-oauth') {
221 return res.redirect('/account?error=' + encodeURIComponent('Dit account heeft geen wachtwoord (Google-login)'));
222 }
223 if (!bcrypt.compareSync(current, row.password_hash)) {
224 return res.redirect('/account?error=' + encodeURIComponent('Huidig wachtwoord is onjuist'));
225 }
226
227 const newHash = bcrypt.hashSync(new_password, 10);
228 db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
229 .run(newHash, req.session.user.id);
230
231 res.redirect('/account?success=' + encodeURIComponent('Wachtwoord gewijzigd'));
232});
233
234// ==================== UPLOAD AVATAR ====================
235router.post('/avatar', requireAuth, (req, res) => {
236 avatarUpload.single('avatar')(req, res, (err) => {
237 if (err) {
238 return res.redirect('/account?error=' + encodeURIComponent(err.message));
239 }
240 if (!req.file) {
241 return res.redirect('/account?error=' + encodeURIComponent('No file uploaded'));
242 }
243
244 const url = `/media/avatars/${toWebp(req.file)}`;
245
246 // Remove the old avatar file (if it lives in our avatar dir)
247 const old = db.prepare('SELECT avatar_url FROM users WHERE id = ?').get(req.session.user.id)?.avatar_url;
248 if (old && old.startsWith('/media/avatars/')) {
249 const oldPath = path.join(AVATAR_DIR, path.basename(old));
250 try { fs.unlinkSync(oldPath); } catch {}
251 }
252
253 db.prepare('UPDATE users SET avatar_url = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
254 .run(url, req.session.user.id);
255 req.session.user.avatar_url = url;
256
257 res.redirect('/account?success=' + encodeURIComponent('Avatar updated'));
258 });
259});
260
261// ==================== REMOVE AVATAR ====================
262router.post('/avatar/remove', requireAuth, (req, res) => {
263 const old = db.prepare('SELECT avatar_url FROM users WHERE id = ?').get(req.session.user.id)?.avatar_url;
264 if (old && old.startsWith('/media/avatars/')) {
265 const oldPath = path.join(AVATAR_DIR, path.basename(old));
266 try { fs.unlinkSync(oldPath); } catch {}
267 }
268 db.prepare('UPDATE users SET avatar_url = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
269 .run(req.session.user.id);
270 req.session.user.avatar_url = null;
271 res.redirect('/account?success=' + encodeURIComponent('Avatar removed'));
272});
273
274export default router;
Note: See TracBrowser for help on using the repository browser.