Changeset 075185a in Klonkt
- Timestamp:
- 06/24/2026 02:16:57 PM (3 months ago)
- Branches:
- main
- Children:
- 2e9773f
- Parents:
- 5b47619
- Location:
- src
- Files:
-
- 2 deleted
- 7 edited
-
config/google.js (deleted)
-
routes/account.js (modified) (4 diffs)
-
routes/admin-settings.js (modified) (3 diffs)
-
routes/auth.js (modified) (4 diffs)
-
views/pages/account.ejs (modified) (1 diff)
-
views/pages/admin-google.ejs (deleted)
-
views/pages/admin-settings.ejs (modified) (1 diff)
-
views/pages/admin.ejs (modified) (1 diff)
-
views/pages/auth-login.ejs (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/account.js
r5b47619 r075185a 24 24 import { renderPage } from '../middleware/render.js'; 25 25 import { requireAuth } from '../middleware/auth.js'; 26 import { googleConfigured } from '../config/google.js';27 26 import { toWebp } from '../services/ImageWebpService.js'; 28 27 import { SUPPORTED } from '../services/i18n.js'; … … 65 64 `).get(req.session.user.id); 66 65 const hasPassword = !!(account && account.password_hash && account.password_hash !== '!google-oauth'); 67 const googleLinked = !!(account && account.google_sub);68 66 if (account) { delete account.password_hash; delete account.google_sub; } // don't leak to the view 69 67 … … 74 72 account, 75 73 hasPassword, 76 googleLinked,77 googleAvailable: googleConfigured(),78 74 editableSite, 79 75 // Display fallback: when you have no own account avatar, show your site's photo. … … 204 200 }); 205 201 206 // Unlink Google account. Only allowed if a password is set,207 // otherwise the user would lock themselves out (no login method left).208 router.post('/google/unlink', requireAuth, (req, res) => {209 const row = db.prepare('SELECT password_hash, google_sub FROM users WHERE id = ?').get(req.session.user.id);210 if (!row || !row.google_sub) {211 return res.redirect('/account?error=' + encodeURIComponent('Er is geen Google-account gekoppeld'));212 }213 if (!row.password_hash || row.password_hash === '!google-oauth') {214 return res.redirect('/account?error=' + encodeURIComponent('Stel eerst een wachtwoord in — anders kun je niet meer inloggen.'));215 }216 db.prepare('UPDATE users SET google_sub = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(req.session.user.id);217 res.redirect('/account?success=' + encodeURIComponent('Google-account ontkoppeld'));218 });219 220 202 // ==================== UPLOAD AVATAR ==================== 221 203 router.post('/avatar', requireAuth, (req, res) => { -
src/routes/admin-settings.js
r5b47619 r075185a 25 25 import { mailerStatus, sendMail } from '../config/mailer.js'; 26 26 import { entitlementStatus, premiumUnlocked } from '../services/PatreonService.js'; 27 import { googleConfigured, redirectUri, currentClientId, clientSecretSet } from '../config/google.js';28 27 import { toWebp } from '../services/ImageWebpService.js'; 29 28 … … 81 80 defaultLang: getSetting('default_lang') || '', 82 81 premium: entitlementStatus(), 83 google: {84 configured: googleConfigured(),85 redirectUri: redirectUri(),86 clientId: currentClientId(),87 secretSet: clientSecretSet(),88 },89 82 smtp: mailerStatus(), 90 83 footerNewsletter: getSetting('footer_newsletter') === '1', … … 155 148 }); 156 149 157 // Google login on its own admin page (separate from the general settings).158 router.get('/google', requireGod, (req, res) => {159 renderPage(req, res, 'pages/admin-google', {160 pageTitle: 'Google-login',161 bodyClass: 'on-admin',162 google: {163 configured: googleConfigured(),164 redirectUri: redirectUri(),165 clientId: currentClientId(),166 secretSet: clientSecretSet(),167 },168 success: req.query.success || null,169 error: req.query.error || null,170 });171 });172 173 // Configure Google login (listeners) — Client ID + Secret in app_settings.174 // The redirect URI is derived from PUBLIC_BASE_URL (see config/google.js).175 router.post('/google', requireGod, (req, res) => {176 if (req.body.clear === '1') {177 setSetting('google_client_id', '');178 setSetting('google_client_secret', '');179 return res.redirect('/admin/settings?success=' + encodeURIComponent('Google-login losgekoppeld'));180 }181 setSetting('google_client_id', (req.body.google_client_id || '').toString().trim());182 // Only overwrite the secret if a new value was entered (empty = leave as-is).183 const secret = (req.body.google_client_secret || '').toString().trim();184 if (secret) setSetting('google_client_secret', secret);185 res.redirect('/admin/settings?success=' + encodeURIComponent('Google-login opgeslagen'));186 });187 188 150 // ── SMTP / e-mail-instellingen ──────────────────────────────────── 189 151 router.post('/smtp', requireGod, (req, res) => { -
src/routes/auth.js
r5b47619 r075185a 7 7 import { loginLimiter, registerLimiter } from '../middleware/rate-limit.js'; 8 8 import { safeNext, requireAuth } from '../middleware/auth.js'; 9 import { googleConfigured, authorizeUrl, exchangeCode, fetchUserinfo } from '../config/google.js';10 import { premiumUnlocked } from '../services/PatreonService.js';11 12 // Fan login (listeners signing in with Google to comment) is a premium feature:13 // available when Google is configured AND the premium layer is unlocked14 // (premium off = open to all; on = Patreon required).15 function fanLoginReady() {16 return googleConfigured() && premiumUnlocked();17 }18 9 import { mailerConfigured, sendMail } from '../config/mailer.js'; 19 10 import { resolveLang, t } from '../services/i18n.js'; … … 47 38 48 39 // ==================== LOGIN ==================== 49 // Public login page: for VISITORS only Google-login (listeners/fans). 50 // The admin login (password) is intentionally NOT here — it lives hidden at 51 // /auth/admin (see below), so the admin login is not visible where visitors land. 40 // Single login = admin/owner password (no public/listener login anymore; social 41 // interaction happens via the fediverse). /login and /auth/admin both show it. 52 42 router.get('/login', (req, res) => { 53 43 const next = safeNext(req.query.next) || ''; … … 58 48 bodyClass: 'on-special on-auth', 59 49 error: req.query.error || null, 60 gerr: req.query.gerr || null, // foutcode voor een rijkere uitleg (bv. 'admin')50 gerr: null, 61 51 success: req.query.success || null, 62 52 username: '', 63 adminLogin: false,64 googleReady: fa nLoginReady(),53 adminLogin: true, 54 googleReady: false, 65 55 next, 66 56 }); … … 265 255 }); 266 256 267 // ==================== GOOGLE LOGIN (listeners/commenters) ====================268 // Per-instance, own Google client. ALWAYS grants role member — never admin.269 router.get('/google', (req, res) => {270 if (!fanLoginReady()) {271 return res.redirect('/auth/login?gerr=unavailable');272 }273 const state = crypto.randomBytes(16).toString('hex');274 req.session.oauthState = state;275 req.session.oauthNext = safeNext(req.query.next) || '';276 delete req.session.oauthLink;277 res.redirect(authorizeUrl(state));278 });279 280 // LINK Google to the current (logged-in) account — e.g. an admin who also wants281 // to log in with Google. Requires being already logged in (with password); the282 // link stores the google_sub on their own account.283 // Only googleConfigured() needed (no premium gate — this is not fan login).284 router.get('/google/link', requireAuth, (req, res) => {285 if (!googleConfigured()) {286 return res.redirect('/account?error=' + encodeURIComponent('Google-login is op deze site niet ingesteld.'));287 }288 const state = crypto.randomBytes(16).toString('hex');289 req.session.oauthState = state;290 req.session.oauthLink = true; // link mode instead of login mode291 res.redirect(authorizeUrl(state));292 });293 294 function uniqueUsername(base) {295 let u = String(base || 'luisteraar').toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 28);296 if (u.length < 3) u = 'luisteraar';297 let candidate = u, n = 1;298 while (db.prepare('SELECT 1 FROM users WHERE username = ?').get(candidate)) {299 candidate = (u.slice(0, 26) + n).slice(0, 32);300 n++;301 }302 return candidate;303 }304 305 router.get('/google/callback', async (req, res) => {306 const linking = !!req.session.oauthLink;307 const failLogin = (code) => res.redirect('/auth/login?gerr=' + code);308 const failLink = (msg) => res.redirect('/account?error=' + encodeURIComponent(msg));309 310 try {311 const { code, state } = req.query;312 if (!code || !state || state !== req.session.oauthState) {313 delete req.session.oauthState; delete req.session.oauthLink; delete req.session.oauthNext;314 return linking ? failLink('Google-koppeling afgebroken of sessie verlopen. Probeer opnieuw.') : failLogin('session');315 }316 317 const tok = await exchangeCode(String(code));318 const info = await fetchUserinfo(tok.access_token);319 const email = (info.email || '').trim().toLowerCase();320 321 // ── LINK MODE: attach Google to the current (logged-in) account ──322 if (linking) {323 delete req.session.oauthState; delete req.session.oauthLink;324 if (!req.session.user) return failLogin('session');325 if (!info.sub) return failLink('Google gaf geen account-id terug. Probeer opnieuw.');326 if (info.email && info.email_verified === false) return failLink('Je Google-adres is niet geverifieerd.');327 // This Google account must not already be linked to a DIFFERENT account.328 const other = db.prepare('SELECT id FROM users WHERE google_sub = ? AND id != ?').get(info.sub, req.session.user.id);329 if (other) return failLink('Dit Google-account is al aan een andere gebruiker gekoppeld.');330 db.prepare(`331 UPDATE users SET google_sub = ?, avatar_url = COALESCE(avatar_url, ?),332 updated_at = CURRENT_TIMESTAMP WHERE id = ?333 `).run(info.sub, info.picture || null, req.session.user.id);334 return res.redirect('/account?success=' + encodeURIComponent('Google-account gekoppeld — je kunt nu ook met Google inloggen.'));335 }336 337 // ── LOGIN MODE (listeners/fans + linked admin) ──338 if (!fanLoginReady()) return failLogin('unavailable');339 const next = safeNext(req.session.oauthNext) || '';340 delete req.session.oauthState; delete req.session.oauthNext;341 if (!email || info.email_verified === false) return failLogin('email');342 343 // Look FIRST by linked Google account (google_sub). A sub-match is explicit344 // proof of the link → log in with their own role, EVEN IF the Google email345 // differs from the account email (e.g. an admin who linked a different Gmail).346 // Only then fall back to email lookup.347 let user = info.sub ? db.prepare('SELECT * FROM users WHERE google_sub = ?').get(info.sub) : null;348 349 if (user) {350 // Linked account found → keep their own role. Update avatar if empty.351 db.prepare(`352 UPDATE users SET avatar_url = COALESCE(avatar_url, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ?353 `).run(info.picture || null, user.id);354 } else {355 user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email);356 if (user && (user.role === 'god' || user.role === 'admin')) {357 // Admin found by email but WITHOUT a linked sub → Google never grants admin.358 // Must first link via Account → Sign in with Google.359 return failLogin('admin');360 } else if (user) {361 // Existing listener: link google_sub/avatar if missing.362 db.prepare(`363 UPDATE users SET google_sub = COALESCE(google_sub, ?), avatar_url = COALESCE(avatar_url, ?),364 updated_at = CURRENT_TIMESTAMP WHERE id = ?365 `).run(info.sub || null, info.picture || null, user.id);366 } else {367 // New listener — always member.368 const userId = uuid();369 const username = uniqueUsername(info.name || email.split('@')[0]);370 db.prepare(`371 INSERT INTO users (id, username, email, password_hash, role, avatar_url, theme, palette, google_sub)372 VALUES (?, ?, ?, '!google-oauth', 'member', ?, 'dark', 'sage', ?)373 `).run(userId, username, info.email || email, info.picture || null, info.sub || null);374 user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);375 }376 }377 378 req.session.user = {379 id: user.id, username: user.username, email: user.email, role: user.role,380 avatar_url: user.avatar_url, palette: user.palette, theme: user.theme,381 readonly: !!user.readonly,382 };383 res.redirect(next || '/');384 } catch (e) {385 console.error('[auth/google/callback]', e.message);386 delete req.session.oauthState; delete req.session.oauthLink; delete req.session.oauthNext;387 return linking388 ? res.redirect('/account?error=' + encodeURIComponent('Google koppelen mislukt — probeer opnieuw.'))389 : failLogin('failed');390 }391 });392 393 257 // ==================== LOGOUT ==================== 394 258 router.get('/logout', (req, res) => { req.session.destroy(() => res.redirect('/')); }); -
src/views/pages/account.ejs
r5b47619 r075185a 163 163 </section> 164 164 165 <% if (typeof googleAvailable !== 'undefined' && googleAvailable) { %>166 <section class="ax-card">167 <div class="ax-card-title"><%= t('acct.google_login') %></div>168 <% if (typeof googleLinked !== 'undefined' && googleLinked) { %>169 <p class="ax-tagline" style="margin:0 0 1rem"><%= t('acct.google_linked') %></p>170 <form action="/account/google/unlink" method="post">171 <button type="submit" class="ax-btn"><%= t('acct.google_unlink') %></button>172 </form>173 <% } else { %>174 <p class="ax-tagline" style="margin:0 0 1rem"><%= t('acct.google_link_hint') %></p>175 <a href="/auth/google/link" class="ax-btn ax-btn-primary" data-full-load><%= t('acct.google_link') %></a>176 <% } %>177 </section>178 <% } %>179 165 <% } else { %> 180 166 <section class="ax-card"> -
src/views/pages/admin-settings.ejs
r5b47619 r075185a 161 161 </section> 162 162 <% } %> 163 164 <%# Google login has its own admin page (compact link shown here). %>165 <section class="set-card" style="margin-top:1rem">166 <h2><%= t('aset.google_login') %></h2>167 <p class="set-help"><%= t('aset.google_login_help') %>168 <% if (google && google.configured) { %><strong><%= t('aset.status_set') %> ✓</strong><% } else { %><%= t('aset.not_set_yet') %><% } %></p>169 <div class="set-actions"><a href="/admin/settings/google" class="btn btn-primary"><%= t('aset.google_login_setup') %> →</a></div>170 </section>171 163 172 164 <%# Newsletter signup in the footer (premium). %> -
src/views/pages/admin.ejs
r5b47619 r075185a 59 59 <a href="/admin/shows" class="btn"><%= t('admin.b_agenda') %></a> 60 60 <% } %> 61 <a href="/admin/settings/google" class="btn"><%= t('admin.b_google') %></a>62 61 <a href="/admin/updates" class="btn"><%= t('admin.b_updates') %></a> 63 62 <a href="/admin/handleiding" class="btn"><%= t('admin.b_help') %></a> -
src/views/pages/auth-login.ejs
r5b47619 r075185a 48 48 <p class="auth-link auth-link-muted"><a href="/auth/reset-request"><%= t('auth.forgot') %></a></p> 49 49 </form> 50 <% } else if (googleReady) { %>51 <%# Public login: Google only, for listeners/fans. %>52 <p class="auth-sub"><%= t('auth.public_sub') %></p>53 <a class="btn btn-google" href="/auth/google<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>">54 <svg class="g-icon" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true">55 <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"/>56 <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"/>57 <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"/>58 <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"/>59 </svg>60 <span><%= t('auth.google_btn') %></span>61 </a>62 <% } else { %>63 <div class="alert"><%= t('auth.google_unavail') %></div>64 <% } %>65 66 <% if (!_admin) { %>67 <%# Clickable box for the admin → navigates to the hidden password login. %>68 <a class="auth-admin-box" href="/auth/admin<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>">69 <span class="auth-admin-box-icon" aria-hidden="true">🔑</span>70 <span class="auth-admin-box-text">71 <strong><%= t('auth.admin_box_q') %></strong>72 <small><%= t('auth.admin_box_sub') %></small>73 </span>74 <span class="auth-admin-box-arrow" aria-hidden="true">→</span>75 </a>76 50 <% } %> 77 51 </div>
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)