Changeset c80e78b in Klonkt
- Timestamp:
- 06/14/2026 04:39:57 AM (3 months ago)
- Branches:
- main
- Children:
- ae924a2
- Parents:
- 89d6536
- Files:
-
- 1 added
- 8 edited
-
.env.example (modified) (1 diff)
-
src/config/database.js (modified) (1 diff)
-
src/config/google.js (added)
-
src/routes/account.js (modified) (2 diffs)
-
src/routes/auth.js (modified) (2 diffs)
-
src/views/pages/account.ejs (modified) (2 diffs)
-
src/views/pages/auth-login.ejs (modified) (1 diff)
-
src/views/pages/welcome.ejs (modified) (1 diff)
-
src/views/partials/shared-styles.ejs (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
.env.example
r89d6536 rc80e78b 4 4 DATABASE_PATH=./storage/database.sqlite 5 5 MEDIA_PATH=./storage/media 6 7 # ── Google login (OAuth2) ────────────────────────────────────────── 8 # Inloggen gaat volledig via Google. Maak een OAuth-client aan in de 9 # Google Cloud Console (APIs & Services -> Credentials -> OAuth client ID, 10 # type "Web application") en vul hieronder in. Zonder deze waarden boot de 11 # app gewoon, maar toont de loginpagina "nog niet geconfigureerd". 12 # 13 # GOOGLE_REDIRECT_URI moet EXACT overeenkomen met een "Authorized redirect 14 # URI" in de Google-client, en is per site het eigen domein + /auth/google/callback. 15 GOOGLE_CLIENT_ID= 16 GOOGLE_CLIENT_SECRET= 17 GOOGLE_REDIRECT_URI=https://example.com/auth/google/callback 18 19 # Het Google-e-mailadres dat owner/admin (god) is op deze instance. 20 # De eerste gebruiker die inlogt wordt sowieso god als de DB nog leeg is. 21 ADMIN_EMAIL= -
src/config/database.js
r89d6536 rc80e78b 39 39 ensureColumn('users', 'reset_token', 'TEXT'); 40 40 ensureColumn('users', 'reset_token_expires', 'DATETIME'); 41 // Google OAuth: koppel een Google-account aan een user (login via Google). 42 ensureColumn('users', 'google_sub', 'TEXT'); 41 43 // Site-level moderation toggle. 'trust' = auto-approve, 'moderate' = pending until reviewed. 42 44 ensureColumn('sites', 'comments_moderation_mode', "TEXT DEFAULT 'trust'"); -
src/routes/account.js
r89d6536 rc80e78b 17 17 import fs from 'fs'; 18 18 import { fileURLToPath } from 'url'; 19 import bcrypt from 'bcryptjs';20 19 import multer from 'multer'; 21 20 import { v4 as uuid } from 'uuid'; … … 84 83 // longer read or written. 85 84 86 // ==================== CHANGE PASSWORD ==================== 87 router.post('/password', requireAuth, (req, res) => { 88 const { current, new_password, confirm } = req.body; 89 if (!current || !new_password || !confirm) { 90 return res.redirect('/account?error=' + encodeURIComponent('All password fields required')); 91 } 92 if (new_password.length < 8) { 93 return res.redirect('/account?error=' + encodeURIComponent('New password must be at least 8 characters')); 94 } 95 if (new_password !== confirm) { 96 return res.redirect('/account?error=' + encodeURIComponent('New passwords do not match')); 97 } 98 99 const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.session.user.id); 100 if (!row || !bcrypt.compareSync(current, row.password_hash)) { 101 return res.redirect('/account?error=' + encodeURIComponent('Current password is incorrect')); 102 } 103 104 const newHash = bcrypt.hashSync(new_password, 10); 105 db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?') 106 .run(newHash, req.session.user.id); 107 108 res.redirect('/account?success=' + encodeURIComponent('Password changed')); 109 }); 85 // Wachtwoord-wijzigen is verwijderd: inloggen gaat sinds de Google-OAuth-migratie 86 // volledig via Google, er is geen wachtwoord meer om te wijzigen. 110 87 111 88 // ==================== UPLOAD AVATAR ==================== -
src/routes/auth.js
r89d6536 rc80e78b 1 1 import express from 'express'; 2 2 import crypto from 'crypto'; 3 import bcrypt from 'bcryptjs';4 3 import { v4 as uuid } from 'uuid'; 5 4 import db from '../config/database.js'; 6 5 import { renderPage } from '../middleware/render.js'; 7 import { loginLimiter, registerLimiter } from '../middleware/rate-limit.js';8 6 import { safeNext } from '../middleware/auth.js'; 7 import { googleConfigured, authorizeUrl, exchangeCode, fetchUserinfo } from '../config/google.js'; 9 8 10 9 const router = express.Router(); 10 const ADMIN_EMAIL = (process.env.ADMIN_EMAIL || '').trim().toLowerCase(); 11 11 12 // ==================== LOGIN ==================== 12 // ==================== LOGIN (alleen Google) ==================== 13 // Het oude username/wachtwoord-systeem is verwijderd; inloggen gaat via Google. 13 14 router.get('/login', (req, res) => { 14 15 const next = safeNext(req.query.next) || ''; 15 16 if (req.session.user) return res.redirect(next || '/'); 16 17 renderPage(req, res, 'pages/auth-login', { 17 pageTitle: ' Login',18 pageTitle: 'Inloggen', 18 19 bodyClass: 'on-special', 19 error: null, 20 success: req.query.success || null, 21 username: '', 20 googleReady: googleConfigured(), 21 error: req.query.error || null, 22 22 next, 23 23 }); 24 24 }); 25 25 26 router.post('/login', loginLimiter, (req, res) => { 27 const { username, password } = req.body; 28 const next = safeNext(req.body.next) || ''; 29 30 if (!username || !password) { 31 res.status(400); 32 return renderPage(req, res, 'pages/auth-login', { 33 pageTitle: 'Login', 34 bodyClass: 'on-special', 35 error: 'Username and password required', 36 username: username || '', 37 next, 38 }); 26 // Start de Google OAuth-flow. 27 router.get('/google', (req, res) => { 28 if (!googleConfigured()) { 29 return res.redirect('/auth/login?error=' + encodeURIComponent('Google-login is op deze site nog niet geconfigureerd.')); 39 30 } 40 41 const user = db.prepare( 42 'SELECT * FROM users WHERE username = ? OR email = ?' 43 ).get(username, username); 44 45 if (!user || !bcrypt.compareSync(password, user.password_hash)) { 46 res.status(401); 47 return renderPage(req, res, 'pages/auth-login', { 48 pageTitle: 'Login', 49 bodyClass: 'on-special', 50 error: 'Invalid credentials', 51 username: username, 52 next, 53 }); 54 } 55 56 req.session.user = { 57 id: user.id, 58 username: user.username, 59 email: user.email, 60 role: user.role, 61 avatar_url: user.avatar_url, 62 palette: user.palette, 63 theme: user.theme, 64 }; 65 66 res.redirect(next || '/'); 31 const next = safeNext(req.query.next) || ''; 32 const state = crypto.randomBytes(16).toString('hex'); 33 req.session.oauthState = state; 34 req.session.oauthNext = next; 35 res.redirect(authorizeUrl(state)); 67 36 }); 68 37 69 // ==================== REGISTER ==================== 70 router.get('/register', (req, res) => { 71 const next = safeNext(req.query.next) || ''; 72 if (req.session.user) return res.redirect(next || '/'); 73 renderPage(req, res, 'pages/auth-register', { 74 pageTitle: 'Register', 75 bodyClass: 'on-special', 76 error: null, 77 username: '', 78 email: '', 79 next, 80 }); 81 }); 38 // Leid een geldige, unieke username af uit naam/e-mail (schema vereist username). 39 function uniqueUsername(base) { 40 let u = String(base || 'luisteraar').toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 28); 41 if (u.length < 3) u = 'luisteraar'; 42 let candidate = u, n = 1; 43 while (db.prepare('SELECT 1 FROM users WHERE username = ?').get(candidate)) { 44 candidate = (u.slice(0, 26) + n).slice(0, 32); 45 n++; 46 } 47 return candidate; 48 } 82 49 83 router.post('/register', registerLimiter, (req, res) => { 84 const { username, email, password } = req.body; 85 const next = safeNext(req.body.next) || ''; 50 // Google callback: wissel code in, haal profiel, vind-of-maak user (op e-mail), 51 // zet de sessie. ADMIN_EMAIL bepaalt wie owner/admin (god) is. 52 router.get('/google/callback', async (req, res) => { 53 const fail = (msg) => res.redirect('/auth/login?error=' + encodeURIComponent(msg)); 54 try { 55 const { code, state } = req.query; 56 if (!code || !state || state !== req.session.oauthState) { 57 return fail('Login afgebroken of ongeldige sessie.'); 58 } 59 const next = safeNext(req.session.oauthNext) || ''; 60 delete req.session.oauthState; 61 delete req.session.oauthNext; 86 62 87 // Validation 88 if (!username || !email || !password) { 89 return renderPage(req, res, 'pages/auth-register', { 90 pageTitle: 'Register', 91 bodyClass: 'on-special', 92 error: 'All fields required', 93 username: username || '', 94 email: email || '', 95 next, 96 }); 63 const tok = await exchangeCode(String(code)); 64 const info = await fetchUserinfo(tok.access_token); 65 const email = (info.email || '').trim().toLowerCase(); 66 if (!email || info.email_verified === false) { 67 return fail('Geen geverifieerd Google-e-mailadres ontvangen.'); 68 } 69 70 const isAdmin = !!ADMIN_EMAIL && email === ADMIN_EMAIL; 71 let user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email); 72 73 if (!user) { 74 const userCount = db.prepare('SELECT COUNT(*) AS c FROM users').get().c; 75 const role = (isAdmin || userCount === 0) ? 'god' : 'member'; 76 const userId = uuid(); 77 const username = uniqueUsername(info.name || email.split('@')[0]); 78 // password_hash is NOT NULL in het schema; we zetten een onbruikbare 79 // sentinel (er is geen wachtwoord-login meer). 80 db.prepare(` 81 INSERT INTO users (id, username, email, password_hash, role, avatar_url, theme, palette, google_sub) 82 VALUES (?, ?, ?, '!google-oauth', ?, ?, 'dark', 'sage', ?) 83 `).run(userId, username, info.email || email, role, info.picture || null, info.sub || null); 84 85 // Eerste/admin-user krijgt een persoonlijke site (zoals de oude flow), maar 86 // alleen als er nog geen site bestaat. 87 if (role === 'god' && !db.prepare('SELECT 1 FROM sites LIMIT 1').get()) { 88 const siteId = uuid(); 89 db.prepare(` 90 INSERT INTO sites (id, slug, title, description, owner_id, palette, accent, language) 91 VALUES (?, ?, ?, ?, ?, 'sage', '#c2410c', 'nl') 92 `).run(siteId, username.toLowerCase(), username + "'s Site", 'Welkom', userId); 93 db.prepare(`INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')`).run(siteId, userId); 94 } 95 user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId); 96 } else { 97 // Bestaande user (gekoppeld op e-mail): koppel Google-id + avatar als die 98 // nog ontbreken, en promoveer naar god als dit ADMIN_EMAIL is. 99 db.prepare(` 100 UPDATE users 101 SET google_sub = COALESCE(google_sub, ?), 102 avatar_url = COALESCE(avatar_url, ?), 103 updated_at = CURRENT_TIMESTAMP 104 WHERE id = ? 105 `).run(info.sub || null, info.picture || null, user.id); 106 if (isAdmin && user.role !== 'god') { 107 db.prepare("UPDATE users SET role = 'god' WHERE id = ?").run(user.id); 108 user.role = 'god'; 109 } 110 } 111 112 req.session.user = { 113 id: user.id, 114 username: user.username, 115 email: user.email, 116 role: user.role, 117 avatar_url: user.avatar_url, 118 palette: user.palette, 119 theme: user.theme, 120 }; 121 res.redirect(next || '/'); 122 } catch (e) { 123 console.error('[auth/google/callback]', e.message); 124 fail('Google-login mislukt. Probeer opnieuw.'); 97 125 } 98 99 if (!/^[a-z0-9_-]{3,32}$/i.test(username)) {100 return renderPage(req, res, 'pages/auth-register', {101 pageTitle: 'Register',102 bodyClass: 'on-special',103 error: 'Username: 3-32 characters, letters/numbers/underscore/dash only',104 username, email, next,105 });106 }107 108 if (password.length < 8) {109 return renderPage(req, res, 'pages/auth-register', {110 pageTitle: 'Register',111 bodyClass: 'on-special',112 error: 'Password must be at least 8 characters',113 username, email, next,114 });115 }116 117 // Check uniqueness118 const existing = db.prepare(119 'SELECT id FROM users WHERE username = ? OR email = ?'120 ).get(username, email);121 122 if (existing) {123 return renderPage(req, res, 'pages/auth-register', {124 pageTitle: 'Register',125 bodyClass: 'on-special',126 error: 'Username or email already taken',127 username, email, next,128 });129 }130 131 // Create user132 const userId = uuid();133 const hash = bcrypt.hashSync(password, 10);134 135 // First user becomes god136 const userCount = db.prepare('SELECT COUNT(*) as c FROM users').get().c;137 const role = userCount === 0 ? 'god' : 'member';138 139 db.prepare(`140 INSERT INTO users (id, username, email, password_hash, role, theme, palette)141 VALUES (?, ?, ?, ?, ?, 'dark', 'sage')142 `).run(userId, username, email, hash, role);143 144 // First user gets a personal site auto-created145 if (userCount === 0) {146 const siteId = uuid();147 const siteSlug = username.toLowerCase();148 db.prepare(`149 INSERT INTO sites (id, slug, title, description, owner_id, palette, accent, language)150 VALUES (?, ?, ?, ?, ?, 'sage', '#c2410c', 'nl')151 `).run(siteId, siteSlug, username + "'s Site", 'Welcome to my site', userId);152 153 db.prepare(`154 INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')155 `).run(siteId, userId);156 }157 158 req.session.user = {159 id: userId, username, email, role,160 palette: 'sage', theme: 'dark',161 };162 163 res.redirect(next || '/');164 });165 166 // ==================== PASSWORD RESET (request) ====================167 router.get('/reset-request', (req, res) => {168 if (req.session.user) return res.redirect('/');169 renderPage(req, res, 'pages/auth-reset-request', {170 pageTitle: 'Reset password',171 bodyClass: 'on-special',172 error: null,173 sent: false,174 devResetUrl: null,175 });176 });177 178 router.post('/reset-request', registerLimiter, (req, res) => {179 const email = (req.body.email || '').trim().toLowerCase();180 let devResetUrl = null;181 182 if (email) {183 const user = db.prepare('SELECT id, email FROM users WHERE LOWER(email) = ?').get(email);184 if (user) {185 // Generate token (32-byte hex, ~10 min expiry)186 const token = crypto.randomBytes(32).toString('hex');187 const expires = new Date(Date.now() + 30 * 60 * 1000).toISOString(); // 30 min188 db.prepare('UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?')189 .run(token, expires, user.id);190 191 // No email sender yet — log the reset URL so dev can use it.192 const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';193 const host = req.headers['x-forwarded-host'] || req.get('host');194 const url = `${proto}://${host}/auth/reset/${token}`;195 console.log(`[password-reset] ${email} -> ${url}`);196 if (process.env.NODE_ENV !== 'production') devResetUrl = url;197 }198 }199 200 // Anti-enumeration: same response regardless of whether the email exists.201 renderPage(req, res, 'pages/auth-reset-request', {202 pageTitle: 'Reset password',203 bodyClass: 'on-special',204 error: null,205 sent: true,206 devResetUrl,207 });208 });209 210 // ==================== PASSWORD RESET (apply) ====================211 router.get('/reset/:token', (req, res) => {212 const row = db.prepare(`213 SELECT id, username FROM users214 WHERE reset_token = ? AND reset_token_expires > datetime('now')215 `).get(req.params.token);216 217 if (!row) {218 return renderPage(req, res, 'pages/auth-reset', {219 pageTitle: 'Reset password',220 bodyClass: 'on-special',221 error: 'This reset link is invalid or has expired.',222 token: null,223 username: null,224 });225 }226 227 renderPage(req, res, 'pages/auth-reset', {228 pageTitle: 'Reset password',229 bodyClass: 'on-special',230 error: null,231 token: req.params.token,232 username: row.username,233 });234 });235 236 router.post('/reset/:token', (req, res) => {237 const { new_password, confirm } = req.body;238 const row = db.prepare(`239 SELECT id, username FROM users240 WHERE reset_token = ? AND reset_token_expires > datetime('now')241 `).get(req.params.token);242 243 const renderError = (msg) => renderPage(req, res, 'pages/auth-reset', {244 pageTitle: 'Reset password',245 bodyClass: 'on-special',246 error: msg,247 token: row ? req.params.token : null,248 username: row ? row.username : null,249 });250 251 if (!row) return renderError('This reset link is invalid or has expired.');252 if (!new_password || new_password.length < 8) {253 return renderError('Password must be at least 8 characters');254 }255 if (new_password !== confirm) {256 return renderError('Passwords do not match');257 }258 259 const hash = bcrypt.hashSync(new_password, 10);260 db.prepare(`261 UPDATE users262 SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL,263 updated_at = CURRENT_TIMESTAMP264 WHERE id = ?265 `).run(hash, row.id);266 267 // Force login (don't auto-log them in — let them prove ownership)268 res.redirect('/auth/login?success=' + encodeURIComponent('Password reset — please log in.'));269 126 }); 270 127 … … 273 130 req.session.destroy(() => res.redirect('/')); 274 131 }); 275 276 132 router.post('/logout', (req, res) => { 277 133 req.session.destroy(() => res.redirect('/')); -
src/views/pages/account.ejs
r89d6536 rc80e78b 5 5 <div> 6 6 <h1>Account</h1> 7 <p class="ax-tagline">Profiel en wachtwoord.</p>7 <p class="ax-tagline">Profiel en avatar.</p> 8 8 </div> 9 9 </header> … … 65 65 </section> 66 66 67 <%# ── PASSWORD──────────────────────────────────────────── %>67 <%# ── INLOG ─────────────────────────────────────────────── %> 68 68 <section class="ax-card"> 69 <div class="ax-card-title">Wachtwoord wijzigen</div> 70 <form action="/account/password" method="post" class="ax-form"> 71 <label class="ax-field"> 72 <span>Huidig wachtwoord</span> 73 <input type="password" name="current" required autocomplete="current-password"> 74 </label> 75 <div class="ax-row ax-row-2"> 76 <label class="ax-field"> 77 <span>Nieuw wachtwoord <small>(min 8 tekens)</small></span> 78 <input type="password" name="new_password" required minlength="8" autocomplete="new-password"> 79 </label> 80 <label class="ax-field"> 81 <span>Bevestig nieuw wachtwoord</span> 82 <input type="password" name="confirm" required minlength="8" autocomplete="new-password"> 83 </label> 84 </div> 85 <div class="ax-form-actions"> 86 <button type="submit" class="ax-btn ax-btn-primary">🔒 Wachtwoord wijzigen</button> 87 </div> 88 </form> 69 <div class="ax-card-title">Inloggen</div> 70 <p class="ax-tagline" style="margin:0">Je bent ingelogd via Google (<%= account.email %>). Er is geen apart wachtwoord.</p> 89 71 </section> 90 72 -
src/views/pages/auth-login.ejs
r89d6536 rc80e78b 1 1 <div class="container auth-page"> 2 <h1>Login</h1> 3 <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %> 2 <h1>Inloggen</h1> 4 3 <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %> 5 <form method="post" action="/auth/login" class="auth-form"> 6 <% if (typeof next !== 'undefined' && next) { %> 7 <input type="hidden" name="next" value="<%= next %>"> 4 5 <div class="auth-form auth-google"> 6 <% if (googleReady) { %> 7 <p class="auth-intro">Log in met je Google-account om mee te praten.</p> 8 <a class="btn btn-google" href="/auth/google<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>"> 9 <svg class="g-icon" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"> 10 <path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.62z"/> 11 <path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.8.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18z"/> 12 <path fill="#FBBC05" d="M3.97 10.72a5.4 5.4 0 0 1 0-3.44V4.95H.96a9 9 0 0 0 0 8.1l3.01-2.33z"/> 13 <path fill="#EA4335" d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.58A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58z"/> 14 </svg> 15 <span>Inloggen met Google</span> 16 </a> 17 <% } else { %> 18 <p class="auth-intro">Inloggen via Google is op deze site nog niet ingesteld.</p> 8 19 <% } %> 9 <label> 10 <span>Username or email</span> 11 <input type="text" name="username" value="<%= username %>" required autofocus autocomplete="username"> 12 </label> 13 <label> 14 <span>Password</span> 15 <input type="password" name="password" required autocomplete="current-password"> 16 </label> 17 <button type="submit" class="btn btn-primary">Login</button> 18 <p class="auth-link">No account? 19 <a href="/auth/register<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>">Register</a> 20 </p> 21 <p class="auth-link auth-link-muted"><a href="/auth/reset-request">Forgot password?</a></p> 22 </form> 20 </div> 23 21 </div> -
src/views/pages/welcome.ejs
r89d6536 rc80e78b 5 5 <% if (!user) { %> 6 6 <div class="welcome-actions"> 7 <a href="/auth/register" class="btn btn-primary">Create your account</a> 8 <a href="/auth/login" class="btn">Already have one? Login</a> 7 <a href="/auth/login" class="btn btn-primary">Inloggen met Google</a> 9 8 </div> 10 <p class="welcome-note"> The first user becomes <strong>god</strong> and gets a personal site auto-created.</p>9 <p class="welcome-note">De eerste gebruiker die inlogt wordt <strong>god</strong> en krijgt automatisch een eigen site.</p> 11 10 <% } else { %> 12 11 <p>Hi <%= user.username %>! No site is configured yet.</p> -
src/views/partials/shared-styles.ejs
r89d6536 rc80e78b 117 117 .auth-link { text-align: center; color: var(--ink-muted); font-size: 0.9rem; margin: 1rem 0 0; } 118 118 .auth-link a { color: var(--accent); } 119 .auth-google { align-items: stretch; } 120 .auth-intro { text-align: center; color: var(--ink-muted); font-size: 0.95rem; margin: 0 0 1rem; } 121 .btn-google { 122 justify-content: center; gap: 0.6rem; padding: 0.7rem; font-size: 1rem; 123 background: #fff; color: #1f1f1f; border: 1px solid #dadce0; 124 } 125 .btn-google:hover { background: #f7f8f8; border-color: #dadce0; } 126 .btn-google .g-icon { flex-shrink: 0; } 119 127 120 128 /* HTMX loading indicator */
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)