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

main
Last change on this file since ef519a3 was 2d66d66, checked in by Robin <roboburr@…>, 7 weeks ago

Feature: revoke connected OAuth apps from the account page

You issue C2S bearer tokens (Shaer, etc.) but had no way to see or revoke them.
The account page now has a 'Connected apps' section listing every authorization
(app name via the client join, site, scope, last used) with a Revoke button.
Already-issued tokens appear because they were always stored (hashed) with the
user/client/site; the bearer is never kept, so revocation is keyed on the safe
token_hash and scoped to the owner (you cannot revoke someone else's).

  • OAuthService.listAuthorizations(userId) / revokeAuthorization(userId, hash).
  • account.js: authorizations passed to the page; POST /account/oauth/revoke.
  • account.ejs: the section + styles; i18n NL/EN/DE. Visible to viewers too (revoking your own app access is a safety action).

83 tests green. Live-verified: two apps listed on /account, revoke one -> it is
gone and the other stays, token count drops in the DB.

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

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