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

main
Last change on this file since f0c3f83 was 075185a, checked in by Robin Genis <roboburr@…>, 3 months ago

refactor(auth): remove Google login (listeners now interact via the fediverse)

Removes config/google.js, the /auth/google* routes, the admin Google config page
+ links, the account link/unlink, and the public Google login button. /login and
/auth/admin both show the admin password form. SEO/Search-Console Google is
untouched. (Native comments removed next.)

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

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